feat(workspace): 增强工作区功能,处理符号链接和非 UTF-8 文本文件的预览支持

This commit is contained in:
Wenjie Zhang 2026-05-01 22:17:27 +08:00
parent 5fb6af8cb8
commit cbd265fea8
13 changed files with 145 additions and 54 deletions

View File

@ -11,24 +11,29 @@ from urllib.parse import quote
import aiofiles import aiofiles
from fastapi import HTTPException, UploadFile from fastapi import HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from yuxi.agents.backends.sandbox.paths import _global_user_data_dir, ensure_workspace_default_files from yuxi.agents.backends.sandbox.paths import _global_user_data_dir, ensure_workspace_default_files
from yuxi.services.viewer_filesystem_service import _detect_preview_type from yuxi.services.viewer_filesystem_service import _detect_preview_type
from yuxi.storage.postgres.models_business import User from yuxi.storage.postgres.models_business import User
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
from yuxi.utils.paths import WORKSPACE_DIR_NAME from yuxi.utils.paths import WORKSPACE_DIR_NAME
EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"} EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"}
def _workspace_root(user: User) -> Path: def _workspace_root(user: User) -> Path:
try: try:
root = _global_user_data_dir(str(user.id)) / WORKSPACE_DIR_NAME user_data_root = _global_user_data_dir(str(user.id)).resolve()
root = user_data_root / WORKSPACE_DIR_NAME
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=403, detail="Access denied") from exc raise HTTPException(status_code=403, detail="Access denied") from exc
if root.is_symlink():
raise HTTPException(status_code=403, detail="Access denied")
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
resolved_root = root.resolve() resolved_root = root.resolve()
try:
resolved_root.relative_to(user_data_root)
except ValueError as exc:
raise HTTPException(status_code=403, detail="Access denied") from exc
ensure_workspace_default_files(resolved_root) ensure_workspace_default_files(resolved_root)
return resolved_root return resolved_root
@ -138,8 +143,17 @@ async def read_workspace_file_content(*, path: str, current_user: User) -> dict:
"supported": supported, "supported": supported,
"message": message, "message": message,
} }
try:
content = raw_content.decode("utf-8")
except UnicodeDecodeError:
return {
"content": None,
"preview_type": "unsupported",
"supported": False,
"message": "当前文件不是 UTF-8 文本,暂不支持预览",
}
return { return {
"content": raw_content.decode("utf-8"), "content": content,
"preview_type": preview_type, "preview_type": preview_type,
"supported": supported, "supported": supported,
"message": message, "message": message,

View File

@ -43,6 +43,38 @@ def test_workspace_root_keeps_existing_agents_prompt_file(tmp_path: Path, monkey
assert agents_file.read_text(encoding="utf-8") == "保留已有内容" assert agents_file.read_text(encoding="utf-8") == "保留已有内容"
def test_workspace_root_rejects_symlink_root(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
user_root = tmp_path / "threads" / "shared" / "user-1"
outside_root = tmp_path / "outside"
user_root.mkdir(parents=True)
outside_root.mkdir()
(user_root / "workspace").symlink_to(outside_root, target_is_directory=True)
with pytest.raises(HTTPException) as exc_info:
svc._workspace_root(SimpleNamespace(id="user-1"))
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_read_workspace_file_content_returns_unsupported_for_non_utf8_text(
tmp_path: Path,
monkeypatch,
) -> None:
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
user = SimpleNamespace(id="user-1")
root = svc._workspace_root(user)
target = root / "bad.txt"
target.write_bytes(b"\xff\xfe\x00")
result = await svc.read_workspace_file_content(path="/bad.txt", current_user=user)
assert result["content"] is None
assert result["preview_type"] == "unsupported"
assert result["supported"] is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_write_workspace_file_content_updates_markdown_file(tmp_path: Path, monkeypatch) -> None: async def test_write_workspace_file_content_updates_markdown_file(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))

View File

@ -240,7 +240,17 @@
<script setup> <script setup>
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { Code2, Download, Eye, Globe, Maximize2, PanelRightClose, Pencil, Save, X } from 'lucide-vue-next' import {
Code2,
Download,
Eye,
Globe,
Maximize2,
PanelRightClose,
Pencil,
Save,
X
} from 'lucide-vue-next'
import { MdPreview } from 'md-editor-v3' import { MdPreview } from 'md-editor-v3'
import hljs from 'highlight.js/lib/common' import hljs from 'highlight.js/lib/common'
import 'md-editor-v3/lib/preview.css' import 'md-editor-v3/lib/preview.css'
@ -311,8 +321,12 @@ const emit = defineEmits(['close', 'download', 'save'])
const themeStore = useThemeStore() const themeStore = useThemeStore()
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light')) const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
const closeTitle = computed(() => (props.closeVariant === 'collapse-right' ? '收起预览面板' : '关闭预览')) const closeTitle = computed(() =>
const closeIconComponent = computed(() => (props.closeVariant === 'collapse-right' ? PanelRightClose : X)) props.closeVariant === 'collapse-right' ? '收起预览面板' : '关闭预览'
)
const closeIconComponent = computed(() =>
props.closeVariant === 'collapse-right' ? PanelRightClose : X
)
const htmlPreviewMode = ref('render') const htmlPreviewMode = ref('render')
const editMode = ref('preview') const editMode = ref('preview')
const draftContent = ref('') const draftContent = ref('')
@ -406,7 +420,9 @@ watch(
} }
) )
watch([() => props.filePath, () => props.file?.content, canEdit], syncDraftContent, { immediate: true }) watch([() => props.filePath, () => props.file?.content, canEdit], syncDraftContent, {
immediate: true
})
watch([() => props.filePath, () => props.file?.previewType, () => props.file?.content], () => { watch([() => props.filePath, () => props.file?.previewType, () => props.file?.content], () => {
if (isHtmlFile.value) { if (isHtmlFile.value) {

View File

@ -124,14 +124,7 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { import { ChevronsDownUp, ChevronsUpDown, Download, RefreshCw, Trash2, X } from 'lucide-vue-next'
ChevronsDownUp,
ChevronsUpDown,
Download,
RefreshCw,
Trash2,
X
} from 'lucide-vue-next'
import { Modal, message } from 'ant-design-vue' import { Modal, message } from 'ant-design-vue'
import FileTreeComponent from '@/components/FileTreeComponent.vue' import FileTreeComponent from '@/components/FileTreeComponent.vue'
import AgentFilePreview from '@/components/AgentFilePreview.vue' import AgentFilePreview from '@/components/AgentFilePreview.vue'

View File

@ -266,7 +266,11 @@ const renameChat = async (chatId) => {
.actions-mask { .actions-mask {
opacity: 1; opacity: 1;
background: linear-gradient(to right, transparent, color-mix(in srgb, var(--main-color) 6%, var(--gray-0)) 20px); background: linear-gradient(
to right,
transparent,
color-mix(in srgb, var(--main-color) 6%, var(--gray-0)) 20px
);
} }
} }

View File

@ -259,7 +259,10 @@ defineExpose({
background: var(--gray-0); background: var(--gray-0);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease; transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
&:hover, &:hover,
&:focus-visible { &:focus-visible {

View File

@ -469,7 +469,6 @@ onMounted(() => {
@import '@/assets/css/extensions.less'; @import '@/assets/css/extensions.less';
@import '@/assets/css/extension-detail.less'; @import '@/assets/css/extension-detail.less';
/* 工具列表样式 */ /* 工具列表样式 */
.tools-tab { .tools-tab {
.tools-toolbar { .tools-toolbar {
@ -607,7 +606,6 @@ onMounted(() => {
} }
} }
.mcp-detail { .mcp-detail {
.detail-content-wrapper { .detail-content-wrapper {
flex: 1; flex: 1;

View File

@ -323,10 +323,6 @@ const currentSkillStatusLabel = computed(() => {
return '已上传' return '已上传'
}) })
const currentSkillStatusTone = computed(() => {
return currentSkill.value?.status === 'update_available' ? 'warning' : 'default'
})
const canSave = computed(() => { const canSave = computed(() => {
if (!selectedPath.value || selectedIsDir.value) return false if (!selectedPath.value || selectedIsDir.value) return false
return fileContent.value !== originalFileContent.value return fileContent.value !== originalFileContent.value

View File

@ -1,9 +1,5 @@
<template> <template>
<div <div class="info-card" :class="{ 'info-card-disabled': disabled }" @click="$emit('click')">
class="info-card"
:class="{ 'info-card-disabled': disabled }"
@click="$emit('click')"
>
<div class="info-card-header"> <div class="info-card-header">
<div class="info-card-icon"> <div class="info-card-icon">
<slot name="icon"> <slot name="icon">
@ -12,9 +8,7 @@
</div> </div>
<div class="info-card-info"> <div class="info-card-info">
<span class="info-card-name" :title="title">{{ title }}</span> <span class="info-card-name" :title="title">{{ title }}</span>
<span v-if="subtitle" class="info-card-subtitle" :title="subtitle">{{ <span v-if="subtitle" class="info-card-subtitle" :title="subtitle">{{ subtitle }}</span>
subtitle
}}</span>
</div> </div>
<div class="info-card-status"> <div class="info-card-status">
<slot name="status" /> <slot name="status" />
@ -54,10 +48,7 @@
</div> </div>
</div> </div>
<div <div v-if="$slots.tags || (normalizedTags && normalizedTags.length > 0)" class="info-card-tags">
v-if="$slots.tags || (normalizedTags && normalizedTags.length > 0)"
class="info-card-tags"
>
<slot name="tags"> <slot name="tags">
<span <span
v-for="(tag, idx) in normalizedTags" v-for="(tag, idx) in normalizedTags"
@ -254,7 +245,6 @@ const normalizedTags = computed(() => {
} }
} }
.card-action-btn { .card-action-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@ -105,7 +105,11 @@
</button> </button>
<template #overlay> <template #overlay>
<a-menu> <a-menu>
<a-menu-item v-if="!entry.is_dir" key="download" @click="$emit('download-entry', entry)"> <a-menu-item
v-if="!entry.is_dir"
key="download"
@click="$emit('download-entry', entry)"
>
<span class="menu-item-content"> <span class="menu-item-content">
<Download :size="14" /> <Download :size="14" />
<span>下载</span> <span>下载</span>
@ -135,7 +139,12 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { Download, Folder, ListChecks, MoreHorizontal, Trash2 } from 'lucide-vue-next' import { Download, Folder, ListChecks, MoreHorizontal, Trash2 } from 'lucide-vue-next'
import { formatFileSize, formatRelativeTime, getFileIcon, getFileIconColor } from '@/utils/file_utils' import {
formatFileSize,
formatRelativeTime,
getFileIcon,
getFileIconColor
} from '@/utils/file_utils'
const props = defineProps({ const props = defineProps({
entries: { type: Array, default: () => [] }, entries: { type: Array, default: () => [] },
@ -179,7 +188,9 @@ const breadcrumbItems = computed(() => {
}) })
const allSelected = computed(() => { const allSelected = computed(() => {
return entryPaths.value.length > 0 && entryPaths.value.every((path) => selectedPathSet.value.has(path)) return (
entryPaths.value.length > 0 && entryPaths.value.every((path) => selectedPathSet.value.has(path))
)
}) })
const partiallySelected = computed(() => { const partiallySelected = computed(() => {
@ -207,7 +218,10 @@ const toggleEntrySelection = (path, checked) => {
} else { } else {
nextSelectedPaths.delete(path) nextSelectedPaths.delete(path)
} }
emit('update:selectedPaths', [...nextSelectedPaths].filter((selectedPath) => entryPaths.value.includes(selectedPath))) emit(
'update:selectedPaths',
[...nextSelectedPaths].filter((selectedPath) => entryPaths.value.includes(selectedPath))
)
} }
</script> </script>

View File

@ -17,7 +17,9 @@
<button <button
type="button" type="button"
class="workspace-nav-item secondary" class="workspace-nav-item secondary"
:class="{ active: activeKey === 'personal' && isSameOrChildPath(currentPath, savedArtifactsPath) }" :class="{
active: activeKey === 'personal' && isSameOrChildPath(currentPath, savedArtifactsPath)
}"
@click="$emit('select-path', savedArtifactsPath)" @click="$emit('select-path', savedArtifactsPath)"
> >
<Archive :size="15" /> <Archive :size="15" />
@ -75,7 +77,8 @@ const isSameOrChildPath = (path, targetPath) => {
const target = normalizePath(targetPath) const target = normalizePath(targetPath)
return current === target || current.startsWith(`${target}/`) return current === target || current.startsWith(`${target}/`)
} }
const isQuickAccessPath = (path) => quickAccessPaths.some((targetPath) => isSameOrChildPath(path, targetPath)) const isQuickAccessPath = (path) =>
quickAccessPaths.some((targetPath) => isSameOrChildPath(path, targetPath))
defineProps({ defineProps({
activeKey: { type: String, default: 'personal' }, activeKey: { type: String, default: 'personal' },

View File

@ -43,9 +43,7 @@
<div class="new-database-form"> <div class="new-database-form">
<!-- 知识库类型选择 --> <!-- 知识库类型选择 -->
<div class="form-section"> <div class="form-section">
<h3 class="section-title"> <h3 class="section-title">知识库类型<span class="required-mark">*</span></h3>
知识库类型<span class="required-mark">*</span>
</h3>
<div class="kb-type-cards"> <div class="kb-type-cards">
<div <div
v-for="(typeInfo, typeKey) in orderedKbTypes" v-for="(typeInfo, typeKey) in orderedKbTypes"
@ -156,7 +154,7 @@
/> />
</div> </div>
<!-- 隐私设置暂时隐藏 <!-- 隐私设置暂时隐藏
<h3 style="margin-top: 20px">隐私设置</h3> <h3 style="margin-top: 20px">隐私设置</h3>
<div class="privacy-config"> <div class="privacy-config">
<a-switch <a-switch

View File

@ -5,13 +5,22 @@
<a-button :disabled="activeSourceKey !== 'personal'" @click="openCreateDirectoryModal"> <a-button :disabled="activeSourceKey !== 'personal'" @click="openCreateDirectoryModal">
新建文件夹 新建文件夹
</a-button> </a-button>
<a-button :loading="uploadingFile" :disabled="activeSourceKey !== 'personal'" @click="openUploadFilePicker"> <a-button
:loading="uploadingFile"
:disabled="activeSourceKey !== 'personal'"
@click="openUploadFilePicker"
>
上传文件 上传文件
</a-button> </a-button>
</template> </template>
</PageHeader> </PageHeader>
<input ref="uploadInputRef" class="upload-input" type="file" @change="handleUploadInputChange" /> <input
ref="uploadInputRef"
class="upload-input"
type="file"
@change="handleUploadInputChange"
/>
<div class="workspace-shell" :class="{ 'is-sidebar-collapsed': sidebarCollapsed }"> <div class="workspace-shell" :class="{ 'is-sidebar-collapsed': sidebarCollapsed }">
<div v-if="!sidebarCollapsed" class="workspace-sidebar-slot"> <div v-if="!sidebarCollapsed" class="workspace-sidebar-slot">
@ -181,6 +190,7 @@ const uploadInputRef = ref(null)
const deletingPaths = ref([]) const deletingPaths = ref([])
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
const previewWidthPercent = ref(50) const previewWidthPercent = ref(50)
const previewRequestId = ref(0)
const INLINE_PREVIEW_MIN_WIDTH = 960 const INLINE_PREVIEW_MIN_WIDTH = 960
const useInlinePreview = computed(() => workspaceMainWidth.value >= INLINE_PREVIEW_MIN_WIDTH) const useInlinePreview = computed(() => workspaceMainWidth.value >= INLINE_PREVIEW_MIN_WIDTH)
@ -196,7 +206,11 @@ const workspaceMainStyle = computed(() => {
const filteredEntries = computed(() => { const filteredEntries = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase() const keyword = searchQuery.value.trim().toLowerCase()
if (!keyword) return entries.value if (!keyword) return entries.value
return entries.value.filter((entry) => String(entry.name || '').toLowerCase().includes(keyword)) return entries.value.filter((entry) =>
String(entry.name || '')
.toLowerCase()
.includes(keyword)
)
}) })
const selectedEntries = computed(() => { const selectedEntries = computed(() => {
@ -309,6 +323,8 @@ const handleSelectEntry = async (entry) => {
return return
} }
const requestId = previewRequestId.value + 1
previewRequestId.value = requestId
selectedEntry.value = entry selectedEntry.value = entry
revokePreviewObjectUrl() revokePreviewObjectUrl()
previewFile.value = { previewFile.value = {
@ -323,8 +339,10 @@ const handleSelectEntry = async (entry) => {
loadingPreview.value = true loadingPreview.value = true
try { try {
const response = await getWorkspaceFileContent(entry.path) const response = await getWorkspaceFileContent(entry.path)
if (previewRequestId.value !== requestId || selectedEntry.value?.path !== entry.path) return
previewFile.value = await normalizePreviewFile(entry, response) previewFile.value = await normalizePreviewFile(entry, response)
} catch (error) { } catch (error) {
if (previewRequestId.value !== requestId || selectedEntry.value?.path !== entry.path) return
console.warn('加载文件预览失败:', error) console.warn('加载文件预览失败:', error)
previewFile.value = { previewFile.value = {
...entry, ...entry,
@ -336,14 +354,18 @@ const handleSelectEntry = async (entry) => {
} }
message.error('加载文件预览失败') message.error('加载文件预览失败')
} finally { } finally {
loadingPreview.value = false if (previewRequestId.value === requestId) {
loadingPreview.value = false
}
} }
} }
const closePreview = () => { const closePreview = () => {
previewRequestId.value += 1
previewModalVisible.value = false previewModalVisible.value = false
selectedEntry.value = null selectedEntry.value = null
previewFile.value = null previewFile.value = null
loadingPreview.value = false
revokePreviewObjectUrl() revokePreviewObjectUrl()
} }
@ -430,7 +452,9 @@ const comparablePath = (path) => String(path || '/').replace(/\/$/, '') || '/'
const isSameOrChildPath = (path, targetPath) => { const isSameOrChildPath = (path, targetPath) => {
const normalizedPath = comparablePath(path) const normalizedPath = comparablePath(path)
const normalizedTargetPath = comparablePath(targetPath) const normalizedTargetPath = comparablePath(targetPath)
return normalizedPath === normalizedTargetPath || normalizedPath.startsWith(`${normalizedTargetPath}/`) return (
normalizedPath === normalizedTargetPath || normalizedPath.startsWith(`${normalizedTargetPath}/`)
)
} }
const confirmDeleteEntries = (targetEntries) => { const confirmDeleteEntries = (targetEntries) => {
@ -445,7 +469,10 @@ const confirmDeleteEntries = (targetEntries) => {
: firstEntry.is_dir : firstEntry.is_dir
? `确认删除文件夹「${firstEntry.name}」?` ? `确认删除文件夹「${firstEntry.name}」?`
: `确认删除文件「${firstEntry.name}」?`, : `确认删除文件「${firstEntry.name}」?`,
content: isBatch || firstEntry.is_dir ? '将删除文件夹及其所有内容,删除后不可恢复。' : '删除后不可恢复。', content:
isBatch || firstEntry.is_dir
? '将删除文件夹及其所有内容,删除后不可恢复。'
: '删除后不可恢复。',
okText: '删除', okText: '删除',
okType: 'danger', okType: 'danger',
cancelText: '取消', cancelText: '取消',
@ -458,7 +485,10 @@ const deleteEntries = async (targetEntries) => {
deletingPaths.value = paths deletingPaths.value = paths
try { try {
await Promise.all(paths.map((path) => deleteWorkspacePath(path))) await Promise.all(paths.map((path) => deleteWorkspacePath(path)))
if (selectedEntry.value && paths.some((path) => isSameOrChildPath(selectedEntry.value.path, path))) { if (
selectedEntry.value &&
paths.some((path) => isSameOrChildPath(selectedEntry.value.path, path))
) {
closePreview() closePreview()
} }
clearWorkspaceSelection() clearWorkspaceSelection()