Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,12 @@
"echarts": "^6.1.0",
"element-plus": "2.14.8",
"highlight.js": "^11.12.0",
"html-to-image": "^1.11.13",
"jsencrypt": "^3.5.4",
"jspdf": "^4.2.1",
"katex": "^0.16.47",
"lodash": "^4.18.1",
"marked": "^18.2.0",
"md-editor-v3": "^7.1.0",
"mermaid": "^11.17.2",
"mitt": "^3.0.1",
Expand Down
8 changes: 7 additions & 1 deletion ui/src/components/mk-edit-avatar/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,13 @@ onBeforeUnmount(() => {
:show-file-list="false"
:on-change="selectImage"
>
<el-image v-if="previewIcon" :src="previewIcon" fit="cover" class="size-20" />
<div v-if="previewIcon" class="group relative size-20 cursor-pointer overflow-hidden rounded-md">
<el-image :src="previewIcon" fit="cover" class="h-full w-full" />
<!-- 悬停提示点击重新上传头像 -->
<div class="group-hover-visible pointer-events-none absolute inset-0 flex-center bg-N900/30">
<MkIcon name="icon_edit_outlined" class="text-white!" :size="16" />
</div>
</div>
<div v-else class="flex-center h-20 w-20 rounded-md border border-dashed bg-N200 hover:border-primary">
<MkIcon name="icon_add_outlined" :size="24" />
</div>
Expand Down
40 changes: 19 additions & 21 deletions ui/src/conversation-panel/components/header/chat/index.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import HeaderActions from '../header-actions/index.vue'
import ExportAction from '../header-actions/export/ExportAction.vue'
import { useConversationListStore } from '../../../left-sidebar/conversation-list/index'
import { useMessageListStore } from '../../message-list/index'
import { useShareLinkStoreOptional } from '../../message-list/share/index'
import { useShowShare } from '../../../view/index'
import { DEVICE_TYPE, useDeviceType, useShowShare } from '../../../view/index'

const props = defineProps<{
/** 新建对话;不传时在当前应用中新建(门户在没有当前智能体时改为选择智能体) */
Expand All @@ -17,6 +19,7 @@ const shareLink = useShareLinkStoreOptional()
// 回答生成中或没有消息时不可分享
const canShare = computed(() => msgs.messages.value.length > 0 && !msgs.streamLoading.value)
const showShare = useShowShare()
const deviceType = useDeviceType()
const handleNewConversation = () => (props.newConversation ? props.newConversation() : list.newConversation())
</script>

Expand All @@ -37,26 +40,21 @@ const handleNewConversation = () => (props.newConversation ? props.newConversati
</div>
<template v-if="!list.isShared.value">
<!-- 新建对话 -->
<MkTooltip content="新建对话" v-if="!list.leftSideOpen.value">
<el-button text @click="handleNewConversation">
<MkIcon name="icon_new-chat_outlined" :size="18" class="text-N900!" />
</el-button>
</MkTooltip>
<!-- 进入分享模式,勾选要分享的对话 -->
<MkTooltip v-if="showShare" content="分享">
<el-button text :disabled="!canShare" @click="shareLink?.enterShare()">
<MkIcon name="icon_share_outlined" :size="18" :class="canShare ? 'text-N900!' : 'text-N400!'" />
</el-button>
</MkTooltip>
<!-- 导出-->
<MkTooltip content="导出">
<el-button text>
<MkIcon name="icon_export_outlined" :size="18" class="text-N900!" />
</el-button>
</MkTooltip>
<slot />
<MkAction
v-if="!list.leftSideOpen.value"
display="button"
label="新建对话"
icon="icon_new-chat_outlined"
class="text-N900!"
@click="handleNewConversation"
/>
<HeaderActions :collapse="deviceType === DEVICE_TYPE.MOBILE">
<!-- 进入分享模式,勾选要分享的对话 -->
<MkAction v-if="showShare" label="分享" icon="icon_share_outlined" :disabled="!canShare" @click="shareLink?.enterShare()" />
<!-- 导出对话 -->
<ExportAction />
<slot />
</HeaderActions>
</template>
</header>
</template>

<style scoped lang="scss"></style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { inject, onBeforeUnmount, ref } from 'vue'
import type { Options } from '@popperjs/core'
import MkDropdown from '@/components/global/mk-dropdown/index.vue'
import ExportPreviewDialog from './ExportPreviewDialog.vue'
import { useConversationExport } from './use-conversation-export.ts'
import { EXPORT_MESSAGE_ELEMENT_KEY } from './context.ts'
import { MsgError } from '@/utils/message'

withDefaults(defineProps<{ display?: 'button' | 'menu' }>(), { display: 'button' })
defineOptions({ inheritAttrs: false })
const { disabled, loading, fileName, exportText } = useConversationExport()
const getMessageElement = inject(EXPORT_MESSAGE_ELEMENT_KEY, () => null)
const dropdownRef = ref<InstanceType<typeof MkDropdown>>()
const submenuOpen = ref(false)
// 优先向左展开,空间不足时仅回退到右侧,避免子菜单挤到主菜单下方。
const submenuOptions: Partial<Options> = {
modifiers: [{ name: 'flip', options: { fallbackPlacements: ['right-start'] } }],
}
function selectFormat(format: 'markdown' | 'html' | 'pdf') {
dropdownRef.value?.handleClose()
if (format === 'pdf') void previewPdf()
else void exportText(format)
}
const previewRef = ref<InstanceType<typeof ExportPreviewDialog>>()
let disposed = false
onBeforeUnmount(() => {
disposed = true
})

async function previewPdf() {
if (disabled.value) return
const element = getMessageElement()
if (!element) return
const name = fileName()
loading.value = true
try {
const { captureMessages } = await import('./capture.ts')
const canvas = await captureMessages(element)
if (!disposed) previewRef.value?.open(canvas, name)
} catch {
if (!disposed) MsgError('导出预览失败,请等待内容加载完成后重试')
} finally {
loading.value = false
}
}
</script>

<template>
<div class="inline-flex" :class="{ 'w-full': display === 'menu' }">
<MkDropdown
ref="dropdownRef"
:disabled="disabled"
:trigger="display === 'menu' ? ['hover', 'click'] : 'click'"
:placement="display === 'menu' ? 'left-start' : 'bottom-end'"
:popper-options="display === 'menu' ? submenuOptions : undefined"
:hide-on-click="false"
class="w-full"
@visible-change="submenuOpen = $event"
>
<!-- 导出子菜单:沿用菜单项外观,展开时保留当前行高亮 -->
<span v-if="display === 'menu'" class="inline-flex w-full">
<MkDropdownItem
:disabled="disabled"
class="export-menu-trigger min-w-32! [&.is-disabled_.mk-icon]:text-N400!"
:class="{ 'is-open': submenuOpen }"
>
<template #icon><MkIcon name="icon_export_outlined" /></template>
<span class="flex-1">导出</span>
<MkIcon name="icon_right_outlined" class="text-N500" />
</MkDropdownItem>
</span>
<!-- 桌面端导出图标入口 -->
<span v-else class="inline-flex">
<MkTooltip content="导出">
<el-button text :disabled="disabled" :loading="loading" class="[&_.mk-icon]:text-N900! [&.is-disabled_.mk-icon]:text-N400!">
<MkIcon name="icon_export_outlined" :size="18" />
</el-button>
</MkTooltip>
</span>
<template #dropdown>
<MkDropdownMenu>
<!-- 导出 Markdown -->
<MkDropdownItem :disabled="disabled" @click="selectFormat('markdown')">导出 Markdown</MkDropdownItem>
<!-- 导出 HTML -->
<MkDropdownItem :disabled="disabled" @click="selectFormat('html')">导出 HTML</MkDropdownItem>
<!-- 预览并导出 PDF -->
<MkDropdownItem :disabled="disabled" @click="selectFormat('pdf')">导出 PDF</MkDropdownItem>
</MkDropdownMenu>
</template>
</MkDropdown>
<ExportPreviewDialog ref="previewRef" />
</div>
</template>

<style scoped lang="scss">
.export-menu-trigger.is-open {
background-color: var(--mk-N900-transparent-10);
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { downloadBlob } from './use-conversation-export'
import { MsgError } from '@/utils/message'

const visible = ref(false)
const loading = ref(false)
const previewUrl = ref('')
let capturedCanvas: HTMLCanvasElement | null = null
let fileName = '对话记录'

function clear() {
previewUrl.value = ''
capturedCanvas = null
}

onBeforeUnmount(clear)

function open(canvas: HTMLCanvasElement, name: string) {
clear()
capturedCanvas = canvas
fileName = name
previewUrl.value = canvas.toDataURL('image/png')
visible.value = true
}

async function save(format: 'pdf' | 'png') {
const canvas = capturedCanvas
if (!canvas || loading.value) return
loading.value = true
try {
if (format === 'png') {
const blob = await new Promise<Blob>((resolve, reject) =>
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('图片生成失败'))), 'image/png'),
)
downloadBlob(blob, `${fileName}.png`)
} else {
const { jsPDF } = await import('jspdf')
const pdf = new jsPDF('p', 'mm', 'a4')
const width = pdf.internal.pageSize.getWidth()
const height = pdf.internal.pageSize.getHeight()
const pagePixels = Math.max(1, Math.floor((canvas.width * height) / width))
// 逐页裁切,避免在每页重复嵌入整张长图。
for (let top = 0; top < canvas.height; top += pagePixels) {
const page = document.createElement('canvas')
page.width = canvas.width
page.height = Math.min(pagePixels, canvas.height - top)
const context = page.getContext('2d')
if (!context) throw new Error('无法创建画布')
context.drawImage(canvas, 0, top, page.width, page.height, 0, 0, page.width, page.height)
if (top) pdf.addPage()
pdf.addImage(page.toDataURL('image/jpeg', 0.95), 'JPEG', 0, 0, width, (page.height * width) / page.width)
}
pdf.save(`${fileName}.pdf`)
}
} catch {
MsgError('导出失败,请稍后重试')
} finally {
loading.value = false
}
}
defineExpose({ open })
</script>

<template>
<MkDialog v-model="visible" title="导出预览" width="900px" @closed="clear">
<img v-if="previewUrl" :src="previewUrl" alt="对话导出预览" class="w-full" />
<template #footer>
<!-- 导出 PDF -->
<el-button :loading="loading" @click="save('pdf')">导出 PDF</el-button>
<!-- 导出图片 -->
<el-button type="primary" :loading="loading" @click="save('png')">导出图片</el-button>
</template>
</MkDialog>
</template>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the code

Overall the logic is sound. Below are irregularities, potential issues, and optimization suggestions.

1. Potential issues (correctness / robustness)

a) toDataURL on a tainted canvas

  • In open(), canvas.toDataURL('image/png') can throw a SecurityError if the canvas contains cross-origin images/fonts that weren't drawn with CORS. This isn't wrapped in try/catch, so it would surface as an uncaught error. Consider wrapping and showing MsgError.

b) PDF pagination crops content at page boundaries

  • Slicing by pagePixels means a row of pixels can be cut mid-line across two pages. If content ends up taller than pagePixels, it will overlap/duplicate visually. A common approach: scale the whole image to page width and let jsPDF handle multi-page via autoPaging, or add small margins. At minimum document this behavior.
  • Also (page.height * width) / page.width uses width (page width mm), which is correct, but for the last partial page the image height is smaller — that's fine, but there's no vertical margin.

c) JPEG quality loss

  • Using 'image/jpeg', 0.95 for PDF introduces compression artifacts and makes text look blurry. For screenshots/logs, consider PNG ('image/png') for sharper text, at the cost of larger file size. Or make it configurable.

d) Re-entrancy of loading guard

  • if (!canvas || loading.value) return guards concurrent saves, but since both buttons share loading, clicking one disables both — expected here, but note el-button with the same loading prop means during one export the other button also shows spinning. Acceptable, just be aware.

e) fileName not sanitized

  • If name comes from user/conversation content, it may contain / \ : * ? " < > | etc., breaking downloads on some OSes. Sanitize before use.

f) Large canvas memory

  • Creating one <canvas> per page in a loop is fine, but for very long conversations this could allocate many large canvases. They become GC-eligible each iteration (no reference kept), so it's OK. However toDataURL on each page is synchronous and blocks the UI. Consider toBlob + async conversion, or chunk with await new Promise(r => setTimeout(r)) between pages to keep UI responsive.

g) capturedCanvas retained after close

  • clear() on @closed and onBeforeUnmount nulls it — good. But previewUrl (a full base64 data URL) stays in memory until close; that's expected. Ensure MkDialog actually emits closed; if it only supports v-model update, clear might never run. Verify the component contract.

h) alt / accessibility

  • The preview <img> has alt — good. The dialog itself could use a more descriptive label, but that depends on MkDialog.

2. Minor / style

  • previewUrl = canvas.toDataURL() duplicates work: save('png') calls canvas.toBlob again. Not a bug, just redundant.
  • Magic numbers 0.95, 'image/jpeg' could be named constants.
  • Error message '图片生成失败' and '无法创建画布' aren't surfaced to the user (the catch swallows them). Log the actual error for debugging:
    } catch (err) {
      console.error(err)
      MsgError('导出失败,请稍后重试')
    }
  • The for loop could compute totalPages = Math.ceil(canvas.height / pagePixels) first for clarity and to reuse in progress display.

3. Optimization suggestions

  • Use toBlob instead of toDataURL for the final image in open() if you don't strictly need a data URL — create an object URL (URL.createObjectURL(blob)) and revoke it in clear(). This avoids a large base64 string lingering in memory and is friendlier to memory for big canvases:
    function clear() {
      if (previewUrl.value.startsWith('blob:')) URL.revokeObjectURL(previewUrl.value)
      previewUrl.value = ''
      capturedCanvas = null
    }
  • Yield to the event loop between pages when generating the PDF to keep the UI responsive on long conversations.
  • Consider passing jsPDF image format based on content: PNG for text-heavy logs (sharpness), JPEG for photo-heavy.
  • Add a page margin so sliced pages don't run edge-to-edge and look cleaner.

Summary

No blocking bugs, but the main risks are: uncaught toDataURL security error, mid-line cropping in PDF pagination, unsanitized filename, swallowed error details, and synchronous blocking on large exports. Address those and the code is solid.

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/** 沙箱内容通过已有 snapshot 协议序列化,随后在禁用脚本的临时 iframe 中截图。 */
function requestSnapshot(iframe: HTMLIFrameElement): Promise<string> {
return new Promise((resolve, reject) => {
const target = iframe.contentWindow
if (!target) return reject(new Error('内容尚未加载'))
const requestId = crypto.randomUUID()
const timer = window.setTimeout(() => finish(), 5000)
function finish(html?: string) {
clearTimeout(timer)
window.removeEventListener('message', onMessage)
if (html) resolve(html)
else reject(new Error('内容快照失败'))
}
function onMessage(event: MessageEvent) {
if (event.source === target && event.data?.type === 'snapshot:response' && event.data.requestId === requestId) finish(event.data.html)
}
window.addEventListener('message', onMessage)
target.postMessage({ type: 'snapshot', requestId }, '*')
})
}

export async function captureMessages(element: HTMLElement): Promise<HTMLCanvasElement> {
const { toCanvas } = await import('html-to-image')
await document.fonts.ready
const replacements: { iframe: HTMLIFrameElement; image: HTMLImageElement; display: string }[] = []
const temporaryFrames: HTMLIFrameElement[] = []
try {
const frames = Array.from(element.querySelectorAll('iframe')).filter((frame) => frame.offsetWidth && frame.offsetHeight)
for (const iframe of frames) {
const html = await requestSnapshot(iframe)
const frame = document.createElement('iframe')
temporaryFrames.push(frame)
frame.setAttribute('sandbox', 'allow-same-origin')
Object.assign(frame.style, { position: 'fixed', left: '-100000px', width: `${iframe.offsetWidth}px`, height: `${iframe.offsetHeight}px` })
await new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error('快照加载超时')), 10000)
frame.onload = () => {
clearTimeout(timer)
resolve()
}
frame.srcdoc = html
document.body.appendChild(frame)
})
const root = frame.contentDocument?.documentElement
if (!root) throw new Error('无法读取快照')
await frame.contentDocument?.fonts.ready
const canvas = await toCanvas(root, { pixelRatio: 2, width: iframe.offsetWidth, height: iframe.offsetHeight })
const image = new Image()
image.src = canvas.toDataURL()
image.style.cssText = iframe.style.cssText
image.style.width = `${iframe.offsetWidth}px`
image.style.height = `${iframe.offsetHeight}px`
await image.decode()
replacements.push({ iframe, image, display: iframe.style.display })
iframe.after(image)
iframe.style.display = 'none'
}
return await toCanvas(element, {
pixelRatio: 2,
backgroundColor: '#ffffff',
filter: (node) =>
!(node instanceof HTMLElement && (node.matches('button, .mk-conversation-export-exclude') || node.getAttribute('role') === 'button')),
})
} finally {
replacements.forEach(({ iframe, image, display }) => {
iframe.style.display = display
image.remove()
})
temporaryFrames.forEach((frame) => frame.remove())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { InjectionKey } from 'vue'

/** 当前主面板的消息正文,由主面板提供,避免读取其他对话或调试窗口。 */
export const EXPORT_MESSAGE_ELEMENT_KEY: InjectionKey<() => HTMLElement | null> = Symbol('export-message-element')
Loading
Loading