💄 style: support html preview (#8969)

* support preview html

* add html

* refactor some code
This commit is contained in:
Arvin Xu
2025-08-28 23:12:56 +08:00
committed by GitHub
parent 4b3f8d4abb
commit 82abf6d7d0
30 changed files with 429 additions and 9 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ module.exports = defineConfig({
],
temperature: 0,
saveImmediately: true,
modelName: 'gpt-5-mini',
modelName: 'gpt-4.1-mini',
experimental: {
jsonMode: true,
},
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "عودة"
},
"HtmlPreview": {
"actions": {
"download": "تنزيل",
"preview": "معاينة"
},
"iframeTitle": "معاينة HTML",
"mode": {
"code": "رمز",
"preview": "معاينة"
},
"title": "معاينة HTML"
},
"ImageUpload": {
"actions": {
"changeImage": "انقر لتغيير الصورة",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "Назад"
},
"HtmlPreview": {
"actions": {
"download": "Изтегляне",
"preview": "Преглед"
},
"iframeTitle": "HTML Преглед",
"mode": {
"code": "Код",
"preview": "Преглед"
},
"title": "HTML Преглед"
},
"ImageUpload": {
"actions": {
"changeImage": "Кликнете, за да смените изображението",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "Zurück"
},
"HtmlPreview": {
"actions": {
"download": "Herunterladen",
"preview": "Vorschau"
},
"iframeTitle": "HTML-Vorschau",
"mode": {
"code": "Code",
"preview": "Vorschau"
},
"title": "HTML-Vorschau"
},
"ImageUpload": {
"actions": {
"changeImage": "Klicken, um das Bild zu ändern",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "Back"
},
"HtmlPreview": {
"actions": {
"download": "Download",
"preview": "Preview"
},
"iframeTitle": "HTML Preview",
"mode": {
"code": "Code",
"preview": "Preview"
},
"title": "HTML Preview"
},
"ImageUpload": {
"actions": {
"changeImage": "Click to change image",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "Regresar"
},
"HtmlPreview": {
"actions": {
"download": "Descargar",
"preview": "Vista previa"
},
"iframeTitle": "Vista previa HTML",
"mode": {
"code": "Código",
"preview": "Vista previa"
},
"title": "Vista previa HTML"
},
"ImageUpload": {
"actions": {
"changeImage": "Haz clic para cambiar la imagen",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "بازگشت"
},
"HtmlPreview": {
"actions": {
"download": "دانلود",
"preview": "پیش‌نمایش"
},
"iframeTitle": "پیش‌نمایش HTML",
"mode": {
"code": "کد",
"preview": "پیش‌نمایش"
},
"title": "پیش‌نمایش HTML"
},
"ImageUpload": {
"actions": {
"changeImage": "برای تغییر تصویر کلیک کنید",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "Retour"
},
"HtmlPreview": {
"actions": {
"download": "Télécharger",
"preview": "Aperçu"
},
"iframeTitle": "Aperçu HTML",
"mode": {
"code": "Code",
"preview": "Aperçu"
},
"title": "Aperçu HTML"
},
"ImageUpload": {
"actions": {
"changeImage": "Cliquez pour changer l'image",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "Indietro"
},
"HtmlPreview": {
"actions": {
"download": "Scarica",
"preview": "Anteprima"
},
"iframeTitle": "Anteprima HTML",
"mode": {
"code": "Codice",
"preview": "Anteprima"
},
"title": "Anteprima HTML"
},
"ImageUpload": {
"actions": {
"changeImage": "Clicca per cambiare immagine",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "戻る"
},
"HtmlPreview": {
"actions": {
"download": "ダウンロード",
"preview": "プレビュー"
},
"iframeTitle": "HTML プレビュー",
"mode": {
"code": "コード",
"preview": "プレビュー"
},
"title": "HTML プレビュー"
},
"ImageUpload": {
"actions": {
"changeImage": "画像を変更するにはクリックしてください",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "뒤로 가기"
},
"HtmlPreview": {
"actions": {
"download": "다운로드",
"preview": "미리보기"
},
"iframeTitle": "HTML 미리보기",
"mode": {
"code": "코드",
"preview": "미리보기"
},
"title": "HTML 미리보기"
},
"ImageUpload": {
"actions": {
"changeImage": "이미지 변경 클릭",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "Terug"
},
"HtmlPreview": {
"actions": {
"download": "Downloaden",
"preview": "Voorbeeld"
},
"iframeTitle": "HTML Voorbeeld",
"mode": {
"code": "Code",
"preview": "Voorbeeld"
},
"title": "HTML Voorbeeld"
},
"ImageUpload": {
"actions": {
"changeImage": "Klik om afbeelding te wijzigen",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "Wróć"
},
"HtmlPreview": {
"actions": {
"download": "Pobierz",
"preview": "Podgląd"
},
"iframeTitle": "Podgląd HTML",
"mode": {
"code": "Kod",
"preview": "Podgląd"
},
"title": "Podgląd HTML"
},
"ImageUpload": {
"actions": {
"changeImage": "Kliknij, aby zmienić obraz",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "Voltar"
},
"HtmlPreview": {
"actions": {
"download": "Baixar",
"preview": "Visualizar"
},
"iframeTitle": "Visualização HTML",
"mode": {
"code": "Código",
"preview": "Visualizar"
},
"title": "Visualização HTML"
},
"ImageUpload": {
"actions": {
"changeImage": "Clique para alterar a imagem",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "Назад"
},
"HtmlPreview": {
"actions": {
"download": "Скачать",
"preview": "Предпросмотр"
},
"iframeTitle": "Предпросмотр HTML",
"mode": {
"code": "Код",
"preview": "Предпросмотр"
},
"title": "Предпросмотр HTML"
},
"ImageUpload": {
"actions": {
"changeImage": "Нажмите, чтобы изменить изображение",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "Geri dön"
},
"HtmlPreview": {
"actions": {
"download": "İndir",
"preview": "Önizleme"
},
"iframeTitle": "HTML Önizlemesi",
"mode": {
"code": "Kod",
"preview": "Önizleme"
},
"title": "HTML Önizlemesi"
},
"ImageUpload": {
"actions": {
"changeImage": "Resmi değiştirmek için tıklayın",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "Quay lại"
},
"HtmlPreview": {
"actions": {
"download": "Tải xuống",
"preview": "Xem trước"
},
"iframeTitle": "Xem trước HTML",
"mode": {
"code": "Mã",
"preview": "Xem trước"
},
"title": "Xem trước HTML"
},
"ImageUpload": {
"actions": {
"changeImage": "Nhấn để thay đổi hình ảnh",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "返回"
},
"HtmlPreview": {
"actions": {
"download": "下载",
"preview": "预览"
},
"iframeTitle": "HTML 预览",
"mode": {
"code": "代码",
"preview": "预览"
},
"title": "HTML 预览"
},
"ImageUpload": {
"actions": {
"changeImage": "点击更换图片",
+12
View File
@@ -73,6 +73,18 @@
"GoBack": {
"back": "返回"
},
"HtmlPreview": {
"actions": {
"download": "下載",
"preview": "預覽"
},
"iframeTitle": "HTML 預覽",
"mode": {
"code": "程式碼",
"preview": "預覽"
},
"title": "HTML 預覽"
},
"ImageUpload": {
"actions": {
"changeImage": "點擊更換圖片",
+2
View File
@@ -1,4 +1,6 @@
export * from './branding';
export * from './image';
export * from './layoutTokens';
export * from './message';
export * from './settings';
export * from './version';
+2
View File
@@ -0,0 +1,2 @@
export * from './downloadFile';
export * from './exportFile';
@@ -0,0 +1,32 @@
import { ActionIcon } from '@lobehub/ui';
import { Eye } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import HtmlPreviewDrawer from './PreviewDrawer';
interface HtmlPreviewActionProps {
content: string;
size?: number;
}
const HtmlPreviewAction = memo<HtmlPreviewActionProps>(({ content, size }) => {
const { t } = useTranslation('components');
const [open, setOpen] = useState(false);
return (
<>
<ActionIcon
icon={Eye}
onClick={() => setOpen(true)}
size={size}
title={t('HtmlPreview.actions.preview')}
/>
<HtmlPreviewDrawer content={content} onClose={() => setOpen(false)} open={open} />
</>
);
});
HtmlPreviewAction.displayName = 'HtmlPreviewAction';
export default HtmlPreviewAction;
@@ -0,0 +1,133 @@
import { exportFile } from '@lobechat/utils/client';
import { Block, Button, Highlighter, Segmented } from '@lobehub/ui';
import { Drawer } from 'antd';
import { createStyles } from 'antd-style';
import { Code2, Download, Eye } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { isDesktop } from '@/const/version';
import { TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar';
const useStyles = createStyles(({ css }) => ({
container: css`
height: 100%;
`,
iframe: css`
width: 100%;
height: 100%;
border: none;
`,
}));
interface HtmlPreviewDrawerProps {
content: string;
onClose: () => void;
open: boolean;
}
const HtmlPreviewDrawer = memo<HtmlPreviewDrawerProps>(({ content, open, onClose }) => {
const { styles } = useStyles();
const { t } = useTranslation('components');
const [mode, setMode] = useState<'preview' | 'code'>('preview');
const htmlContent = content;
const extractTitle = useCallback(() => {
const m = htmlContent.match(/<title>([\S\s]*?)<\/title>/i);
return m ? m[1].trim() : undefined;
}, [htmlContent]);
const sanitizeFileName = useCallback((name: string) => {
return name
.replaceAll(/["*/:<>?\\|]/g, '-')
.replaceAll(/\s+/g, ' ')
.trim()
.slice(0, 100);
}, []);
const onDownload = useCallback(() => {
const title = extractTitle();
const base = title ? sanitizeFileName(title) : `chat-html-preview-${Date.now()}`;
exportFile(content, `${base}.html`);
}, [content, extractTitle, sanitizeFileName]);
const Title = (
<Flexbox align={'center'} horizontal justify={'space-between'} style={{ width: '100%' }}>
{t('HtmlPreview.title')}
<Segmented
onChange={(v) => setMode(v as 'preview' | 'code')}
options={[
{
label: (
<Flexbox align={'center'} gap={6} horizontal>
<Eye size={16} />
{t('HtmlPreview.mode.preview')}
</Flexbox>
),
value: 'preview',
},
{
label: (
<Flexbox align={'center'} gap={6} horizontal>
<Code2 size={16} />
{t('HtmlPreview.mode.code')}
</Flexbox>
),
value: 'code',
},
]}
value={mode}
/>
<Button
color={'default'}
icon={<Download size={16} />}
onClick={onDownload}
variant={'filled'}
>
{t('HtmlPreview.actions.download')}
</Button>
</Flexbox>
);
return (
<Drawer
destroyOnHidden
height={isDesktop ? `calc(100vh - ${TITLE_BAR_HEIGHT}px)` : '100vh'}
onClose={onClose}
open={open}
placement="bottom"
styles={{
body: { height: '100%', padding: 0 },
header: { paddingBlock: 8, paddingInline: 12 },
}}
title={Title}
>
{mode === 'preview' ? (
<Block className={styles.container}>
<iframe
className={styles.iframe}
sandbox="allow-scripts allow-same-origin"
srcDoc={content}
title={t('HtmlPreview.iframeTitle')}
/>
</Block>
) : (
<Block className={styles.container}>
<Highlighter
language={'html'}
showLanguage={false}
style={{ height: '100%', overflow: 'auto' }}
>
{htmlContent}
</Highlighter>
</Block>
)}
</Drawer>
);
});
HtmlPreviewDrawer.displayName = 'HtmlPreviewDrawer';
export default HtmlPreviewDrawer;
+2
View File
@@ -0,0 +1,2 @@
export { default as HtmlPreviewAction } from './HtmlPreviewAction';
export { default as HtmlPreviewDrawer } from './PreviewDrawer';
@@ -1,3 +1,4 @@
import { exportFile } from '@lobechat/utils/client';
import { Button, copyToClipboard } from '@lobehub/ui';
import { App } from 'antd';
import isEqual from 'fast-deep-equal';
@@ -10,7 +11,6 @@ import { useIsMobile } from '@/hooks/useIsMobile';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { ChatMessage } from '@/types/message';
import { exportFile } from '@/utils/client/exportFile';
import { useStyles } from '../style';
import Preview from './Preview';
@@ -6,6 +6,7 @@ import { MouseEventHandler, ReactNode, memo, use, useCallback, useMemo } from 'r
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { HtmlPreviewAction } from '@/components/HtmlPreview';
import { isDesktop } from '@/const/version';
import ChatItem from '@/features/ChatItem';
import { VirtuosoContext } from '@/features/Conversation/components/VirtualizedList/VirtuosoContext';
@@ -33,6 +34,14 @@ import { normalizeThinkTags, processWithArtifact } from './utils';
const rehypePlugins = markdownElements.map((element) => element.rehypePlugin).filter(Boolean);
const remarkPlugins = markdownElements.map((element) => element.remarkPlugin).filter(Boolean);
const isHtmlCode = (content: string, language: string) => {
return (
language === 'html' ||
(language === '' && content.includes('<html>')) ||
(language === '' && content.includes('<!DOCTYPE html>'))
);
};
const useStyles = createStyles(({ css, prefixCls }) => ({
loading: css`
opacity: 0.6;
@@ -175,6 +184,20 @@ const Item = memo<ChatListItemProps>(
() => ({
animated,
citations: item?.role === 'user' ? undefined : item?.search?.citations,
componentProps: {
highlight: {
actionsRender: ({ content, actionIconSize, language, originalNode }: any) => {
const showHtmlPreview = isHtmlCode(content, language);
return (
<>
{showHtmlPreview && <HtmlPreviewAction content={content} size={actionIconSize} />}
{originalNode}
</>
);
},
},
},
components,
customRender: markdownCustomRender,
enableCustomFootnotes: item?.role === 'assistant',
+2 -2
View File
@@ -1,3 +1,5 @@
import { FORM_STYLE } from '@lobechat/const';
import { exportFile } from '@lobechat/utils/client';
import { Button, Form, type FormItemProps, copyToClipboard } from '@lobehub/ui';
import { App, Switch } from 'antd';
import isEqual from 'fast-deep-equal';
@@ -6,13 +8,11 @@ import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { FORM_STYLE } from '@/const/layoutTokens';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { chatSelectors, topicSelectors } from '@/store/chat/selectors';
import { exportFile } from '@/utils/client/exportFile';
import { useStyles } from '../style';
import Preview from './Preview';
+1 -1
View File
@@ -1,3 +1,4 @@
import { exportFile } from '@lobechat/utils/client';
import { Button, Form, type FormItemProps, copyToClipboard } from '@lobehub/ui';
import { App, Switch } from 'antd';
import isEqual from 'fast-deep-equal';
@@ -12,7 +13,6 @@ import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { chatSelectors, topicSelectors } from '@/store/chat/selectors';
import { exportFile } from '@/utils/client/exportFile';
import { useStyles } from '../style';
import Preview from './Preview';
+12
View File
@@ -75,6 +75,18 @@ export default {
GoBack: {
back: '返回',
},
HtmlPreview: {
actions: {
download: '下载',
preview: '预览',
},
iframeTitle: 'HTML 预览',
mode: {
code: '代码',
preview: '预览',
},
title: 'HTML 预览',
},
ImageUpload: {
actions: {
changeImage: '点击更换图片',
+2 -4
View File
@@ -1,11 +1,9 @@
import { BRANDING_NAME, isDeprecatedEdition, isServerMode } from '@lobechat/const';
import { downloadFile, exportJSONFile } from '@lobechat/utils/client';
import dayjs from 'dayjs';
import { BRANDING_NAME } from '@/const/branding';
import { isDeprecatedEdition, isServerMode } from '@/const/version';
import { CURRENT_CONFIG_VERSION } from '@/migrations';
import { ImportPgDataStructure } from '@/types/export';
import { downloadFile } from '@/utils/client/downloadFile';
import { exportJSONFile } from '@/utils/client/exportFile';
import { exportService } from './export';
import { configService as deprecatedExportService } from './export/_deprecated';