feat: add ChatUI workspace file browser (#9432)

* feat: add ChatUI workspace file browser

* fix: harden workspace path validation

* fix: validate workspace download boundary

* fix: resolve workspace files from directory entries
This commit is contained in:
Soulter
2026-07-28 23:53:47 +08:00
committed by GitHub
parent b0cb91f7a6
commit 73ecc394de
14 changed files with 1490 additions and 10 deletions
+53 -1
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
import os
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse
from astrbot.dashboard.async_utils import run_maybe_async
from astrbot.dashboard.responses import error, ok
@@ -155,6 +158,55 @@ async def list_chat_project_sessions(
return await _run(lambda: service.get_project_sessions(auth.username, project_id))
@router.get("/chat/projects/{project_id}/workspace/files")
async def list_chat_project_workspace_files(
project_id: str,
path: str = Query(default=""),
auth: AuthContext = Depends(require_chat_scope),
service: ChatUIProjectService = Depends(get_service),
):
return await _run(
lambda: service.list_workspace_files(auth.username, project_id, path)
)
@router.get("/chat/projects/{project_id}/workspace/file")
async def get_chat_project_workspace_file(
project_id: str,
path: str,
auth: AuthContext = Depends(require_chat_scope),
service: ChatUIProjectService = Depends(get_service),
):
return await _run(
lambda: service.get_workspace_file(auth.username, project_id, path)
)
@router.get("/chat/projects/{project_id}/workspace/file/download")
async def download_chat_project_workspace_file(
project_id: str,
path: str,
auth: AuthContext = Depends(require_chat_scope),
service: ChatUIProjectService = Depends(get_service),
):
try:
workspace_root, file_path = await service.get_workspace_file_location(
auth.username,
project_id,
path,
)
except ChatUIProjectServiceError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
workspace_root_path = os.path.normcase(os.path.realpath(workspace_root))
download_path = os.path.normcase(os.path.realpath(file_path))
workspace_root_prefix = os.path.join(workspace_root_path, "")
if download_path != workspace_root_path and not download_path.startswith(
workspace_root_prefix
):
raise HTTPException(status_code=400, detail="Invalid workspace path")
return FileResponse(download_path, filename=os.path.basename(download_path))
@legacy_router.get("/get_sessions")
async def list_dashboard_chat_project_sessions(
project_id: str | None = Query(default=None),
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
from pathlib import Path
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.datetime_utils import to_utc_isoformat
@@ -13,6 +14,8 @@ from astrbot.core.workspace import (
workspace_path_to_root,
)
_WORKSPACE_FILE_MAX_BYTES = 512 * 1024
class ChatUIProjectServiceError(Exception):
pass
@@ -141,6 +144,207 @@ class ChatUIProjectService:
) -> list[dict]:
return await self.get_project_sessions(username, project_id)
async def list_workspace_files(
self,
username: str,
project_id: str,
relative_path: str = "",
) -> dict:
"""List one directory inside an owned project's workspace.
Args:
username: Dashboard username.
project_id: ChatUI project ID.
relative_path: Directory path relative to the workspace root.
Returns:
Directory metadata and its direct child entries.
Raises:
ChatUIProjectServiceError: If the path is invalid or unreadable.
"""
project = await self._get_owned_project(username, project_id)
fallback_umo = f"webchat:FriendMessage:webchat!{project.creator}!default"
workspace_root_path = os.path.normcase(
os.path.realpath(
resolve_project_workspace_root(
project,
fallback_umo=fallback_umo,
)
)
)
workspace_root = Path(workspace_root_path)
raw_path = str(relative_path or "").strip()
normalized_path = Path(raw_path.replace("\\", "/") or ".")
if normalized_path.is_absolute() or ".." in normalized_path.parts:
raise ChatUIProjectServiceError("Invalid workspace path")
target_dir_path = os.path.normcase(
os.path.realpath(os.path.join(workspace_root_path, normalized_path))
)
# Keep the separator to reject sibling paths with the same name prefix.
workspace_root_prefix = os.path.join(workspace_root_path, "")
if target_dir_path != workspace_root_path and not target_dir_path.startswith(
workspace_root_prefix
):
raise ChatUIProjectServiceError("Workspace path escapes project directory")
target_dir = Path(target_dir_path)
if not workspace_root.exists() and normalized_path == Path("."):
return {"path": "", "entries": []}
if not target_dir.is_dir():
raise ChatUIProjectServiceError("Workspace directory not found")
try:
children = sorted(
target_dir.iterdir(),
key=lambda item: (not item.is_dir(), item.name.lower()),
)
except OSError as exc:
raise ChatUIProjectServiceError(
"Workspace directory cannot be read"
) from exc
entries = []
for entry in children:
if entry.is_symlink():
continue
try:
if not entry.is_dir() and not entry.is_file():
continue
stat = entry.stat()
except OSError:
continue
is_directory = entry.is_dir()
entries.append(
{
"name": entry.name,
"path": entry.relative_to(workspace_root).as_posix(),
"type": "directory" if is_directory else "file",
"size": 0 if is_directory else stat.st_size,
"readable": (
not is_directory and stat.st_size <= _WORKSPACE_FILE_MAX_BYTES
),
}
)
current_path = target_dir.relative_to(workspace_root).as_posix()
return {
"path": "" if current_path == "." else current_path,
"entries": entries,
}
async def get_workspace_file(
self,
username: str,
project_id: str,
relative_path: str,
) -> dict:
"""Read a UTF-8 text file inside an owned project's workspace.
Args:
username: Dashboard username.
project_id: ChatUI project ID.
relative_path: File path relative to the workspace root.
Returns:
Relative path, UTF-8 content, and byte size.
Raises:
ChatUIProjectServiceError: If the file is invalid or cannot be previewed.
"""
_, target_file = await self.get_workspace_file_location(
username,
project_id,
relative_path,
)
try:
with target_file.open("rb") as file:
content_bytes = file.read(_WORKSPACE_FILE_MAX_BYTES + 1)
except OSError as exc:
raise ChatUIProjectServiceError("Workspace file cannot be read") from exc
if len(content_bytes) > _WORKSPACE_FILE_MAX_BYTES:
raise ChatUIProjectServiceError("Workspace file is too large to preview")
try:
content = content_bytes.decode("utf-8")
except UnicodeDecodeError as exc:
raise ChatUIProjectServiceError(
"Workspace file is not valid UTF-8 text"
) from exc
return {
"path": relative_path,
"content": content,
"size": len(content_bytes),
}
async def get_workspace_file_location(
self,
username: str,
project_id: str,
relative_path: str,
) -> tuple[Path, Path]:
"""Resolve a file inside an owned project's workspace.
Args:
username: Dashboard username.
project_id: ChatUI project ID.
relative_path: File path relative to the workspace root.
Returns:
Validated workspace root and absolute path to the workspace file.
Raises:
ChatUIProjectServiceError: If the file path is invalid or missing.
"""
project = await self._get_owned_project(username, project_id)
fallback_umo = f"webchat:FriendMessage:webchat!{project.creator}!default"
workspace_root_path = os.path.normcase(
os.path.realpath(
resolve_project_workspace_root(
project,
fallback_umo=fallback_umo,
)
)
)
raw_path = str(relative_path or "").strip()
normalized_path = Path(raw_path.replace("\\", "/"))
if (
not raw_path
or normalized_path.is_absolute()
or ".." in normalized_path.parts
):
raise ChatUIProjectServiceError("Invalid workspace path")
# Match server-enumerated entries so request values never form a file path.
target_file = Path(workspace_root_path)
path_parts = normalized_path.parts
for index, part in enumerate(path_parts):
try:
children = {entry.name: entry for entry in target_file.iterdir()}
except OSError as exc:
raise ChatUIProjectServiceError(
"Workspace file cannot be read"
) from exc
child = children.get(part)
if child is None:
raise ChatUIProjectServiceError("Workspace file not found")
if child.is_symlink():
if not child.resolve(strict=False).is_relative_to(
Path(workspace_root_path)
):
raise ChatUIProjectServiceError(
"Workspace path escapes project directory"
)
raise ChatUIProjectServiceError("Workspace file not found")
if index < len(path_parts) - 1 and not child.is_dir():
raise ChatUIProjectServiceError("Workspace file not found")
target_file = child
if not path_parts or not target_file.is_file():
raise ChatUIProjectServiceError("Workspace file not found")
return Path(workspace_root_path), target_file
async def _get_owned_project(self, username: str, project_id: str):
project = await self.db.get_chatui_project_by_id(project_id)
if not project:
File diff suppressed because one or more lines are too long
@@ -1534,6 +1534,45 @@ export type ListChatProjectSessionsResponse = (SuccessEnvelope);
export type ListChatProjectSessionsError = unknown;
export type ListChatProjectWorkspaceFilesData = {
path: {
project_id: string;
};
query?: {
path?: string;
};
};
export type ListChatProjectWorkspaceFilesResponse = (SuccessEnvelope);
export type ListChatProjectWorkspaceFilesError = unknown;
export type GetChatProjectWorkspaceFileData = {
path: {
project_id: string;
};
query: {
path: string;
};
};
export type GetChatProjectWorkspaceFileResponse = (SuccessEnvelope);
export type GetChatProjectWorkspaceFileError = unknown;
export type DownloadChatProjectWorkspaceFileData = {
path: {
project_id: string;
};
query: {
path: string;
};
};
export type DownloadChatProjectWorkspaceFileResponse = ((Blob | File));
export type DownloadChatProjectWorkspaceFileError = unknown;
export type AddChatProjectSessionData = {
path: {
project_id: string;
+23
View File
@@ -924,6 +924,29 @@ export const chatApi = {
openApiV1.listChatProjectSessions({ path: { project_id: projectId } }),
);
},
listProjectWorkspaceFiles(projectId: string, path = '') {
return typed<any>(
openApiV1.listChatProjectWorkspaceFiles({
path: { project_id: projectId },
query: path ? { path } : undefined,
}),
);
},
getProjectWorkspaceFile(projectId: string, path: string) {
return typed<any>(
openApiV1.getChatProjectWorkspaceFile({
path: { project_id: projectId },
query: { path },
}),
);
},
downloadProjectWorkspaceFile(projectId: string, path: string) {
return openApiV1.downloadChatProjectWorkspaceFile({
path: { project_id: projectId },
query: { path },
responseType: 'blob',
}) as Promise<AxiosResponse<Blob>>;
},
addProjectSession(projectId: string, sessionId: string) {
return typed<any>(
openApiV1.addChatProjectSession({
+40 -3
View File
@@ -532,6 +532,12 @@
:is-dark="isDark"
/>
<RefsSidebar v-model="refsSidebarOpen" :refs="selectedRefs" />
<WorkspaceFilesPanel
:model-value="chatHeader.workspaceFilesOpen"
:project-id="activeProject?.project_id || ''"
:project-title="activeProject?.title || ''"
@update:model-value="chatHeader.SET_WORKSPACE_FILES_OPEN"
/>
</div>
</template>
@@ -576,6 +582,7 @@ import ChatUILogo from "@/components/chat/ChatUILogo.vue";
import type { RegenerateModelSelection } from "@/components/chat/RegenerateMenu.vue";
import ReasoningSidebar from "@/components/chat/ReasoningSidebar.vue";
import ThreadPanel from "@/components/chat/ThreadPanel.vue";
import WorkspaceFilesPanel from "@/components/chat/WorkspaceFilesPanel.vue";
import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue";
import { useSessions, type Session } from "@/composables/useSessions";
import {
@@ -834,6 +841,14 @@ const selectedProject = computed(
(project) => project.project_id === selectedProjectId.value,
) || null,
);
const activeProject = computed(() => {
if (isProviderWorkspace.value) return null;
if (selectedProject.value) return selectedProject.value;
const projectId = sessionProject.value?.project_id;
return (
projects.value.find((project) => project.project_id === projectId) || null
);
});
const isEmptyChat = computed(
() =>
!isProviderWorkspace.value &&
@@ -923,13 +938,31 @@ function getSelectedProviderSelection() {
provide("isDark", isDark);
watch(
[chatHeaderTitle, chatHeaderSubtitle],
([title, subtitle]) => {
chatHeader.SET_CONTEXT({ title, subtitle });
[chatHeaderTitle, chatHeaderSubtitle, activeProject],
([title, subtitle, project]) => {
chatHeader.SET_CONTEXT({
title,
subtitle,
projectId: project?.project_id,
});
},
{ immediate: true },
);
watch(
() => chatHeader.workspaceFilesOpen,
(open) => {
if (!open) return;
threadSelection.visible = false;
threadPanelOpen.value = false;
activeThread.value = null;
reasoningPanelOpen.value = false;
activeReasoningTarget.value = null;
refsSidebarOpen.value = false;
selectedRefs.value = null;
},
);
onMounted(async () => {
loadingSessions.value = true;
try {
@@ -998,6 +1031,7 @@ function closeSecondaryPanels() {
activeReasoningTarget.value = null;
refsSidebarOpen.value = false;
selectedRefs.value = null;
chatHeader.SET_WORKSPACE_FILES_OPEN(false);
}
function showChatWorkspace() {
@@ -1537,6 +1571,7 @@ async function createThreadFromSelection() {
}
function openThreadPanel(thread: ChatThread) {
chatHeader.SET_WORKSPACE_FILES_OPEN(false);
reasoningPanelOpen.value = false;
activeReasoningTarget.value = null;
refsSidebarOpen.value = false;
@@ -1545,6 +1580,7 @@ function openThreadPanel(thread: ChatThread) {
}
function openRefsSidebar(refs: unknown) {
chatHeader.SET_WORKSPACE_FILES_OPEN(false);
threadPanelOpen.value = false;
activeThread.value = null;
reasoningPanelOpen.value = false;
@@ -1558,6 +1594,7 @@ function openReasoningPanel(payload: {
message: ChatRecord;
blockIndex: number;
}) {
chatHeader.SET_WORKSPACE_FILES_OPEN(false);
threadPanelOpen.value = false;
activeThread.value = null;
refsSidebarOpen.value = false;
@@ -0,0 +1,739 @@
<template>
<transition name="workspace-panel-slide">
<aside v-if="modelValue" class="workspace-files-panel">
<div class="workspace-toolbar">
<div class="workspace-filter">
<Search :size="17" />
<input
v-model="filterQuery"
type="search"
:placeholder="tm('workspaceFiles.filter')"
/>
<button
v-if="filterQuery"
type="button"
:aria-label="tm('workspaceFiles.clearFilter')"
@click="filterQuery = ''"
>
<X :size="15" />
</button>
</div>
<div class="workspace-files-actions">
<v-btn
icon
size="small"
variant="text"
:title="tm('workspaceFiles.refresh')"
:loading="rootLoading"
@click="refreshTree"
>
<RotateCw :size="17" />
</v-btn>
<v-btn
icon
size="small"
variant="text"
:title="tm('workspaceFiles.close')"
@click="close"
>
<X :size="18" />
</v-btn>
</div>
</div>
<v-alert
v-if="treeError"
class="workspace-error"
density="compact"
type="error"
variant="tonal"
>
{{ treeError }}
</v-alert>
<div
class="workspace-tree"
:class="{ 'workspace-tree--with-preview': selectedFilePath }"
>
<div v-if="rootLoading && !rootEntries.length" class="workspace-state">
<v-progress-circular indeterminate size="24" width="2" />
</div>
<div v-else-if="!visibleEntries.length" class="workspace-state">
{{
filterQuery
? tm("workspaceFiles.noMatches")
: tm("workspaceFiles.empty")
}}
</div>
<template v-else>
<button
v-for="{ entry, depth } in visibleEntries"
:key="entry.path"
class="workspace-tree-row"
:class="{
'workspace-tree-row--active': selectedFilePath === entry.path,
}"
:style="{ paddingLeft: `${10 + depth * 18}px` }"
type="button"
:title="entry.path"
@click="openEntry(entry)"
>
<span class="workspace-tree-chevron">
<v-progress-circular
v-if="entry.loading"
indeterminate
size="14"
width="2"
/>
<ChevronDown
v-else-if="entry.type === 'directory' && entry.expanded"
:size="16"
/>
<ChevronRight v-else-if="entry.type === 'directory'" :size="16" />
</span>
<FolderOpen
v-if="entry.type === 'directory' && entry.expanded"
:size="17"
class="workspace-folder-icon"
/>
<Folder
v-else-if="entry.type === 'directory'"
:size="17"
class="workspace-folder-icon"
/>
<FileText v-else :size="16" class="workspace-file-icon" />
<span class="workspace-tree-name">{{ entry.name }}</span>
<span v-if="entry.type === 'file'" class="workspace-tree-size">
{{ formatSize(entry.size) }}
</span>
</button>
</template>
</div>
<section v-if="selectedFilePath" class="workspace-preview">
<header class="workspace-preview-header">
<div class="workspace-preview-path" :title="selectedFilePath">
{{ selectedFilePath }}
</div>
<div class="workspace-preview-actions">
<v-btn
icon
size="x-small"
variant="text"
:title="tm('workspaceFiles.download')"
:loading="fileDownloading"
@click="downloadSelectedFile"
>
<Download :size="16" />
</v-btn>
<v-btn
icon
size="x-small"
variant="text"
:title="tm('workspaceFiles.dialogPreview')"
@click="previewDialog = true"
>
<Maximize2 :size="15" />
</v-btn>
<v-btn
icon
size="x-small"
variant="text"
:title="tm('workspaceFiles.closePreview')"
@click="clearPreview"
>
<X :size="16" />
</v-btn>
</div>
</header>
<div v-if="fileLoading" class="workspace-preview-state">
<v-progress-circular indeterminate size="24" width="2" />
</div>
<div v-else-if="fileError" class="workspace-preview-state">
{{ fileError }}
</div>
<pre
v-else
class="workspace-preview-content"
><code>{{ fileContent }}</code></pre>
</section>
<v-dialog v-model="previewDialog" max-width="1000" width="calc(100% - 32px)">
<v-card class="workspace-dialog-preview">
<header class="workspace-dialog-preview-header">
<div class="workspace-preview-path" :title="selectedFilePath">
{{ selectedFilePath }}
</div>
<div class="workspace-preview-actions">
<v-btn
icon
variant="text"
:title="tm('workspaceFiles.download')"
:loading="fileDownloading"
@click="downloadSelectedFile"
>
<Download :size="18" />
</v-btn>
<v-btn
icon
variant="text"
:title="tm('workspaceFiles.closeDialogPreview')"
@click="previewDialog = false"
>
<X :size="20" />
</v-btn>
</div>
</header>
<div v-if="fileLoading" class="workspace-preview-state">
<v-progress-circular indeterminate size="28" width="2" />
</div>
<div v-else-if="fileError" class="workspace-preview-state">
{{ fileError }}
</div>
<pre
v-else
class="workspace-preview-content workspace-dialog-preview-content"
><code>{{ fileContent }}</code></pre>
</v-card>
</v-dialog>
</aside>
</transition>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import {
ChevronDown,
ChevronRight,
Download,
FileText,
Folder,
FolderOpen,
Maximize2,
RotateCw,
Search,
X,
} from "@lucide/vue";
import { chatApi } from "@/api/v1";
import { useModuleI18n } from "@/i18n/composables";
interface WorkspaceEntry {
name: string;
path: string;
type: "directory" | "file";
size: number;
readable: boolean;
children?: WorkspaceEntry[];
expanded?: boolean;
loading?: boolean;
}
const props = defineProps<{
modelValue: boolean;
projectId: string;
projectTitle?: string;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
}>();
const { tm } = useModuleI18n("features/chat");
const rootEntries = ref<WorkspaceEntry[]>([]);
const loadedProjectId = ref("");
const rootLoading = ref(false);
const treeError = ref("");
const filterQuery = ref("");
const selectedFilePath = ref("");
const fileContent = ref("");
const fileLoading = ref(false);
const fileError = ref("");
const fileDownloading = ref(false);
const previewDialog = ref(false);
const treeGeneration = ref(0);
const fileGeneration = ref(0);
const visibleEntries = computed(() => {
const query = filterQuery.value.trim().toLocaleLowerCase();
const flattened: Array<{ entry: WorkspaceEntry; depth: number }> = [];
const visit = (entries: WorkspaceEntry[], depth: number) => {
entries.forEach((entry) => {
if (
!query ||
entry.type === "directory" ||
entry.name.toLocaleLowerCase().includes(query)
) {
flattened.push({ entry, depth });
}
if (entry.type === "directory" && entry.expanded && entry.children) {
visit(entry.children, depth + 1);
}
});
};
visit(rootEntries.value, 0);
return flattened;
});
watch(
() => [props.modelValue, props.projectId] as const,
async ([open, projectId], previous) => {
if (!open || !projectId) return;
const previousProjectId = previous?.[1];
if (
projectId !== previousProjectId ||
loadedProjectId.value !== projectId
) {
resetPanel();
await loadDirectory("");
}
},
{ immediate: true },
);
function close() {
emit("update:modelValue", false);
}
function resetPanel() {
treeGeneration.value += 1;
rootEntries.value = [];
loadedProjectId.value = "";
rootLoading.value = false;
treeError.value = "";
filterQuery.value = "";
clearPreview();
}
function clearPreview() {
fileGeneration.value += 1;
previewDialog.value = false;
selectedFilePath.value = "";
fileContent.value = "";
fileError.value = "";
fileLoading.value = false;
}
async function refreshTree() {
resetPanel();
if (props.projectId) {
await loadDirectory("");
}
}
async function loadDirectory(path: string, parent?: WorkspaceEntry) {
if (!props.projectId || rootLoading.value || parent?.loading) return;
const projectId = props.projectId;
const generation = treeGeneration.value;
if (parent) {
parent.loading = true;
} else {
rootLoading.value = true;
}
treeError.value = "";
try {
const response = await chatApi.listProjectWorkspaceFiles(projectId, path);
if (generation !== treeGeneration.value || projectId !== props.projectId) {
return;
}
if (response.data?.status !== "ok") {
throw new Error(
response.data?.message || tm("workspaceFiles.loadFailed"),
);
}
const entries = (
(response.data?.data?.entries || []) as WorkspaceEntry[]
).map((entry) => ({ ...entry }));
if (parent) {
parent.children = entries;
} else {
rootEntries.value = entries;
loadedProjectId.value = projectId;
}
} catch (error) {
if (generation !== treeGeneration.value || projectId !== props.projectId) {
return;
}
treeError.value =
(error as any)?.response?.data?.message ||
(error as Error)?.message ||
tm("workspaceFiles.loadFailed");
if (parent) {
parent.expanded = false;
}
} finally {
if (generation === treeGeneration.value && projectId === props.projectId) {
if (parent) {
parent.loading = false;
} else {
rootLoading.value = false;
}
}
}
}
async function openEntry(entry: WorkspaceEntry) {
if (entry.type === "directory") {
entry.expanded = !entry.expanded;
if (entry.expanded && !entry.children) {
await loadDirectory(entry.path, entry);
}
return;
}
selectedFilePath.value = entry.path;
fileContent.value = "";
fileError.value = "";
fileLoading.value = false;
fileGeneration.value += 1;
const generation = fileGeneration.value;
const projectId = props.projectId;
if (!entry.readable) {
fileError.value = tm("workspaceFiles.tooLarge");
return;
}
fileLoading.value = true;
try {
const response = await chatApi.getProjectWorkspaceFile(
projectId,
entry.path,
);
if (generation !== fileGeneration.value || projectId !== props.projectId) {
return;
}
if (response.data?.status !== "ok") {
throw new Error(
response.data?.message || tm("workspaceFiles.previewFailed"),
);
}
fileContent.value = response.data?.data?.content || "";
} catch (error) {
if (generation !== fileGeneration.value || projectId !== props.projectId) {
return;
}
fileError.value =
(error as any)?.response?.data?.message ||
(error as Error)?.message ||
tm("workspaceFiles.previewFailed");
} finally {
if (generation === fileGeneration.value && projectId === props.projectId) {
fileLoading.value = false;
}
}
}
async function downloadSelectedFile() {
if (
!props.projectId ||
!selectedFilePath.value ||
fileDownloading.value
) {
return;
}
fileDownloading.value = true;
try {
const response = await chatApi.downloadProjectWorkspaceFile(
props.projectId,
selectedFilePath.value,
);
const url = URL.createObjectURL(response.data);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = selectedFilePath.value.split("/").pop() || "download";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
} catch {
fileError.value = tm("workspaceFiles.downloadFailed");
} finally {
fileDownloading.value = false;
}
}
function formatSize(size: number) {
if (!Number.isFinite(size) || size < 1024) return `${size || 0} B`;
if (size < 1024 * 1024) return `${Math.round(size / 102.4) / 10} KB`;
return `${Math.round(size / (1024 * 102.4)) / 10} MB`;
}
</script>
<style scoped>
.workspace-files-panel {
width: clamp(340px, 29vw, 440px);
height: calc(100% - var(--chat-panel-top-offset, 0px));
margin-top: var(--chat-panel-top-offset, 0px);
border-left: 1px solid var(--chat-border, rgba(var(--v-border-color), 0.14));
background: var(--chat-page-bg, rgb(var(--v-theme-surface)));
color: rgb(var(--v-theme-on-surface));
display: flex;
flex-direction: column;
flex: 0 0 auto;
min-width: 0;
}
.workspace-panel-slide-enter-active,
.workspace-panel-slide-leave-active {
transition:
transform 0.2s ease,
opacity 0.2s ease;
}
.workspace-panel-slide-enter-from,
.workspace-panel-slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
.workspace-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.workspace-toolbar {
min-height: 52px;
padding: 8px 8px 8px 12px;
display: flex;
align-items: center;
gap: 4px;
}
.workspace-files-actions {
min-width: 0;
display: flex;
align-items: center;
flex: 0 0 auto;
gap: 2px;
}
.workspace-filter {
height: 36px;
min-width: 0;
flex: 1;
padding: 0 10px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.14);
border-radius: 9px;
display: flex;
align-items: center;
gap: 8px;
color: rgba(var(--v-theme-on-surface), 0.52);
}
.workspace-filter:focus-within {
border-color: rgba(var(--v-theme-on-surface), 0.32);
}
.workspace-filter input {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
background: transparent;
color: rgb(var(--v-theme-on-surface));
font: inherit;
font-size: 13px;
}
.workspace-filter input::-webkit-search-cancel-button {
display: none;
}
.workspace-filter button {
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: 5px;
background: transparent;
color: inherit;
display: grid;
place-items: center;
cursor: pointer;
}
.workspace-filter button:hover {
background: rgba(var(--v-theme-on-surface), 0.08);
}
.workspace-error {
margin: 0 12px 8px;
font-size: 12px;
}
.workspace-tree {
min-height: 0;
flex: 1 1 auto;
overflow: auto;
padding: 2px 8px 10px;
display: flex;
flex-direction: column;
gap: 2px;
}
.workspace-tree--with-preview {
flex: 0 1 42%;
min-height: 140px;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
}
.workspace-state,
.workspace-preview-state {
min-height: 110px;
padding: 20px;
display: grid;
place-items: center;
color: rgba(var(--v-theme-on-surface), 0.5);
font-size: 13px;
text-align: center;
}
.workspace-tree-row {
width: 100%;
height: 30px;
padding-right: 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: inherit;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
text-align: left;
}
.workspace-tree-row:hover,
.workspace-tree-row--active {
background: rgba(var(--v-theme-on-surface), 0.07);
}
.workspace-tree-chevron {
width: 16px;
height: 18px;
flex: 0 0 16px;
display: grid;
place-items: center;
color: rgba(var(--v-theme-on-surface), 0.58);
}
.workspace-folder-icon {
flex: 0 0 auto;
color: #d5a84a;
}
.workspace-file-icon {
flex: 0 0 auto;
color: rgba(var(--v-theme-on-surface), 0.58);
}
.workspace-tree-name {
min-width: 0;
flex: 1;
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-tree-size {
flex: 0 0 auto;
color: rgba(var(--v-theme-on-surface), 0.42);
font-size: 10px;
}
.workspace-preview {
min-height: 0;
flex: 1 1 58%;
display: flex;
flex-direction: column;
}
.workspace-preview-header {
min-height: 40px;
padding: 4px 8px 4px 14px;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.workspace-preview-path {
min-width: 0;
overflow: hidden;
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-preview-actions {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 2px;
}
.workspace-preview-state {
flex: 1;
}
.workspace-preview-content {
min-height: 0;
flex: 1;
margin: 0;
overflow: auto;
padding: 12px 14px 20px;
background: rgba(var(--v-theme-on-surface), 0.025);
color: rgb(var(--v-theme-on-surface));
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.55;
tab-size: 2;
white-space: pre;
}
.workspace-dialog-preview {
height: min(78vh, 760px);
background: var(--chat-page-bg, rgb(var(--v-theme-surface)));
color: rgb(var(--v-theme-on-surface));
display: flex;
flex-direction: column;
}
.workspace-dialog-preview-header {
min-height: 56px;
padding: 6px 10px 6px 20px;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.workspace-dialog-preview-content {
padding: 20px 24px 32px;
}
@media (max-width: 760px) {
.workspace-files-panel {
position: fixed;
inset: 0;
z-index: 1300;
width: 100vw;
height: 100dvh;
margin-top: 0;
border-left: 0;
}
.workspace-toolbar {
min-height: calc(52px + env(safe-area-inset-top));
padding: calc(8px + env(safe-area-inset-top)) 8px 8px 12px;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
}
.workspace-tree--with-preview {
min-height: 160px;
}
}
</style>
@@ -144,6 +144,24 @@
"noProjects": "No projects",
"confirmDelete": "Are you sure you want to delete project \"{title}\"? Conversations in this project will not be deleted."
},
"workspaceFiles": {
"title": "Workspace Files",
"open": "Open workspace files",
"close": "Close workspace files",
"refresh": "Refresh file tree",
"filter": "Filter files...",
"clearFilter": "Clear file filter",
"empty": "This workspace is empty",
"noMatches": "No matching files",
"loadFailed": "Failed to load workspace files",
"previewFailed": "Failed to read this file",
"tooLarge": "This file is too large to preview",
"download": "Download file",
"downloadFailed": "Failed to download this file",
"dialogPreview": "Open larger preview",
"closePreview": "Close file preview",
"closeDialogPreview": "Close larger preview"
},
"time": {
"today": "Today",
"yesterday": "Yesterday"
@@ -144,6 +144,24 @@
"noProjects": "Проектов пока нет",
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
},
"workspaceFiles": {
"title": "Файлы рабочей области",
"open": "Открыть файлы рабочей области",
"close": "Закрыть файлы рабочей области",
"refresh": "Обновить дерево файлов",
"filter": "Фильтр файлов...",
"clearFilter": "Очистить фильтр файлов",
"empty": "Рабочая область пуста",
"noMatches": "Подходящие файлы не найдены",
"loadFailed": "Не удалось загрузить файлы рабочей области",
"previewFailed": "Не удалось прочитать файл",
"tooLarge": "Файл слишком большой для предпросмотра",
"download": "Скачать файл",
"downloadFailed": "Не удалось скачать файл",
"dialogPreview": "Открыть увеличенный просмотр",
"closePreview": "Закрыть предпросмотр файла",
"closeDialogPreview": "Закрыть увеличенный просмотр"
},
"time": {
"today": "Сегодня",
"yesterday": "Вчера"
@@ -144,6 +144,24 @@
"noProjects": "暂无项目",
"confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。"
},
"workspaceFiles": {
"title": "工作区文件",
"open": "打开工作区文件",
"close": "关闭工作区文件",
"refresh": "刷新文件树",
"filter": "筛选文件...",
"clearFilter": "清除文件筛选",
"empty": "工作区暂无文件",
"noMatches": "没有匹配的文件",
"loadFailed": "加载工作区文件失败",
"previewFailed": "读取文件失败",
"tooLarge": "文件过大,无法预览",
"download": "下载文件",
"downloadFailed": "下载文件失败",
"dialogPreview": "放大预览",
"closePreview": "关闭文件预览",
"closeDialogPreview": "关闭放大预览"
},
"time": {
"today": "今天",
"yesterday": "昨天"
@@ -10,7 +10,7 @@ import { MarkdownRender, enableKatex, enableMermaid } from "markstream-vue";
import "markstream-vue/index.css";
import "katex/dist/katex.min.css";
import "highlight.js/styles/github.css";
import { useI18n } from "@/i18n/composables";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { router } from "@/router";
import { useRoute } from "vue-router";
import { useDisplay, useTheme } from "vuetify";
@@ -31,6 +31,7 @@ const chatHeader = useChatHeaderStore();
const theme = useTheme();
const { lgAndUp } = useDisplay();
const { t } = useI18n();
const { tm } = useModuleI18n("features/chat");
const route = useRoute();
const LAST_BOT_ROUTE_KEY = "astrbot:last_bot_route";
const LAST_CHAT_ROUTE_KEY = "astrbot:last_chat_route";
@@ -1141,6 +1142,28 @@ onMounted(async () => {
</div>
<div class="header-actions" :class="{ 'chat-header-actions': isChatPath }">
<v-btn
v-if="isChatPath && chatHeader.projectId"
class="chat-action-btn workspace-files-trigger"
:class="{
'workspace-files-trigger--active': chatHeader.workspaceFilesOpen,
}"
variant="text"
size="small"
rounded="sm"
icon
:title="tm('workspaceFiles.open')"
@click="chatHeader.TOGGLE_WORKSPACE_FILES"
>
<v-icon size="20">
{{
chatHeader.workspaceFilesOpen
? "mdi-folder-open-outline"
: "mdi-folder-outline"
}}
</v-icon>
</v-btn>
<!-- Bot/Chat mode switch - single button, hidden in chat mobile menu -->
<v-btn
v-if="!isChatPath || !$vuetify.display.smAndDown"
@@ -2122,6 +2145,14 @@ onMounted(async () => {
margin-right: 0;
}
.workspace-files-trigger {
color: rgb(var(--v-theme-on-surface));
}
.workspace-files-trigger--active {
background: rgba(var(--v-theme-on-surface), 0.08) !important;
}
.mode-switch-btn {
margin: 0;
border: 0;
+22 -1
View File
@@ -4,16 +4,37 @@ export const useChatHeaderStore = defineStore("chatHeader", {
state: () => ({
title: "",
subtitle: "",
projectId: "",
workspaceFilesOpen: false,
}),
actions: {
SET_CONTEXT(payload: { title?: string; subtitle?: string }) {
SET_CONTEXT(payload: {
title?: string;
subtitle?: string;
projectId?: string;
}) {
const nextProjectId = payload.projectId || "";
if (this.projectId !== nextProjectId) {
this.workspaceFilesOpen = false;
}
this.title = payload.title || "";
this.subtitle = payload.subtitle || "";
this.projectId = nextProjectId;
},
TOGGLE_WORKSPACE_FILES() {
if (this.projectId) {
this.workspaceFilesOpen = !this.workspaceFilesOpen;
}
},
SET_WORKSPACE_FILES_OPEN(open: boolean) {
this.workspaceFilesOpen = Boolean(open && this.projectId);
},
CLEAR_CONTEXT() {
this.title = "";
this.subtitle = "";
this.projectId = "";
this.workspaceFilesOpen = false;
},
},
});
+69
View File
@@ -1497,6 +1497,75 @@ paths:
"200":
$ref: "#/components/responses/Ok"
/api/v1/chat/projects/{project_id}/workspace/files:
get:
tags: [Chat]
summary: List files in a ChatUI project workspace directory
operationId: listChatProjectWorkspaceFiles
x-astrbot-scope: chat
parameters:
- name: project_id
in: path
required: true
schema:
type: string
- name: path
in: query
required: false
schema:
type: string
default: ""
responses:
"200":
$ref: "#/components/responses/Ok"
/api/v1/chat/projects/{project_id}/workspace/file:
get:
tags: [Chat]
summary: Read a file in a ChatUI project workspace
operationId: getChatProjectWorkspaceFile
x-astrbot-scope: chat
parameters:
- name: project_id
in: path
required: true
schema:
type: string
- name: path
in: query
required: true
schema:
type: string
responses:
"200":
$ref: "#/components/responses/Ok"
/api/v1/chat/projects/{project_id}/workspace/file/download:
get:
tags: [Chat]
summary: Download a file from a ChatUI project workspace
operationId: downloadChatProjectWorkspaceFile
x-astrbot-scope: chat
parameters:
- name: project_id
in: path
required: true
schema:
type: string
- name: path
in: query
required: true
schema:
type: string
responses:
"200":
description: Workspace file content
content:
application/octet-stream:
schema:
type: string
format: binary
/api/v1/chat/projects/{project_id}/sessions/{session_id}:
post:
tags: [Chat]
+184 -3
View File
@@ -1,3 +1,6 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from astrbot.dashboard.services.chatui_project_service import (
@@ -60,9 +63,7 @@ def test_custom_workspace_rejects_file_path(tmp_path, monkeypatch):
)
def test_custom_workspace_relative_path_uses_astrbot_workspaces(
tmp_path, monkeypatch
):
def test_custom_workspace_relative_path_uses_astrbot_workspaces(tmp_path, monkeypatch):
"""Relative custom workspace paths should resolve under AstrBot workspaces."""
relative_workspace = tmp_path / "relative-workspace"
relative_workspace.mkdir()
@@ -140,3 +141,183 @@ def test_custom_workspace_accepts_absolute_path_outside_workspaces(
assert workspace_type == "custom"
assert workspace_path == str(outside_workspace)
@pytest.fixture
def workspace_service(tmp_path, monkeypatch):
"""Create a project service backed by a temporary workspace.
Args:
tmp_path: Temporary workspace root.
monkeypatch: Pytest monkeypatch fixture.
Returns:
Project service configured with an owned project.
"""
project = SimpleNamespace(
project_id="project-1",
creator="alice",
workspace_type="custom",
workspace_path=str(tmp_path),
)
db = SimpleNamespace(get_chatui_project_by_id=AsyncMock(return_value=project))
monkeypatch.setattr(
"astrbot.dashboard.services.chatui_project_service.resolve_project_workspace_root",
lambda _project, *, fallback_umo: tmp_path,
)
return ChatUIProjectService(db)
@pytest.mark.asyncio
async def test_list_workspace_files_is_sorted_and_idempotent(
tmp_path,
workspace_service,
):
"""Workspace listing should be stable, read-only, and directory-first."""
(tmp_path / "z-dir").mkdir()
(tmp_path / "a.txt").write_text("alpha", encoding="utf-8")
(tmp_path / "b.txt").write_text("beta", encoding="utf-8")
first = await workspace_service.list_workspace_files("alice", "project-1")
second = await workspace_service.list_workspace_files("alice", "project-1")
assert first == second
assert [entry["name"] for entry in first["entries"]] == [
"z-dir",
"a.txt",
"b.txt",
]
assert first["entries"][1]["readable"] is True
assert (tmp_path / "a.txt").read_text(encoding="utf-8") == "alpha"
@pytest.mark.asyncio
async def test_get_workspace_file_reads_utf8_text(tmp_path, workspace_service):
"""Workspace file reads should return content without changing the file."""
target = tmp_path / "notes.md"
target.write_text("你好,workspace", encoding="utf-8")
result = await workspace_service.get_workspace_file(
"alice",
"project-1",
"notes.md",
)
assert result == {
"path": "notes.md",
"content": "你好,workspace",
"size": len("你好,workspace".encode()),
}
assert target.read_text(encoding="utf-8") == "你好,workspace"
@pytest.mark.asyncio
async def test_get_workspace_file_allows_nested_path(tmp_path, workspace_service):
"""Workspace reads should preserve legitimate nested file access."""
nested_dir = tmp_path / "docs"
nested_dir.mkdir()
target = nested_dir / "notes.md"
target.write_text("nested", encoding="utf-8")
result = await workspace_service.get_workspace_file(
"alice",
"project-1",
"docs/notes.md",
)
assert result["path"] == "docs/notes.md"
assert result["content"] == "nested"
@pytest.mark.asyncio
async def test_get_workspace_file_location_supports_binary_download(
tmp_path,
workspace_service,
):
"""Workspace downloads should resolve binary files without changing them."""
target = tmp_path / "archive.bin"
target.write_bytes(b"\xff\xfe\x00")
workspace_root, result = await workspace_service.get_workspace_file_location(
"alice",
"project-1",
"archive.bin",
)
assert workspace_root == tmp_path
assert result == target
assert result.read_bytes() == b"\xff\xfe\x00"
@pytest.mark.asyncio
async def test_workspace_paths_reject_traversal(workspace_service):
"""Workspace APIs should reject relative paths that escape the project."""
with pytest.raises(ChatUIProjectServiceError, match="Invalid workspace path"):
await workspace_service.list_workspace_files(
"alice",
"project-1",
"../outside",
)
with pytest.raises(ChatUIProjectServiceError, match="Invalid workspace path"):
await workspace_service.get_workspace_file(
"alice",
"project-1",
"../outside.txt",
)
@pytest.mark.asyncio
async def test_get_workspace_file_rejects_binary_text(tmp_path, workspace_service):
"""Workspace preview should reject files that are not valid UTF-8."""
(tmp_path / "binary.dat").write_bytes(b"\xff\xfe\x00")
with pytest.raises(ChatUIProjectServiceError, match="not valid UTF-8"):
await workspace_service.get_workspace_file(
"alice",
"project-1",
"binary.dat",
)
@pytest.mark.asyncio
async def test_workspace_file_rejects_symlink_escape(
tmp_path,
workspace_service,
):
"""Workspace reads should not follow a symlink outside the project root."""
outside_file = tmp_path.parent / f"{tmp_path.name}-outside.txt"
outside_file.write_text("outside", encoding="utf-8")
(tmp_path / "outside-link.txt").symlink_to(outside_file)
with pytest.raises(
ChatUIProjectServiceError,
match="escapes project directory",
):
await workspace_service.get_workspace_file(
"alice",
"project-1",
"outside-link.txt",
)
@pytest.mark.asyncio
async def test_workspace_file_rejects_symlink_directory_escape(
tmp_path,
workspace_service,
):
"""Workspace reads should reject an escaping symlink in any path segment."""
outside_dir = tmp_path.parent / f"{tmp_path.name}-outside-dir"
outside_dir.mkdir()
(outside_dir / "secret.txt").write_text("outside", encoding="utf-8")
(tmp_path / "outside-link").symlink_to(outside_dir, target_is_directory=True)
with pytest.raises(
ChatUIProjectServiceError,
match="escapes project directory",
):
await workspace_service.get_workspace_file(
"alice",
"project-1",
"outside-link/secret.txt",
)