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.
This commit is contained in:
kangfenmao 2025-07-10 02:17:32 +08:00
parent 1d854c232e
commit 559fcecf77
17 changed files with 923 additions and 81 deletions

View File

@ -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<Props> = ({ 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 (
<Container>
<Button icon={<ExpandOutlined />} onClick={handleOpenInApp}>
{t('chat.artifacts.button.preview')}
</Button>
<Button icon={<LinkOutlined />} onClick={handleOpenExternal}>
{t('chat.artifacts.button.openExternal')}
</Button>
</Container>
)
}
const Container = styled.div`
margin: 10px;
display: flex;
flex-direction: row;
gap: 8px;
padding-bottom: 10px;
`
export default Artifacts

View File

@ -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<Props> = ({ 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: /<html[^>]*>/i.test(trimmedHtml),
hasClosingHtmlTag: /<\/html\s*>$/i.test(trimmedHtml),
// 2. 检查 body 标签完整性
hasBodyTag: /<body[^>]*>/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('</')
const isSelfClosing =
fullTag.endsWith('/>') || ['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 (
<>
<Container $isStreaming={isStreaming}>
<Header>
<IconWrapper $isStreaming={isStreaming}>
{isStreaming ? <Sparkles size={20} color="white" /> : <Globe size={20} color="white" />}
</IconWrapper>
<TitleSection>
<Title>{title}</Title>
<TypeBadge>
<Code size={12} />
<span>HTML</span>
</TypeBadge>
</TitleSection>
{isStreaming && (
<StreamingIndicator>
<ClipLoader size={16} color="currentColor" />
<StreamingText>{t('html_artifacts.generating')}</StreamingText>
</StreamingIndicator>
)}
</Header>
<Content>
{isStreaming && !hasContent ? (
<GeneratingContainer>
<ClipLoader size={20} color="var(--color-primary)" />
<GeneratingText>{t('html_artifacts.generating_content', 'Generating content...')}</GeneratingText>
</GeneratingContainer>
) : isStreaming && hasContent ? (
<>
<TerminalPreview $theme={theme}>
<TerminalContent $theme={theme}>
<TerminalLine>
<TerminalPrompt $theme={theme}>$</TerminalPrompt>
<TerminalCodeLine $theme={theme}>
{getFormattedCodePreview(htmlContent)}
<TerminalCursor $theme={theme} />
</TerminalCodeLine>
</TerminalLine>
</TerminalContent>
</TerminalPreview>
<ButtonContainer>
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary">
{t('chat.artifacts.button.preview')}
</Button>
</ButtonContainer>
</>
) : (
<ButtonContainer>
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary" disabled={!hasContent}>
{t('chat.artifacts.button.preview')}
</Button>
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} disabled={!hasContent}>
{t('chat.artifacts.button.openExternal')}
</Button>
<Button icon={<Download size={16} />} onClick={handleDownload} disabled={!hasContent}>
{t('code_block.download')}
</Button>
</ButtonContainer>
)}
</Content>
</Container>
{/* 弹窗组件 */}
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={handleClosePopup} />
</>
)
}
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

View File

@ -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<ModalHeaderProps> = ({
title,
isFullscreen,
viewMode,
onViewModeChange,
onToggleFullscreen,
onCancel
}) => {
const { t } = useTranslation()
const viewButtons = useMemo(() => {
return Object.values(VIEW_MODE_CONFIG).map(({ key, icon: Icon, i18nKey }) => (
<ViewButton
key={key}
size="small"
type={viewMode === key ? 'primary' : 'default'}
icon={<Icon size={14} />}
onClick={() => onViewModeChange(key)}>
{t(i18nKey)}
</ViewButton>
))
}, [viewMode, onViewModeChange, t])
return (
<ModalHeader>
<HeaderLeft $isFullscreen={isFullscreen}>
<TitleText>{title}</TitleText>
</HeaderLeft>
<HeaderCenter>
<ViewControls>{viewButtons}</ViewControls>
</HeaderCenter>
<HeaderRight>
<Button
onClick={onToggleFullscreen}
type="text"
icon={isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
/>
<Button onClick={onCancel} type="text" icon={<X size={16} />} />
</HeaderRight>
</ModalHeader>
)
}
// 抽取代码编辑器组件
interface CodeSectionProps {
html: string
visible: boolean
onCodeChange: (code: string) => void
}
const CodeSectionComponent: React.FC<CodeSectionProps> = ({ html, visible, onCodeChange }) => {
if (!visible) return null
return (
<CodeSection $visible={visible}>
<CodeEditorWrapper>
<CodeEditor
value={html}
language="html"
editable={false}
onSave={onCodeChange}
style={{ height: '100%' }}
options={{
stream: false,
collapsible: false
}}
/>
</CodeEditorWrapper>
</CodeSection>
)
}
// 抽取预览组件
interface PreviewSectionProps {
html: string
visible: boolean
}
const PreviewSectionComponent: React.FC<PreviewSectionProps> = ({ html, visible }) => {
const htmlContent = html || ''
const [debouncedHtml, setDebouncedHtml] = useState(htmlContent)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const { t } = useTranslation()
// 防抖更新 HTML 内容,避免过于频繁的刷新
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
setDebouncedHtml(htmlContent)
}, 300) // 300ms 防抖延迟
// 清理函数
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [htmlContent])
if (!visible) return null
const isHtmlEmpty = !debouncedHtml.trim()
return (
<PreviewSection $visible={visible}>
{isHtmlEmpty ? (
<EmptyPreview>
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
</EmptyPreview>
) : (
<PreviewFrame
key={debouncedHtml} // 强制重新创建iframe当内容变化时
srcDoc={debouncedHtml}
title="HTML Preview"
sandbox="allow-scripts allow-same-origin allow-forms"
/>
)}
</PreviewSection>
)
}
// 主弹窗组件
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
const [viewMode, setViewMode] = useState<ViewMode>('split')
const [currentHtml, setCurrentHtml] = useState(html)
const [isFullscreen, setIsFullscreen] = useState(false)
// 当外部html更新时同步更新内部状态
useEffect(() => {
setCurrentHtml(html)
}, [html])
// 计算视图可见性
const viewVisibility = useMemo(
() => ({
code: viewMode === 'split' || viewMode === 'code',
preview: viewMode === 'split' || viewMode === 'preview'
}),
[viewMode]
)
// 计算Modal属性
const modalProps = useMemo(
() => ({
width: isFullscreen ? '100vw' : '90vw',
height: isFullscreen ? '100vh' : 'auto',
style: { maxWidth: isFullscreen ? '100vw' : '1400px' }
}),
[isFullscreen]
)
const handleOk = useCallback(() => {
onClose()
}, [onClose])
const handleCancel = useCallback(() => {
onClose()
}, [onClose])
const handleClose = useCallback(() => {
onClose()
}, [onClose])
const handleCodeChange = useCallback((newCode: string) => {
setCurrentHtml(newCode)
}, [])
const toggleFullscreen = useCallback(() => {
setIsFullscreen((prev) => !prev)
}, [])
const handleViewModeChange = useCallback((mode: ViewMode) => {
setViewMode(mode)
}, [])
return (
<StyledModal
$isFullscreen={isFullscreen}
title={
<ModalHeaderComponent
title={title}
isFullscreen={isFullscreen}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
onToggleFullscreen={toggleFullscreen}
onCancel={handleCancel}
/>
}
open={open}
onOk={handleOk}
onCancel={handleCancel}
afterClose={handleClose}
centered
{...modalProps}
footer={null}
closable={false}>
<Container>
<CodeSectionComponent html={currentHtml} visible={viewVisibility.code} onCodeChange={handleCodeChange} />
<PreviewSectionComponent html={currentHtml} visible={viewVisibility.preview} />
</Container>
</StyledModal>
)
}
// 样式组件保持不变
const commonModalBodyStyles = `
padding: 0 !important;
display: flex !important;
flex-direction: column !important;
`
const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
${(props) =>
props.$isFullscreen
? `
.ant-modal-wrap {
padding: 0 !important;
}
.ant-modal {
margin: 0 !important;
padding: 0 !important;
max-width: none !important;
}
.ant-modal-body {
height: calc(100vh - 45px) !important;
${commonModalBodyStyles}
max-height: initial !important;
}
`
: `
.ant-modal-body {
height: 80vh !important;
${commonModalBodyStyles}
min-height: 600px !important;
}
`}
.ant-modal-body {
${commonModalBodyStyles}
}
.ant-modal-content {
border-radius: ${(props) => (props.$isFullscreen ? '0px' : '12px')};
overflow: hidden;
height: ${(props) => (props.$isFullscreen ? '100vh' : 'auto')};
padding: 0 !important;
}
.ant-modal-header {
padding: 10px 24px !important;
border-bottom: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 0 !important;
margin-bottom: 0 !important;
}
.ant-modal-title {
margin: 0;
width: 100%;
}
`
const ModalHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
position: relative;
`
const HeaderLeft = styled.div<{ $isFullscreen?: boolean }>`
flex: 1;
min-width: 0;
padding-left: ${(props) => (props.$isFullscreen && isMac ? '70px' : 0)};
`
const HeaderCenter = styled.div`
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
z-index: 1;
`
const HeaderRight = styled.div`
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
`
const TitleText = styled.span`
font-size: 16px;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`
const ViewControls = styled.div`
display: flex;
width: auto;
gap: 8px;
padding: 4px;
background: var(--color-background-mute);
border-radius: 8px;
border: 1px solid var(--color-border);
-webkit-app-region: no-drag;
`
const ViewButton = styled(Button)`
border: none;
box-shadow: none;
&.ant-btn-primary {
background: var(--color-primary);
color: white;
}
&.ant-btn-default {
background: transparent;
color: var(--color-text-secondary);
&:hover {
background: var(--color-background);
color: var(--color-text);
}
}
`
const Container = styled.div`
display: flex;
height: 100%;
width: 100%;
flex: 1;
background: var(--color-background);
`
const CodeSection = styled.div<{ $visible: boolean }>`
flex: ${(props) => (props.$visible ? '1' : '0')};
min-width: ${(props) => (props.$visible ? '300px' : '0')};
border-right: ${(props) => (props.$visible ? '1px solid var(--color-border)' : 'none')};
overflow: hidden;
display: ${(props) => (props.$visible ? 'flex' : 'none')};
flex-direction: column;
`
const CodeEditorWrapper = styled.div`
flex: 1;
height: 100%;
overflow: hidden;
.monaco-editor {
height: 100% !important;
}
.cm-editor {
height: 100% !important;
}
.cm-scroller {
height: 100% !important;
}
`
const PreviewSection = styled.div<{ $visible: boolean }>`
flex: ${(props) => (props.$visible ? '1' : '0')};
min-width: ${(props) => (props.$visible ? '300px' : '0')};
background: white;
overflow: hidden;
display: ${(props) => (props.$visible ? 'block' : 'none')};
`
const PreviewFrame = styled.iframe`
width: 100%;
height: 100%;
border: none;
background: white;
`
const EmptyPreview = styled.div`
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: var(--color-background-soft);
color: var(--color-text-secondary);
font-size: 14px;
`
export default HtmlArtifactsPopup

View File

@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import CodePreview from './CodePreview'
import HtmlArtifacts from './HtmlArtifacts'
import HtmlArtifactsCard from './HtmlArtifactsCard'
import MermaidPreview from './MermaidPreview'
import PlantUmlPreview from './PlantUmlPreview'
import StatusBar from './StatusBar'
@ -24,6 +24,7 @@ interface Props {
children: string
language: string
onSave?: (newContent: string) => void
isStreaming?: boolean
}
/**
@ -45,6 +46,7 @@ interface Props {
const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
const { t } = useTranslation()
const { codeEditor, codeExecution } = useSettings()
const [viewMode, setViewMode] = useState<ViewMode>('special')
const [isRunning, setIsRunning] = useState(false)
const [output, setOutput] = useState('')
@ -229,11 +231,14 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
}, [specialView, sourceView, viewMode])
const renderArtifacts = useMemo(() => {
if (language === 'html') {
return <HtmlArtifacts html={children} />
}
// HTML artifacts 已经在早期返回中处理
return null
}, [children, language])
}, [])
// HTML 代码块特殊处理 - 在所有 hooks 调用之后
if (language === 'html') {
return <HtmlArtifactsCard html={children} />
}
return (
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>

View File

@ -42,6 +42,7 @@ interface Props {
extensions?: Extension[]
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */
style?: React.CSSProperties
editable?: boolean
}
/**
@ -62,7 +63,8 @@ const CodeEditor = ({
maxHeight,
options,
extensions,
style
style,
editable = true
}: Props) => {
const {
fontSize,
@ -190,7 +192,7 @@ const CodeEditor = ({
height={height}
minHeight={minHeight}
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
editable={true}
editable={editable}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
theme={activeCmTheme}
extensions={customExtensions}

View File

@ -103,7 +103,6 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
token: {
colorPrimary: colorPrimary,
fontFamily: 'var(--font-family)',
colorBgMask: _theme === 'dark' ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.8)',
motionDurationMid: '100ms'
}
}}>

View File

@ -338,6 +338,12 @@
"topics.unpinned": "Unpinned Topics",
"translate": "Translate"
},
"html_artifacts": {
"code": "Code",
"generating": "Generating",
"preview": "Preview",
"split": "Split"
},
"code_block": {
"collapse": "Collapse",
"copy": "Copy",

View File

@ -338,6 +338,12 @@
"topics.unpinned": "固定解除",
"translate": "翻訳"
},
"html_artifacts": {
"code": "コード",
"generating": "生成中",
"preview": "プレビュー",
"split": "分割"
},
"code_block": {
"collapse": "折りたたむ",
"copy": "コピー",

View File

@ -338,6 +338,12 @@
"topics.unpinned": "Открепленные темы",
"translate": "Перевести"
},
"html_artifacts": {
"code": "Код",
"generating": "Генерация",
"preview": "Предпросмотр",
"split": "Разделить"
},
"code_block": {
"collapse": "Свернуть",
"copy": "Копировать",

View File

@ -338,6 +338,12 @@
"topics.unpinned": "取消固定",
"translate": "翻译"
},
"html_artifacts": {
"code": "代码",
"generating": "生成中",
"preview": "预览",
"split": "分屏"
},
"code_block": {
"collapse": "收起",
"copy": "复制",

View File

@ -338,6 +338,12 @@
"topics.unpinned": "取消固定",
"translate": "翻譯"
},
"html_artifacts": {
"code": "程式碼",
"generating": "生成中",
"preview": "預覽",
"split": "分屏"
},
"code_block": {
"collapse": "折疊",
"copy": "複製",

View File

@ -289,6 +289,12 @@
"topics.unpinned": "Αποστέλλω",
"translate": "Μετάφραση"
},
"html_artifacts": {
"code": "Κώδικας",
"generating": "Δημιουργία",
"preview": "Προεπισκόπηση",
"split": "Διαχωρισμός"
},
"code_block": {
"collapse": "συμπεριληφθείς",
"disable_wrap": "ακύρωση αλλαγής γραμμής",

View File

@ -290,6 +290,12 @@
"topics.unpinned": "Quitar fijación",
"translate": "Traducir"
},
"html_artifacts": {
"code": "Código",
"generating": "Generando",
"preview": "Vista previa",
"split": "Dividir"
},
"code_block": {
"collapse": "Replegar",
"disable_wrap": "Deshabilitar salto de línea",

View File

@ -289,6 +289,12 @@
"topics.unpinned": "Annuler le fixage",
"translate": "Traduire"
},
"html_artifacts": {
"code": "Code",
"generating": "Génération",
"preview": "Aperçu",
"split": "Diviser"
},
"code_block": {
"collapse": "Réduire",
"disable_wrap": "Désactiver le retour à la ligne",

View File

@ -291,6 +291,12 @@
"topics.unpinned": "Desfixar",
"translate": "Traduzir"
},
"html_artifacts": {
"code": "Código",
"generating": "Gerando",
"preview": "Visualizar",
"split": "Dividir"
},
"code_block": {
"collapse": "Recolher",
"disable_wrap": "Desativar quebra de linha",

View File

@ -43,7 +43,7 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
const model = assistant.model || assistant.defaultModel
const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings()
const { pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings()
const { t } = useTranslation()
const textareaRef = useRef<TextAreaRef>(null)
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)

View File

@ -101,8 +101,7 @@ const SettingsTab: FC<Props> = (props) => {
messageNavigation,
enableQuickPanelTriggers,
enableBackspaceDeleteModel,
showTranslateConfirm,
showTokens
showTranslateConfirm
} = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {