feat: add mermaid preview and download feature

This commit is contained in:
kangfenmao 2024-11-10 17:09:18 +08:00
parent 79cabadfb8
commit d258c1cfe2
14 changed files with 294 additions and 55 deletions

View File

@ -1,7 +1,9 @@
import { locales } from '@main/utils/locales'
import { app, Menu, nativeImage, nativeTheme, Tray } from 'electron' import { app, Menu, nativeImage, nativeTheme, Tray } from 'electron'
import iconDark from '../../../build/tray_icon_dark.png?asset' import iconDark from '../../../build/tray_icon_dark.png?asset'
import iconLight from '../../../build/tray_icon_light.png?asset' import iconLight from '../../../build/tray_icon_light.png?asset'
import { configManager } from './ConfigManager'
import { windowService } from './WindowService' import { windowService } from './WindowService'
export class TrayService { export class TrayService {
@ -40,13 +42,16 @@ export class TrayService {
this.tray = tray this.tray = tray
const locale = locales[configManager.getLanguage()]
const { tray: trayLocale } = locale.translation
const contextMenu = Menu.buildFromTemplate([ const contextMenu = Menu.buildFromTemplate([
{ {
label: '显示窗口', label: trayLocale.show_window,
click: () => windowService.showMainWindow() click: () => windowService.showMainWindow()
}, },
{ {
label: '退出', label: trayLocale.quit,
click: () => this.quit() click: () => this.quit()
} }
]) ])

View File

@ -4,8 +4,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" /> <meta name="viewport" content="initial-scale=1, width=device-width" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: *; frame-src * file:" /> <meta http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<style> <style>
html, html,
body { body {
@ -37,4 +38,4 @@
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@ -112,6 +112,9 @@
overflow-x: auto; overflow-x: auto;
font-family: 'Fira Code', 'Courier New', Courier, monospace; font-family: 'Fira Code', 'Courier New', Courier, monospace;
background-color: var(--color-background-mute); background-color: var(--color-background-mute);
&:has(> .mermaid) {
background-color: transparent;
}
&:not(pre pre) { &:not(pre pre) {
> code:not(pre pre > code) { > code:not(pre pre > code) {
padding: 15px; padding: 15px;

File diff suppressed because one or more lines are too long

View File

@ -60,7 +60,6 @@ export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ childre
await highlighter.loadLanguage(language as BundledLanguage) await highlighter.loadLanguage(language as BundledLanguage)
console.log(`Loaded language: ${language}`) console.log(`Loaded language: ${language}`)
} else { } else {
console.warn(`Language '${language}' is not supported`)
return `<pre><code>${code}</code></pre>` return `<pre><code>${code}</code></pre>`
} }
} }

View File

@ -13,25 +13,13 @@ export const useMermaid = () => {
runAsyncFunction(async () => { runAsyncFunction(async () => {
if (!window.mermaid) { if (!window.mermaid) {
await loadScript('https://unpkg.com/mermaid@11.4.0/dist/mermaid.min.js') await loadScript('https://unpkg.com/mermaid@11.4.0/dist/mermaid.min.js')
window.mermaid.initialize({
startOnLoad: true,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
window.mermaid.contentLoaded()
} }
window.mermaid.initialize({
startOnLoad: true,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
window.mermaid.contentLoaded()
}) })
}, [])
useEffect(() => {
setTimeout(() => {
if (window.mermaid) {
window.mermaid.initialize({
startOnLoad: true,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
window.mermaid.contentLoaded()
}
}, 2000)
}, [theme]) }, [theme])
useEffect(() => { useEffect(() => {

View File

@ -406,6 +406,21 @@
"messages": "Messages", "messages": "Messages",
"conversation_details": "Conversation Details", "conversation_details": "Conversation Details",
"conversation_history": "Conversation History" "conversation_history": "Conversation History"
},
"mermaid": {
"title": "Mermaid Diagram",
"download": {
"svg": "Download SVG",
"png": "Download PNG"
},
"tabs": {
"preview": "Preview",
"source": "Source"
}
},
"tray": {
"show_window": "Show Window",
"quit": "Quit"
} }
} }
} }

View File

@ -406,6 +406,21 @@
"messages": "消息数", "messages": "消息数",
"conversation_details": "会话详情", "conversation_details": "会话详情",
"conversation_history": "会话历史" "conversation_history": "会话历史"
},
"mermaid": {
"title": "Mermaid 图表",
"download": {
"svg": "下载 SVG",
"png": "下载 PNG"
},
"tabs": {
"preview": "预览",
"source": "源码"
}
},
"tray": {
"show_window": "显示窗口",
"quit": "退出"
} }
} }
} }

View File

@ -406,6 +406,21 @@
"messages": "訊息數", "messages": "訊息數",
"conversation_details": "會話詳情", "conversation_details": "會話詳情",
"conversation_history": "會話歷史" "conversation_history": "會話歷史"
},
"mermaid": {
"title": "Mermaid 圖表",
"download": {
"svg": "下載 SVG",
"png": "下載 PNG"
},
"tabs": {
"preview": "預覽",
"source": "原始碼"
}
},
"tray": {
"show_window": "顯示視窗",
"quit": "退出"
} }
} }
} }

View File

@ -113,7 +113,7 @@ const AgentsPage: FC = () => {
const tabItems = useMemo(() => { const tabItems = useMemo(() => {
let groups = Object.keys(filteredAgentGroups) let groups = Object.keys(filteredAgentGroups)
groups = groups.includes('办公') ? [groups[0], '办公', ...groups.slice(1)] : groups groups = groups.includes('精选') ? [groups[0], '精选', ...groups.slice(1)] : groups
return groups.map((group, i) => { return groups.map((group, i) => {
const id = String(i + 1) const id = String(i + 1)

View File

@ -47,10 +47,10 @@ export const groupTranslations: GroupTranslations = {
'zh-CN': '写作', 'zh-CN': '写作',
'zh-TW': '寫作' 'zh-TW': '寫作'
}, },
Artifacts: { : {
'en-US': 'Artifacts', 'en-US': 'Featured',
'zh-CN': 'Artifacts', 'zh-CN': '精选',
'zh-TW': 'Artifacts' 'zh-TW': '精選'
}, },
: { : {
'en-US': 'Programming', 'en-US': 'Programming',

View File

@ -1,5 +1,7 @@
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import MermaidPopup from './MermaidPopup'
interface Props { interface Props {
chart: string chart: string
} }
@ -9,7 +11,15 @@ const Mermaid: React.FC<Props> = ({ chart }) => {
window?.mermaid?.contentLoaded() window?.mermaid?.contentLoaded()
}, []) }, [])
return <div className="mermaid">{chart}</div> const onPreview = () => {
MermaidPopup.show({ chart })
}
return (
<div className="mermaid" onClick={onPreview} style={{ cursor: 'pointer' }}>
{chart}
</div>
)
} }
export default Mermaid export default Mermaid

View File

@ -0,0 +1,164 @@
import { TopView } from '@renderer/components/TopView'
import { download } from '@renderer/utils/download'
import { Button, Modal, Space, Tabs } from 'antd'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface ShowParams {
chart: string
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ resolve, chart }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const onOk = () => {
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
const handleDownload = async (format: 'svg' | 'png') => {
try {
const element = document.querySelector('.mermaid')
if (!element) return
const timestamp = Date.now()
if (format === 'svg') {
const svgElement = element.querySelector('svg')
if (!svgElement) return
const svgData = new XMLSerializer().serializeToString(svgElement)
const blob = new Blob([svgData], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
download(url, `mermaid-diagram-${timestamp}.svg`)
URL.revokeObjectURL(url)
} else if (format === 'png') {
const svgElement = element.querySelector('svg')
if (!svgElement) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
const viewBox = svgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
const width = viewBox[2] || svgElement.clientWidth || svgElement.getBoundingClientRect().width
const height = viewBox[3] || svgElement.clientHeight || svgElement.getBoundingClientRect().height
const svgData = new XMLSerializer().serializeToString(svgElement)
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(svgBlob)
img.onload = () => {
const scale = 3
canvas.width = width * scale
canvas.height = height * scale
if (ctx) {
ctx.scale(scale, scale)
ctx.drawImage(img, 0, 0, width, height)
}
canvas.toBlob((blob) => {
if (blob) {
const pngUrl = URL.createObjectURL(blob)
download(pngUrl, `mermaid-diagram-${timestamp}.png`)
URL.revokeObjectURL(pngUrl)
}
}, 'image/png')
URL.revokeObjectURL(url)
}
img.src = url
}
} catch (error) {
console.error('Download failed:', error)
}
}
useEffect(() => {
window?.mermaid?.contentLoaded()
}, [])
return (
<Modal
title={t('mermaid.title')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
width={1000}
footer={[
<Space key="download-buttons">
<Button onClick={() => handleDownload('svg')}>{t('mermaid.download.svg')}</Button>
<Button onClick={() => handleDownload('png')}>{t('mermaid.download.png')}</Button>
</Space>
]}>
<Tabs
items={[
{
key: 'preview',
label: t('mermaid.tabs.preview'),
children: (
<div
className="mermaid"
style={{
maxHeight: 'calc(80vh - 200px)',
overflowY: 'auto',
padding: '16px',
display: 'flex',
justifyContent: 'center'
}}>
{chart}
</div>
)
},
{
key: 'source',
label: t('mermaid.tabs.source'),
children: (
<pre
style={{
maxHeight: 'calc(80vh - 200px)',
overflowY: 'auto',
padding: '16px'
}}>
{chart}
</pre>
)
}
]}
/>
</Modal>
)
}
export default class MermaidPopup {
static topviewId = 0
static hide() {
TopView.hide('MermaidPopup')
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
this.hide()
}}
/>,
'MermaidPopup'
)
})
}
}

View File

@ -1,44 +1,57 @@
export const download = (url: string) => { export const download = (url: string, filename?: string) => {
// 处理 file:// 协议 // 处理 file:// 协议
if (url.startsWith('file://')) { if (url.startsWith('file://')) {
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = url.split('/').pop() || 'download' link.download = filename || url.split('/').pop() || 'download'
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
link.remove() link.remove()
return return
} }
// 处理 Blob URL
if (url.startsWith('blob:')) {
const link = document.createElement('a')
link.href = url
link.download = filename || `${Date.now()}_diagram.svg`
document.body.appendChild(link)
link.click()
link.remove()
return
}
// 处理普通 URL
fetch(url) fetch(url)
.then((response) => { .then((response) => {
// 尝试从Content-Disposition头获取文件名 let finalFilename = filename || 'download'
const contentDisposition = response.headers.get('Content-Disposition')
let filename = 'download' // 默认文件名
if (contentDisposition) { if (!filename) {
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i) // 尝试从Content-Disposition头获取文件名
if (filenameMatch) { const contentDisposition = response.headers.get('Content-Disposition')
filename = filenameMatch[1] if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i)
if (filenameMatch) {
finalFilename = filenameMatch[1]
}
} }
}
// 如果URL中有文件名使用URL中的文件名 // 如果URL中有文件名使用URL中的文件名
const urlFilename = url.split('/').pop() const urlFilename = url.split('/').pop()
if (urlFilename && urlFilename.includes('.')) { if (urlFilename && urlFilename.includes('.')) {
filename = urlFilename finalFilename = urlFilename
} }
// 如果文件名没有后缀根据Content-Type添加后缀 // 如果文件名没有后缀根据Content-Type添加后缀
if (!filename.includes('.')) { if (!finalFilename.includes('.')) {
const contentType = response.headers.get('Content-Type') const contentType = response.headers.get('Content-Type')
const extension = getExtensionFromMimeType(contentType) const extension = getExtensionFromMimeType(contentType)
filename += extension finalFilename += extension
} }
// 添加时间戳以确保文件名唯一 // 添加时间戳以确保文件名唯一
const timestamp = Date.now() finalFilename = `${Date.now()}_${finalFilename}`
const finalFilename = `${timestamp}_${filename}` }
return response.blob().then((blob) => ({ blob, finalFilename })) return response.blob().then((blob) => ({ blob, finalFilename }))
}) })
@ -62,6 +75,7 @@ function getExtensionFromMimeType(mimeType: string | null): string {
'image/jpeg': '.jpg', 'image/jpeg': '.jpg',
'image/png': '.png', 'image/png': '.png',
'image/gif': '.gif', 'image/gif': '.gif',
'image/svg+xml': '.svg',
'application/pdf': '.pdf', 'application/pdf': '.pdf',
'text/plain': '.txt', 'text/plain': '.txt',
'application/msword': '.doc', 'application/msword': '.doc',