feat: 新增源文件预览功能,支持图片和 PDF 格式,优化文件预览体验
This commit is contained in:
parent
b09f4f426f
commit
93a7dccd07
@ -32,7 +32,8 @@
|
|||||||
- 重构文档解析,统一文档解析体验,并新增 Parser 类
|
- 重构文档解析,统一文档解析体验,并新增 Parser 类
|
||||||
- 新增 LITE 模式启动,启动时不加载知识库、知识图谱相关模块,可以使用 make up-lite 快捷启动
|
- 新增 LITE 模式启动,启动时不加载知识库、知识图谱相关模块,可以使用 make up-lite 快捷启动
|
||||||
- 新增沙盒环境,详见后续文档更新,统一沙盒虚拟路径前缀默认值为 `/home/gem/user-data`
|
- 新增沙盒环境,详见后续文档更新,统一沙盒虚拟路径前缀默认值为 `/home/gem/user-data`
|
||||||
- 新增基于沙盒的文件系统,前端工作台可以查看文件系统,支持预览、下载文件
|
- 新增基于沙盒的文件系统,前端工作台可以查看文件系统,支持预览(文本、图片、PDF)、下载文件
|
||||||
|
- 新增基于沙盒的知识库只读映射,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown
|
||||||
- 重构附件系统,直接集成在了沙盒文件系统中,附件上传后直接落盘到沙盒挂载目录
|
- 重构附件系统,直接集成在了沙盒文件系统中,附件上传后直接落盘到沙盒挂载目录
|
||||||
- 优化前端流式消息体验:新增通用 `useStreamSmoother` 调度层,统一平滑 Agent runs SSE、普通聊天流与审批恢复流中的 `loading` chunk
|
- 优化前端流式消息体验:新增通用 `useStreamSmoother` 调度层,统一平滑 Agent runs SSE、普通聊天流与审批恢复流中的 `loading` chunk
|
||||||
- 优化项目文档说明,并添加贡献指南
|
- 优化项目文档说明,并添加贡献指南
|
||||||
@ -40,13 +41,13 @@
|
|||||||
- 新增 API Key 认证功能,支持外部系统通过 API Key 调用系统服务
|
- 新增 API Key 认证功能,支持外部系统通过 API Key 调用系统服务
|
||||||
- 新增 subagents 的支持,支持在 web 中添加 subagents,以及两个内置的子智能体
|
- 新增 subagents 的支持,支持在 web 中添加 subagents,以及两个内置的子智能体
|
||||||
- 新增内置Skills reporter,并移除内置 Agent reporter,数据库报表将由 Skills 完成
|
- 新增内置Skills reporter,并移除内置 Agent reporter,数据库报表将由 Skills 完成
|
||||||
|
- 新增知识库 PDF、图片的预览功能
|
||||||
|
|
||||||
<!-- 添加到这里 -->
|
<!-- 添加到这里 -->
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|
||||||
- 为沙盒与 viewer 文件系统补齐知识库只读映射:新增 `/home/gem/kbs` 命名空间,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown,并补充对应后端与 viewer 路由测试
|
- 为沙盒与 viewer 文件系统补齐知识库只读映射:新增 `/home/gem/kbs` 命名空间,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown,并补充对应后端与 viewer 路由测试
|
||||||
- 修复前端工作台文件预览误读二进制内容的问题:viewer 文件接口新增预览类型判定,支持 Markdown、纯文本、图片与 PDF 预览,其他格式在前端统一显示“不支持预览”,避免乱码
|
|
||||||
|
|
||||||
<!-- 添加到这里 -->
|
<!-- 添加到这里 -->
|
||||||
|
|
||||||
|
|||||||
@ -63,9 +63,30 @@
|
|||||||
<div v-if="loading" class="loading-container">
|
<div v-if="loading" class="loading-container">
|
||||||
<a-spin tip="正在加载文档内容..." />
|
<a-spin tip="正在加载文档内容..." />
|
||||||
</div>
|
</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 模式 -->
|
<!-- 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
|
<MdPreview
|
||||||
v-if="mergedContent"
|
v-if="mergedContent"
|
||||||
:modelValue="mergedContent"
|
:modelValue="mergedContent"
|
||||||
@ -110,6 +131,7 @@ import { message } from 'ant-design-vue'
|
|||||||
import { documentApi } from '@/apis/knowledge_api'
|
import { documentApi } from '@/apis/knowledge_api'
|
||||||
import { mergeChunks } from '@/utils/chunkUtils'
|
import { mergeChunks } from '@/utils/chunkUtils'
|
||||||
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
|
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
|
||||||
|
import { getPreviewTypeByPath } from '@/utils/file_preview'
|
||||||
import { MdPreview } from 'md-editor-v3'
|
import { MdPreview } from 'md-editor-v3'
|
||||||
import 'md-editor-v3/lib/preview.css'
|
import 'md-editor-v3/lib/preview.css'
|
||||||
import { Download, ChevronDown, FileText, X } from 'lucide-vue-next'
|
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 downloadingOriginal = ref(false)
|
||||||
const downloadingMarkdown = ref(false)
|
const downloadingMarkdown = ref(false)
|
||||||
|
const sourcePreviewLoading = ref(false)
|
||||||
|
const sourcePreviewUrl = ref('')
|
||||||
|
|
||||||
// 主题设置
|
// 主题设置
|
||||||
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
|
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
|
||||||
|
|
||||||
// 视图模式
|
// 视图模式
|
||||||
const viewMode = ref('markdown')
|
const viewMode = ref('markdown')
|
||||||
const hasIndexed = computed(() => ['done', 'indexed'].includes(file.value?.status))
|
|
||||||
const hasContent = computed(
|
const hasContent = computed(
|
||||||
() => (file.value?.lines && file.value?.lines.length > 0) || file.value?.content
|
() => (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 hasChunks = computed(() => mappedChunks.value && mappedChunks.value.length > 0)
|
||||||
|
|
||||||
const viewModeOptions = computed(() => {
|
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 选项
|
// 只有当有实际的分块数据时才显示 Chunks 选项
|
||||||
if (hasChunks.value) {
|
if (hasChunks.value) {
|
||||||
options.push({ label: 'Chunks', value: 'chunks' })
|
options.push({ label: 'Chunks', value: 'chunks' })
|
||||||
@ -154,12 +183,36 @@ const viewModeOptions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 监听文件变化,如果没有 chunks 则重置为 markdown
|
// 监听文件变化,如果没有 chunks 则重置为 markdown
|
||||||
watch(file, (newFile) => {
|
watch(file, (newFile, oldFile) => {
|
||||||
if (newFile && !hasChunks.value) {
|
if (newFile?.file_id !== oldFile?.file_id) {
|
||||||
viewMode.value = 'markdown'
|
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 mergeResult = computed(() => mergeChunks(file.value?.lines || []))
|
||||||
const mappedChunks = computed(() => mergeResult.value.chunks)
|
const mappedChunks = computed(() => mergeResult.value.chunks)
|
||||||
@ -180,11 +233,37 @@ function formatTextLength(length) {
|
|||||||
|
|
||||||
const afterOpenChange = (open) => {
|
const afterOpenChange = (open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
revokeSourcePreviewUrl()
|
||||||
store.selectedFile = null
|
store.selectedFile = null
|
||||||
viewMode.value = 'markdown'
|
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 }) => {
|
const handleDownloadMenuClick = ({ key }) => {
|
||||||
if (key === 'original') {
|
if (key === 'original') {
|
||||||
@ -314,7 +393,7 @@ const handleDownloadMarkdown = () => {
|
|||||||
.chunks-panel {
|
.chunks-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px 0;
|
padding: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -322,6 +401,30 @@ const handleDownloadMarkdown = () => {
|
|||||||
min-height: 100%;
|
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 {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown', '.mdx'])
|
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) => {
|
export const getPreviewFileExtension = (path) => {
|
||||||
const normalizedPath = String(path || '')
|
const normalizedPath = String(path || '')
|
||||||
@ -16,3 +18,11 @@ export const isMarkdownPreview = (path, previewType) => {
|
|||||||
if (previewType === 'markdown') return true
|
if (previewType === 'markdown') return true
|
||||||
return MARKDOWN_EXTENSIONS.has(getPreviewFileExtension(path))
|
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