mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-22 06:56:32 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user