diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py index 44122361d..3eba681ae 100644 --- a/astrbot/core/computer/booters/local.py +++ b/astrbot/core/computer/booters/local.py @@ -334,9 +334,15 @@ class LocalBooter(ComputerBooter): ) async def download_file(self, remote_path: str, local_path: str) -> None: - raise NotImplementedError( - "LocalBooter does not support download_file operation. Use shell instead." - ) + def _run() -> None: + source = os.path.abspath(remote_path) + destination = os.path.abspath(local_path) + if not os.path.isfile(source): + raise FileNotFoundError(source) + os.makedirs(os.path.dirname(destination), exist_ok=True) + shutil.copyfile(source, destination) + + await asyncio.to_thread(_run) async def available(self) -> bool: return True diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index d7d4777ac..56c11d3bc 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -5,6 +5,7 @@ import re import uuid from contextlib import asynccontextmanager from copy import deepcopy +from pathlib import Path from typing import cast from quart import Response as QuartResponse @@ -12,6 +13,7 @@ from quart import g, make_response, request, send_file from astrbot.core import logger, sp from astrbot.core.agent.message import get_checkpoint_id, is_checkpoint_message +from astrbot.core.computer.computer_client import get_local_booter from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase from astrbot.core.platform.message_type import MessageType @@ -22,6 +24,7 @@ from astrbot.core.platform.sources.webchat.message_parts_helper import ( webchat_message_parts_have_content, ) from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr +from astrbot.core.tools.computer_tools.util import workspace_root from astrbot.core.utils.active_event_registry import active_event_registry from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.datetime_utils import to_utc_isoformat @@ -30,6 +33,34 @@ from .route import Response, Route, RouteContext # SSE heartbeat message to keep the connection alive during long-running operations SSE_HEARTBEAT = ": heartbeat\n\n" +WORKSPACE_TEXT_EXTENSIONS = { + ".css", + ".csv", + ".html", + ".js", + ".json", + ".jsonl", + ".jsx", + ".log", + ".markdown", + ".md", + ".py", + ".rst", + ".sass", + ".scss", + ".sh", + ".sql", + ".toml", + ".ts", + ".tsx", + ".txt", + ".vue", + ".xml", + ".yaml", + ".yml", +} +WORKSPACE_MAX_PREVIEW_BYTES = 1024 * 1024 +WORKSPACE_MAX_LIST_ITEMS = 500 @asynccontextmanager @@ -87,6 +118,8 @@ class ChatRoute(Route): "/chat/get_file": ("GET", self.get_file), "/chat/get_attachment": ("GET", self.get_attachment), "/chat/post_file": ("POST", self.post_file), + "/chat/workspace/list_files": ("GET", self.list_workspace_files), + "/chat/workspace/download_file": ("GET", self.download_workspace_file), } self.core_lifecycle = core_lifecycle self.register_routes() @@ -200,6 +233,120 @@ class ChatRoute(Route): .__dict__ ) + async def list_workspace_files(self): + """List files in the local computer workspace for a webchat session.""" + username = g.get("username", "guest") + session_id = request.args.get("session_id") + if not session_id: + return Response().error("Missing key: session_id").__dict__ + + try: + session = await self._get_owned_webchat_session(session_id, username) + root = workspace_root(self._build_webchat_unified_msg_origin(session)) + root.mkdir(parents=True, exist_ok=True) + target = self._resolve_workspace_path(root, request.args.get("path")) + if not target.exists(): + return Response().error("Workspace path not found").__dict__ + if not target.is_dir(): + return Response().error("Workspace path is not a directory").__dict__ + + result = await get_local_booter().fs.list_dir( + str(target), show_hidden=False + ) + if not result.get("success", False): + return Response().error("Failed to list workspace files").__dict__ + + entries: list[dict] = [] + for name in result.get("entries", [])[:WORKSPACE_MAX_LIST_ITEMS]: + if not isinstance(name, str): + continue + entry = target / name + if not entry.exists(): + continue + resolved_entry = entry.resolve(strict=False) + if resolved_entry != root and not resolved_entry.is_relative_to(root): + continue + entries.append(self._serialize_workspace_entry(root, entry)) + + entries.sort(key=lambda item: (item["type"] != "directory", item["name"])) + return ( + Response() + .ok( + data={ + "path": self._workspace_relative_path(root, target), + "entries": entries, + "truncated": len(result.get("entries", [])) + > WORKSPACE_MAX_LIST_ITEMS, + } + ) + .__dict__ + ) + except PermissionError as exc: + return Response().error(str(exc)).__dict__ + except ValueError as exc: + return Response().error(str(exc)).__dict__ + except OSError as exc: + logger.error(f"Error listing workspace files: {exc}") + return Response().error("File access error").__dict__ + + async def download_workspace_file(self): + """Read a text file from the local computer workspace for preview.""" + username = g.get("username", "guest") + session_id = request.args.get("session_id") + raw_path = request.args.get("path") + if not session_id: + return Response().error("Missing key: session_id").__dict__ + if not raw_path: + return Response().error("Missing key: path").__dict__ + + try: + session = await self._get_owned_webchat_session(session_id, username) + root = workspace_root(self._build_webchat_unified_msg_origin(session)) + target = self._resolve_workspace_path(root, raw_path) + if not target.exists(): + return Response().error("Workspace file not found").__dict__ + if not target.is_file(): + return Response().error("Workspace path is not a file").__dict__ + if target.suffix.lower() not in WORKSPACE_TEXT_EXTENSIONS: + return Response().error("This file type cannot be previewed").__dict__ + + stat = target.stat() + if stat.st_size > WORKSPACE_MAX_PREVIEW_BYTES: + return ( + Response() + .error( + "File is too large to preview. " + f"Maximum size is {WORKSPACE_MAX_PREVIEW_BYTES} bytes." + ) + .__dict__ + ) + + result = await get_local_booter().fs.read_file(str(target)) + if not result.get("success", False): + return Response().error("Failed to read workspace file").__dict__ + + return ( + Response() + .ok( + data={ + "name": target.name, + "path": self._workspace_relative_path(root, target), + "content": result.get("content", ""), + "size": stat.st_size, + "modified_at": stat.st_mtime, + "extension": target.suffix.lower(), + } + ) + .__dict__ + ) + except PermissionError as exc: + return Response().error(str(exc)).__dict__ + except ValueError as exc: + return Response().error(str(exc)).__dict__ + except OSError as exc: + logger.error(f"Error reading workspace file: {exc}") + return Response().error("File access error").__dict__ + async def _build_user_message_parts(self, message: str | list) -> list[dict]: """构建用户消息的部分列表。""" return await build_webchat_message_parts( @@ -325,6 +472,54 @@ class ChatRoute(Route): f"{session.platform_id}!{session.creator}!{session.session_id}" ) + async def _get_owned_webchat_session(self, session_id: str, username: str): + session = await self.db.get_platform_session_by_id(session_id) + if not session: + raise ValueError(f"Session {session_id} not found") + if session.creator != username: + raise PermissionError("Permission denied") + return session + + def _resolve_workspace_path(self, root: Path, raw_path: str | None) -> Path: + normalized = (raw_path or "").strip().replace("\\", "/") + if normalized in {"", ".", "/"}: + candidate = root + else: + relative_path = Path(normalized) + if relative_path.is_absolute(): + raise ValueError("Invalid workspace path") + candidate = root / relative_path + + resolved_root = root.resolve(strict=False) + resolved_candidate = candidate.resolve(strict=False) + if ( + resolved_candidate != resolved_root + and not resolved_candidate.is_relative_to(resolved_root) + ): + raise ValueError("Invalid workspace path") + return resolved_candidate + + def _workspace_relative_path(self, root: Path, path: Path) -> str: + resolved_root = root.resolve(strict=False) + resolved_path = path.resolve(strict=False) + if resolved_path == resolved_root: + return "" + return resolved_path.relative_to(resolved_root).as_posix() + + def _serialize_workspace_entry(self, root: Path, entry: Path) -> dict: + stat = entry.stat() + is_dir = entry.is_dir() + suffix = entry.suffix.lower() + return { + "name": entry.name, + "path": self._workspace_relative_path(root, entry), + "type": "directory" if is_dir else "file", + "size": stat.st_size if not is_dir else None, + "modified_at": stat.st_mtime, + "extension": suffix, + "previewable": (not is_dir) and suffix in WORKSPACE_TEXT_EXTENSIONS, + } + def _build_thread_unified_msg_origin(self, creator: str, thread_id: str) -> str: return ( f"webchat:{MessageType.FRIEND_MESSAGE.value}:webchat!{creator}!{thread_id}" diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index dba47fa05..8de3c9522 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -282,6 +282,16 @@ !selectedProject && !loadingMessages && !activeMessages.length, }" > +
+ +
+ + @@ -496,6 +510,7 @@ import ChatInput from "@/components/chat/ChatInput.vue"; import ChatMessageList from "@/components/chat/ChatMessageList.vue"; import type { RegenerateModelSelection } from "@/components/chat/RegenerateMenu.vue"; import ThreadPanel from "@/components/chat/ThreadPanel.vue"; +import WorkspacePanel from "@/components/chat/WorkspacePanel.vue"; import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue"; import { useSessions, type Session } from "@/composables/useSessions"; import { @@ -588,6 +603,7 @@ const replyTarget = ref(null); const threadPanelOpen = ref(false); const activeThread = ref(null); const deletingThread = ref(false); +const workspacePanelOpen = ref(false); const refsSidebarOpen = ref(false); const selectedRefs = ref | null>(null); const threadSelection = reactive<{ @@ -933,7 +949,7 @@ async function sendCurrentMessage() { draft.value = ""; replyTarget.value = null; - clearStaged({ revokeUrls: false }); + clearStaged(); scrollToBottom(); sendMessageStream({ @@ -1466,6 +1482,22 @@ function toggleTheme() { justify-content: center; } +.chat-main-actions { + position: absolute; + top: 12px; + right: 14px; + z-index: 3; + display: flex; + align-items: center; + gap: 4px; + color: var(--chat-muted); +} + +.chat-main-actions :deep(.v-btn) { + background: rgba(var(--v-theme-surface), 0.86); + backdrop-filter: blur(8px); +} + .messages-panel { flex: 1; min-height: 0; @@ -1585,6 +1617,11 @@ kbd { } @media (max-width: 760px) { + .chat-main-actions { + top: 10px; + right: 10px; + } + .messages-panel { padding: 18px 14px; } diff --git a/dashboard/src/components/chat/WorkspacePanel.vue b/dashboard/src/components/chat/WorkspacePanel.vue new file mode 100644 index 000000000..73f9f0720 --- /dev/null +++ b/dashboard/src/components/chat/WorkspacePanel.vue @@ -0,0 +1,476 @@ + + + + + diff --git a/dashboard/src/i18n/locales/en-US/features/chat.json b/dashboard/src/i18n/locales/en-US/features/chat.json index ccbe35aae..1b964c605 100644 --- a/dashboard/src/i18n/locales/en-US/features/chat.json +++ b/dashboard/src/i18n/locales/en-US/features/chat.json @@ -72,6 +72,18 @@ "confirmDelete": "Delete this thread? This action cannot be undone.", "placeholder": "Ask about this excerpt..." }, + "workspace": { + "title": "Workspace", + "open": "Open workspace", + "root": "Root", + "refresh": "Refresh", + "empty": "No files", + "preview": "Preview", + "selectFile": "Select a text file to preview", + "noSession": "Open a session first", + "loadFailed": "Failed to load workspace", + "previewFailed": "Failed to read file" + }, "conversation": { "newConversation": "New Conversation", "noHistory": "No conversation history", diff --git a/dashboard/src/i18n/locales/zh-CN/features/chat.json b/dashboard/src/i18n/locales/zh-CN/features/chat.json index 2aea133ad..1e99a8e4e 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/chat.json +++ b/dashboard/src/i18n/locales/zh-CN/features/chat.json @@ -72,6 +72,18 @@ "confirmDelete": "确定要删除这个分支吗?此操作无法撤销。", "placeholder": "继续追问这段内容..." }, + "workspace": { + "title": "工作区", + "open": "打开工作区", + "root": "根目录", + "refresh": "刷新", + "empty": "暂无文件", + "preview": "预览", + "selectFile": "选择一个文本文件进行预览", + "noSession": "请先打开一个会话", + "loadFailed": "加载工作区失败", + "previewFailed": "读取文件失败" + }, "conversation": { "newConversation": "新的聊天", "noHistory": "暂无对话历史", diff --git a/tests/test_chat_route.py b/tests/test_chat_route.py index 47bd747a0..7d26eaeb2 100644 --- a/tests/test_chat_route.py +++ b/tests/test_chat_route.py @@ -1,8 +1,9 @@ import asyncio +from pathlib import Path import pytest -from astrbot.dashboard.routes.chat import _poll_webchat_stream_result +from astrbot.dashboard.routes.chat import ChatRoute, _poll_webchat_stream_result class _QueueThatRaises: @@ -54,3 +55,26 @@ async def test_poll_webchat_stream_result_returns_queue_payload(): assert result == payload assert should_break is False + + +def test_resolve_workspace_path_blocks_traversal(tmp_path: Path): + route = ChatRoute.__new__(ChatRoute) + root = tmp_path / "workspace" + root.mkdir() + + with pytest.raises(ValueError): + route._resolve_workspace_path(root, "../secret.txt") + + +def test_serialize_workspace_entry_marks_text_previewable(tmp_path: Path): + route = ChatRoute.__new__(ChatRoute) + root = tmp_path / "workspace" + root.mkdir() + file_path = root / "notes.md" + file_path.write_text("# notes", encoding="utf-8") + + payload = route._serialize_workspace_entry(root, file_path) + + assert payload["path"] == "notes.md" + assert payload["type"] == "file" + assert payload["previewable"] is True diff --git a/tests/unit/test_computer.py b/tests/unit/test_computer.py index 71b31a301..6c566cb62 100644 --- a/tests/unit/test_computer.py +++ b/tests/unit/test_computer.py @@ -77,14 +77,16 @@ class TestLocalBooterUploadDownload: ) @pytest.mark.asyncio - async def test_download_file_not_supported(self): - """Test LocalBooter download_file raises NotImplementedError.""" + async def test_download_file_copies_local_file(self, tmp_path): + """Test LocalBooter download_file copies a local file.""" booter = LocalBooter() - with pytest.raises(NotImplementedError) as exc_info: - await booter.download_file("remote_path", "local_path") - assert "LocalBooter does not support download_file operation" in str( - exc_info.value - ) + source = tmp_path / "source.txt" + target = tmp_path / "nested" / "target.txt" + source.write_text("content", encoding="utf-8") + + await booter.download_file(str(source), str(target)) + + assert target.read_text(encoding="utf-8") == "content" class TestSecurityRestrictions: