From 559fcecf777d4b8f3f88bc95fc1cb5010a17f58d Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 10 Jul 2025 02:17:32 +0800 Subject: [PATCH] refactor(CodeBlockView): replace HtmlArtifacts component with HtmlArtifactsCard - Removed the obsolete HtmlArtifacts component and its associated logic. - Introduced the new HtmlArtifactsCard component to enhance the rendering of HTML artifacts. - Updated the CodeBlockView to utilize HtmlArtifactsCard, improving maintainability and user experience. - Added a new HtmlArtifactsPopup component for better HTML content preview and editing capabilities. - Enhanced localization by adding translation keys for HTML artifacts in multiple languages. --- .../CodeBlockView/HtmlArtifacts.tsx | 70 --- .../CodeBlockView/HtmlArtifactsCard.tsx | 408 ++++++++++++++++ .../CodeBlockView/HtmlArtifactsPopup.tsx | 445 ++++++++++++++++++ .../src/components/CodeBlockView/index.tsx | 15 +- .../src/components/CodeEditor/index.tsx | 6 +- src/renderer/src/context/AntdProvider.tsx | 1 - src/renderer/src/i18n/locales/en-us.json | 6 + src/renderer/src/i18n/locales/ja-jp.json | 6 + src/renderer/src/i18n/locales/ru-ru.json | 6 + src/renderer/src/i18n/locales/zh-cn.json | 6 + src/renderer/src/i18n/locales/zh-tw.json | 6 + src/renderer/src/i18n/translate/el-gr.json | 6 + src/renderer/src/i18n/translate/es-es.json | 6 + src/renderer/src/i18n/translate/fr-fr.json | 6 + src/renderer/src/i18n/translate/pt-pt.json | 6 + .../src/pages/home/Messages/MessageEditor.tsx | 2 +- .../src/pages/home/Tabs/SettingsTab.tsx | 3 +- 17 files changed, 923 insertions(+), 81 deletions(-) delete mode 100644 src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx create mode 100644 src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx create mode 100644 src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx deleted file mode 100644 index 87dc172bd6..0000000000 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { ExpandOutlined, LinkOutlined } from '@ant-design/icons' -import { AppLogo } from '@renderer/config/env' -import { useMinappPopup } from '@renderer/hooks/useMinappPopup' -import { extractTitle } from '@renderer/utils/formats' -import { Button } from 'antd' -import { FC } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -interface Props { - html: string -} - -const Artifacts: FC = ({ html }) => { - const { t } = useTranslation() - const { openMinapp } = useMinappPopup() - - /** - * 在应用内打开 - */ - const handleOpenInApp = async () => { - const path = await window.api.file.createTempFile('artifacts-preview.html') - await window.api.file.write(path, html) - const filePath = `file://${path}` - const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview') - openMinapp({ - id: 'artifacts-preview', - name: title, - logo: AppLogo, - url: filePath - }) - } - - /** - * 外部链接打开 - */ - const handleOpenExternal = async () => { - const path = await window.api.file.createTempFile('artifacts-preview.html') - await window.api.file.write(path, html) - const filePath = `file://${path}` - - if (window.api.shell && window.api.shell.openExternal) { - window.api.shell.openExternal(filePath) - } else { - console.error(t('artifacts.preview.openExternal.error.content')) - } - } - - return ( - - - - - - ) -} - -const Container = styled.div` - margin: 10px; - display: flex; - flex-direction: row; - gap: 8px; - padding-bottom: 10px; -` - -export default Artifacts diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx new file mode 100644 index 0000000000..0691a25d18 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx @@ -0,0 +1,408 @@ +import { CodeOutlined, LinkOutlined } from '@ant-design/icons' +import { useTheme } from '@renderer/context/ThemeProvider' +import { ThemeMode } from '@renderer/types' +import { extractTitle } from '@renderer/utils/formats' +import { Button } from 'antd' +import { Code, Download, Globe, Sparkles } from 'lucide-react' +import { FC, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ClipLoader } from 'react-spinners' +import styled, { keyframes } from 'styled-components' + +import HtmlArtifactsPopup from './HtmlArtifactsPopup' + +interface Props { + html: string +} + +const HtmlArtifactsCard: FC = ({ html }) => { + const { t } = useTranslation() + const title = extractTitle(html) || 'HTML Artifacts' + const [isPopupOpen, setIsPopupOpen] = useState(false) + const { theme } = useTheme() + + const htmlContent = html || '' + const hasContent = htmlContent.trim().length > 0 + + // 判断是否正在流式生成的逻辑 + const isStreaming = useMemo(() => { + if (!hasContent) return false + + const trimmedHtml = htmlContent.trim() + + // 检查 HTML 是否看起来是完整的 + const indicators = { + // 1. 检查常见的 HTML 结构完整性 + hasHtmlTag: /]*>/i.test(trimmedHtml), + hasClosingHtmlTag: /<\/html\s*>$/i.test(trimmedHtml), + + // 2. 检查 body 标签完整性 + hasBodyTag: /]*>/i.test(trimmedHtml), + hasClosingBodyTag: /<\/body\s*>/i.test(trimmedHtml), + + // 3. 检查是否以未闭合的标签结尾 + endsWithIncompleteTag: /<[^>]*$/.test(trimmedHtml), + + // 4. 检查是否有未配对的标签 + hasUnmatchedTags: checkUnmatchedTags(trimmedHtml), + + // 5. 检查是否以常见的"流式结束"模式结尾 + endsWithTypicalCompletion: /(<\/html>\s*|<\/body>\s*|<\/div>\s*|<\/script>\s*|<\/style>\s*)$/i.test(trimmedHtml) + } + + // 如果有明显的未完成标志,则认为正在生成 + if (indicators.endsWithIncompleteTag || indicators.hasUnmatchedTags) { + return true + } + + // 如果有 HTML 结构但不完整 + if (indicators.hasHtmlTag && !indicators.hasClosingHtmlTag) { + return true + } + + // 如果有 body 结构但不完整 + if (indicators.hasBodyTag && !indicators.hasClosingBodyTag) { + return true + } + + // 对于简单的 HTML 片段,检查是否看起来是完整的 + if (!indicators.hasHtmlTag && !indicators.hasBodyTag) { + // 如果是简单片段且没有明显的结束标志,可能还在生成 + return !indicators.endsWithTypicalCompletion && trimmedHtml.length < 500 + } + + return false + }, [htmlContent, hasContent]) + + // 检查未配对标签的辅助函数 + function checkUnmatchedTags(html: string): boolean { + const stack: string[] = [] + const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g + let match + + while ((match = tagRegex.exec(html)) !== null) { + const [fullTag, tagName] = match + const isClosing = fullTag.startsWith('') || ['img', 'br', 'hr', 'input', 'meta', 'link'].includes(tagName.toLowerCase()) + + if (isSelfClosing) continue + + if (isClosing) { + if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) { + return true // 找到不匹配的闭合标签 + } + } else { + stack.push(tagName.toLowerCase()) + } + } + + return stack.length > 0 // 还有未闭合的标签 + } + + // 获取格式化的代码预览 + function getFormattedCodePreview(html: string): string { + const trimmed = html.trim() + const lines = trimmed.split('\n') + const lastFewLines = lines.slice(-3) // 显示最后3行 + return lastFewLines.join('\n') + } + + /** + * 在编辑器中打开 + */ + const handleOpenInEditor = () => { + setIsPopupOpen(true) + } + + /** + * 关闭弹窗 + */ + const handleClosePopup = () => { + setIsPopupOpen(false) + } + + /** + * 外部链接打开 + */ + const handleOpenExternal = async () => { + const path = await window.api.file.createTempFile('artifacts-preview.html') + await window.api.file.write(path, htmlContent) + const filePath = `file://${path}` + + if (window.api.shell && window.api.shell.openExternal) { + window.api.shell.openExternal(filePath) + } else { + console.error(t('artifacts.preview.openExternal.error.content')) + } + } + + /** + * 下载到本地 + */ + const handleDownload = async () => { + const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html` + await window.api.file.save(fileName, htmlContent) + window.message.success({ content: t('message.download.success'), key: 'download' }) + } + + return ( + <> + +
+ + {isStreaming ? : } + + + {title} + + + HTML + + + {isStreaming && ( + + + {t('html_artifacts.generating')} + + )} +
+ + {isStreaming && !hasContent ? ( + + + {t('html_artifacts.generating_content', 'Generating content...')} + + ) : isStreaming && hasContent ? ( + <> + + + + $ + + {getFormattedCodePreview(htmlContent)} + + + + + + + + + + ) : ( + + + + + + )} + +
+ + {/* 弹窗组件 */} + + + ) +} + +const shimmer = keyframes` + 0% { + background-position: -200px 0; + } + 100% { + background-position: calc(200px + 100%) 0; + } +` + +const Container = styled.div<{ $isStreaming: boolean }>` + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + overflow: hidden; + margin: 16px 0; +` + +const GeneratingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + padding: 20px; + min-height: 78px; +` + +const GeneratingText = styled.div` + font-size: 14px; + color: var(--color-text-secondary); +` + +const Header = styled.div` + display: flex; + align-items: center; + gap: 12px; + padding: 20px 24px 16px; + background: var(--color-background-soft); + border-bottom: 1px solid var(--color-border); + position: relative; + border-radius: 8px 8px 0 0; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #3b82f6, #8b5cf6, #06b6d4); + background-size: 200% 100%; + animation: ${shimmer} 3s ease-in-out infinite; + border-radius: 8px 8px 0 0; + } +` + +const IconWrapper = styled.div<{ $isStreaming: boolean }>` + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + border-radius: 12px; + color: white; + box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3); + transition: background 0.3s ease; + + ${(props) => + props.$isStreaming && + ` + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); /* Darker orange for loading */ + box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3); + `} +` + +const TitleSection = styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +` + +const Title = styled.h3` + margin: 0 !important; + font-size: 16px; + font-weight: 600; + color: var(--color-text); + line-height: 1.4; +` + +const TypeBadge = styled.div` + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: var(--color-background-mute); + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 11px; + font-weight: 500; + color: var(--color-text-secondary); + width: fit-content; +` + +const StreamingIndicator = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--color-status-warning); + border: 1px solid var(--color-status-warning); + border-radius: 8px; + color: var(--color-text); + font-size: 12px; + opacity: 0.9; + + [theme-mode='light'] & { + background: #fef3c7; + border-color: #fbbf24; + color: #92400e; + } +` + +const StreamingText = styled.div` + display: flex; + align-items: center; + gap: 4px; + font-weight: 500; +` + +const Content = styled.div` + padding: 0; + background: var(--color-background); +` + +const ButtonContainer = styled.div` + margin: 16px; + display: flex; + flex-direction: row; + gap: 8px; +` + +const TerminalPreview = styled.div<{ $theme: ThemeMode }>` + margin: 16px; + background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')}; + border-radius: 8px; + overflow: hidden; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; +` + +const TerminalContent = styled.div<{ $theme: ThemeMode }>` + padding: 12px; + background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')}; + color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')}; + font-size: 13px; + line-height: 1.4; + min-height: 80px; +` + +const TerminalLine = styled.div` + display: flex; + align-items: flex-start; + gap: 8px; +` + +const TerminalCodeLine = styled.span<{ $theme: ThemeMode }>` + flex: 1; + white-space: pre-wrap; + word-break: break-word; + color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')}; + background-color: transparent !important; +` + +const TerminalPrompt = styled.span<{ $theme: ThemeMode }>` + color: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')}; + font-weight: bold; + flex-shrink: 0; +` + +const TerminalCursor = styled.span<{ $theme: ThemeMode }>` + display: inline-block; + width: 2px; + height: 16px; + background: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')}; + animation: ${keyframes` + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } + `} 1s infinite; + margin-left: 2px; +` + +export default HtmlArtifactsCard diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx new file mode 100644 index 0000000000..afba9f04e1 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -0,0 +1,445 @@ +import CodeEditor from '@renderer/components/CodeEditor' +import { isMac } from '@renderer/config/constant' +import { Button, Modal } from 'antd' +import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface HtmlArtifactsPopupProps { + open: boolean + title: string + html: string + onClose: () => void +} + +type ViewMode = 'split' | 'code' | 'preview' + +// 视图模式配置 +const VIEW_MODE_CONFIG = { + split: { + key: 'split' as const, + icon: MonitorSpeaker, + i18nKey: 'html_artifacts.split' + }, + code: { + key: 'code' as const, + icon: Code, + i18nKey: 'html_artifacts.code' + }, + preview: { + key: 'preview' as const, + icon: Monitor, + i18nKey: 'html_artifacts.preview' + } +} as const + +// 抽取头部组件 +interface ModalHeaderProps { + title: string + isFullscreen: boolean + viewMode: ViewMode + onViewModeChange: (mode: ViewMode) => void + onToggleFullscreen: () => void + onCancel: () => void +} + +const ModalHeaderComponent: React.FC = ({ + title, + isFullscreen, + viewMode, + onViewModeChange, + onToggleFullscreen, + onCancel +}) => { + const { t } = useTranslation() + + const viewButtons = useMemo(() => { + return Object.values(VIEW_MODE_CONFIG).map(({ key, icon: Icon, i18nKey }) => ( + } + onClick={() => onViewModeChange(key)}> + {t(i18nKey)} + + )) + }, [viewMode, onViewModeChange, t]) + + return ( + + + {title} + + + {viewButtons} + + +