from __future__ import annotations import asyncio import io import mimetypes from pathlib import PurePosixPath from urllib.parse import quote from fastapi import HTTPException from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from yuxi.agents.backends import KBS_PATH, KnowledgeBaseReadonlyBackend, resolve_visible_knowledge_bases_for_context from yuxi.agents.backends.sandbox import SKILLS_PATH, USER_DATA_PATH, resolve_virtual_path from yuxi.agents.backends.skills_backend import SelectedSkillsReadonlyBackend from yuxi.agents.middlewares.skills_middleware import normalize_selected_skills from yuxi.services.filesystem_service import _resolve_filesystem_state from yuxi.storage.postgres.models_business import User # Sandbox 的 /home/gem 路径(虚拟根) SANDBOX_HOME = "/home/gem" # 根目录排除的文件/目录名 _HIDDEN_EXCLUDE = frozenset( { ".cache", ".config", ".jupyter", ".local", ".npm-global", ".pki", ".ipython", ".npm", ".bashrc", ".Xauthority", } ) _MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown", ".mdx"}) _PDF_EXTENSIONS = frozenset({".pdf"}) _TEXT_EXTENSIONS = frozenset( { ".txt", ".text", ".log", ".json", ".jsonl", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".csv", ".tsv", ".py", ".js", ".ts", ".jsx", ".tsx", ".vue", ".html", ".htm", ".css", ".less", ".scss", ".xml", ".sql", ".sh", ".bash", ".zsh", ".fish", ".env", ".dockerfile", ".gitignore", ".weather", } ) _IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"}) _BINARY_SIGNATURES = ( b"\x7fELF", b"MZ", b"%PDF-", b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08", b"\x89PNG\r\n\x1a\n", b"\xff\xd8\xff", b"GIF87a", b"GIF89a", b"RIFF", ) def _detect_preview_type(path: str, raw_content: bytes) -> tuple[str, bool, str | None]: suffix = PurePosixPath(path).suffix.lower() mime_type, _encoding = mimetypes.guess_type(path) head = raw_content[:1024] if suffix in _IMAGE_EXTENSIONS or (mime_type and mime_type.startswith("image/")): return "image", True, None if suffix in _PDF_EXTENSIONS or mime_type == "application/pdf" or head.startswith(b"%PDF-"): return "pdf", True, None if suffix in _MARKDOWN_EXTENSIONS: return "markdown", True, None if suffix in _TEXT_EXTENSIONS: return "text", True, None if b"\x00" in head: return "unsupported", False, "当前文件是二进制文件,暂不支持预览" if any(head.startswith(signature) for signature in _BINARY_SIGNATURES): if head.startswith(b"RIFF") and b"WEBP" in head[:16]: return "image", True, None return "unsupported", False, "当前文件格式暂不支持预览" if mime_type: if mime_type.startswith("text/"): return "text", True, None if mime_type in {"application/json", "application/xml", "application/javascript"}: return "text", True, None if mime_type.startswith("application/"): return "unsupported", False, "当前文件格式暂不支持预览" if not raw_content: return "text", True, None try: raw_content.decode("utf-8") return "text", True, None except UnicodeDecodeError: return "unsupported", False, "当前文件不是可读文本,暂不支持预览" def _normalize_path(path: str | None) -> str: normalized = (path or "/").strip() or "/" if not normalized.startswith("/"): normalized = f"/{normalized}" return normalized.rstrip("/") if normalized not in {"/", KBS_PATH, SKILLS_PATH, USER_DATA_PATH} else normalized def _is_user_data_path(path: str) -> bool: return path == USER_DATA_PATH or path.startswith(f"{USER_DATA_PATH}/") def _is_skills_path(path: str) -> bool: return path == SKILLS_PATH or path.startswith(f"{SKILLS_PATH}/") def _is_kbs_path(path: str) -> bool: return path == KBS_PATH or path.startswith(f"{KBS_PATH}/") def _is_in_home_gem(path: str) -> bool: """检查路径是否在 /home/gem/ 下但不在虚拟挂载点内""" if not path.startswith("/home/gem/"): return False # 排除虚拟挂载点 if path.startswith(f"{USER_DATA_PATH}/") or path == USER_DATA_PATH: return False if path.startswith(f"{SKILLS_PATH}/") or path == SKILLS_PATH: return False if path.startswith(f"{KBS_PATH}/") or path == KBS_PATH: return False return True def _strip_skills_prefix(path: str) -> str: if path == SKILLS_PATH: return "/" return path[len(SKILLS_PATH) :] or "/" def _strip_kbs_prefix(path: str) -> str: if path == KBS_PATH: return "/" return path[len(KBS_PATH) :] or "/" def _remap_prefixed_entry(entry: dict, prefix: str) -> dict: raw_path = str(entry.get("path") or "") is_dir = bool(entry.get("is_dir", False)) remapped = f"{prefix}{raw_path}" if raw_path != "/" else f"{prefix}/" if is_dir and not remapped.endswith("/"): remapped = f"{remapped}/" return { "path": remapped, "name": PurePosixPath(remapped.rstrip("/")).name or remapped, "is_dir": is_dir, "size": int(entry.get("size", 0) or 0), "modified_at": str(entry.get("modified_at", "") or ""), } def _normalize_entries(entries: list[dict]) -> list[dict]: normalized: list[dict] = [] for entry in entries or []: raw_path = str(entry.get("path") or "") if not raw_path: continue is_dir = bool(entry.get("is_dir", False)) display_path = raw_path if is_dir and not display_path.endswith("/"): display_path = f"{display_path}/" normalized.append( { "path": display_path, "name": PurePosixPath(display_path.rstrip("/")).name or display_path, "is_dir": is_dir, "size": int(entry.get("size", 0) or 0), "modified_at": str(entry.get("modified_at", "") or ""), } ) return normalized def _sort_entries(entries: list[dict]) -> list[dict]: """Sort entries: folders first, then files alphabetically.""" return sorted( entries, key=lambda e: ( not bool(e.get("is_dir")), PurePosixPath(str(e.get("path") or "").rstrip("/")).name.lower(), ), ) async def _resolve_viewer_state( *, thread_id: str, agent_id: str | None, agent_config_id: int | None, current_user: User, db: AsyncSession, ): _conversation, runtime_context, sandbox_backend = await _resolve_filesystem_state( thread_id=thread_id, user=current_user, db=db, agent_id=agent_id, agent_config_id=agent_config_id, ) visible_kbs = await resolve_visible_knowledge_bases_for_context(runtime_context) selected_skills = normalize_selected_skills(getattr(runtime_context, "skills", None) or []) skills_backend = SelectedSkillsReadonlyBackend(selected_slugs=selected_skills) kb_backend = KnowledgeBaseReadonlyBackend(visible_kbs=visible_kbs) return sandbox_backend, skills_backend, kb_backend, selected_skills async def list_viewer_filesystem_tree( *, thread_id: str, path: str, agent_id: str | None, agent_config_id: int | None, current_user: User, db: AsyncSession, ) -> dict: if not thread_id: raise HTTPException(status_code=422, detail="thread_id 不能为空") normalized_path = _normalize_path(path) sandbox_backend, skills_backend, kb_backend, selected_skills = await _resolve_viewer_state( thread_id=thread_id, agent_id=agent_id, agent_config_id=agent_config_id, current_user=current_user, db=db, ) if normalized_path == "/": # 根目录显示 /home/gem/ 下的所有内容 entries = [] # 收集已添加的虚拟挂载点名称,用于去重 added_names = set() # 添加虚拟挂载点 entries.append( {"path": f"{USER_DATA_PATH}/", "name": "user-data", "is_dir": True, "size": 0, "modified_at": ""} ) added_names.add("user-data") if selected_skills: entries.append({"path": f"{SKILLS_PATH}/", "name": "skills", "is_dir": True, "size": 0, "modified_at": ""}) added_names.add("skills") if kb_backend.has_entries(): entries.append({"path": f"{KBS_PATH}/", "name": "kbs", "is_dir": True, "size": 0, "modified_at": ""}) added_names.add("kbs") # 添加 sandbox 根目录 /home/gem/ 的实际文件(排除已添加的虚拟挂载点和隐藏文件) sandbox_root_entries = await asyncio.to_thread(sandbox_backend.ls_info, SANDBOX_HOME) if sandbox_root_entries: sandbox_entries = _normalize_entries(sandbox_root_entries) for entry in sandbox_entries: name = PurePosixPath(entry["path"].rstrip("/")).name if name in _HIDDEN_EXCLUDE: continue if name not in added_names: entries.append(entry) added_names.add(name) # 防止其他同名文件/文件夹重复 return {"entries": _sort_entries(entries)} try: if _is_user_data_path(normalized_path): entries = await asyncio.to_thread(sandbox_backend.ls_info, normalized_path) return {"entries": _sort_entries(_normalize_entries(entries))} if _is_skills_path(normalized_path): entries = await asyncio.to_thread(skills_backend.ls_info, _strip_skills_prefix(normalized_path)) remapped = [_remap_prefixed_entry(entry, SKILLS_PATH) for entry in entries] return {"entries": _sort_entries(remapped)} if _is_kbs_path(normalized_path): entries = await asyncio.to_thread(kb_backend.ls_info, _strip_kbs_prefix(normalized_path)) remapped = [_remap_prefixed_entry(entry, KBS_PATH) for entry in entries] return {"entries": _sort_entries(remapped)} except PermissionError as e: raise HTTPException(status_code=400, detail=str(e)) from e except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) from e raise HTTPException(status_code=400, detail=f"Access denied: '{normalized_path}' is outside viewer namespace") async def read_viewer_file_content( *, thread_id: str, path: str, agent_id: str | None, agent_config_id: int | None, current_user: User, db: AsyncSession, ) -> dict: if not thread_id: raise HTTPException(status_code=422, detail="thread_id 不能为空") normalized_path = _normalize_path(path) sandbox_backend, skills_backend, kb_backend, _selected_skills = await _resolve_viewer_state( thread_id=thread_id, agent_id=agent_id, agent_config_id=agent_config_id, current_user=current_user, db=db, ) try: if _is_user_data_path(normalized_path): actual_path = resolve_virtual_path(thread_id, normalized_path) if not actual_path.exists(): raise HTTPException(status_code=404, detail="文件不存在") if not actual_path.is_file(): raise HTTPException(status_code=400, detail="当前路径是目录") raw_content = await asyncio.to_thread(actual_path.read_bytes) preview_type, supported, message = _detect_preview_type(normalized_path, raw_content) if preview_type in {"image", "pdf"} or not supported: return { "content": None, "preview_type": preview_type, "supported": supported, "message": message, } return { "content": raw_content.decode("utf-8"), "preview_type": preview_type, "supported": supported, "message": message, } elif _is_skills_path(normalized_path): responses = await asyncio.to_thread(skills_backend.download_files, [_strip_skills_prefix(normalized_path)]) elif _is_kbs_path(normalized_path): responses = await asyncio.to_thread(kb_backend.download_files, [_strip_kbs_prefix(normalized_path)]) elif _is_in_home_gem(normalized_path): # /home/gem/ 下的其他文件(如 workspace 目录) responses = await asyncio.to_thread(sandbox_backend.download_files, [normalized_path]) else: raise HTTPException( status_code=400, detail=f"Access denied: '{normalized_path}' is outside viewer namespace", ) except PermissionError as e: raise HTTPException(status_code=400, detail=str(e)) from e except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) from e response = responses[0] if responses else None if response is None or response.error == "file_not_found": raise HTTPException(status_code=404, detail="文件不存在") if response.error == "is_directory": raise HTTPException(status_code=400, detail="当前路径是目录") if response.error: raise HTTPException(status_code=400, detail=str(response.error)) raw_content = response.content or b"" preview_type, supported, message = _detect_preview_type(normalized_path, raw_content) if preview_type in {"image", "pdf"}: return { "content": None, "preview_type": preview_type, "supported": supported, "message": message, } if not supported: return { "content": None, "preview_type": preview_type, "supported": supported, "message": message, } content = raw_content.decode("utf-8") return { "content": content, "preview_type": preview_type, "supported": supported, "message": message, } async def download_viewer_file( *, thread_id: str, path: str, agent_id: str | None, agent_config_id: int | None, current_user: User, db: AsyncSession, ) -> StreamingResponse: normalized_path = _normalize_path(path) sandbox_backend, skills_backend, kb_backend, _selected_skills = await _resolve_viewer_state( thread_id=thread_id, agent_id=agent_id, agent_config_id=agent_config_id, current_user=current_user, db=db, ) try: if _is_user_data_path(normalized_path): actual_path = resolve_virtual_path(thread_id, normalized_path) if not actual_path.exists(): raise HTTPException(status_code=404, detail="文件不存在") if not actual_path.is_file(): raise HTTPException(status_code=400, detail="当前路径是目录") file_name = actual_path.name or "download" media_type = mimetypes.guess_type(file_name)[0] or "application/octet-stream" headers = { "Content-Disposition": f"attachment; filename*=UTF-8''{quote(file_name)}", } return FileResponse(path=actual_path, media_type=media_type, headers=headers) if _is_skills_path(normalized_path): responses = await asyncio.to_thread(skills_backend.download_files, [_strip_skills_prefix(normalized_path)]) elif _is_kbs_path(normalized_path): responses = await asyncio.to_thread(kb_backend.download_files, [_strip_kbs_prefix(normalized_path)]) elif _is_in_home_gem(normalized_path): # /home/gem/ 下的其他文件(如 workspace 目录) responses = await asyncio.to_thread(sandbox_backend.download_files, [normalized_path]) else: raise HTTPException( status_code=400, detail=f"Access denied: '{normalized_path}' is outside viewer namespace", ) except PermissionError as e: raise HTTPException(status_code=400, detail=str(e)) from e except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) from e response = responses[0] if responses else None if response is None or response.error == "file_not_found": raise HTTPException(status_code=404, detail="文件不存在") if response.error == "is_directory": raise HTTPException(status_code=400, detail="当前路径是目录") if response.error: raise HTTPException(status_code=400, detail=str(response.error)) file_name = PurePosixPath(normalized_path).name or "download" media_type = mimetypes.guess_type(file_name)[0] or "application/octet-stream" stream = io.BytesIO(response.content or b"") headers = { "Content-Disposition": f"attachment; filename*=UTF-8''{quote(file_name)}", } return StreamingResponse(stream, media_type=media_type, headers=headers)