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
from fastapi import HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
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.storage.postgres.models_business import User
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
from yuxi.utils.paths import WORKSPACE_DIR_NAME
EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"}
def _workspace_root(user: User) -> Path:
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:
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)
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)
return resolved_root
@ -138,8 +143,17 @@ async def read_workspace_file_content(*, path: str, current_user: User) -> dict:
"supported": supported,
"message": message,
}
try:
content = raw_content.decode("utf-8")
except UnicodeDecodeError:
return {
"content": None,
"preview_type": "unsupported",
"supported": False,
"message": "当前文件不是 UTF-8 文本,暂不支持预览",
}
return {
"content": raw_content.decode("utf-8"),
"content": content,
"preview_type": preview_type,
"supported": supported,
"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") == "保留已有内容"
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
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))

View File

@ -240,7 +240,17 @@
<script setup>
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 hljs from 'highlight.js/lib/common'
import 'md-editor-v3/lib/preview.css'
@ -311,8 +321,12 @@ const emit = defineEmits(['close', 'download', 'save'])
const themeStore = useThemeStore()
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
const closeTitle = computed(() => (props.closeVariant === 'collapse-right' ? '收起预览面板' : '关闭预览'))
const closeIconComponent = computed(() => (props.closeVariant === 'collapse-right' ? PanelRightClose : X))
const closeTitle = computed(() =>
props.closeVariant === 'collapse-right' ? '收起预览面板' : '关闭预览'
)
const closeIconComponent = computed(() =>
props.closeVariant === 'collapse-right' ? PanelRightClose : X
)
const htmlPreviewMode = ref('render')
const editMode = ref('preview')
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], () => {
if (isHtmlFile.value) {

View File

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

View File

@ -266,7 +266,11 @@ const renameChat = async (chatId) => {
.actions-mask {
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);
text-align: left;
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,
&:focus-visible {

View File

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

View File

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

View File

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

View File

@ -105,7 +105,11 @@
</button>
<template #overlay>
<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">
<Download :size="14" />
<span>下载</span>
@ -135,7 +139,12 @@
<script setup>
import { computed } from 'vue'
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({
entries: { type: Array, default: () => [] },
@ -179,7 +188,9 @@ const breadcrumbItems = 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(() => {
@ -207,7 +218,10 @@ const toggleEntrySelection = (path, checked) => {
} else {
nextSelectedPaths.delete(path)
}
emit('update:selectedPaths', [...nextSelectedPaths].filter((selectedPath) => entryPaths.value.includes(selectedPath)))
emit(
'update:selectedPaths',
[...nextSelectedPaths].filter((selectedPath) => entryPaths.value.includes(selectedPath))
)
}
</script>

View File

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

View File

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

View File

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