feat(web): 统一预览插件能力

- 潜在问题:大型 HTML 预览存在性能问题,待排查
This commit is contained in:
Wenjie Zhang 2026-03-31 12:00:48 +08:00
parent b4401d0339
commit 8d12be99af
4 changed files with 608 additions and 837 deletions

View File

@ -58,6 +58,7 @@
### 修复
- 调整前端工作台文件预览交互:恢复默认侧边/弹窗预览,并新增显式“全屏预览”入口;全屏模式下由预览内容直接覆盖整页,仅保留右上角悬浮关闭按钮
- 统一 Agent Panel 文件预览与消息区交付物预览组件:两处改为复用同一套 `AgentFilePreview` 预览实现,并为交付物预览补齐与工作台一致的“全屏预览”入口
- 兼容旧版已安装的内置 `reporter` 技能记录:`update_builtin_skill` 现在会识别由 `system``builtin-system` 管理的历史记录,避免更新时误报“技能 `reporter` 不是内置 skill”
- 调整沙盒 user-data 目录隔离策略:`workspace` 改为共享目录 `saves/threads/shared/workspace``uploads/outputs` 继续保持 thread 级隔离;同时更新 thread artifact 权限校验、viewer 文件系统列举逻辑,以及对应的 router/E2E 测试
- 重构聊天接口请求模型:流式与非流式聊天统一使用 `query + agent_config_id` 请求体,并移除路径中的 `agent_id`;同时修复非流式接口实际误走流式执行链路的问题,改为调用 `invoke_messages` 一次性执行,并补充对应测试

View File

@ -53,102 +53,24 @@
:closable="false"
@cancel="closePreview"
>
<template #title>
<div class="modal-header-title">
<div class="file-title">
<component
:is="getFileIcon(currentFilePath)"
:style="{ color: getFileIconColor(currentFilePath), fontSize: '18px' }"
/>
<span class="file-path-title">{{ currentFilePath }}</span>
</div>
<div class="modal-actions">
<div v-if="isHtmlFile" class="preview-mode-switch">
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'render' }"
@click="htmlPreviewMode = 'render'"
title="预览"
>
<Globe :size="16" />
</button>
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'source' }"
@click="htmlPreviewMode = 'source'"
title="源码"
>
<Code2 :size="16" />
</button>
</div>
<button class="modal-action-btn" @click="downloadFile(currentFile)" title="下载">
<Download :size="18" />
</button>
<button class="modal-action-btn" @click="closePreview" title="关闭">
<X :size="18" />
</button>
</div>
</div>
</template>
<div class="file-content">
<template v-if="currentFile?.previewType === 'image' && currentFile?.previewUrl">
<div class="image-preview-wrapper">
<img :src="currentFile.previewUrl" :alt="currentFilePath" class="image-preview" />
</div>
</template>
<template v-else-if="currentFile?.previewType === 'pdf' && currentFile?.previewUrl">
<iframe :src="currentFile.previewUrl" class="pdf-preview" :title="currentFilePath" />
</template>
<template v-else-if="isHtmlFile && htmlPreviewMode === 'render'">
<iframe
class="html-preview"
:srcdoc="formatContent(currentFile?.content)"
:title="currentFilePath"
sandbox=""
/>
</template>
<template v-else-if="isMarkdown">
<MdPreview
class="flat-md-preview"
:modelValue="formatContent(currentFile?.content)"
:theme="theme"
previewTheme="github"
/>
</template>
<template v-else-if="currentFile?.supported === false">
<div class="unsupported-preview">
{{ currentFile?.message || '当前文件暂不支持预览,请下载后查看' }}
</div>
</template>
<template v-else>
<pre
v-if="isCodePreview && typeof currentFile?.content === 'string'"
:class="['file-content-pre', 'code-highlight', codeThemeClass]"
><code class="hljs" v-html="highlightedCodeContent"></code></pre>
<pre v-else-if="typeof currentFile?.content === 'string'" class="file-content-pre">{{
currentFile.content
}}</pre>
<pre v-else>{{ JSON.stringify(currentFile, null, 2) }}</pre>
</template>
</div>
<AgentFilePreview
:file="currentFile"
:filePath="currentFilePath"
:showClose="true"
:showDownload="true"
:showFullscreen="true"
@download="downloadFile"
@close="closePreview"
/>
</a-modal>
</section>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import { ChevronDown, Code2, Download, Eye, FolderOutput, Globe, 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'
import { useThemeStore } from '@/stores/theme'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
import {
getCodeLanguageByPath,
getPreviewTypeByPath,
isHtmlPreview,
isMarkdownPreview
} from '@/utils/file_preview'
import { ChevronDown, Download, Eye, FolderOutput } from 'lucide-vue-next'
import AgentFilePreview from '@/components/AgentFilePreview.vue'
import { getPreviewTypeByPath } from '@/utils/file_preview'
import { downloadViewerFile, getViewerFileContent } from '@/apis/viewer_filesystem'
const props = defineProps({
@ -170,9 +92,6 @@ const props = defineProps({
}
})
const themeStore = useThemeStore()
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
const normalizedArtifacts = computed(() =>
(props.artifacts || [])
.filter((path) => typeof path === 'string' && path.trim())
@ -193,55 +112,6 @@ const expanded = ref(false)
const modalVisible = ref(false)
const currentFile = ref(null)
const currentFilePath = ref('')
const htmlPreviewMode = ref('render')
const isMarkdown = computed(() =>
isMarkdownPreview(currentFilePath.value, currentFile.value?.previewType)
)
const isHtmlFile = computed(
() =>
currentFile.value?.previewType === 'text' &&
typeof currentFile.value?.content === 'string' &&
isHtmlPreview(currentFilePath.value)
)
const codeThemeClass = computed(() => (themeStore.isDark ? 'hljs-theme-dark' : 'hljs-theme-light'))
const codeLanguage = computed(() => getCodeLanguageByPath(currentFilePath.value))
const isCodePreview = computed(
() =>
currentFile.value?.previewType === 'text' &&
typeof currentFile.value?.content === 'string' &&
!isHtmlFile.value &&
Boolean(codeLanguage.value)
)
const escapeHtml = (content) =>
String(content)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
const highlightedCodeContent = computed(() => {
const content = currentFile.value?.content
if (!isCodePreview.value || typeof content !== 'string') {
return ''
}
try {
return codeLanguage.value
? hljs.highlight(content, { language: codeLanguage.value }).value
: hljs.highlightAuto(content).value
} catch (error) {
console.warn('代码高亮失败:', error)
return escapeHtml(content)
}
})
const formatContent = (content) => {
if (content === undefined || content === null) return ''
return String(content)
}
const parseDownloadFilename = (contentDisposition) => {
if (!contentDisposition) return ''
@ -271,7 +141,6 @@ const closePreview = () => {
modalVisible.value = false
currentFile.value = null
currentFilePath.value = ''
htmlPreviewMode.value = 'render'
}
const openPreview = async (file) => {
@ -279,7 +148,6 @@ const openPreview = async (file) => {
revokeCurrentPreviewUrl()
currentFilePath.value = file.path
htmlPreviewMode.value = 'render'
currentFile.value = {
...file,
content: 'Loading...',
@ -551,9 +419,7 @@ watch(
margin-left: 8px;
}
.item-action-btn,
.modal-action-btn,
.preview-mode-btn {
.item-action-btn {
width: 30px;
height: 30px;
border: none;
@ -567,94 +433,11 @@ watch(
transition: all 0.2s ease;
}
.item-action-btn:hover,
.modal-action-btn:hover,
.preview-mode-btn:hover,
.preview-mode-btn.active {
.item-action-btn:hover {
color: var(--main-700);
background: var(--gray-100);
}
.modal-header-title,
.file-title,
.modal-actions,
.preview-mode-switch {
display: flex;
align-items: center;
}
.modal-header-title {
justify-content: space-between;
gap: 12px;
}
.file-title {
gap: 10px;
min-width: 0;
}
.file-path-title {
word-break: break-all;
color: var(--gray-900);
}
.modal-actions,
.preview-mode-switch {
gap: 8px;
}
.file-content {
min-height: 120px;
}
.file-content-pre {
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-size: 13px;
line-height: 1.7;
color: var(--gray-900);
}
.image-preview-wrapper {
display: flex;
justify-content: center;
}
.image-preview,
.pdf-preview,
.html-preview {
width: 100%;
border: none;
border-radius: 12px;
}
.image-preview {
max-height: 70vh;
object-fit: contain;
}
.pdf-preview,
.html-preview {
min-height: 70vh;
background: var(--gray-25);
}
.unsupported-preview {
padding: 16px;
border-radius: 12px;
background: var(--gray-50);
color: var(--gray-600);
}
:deep(.flat-md-preview .md-editor) {
background: transparent;
}
:deep(.flat-md-preview .md-editor-preview-wrapper) {
padding: 0;
}
@media (max-width: 768px) {
.output-item {
align-items: flex-start;

View File

@ -0,0 +1,569 @@
<template>
<div class="agent-file-preview" :class="[containerClass, { 'is-full-height': fullHeight }]">
<div v-if="showHeader" class="preview-header">
<div class="file-title">
<component
:is="getFileIcon(filePath)"
:style="{ color: getFileIconColor(filePath), fontSize: '18px' }"
/>
<span class="file-path-title">{{ filePath }}</span>
</div>
<div class="modal-actions">
<div v-if="isHtmlFile" class="preview-mode-switch">
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'render' }"
@click="htmlPreviewMode = 'render'"
title="预览"
>
<Globe :size="16" />
</button>
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'source' }"
@click="htmlPreviewMode = 'source'"
title="源码"
>
<Code2 :size="16" />
</button>
</div>
<button
v-if="showDownload && file"
class="modal-action-btn"
@click="$emit('download', file)"
title="下载"
>
<Download :size="18" />
</button>
<button
v-if="showFullscreen && file"
class="modal-action-btn"
@click="openFullscreenPreview"
title="全屏预览"
>
<Maximize2 :size="18" />
</button>
<button v-if="showClose" class="modal-action-btn" @click="$emit('close')" title="关闭">
<X :size="18" />
</button>
</div>
</div>
<div class="file-content" :class="contentClass">
<template v-if="file?.previewType === 'image' && file?.previewUrl">
<div class="image-preview-wrapper">
<img :src="file.previewUrl" :alt="filePath" class="image-preview" />
</div>
</template>
<template v-else-if="file?.previewType === 'pdf' && file?.previewUrl">
<iframe :src="file.previewUrl" class="pdf-preview" :title="filePath" />
</template>
<template v-else-if="isHtmlFile && htmlPreviewMode === 'render'">
<iframe
class="html-preview"
:srcdoc="formatContent(file?.content)"
:title="filePath"
sandbox=""
/>
</template>
<template v-else-if="isMarkdown">
<MdPreview
class="flat-md-preview"
:modelValue="formatContent(file?.content)"
:theme="theme"
previewTheme="github"
/>
</template>
<template v-else-if="file?.supported === false">
<div class="unsupported-preview">
{{ file?.message || '当前文件暂不支持预览,请下载后查看' }}
</div>
</template>
<template v-else>
<pre v-if="Array.isArray(file?.content)" class="file-content-pre">{{
formatContent(file.content)
}}</pre>
<pre
v-else-if="isCodePreview && typeof file?.content === 'string'"
:class="['file-content-pre', 'code-highlight', codeThemeClass]"
><code class="hljs" v-html="highlightedCodeContent"></code></pre>
<pre v-else-if="typeof file?.content === 'string'" class="file-content-pre">{{
file.content
}}</pre>
<pre v-else>{{ JSON.stringify(file, null, 2) }}</pre>
</template>
</div>
<Teleport to="body">
<div v-if="fullscreenPreviewVisible && file" class="fullscreen-preview-overlay">
<div class="fullscreen-preview-actions">
<div v-if="isHtmlFile" class="preview-mode-switch fullscreen-preview-switch">
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'render' }"
@click="htmlPreviewMode = 'render'"
title="预览"
>
<Globe :size="16" />
</button>
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'source' }"
@click="htmlPreviewMode = 'source'"
title="源码"
>
<Code2 :size="16" />
</button>
</div>
<button
v-if="showDownload && file"
class="modal-action-btn fullscreen-action-btn"
@click="$emit('download', file)"
title="下载"
>
<Download :size="18" />
</button>
<button
class="modal-action-btn fullscreen-action-btn"
@click="closeFullscreenPreview"
title="关闭"
>
<X :size="18" />
</button>
</div>
<div class="fullscreen-preview-content">
<div class="file-content fullscreen-file-content">
<template v-if="file?.previewType === 'image' && file?.previewUrl">
<div class="image-preview-wrapper fullscreen-image-preview-wrapper">
<img :src="file.previewUrl" :alt="filePath" class="image-preview" />
</div>
</template>
<template v-else-if="file?.previewType === 'pdf' && file?.previewUrl">
<iframe :src="file.previewUrl" class="pdf-preview fullscreen-embed-preview" :title="filePath" />
</template>
<template v-else-if="isHtmlFile && htmlPreviewMode === 'render'">
<iframe
class="html-preview fullscreen-embed-preview"
:srcdoc="formatContent(file?.content)"
:title="filePath"
sandbox=""
/>
</template>
<template v-else-if="isMarkdown">
<MdPreview
class="flat-md-preview"
:modelValue="formatContent(file?.content)"
:theme="theme"
previewTheme="github"
/>
</template>
<template v-else-if="file?.supported === false">
<div class="unsupported-preview fullscreen-unsupported-preview">
{{ file?.message || '当前文件暂不支持预览,请下载后查看' }}
</div>
</template>
<template v-else>
<pre v-if="Array.isArray(file?.content)" class="file-content-pre">{{
formatContent(file.content)
}}</pre>
<pre
v-else-if="isCodePreview && typeof file?.content === 'string'"
:class="['file-content-pre', 'code-highlight', codeThemeClass]"
><code class="hljs" v-html="highlightedCodeContent"></code></pre>
<pre v-else-if="typeof file?.content === 'string'" class="file-content-pre">{{
file.content
}}</pre>
<pre v-else>{{ JSON.stringify(file, null, 2) }}</pre>
</template>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import { Code2, Download, Globe, Maximize2, 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'
import { useThemeStore } from '@/stores/theme'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
import { getCodeLanguageByPath, isHtmlPreview, isMarkdownPreview } from '@/utils/file_preview'
const props = defineProps({
file: {
type: Object,
default: null
},
filePath: {
type: String,
default: ''
},
showHeader: {
type: Boolean,
default: true
},
showDownload: {
type: Boolean,
default: true
},
showClose: {
type: Boolean,
default: false
},
showFullscreen: {
type: Boolean,
default: false
},
fullHeight: {
type: Boolean,
default: false
},
containerClass: {
type: [String, Array, Object],
default: ''
},
contentClass: {
type: [String, Array, Object],
default: ''
}
})
defineEmits(['close', 'download'])
const themeStore = useThemeStore()
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
const htmlPreviewMode = ref('render')
const fullscreenPreviewVisible = ref(false)
const isMarkdown = computed(() => isMarkdownPreview(props.filePath, props.file?.previewType))
const isHtmlFile = computed(
() =>
props.file?.previewType === 'text' &&
typeof props.file?.content === 'string' &&
isHtmlPreview(props.filePath)
)
const codeThemeClass = computed(() => (themeStore.isDark ? 'hljs-theme-dark' : 'hljs-theme-light'))
const codeLanguage = computed(() => getCodeLanguageByPath(props.filePath))
const isCodePreview = computed(
() =>
props.file?.previewType === 'text' &&
typeof props.file?.content === 'string' &&
!isHtmlFile.value &&
Boolean(codeLanguage.value)
)
const escapeHtml = (content) =>
String(content)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
const highlightedCodeContent = computed(() => {
const content = props.file?.content
if (!isCodePreview.value || typeof content !== 'string') {
return ''
}
try {
if (codeLanguage.value) {
return hljs.highlight(content, { language: codeLanguage.value }).value
}
return hljs.highlightAuto(content).value
} catch (error) {
console.warn('代码高亮失败:', error)
return escapeHtml(content)
}
})
const formatContent = (content) => {
if (Array.isArray(content)) return content.join('\n')
if (content === undefined || content === null) return ''
return String(content)
}
const openFullscreenPreview = () => {
if (!props.file) return
fullscreenPreviewVisible.value = true
}
const closeFullscreenPreview = () => {
fullscreenPreviewVisible.value = false
}
watch(
() => props.filePath,
() => {
htmlPreviewMode.value = 'render'
}
)
watch(fullscreenPreviewVisible, (visible) => {
document.body.style.overflow = visible ? 'hidden' : ''
})
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>
<style scoped lang="less">
.agent-file-preview {
min-width: 0;
}
.agent-file-preview.is-full-height {
display: flex;
flex-direction: column;
min-height: 0;
}
.preview-header {
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 4px 12px;
border-bottom: 1px solid var(--gray-150);
background: var(--gray-25);
}
.file-title,
.modal-actions,
.preview-mode-switch {
display: flex;
align-items: center;
}
.file-title {
gap: 8px;
min-width: 0;
}
.modal-actions,
.preview-mode-switch {
gap: 8px;
}
.preview-mode-switch {
padding: 2px;
border-radius: 8px;
background: var(--gray-100);
}
.file-path-title {
font-weight: 400;
color: var(--gray-700);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-action-btn,
.preview-mode-btn {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: transparent;
color: var(--gray-600);
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.modal-action-btn:hover,
.preview-mode-btn:hover {
background: var(--gray-100);
color: var(--gray-900);
}
.preview-mode-btn.active {
background: var(--gray-0);
color: var(--gray-900);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.file-content {
min-height: 300px;
max-height: 80vh;
overflow-y: auto;
border-radius: 6px;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--gray-50);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--gray-300);
border-radius: 4px;
&:hover {
background: var(--gray-400);
}
}
.flat-md-preview {
padding: 12px;
}
}
.file-content pre,
.file-content-pre {
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
line-height: 1.5;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
color: var(--gray-1000);
background: transparent;
}
.file-content-pre.code-highlight {
border-radius: 8px;
background: var(--gray-25);
border: 1px solid var(--gray-150);
white-space: pre;
overflow-x: auto;
}
.file-content-pre.code-highlight code {
padding: 14px 16px;
display: block;
white-space: pre;
color: inherit;
min-height: calc(80vh - 40px);
}
.image-preview-wrapper {
display: flex;
justify-content: center;
align-items: flex-start;
}
.image-preview {
display: block;
max-width: 100%;
max-height: calc(80vh - 32px);
object-fit: contain;
border-radius: 6px;
}
.pdf-preview {
width: 100%;
min-height: calc(80vh - 40px);
border: none;
border-radius: 6px;
background: var(--gray-25);
}
.html-preview {
width: 100%;
min-height: calc(80vh - 40px);
border: 1px solid var(--gray-150);
border-radius: 6px;
background: #fff;
}
.unsupported-preview {
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--gray-600);
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.fullscreen-preview-overlay {
position: fixed;
inset: 0;
z-index: 1200;
background: var(--gray-0);
}
.fullscreen-preview-actions {
position: fixed;
top: 16px;
right: 16px;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
}
.fullscreen-preview-switch {
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.14);
}
.fullscreen-action-btn {
width: 40px;
height: 40px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--gray-200);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.14);
backdrop-filter: blur(10px);
}
.fullscreen-preview-content {
position: absolute;
inset: 0;
min-height: 0;
}
.fullscreen-file-content {
height: 100vh;
max-height: none;
min-height: 100vh;
padding: 24px;
border-radius: 0;
}
.fullscreen-image-preview-wrapper {
min-height: calc(100vh - 48px);
align-items: center;
}
.fullscreen-embed-preview {
min-height: calc(100vh - 48px);
height: calc(100vh - 48px);
border-radius: 12px;
}
.fullscreen-unsupported-preview {
min-height: calc(100vh - 48px);
}
.fullscreen-file-content .file-content-pre.code-highlight code {
min-height: calc(100vh - 48px);
}
.fullscreen-file-content .image-preview {
max-height: calc(100vh - 48px);
}
:deep(.flat-md-preview .md-editor) {
background: transparent;
}
:deep(.flat-md-preview .md-editor-preview-wrapper) {
padding: 0;
}
</style>

View File

@ -105,100 +105,19 @@
</div>
<div v-if="useInlinePreview" class="inline-preview-pane">
<div v-if="currentFile" class="inline-preview-shell">
<div class="inline-preview-header">
<div class="file-title">
<component
:is="getFileIcon(currentFilePath)"
:style="{ color: getFileIconColor(currentFilePath), fontSize: '18px' }"
/>
<span class="file-path-title">{{ currentFilePath }}</span>
</div>
<div class="modal-actions">
<div v-if="isHtmlFile" class="preview-mode-switch">
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'render' }"
@click="htmlPreviewMode = 'render'"
title="预览"
>
<Globe :size="16" />
</button>
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'source' }"
@click="htmlPreviewMode = 'source'"
title="源码"
>
<Code2 :size="16" />
</button>
</div>
<button class="modal-action-btn" @click="downloadFile(currentFile)" title="下载">
<Download :size="18" />
</button>
<button class="modal-action-btn" @click="openFullscreenPreview" title="全屏预览">
<Maximize2 :size="18" />
</button>
<button class="modal-action-btn" @click="closePreview" title="关闭预览">
<X :size="18" />
</button>
</div>
</div>
<div class="file-content inline-file-content">
<template v-if="currentFile?.previewType === 'image' && currentFile?.previewUrl">
<div class="image-preview-wrapper">
<img
:src="currentFile.previewUrl"
:alt="currentFilePath"
class="image-preview"
/>
</div>
</template>
<template v-else-if="currentFile?.previewType === 'pdf' && currentFile?.previewUrl">
<iframe
:src="currentFile.previewUrl"
class="pdf-preview"
:title="currentFilePath"
/>
</template>
<template v-else-if="isHtmlFile && htmlPreviewMode === 'render'">
<iframe
class="html-preview"
:srcdoc="formatContent(currentFile?.content)"
:title="currentFilePath"
sandbox=""
/>
</template>
<template v-else-if="isMarkdown">
<MdPreview
class="flat-md-preview"
:modelValue="formatContent(currentFile?.content)"
:theme="theme"
previewTheme="github"
/>
</template>
<template v-else-if="currentFile?.supported === false">
<div class="unsupported-preview">
{{ currentFile?.message || '当前文件暂不支持预览,请下载后查看' }}
</div>
</template>
<template v-else>
<pre v-if="Array.isArray(currentFile?.content)">{{
formatContent(currentFile.content)
}}</pre>
<pre
v-else-if="isCodePreview && typeof currentFile?.content === 'string'"
:class="['file-content-pre', 'code-highlight', codeThemeClass]"
><code class="hljs" v-html="highlightedCodeContent"></code></pre>
<pre
v-else-if="typeof currentFile?.content === 'string'"
class="file-content-pre"
>{{ currentFile.content }}</pre
>
<pre v-else>{{ JSON.stringify(currentFile, null, 2) }}</pre>
</template>
</div>
</div>
<AgentFilePreview
v-if="currentFile"
containerClass="inline-preview-shell"
contentClass="inline-file-content"
:file="currentFile"
:filePath="currentFilePath"
:fullHeight="true"
:showClose="true"
:showDownload="true"
:showFullscreen="true"
@download="downloadFile"
@close="closePreview"
/>
<div v-else class="inline-preview-empty">
<div class="inline-preview-empty-title">选择文件后可在此预览</div>
<div class="inline-preview-empty-desc">
@ -219,197 +138,22 @@
:closable="false"
@cancel="closePreview"
>
<template #title>
<div class="modal-header-title">
<div class="file-title">
<component
:is="getFileIcon(currentFilePath)"
:style="{ color: getFileIconColor(currentFilePath), fontSize: '18px' }"
/>
<span class="file-path-title">{{ currentFilePath }}</span>
</div>
<div class="modal-actions">
<div v-if="isHtmlFile" class="preview-mode-switch">
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'render' }"
@click="htmlPreviewMode = 'render'"
title="预览"
>
<Globe :size="16" />
</button>
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'source' }"
@click="htmlPreviewMode = 'source'"
title="源码"
>
<Code2 :size="16" />
</button>
</div>
<button
v-if="currentFile"
class="modal-action-btn"
@click="downloadFile(currentFile)"
title="下载"
>
<Download :size="18" />
</button>
<button
v-if="currentFile"
class="modal-action-btn"
@click="openFullscreenPreview"
title="全屏预览"
>
<Maximize2 :size="18" />
</button>
<button class="modal-action-btn" @click="closePreview" title="关闭">
<X :size="18" />
</button>
</div>
</div>
</template>
<div class="file-content">
<template v-if="currentFile?.previewType === 'image' && currentFile?.previewUrl">
<div class="image-preview-wrapper">
<img :src="currentFile.previewUrl" :alt="currentFilePath" class="image-preview" />
</div>
</template>
<template v-else-if="currentFile?.previewType === 'pdf' && currentFile?.previewUrl">
<iframe :src="currentFile.previewUrl" class="pdf-preview" :title="currentFilePath" />
</template>
<template v-else-if="isHtmlFile && htmlPreviewMode === 'render'">
<iframe
class="html-preview"
:srcdoc="formatContent(currentFile?.content)"
:title="currentFilePath"
sandbox=""
/>
</template>
<template v-else-if="isMarkdown">
<MdPreview
class="flat-md-preview"
:modelValue="formatContent(currentFile?.content)"
:theme="theme"
previewTheme="github"
/>
</template>
<template v-else-if="currentFile?.supported === false">
<div class="unsupported-preview">
{{ currentFile?.message || '当前文件暂不支持预览,请下载后查看' }}
</div>
</template>
<template v-else>
<pre v-if="Array.isArray(currentFile?.content)">{{ formatContent(currentFile.content) }}</pre>
<pre
v-else-if="isCodePreview && typeof currentFile?.content === 'string'"
:class="['file-content-pre', 'code-highlight', codeThemeClass]"
><code class="hljs" v-html="highlightedCodeContent"></code></pre>
<pre v-else-if="typeof currentFile?.content === 'string'" class="file-content-pre">{{
currentFile.content
}}</pre>
<pre v-else>{{ JSON.stringify(currentFile, null, 2) }}</pre>
</template>
</div>
<AgentFilePreview
:file="currentFile"
:filePath="currentFilePath"
:showClose="true"
:showDownload="true"
:showFullscreen="true"
@download="downloadFile"
@close="closePreview"
/>
</a-modal>
<Teleport to="body">
<div v-if="fullscreenPreviewVisible && currentFile" class="fullscreen-preview-overlay">
<div class="fullscreen-preview-actions">
<div v-if="isHtmlFile" class="preview-mode-switch fullscreen-preview-switch">
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'render' }"
@click="htmlPreviewMode = 'render'"
title="预览"
>
<Globe :size="16" />
</button>
<button
class="preview-mode-btn"
:class="{ active: htmlPreviewMode === 'source' }"
@click="htmlPreviewMode = 'source'"
title="源码"
>
<Code2 :size="16" />
</button>
</div>
<button
class="modal-action-btn fullscreen-action-btn"
@click="closeFullscreenPreview"
title="关闭"
>
<X :size="18" />
</button>
</div>
<div class="fullscreen-preview-content">
<div class="file-content fullscreen-file-content">
<template v-if="currentFile?.previewType === 'image' && currentFile?.previewUrl">
<div class="image-preview-wrapper fullscreen-image-preview-wrapper">
<img :src="currentFile.previewUrl" :alt="currentFilePath" class="image-preview" />
</div>
</template>
<template v-else-if="currentFile?.previewType === 'pdf' && currentFile?.previewUrl">
<iframe
:src="currentFile.previewUrl"
class="pdf-preview fullscreen-embed-preview"
:title="currentFilePath"
/>
</template>
<template v-else-if="isHtmlFile && htmlPreviewMode === 'render'">
<iframe
class="html-preview fullscreen-embed-preview"
:srcdoc="formatContent(currentFile?.content)"
:title="currentFilePath"
sandbox=""
/>
</template>
<template v-else-if="isMarkdown">
<MdPreview
class="flat-md-preview"
:modelValue="formatContent(currentFile?.content)"
:theme="theme"
previewTheme="github"
/>
</template>
<template v-else-if="currentFile?.supported === false">
<div class="unsupported-preview fullscreen-unsupported-preview">
{{ currentFile?.message || '当前文件暂不支持预览,请下载后查看' }}
</div>
</template>
<template v-else>
<pre v-if="Array.isArray(currentFile?.content)">{{
formatContent(currentFile.content)
}}</pre>
<pre
v-else-if="isCodePreview && typeof currentFile?.content === 'string'"
:class="['file-content-pre', 'code-highlight', codeThemeClass]"
><code class="hljs" v-html="highlightedCodeContent"></code></pre>
<pre v-else-if="typeof currentFile?.content === 'string'" class="file-content-pre">{{
currentFile.content
}}</pre>
<pre v-else>{{ JSON.stringify(currentFile, null, 2) }}</pre>
</template>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, onUpdated, nextTick, ref, watch } from 'vue'
import {
ChevronsDownUp,
ChevronsUpDown,
Code2,
Download,
FolderCode,
Globe,
Maximize2,
RefreshCw,
X
} from 'lucide-vue-next'
import { ChevronsDownUp, ChevronsUpDown, Download, FolderCode, RefreshCw, X } from 'lucide-vue-next'
import {
CheckCircleOutlined,
SyncOutlined,
@ -417,13 +161,8 @@ import {
CloseCircleOutlined,
QuestionCircleOutlined
} from '@ant-design/icons-vue'
import { MdPreview } from 'md-editor-v3'
import hljs from 'highlight.js/lib/common'
import 'md-editor-v3/lib/preview.css'
import { useThemeStore } from '@/stores/theme'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
import { getCodeLanguageByPath, isHtmlPreview, isMarkdownPreview } from '@/utils/file_preview'
import FileTreeComponent from '@/components/FileTreeComponent.vue'
import AgentFilePreview from '@/components/AgentFilePreview.vue'
import {
downloadViewerFile,
getViewerFileContent,
@ -471,10 +210,8 @@ const INLINE_PREVIEW_MIN_WIDTH = 920
const panelRef = ref(null)
const activeTab = ref('files')
const modalVisible = ref(false)
const fullscreenPreviewVisible = ref(false)
const currentFile = ref(null)
const currentFilePath = ref('')
const htmlPreviewMode = ref('render')
const loadingFiles = ref(false)
const filesystemError = ref('')
const panelWidth = ref(0)
@ -483,54 +220,8 @@ const dynamicTreeData = ref([])
const selectedKeys = ref([])
const expandedKeys = ref([])
const themeStore = useThemeStore()
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
const useInlinePreview = computed(() => panelWidth.value >= INLINE_PREVIEW_MIN_WIDTH)
const isMarkdown = computed(() =>
isMarkdownPreview(currentFilePath.value, currentFile.value?.previewType)
)
const isHtmlFile = computed(
() =>
currentFile.value?.previewType === 'text' &&
typeof currentFile.value?.content === 'string' &&
isHtmlPreview(currentFilePath.value)
)
const codeThemeClass = computed(() => (themeStore.isDark ? 'hljs-theme-dark' : 'hljs-theme-light'))
const codeLanguage = computed(() => getCodeLanguageByPath(currentFilePath.value))
const isCodePreview = computed(
() =>
currentFile.value?.previewType === 'text' &&
typeof currentFile.value?.content === 'string' &&
!isHtmlFile.value &&
Boolean(codeLanguage.value)
)
const escapeHtml = (content) =>
String(content)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
const highlightedCodeContent = computed(() => {
const content = currentFile.value?.content
if (!isCodePreview.value || typeof content !== 'string') {
return ''
}
try {
if (codeLanguage.value) {
return hljs.highlight(content, { language: codeLanguage.value }).value
}
return hljs.highlightAuto(content).value
} catch (error) {
console.warn('代码高亮失败:', error)
return escapeHtml(content)
}
})
const todos = computed(() => props.agentState?.todos || [])
const completedCount = computed(() => todos.value.filter((t) => t.status === 'completed').length)
@ -678,12 +369,6 @@ const getFileName = (fileItem) => {
return '未知文件'
}
const formatContent = (content) => {
if (Array.isArray(content)) return content.join('\n')
if (content === undefined || content === null) return ''
return String(content)
}
const refreshFileSystem = async () => {
if (!props.threadId) {
dynamicTreeData.value = []
@ -756,7 +441,6 @@ const onFileSelect = async (nextSelectedKeys, { node }) => {
revokeCurrentPreviewUrl()
currentFilePath.value = node.key
htmlPreviewMode.value = 'render'
currentFile.value = {
...node.fileData,
content: 'Loading...',
@ -809,24 +493,13 @@ const onFileSelect = async (nextSelectedKeys, { node }) => {
}
const closePreview = () => {
fullscreenPreviewVisible.value = false
revokeCurrentPreviewUrl()
modalVisible.value = false
currentFile.value = null
currentFilePath.value = ''
htmlPreviewMode.value = 'render'
selectedKeys.value = []
}
const openFullscreenPreview = () => {
if (!currentFile.value) return
fullscreenPreviewVisible.value = true
}
const closeFullscreenPreview = () => {
fullscreenPreviewVisible.value = false
}
const downloadFile = async (fileItem) => {
if (!props.threadId || !fileItem?.path) return
@ -949,7 +622,6 @@ onUnmounted(() => {
window.removeEventListener('pointercancel', stopResize)
document.body.style.cursor = ''
document.body.style.userSelect = ''
document.body.style.overflow = ''
revokeCurrentPreviewUrl()
})
@ -972,14 +644,6 @@ watch(useInlinePreview, (isInline) => {
modalVisible.value = !isInline
})
watch([modalVisible, fullscreenPreviewVisible], ([modalOpen, fullscreenOpen]) => {
document.body.style.overflow = fullscreenOpen ? 'hidden' : ''
if (fullscreenOpen && modalOpen) {
modalVisible.value = false
}
})
</script>
<style scoped lang="less">
@ -1376,252 +1040,6 @@ watch([modalVisible, fullscreenPreviewVisible], ([modalOpen, fullscreenOpen]) =>
}
}
.file-content {
min-height: 300px;
max-height: 80vh;
overflow-y: auto;
border-radius: 6px;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--gray-50);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--gray-300);
border-radius: 4px;
&:hover {
background: var(--gray-400);
}
}
pre {
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
line-height: 1.5;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
color: var(--gray-1000);
background: transparent;
}
.flat-md-preview {
padding: 12px;
}
}
.fullscreen-preview-overlay {
position: fixed;
inset: 0;
z-index: 1200;
background: var(--gray-0);
}
.fullscreen-preview-actions {
position: fixed;
top: 16px;
right: 16px;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
}
.fullscreen-preview-switch {
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.14);
}
.fullscreen-action-btn {
width: 40px;
height: 40px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--gray-200);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.14);
backdrop-filter: blur(10px);
}
.fullscreen-preview-content {
position: absolute;
inset: 0;
min-height: 0;
}
.fullscreen-file-content {
height: 100vh;
max-height: none;
min-height: 100vh;
padding: 24px;
border-radius: 0;
}
.fullscreen-image-preview-wrapper {
min-height: calc(100vh - 48px);
align-items: center;
}
.fullscreen-embed-preview {
min-height: calc(100vh - 48px);
height: calc(100vh - 48px);
border-radius: 12px;
}
.fullscreen-unsupported-preview {
min-height: calc(100vh - 48px);
}
.file-content-pre.code-highlight {
border-radius: 8px;
background: var(--gray-25);
border: 1px solid var(--gray-150);
white-space: pre;
overflow-x: auto;
}
.file-content-pre.code-highlight code {
padding: 14px 16px;
display: block;
white-space: pre;
color: inherit;
min-height: calc(80vh - 40px);
}
.fullscreen-file-content .file-content-pre.code-highlight code {
min-height: calc(100vh - 48px);
}
.image-preview-wrapper {
display: flex;
justify-content: center;
align-items: flex-start;
}
.image-preview {
display: block;
max-width: 100%;
max-height: calc(80vh - 32px);
object-fit: contain;
border-radius: 6px;
}
.fullscreen-file-content .image-preview {
max-height: calc(100vh - 48px);
}
.pdf-preview {
width: 100%;
min-height: calc(80vh - 40px);
border: none;
border-radius: 6px;
background: var(--gray-25);
}
.html-preview {
width: 100%;
min-height: calc(80vh - 40px);
border: 1px solid var(--gray-150);
border-radius: 6px;
background: #fff;
}
.unsupported-preview {
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--gray-600);
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.modal-header-title {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.file-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.modal-actions {
display: flex;
align-items: center;
gap: 8px;
}
.preview-mode-switch {
display: flex;
align-items: center;
padding: 2px;
border-radius: 8px;
background: var(--gray-100);
}
.preview-mode-btn {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: transparent;
color: var(--gray-600);
cursor: pointer;
transition: all 0.15s ease;
&.active {
background: var(--gray-0);
color: var(--gray-900);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
}
.modal-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: none;
border-radius: 4px;
color: var(--gray-600);
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
&:hover {
background: var(--gray-100);
color: var(--gray-900);
}
&:active {
background: var(--gray-200);
}
}
.file-path-title {
font-weight: 400;
color: var(--gray-700);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.ant-modal) {
z-index: 1050;
}