Commit 8f177a0f by siigure Committed by GitHub

feat: improve code block rendering (#7066)

* feat: auto-preview HTML code blocks in chat streaming

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: improve HTML code block preview and response modal layout

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: simplify HTML code block preview state

* perf: code

* fix(chat): 修复 HTML 预览 Tab 被重置,并优化完整响应弹窗代码块布局

稳定 react-markdown components 引用以保留用户手动切换的 Code/Preview 状态;同时修正弹窗内代码块高度约束、JSON fence 渲染与弹窗 maxH。

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: simplify html code block preview state

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: archer <545436317@qq.com>
parent 445a5dc4
...@@ -34,6 +34,7 @@ description: 'FastGPT V4.15.0-beta4 更新说明' ...@@ -34,6 +34,7 @@ description: 'FastGPT V4.15.0-beta4 更新说明'
3. 应用/知识库增加虚拟列表渲染。 3. 应用/知识库增加虚拟列表渲染。
4. 增加单独的 openapi 文档,区分 devapi 文档。 4. 增加单独的 openapi 文档,区分 devapi 文档。
5. 导出工作流模板,同时导出名字和介绍。 5. 导出工作流模板,同时导出名字和介绍。
6. HTML 输出自动切换预览。
## ⚙️ 优化 ## ⚙️ 优化
......
...@@ -309,6 +309,7 @@ const CodeLight = ({ ...@@ -309,6 +309,7 @@ const CodeLight = ({
return ( return (
<Box <Box
className="code-block-wrapper"
my={3} my={3}
borderRadius={'md'} borderRadius={'md'}
overflow={'overlay'} overflow={'overlay'}
......
import React, { useMemo, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { import {
Box, Box,
...@@ -18,6 +18,45 @@ import { useMarkdownWidth } from '../hooks'; ...@@ -18,6 +18,45 @@ import { useMarkdownWidth } from '../hooks';
import type { IconNameType } from '@fastgpt/web/components/common/Icon/type'; import type { IconNameType } from '@fastgpt/web/components/common/Icon/type';
import { codeLight } from './CodeLight'; import { codeLight } from './CodeLight';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import styles from '../index.module.scss';
type HtmlCodeBlockViewMode = 'source' | 'iframe';
/**
* 管理 HTML 代码块的 Code/Preview 视图状态。
*
* 自动预览只服务聊天流式 HTML 输出:流式阶段保持源码,流式结束后切到预览;
* 用户主动选择 Code/Preview 后,后续流式状态变化不再覆盖用户选择。
*/
const useHtmlCodeBlockViewMode = ({
shouldAutoPreview,
showAnimation
}: {
shouldAutoPreview: boolean;
showAnimation?: boolean;
}) => {
const [viewMode, setViewMode] = useState<HtmlCodeBlockViewMode>(() =>
shouldAutoPreview && !showAnimation ? 'iframe' : 'source'
);
const hasUserSelectedViewRef = useRef(false);
useEffect(() => {
if (!shouldAutoPreview) return;
if (hasUserSelectedViewRef.current) return;
setViewMode(showAnimation ? 'source' : 'iframe');
}, [shouldAutoPreview, showAnimation]);
const selectViewMode = (mode: HtmlCodeBlockViewMode) => {
hasUserSelectedViewRef.current = true;
setViewMode(mode);
};
return {
viewMode,
selectViewMode
};
};
const StyledButton = ({ const StyledButton = ({
label, label,
...@@ -31,7 +70,7 @@ const StyledButton = ({ ...@@ -31,7 +70,7 @@ const StyledButton = ({
iconName: IconNameType; iconName: IconNameType;
onClick: () => void; onClick: () => void;
isActive?: boolean; isActive?: boolean;
viewMode: 'source' | 'iframe'; viewMode: HtmlCodeBlockViewMode;
isMobile?: boolean; isMobile?: boolean;
}) => { }) => {
const isPreview = viewMode === 'iframe'; const isPreview = viewMode === 'iframe';
...@@ -81,20 +120,49 @@ const StyledButton = ({ ...@@ -81,20 +120,49 @@ const StyledButton = ({
); );
}; };
const HtmlPreviewIframe = ({ code }: { code: string }) => (
<iframe
srcDoc={code}
sandbox="allow-popups"
referrerPolicy="no-referrer"
style={{
display: 'block',
width: '100%',
height: '100%',
border: 'none',
background: 'white'
}}
/>
);
const IframeHtmlCodeBlock = ({ const IframeHtmlCodeBlock = ({
children, children,
className, className,
codeBlock, codeBlock,
match match,
showAnimation,
autoPreviewHtmlCodeBlock
}: { }: {
children: React.ReactNode & React.ReactNode[]; children: React.ReactNode & React.ReactNode[];
className?: string; className?: string;
codeBlock?: boolean; codeBlock?: boolean;
match: RegExpExecArray | null; match: RegExpExecArray | null;
showAnimation?: boolean;
autoPreviewHtmlCodeBlock?: boolean;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { copyData } = useCopyData(); const { copyData } = useCopyData();
const [viewMode, setViewMode] = useState<'source' | 'iframe'>('source'); const code = String(children);
const lang = match?.[1]?.toLowerCase();
const isHtmlBlock = lang === 'html' || lang === 'htm';
const shouldAutoPreview = !!autoPreviewHtmlCodeBlock && isHtmlBlock;
// 流式阶段仍展示源码高亮;只额外接管滚动,让最新输出保持可见。
const showStreamingSourceCode = !!autoPreviewHtmlCodeBlock && isHtmlBlock && showAnimation;
const { viewMode, selectViewMode } = useHtmlCodeBlockViewMode({
shouldAutoPreview,
showAnimation
});
const streamingCodeRef = useRef<HTMLPreElement | null>(null);
const isPreview = viewMode === 'iframe'; const isPreview = viewMode === 'iframe';
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
...@@ -102,6 +170,23 @@ const IframeHtmlCodeBlock = ({ ...@@ -102,6 +170,23 @@ const IframeHtmlCodeBlock = ({
const { width, Ref } = useMarkdownWidth(); const { width, Ref } = useMarkdownWidth();
const isMobile = width <= 420; const isMobile = width <= 420;
const SourcePreTag = useMemo(
() =>
function SourcePreTag(props: React.HTMLAttributes<HTMLPreElement>) {
return (
<pre
{...props}
ref={(node) => {
if (showStreamingSourceCode) {
streamingCodeRef.current = node;
}
}}
/>
);
},
[showStreamingSourceCode]
);
const codeBoxName = useMemo(() => { const codeBoxName = useMemo(() => {
const input = match?.['input'] || ''; const input = match?.['input'] || '';
if (!input) return match?.[1]?.toUpperCase(); if (!input) return match?.[1]?.toUpperCase();
...@@ -110,27 +195,23 @@ const IframeHtmlCodeBlock = ({ ...@@ -110,27 +195,23 @@ const IframeHtmlCodeBlock = ({
return splitInput[1] || match?.[1]?.toUpperCase(); return splitInput[1] || match?.[1]?.toUpperCase();
}, [match]); }, [match]);
const Iframe = useMemo( useEffect(() => {
() => ( if (!showStreamingSourceCode) return;
<iframe if (isPreview) return;
srcDoc={String(children)}
sandbox="allow-popups" const node = streamingCodeRef.current;
referrerPolicy="no-referrer" if (!node) return;
style={{
width: '100%', // 源码面板限制最大高度,流式写入时需要跟随到底部才能看到最新代码。
height: '100%', node.scrollTop = node.scrollHeight;
border: 'none', }, [code, isPreview, showStreamingSourceCode]);
background: 'white'
}}
/>
),
[children]
);
if (codeBlock) { if (codeBlock) {
return ( return (
<Box <Box
ref={Ref} ref={Ref}
className={`${styles.htmlCodeBlock} code-block-wrapper`}
w="100%"
my={3} my={3}
borderRadius={'md'} borderRadius={'md'}
overflow={'hidden'} overflow={'hidden'}
...@@ -139,10 +220,13 @@ const IframeHtmlCodeBlock = ({ ...@@ -139,10 +220,13 @@ const IframeHtmlCodeBlock = ({
} }
> >
<Flex <Flex
className="code-header"
py={2} py={2}
px={4} px={4}
color={'white'} color={'white'}
userSelect={'none'} userSelect={'none'}
position="relative"
zIndex={2}
alignItems="center" alignItems="center"
fontSize={'sm'} fontSize={'sm'}
gap={1.5} gap={1.5}
...@@ -163,19 +247,14 @@ const IframeHtmlCodeBlock = ({ ...@@ -163,19 +247,14 @@ const IframeHtmlCodeBlock = ({
color={isPreview ? 'myGray.800' : 'rgba(255, 255, 255, 0.9)'} color={isPreview ? 'myGray.800' : 'rgba(255, 255, 255, 0.9)'}
> >
{codeBoxName} {codeBoxName}
<Flex <Flex cursor="pointer" onClick={() => copyData(code)} alignItems="center" ml={2}>
cursor="pointer"
onClick={() => copyData(String(children))}
alignItems="center"
ml={2}
>
<Icon name="copy" width="14px" /> <Icon name="copy" width="14px" />
</Flex> </Flex>
</Box> </Box>
<StyledButton <StyledButton
label={t('common:Code')} label={t('common:Code')}
iconName="code" iconName="code"
onClick={() => setViewMode('source')} onClick={() => selectViewMode('source')}
isActive={viewMode === 'source'} isActive={viewMode === 'source'}
viewMode={viewMode} viewMode={viewMode}
isMobile={isMobile} isMobile={isMobile}
...@@ -183,7 +262,7 @@ const IframeHtmlCodeBlock = ({ ...@@ -183,7 +262,7 @@ const IframeHtmlCodeBlock = ({
<StyledButton <StyledButton
label={t('common:Preview')} label={t('common:Preview')}
iconName="preview" iconName="preview"
onClick={() => setViewMode('iframe')} onClick={() => selectViewMode('iframe')}
isActive={viewMode === 'iframe'} isActive={viewMode === 'iframe'}
viewMode={viewMode} viewMode={viewMode}
isMobile={isMobile} isMobile={isMobile}
...@@ -197,12 +276,19 @@ const IframeHtmlCodeBlock = ({ ...@@ -197,12 +276,19 @@ const IframeHtmlCodeBlock = ({
/> />
</Flex> </Flex>
{isPreview ? ( {isPreview ? (
<Box w={width} h="60vh"> <Box className="code-block-body" h={'60vh'}>
{Iframe} <HtmlPreviewIframe code={code} />
</Box> </Box>
) : ( ) : (
<SyntaxHighlighter style={codeLight as any} language={match?.[1]} PreTag="pre"> <SyntaxHighlighter
{String(children).replace(/&nbsp;/g, ' ')} style={codeLight as any}
language={match?.[1]}
PreTag={SourcePreTag}
customStyle={{
margin: 0
}}
>
{code.replace(/&nbsp;/g, ' ')}
</SyntaxHighlighter> </SyntaxHighlighter>
)} )}
...@@ -227,7 +313,7 @@ const IframeHtmlCodeBlock = ({ ...@@ -227,7 +313,7 @@ const IframeHtmlCodeBlock = ({
</ModalHeader> </ModalHeader>
<ModalBody p={0} flex="1"> <ModalBody p={0} flex="1">
{Iframe} <HtmlPreviewIframe code={code} />
</ModalBody> </ModalBody>
</ModalContent> </ModalContent>
</Modal> </Modal>
......
...@@ -9,6 +9,9 @@ ...@@ -9,6 +9,9 @@
animation: blink 0.6s infinite; animation: blink 0.6s infinite;
vertical-align: baseline; vertical-align: baseline;
} }
.waitingAnimation > .htmlCodeBlock:last-child::after {
display: none;
}
.animation { .animation {
height: 20px; height: 20px;
...@@ -495,3 +498,68 @@ ...@@ -495,3 +498,68 @@
.mermaid { .mermaid {
overflow-x: auto; overflow-x: auto;
} }
// 聊天 HTML 代码块:内容区随高度伸缩,上限 60vh,超出后滚动。
.htmlCodeBlock {
width: 100%;
:global(.code-block-body) {
width: 100%;
max-height: 60vh;
overflow: auto;
iframe {
display: block;
width: 100%;
height: 100%;
border: none;
}
}
:global(pre) {
width: 100%;
height: auto !important;
max-height: 60vh !important;
margin: 0 !important;
overflow: auto !important;
}
}
// 完整响应弹窗代码块:外层固定为弹窗内容区 80%,header 固定,内容区内部滚动。
.codeJsonConstrained {
:global(.markdown) :global(.code-block-wrapper),
:global(.markdown) :global(.htmlCodeBlock) {
display: flex;
flex-direction: column;
height: var(--response-code-block-height);
max-height: var(--response-code-block-height);
width: 100%;
overflow: hidden;
border-radius: var(--chakra-radii-md);
}
:global(.markdown) :global(.code-header) {
flex-shrink: 0;
}
:global(.markdown) :global(.code-block-wrapper) pre,
:global(.markdown) :global(.htmlCodeBlock) pre,
:global(.markdown) :global(.code-block-body) {
flex: 1 1 0;
min-height: 0;
height: auto !important;
max-height: none !important;
overflow: auto !important;
margin: 0 !important;
border-bottom-left-radius: var(--chakra-radii-md);
border-bottom-right-radius: var(--chakra-radii-md);
}
// HTML 预览区在完整响应弹窗内跟随外层限高,覆盖聊天的 60vh 上限。
:global(.markdown) :global(.htmlCodeBlock) :global(.code-block-body) {
flex: 1 1 0;
min-height: 0;
max-height: none !important;
overflow: auto !important;
}
}
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useContext, useMemo } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import RemarkMath from 'remark-math'; // Math syntax import RemarkMath from 'remark-math'; // Math syntax
...@@ -12,9 +12,9 @@ import dynamic from 'next/dynamic'; ...@@ -12,9 +12,9 @@ import dynamic from 'next/dynamic';
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { CodeClassNameEnum, mdTextFormat } from './utils'; import { CodeClassNameEnum, mdTextFormat } from './utils';
import { useCreation } from 'ahooks';
import type { AProps } from './A'; import type { AProps } from './A';
import MarkdownTable from '@fastgpt/web/components/common/Markdown/MarkdownTable'; import MarkdownTable from '@fastgpt/web/components/common/Markdown/MarkdownTable';
import { MarkdownRendererRuntimeContext } from './runtimeContext';
const CodeLight = dynamic(() => import('./codeBlock/CodeLight'), { ssr: false }); const CodeLight = dynamic(() => import('./codeBlock/CodeLight'), { ssr: false });
const MermaidCodeBlock = dynamic(() => import('./img/MermaidCodeBlock'), { ssr: false }); const MermaidCodeBlock = dynamic(() => import('./img/MermaidCodeBlock'), { ssr: false });
...@@ -29,12 +29,56 @@ const ChatGuide = dynamic(() => import('./chat/Guide'), { ssr: false }); ...@@ -29,12 +29,56 @@ const ChatGuide = dynamic(() => import('./chat/Guide'), { ssr: false });
const QuestionGuide = dynamic(() => import('./chat/QuestionGuide'), { ssr: false }); const QuestionGuide = dynamic(() => import('./chat/QuestionGuide'), { ssr: false });
const A = dynamic(() => import('./A'), { ssr: false }); const A = dynamic(() => import('./A'), { ssr: false });
function MarkdownImgRenderer(props: any) {
const { chatAuthData } = useContext(MarkdownRendererRuntimeContext);
return <Image {...props} alt={props.alt} chatAuthData={chatAuthData} />;
}
function MarkdownCodeRenderer(props: any) {
const { showAnimation, autoPreviewHtmlCodeBlock, markdownClassName } = useContext(
MarkdownRendererRuntimeContext
);
return (
<Code
{...props}
showAnimation={showAnimation}
autoPreviewHtmlCodeBlock={autoPreviewHtmlCodeBlock}
markdownClassName={markdownClassName}
/>
);
}
function MarkdownLinkRenderer(props: any) {
const { showAnimation, chatAuthData, onOpenCiteModal } = useContext(
MarkdownRendererRuntimeContext
);
return (
<A
{...props}
showAnimation={showAnimation}
chatAuthData={chatAuthData}
onOpenCiteModal={onOpenCiteModal}
/>
);
}
const markdownComponents = {
img: MarkdownImgRenderer,
pre: RewritePre,
code: MarkdownCodeRenderer,
table: MarkdownTable as any,
a: MarkdownLinkRenderer
};
type Props = { type Props = {
source?: string; source?: string;
showAnimation?: boolean; showAnimation?: boolean;
isDisabled?: boolean; isDisabled?: boolean;
forbidZhFormat?: boolean; forbidZhFormat?: boolean;
className?: string; className?: string;
autoPreviewHtmlCodeBlock?: boolean;
} & AProps; } & AProps;
const Markdown = (props: Props) => { const Markdown = (props: Props) => {
const source = props.source || ''; const source = props.source || '';
...@@ -51,26 +95,21 @@ const MarkdownRender = ({ ...@@ -51,26 +95,21 @@ const MarkdownRender = ({
isDisabled, isDisabled,
forbidZhFormat, forbidZhFormat,
className, className,
autoPreviewHtmlCodeBlock,
chatAuthData, chatAuthData,
onOpenCiteModal onOpenCiteModal
}: Props) => { }: Props) => {
const components = useCreation(() => { const renderContextValue = useMemo(
return { () => ({
img: (props: any) => <Image {...props} alt={props.alt} chatAuthData={chatAuthData} />, showAnimation,
pre: RewritePre, autoPreviewHtmlCodeBlock,
code: (props: any) => <Code {...props} markdownClassName={className} />, markdownClassName: className,
table: MarkdownTable as any, chatAuthData,
a: (props: any) => ( onOpenCiteModal
<A }),
{...props} [autoPreviewHtmlCodeBlock, chatAuthData, className, onOpenCiteModal, showAnimation]
showAnimation={showAnimation} );
chatAuthData={chatAuthData}
onOpenCiteModal={onOpenCiteModal}
/>
)
};
}, [chatAuthData, onOpenCiteModal, showAnimation]);
const formatSource = useMemo(() => { const formatSource = useMemo(() => {
if (showAnimation || forbidZhFormat) return source; if (showAnimation || forbidZhFormat) return source;
...@@ -82,6 +121,7 @@ const MarkdownRender = ({ ...@@ -82,6 +121,7 @@ const MarkdownRender = ({
}, []); }, []);
return ( return (
<MarkdownRendererRuntimeContext.Provider value={renderContextValue}>
<Box position={'relative'}> <Box position={'relative'}>
<ReactMarkdown <ReactMarkdown
className={`markdown ${styles.markdown} className={`markdown ${styles.markdown}
...@@ -90,13 +130,16 @@ const MarkdownRender = ({ ...@@ -90,13 +130,16 @@ const MarkdownRender = ({
`} `}
remarkPlugins={[RemarkMath, [RemarkGfm, { singleTilde: false }], RemarkBreaks]} remarkPlugins={[RemarkMath, [RemarkGfm, { singleTilde: false }], RemarkBreaks]}
rehypePlugins={[RehypeKatex, [RehypeExternalLinks, { target: '_blank' }]]} rehypePlugins={[RehypeKatex, [RehypeExternalLinks, { target: '_blank' }]]}
components={components} components={markdownComponents}
urlTransform={urlTransform} urlTransform={urlTransform}
> >
{formatSource} {formatSource}
</ReactMarkdown> </ReactMarkdown>
{isDisabled && <Box position={'absolute'} top={0} right={0} left={0} bottom={0} />} {isDisabled && (
<Box position={'absolute'} top={0} right={0} left={0} bottom={0} zIndex={1} />
)}
</Box> </Box>
</MarkdownRendererRuntimeContext.Provider>
); );
}; };
...@@ -104,13 +147,19 @@ export default React.memo(Markdown); ...@@ -104,13 +147,19 @@ export default React.memo(Markdown);
/* Custom dom */ /* Custom dom */
function Code(e: any) { function Code(e: any) {
const { className, codeBlock, children, markdownClassName } = e; const {
className,
codeBlock,
children,
showAnimation,
autoPreviewHtmlCodeBlock,
markdownClassName
} = e;
const match = /language-(\w+)/.exec(className || ''); const match = /language-(\w+)/.exec(className || '');
const codeType = match?.[1]?.toLowerCase(); const codeType = match?.[1]?.toLowerCase();
const strChildren = String(children); const strChildren = String(children);
const Component = useMemo(() => {
if (codeType === CodeClassNameEnum.mermaid) { if (codeType === CodeClassNameEnum.mermaid) {
return <MermaidCodeBlock code={strChildren} />; return <MermaidCodeBlock code={strChildren} />;
} }
...@@ -126,9 +175,19 @@ function Code(e: any) { ...@@ -126,9 +175,19 @@ function Code(e: any) {
if (codeType === CodeClassNameEnum.iframe) { if (codeType === CodeClassNameEnum.iframe) {
return <IframeCodeBlock code={strChildren} />; return <IframeCodeBlock code={strChildren} />;
} }
if (codeType === CodeClassNameEnum.html || codeType === CodeClassNameEnum.svg) { if (
codeType === CodeClassNameEnum.html ||
codeType === CodeClassNameEnum.htm ||
codeType === CodeClassNameEnum.svg
) {
return ( return (
<IframeHtmlCodeBlock className={className} codeBlock={codeBlock} match={match}> <IframeHtmlCodeBlock
className={className}
codeBlock={codeBlock}
match={match}
showAnimation={showAnimation}
autoPreviewHtmlCodeBlock={autoPreviewHtmlCodeBlock}
>
{children} {children}
</IframeHtmlCodeBlock> </IframeHtmlCodeBlock>
); );
...@@ -145,9 +204,6 @@ function Code(e: any) { ...@@ -145,9 +204,6 @@ function Code(e: any) {
{children} {children}
</CodeLight> </CodeLight>
); );
}, [codeType, className, codeBlock, match, children, strChildren, markdownClassName]);
return Component;
} }
function Image({ src, chatAuthData }: { src?: string; chatAuthData?: AProps['chatAuthData'] }) { function Image({ src, chatAuthData }: { src?: string; chatAuthData?: AProps['chatAuthData'] }) {
......
import React from 'react';
import type { AProps } from './A';
export type MarkdownRendererRuntimeContextValue = {
showAnimation?: boolean;
autoPreviewHtmlCodeBlock?: boolean;
markdownClassName?: string;
chatAuthData?: AProps['chatAuthData'];
onOpenCiteModal?: AProps['onOpenCiteModal'];
};
/**
* 仅用于给 react-markdown 的稳定 components 传递 renderer 运行时参数。
* 不要放上层聊天/工作流业务状态,避免 Markdown 组件变成业务状态容器。
*/
export const MarkdownRendererRuntimeContext =
React.createContext<MarkdownRendererRuntimeContextValue>({});
...@@ -8,6 +8,7 @@ export enum CodeClassNameEnum { ...@@ -8,6 +8,7 @@ export enum CodeClassNameEnum {
latex = 'latex', latex = 'latex',
iframe = 'iframe', iframe = 'iframe',
html = 'html', html = 'html',
htm = 'htm',
svg = 'svg', svg = 'svg',
video = 'video', video = 'video',
audio = 'audio' audio = 'audio'
......
...@@ -49,6 +49,7 @@ const RenderText = React.memo(function RenderText({ ...@@ -49,6 +49,7 @@ const RenderText = React.memo(function RenderText({
chatAuthData={chatAuthData} chatAuthData={chatAuthData}
onOpenCiteModal={onOpenCiteModal} onOpenCiteModal={onOpenCiteModal}
isDisabled={isDisabled} isDisabled={isDisabled}
autoPreviewHtmlCodeBlock
/> />
); );
}); });
......
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Box, Flex, useDisclosure } from '@chakra-ui/react'; import { Box, Flex, useDisclosure } from '@chakra-ui/react';
import { useSize } from 'ahooks';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants'; import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants';
...@@ -64,12 +65,16 @@ export const ResponseBox = React.memo(function ResponseBox({ ...@@ -64,12 +65,16 @@ export const ResponseBox = React.memo(function ResponseBox({
onClose: onCloseMobileModal onClose: onCloseMobileModal
} = useDisclosure(); } = useDisclosure();
const contentPanelRef = useRef<HTMLDivElement>(null);
const contentPanelSize = useSize(contentPanelRef);
return ( return (
<> <>
{isPc && !useMobile ? ( {isPc && !useMobile ? (
<Flex <Flex
overflow={'hidden'} overflow={'hidden'}
height={'100%'} height={'100%'}
minH={0}
bg={'myGray.25'} bg={'myGray.25'}
border={'1px solid'} border={'1px solid'}
borderColor={'myGray.200'} borderColor={'myGray.200'}
...@@ -90,11 +95,12 @@ export const ResponseBox = React.memo(function ResponseBox({ ...@@ -90,11 +95,12 @@ export const ResponseBox = React.memo(function ResponseBox({
onChange={setCurrentNodeId} onChange={setCurrentNodeId}
/> />
</Box> </Box>
<Box flex={'1 0 0'} w={0} height={'100%'}> <Box ref={contentPanelRef} flex={'1 0 0'} w={0} h={'100%'} minH={0} overflow={'hidden'}>
<WholeResponseContent <WholeResponseContent
dataId={dataId} dataId={dataId}
activeModule={activeModule} activeModule={activeModule}
hideTabs={hideTabs} hideTabs={hideTabs}
contentHeight={contentPanelSize?.height}
onOpenRequestIdDetail={handleOpenRequestIdDetail} onOpenRequestIdDetail={handleOpenRequestIdDetail}
/> />
</Box> </Box>
...@@ -155,11 +161,12 @@ export const ResponseBox = React.memo(function ResponseBox({ ...@@ -155,11 +161,12 @@ export const ResponseBox = React.memo(function ResponseBox({
{t(activeModule.moduleName as any, activeModule.moduleNameArgs)} {t(activeModule.moduleName as any, activeModule.moduleNameArgs)}
</Box> </Box>
</Flex> </Flex>
<Box flex={'1 0 0'}> <Box ref={contentPanelRef} flex={'1 0 0'} minH={0} overflow={'hidden'}>
<WholeResponseContent <WholeResponseContent
dataId={dataId} dataId={dataId}
activeModule={activeModule} activeModule={activeModule}
hideTabs={hideTabs} hideTabs={hideTabs}
contentHeight={contentPanelSize?.height}
onOpenRequestIdDetail={handleOpenRequestIdDetail} onOpenRequestIdDetail={handleOpenRequestIdDetail}
/> />
</Box> </Box>
......
import { type CSSProperties } from 'react';
import { Box, Flex, Grid, HStack } from '@chakra-ui/react'; import { Box, Flex, Grid, HStack } from '@chakra-ui/react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import { getChildrenResponses } from '@fastgpt/global/core/chat/utils'; import { getChildrenResponses } from '@fastgpt/global/core/chat/utils';
import { DatasetSearchModeMap } from '@fastgpt/global/core/dataset/constants'; import { DatasetSearchModeMap } from '@fastgpt/global/core/dataset/constants';
import styles from '@/components/Markdown/index.module.scss';
import { formatNumber } from '@fastgpt/global/common/math/tools'; import { formatNumber } from '@fastgpt/global/common/math/tools';
import { getFileIcon } from '@fastgpt/global/common/file/icon'; import { getFileIcon } from '@fastgpt/global/common/file/icon';
import { completionFinishReasonMap } from '@fastgpt/global/core/ai/constants'; import { completionFinishReasonMap } from '@fastgpt/global/core/ai/constants';
...@@ -337,8 +339,25 @@ const ReadFilesRows = ({ activeModule }: { activeModule: ChatHistoryItemResType ...@@ -337,8 +339,25 @@ const ReadFilesRows = ({ activeModule }: { activeModule: ChatHistoryItemResType
); );
}; };
export const WorkflowResultRows = ({ activeModule }: { activeModule: ChatHistoryItemResType }) => { export const WorkflowResultRows = ({
activeModule,
contentHeight
}: {
activeModule: ChatHistoryItemResType;
contentHeight?: number;
}) => {
const { t } = useSafeTranslation(); const { t } = useSafeTranslation();
const responseCodeBlockHeight = contentHeight
? `${Math.floor(contentHeight * 0.8)}px`
: undefined;
const codeBlockContentBoxProps = responseCodeBlockHeight
? {
className: styles.codeJsonConstrained,
style: {
'--response-code-block-height': responseCodeBlockHeight
} as CSSProperties
}
: undefined;
return ( return (
<> <>
...@@ -372,9 +391,21 @@ export const WorkflowResultRows = ({ activeModule }: { activeModule: ChatHistory ...@@ -372,9 +391,21 @@ export const WorkflowResultRows = ({ activeModule }: { activeModule: ChatHistory
/> />
<Row label={t('chat:tool_input')} value={activeModule.toolInput} /> <Row label={t('chat:tool_input')} value={activeModule.toolInput} />
<Row label={t('chat:tool_output')} value={activeModule.pluginOutput} /> <Row label={t('chat:tool_output')} value={activeModule.pluginOutput} />
<Row label={t('common:core.chat.response.text output')} value={activeModule.textOutput} /> <Row
<Row label={t('workflow:response.Custom inputs')} value={activeModule.customInputs} /> label={t('common:core.chat.response.text output')}
<Row label={t('workflow:response.Custom outputs')} value={activeModule.customOutputs} /> value={activeModule.textOutput}
contentBoxProps={codeBlockContentBoxProps}
/>
<Row
label={t('workflow:response.Custom inputs')}
value={activeModule.customInputs}
contentBoxProps={codeBlockContentBoxProps}
/>
<Row
label={t('workflow:response.Custom outputs')}
value={activeModule.customOutputs}
contentBoxProps={codeBlockContentBoxProps}
/>
<Row label={t('workflow:response.Code log')} value={activeModule.codeLog} /> <Row label={t('workflow:response.Code log')} value={activeModule.codeLog} />
<ReadFilesRows activeModule={activeModule} /> <ReadFilesRows activeModule={activeModule} />
<Row <Row
......
...@@ -2,6 +2,7 @@ import { useMemo, type ReactNode } from 'react'; ...@@ -2,6 +2,7 @@ import { useMemo, type ReactNode } from 'react';
import { Box, type BoxProps } from '@chakra-ui/react'; import { Box, type BoxProps } from '@chakra-ui/react';
import Markdown from '@/components/Markdown'; import Markdown from '@/components/Markdown';
import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation';
import markdownStyles from '../../ChatContainer/ChatBox/components/AIChatBubble/index.module.scss';
export const responseRowValueBoxStyles: BoxProps = { export const responseRowValueBoxStyles: BoxProps = {
minH: '32px', minH: '32px',
...@@ -44,12 +45,14 @@ export const Row = ({ ...@@ -44,12 +45,14 @@ export const Row = ({
label, label,
value, value,
rawDom, rawDom,
rawDomBoxProps rawDomBoxProps,
contentBoxProps
}: { }: {
label: string; label: string;
value?: string | number | boolean | object; value?: string | number | boolean | object;
rawDom?: ReactNode; rawDom?: ReactNode;
rawDomBoxProps?: BoxProps; rawDomBoxProps?: BoxProps;
contentBoxProps?: BoxProps;
}) => { }) => {
const { t } = useSafeTranslation(); const { t } = useSafeTranslation();
const val = value || rawDom; const val = value || rawDom;
...@@ -57,7 +60,7 @@ export const Row = ({ ...@@ -57,7 +60,7 @@ export const Row = ({
const formatValue = useMemo(() => { const formatValue = useMemo(() => {
if (isObject) { if (isObject) {
return `~~~json\n${JSON.stringify(value, null, 2)}`; return `~~~json\n${JSON.stringify(value, null, 2)}\n~~~`;
} }
if (typeof value === 'string') { if (typeof value === 'string') {
return t(value); return t(value);
...@@ -87,12 +90,16 @@ export const Row = ({ ...@@ -87,12 +90,16 @@ export const Row = ({
})} })}
> >
<Box <Box
{...contentBoxProps}
minW={0}
w={'100%'}
sx={{ sx={{
'& .markdown': { fontSize: '12px !important' }, '& .markdown': { fontSize: '12px !important' },
'& .markdown pre': { fontSize: '12px !important' } '& .markdown pre': { fontSize: '12px !important' },
...contentBoxProps?.sx
}} }}
> >
<Markdown source={formatValue} /> <Markdown className={markdownStyles.markdown} source={formatValue} />
</Box> </Box>
</RowRender> </RowRender>
); );
......
...@@ -7,11 +7,13 @@ export const WholeResponseContent = ({ ...@@ -7,11 +7,13 @@ export const WholeResponseContent = ({
activeModule, activeModule,
hideTabs, hideTabs,
dataId, dataId,
contentHeight,
onOpenRequestIdDetail onOpenRequestIdDetail
}: { }: {
activeModule: ChatHistoryItemResType; activeModule: ChatHistoryItemResType;
hideTabs?: boolean; hideTabs?: boolean;
dataId?: string; dataId?: string;
contentHeight?: number;
onOpenRequestIdDetail?: (requestId: string) => void; onOpenRequestIdDetail?: (requestId: string) => void;
}) => { }) => {
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
...@@ -27,6 +29,7 @@ export const WholeResponseContent = ({ ...@@ -27,6 +29,7 @@ export const WholeResponseContent = ({
return ( return (
<Box <Box
h={'100%'} h={'100%'}
minH={0}
ref={contentRef} ref={contentRef}
py={3} py={3}
px={hideTabs ? 4 : 3} px={hideTabs ? 4 : 3}
...@@ -43,7 +46,7 @@ export const WholeResponseContent = ({ ...@@ -43,7 +46,7 @@ export const WholeResponseContent = ({
<CommonInfoRows activeModule={activeModule} /> <CommonInfoRows activeModule={activeModule} />
<AiChatRows activeModule={activeModule} onOpenRequestIdDetail={onOpenRequestIdDetail} /> <AiChatRows activeModule={activeModule} onOpenRequestIdDetail={onOpenRequestIdDetail} />
<DatasetSearchRows activeModule={activeModule} dataId={dataId} /> <DatasetSearchRows activeModule={activeModule} dataId={dataId} />
<WorkflowResultRows activeModule={activeModule} /> <WorkflowResultRows activeModule={activeModule} contentHeight={contentHeight} />
</Box> </Box>
); );
}; };
...@@ -31,7 +31,7 @@ const WholeResponseModal = ({ onClose, dataId }: { onClose: () => void; dataId: ...@@ -31,7 +31,7 @@ const WholeResponseModal = ({ onClose, dataId }: { onClose: () => void; dataId:
w={['90vw', '880px']} w={['90vw', '880px']}
maxW={['90vw', '880px']} maxW={['90vw', '880px']}
h={['90vh', '80vh']} h={['90vh', '80vh']}
maxH={['90vh', '700px']} maxH={['90vh', '80vh']}
headerStyles={{ headerStyles={{
px: [5, 8], px: [5, 8],
pt: [6, 8] pt: [6, 8]
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment