mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
feat: workspace panel for chatui
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
476
dashboard/src/components/chat/WorkspacePanel.vue
Normal file
476
dashboard/src/components/chat/WorkspacePanel.vue
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
@@ -72,6 +72,18 @@
|
||||
"confirmDelete": "确定要删除这个分支吗?此操作无法撤销。",
|
||||
"placeholder": "继续追问这段内容..."
|
||||
},
|
||||
"workspace": {
|
||||
"title": "工作区",
|
||||
"open": "打开工作区",
|
||||
"root": "根目录",
|
||||
"refresh": "刷新",
|
||||
"empty": "暂无文件",
|
||||
"preview": "预览",
|
||||
"selectFile": "选择一个文本文件进行预览",
|
||||
"noSession": "请先打开一个会话",
|
||||
"loadFailed": "加载工作区失败",
|
||||
"previewFailed": "读取文件失败"
|
||||
},
|
||||
"conversation": {
|
||||
"newConversation": "新的聊天",
|
||||
"noHistory": "暂无对话历史",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user