feat: 新增源文件预览功能,支持图片和 PDF 格式,优化文件预览体验
This commit is contained in:
parent
b09f4f426f
commit
93a7dccd07
@ -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 预览,其他格式在前端统一显示“不支持预览”,避免乱码
|
||||
|
||||
<!-- 添加到这里 -->
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user