From 30b55a9baac6ce40d7f5f8941aab4339e53ca47d Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:59:21 +0530 Subject: [PATCH] feat(filesystem): refactor local filesystem handling to use mounts instead of root paths, enhancing mount management and path normalization --- .../agents/new_chat/filesystem_backends.py | 11 +- .../agents/new_chat/filesystem_selection.py | 10 +- .../agents/new_chat/middleware/file_intent.py | 119 ++++++++++++++++-- .../multi_root_local_folder_backend.py | 21 ++-- .../app/routes/new_chat_routes.py | 36 ++++-- surfsense_backend/app/schemas/new_chat.py | 11 +- .../app/tasks/chat/stream_new_chat.py | 41 ++++++ .../middleware/test_file_intent_middleware.py | 57 +++++++++ .../middleware/test_filesystem_backends.py | 8 +- .../test_filesystem_verification.py | 41 +++++- .../test_multi_root_local_folder_backend.py | 28 +++++ .../src/modules/agent-filesystem.ts | 28 ++++- .../new-chat/[[...chat_id]]/page.tsx | 48 +++---- .../components/assistant-ui/markdown-text.tsx | 10 +- .../components/editor/source-code-editor.tsx | 8 +- surfsense_web/lib/agent-filesystem.ts | 24 +++- 16 files changed, 421 insertions(+), 80 deletions(-) create mode 100644 surfsense_backend/tests/unit/middleware/test_multi_root_local_folder_backend.py diff --git a/surfsense_backend/app/agents/new_chat/filesystem_backends.py b/surfsense_backend/app/agents/new_chat/filesystem_backends.py index 0c32ef845..85ed5f801 100644 --- a/surfsense_backend/app/agents/new_chat/filesystem_backends.py +++ b/surfsense_backend/app/agents/new_chat/filesystem_backends.py @@ -16,9 +16,9 @@ from app.agents.new_chat.middleware.multi_root_local_folder_backend import ( @lru_cache(maxsize=64) def _cached_multi_root_backend( - root_paths: tuple[str, ...], + mounts: tuple[tuple[str, str], ...], ) -> MultiRootLocalFolderBackend: - return MultiRootLocalFolderBackend(root_paths) + return MultiRootLocalFolderBackend(mounts) def build_backend_resolver( @@ -26,10 +26,13 @@ def build_backend_resolver( ) -> Callable[[ToolRuntime], StateBackend | MultiRootLocalFolderBackend]: """Create deepagents backend resolver for the selected filesystem mode.""" - if selection.mode == FilesystemMode.DESKTOP_LOCAL_FOLDER and selection.local_root_paths: + if selection.mode == FilesystemMode.DESKTOP_LOCAL_FOLDER and selection.local_mounts: def _resolve_local(_runtime: ToolRuntime) -> MultiRootLocalFolderBackend: - return _cached_multi_root_backend(selection.local_root_paths) + mounts = tuple( + (entry.mount_id, entry.root_path) for entry in selection.local_mounts + ) + return _cached_multi_root_backend(mounts) return _resolve_local diff --git a/surfsense_backend/app/agents/new_chat/filesystem_selection.py b/surfsense_backend/app/agents/new_chat/filesystem_selection.py index 4b8f42847..bf0497d26 100644 --- a/surfsense_backend/app/agents/new_chat/filesystem_selection.py +++ b/surfsense_backend/app/agents/new_chat/filesystem_selection.py @@ -20,13 +20,21 @@ class ClientPlatform(StrEnum): DESKTOP = "desktop" +@dataclass(slots=True) +class LocalFilesystemMount: + """Canonical mount mapping provided by desktop runtime.""" + + mount_id: str + root_path: str + + @dataclass(slots=True) class FilesystemSelection: """Resolved filesystem selection for a single chat request.""" mode: FilesystemMode = FilesystemMode.CLOUD client_platform: ClientPlatform = ClientPlatform.WEB - local_root_paths: tuple[str, ...] = () + local_mounts: tuple[LocalFilesystemMount, ...] = () @property def is_local_mode(self) -> bool: diff --git a/surfsense_backend/app/agents/new_chat/middleware/file_intent.py b/surfsense_backend/app/agents/new_chat/middleware/file_intent.py index e264a939c..1e5fd0ede 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/file_intent.py +++ b/surfsense_backend/app/agents/new_chat/middleware/file_intent.py @@ -48,6 +48,20 @@ class FileIntentPlan(BaseModel): default=None, description="Optional filename (e.g. notes.md) inferred from user request.", ) + suggested_directory: str | None = Field( + default=None, + description=( + "Optional directory path (e.g. /reports/q2 or reports/q2) inferred from " + "user request." + ), + ) + suggested_path: str | None = Field( + default=None, + description=( + "Optional full file path (e.g. /reports/q2/summary.md). If present, this " + "takes precedence over suggested_directory + suggested_filename." + ), + ) def _extract_text_from_message(message: BaseMessage) -> str: @@ -88,6 +102,13 @@ def _sanitize_filename(value: str) -> str: return name +def _sanitize_path_segment(value: str) -> str: + segment = re.sub(r"[\\/:*?\"<>|]+", "_", value).strip() + segment = re.sub(r"\s+", "_", segment) + segment = segment.strip("._-") + return segment + + def _infer_text_file_extension(user_text: str) -> str: lowered = user_text.lower() if any(token in lowered for token in ("json", ".json")): @@ -119,29 +140,102 @@ def _infer_text_file_extension(user_text: str) -> str: return ".md" -def _fallback_path(suggested_filename: str | None, *, user_text: str) -> str: +def _normalize_directory(value: str) -> str: + raw = value.strip().replace("\\", "/") + raw = raw.strip("/") + if not raw: + return "" + parts = [_sanitize_path_segment(part) for part in raw.split("/") if part.strip()] + parts = [part for part in parts if part] + return "/".join(parts) + + +def _normalize_file_path(value: str) -> str: + raw = value.strip().replace("\\", "/").strip() + if not raw: + return "" + had_trailing_slash = raw.endswith("/") + raw = raw.strip("/") + if not raw: + return "" + parts = [_sanitize_path_segment(part) for part in raw.split("/") if part.strip()] + parts = [part for part in parts if part] + if not parts: + return "" + if had_trailing_slash: + return f"/{'/'.join(parts)}/" + return f"/{'/'.join(parts)}" + + +def _infer_directory_from_user_text(user_text: str) -> str | None: + patterns = ( + r"\b(?:in|inside|under)\s+(?:the\s+)?([a-zA-Z0-9 _\-/]+?)\s+folder\b", + r"\b(?:in|inside|under)\s+([a-zA-Z0-9 _\-/]+?)\b", + ) + lowered = user_text.lower() + for pattern in patterns: + match = re.search(pattern, lowered, flags=re.IGNORECASE) + if not match: + continue + candidate = match.group(1).strip() + if candidate in {"the", "a", "an"}: + continue + normalized = _normalize_directory(candidate) + if normalized: + return normalized + return None + + +def _fallback_path( + suggested_filename: str | None, + *, + suggested_directory: str | None = None, + suggested_path: str | None = None, + user_text: str, +) -> str: default_extension = _infer_text_file_extension(user_text) + inferred_dir = _infer_directory_from_user_text(user_text) + + sanitized_filename = "" if suggested_filename: - sanitized = _sanitize_filename(suggested_filename) - if sanitized.lower().endswith(".txt"): - sanitized = f"{sanitized[:-4]}.md" - if "." not in sanitized: - sanitized = f"{sanitized}{default_extension}" - return f"/{sanitized}" - return f"/notes{default_extension}" + sanitized_filename = _sanitize_filename(suggested_filename) + if sanitized_filename.lower().endswith(".txt"): + sanitized_filename = f"{sanitized_filename[:-4]}.md" + if not sanitized_filename: + sanitized_filename = f"notes{default_extension}" + elif "." not in sanitized_filename: + sanitized_filename = f"{sanitized_filename}{default_extension}" + + normalized_suggested_path = ( + _normalize_file_path(suggested_path) if suggested_path else "" + ) + if normalized_suggested_path: + if normalized_suggested_path.endswith("/"): + return f"{normalized_suggested_path.rstrip('/')}/{sanitized_filename}" + return normalized_suggested_path + + directory = _normalize_directory(suggested_directory or "") + if not directory and inferred_dir: + directory = inferred_dir + if directory: + return f"/{directory}/{sanitized_filename}" + + return f"/{sanitized_filename}" def _build_classifier_prompt(*, recent_conversation: str, user_text: str) -> str: return ( "Classify the latest user request into a filesystem intent for an AI agent.\n" "Return JSON only with this exact schema:\n" - '{"intent":"chat_only|file_write|file_read","confidence":0.0,"suggested_filename":"string or null"}\n\n' + '{"intent":"chat_only|file_write|file_read","confidence":0.0,"suggested_filename":"string or null","suggested_directory":"string or null","suggested_path":"string or null"}\n\n' "Rules:\n" "- Use semantic intent, not literal keywords.\n" "- file_write: user asks to create/save/write/update/edit content as a file.\n" "- file_read: user asks to open/read/list/search existing files.\n" "- chat_only: conversational/analysis responses without required file operations.\n" "- For file_write, choose a concise semantic suggested_filename and match the requested format.\n" + "- If the user mentions a folder/directory, populate suggested_directory.\n" + "- If user specifies an explicit full path, populate suggested_path.\n" "- Use extensions that match user intent (e.g. .md, .json, .yaml, .csv, .py, .ts, .js, .html, .css, .sql).\n" "- Do not use .txt; prefer .md for generic text notes.\n" "- Do not include dates or timestamps in suggested_filename unless explicitly requested.\n" @@ -217,7 +311,12 @@ class FileIntentMiddleware(AgentMiddleware): # type: ignore[type-arg] return None plan = await self._classify_intent(messages=messages, user_text=user_text) - suggested_path = _fallback_path(plan.suggested_filename, user_text=user_text) + suggested_path = _fallback_path( + plan.suggested_filename, + suggested_directory=plan.suggested_directory, + suggested_path=plan.suggested_path, + user_text=user_text, + ) contract = { "intent": plan.intent.value, "confidence": plan.confidence, diff --git a/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py b/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py index 2eb4e78dc..12632f00f 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py +++ b/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py @@ -30,19 +30,20 @@ class MultiRootLocalFolderBackend: where `` is derived from each selected root folder name. """ - def __init__(self, root_paths: tuple[str, ...]) -> None: - if not root_paths: - msg = "At least one local root path is required" + def __init__(self, mounts: tuple[tuple[str, str], ...]) -> None: + if not mounts: + msg = "At least one local mount is required" raise ValueError(msg) self._mount_to_backend: dict[str, LocalFolderBackend] = {} - for raw_root in root_paths: + for raw_mount, raw_root in mounts: + mount = raw_mount.strip() + if not mount: + msg = "Mount id cannot be empty" + raise ValueError(msg) + if mount in self._mount_to_backend: + msg = f"Duplicate mount id: {mount}" + raise ValueError(msg) normalized_root = str(Path(raw_root).expanduser().resolve()) - base_mount = Path(normalized_root).name or "root" - mount = base_mount - suffix = 2 - while mount in self._mount_to_backend: - mount = f"{base_mount}-{suffix}" - suffix += 1 self._mount_to_backend[mount] = LocalFolderBackend(normalized_root) self._mount_order = tuple(self._mount_to_backend.keys()) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index e1a26ba04..85a8658ec 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -24,6 +24,7 @@ from sqlalchemy.orm import selectinload from app.agents.new_chat.filesystem_selection import ( ClientPlatform, + LocalFilesystemMount, FilesystemMode, FilesystemSelection, ) @@ -42,6 +43,7 @@ from app.db import ( ) from app.schemas.new_chat import ( AgentToolInfo, + LocalFilesystemMountPayload, NewChatMessageRead, NewChatRequest, NewChatThreadCreate, @@ -73,7 +75,7 @@ def _resolve_filesystem_selection( *, mode: str, client_platform: str, - local_roots: list[str] | None, + local_mounts: list[LocalFilesystemMountPayload] | None, ) -> FilesystemSelection: """Validate and normalize filesystem mode settings from request payload.""" try: @@ -96,29 +98,37 @@ def _resolve_filesystem_selection( status_code=400, detail="desktop_local_folder mode is only available on desktop runtime.", ) - normalized_roots: list[str] = [] - for root in local_roots or []: - trimmed = root.strip() - if trimmed and trimmed not in normalized_roots: - normalized_roots.append(trimmed) - if not normalized_roots: + normalized_mounts: list[tuple[str, str]] = [] + seen_mounts: set[str] = set() + for mount in local_mounts or []: + mount_id = mount.mount_id.strip() + root_path = mount.root_path.strip() + if not mount_id or not root_path: + continue + if mount_id in seen_mounts: + continue + seen_mounts.add(mount_id) + normalized_mounts.append((mount_id, root_path)) + if not normalized_mounts: raise HTTPException( status_code=400, detail=( - "local_filesystem_roots must include at least one root for " + "local_filesystem_mounts must include at least one mount for " "desktop_local_folder mode." ), ) return FilesystemSelection( mode=resolved_mode, client_platform=resolved_platform, - local_root_paths=tuple(normalized_roots), + local_mounts=tuple( + LocalFilesystemMount(mount_id=mount_id, root_path=root_path) + for mount_id, root_path in normalized_mounts + ), ) return FilesystemSelection( mode=FilesystemMode.CLOUD, client_platform=resolved_platform, - local_root_paths=(), ) @@ -1196,7 +1206,7 @@ async def handle_new_chat( filesystem_selection = _resolve_filesystem_selection( mode=request.filesystem_mode, client_platform=request.client_platform, - local_roots=request.local_filesystem_roots, + local_mounts=request.local_filesystem_mounts, ) # Get search space to check LLM config preferences @@ -1318,7 +1328,7 @@ async def regenerate_response( filesystem_selection = _resolve_filesystem_selection( mode=request.filesystem_mode, client_platform=request.client_platform, - local_roots=request.local_filesystem_roots, + local_mounts=request.local_filesystem_mounts, ) # Get the checkpointer and state history @@ -1577,7 +1587,7 @@ async def resume_chat( filesystem_selection = _resolve_filesystem_selection( mode=request.filesystem_mode, client_platform=request.client_platform, - local_roots=request.local_filesystem_roots, + local_mounts=request.local_filesystem_mounts, ) search_space_result = await session.execute( diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index 38cdf0b28..1222deab2 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -168,6 +168,11 @@ class ChatMessage(BaseModel): content: str +class LocalFilesystemMountPayload(BaseModel): + mount_id: str + root_path: str + + class NewChatRequest(BaseModel): """Request schema for the deep agent chat endpoint.""" @@ -186,7 +191,7 @@ class NewChatRequest(BaseModel): ) filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud" client_platform: Literal["web", "desktop"] = "web" - local_filesystem_roots: list[str] | None = None + local_filesystem_mounts: list[LocalFilesystemMountPayload] | None = None class RegenerateRequest(BaseModel): @@ -209,7 +214,7 @@ class RegenerateRequest(BaseModel): disabled_tools: list[str] | None = None filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud" client_platform: Literal["web", "desktop"] = "web" - local_filesystem_roots: list[str] | None = None + local_filesystem_mounts: list[LocalFilesystemMountPayload] | None = None # ============================================================================= @@ -235,7 +240,7 @@ class ResumeRequest(BaseModel): decisions: list[ResumeDecision] filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud" client_platform: Literal["web", "desktop"] = "web" - local_filesystem_roots: list[str] | None = None + local_filesystem_mounts: list[LocalFilesystemMountPayload] | None = None # ============================================================================= diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index d551f3fd5..5a6117808 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -190,6 +190,23 @@ def _tool_output_has_error(tool_output: Any) -> bool: return False +def _extract_resolved_file_path(*, tool_name: str, tool_output: Any) -> str | None: + if isinstance(tool_output, dict): + path_value = tool_output.get("path") + if isinstance(path_value, str) and path_value.strip(): + return path_value.strip() + text = _tool_output_to_text(tool_output) + if tool_name == "write_file": + match = re.search(r"Updated file\s+(.+)$", text.strip()) + if match: + return match.group(1).strip() + if tool_name == "edit_file": + match = re.search(r"in '([^']+)'", text) + if match: + return match.group(1).strip() + return None + + def _contract_enforcement_active(result: StreamResult) -> bool: # Keep policy deterministic with no env-driven progression modes: # enforce the file-operation contract only in desktop local-folder mode. @@ -1016,6 +1033,30 @@ async def _stream_agent_events( f"Scrape failed: {error_msg}", "error", ) + elif tool_name in ("write_file", "edit_file"): + resolved_path = _extract_resolved_file_path( + tool_name=tool_name, + tool_output=tool_output, + ) + result_text = _tool_output_to_text(tool_output) + if _tool_output_has_error(tool_output): + yield streaming_service.format_tool_output_available( + tool_call_id, + { + "status": "error", + "error": result_text, + "path": resolved_path, + }, + ) + else: + yield streaming_service.format_tool_output_available( + tool_call_id, + { + "status": "completed", + "path": resolved_path, + "result": result_text, + }, + ) elif tool_name == "generate_report": # Stream the full report result so frontend can render the ReportCard yield streaming_service.format_tool_output_available( diff --git a/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py b/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py index 68876dfeb..f42e836cb 100644 --- a/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py +++ b/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py @@ -114,3 +114,60 @@ async def test_file_write_txt_suggestion_is_normalized_to_markdown(): assert contract["intent"] == FileOperationIntent.FILE_WRITE.value assert contract["suggested_path"] == "/random.md" + +@pytest.mark.asyncio +async def test_file_write_with_suggested_directory_preserves_folder(): + llm = _FakeLLM( + '{"intent":"file_write","confidence":0.86,"suggested_filename":"random.md","suggested_directory":"pc backups","suggested_path":null}' + ) + middleware = FileIntentMiddleware(llm=llm) + state = { + "messages": [HumanMessage(content="create a random file in pc backups folder")], + "turn_id": "turn:4", + } + + result = await middleware.abefore_agent(state, runtime=None) # type: ignore[arg-type] + + assert result is not None + contract = result["file_operation_contract"] + assert contract["intent"] == FileOperationIntent.FILE_WRITE.value + assert contract["suggested_path"] == "/pc_backups/random.md" + + +@pytest.mark.asyncio +async def test_file_write_with_suggested_path_takes_precedence(): + llm = _FakeLLM( + '{"intent":"file_write","confidence":0.9,"suggested_filename":"ignored.md","suggested_directory":"docs","suggested_path":"/reports/q2/summary.md"}' + ) + middleware = FileIntentMiddleware(llm=llm) + state = { + "messages": [HumanMessage(content="create report")], + "turn_id": "turn:5", + } + + result = await middleware.abefore_agent(state, runtime=None) # type: ignore[arg-type] + + assert result is not None + contract = result["file_operation_contract"] + assert contract["intent"] == FileOperationIntent.FILE_WRITE.value + assert contract["suggested_path"] == "/reports/q2/summary.md" + + +@pytest.mark.asyncio +async def test_file_write_infers_directory_from_user_text_when_missing(): + llm = _FakeLLM( + '{"intent":"file_write","confidence":0.83,"suggested_filename":"random.md","suggested_directory":null,"suggested_path":null}' + ) + middleware = FileIntentMiddleware(llm=llm) + state = { + "messages": [HumanMessage(content="create a random file in pc backups folder")], + "turn_id": "turn:6", + } + + result = await middleware.abefore_agent(state, runtime=None) # type: ignore[arg-type] + + assert result is not None + contract = result["file_operation_contract"] + assert contract["intent"] == FileOperationIntent.FILE_WRITE.value + assert contract["suggested_path"] == "/pc_backups/random.md" + diff --git a/surfsense_backend/tests/unit/middleware/test_filesystem_backends.py b/surfsense_backend/tests/unit/middleware/test_filesystem_backends.py index a1867ff6c..9600b7e05 100644 --- a/surfsense_backend/tests/unit/middleware/test_filesystem_backends.py +++ b/surfsense_backend/tests/unit/middleware/test_filesystem_backends.py @@ -7,6 +7,7 @@ from app.agents.new_chat.filesystem_selection import ( ClientPlatform, FilesystemMode, FilesystemSelection, + LocalFilesystemMount, ) from app.agents.new_chat.middleware.multi_root_local_folder_backend import ( MultiRootLocalFolderBackend, @@ -23,7 +24,7 @@ def test_backend_resolver_returns_multi_root_backend_for_single_root(tmp_path: P selection = FilesystemSelection( mode=FilesystemMode.DESKTOP_LOCAL_FOLDER, client_platform=ClientPlatform.DESKTOP, - local_root_paths=(str(tmp_path),), + local_mounts=(LocalFilesystemMount(mount_id="tmp", root_path=str(tmp_path)),), ) resolver = build_backend_resolver(selection) @@ -47,7 +48,10 @@ def test_backend_resolver_returns_multi_root_backend_for_multiple_roots(tmp_path selection = FilesystemSelection( mode=FilesystemMode.DESKTOP_LOCAL_FOLDER, client_platform=ClientPlatform.DESKTOP, - local_root_paths=(str(root_one), str(root_two)), + local_mounts=( + LocalFilesystemMount(mount_id="resume", root_path=str(root_one)), + LocalFilesystemMount(mount_id="notes", root_path=str(root_two)), + ), ) resolver = build_backend_resolver(selection) diff --git a/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py b/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py index 9f6b162aa..89a6e2b4c 100644 --- a/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py +++ b/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py @@ -1,5 +1,11 @@ +from pathlib import Path + import pytest +from app.agents.new_chat.middleware.multi_root_local_folder_backend import ( + MultiRootLocalFolderBackend, +) +from app.agents.new_chat.filesystem_selection import FilesystemMode from app.agents.new_chat.middleware.filesystem import SurfSenseFilesystemMiddleware pytestmark = pytest.mark.unit @@ -43,9 +49,9 @@ def test_verify_written_content_prefers_raw_sync() -> None: def test_contract_suggested_path_falls_back_to_notes_md() -> None: - suggested = SurfSenseFilesystemMiddleware._get_contract_suggested_path( - _RuntimeNoSuggestedPath() - ) + middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware) + middleware._filesystem_mode = FilesystemMode.CLOUD + suggested = middleware._get_contract_suggested_path(_RuntimeNoSuggestedPath()) # type: ignore[arg-type] assert suggested == "/notes.md" @@ -62,3 +68,32 @@ async def test_verify_written_content_prefers_raw_async() -> None: ) assert verify_error is None + + +def test_normalize_local_mount_path_prefixes_default_mount(tmp_path: Path) -> None: + root = tmp_path / "PC Backups" + root.mkdir() + backend = MultiRootLocalFolderBackend((("pc_backups", str(root)),)) + runtime = _RuntimeNoSuggestedPath() + middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware) + middleware._get_backend = lambda _runtime: backend # type: ignore[method-assign] + + resolved = middleware._normalize_local_mount_path("/random-note.md", runtime) # type: ignore[arg-type] + + assert resolved == "/pc_backups/random-note.md" + + +def test_normalize_local_mount_path_keeps_explicit_mount(tmp_path: Path) -> None: + root = tmp_path / "PC Backups" + root.mkdir() + backend = MultiRootLocalFolderBackend((("pc_backups", str(root)),)) + runtime = _RuntimeNoSuggestedPath() + middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware) + middleware._get_backend = lambda _runtime: backend # type: ignore[method-assign] + + resolved = middleware._normalize_local_mount_path( # type: ignore[arg-type] + "/pc_backups/notes/random-note.md", + runtime, + ) + + assert resolved == "/pc_backups/notes/random-note.md" diff --git a/surfsense_backend/tests/unit/middleware/test_multi_root_local_folder_backend.py b/surfsense_backend/tests/unit/middleware/test_multi_root_local_folder_backend.py new file mode 100644 index 000000000..7afb47e26 --- /dev/null +++ b/surfsense_backend/tests/unit/middleware/test_multi_root_local_folder_backend.py @@ -0,0 +1,28 @@ +from pathlib import Path + +import pytest + +from app.agents.new_chat.middleware.multi_root_local_folder_backend import ( + MultiRootLocalFolderBackend, +) + +pytestmark = pytest.mark.unit + + +def test_mount_ids_preserve_client_mapping_order(tmp_path: Path) -> None: + root_one = tmp_path / "PC Backups" + root_two = tmp_path / "pc_backups" + root_three = tmp_path / "notes@2026" + root_one.mkdir() + root_two.mkdir() + root_three.mkdir() + + backend = MultiRootLocalFolderBackend( + ( + ("pc_backups", str(root_one)), + ("pc_backups_2", str(root_two)), + ("notes_2026", str(root_three)), + ) + ) + + assert backend.list_mounts() == ("pc_backups", "pc_backups_2", "notes_2026") diff --git a/surfsense_desktop/src/modules/agent-filesystem.ts b/surfsense_desktop/src/modules/agent-filesystem.ts index f00c185f8..6db5fd6f7 100644 --- a/surfsense_desktop/src/modules/agent-filesystem.ts +++ b/surfsense_desktop/src/modules/agent-filesystem.ts @@ -127,12 +127,22 @@ export type LocalRootMount = { rootPath: string; }; +function sanitizeMountName(rawMount: string): string { + const normalized = rawMount + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^[_-]+|[_-]+$/g, ""); + return normalized || "root"; +} + function buildRootMounts(rootPaths: string[]): LocalRootMount[] { const mounts: LocalRootMount[] = []; const usedMounts = new Set(); for (const rawRootPath of rootPaths) { const normalizedRoot = resolve(rawRootPath); - const baseMount = normalizedRoot.split(/[\\/]/).at(-1) || "root"; + const baseMount = sanitizeMountName(normalizedRoot.split(/[\\/]/).at(-1) || "root"); let mount = baseMount; let suffix = 2; while (usedMounts.has(mount)) { @@ -150,7 +160,10 @@ export async function getAgentFilesystemMounts(): Promise { return buildRootMounts(rootPaths); } -function parseMountedVirtualPath(virtualPath: string): { +function parseMountedVirtualPath( + virtualPath: string, + mounts: LocalRootMount[] +): { mount: string; subPath: string; } { @@ -161,8 +174,15 @@ function parseMountedVirtualPath(virtualPath: string): { if (!trimmed) { throw new Error("Path must include a mounted root segment"); } + const [mount, ...rest] = trimmed.split("/"); const remainder = rest.join("/"); + const directMount = mounts.find((entry) => entry.mount === mount); + if (!directMount) { + throw new Error( + `Unknown mounted root '${mount}'. Available roots: ${mounts.map((entry) => `/${entry.mount}`).join(", ")}` + ); + } if (!remainder) { throw new Error("Path must include a file path under the mounted root"); } @@ -191,7 +211,7 @@ export async function readAgentLocalFileText( ): Promise<{ path: string; content: string }> { const rootPaths = await resolveCurrentRootPaths(); const mounts = buildRootMounts(rootPaths); - const { mount, subPath } = parseMountedVirtualPath(virtualPath); + const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts); const rootMount = findMountByName(mounts, mount); if (!rootMount) { throw new Error( @@ -212,7 +232,7 @@ export async function writeAgentLocalFileText( ): Promise<{ path: string }> { const rootPaths = await resolveCurrentRootPaths(); const mounts = buildRootMounts(rootPaths); - const { mount, subPath } = parseMountedVirtualPath(virtualPath); + const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts); const rootMount = findMountByName(mounts, mount); if (!rootMount) { throw new Error( diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 616637a49..62332d2c4 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -159,7 +159,7 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] { /** * Tools that should render custom UI in the chat. */ -const TOOLS_WITH_UI = new Set([ +const BASE_TOOLS_WITH_UI = new Set([ "web_search", "generate_podcast", "generate_report", @@ -211,6 +211,7 @@ export default function NewChatPage() { assistantMsgId: string; interruptData: Record; } | null>(null); + const toolsWithUI = useMemo(() => new Set([...BASE_TOOLS_WITH_UI]), []); // Get disabled tools from the tool toggle UI const disabledTools = useAtomValue(disabledToolsAtom); @@ -660,7 +661,8 @@ export default function NewChatPage() { const selection = await getAgentFilesystemSelection(); if ( selection.filesystem_mode === "desktop_local_folder" && - (!selection.local_filesystem_roots || selection.local_filesystem_roots.length === 0) + (!selection.local_filesystem_mounts || + selection.local_filesystem_mounts.length === 0) ) { toast.error("Select a local folder before using Local Folder mode."); return; @@ -702,7 +704,7 @@ export default function NewChatPage() { search_space_id: searchSpaceId, filesystem_mode: selection.filesystem_mode, client_platform: selection.client_platform, - local_filesystem_roots: selection.local_filesystem_roots, + local_filesystem_mounts: selection.local_filesystem_mounts, messages: messageHistory, mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined, mentioned_surfsense_doc_ids: hasSurfsenseDocIds @@ -721,7 +723,7 @@ export default function NewChatPage() { setMessages((prev) => prev.map((m) => m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) } : m ) ); @@ -736,7 +738,7 @@ export default function NewChatPage() { break; case "tool-input-start": - addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); + addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {}); batcher.flush(); break; @@ -746,7 +748,7 @@ export default function NewChatPage() { } else { addToolCall( contentPartsState, - TOOLS_WITH_UI, + toolsWithUI, parsed.toolCallId, parsed.toolName, parsed.input || {} @@ -842,7 +844,7 @@ export default function NewChatPage() { const tcId = `interrupt-${action.name}`; addToolCall( contentPartsState, - TOOLS_WITH_UI, + toolsWithUI, tcId, action.name, action.args, @@ -856,7 +858,7 @@ export default function NewChatPage() { setMessages((prev) => prev.map((m) => m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) } : m ) ); @@ -883,7 +885,7 @@ export default function NewChatPage() { batcher.flush(); // Skip persistence for interrupted messages -- handleResume will persist the final version - const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); + const finalContent = buildContentForPersistence(contentPartsState, toolsWithUI); if (contentParts.length > 0 && !wasInterrupted) { try { const savedMessage = await appendMessage(currentThreadId, { @@ -919,10 +921,10 @@ export default function NewChatPage() { const hasContent = contentParts.some( (part) => (part.type === "text" && part.text.length > 0) || - (part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName)) + (part.type === "tool-call" && toolsWithUI.has(part.toolName)) ); if (hasContent && currentThreadId) { - const partialContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); + const partialContent = buildContentForPersistence(contentPartsState, toolsWithUI); try { const savedMessage = await appendMessage(currentThreadId, { role: "assistant", @@ -1098,7 +1100,7 @@ export default function NewChatPage() { decisions, filesystem_mode: selection.filesystem_mode, client_platform: selection.client_platform, - local_filesystem_roots: selection.local_filesystem_roots, + local_filesystem_mounts: selection.local_filesystem_mounts, }), signal: controller.signal, }); @@ -1111,7 +1113,7 @@ export default function NewChatPage() { setMessages((prev) => prev.map((m) => m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) } : m ) ); @@ -1126,7 +1128,7 @@ export default function NewChatPage() { break; case "tool-input-start": - addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); + addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {}); batcher.flush(); break; @@ -1138,7 +1140,7 @@ export default function NewChatPage() { } else { addToolCall( contentPartsState, - TOOLS_WITH_UI, + toolsWithUI, parsed.toolCallId, parsed.toolName, parsed.input || {} @@ -1189,7 +1191,7 @@ export default function NewChatPage() { const tcId = `interrupt-${action.name}`; addToolCall( contentPartsState, - TOOLS_WITH_UI, + toolsWithUI, tcId, action.name, action.args, @@ -1206,7 +1208,7 @@ export default function NewChatPage() { setMessages((prev) => prev.map((m) => m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) } : m ) ); @@ -1230,7 +1232,7 @@ export default function NewChatPage() { batcher.flush(); - const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); + const finalContent = buildContentForPersistence(contentPartsState, toolsWithUI); if (contentParts.length > 0) { try { const savedMessage = await appendMessage(resumeThreadId, { @@ -1435,7 +1437,7 @@ export default function NewChatPage() { disabled_tools: disabledTools.length > 0 ? disabledTools : undefined, filesystem_mode: selection.filesystem_mode, client_platform: selection.client_platform, - local_filesystem_roots: selection.local_filesystem_roots, + local_filesystem_mounts: selection.local_filesystem_mounts, }), signal: controller.signal, }); @@ -1448,7 +1450,7 @@ export default function NewChatPage() { setMessages((prev) => prev.map((m) => m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) } : m ) ); @@ -1463,7 +1465,7 @@ export default function NewChatPage() { break; case "tool-input-start": - addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); + addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {}); batcher.flush(); break; @@ -1473,7 +1475,7 @@ export default function NewChatPage() { } else { addToolCall( contentPartsState, - TOOLS_WITH_UI, + toolsWithUI, parsed.toolCallId, parsed.toolName, parsed.input || {} @@ -1522,7 +1524,7 @@ export default function NewChatPage() { batcher.flush(); // Persist messages after streaming completes - const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); + const finalContent = buildContentForPersistence(contentPartsState, toolsWithUI); if (contentParts.length > 0) { try { // Persist user message (for both edit and reload modes, since backend deleted it) diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index 8f2184bd3..a15ff1cd7 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -226,10 +226,16 @@ function extractDomain(url: string): string { } } -const LOCAL_FILE_PATH_REGEX = /^\/(?:[^/\s`]+\/)*[^/\s`]+\.[^/\s`]+$/; +// Canonical local-file virtual paths are mount-prefixed: // +const LOCAL_FILE_PATH_REGEX = /^\/[a-z0-9_-]+\/[^\s`]+(?:\/[^\s`]+)*$/; function isVirtualFilePathToken(value: string): boolean { - return LOCAL_FILE_PATH_REGEX.test(value); + if (!LOCAL_FILE_PATH_REGEX.test(value) || value.startsWith("//")) { + return false; + } + const normalized = value.replace(/\/+$/, ""); + const segments = normalized.split("/").filter(Boolean); + return segments.length >= 2; } function MarkdownImage({ src, alt }: { src?: string; alt?: string }) { diff --git a/surfsense_web/components/editor/source-code-editor.tsx b/surfsense_web/components/editor/source-code-editor.tsx index c2d77be60..5cab8e5b1 100644 --- a/surfsense_web/components/editor/source-code-editor.tsx +++ b/surfsense_web/components/editor/source-code-editor.tsx @@ -31,6 +31,12 @@ export function SourceCodeEditor({ const { resolvedTheme } = useTheme(); const onSaveRef = useRef(onSave); const monacoRef = useRef(null); + const normalizedModelPath = (() => { + const raw = (path || "local-file.txt").trim(); + const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`; + // Monaco model paths should be stable and POSIX-like across platforms. + return withLeadingSlash.replace(/\\/g, "/").replace(/\/{2,}/g, "/"); + })(); useEffect(() => { onSaveRef.current = onSave; @@ -82,7 +88,7 @@ export function SourceCodeEditor({ return (
({ + mount_id: entry.mount, + root_path: entry.rootPath, + })) ?? []; + if (localFilesystemMounts.length === 0) { + return { + filesystem_mode: "cloud", + client_platform: "desktop", + }; + } return { filesystem_mode: "desktop_local_folder", client_platform: "desktop", - local_filesystem_roots: settings.localRootPaths, + local_filesystem_mounts: localFilesystemMounts, }; } return {