feat: workspace panel for chatui

This commit is contained in:
Soulter
2026-04-22 13:18:58 +08:00
parent 0748f0a42f
commit a0b9ecbf92
8 changed files with 776 additions and 12 deletions

View File

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

View File

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

View File

@@ -282,6 +282,16 @@
!selectedProject && !loadingMessages && !activeMessages.length,
}"
>
<div v-if="currSessionId && !workspacePanelOpen" class="chat-main-actions">
<v-btn
icon="mdi-folder-open-outline"
size="small"
variant="text"
:title="tm('workspace.open')"
@click="workspacePanelOpen = true"
/>
</div>
<ProjectView
v-if="selectedProject"
:project="selectedProject"
@@ -467,6 +477,10 @@
:deleting="deletingThread"
@delete="deleteThread"
/>
<WorkspacePanel
v-model="workspacePanelOpen"
:session-id="currSessionId || null"
/>
<RefsSidebar v-model="refsSidebarOpen" :refs="selectedRefs" />
</div>
</template>
@@ -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<ChatRecord | null>(null);
const threadPanelOpen = ref(false);
const activeThread = ref<ChatThread | null>(null);
const deletingThread = ref(false);
const workspacePanelOpen = ref(false);
const refsSidebarOpen = ref(false);
const selectedRefs = ref<Record<string, unknown> | 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;
}

View File

@@ -0,0 +1,476 @@
<template>
<transition name="slide-left">
<aside v-if="modelValue" class="workspace-panel">
<div class="workspace-panel-header">
<div class="workspace-panel-title">{{ tm("workspace.title") }}</div>
<div class="workspace-panel-actions">
<v-btn
icon="mdi-refresh"
size="small"
variant="text"
:title="tm('workspace.refresh')"
:loading="loading"
@click="loadFiles(currentPath)"
/>
<v-btn icon="mdi-close" size="small" variant="text" @click="close" />
</div>
</div>
<div v-if="breadcrumbs.length" class="workspace-breadcrumb">
<template v-for="crumb in breadcrumbs" :key="crumb.path">
<v-icon v-if="crumb.path !== breadcrumbs[0].path" size="14">
mdi-chevron-right
</v-icon>
<button
type="button"
class="breadcrumb-item"
@click="loadFiles(crumb.path)"
>
{{ crumb.name }}
</button>
</template>
</div>
<div class="workspace-body">
<div class="workspace-content">
<div v-if="loading" class="workspace-state">
<v-progress-circular indeterminate size="24" width="2" />
</div>
<div v-else-if="error" class="workspace-state workspace-error">
{{ error }}
</div>
<div v-else-if="!entries.length" class="workspace-state">
{{ tm("workspace.empty") }}
</div>
<div v-else class="workspace-list">
<button
v-if="currentPath"
type="button"
class="workspace-row"
@click="loadFiles(parentPath)"
>
<v-icon size="18">mdi-arrow-up</v-icon>
<span class="workspace-name">..</span>
</button>
<button
v-for="entry in entries"
:key="entry.path"
type="button"
class="workspace-row"
:class="{ active: selectedFile?.path === entry.path }"
:disabled="entry.type === 'file' && !entry.previewable"
@click="openEntry(entry)"
>
<v-icon size="18">
{{
entry.type === "directory"
? "mdi-folder-outline"
: "mdi-file-document-outline"
}}
</v-icon>
<span class="workspace-name">{{ entry.name }}</span>
<span v-if="entry.type === 'file'" class="workspace-size">
{{ formatSize(entry.size) }}
</span>
</button>
</div>
</div>
<section class="workspace-preview">
<div class="preview-header">
<div class="preview-title">
{{ selectedFile?.name || tm("workspace.preview") }}
</div>
<v-progress-circular
v-if="previewLoading"
indeterminate
size="18"
width="2"
/>
</div>
<pre v-if="previewContent" class="preview-body">{{ previewContent }}</pre>
<div v-else class="preview-empty">
{{ tm("workspace.selectFile") }}
</div>
</section>
</div>
</aside>
</transition>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import axios from "axios";
import { useModuleI18n } from "@/i18n/composables";
type WorkspaceEntry = {
name: string;
path: string;
type: "directory" | "file";
size: number | null;
previewable: boolean;
};
const props = defineProps<{
modelValue: boolean;
sessionId: string | null;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
}>();
const { tm } = useModuleI18n("features/chat");
const currentPath = ref("");
const entries = ref<WorkspaceEntry[]>([]);
const loading = ref(false);
const error = ref("");
const selectedFile = ref<WorkspaceEntry | null>(null);
const previewContent = ref("");
const previewLoading = ref(false);
const breadcrumbs = computed(() => {
if (!currentPath.value) return [];
const parts = currentPath.value.split("/").filter(Boolean);
return parts.map((name, index) => ({
name,
path: parts.slice(0, index + 1).join("/"),
}));
});
const parentPath = computed(() => {
const parts = currentPath.value.split("/").filter(Boolean);
parts.pop();
return parts.join("/");
});
watch(
() => [props.modelValue, props.sessionId],
([open, sessionId]) => {
if (open && sessionId) {
loadFiles(currentPath.value);
}
},
{ immediate: true },
);
function close() {
emit("update:modelValue", false);
}
async function loadFiles(path: string) {
if (!props.sessionId) {
entries.value = [];
error.value = tm("workspace.noSession");
return;
}
loading.value = true;
error.value = "";
try {
const response = await axios.get("/api/chat/workspace/list_files", {
params: {
session_id: props.sessionId,
path,
},
});
if (response.data?.status !== "ok") {
throw new Error(response.data?.message || tm("workspace.loadFailed"));
}
currentPath.value = response.data.data?.path || "";
entries.value = response.data.data?.entries || [];
} catch (err) {
error.value = axios.isAxiosError(err)
? err.response?.data?.message || err.message
: String((err as Error)?.message || err);
entries.value = [];
} finally {
loading.value = false;
}
}
async function openEntry(entry: WorkspaceEntry) {
if (entry.type === "directory") {
await loadFiles(entry.path);
return;
}
if (entry.previewable) {
await previewFile(entry);
}
}
async function previewFile(entry: WorkspaceEntry) {
if (!props.sessionId) return;
selectedFile.value = entry;
previewLoading.value = true;
previewContent.value = "";
try {
const response = await axios.get("/api/chat/workspace/download_file", {
params: {
session_id: props.sessionId,
path: entry.path,
},
});
if (response.data?.status !== "ok") {
throw new Error(response.data?.message || tm("workspace.previewFailed"));
}
previewContent.value = response.data.data?.content || "";
} catch (err) {
previewContent.value = axios.isAxiosError(err)
? err.response?.data?.message || err.message
: String((err as Error)?.message || err);
} finally {
previewLoading.value = false;
}
}
function formatSize(size: number | null) {
if (size == null) return "";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
</script>
<style scoped>
.workspace-panel {
width: min(480px, 56vw);
height: 100%;
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.1);
background: rgb(var(--v-theme-surface));
color: rgb(var(--v-theme-on-surface));
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: all 0.2s ease;
}
.slide-left-enter-from,
.slide-left-leave-to {
transform: translateX(100%);
opacity: 0;
}
.workspace-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 8px;
}
.workspace-panel-title {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
}
.workspace-panel-actions {
display: flex;
align-items: center;
gap: 4px;
}
.workspace-breadcrumb {
display: flex;
align-items: center;
gap: 4px;
min-height: 34px;
padding: 0 16px 8px;
overflow-x: auto;
color: rgba(var(--v-theme-on-surface), 0.62);
white-space: nowrap;
}
.breadcrumb-item {
border: 0;
padding: 3px 4px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.breadcrumb-item:hover {
color: rgb(var(--v-theme-on-surface));
}
.workspace-body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(170px, 38%) minmax(260px, 1fr);
border-top: 1px solid rgba(var(--v-border-color), 0.1);
}
.workspace-content {
min-height: 0;
overflow-y: auto;
padding: 10px;
border-right: 1px solid rgba(var(--v-border-color), 0.14);
}
.workspace-state {
min-height: 110px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 13px;
text-align: center;
}
.workspace-error {
color: rgb(var(--v-theme-error));
}
.workspace-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.workspace-row {
width: 100%;
min-height: 36px;
border: 0;
border-radius: 8px;
padding: 0 8px;
display: flex;
align-items: center;
gap: 8px;
background: transparent;
color: inherit;
cursor: pointer;
text-align: left;
}
.workspace-row:hover,
.workspace-row.active {
background: rgba(var(--v-theme-on-surface), 0.06);
}
.workspace-row:disabled {
cursor: default;
opacity: 0.5;
}
.workspace-row:disabled:hover {
background: transparent;
}
.workspace-name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.workspace-size {
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.56);
font-size: 12px;
}
.workspace-preview {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.preview-header {
min-height: 42px;
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.preview-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 600;
}
.preview-body {
flex: 1;
min-height: 0;
margin: 0;
padding: 0 12px 12px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
color: rgba(var(--v-theme-on-surface), 0.84);
font-size: 12px;
line-height: 1.55;
}
.preview-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 13px;
text-align: center;
}
@media (max-width: 760px) {
.workspace-panel {
position: fixed;
inset: 0;
z-index: 1300;
width: 100vw;
height: 100dvh;
border-left: 0;
}
.workspace-row {
margin-top: 8px;
}
.workspace-panel-header {
min-height: 52px;
padding: calc(10px + env(safe-area-inset-top)) 12px 8px;
border-bottom: 1px solid rgba(var(--v-border-color), 0.12);
}
.workspace-breadcrumb {
padding: 8px 12px;
}
.workspace-body {
display: flex;
flex-direction: column;
border-top: 0;
gap: 8px;
}
.workspace-content {
flex: 1 1 0;
padding: 0 8px;
border-right: 0;
}
.workspace-preview {
flex: 0 0 42%;
min-height: 180px;
border-top: 1px solid rgba(var(--v-border-color), 0.14);
margin-top: 0;
}
}
</style>

View File

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

View File

@@ -72,6 +72,18 @@
"confirmDelete": "确定要删除这个分支吗?此操作无法撤销。",
"placeholder": "继续追问这段内容..."
},
"workspace": {
"title": "工作区",
"open": "打开工作区",
"root": "根目录",
"refresh": "刷新",
"empty": "暂无文件",
"preview": "预览",
"selectFile": "选择一个文本文件进行预览",
"noSession": "请先打开一个会话",
"loadFailed": "加载工作区失败",
"previewFailed": "读取文件失败"
},
"conversation": {
"newConversation": "新的聊天",
"noHistory": "暂无对话历史",

View File

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

View File

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