feat(workspace): 增强工作区功能,处理符号链接和非 UTF-8 文本文件的预览支持
This commit is contained in:
parent
5fb6af8cb8
commit
cbd265fea8
@ -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,
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user