feat: 新增源文件预览功能,支持图片和 PDF 格式,优化文件预览体验

This commit is contained in:
Wenjie Zhang 2026-03-26 05:25:43 +08:00
parent b09f4f426f
commit 93a7dccd07
3 changed files with 124 additions and 10 deletions

View File

@ -32,7 +32,8 @@
- 重构文档解析,统一文档解析体验,并新增 Parser 类
- 新增 LITE 模式启动,启动时不加载知识库、知识图谱相关模块,可以使用 make up-lite 快捷启动
- 新增沙盒环境,详见后续文档更新,统一沙盒虚拟路径前缀默认值为 `/home/gem/user-data`
- 新增基于沙盒的文件系统,前端工作台可以查看文件系统,支持预览、下载文件
- 新增基于沙盒的文件系统前端工作台可以查看文件系统支持预览文本、图片、PDF、下载文件
- 新增基于沙盒的知识库只读映射,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown
- 重构附件系统,直接集成在了沙盒文件系统中,附件上传后直接落盘到沙盒挂载目录
- 优化前端流式消息体验:新增通用 `useStreamSmoother` 调度层,统一平滑 Agent runs SSE、普通聊天流与审批恢复流中的 `loading` chunk
- 优化项目文档说明,并添加贡献指南
@ -40,13 +41,13 @@
- 新增 API Key 认证功能,支持外部系统通过 API Key 调用系统服务
- 新增 subagents 的支持,支持在 web 中添加 subagents以及两个内置的子智能体
- 新增内置Skills reporter并移除内置 Agent reporter数据库报表将由 Skills 完成
- 新增知识库 PDF、图片的预览功能
<!-- 添加到这里 -->
### 修复
- 为沙盒与 viewer 文件系统补齐知识库只读映射:新增 `/home/gem/kbs` 命名空间,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown并补充对应后端与 viewer 路由测试
- 修复前端工作台文件预览误读二进制内容的问题viewer 文件接口新增预览类型判定,支持 Markdown、纯文本、图片与 PDF 预览,其他格式在前端统一显示“不支持预览”,避免乱码
<!-- 添加到这里 -->

View File

@ -63,9 +63,30 @@
<div v-if="loading" class="loading-container">
<a-spin tip="正在加载文档内容..." />
</div>
<div v-else-if="file && hasContent" class="file-detail-content">
<div v-else-if="file && (hasContent || hasSourcePreview)" class="file-detail-content">
<div v-if="viewMode === 'source'" class="content-panel source-panel">
<div v-if="sourcePreviewLoading" class="loading-container">
<a-spin tip="正在加载源文件预览..." />
</div>
<div
v-else-if="sourcePreviewUrl && sourcePreviewType === 'image'"
class="source-preview-wrapper"
>
<img :src="sourcePreviewUrl" :alt="file?.filename || '源文件预览'" class="source-image" />
</div>
<iframe
v-else-if="sourcePreviewUrl && sourcePreviewType === 'pdf'"
:src="sourcePreviewUrl"
class="source-pdf"
:title="file?.filename || 'PDF 预览'"
/>
<div v-else class="empty-content">
<p>暂无源文件预览</p>
</div>
</div>
<!-- Markdown 模式 -->
<div v-if="viewMode === 'markdown'" class="content-panel flat-md-preview">
<div v-else-if="viewMode === 'markdown'" class="content-panel flat-md-preview">
<MdPreview
v-if="mergedContent"
:modelValue="mergedContent"
@ -110,6 +131,7 @@ import { message } from 'ant-design-vue'
import { documentApi } from '@/apis/knowledge_api'
import { mergeChunks } from '@/utils/chunkUtils'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
import { getPreviewTypeByPath } from '@/utils/file_preview'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'
import { Download, ChevronDown, FileText, X } from 'lucide-vue-next'
@ -131,21 +153,28 @@ const fileIconColor = computed(() => getFileIconColor(file.value?.filename))
const downloadingOriginal = ref(false)
const downloadingMarkdown = ref(false)
const sourcePreviewLoading = ref(false)
const sourcePreviewUrl = ref('')
//
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
//
const viewMode = ref('markdown')
const hasIndexed = computed(() => ['done', 'indexed'].includes(file.value?.status))
const hasContent = computed(
() => (file.value?.lines && file.value?.lines.length > 0) || file.value?.content
)
const sourcePreviewType = computed(() => getPreviewTypeByPath(file.value?.filename || ''))
const hasSourcePreview = computed(() => ['image', 'pdf'].includes(sourcePreviewType.value))
//
const hasChunks = computed(() => mappedChunks.value && mappedChunks.value.length > 0)
const viewModeOptions = computed(() => {
const options = [{ label: 'Markdown', value: 'markdown' }]
const options = []
if (hasSourcePreview.value) {
options.push({ label: '源文件', value: 'source' })
}
options.push({ label: 'Markdown', value: 'markdown' })
// Chunks
if (hasChunks.value) {
options.push({ label: 'Chunks', value: 'chunks' })
@ -154,12 +183,36 @@ const viewModeOptions = computed(() => {
})
// chunks markdown
watch(file, (newFile) => {
if (newFile && !hasChunks.value) {
viewMode.value = 'markdown'
watch(file, (newFile, oldFile) => {
if (newFile?.file_id !== oldFile?.file_id) {
revokeSourcePreviewUrl()
}
if (!newFile) {
revokeSourcePreviewUrl()
return
}
if (!hasChunks.value) {
viewMode.value = hasSourcePreview.value ? 'source' : 'markdown'
}
})
watch(
[visible, file],
async ([open, currentFile]) => {
if (!open || !currentFile || !hasSourcePreview.value) {
if (!open || !hasSourcePreview.value) {
revokeSourcePreviewUrl()
}
return
}
await loadSourcePreview()
},
{ immediate: true }
)
//
const mergeResult = computed(() => mergeChunks(file.value?.lines || []))
const mappedChunks = computed(() => mergeResult.value.chunks)
@ -180,11 +233,37 @@ function formatTextLength(length) {
const afterOpenChange = (open) => {
if (!open) {
revokeSourcePreviewUrl()
store.selectedFile = null
viewMode.value = 'markdown'
}
}
const revokeSourcePreviewUrl = () => {
if (sourcePreviewUrl.value) {
window.URL.revokeObjectURL(sourcePreviewUrl.value)
sourcePreviewUrl.value = ''
}
}
const loadSourcePreview = async () => {
if (!file.value?.file_id || !store.databaseId || !hasSourcePreview.value) return
if (sourcePreviewUrl.value) return
sourcePreviewLoading.value = true
try {
const response = await documentApi.downloadDocument(store.databaseId, file.value.file_id)
const blob = await response.blob()
revokeSourcePreviewUrl()
sourcePreviewUrl.value = window.URL.createObjectURL(blob)
} catch (error) {
console.error('加载源文件预览失败:', error)
message.error(error.message || '加载源文件预览失败')
} finally {
sourcePreviewLoading.value = false
}
}
//
const handleDownloadMenuClick = ({ key }) => {
if (key === 'original') {
@ -314,7 +393,7 @@ const handleDownloadMarkdown = () => {
.chunks-panel {
flex: 1;
overflow-y: auto;
padding: 16px 0;
padding: 0;
min-height: 0;
}
@ -322,6 +401,30 @@ const handleDownloadMarkdown = () => {
min-height: 100%;
}
.source-preview-wrapper {
height: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
}
.source-image {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 8px;
}
.source-pdf {
width: 100%;
max-height: 100%;
height: calc(100% - 6px);
border: none;
border-radius: 8px;
background: var(--gray-25);
}
.loading-container {
display: flex;
justify-content: center;

View File

@ -1,4 +1,6 @@
const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown', '.mdx'])
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg'])
const PDF_EXTENSIONS = new Set(['.pdf'])
export const getPreviewFileExtension = (path) => {
const normalizedPath = String(path || '')
@ -16,3 +18,11 @@ export const isMarkdownPreview = (path, previewType) => {
if (previewType === 'markdown') return true
return MARKDOWN_EXTENSIONS.has(getPreviewFileExtension(path))
}
export const getPreviewTypeByPath = (path) => {
const extension = getPreviewFileExtension(path)
if (IMAGE_EXTENSIONS.has(extension)) return 'image'
if (PDF_EXTENSIONS.has(extension)) return 'pdf'
if (MARKDOWN_EXTENSIONS.has(extension)) return 'markdown'
return 'text'
}