返回 AiToEarn
generateShareImages.ts
根目录 / project / aitoearn-web / src / components / Chat / Share / generateShareImages.ts
1 /**
2 * generateShareImages - 分享图片生成工具
3 * 将聊天消息渲染为可分享的图片
4 */
5 import type { IDisplayMessage } from '@/store/agent'
6 import html2canvas from 'html2canvas-pro'
7 import QRCode from 'qrcode'
8 import React from 'react'
9 import { createRoot } from 'react-dom/client'
10 import { setDisableLanguageSwitch } from '@/app/i18n/client'
11 import logo from '@/assets/images/logo.png'
12 import ChatMessage from '@/components/Chat/ChatMessage'
13 import { getOssUrl } from '@/utils/oss'
14
15 /** 图片生成选项 */
16 export interface GenerateImageOptions {
17 /** 应用标题 */
18 appTitle?: string
19 /** 应用URL */
20 appUrl?: string
21 /** 分享链接(用于生成二维码) */
22 shareUrl?: string
23 /** 链接过期时间 */
24 expiresAt?: string
25 }
26
27 export async function generateImageFromMessages(
28 messages: IDisplayMessage[],
29 userName?: string,
30 options?: GenerateImageOptions,
31 ): Promise<Blob[]> {
32 // 禁用语言自动切换,防止 ChatMessage 中的 useTransClient 改变全局语言
33 setDisableLanguageSwitch(true)
34
35 try {
36 const blob = await generateImageFromAllMessages(messages, userName, options)
37 if (!blob) {
38 throw new Error('Failed to generate combined image')
39 }
40 return [blob]
41 }
42 finally {
43 // 恢复语言切换功能
44 setDisableLanguageSwitch(false)
45 }
46 }
47
48 async function generateImageFromAllMessages(
49 messages: IDisplayMessage[],
50 userName?: string,
51 options?: GenerateImageOptions,
52 ): Promise<Blob | null> {
53 // 处理消息中的媒体URL
54 const processedMessages = messages.map(message => ({
55 ...message,
56 medias:
57 message.medias?.map(media => ({
58 ...media,
59 url: getOssUrl(media.url),
60 })) || [],
61 }))
62
63 // 创建临时容器
64 const container = document.createElement('div')
65 container.style.cssText = `
66 position: fixed;
67 left: -9999px;
68 top: 0;
69 width: 600px;
70 background: #ffffff;
71 padding: 20px;
72 font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, 'Helvetica Neue', Arial;
73 `
74
75 // 设置 CSS 变量(浅色主题值)
76 const cssVars: Record<string, string> = {
77 '--background': '#ffffff',
78 '--foreground': '#0f172a',
79 '--card': '#ffffff',
80 '--card-foreground': '#0f172a',
81 '--muted': '#f4f4f5',
82 '--muted-foreground': '#71717a',
83 '--border': '#e4e4e7',
84 '--primary': '#18181b',
85 '--primary-foreground': '#fafafa',
86 '--secondary': '#f4f4f5',
87 '--secondary-foreground': '#18181b',
88 '--accent': '#f4f4f5',
89 '--accent-foreground': '#18181b',
90 '--destructive': '#ef4444',
91 '--destructive-foreground': '#fafafa',
92 '--ring': '#a1a1aa',
93 '--radius': '0.625rem',
94 '--success': '#22c55e',
95 '--success-foreground': '#fafafa',
96 '--warning': '#f59e0b',
97 '--warning-foreground': '#18181b',
98 '--info': '#3b82f6',
99 '--info-foreground': '#fafafa',
100 // Tailwind v4 使用 --color-* 变量
101 '--color-background': '#ffffff',
102 '--color-foreground': '#0f172a',
103 '--color-card': '#ffffff',
104 '--color-card-foreground': '#0f172a',
105 '--color-muted': '#f4f4f5',
106 '--color-muted-foreground': '#71717a',
107 '--color-border': '#e4e4e7',
108 '--color-primary': '#18181b',
109 '--color-primary-foreground': '#fafafa',
110 '--color-success': '#22c55e',
111 '--color-destructive': '#ef4444',
112 '--color-warning': '#f59e0b',
113 '--color-info': '#3b82f6',
114 }
115
116 Object.entries(cssVars).forEach(([key, value]) => {
117 container.style.setProperty(key, value)
118 })
119
120 // 使用 React 渲染
121 const root = createRoot(container)
122
123 await new Promise<void>((resolve) => {
124 const messageElements = processedMessages.map((message, index) =>
125 React.createElement(ChatMessage, {
126 key: message.id || index,
127 role: message.role === 'system' ? 'assistant' : message.role,
128 content: message.content,
129 medias: message.medias,
130 status: message.status,
131 errorMessage: message.errorMessage,
132 createdAt: message.createdAt,
133 steps: message.steps,
134 actions: [], // 不渲染 actions,避免路由相关错误
135 className: 'max-w-full',
136 }),
137 )
138
139 const appTitle = options?.appTitle || 'AiToEarn'
140 const appUrl = options?.appUrl || 'https://aitoearn.ai'
141 const shareUrl = options?.shareUrl
142 const expiresAt = options?.expiresAt
143
144 const headerEl = React.createElement(
145 'div',
146 { style: { display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' } },
147 React.createElement('img', {
148 src: typeof logo === 'string' ? logo : logo.src,
149 style: { width: '40px', height: '40px', borderRadius: '8px' },
150 }),
151 React.createElement(
152 'div',
153 { style: { display: 'flex', flexDirection: 'column' } },
154 React.createElement(
155 'div',
156 { style: { fontSize: '18px', fontWeight: 700, color: '#0f172a' } },
157 appTitle,
158 ),
159 React.createElement('div', { style: { fontSize: '12px', color: '#6b7280' } }, appUrl),
160 ),
161 )
162
163 root.render(
164 React.createElement(
165 'div',
166 { style: { display: 'flex', flexDirection: 'column', gap: '16px' } },
167 headerEl,
168 ...messageElements,
169 userName
170 && React.createElement(
171 'div',
172 {
173 style: {
174 fontSize: '12px',
175 color: '#71717a',
176 marginTop: '16px',
177 paddingTop: '12px',
178 borderTop: '1px solid #e4e4e7',
179 },
180 },
181 `Shared by ${userName}`,
182 ),
183 // 底部信息区域(包含二维码)
184 React.createElement(
185 'div',
186 {
187 id: 'share-footer',
188 style: {
189 marginTop: '24px',
190 paddingTop: '20px',
191 borderTop: '1px solid #e4e4e7',
192 display: 'flex',
193 alignItems: 'center',
194 justifyContent: 'space-between',
195 gap: '16px',
196 },
197 },
198 // 左侧:应用信息和日期
199 React.createElement(
200 'div',
201 { style: { display: 'flex', flexDirection: 'column', gap: '8px', flex: 1 } },
202 // 应用标题和 Logo
203 React.createElement(
204 'div',
205 { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
206 React.createElement('img', {
207 src: typeof logo === 'string' ? logo : logo.src,
208 style: { width: '24px', height: '24px', borderRadius: '4px' },
209 }),
210 React.createElement(
211 'span',
212 { style: { fontSize: '14px', fontWeight: 600, color: '#0f172a' } },
213 appTitle,
214 ),
215 ),
216 // 生成日期
217 React.createElement(
218 'div',
219 { style: { fontSize: '12px', color: '#71717a' } },
220 `Generated: ${new Date().toLocaleDateString()}`,
221 ),
222 // 有效期
223 expiresAt
224 && React.createElement(
225 'div',
226 { style: { fontSize: '12px', color: '#71717a' } },
227 `Expires: ${new Date(expiresAt).toLocaleDateString()}`,
228 ),
229 // 分享链接(简短显示)
230 shareUrl
231 && React.createElement(
232 'div',
233 {
234 style: {
235 fontSize: '11px',
236 color: '#94a3b8',
237 wordBreak: 'break-all',
238 maxWidth: '300px',
239 },
240 },
241 shareUrl.length > 50 ? `${shareUrl.substring(0, 50)}...` : shareUrl,
242 ),
243 ),
244 // 右侧:二维码占位(将在 DOM 渲染后替换为实际二维码)
245 shareUrl
246 && React.createElement(
247 'div',
248 {
249 id: 'qr-code-container',
250 style: {
251 width: '100px',
252 height: '100px',
253 backgroundColor: '#f8fafc',
254 borderRadius: '8px',
255 display: 'flex',
256 alignItems: 'center',
257 justifyContent: 'center',
258 border: '1px solid #e2e8f0',
259 },
260 },
261 React.createElement(
262 'span',
263 { style: { fontSize: '12px', color: '#94a3b8' } },
264 'QR Code',
265 ),
266 ),
267 ),
268 ),
269 )
270
271 // 等待组件渲染完成
272 setTimeout(async () => {
273 // 替换媒体URL
274 replaceMediaUrlsWithProxy(container)
275 // 等待视频首帧
276 await ensureVideoThumbnails(container)
277 // 强制设置所有元素的样式(修复透明度问题)
278 forceOpaqueStyles(container)
279
280 // 生成并插入二维码
281 if (shareUrl) {
282 await generateAndInsertQRCode(container, shareUrl)
283 }
284
285 resolve()
286 }, 1000)
287 })
288
289 document.body.appendChild(container)
290
291 try {
292 await waitForImagesToLoad(container)
293 await (document as any).fonts?.ready
294
295 // 在截图前再次强制设置样式,确保所有样式都已正确应用
296 forceOpaqueStyles(container)
297
298 container.style.height = `${container.scrollHeight}px`
299
300 const canvas = await html2canvas(container, {
301 scale: 2,
302 useCORS: true,
303 allowTaint: true,
304 backgroundColor: '#ffffff',
305 logging: process.env.NODE_ENV === 'development',
306 imageTimeout: 15000,
307 removeContainer: false,
308 height: container.scrollHeight,
309 windowHeight: container.scrollHeight,
310 })
311
312 const resultBlob: Blob | null = await new Promise<Blob | null>((resolve, reject) => {
313 const timeout = setTimeout(() => {
314 reject(new Error('Image generation timeout'))
315 }, 45000)
316
317 canvas.toBlob(
318 (blob) => {
319 clearTimeout(timeout)
320 resolve(blob)
321 },
322 'image/png',
323 0.95,
324 )
325 })
326
327 return resultBlob
328 }
329 finally {
330 root.unmount()
331 if (container.parentNode) {
332 container.parentNode.removeChild(container)
333 }
334 }
335 }
336
337 /**
338 * 生成二维码并插入到容器中
339 */
340 async function generateAndInsertQRCode(container: HTMLElement, shareUrl: string): Promise<void> {
341 try {
342 const qrContainer = container.querySelector('#qr-code-container')
343 if (!qrContainer)
344 return
345
346 // 生成二维码 DataURL
347 const qrDataUrl = await QRCode.toDataURL(shareUrl, {
348 width: 100,
349 margin: 1,
350 color: {
351 dark: '#0f172a', // 二维码颜色
352 light: '#ffffff', // 背景色
353 },
354 errorCorrectionLevel: 'M',
355 })
356
357 // 创建二维码图片元素
358 const qrImg = document.createElement('img')
359 qrImg.src = qrDataUrl
360 qrImg.style.cssText = 'width: 100%; height: 100%; border-radius: 4px;'
361 qrImg.alt = 'Share QR Code'
362
363 // 清空占位内容并插入二维码
364 qrContainer.innerHTML = ''
365 qrContainer.appendChild(qrImg)
366 }
367 catch (error) {
368 console.error('Failed to generate QR code:', error)
369 // 保留占位符,不影响截图
370 }
371 }
372
373 /**
374 * 特殊颜色类到 Hex 值的映射(浅色主题)
375 * 用于解决 oklch 颜色在 html2canvas 中无法渲染的问题
376 */
377 const TEXT_COLOR_MAP: Record<string, string> = {
378 'text-success': '#22c55e',
379 'text-destructive': '#ef4444',
380 'text-warning': '#f59e0b',
381 'text-info': '#3b82f6',
382 'text-primary': '#18181b',
383 'text-muted': '#71717a',
384 'text-muted-foreground': '#71717a',
385 'text-foreground': '#0f172a',
386 }
387
388 const BG_COLOR_MAP: Record<string, string> = {
389 'bg-success': '#22c55e',
390 'bg-destructive': '#ef4444',
391 'bg-warning': '#f59e0b',
392 'bg-info': '#3b82f6',
393 'bg-primary': '#18181b',
394 'bg-muted': '#f4f4f5',
395 'bg-card': '#ffffff',
396 'bg-background': '#ffffff',
397 }
398
399 /**
400 * 强制设置所有元素为不透明样式
401 * 解决 CSS 变量和 oklch 颜色在 html2canvas 中无法正确渲染的问题
402 */
403 function forceOpaqueStyles(container: HTMLElement): void {
404 // 首先处理所有消息气泡(bg-card 类)- 这些是最重要的
405 container.querySelectorAll('[class*="bg-card"]').forEach((el) => {
406 const htmlEl = el as HTMLElement
407 htmlEl.style.setProperty('background-color', '#ffffff', 'important')
408 htmlEl.style.setProperty('background', '#ffffff', 'important')
409 htmlEl.style.setProperty('opacity', '1', 'important')
410 })
411
412 // 处理 bg-background 类
413 container.querySelectorAll('[class*="bg-background"]').forEach((el) => {
414 const htmlEl = el as HTMLElement
415 htmlEl.style.setProperty('background-color', '#ffffff', 'important')
416 htmlEl.style.setProperty('opacity', '1', 'important')
417 })
418
419 // 处理 bg-muted 类
420 container.querySelectorAll('[class*="bg-muted"]').forEach((el) => {
421 const htmlEl = el as HTMLElement
422 htmlEl.style.setProperty('background-color', '#f4f4f5', 'important')
423 htmlEl.style.setProperty('opacity', '1', 'important')
424 })
425
426 // 处理圆角消息框
427 container.querySelectorAll('[class*="rounded-2xl"], [class*="rounded-lg"]').forEach((el) => {
428 const htmlEl = el as HTMLElement
429 const className = htmlEl.className?.toString() || ''
430 if (className.includes('border') || className.includes('bg-')) {
431 // 检查是否有背景色设置,如果是透明的就设置为白色
432 const computed = window.getComputedStyle(htmlEl)
433 const bgColor = computed.backgroundColor
434 if (
435 !bgColor
436 || bgColor === 'rgba(0, 0, 0, 0)'
437 || bgColor === 'transparent'
438 || bgColor.includes('oklch')
439 ) {
440 htmlEl.style.setProperty('background-color', '#ffffff', 'important')
441 }
442 htmlEl.style.setProperty('opacity', '1', 'important')
443 }
444 })
445
446 // 处理边框颜色
447 container.querySelectorAll('[class*="border"]').forEach((el) => {
448 const htmlEl = el as HTMLElement
449 const computed = window.getComputedStyle(htmlEl)
450 if (computed.borderColor?.includes('oklch') || computed.borderTopColor?.includes('oklch')) {
451 htmlEl.style.setProperty('border-color', '#e4e4e7', 'important')
452 }
453 })
454
455 // 遍历所有元素,确保没有透明度问题,并处理特殊颜色类
456 container.querySelectorAll('*').forEach((el) => {
457 const htmlEl = el as HTMLElement
458 const computed = window.getComputedStyle(htmlEl)
459 const className = htmlEl.className?.toString() || ''
460
461 // 强制 opacity 为 1
462 if (Number.parseFloat(computed.opacity) < 1) {
463 htmlEl.style.setProperty('opacity', '1', 'important')
464 }
465
466 // 处理特殊文字颜色类
467 let textColorSet = false
468 for (const [colorClass, hexColor] of Object.entries(TEXT_COLOR_MAP)) {
469 if (className.includes(colorClass)) {
470 htmlEl.style.setProperty('color', hexColor, 'important')
471 textColorSet = true
472 break
473 }
474 }
475
476 // 如果没有匹配到特殊颜色类,且颜色包含 oklch,则设置默认颜色
477 if (!textColorSet && computed.color?.includes('oklch')) {
478 htmlEl.style.setProperty('color', '#0f172a', 'important')
479 }
480
481 // 处理特殊背景颜色类
482 for (const [colorClass, hexColor] of Object.entries(BG_COLOR_MAP)) {
483 if (className.includes(colorClass)) {
484 htmlEl.style.setProperty('background-color', hexColor, 'important')
485 break
486 }
487 }
488 })
489 }
490
491 /**
492 * 等待容器内所有图片加载完成
493 */
494 function waitForImagesToLoad(container: HTMLElement, timeoutMs = 15000): Promise<void> {
495 const imgs = Array.from(container.querySelectorAll('img'))
496 if (imgs.length === 0)
497 return Promise.resolve()
498
499 return new Promise<void>((resolve) => {
500 let settled = 0
501 const total = imgs.length
502 const onSettled = () => {
503 settled++
504 if (settled >= total)
505 resolve()
506 }
507
508 setTimeout(() => resolve(), timeoutMs)
509
510 imgs.forEach((img) => {
511 try {
512 const tester = new Image()
513 tester.crossOrigin = 'anonymous'
514 tester.onload = onSettled
515 tester.onerror = onSettled
516 tester.src = img.src
517 }
518 catch {
519 onSettled()
520 }
521 })
522 })
523 }
524
525 /**
526 * 替换媒体 URL 为代理地址
527 */
528 function replaceMediaUrlsWithProxy(container: HTMLElement): void {
529 const awsUrl = process.env.NEXT_PUBLIC_OSS_URL
530 const proxyUrl = process.env.NEXT_PUBLIC_OSS_URL_PROXY
531 if (!awsUrl || !proxyUrl)
532 return
533
534 container.querySelectorAll('img').forEach((img) => {
535 if (img.src?.startsWith(awsUrl)) {
536 img.src = proxyUrl + img.src.substring(awsUrl.length)
537 }
538 })
539
540 container.querySelectorAll('video').forEach((video) => {
541 if (video.src?.startsWith(awsUrl)) {
542 video.src = proxyUrl + video.src.substring(awsUrl.length)
543 }
544 if (video.poster?.startsWith(awsUrl)) {
545 video.poster = proxyUrl + video.poster.substring(awsUrl.length)
546 }
547 })
548 }
549
550 /**
551 * 确保视频显示首帧
552 */
553 async function ensureVideoThumbnails(container: HTMLElement): Promise<void> {
554 const videos = Array.from(container.querySelectorAll('video'))
555 if (videos.length === 0)
556 return
557
558 await Promise.all(
559 videos.map(async (video) => {
560 try {
561 video.preload = 'metadata'
562 video.muted = true
563 video.playsInline = true
564
565 if (video.poster)
566 return
567
568 await new Promise<void>((resolve, reject) => {
569 const timeout = setTimeout(() => reject(new Error('timeout')), 10000)
570 video.onloadedmetadata = () => {
571 clearTimeout(timeout)
572 resolve()
573 }
574 video.onerror = () => {
575 clearTimeout(timeout)
576 reject(new Error('error'))
577 }
578 if (video.readyState >= 1) {
579 clearTimeout(timeout)
580 resolve()
581 }
582 })
583
584 video.currentTime = 0
585 await new Promise<void>((resolve) => {
586 const onSeeked = () => {
587 video.removeEventListener('seeked', onSeeked)
588 setTimeout(resolve, 100)
589 }
590 video.addEventListener('seeked', onSeeked)
591 })
592 }
593 catch {
594 // 视频加载失败,用占位符替换
595 const placeholder = document.createElement('div')
596 placeholder.style.cssText = `
597 width: ${video.offsetWidth || 200}px;
598 height: ${video.offsetHeight || 150}px;
599 background: #f3f4f6;
600 border: 2px dashed #d1d5db;
601 border-radius: 8px;
602 display: flex;
603 align-items: center;
604 justify-content: center;
605 color: #6b7280;
606 font-size: 14px;
607 `
608 placeholder.innerHTML = '🎥 <span>Video</span>'
609 video.parentNode?.replaceChild(placeholder, video)
610 }
611 }),
612 )
613 }
614
615 /**
616 * 从 DOM 节点生成图片
617 */
618 export async function generateImageFromNode(node: HTMLElement, scale = 1): Promise<Blob | null> {
619 if (!node?.isConnected) {
620 throw new Error('Node is not connected to DOM')
621 }
622
623 const originalStyles = {
624 position: node.style.position,
625 left: node.style.left,
626 top: node.style.top,
627 visibility: node.style.visibility,
628 }
629
630 node.style.position = 'fixed'
631 node.style.left = '0'
632 node.style.top = '0'
633 node.style.visibility = 'visible'
634
635 try {
636 const canvas = await html2canvas(node, {
637 scale: Math.max(scale, 1),
638 useCORS: true,
639 allowTaint: false,
640 backgroundColor: '#ffffff',
641 logging: process.env.NODE_ENV === 'development',
642 scrollY: 0,
643 scrollX: 0,
644 width: node.offsetWidth,
645 height: node.offsetHeight,
646 windowWidth: node.offsetWidth,
647 windowHeight: node.offsetHeight,
648 imageTimeout: 10000,
649 removeContainer: false,
650 })
651
652 Object.assign(node.style, originalStyles)
653
654 return new Promise<Blob | null>((resolve, reject) => {
655 const timeout = setTimeout(() => reject(new Error('timeout')), 30000)
656 canvas.toBlob(
657 (blob) => {
658 clearTimeout(timeout)
659 if (!blob)
660 reject(new Error('Failed to generate blob'))
661 else resolve(blob)
662 },
663 'image/png',
664 0.95,
665 )
666 })
667 }
668 catch (error) {
669 Object.assign(node.style, originalStyles)
670 throw error
671 }
672 }
673
673 lines TYPESCRIPT