Repository navigation
feat: chat export #7914
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat: chat export #7914
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
100 changes: 100 additions & 0 deletions
100
ui/src/conversation-panel/components/header/header-actions/export/ExportAction.vue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
75 changes: 75 additions & 0 deletions
75
ui/src/conversation-panel/components/header/header-actions/export/ExportPreviewDialog.vue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
71 changes: 71 additions & 0 deletions
71
ui/src/conversation-panel/components/header/header-actions/export/capture.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) | ||
| } | ||
| } |
4 changes: 4 additions & 0 deletions
4
ui/src/conversation-panel/components/header/header-actions/export/context.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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)
toDataURLon a tainted canvasopen(),canvas.toDataURL('image/png')can throw aSecurityErrorif 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 showingMsgError.b) PDF pagination crops content at page boundaries
pagePixelsmeans a row of pixels can be cut mid-line across two pages. If content ends up taller thanpagePixels, it will overlap/duplicate visually. A common approach: scale the whole image to page width and let jsPDF handle multi-page viaautoPaging, or add small margins. At minimum document this behavior.(page.height * width) / page.widthuseswidth(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
'image/jpeg', 0.95for 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
loadingguardif (!canvas || loading.value) returnguards concurrent saves, but since both buttons shareloading, clicking one disables both — expected here, but noteel-buttonwith the sameloadingprop means during one export the other button also shows spinning. Acceptable, just be aware.e)
fileNamenot sanitizednamecomes from user/conversation content, it may contain/ \ : * ? " < > |etc., breaking downloads on some OSes. Sanitize before use.f) Large canvas memory
<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. HowevertoDataURLon each page is synchronous and blocks the UI. ConsidertoBlob+ async conversion, or chunk withawait new Promise(r => setTimeout(r))between pages to keep UI responsive.g)
capturedCanvasretained after closeclear()on@closedandonBeforeUnmountnulls it — good. ButpreviewUrl(a full base64 data URL) stays in memory until close; that's expected. EnsureMkDialogactually emitsclosed; if it only supportsv-modelupdate,clearmight never run. Verify the component contract.h)
alt/ accessibility<img>hasalt— good. The dialog itself could use a more descriptive label, but that depends onMkDialog.2. Minor / style
previewUrl = canvas.toDataURL()duplicates work:save('png')callscanvas.toBlobagain. Not a bug, just redundant.0.95,'image/jpeg'could be named constants.'图片生成失败'and'无法创建画布'aren't surfaced to the user (the catch swallows them). Log the actual error for debugging:forloop could computetotalPages = Math.ceil(canvas.height / pagePixels)first for clarity and to reuse in progress display.3. Optimization suggestions
toBlobinstead oftoDataURLfor the final image inopen()if you don't strictly need a data URL — create an object URL (URL.createObjectURL(blob)) and revoke it inclear(). This avoids a large base64 string lingering in memory and is friendlier to memory for big canvases:jsPDFimage format based on content: PNG for text-heavy logs (sharpness), JPEG for photo-heavy.Summary
No blocking bugs, but the main risks are: uncaught
toDataURLsecurity 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.