diff --git a/ui/package.json b/ui/package.json index 00226bd17d5..8a03f65d6d6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/components/mk-edit-avatar/index.vue b/ui/src/components/mk-edit-avatar/index.vue index 1895669a56a..6cec02d24e7 100644 --- a/ui/src/components/mk-edit-avatar/index.vue +++ b/ui/src/components/mk-edit-avatar/index.vue @@ -131,7 +131,13 @@ onBeforeUnmount(() => { :show-file-list="false" :on-change="selectImage" > - +
+ + +
+ +
+
diff --git a/ui/src/conversation-panel/components/header/chat/index.vue b/ui/src/conversation-panel/components/header/chat/index.vue index 7020c953071..701ce766a06 100644 --- a/ui/src/conversation-panel/components/header/chat/index.vue +++ b/ui/src/conversation-panel/components/header/chat/index.vue @@ -1,9 +1,11 @@ @@ -37,26 +40,21 @@ const handleNewConversation = () => (props.newConversation ? props.newConversati - - diff --git a/ui/src/conversation-panel/components/header/header-actions/export/ExportAction.vue b/ui/src/conversation-panel/components/header/header-actions/export/ExportAction.vue new file mode 100644 index 00000000000..a0a7d99eacc --- /dev/null +++ b/ui/src/conversation-panel/components/header/header-actions/export/ExportAction.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/ui/src/conversation-panel/components/header/header-actions/export/ExportPreviewDialog.vue b/ui/src/conversation-panel/components/header/header-actions/export/ExportPreviewDialog.vue new file mode 100644 index 00000000000..4bc74b60494 --- /dev/null +++ b/ui/src/conversation-panel/components/header/header-actions/export/ExportPreviewDialog.vue @@ -0,0 +1,75 @@ + + + diff --git a/ui/src/conversation-panel/components/header/header-actions/export/capture.ts b/ui/src/conversation-panel/components/header/header-actions/export/capture.ts new file mode 100644 index 00000000000..6468ff00883 --- /dev/null +++ b/ui/src/conversation-panel/components/header/header-actions/export/capture.ts @@ -0,0 +1,71 @@ +/** 沙箱内容通过已有 snapshot 协议序列化,随后在禁用脚本的临时 iframe 中截图。 */ +function requestSnapshot(iframe: HTMLIFrameElement): Promise { + 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 { + 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((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()) + } +} diff --git a/ui/src/conversation-panel/components/header/header-actions/export/context.ts b/ui/src/conversation-panel/components/header/header-actions/export/context.ts new file mode 100644 index 00000000000..1f684f7c361 --- /dev/null +++ b/ui/src/conversation-panel/components/header/header-actions/export/context.ts @@ -0,0 +1,4 @@ +import type { InjectionKey } from 'vue' + +/** 当前主面板的消息正文,由主面板提供,避免读取其他对话或调试窗口。 */ +export const EXPORT_MESSAGE_ELEMENT_KEY: InjectionKey<() => HTMLElement | null> = Symbol('export-message-element') diff --git a/ui/src/conversation-panel/components/header/header-actions/export/use-conversation-export.ts b/ui/src/conversation-panel/components/header/header-actions/export/use-conversation-export.ts new file mode 100644 index 00000000000..2bf93275a20 --- /dev/null +++ b/ui/src/conversation-panel/components/header/header-actions/export/use-conversation-export.ts @@ -0,0 +1,67 @@ +import { computed, ref } from 'vue' +import DOMPurify from 'dompurify' +import type { ChatContent } from '@/api/types' +import { useMessageListStore } from '../../../message-list/index' +import { useConversationListStore } from '../../../../left-sidebar/conversation-list/index' +import { MsgError } from '@/utils/message' + +export function downloadBlob(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + link.click() + // 浏览器开始下载后释放地址。 + setTimeout(() => URL.revokeObjectURL(url), 1000) +} + +function contentToMarkdown(content: ChatContent): string { + if (['REASONING', 'TOOL', 'GROUP_TOOL'].includes(content.type)) return '' + if (content.children) return content.children.map(contentToMarkdown).filter(Boolean).join('\n\n') + const text = content.content ?? '' + if (content.type === 'HTML') return `\`\`\`html\n${text}\n\`\`\`` + if (content.type === 'ECHARTS') return `\`\`\`json\n${text}\n\`\`\`` + const attachments = ['image_list', 'document_list', 'audio_list', 'video_list', 'other_list'].flatMap((field) => { + const files = content[field] + if (!Array.isArray(files)) return [] + return files.flatMap((file: { name?: string; url?: string }) => { + if (!file.url) return [] + const name = (file.name ?? '附件').replace(/[\[\]\\]/g, '\\$&') + return `${field === 'image_list' ? '!' : ''}[${name}](<${file.url.replace(/[<>\r\n]/g, '')}>)` + }) + }) + return [text, ...attachments].filter(Boolean).join('\n\n') +} + +export function useConversationExport() { + const messages = useMessageListStore() + const conversations = useConversationListStore() + const loading = ref(false) + const disabled = computed(() => !messages.messages.value.length || messages.loading.value || messages.streamLoading.value || loading.value) + const fileName = () => + (conversations.currentConversation.value?.abstract || conversations.currentChatId.value || '对话记录').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_') + + async function exportText(format: 'markdown' | 'html') { + if (disabled.value) return + loading.value = true + const name = fileName() + try { + const markdown = messages.messages.value + .map((message) => `## ${message.role === 'USER' ? '用户' : '助手'}\n\n${message.content.map(contentToMarkdown).filter(Boolean).join('\n\n')}`) + .join('\n\n') + if (format === 'markdown') { + downloadBlob(new Blob([markdown], { type: 'text/markdown;charset=utf-8' }), `${name}.md`) + } else { + const { marked } = await import('marked') + const body = DOMPurify.sanitize(await marked.parse(markdown), { FORBID_TAGS: ['style', 'script', 'iframe', 'form', 'input', 'button'] }) + const html = `对话记录${body}` + downloadBlob(new Blob([html], { type: 'text/html;charset=utf-8' }), `${name}.html`) + } + } catch { + MsgError('导出失败,请稍后重试') + } finally { + loading.value = false + } + } + return { disabled, loading, fileName, exportText } +} diff --git a/ui/src/conversation-panel/components/header/header-actions/index.vue b/ui/src/conversation-panel/components/header/header-actions/index.vue new file mode 100644 index 00000000000..f26c16b2299 --- /dev/null +++ b/ui/src/conversation-panel/components/header/header-actions/index.vue @@ -0,0 +1,49 @@ + + + diff --git a/ui/src/conversation-panel/components/header/portal/index.vue b/ui/src/conversation-panel/components/header/portal/index.vue index 2e37e6e6ce4..ba1b5fdd763 100644 --- a/ui/src/conversation-panel/components/header/portal/index.vue +++ b/ui/src/conversation-panel/components/header/portal/index.vue @@ -2,7 +2,9 @@ import { computed } from 'vue' import { useConversationListStore } from '../../../left-sidebar/conversation-list/index' import { useMessageListStore } from '../../message-list/index' -import { useShowShare } from '../../../view/index' +import { DEVICE_TYPE, useDeviceType, useShowShare } from '../../../view/index' +import HeaderActions from '../header-actions/index.vue' +import ExportAction from '../header-actions/export/ExportAction.vue' const props = defineProps<{ /** 新建对话;不传时在当前应用中新建(门户在没有当前智能体时改为选择智能体) */ @@ -16,6 +18,7 @@ const msgs = useMessageListStore() // 回答生成中或没有消息时不可分享 const canShare = computed(() => msgs.messages.value.length > 0 && !msgs.streamLoading.value && !msgs.loading.value) const showShare = useShowShare() +const deviceType = useDeviceType() const handleNewConversation = () => (props.newConversation ? props.newConversation() : list.newConversation()) @@ -47,19 +50,13 @@ const handleNewConversation = () => (props.newConversation ? props.newConversati - - - - - - - - - - - - - + + + + + + + diff --git a/ui/src/conversation-panel/components/message-list/index.vue b/ui/src/conversation-panel/components/message-list/index.vue index e71db0951e4..62ac7b473a6 100644 --- a/ui/src/conversation-panel/components/message-list/index.vue +++ b/ui/src/conversation-panel/components/message-list/index.vue @@ -71,7 +71,7 @@ const showUserAvatar = computed(() => appInfo.value?.show_user_avatar) -import { computed } from 'vue' +import { computed, provide, ref } from 'vue' +import { EXPORT_MESSAGE_ELEMENT_KEY } from '../../components/header/header-actions/export/context.ts' import ChatHeader from '../../components/header/chat/index.vue' import MessageList from '../../components/message-list/index.vue' import MessageInput from '../../components/message-input/index.vue' import { useConversationListStore } from '../../left-sidebar/conversation-list/index' import { useMessageInputStore } from '../../components/message-input/index' +const mainRef = ref() +provide(EXPORT_MESSAGE_ELEMENT_KEY, () => mainRef.value?.querySelector('.mk-conversation-content') ?? null) const list = useConversationListStore() const input = useMessageInputStore() // 拖拽到面板任意处 → 交给 message-input 接收文件 @@ -15,7 +18,7 @@ const disclaimerText = computed(() => (list.appInfo.value?.disclaimer ? list.app