import React, { ReactElement, ReactNode, useMemo, useCallback } from 'react'; import cls from 'classnames'; import { DialogueContentProps } from '../interface'; import MarkdownRender from '../../markdownRender'; import { cssClasses, strings } from '@douyinfe/semi-foundation/aiChatDialogue/constants'; import { Image } from '../../index'; import { FunctionToolCall, InputFile, ContentItem, InputMessage, InputText, InputImage, OutputText, Reasoning, CustomToolCall, OutputMessage, Refusal, Annotation, Reference } from '@douyinfe/semi-foundation/aiChatDialogue/foundation'; import { DialogueContentItemRenderer } from '../interface'; import { IconAlertCircle, IconCode, IconExcel, IconFile, IconImage, IconPdf, IconSendMsgStroked, IconSpin, IconVideo, IconWord, IconWrench } from '@douyinfe/semi-icons'; import { ReasoningWidget } from './contentItem/reasoning'; import { AnnotationWidget } from './contentItem/annotation'; import { ReferenceWidget } from './contentItem/reference'; import Code from './contentItem/code'; import { Locale } from '../../locale/interface'; import LocaleConsumer from "../../locale/localeConsumer"; import { messageToChatInput } from '@douyinfe/semi-foundation/aiChatDialogue/dataAdapter'; import { escapeHtmlInMarkdown } from '@douyinfe/semi-foundation/utils/escapeHtml'; interface FileAttachmentProps extends InputFile { onFileClick?: (file: InputFile) => void; disabledFileItemClick?: boolean; role?: string; showReference?: boolean; onReferenceClick?: (item: Reference ) => void; isLastFile?: boolean } const { PREFIX_CONTENT } = cssClasses; const { STATUS, MODE, ROLE, MESSAGE_ITEM_TYPE, TEXT_TYPES, TOOL_CALL_TYPES, DOCUMENT_TYPES, IMAGE_TYPES, PDF_TYPES, EXCEL_TYPES, CODE_TYPES, VIDEO_TYPES } = strings; const ImageAttachment = React.memo((props: {src: string; isList: boolean; msg: InputImage; onImageClick?: (msg: InputImage) => void; isLastImage?: boolean}) => { const { src, isList, msg, onImageClick, isLastImage } = props; return { onImageClick && onImageClick(msg); }} />; }); const FileAttachment = React.memo((props: FileAttachmentProps) => { const { onFileClick, disabledFileItemClick, role, onReferenceClick, showReference, isLastFile, ...restProps } = props; const suffix = restProps?.filename?.split('.').pop(); const realType = suffix ?? restProps?.fileInstance?.type?.split('/').pop(); const renderFileIcon = useCallback((type: string, props: InputFile) => { let icon = null; let typeCls = ''; if (DOCUMENT_TYPES.includes(type)) { typeCls = 'word'; icon = ; } else if (IMAGE_TYPES.includes(type)) { typeCls = 'image'; icon =
; } else if (PDF_TYPES.includes(type)) { typeCls = 'pdf'; icon = ; } else if (EXCEL_TYPES.includes(type)) { typeCls = 'excel'; icon = ; } else if (CODE_TYPES.includes(type)) { typeCls = 'code'; icon = ; } else if (VIDEO_TYPES.includes(type)) { typeCls = 'video'; icon = ; } else { typeCls = 'default'; icon = ; } return (
{icon}
); }, []); const handleFileClick = useCallback((e: React.MouseEvent) => { onFileClick?.(restProps); if (disabledFileItemClick) { e.preventDefault(); return; } }, [onFileClick, disabledFileItemClick, restProps]); const handleReferenceClick = useCallback((e: React.MouseEvent) => { onReferenceClick?.({ name: restProps?.filename, url: restProps?.file_url }); e.preventDefault(); }, [onReferenceClick, restProps]); return {renderFileIcon(realType as string, restProps)}
{restProps?.filename} {realType} {' '}{restProps?.size}
{role === ROLE.USER && showReference && ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events
)}
; }); const ToolCallWidget = React.memo((props: FunctionToolCall) => { const { name } = props; return
{name} {props.arguments}
; }); const DialogueContent = React.memo((props: DialogueContentProps) => { const { message, customRenderFunc, role: roleInfo, mode, markdownRenderProps, editing, messageEditRender, showReference, onFileClick, onImageClick, disabledFileItemClick, renderDialogueContentItem, onAnnotationClick, onReferenceClick, escapeHtml } = props; const { content, role, status, references } = message; const shouldEscapeHtml = escapeHtml && role === ROLE.USER; const markdownComponents = useMemo(() => ({ 'code': Code, ...markdownRenderProps?.components }), [markdownRenderProps]); const wrapCls = useMemo(() => { const isUser = role === ROLE.USER; const bubble = mode === MODE.BUBBLE; const userBubble = mode === MODE.USER_BUBBLE && isUser; return cls({ [`${PREFIX_CONTENT}`]: true, [`${PREFIX_CONTENT}-${mode}`]: bubble || userBubble, [`${PREFIX_CONTENT}-no-bubble`]: !(bubble || userBubble), [`${PREFIX_CONTENT}-user`]: isUser, [`${PREFIX_CONTENT}-error`]: status === STATUS.FAILED && (bubble || userBubble) }); }, [role, status, mode]); const customRenderer = useCallback((type: string, index: number, item: ContentItem) => { const customRendererFunc = renderDialogueContentItem?.[type]; if (customRendererFunc) { let renderer: DialogueContentItemRenderer | undefined; // 工具调用类型可从嵌套映射按函数名优先匹配 if (TOOL_CALL_TYPES.includes(type as any)) { const toolCallItem = item as FunctionToolCall | CustomToolCall; const functionName = toolCallItem?.name; if (typeof customRendererFunc === 'object' && functionName) { const nestedRenderer = (customRendererFunc as Record)?.[functionName]; if (nestedRenderer) { renderer = nestedRenderer; } } } // 兜底:如果没有匹配到嵌套渲染器且本身是函数,则使用之 if (!renderer && typeof customRendererFunc === 'function') { renderer = customRendererFunc as DialogueContentItemRenderer; } if (renderer) { return
{renderer(item, message)}
; } } return null; }, [renderDialogueContentItem, message]); const renderMarkdown = useCallback((text: string, key: React.Key) => { if (text !== '') { const rawText = shouldEscapeHtml ? escapeHtmlInMarkdown(text) : text; return
{ role === ROLE.USER && showReference && ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events
onReferenceClick?.({ type: 'text', content: text })} >
) }
; } return null; }, [wrapCls, markdownComponents, markdownRenderProps, role, onReferenceClick, showReference, shouldEscapeHtml]); const renderMessage = useCallback((msg: InputMessage | OutputMessage, index: number) => { if (typeof msg.content === 'string') { return renderMarkdown(msg.content, `msg-${index}`); } const inner = (msg.content ?? []) as Array; const isImageList = inner.filter(i => i?.type === MESSAGE_ITEM_TYPE.INPUT_IMAGE).length > 1; return inner.map((i, innerIdx) => { const customNode = customRenderer(i?.type as string, index, i); if (customNode) return customNode; if (TEXT_TYPES.includes(i?.type as string)) { const annotation = (i as OutputText).annotations; // 过滤掉 file_citation 和 container_file_citation 类型的 annotation const filteredAnnotation = annotation && annotation.length > 0 && annotation.filter((item: Annotation) => (item.type !== 'file_citation' && item.type !== 'container_file_citation')); return ( {filteredAnnotation && filteredAnnotation.length > 0 && onAnnotationClick(filteredAnnotation)} /> } {renderMarkdown((i as InputText | OutputText).text || '', `msg-${index}-${innerIdx}`)} {renderMarkdown((i as Refusal).refusal || '', `msg-${index}-${innerIdx}-refusal`)} ); } if (i?.type === MESSAGE_ITEM_TYPE.INPUT_IMAGE) { const nextItemType = inner[innerIdx + 1]?.type; const isLastImage = innerIdx === inner.length - 1 || nextItemType === MESSAGE_ITEM_TYPE.INPUT_FILE; return ( { nextItemType === MESSAGE_ITEM_TYPE.INPUT_FILE &&
}
); } if (i?.type === MESSAGE_ITEM_TYPE.INPUT_FILE) { const nextItemType = inner[innerIdx + 1]?.type; const isLastFile = innerIdx === inner.length - 1 || nextItemType === MESSAGE_ITEM_TYPE.INPUT_IMAGE; return ( { nextItemType === MESSAGE_ITEM_TYPE.INPUT_IMAGE &&
}
); } return null; }); }, [renderMarkdown, customRenderer, onAnnotationClick, onImageClick, onFileClick, disabledFileItemClick, role, onReferenceClick, showReference]); const renderToolCall = useCallback((item: ContentItem, index: number) => ( ), []); const builtinRenderers = useMemo(() => ({ [MESSAGE_ITEM_TYPE.MESSAGE]: (item: ContentItem, index: number) => renderMessage(item as InputMessage | OutputMessage, index), [MESSAGE_ITEM_TYPE.REASONING]: (item: ContentItem, index: number) => ( ), [MESSAGE_ITEM_TYPE.FUNCTION_CALL ]: renderToolCall, [MESSAGE_ITEM_TYPE.CUSTOM_TOOL_CALL]: renderToolCall, } as Record React.ReactNode>), [renderMessage, markdownRenderProps, renderToolCall]); const loadingNode = useMemo(() => { const isLoading = [STATUS.QUEUED, STATUS.IN_PROGRESS, STATUS.INCOMPLETE].includes(status); const isOutputExist = (content && content?.length > 0) || message.output_text; // 如果内容为空,且没有 output_text,则显示 loading // If the content is empty and there is no output_text, it will display loading if (isLoading && !isOutputExist) { return componentName="AIChatDialogue" > {(locale: Locale["AIChatDialogue"]) => locale['loading']} ; } else { return null; } }, [status, content, message.output_text]); const node = useMemo(() => { if (editing) { return messageEditRender?.(messageToChatInput(message)); } else { let realContent: ReactNode | ReactNode[]; const textContent = typeof content === 'string' ? content : message.output_text; if (textContent) { const defaultRenderer = renderDialogueContentItem?.['default']; if (typeof defaultRenderer === 'function') { realContent =
{defaultRenderer(textContent, message)}
; } else { const rawText = shouldEscapeHtml ? escapeHtmlInMarkdown(textContent) : textContent; realContent =
{role === ROLE.USER && showReference && ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events
onReferenceClick?.({ type: 'text', content: textContent })} >
)}
; } } else if (Array.isArray(content)) { realContent = content.map((item: ContentItem, index) => { const typeKey = item?.type as string | undefined; const effectiveType = typeKey ?? MESSAGE_ITEM_TYPE.MESSAGE; // User defined rendering first const customNode = customRenderer(effectiveType, index, item); if (customNode) return customNode; // Then builtin rendering const renderer = builtinRenderers[effectiveType]; if (renderer) return renderer(item, index); return null; }); } return (
{(status === STATUS.FAILED || status === STATUS.CANCELLED) &&
}
{realContent}
); } }, [status, content, editing, message, role, messageEditRender, markdownRenderProps, wrapCls, markdownComponents, builtinRenderers, customRenderer, renderDialogueContentItem, showReference, onReferenceClick, shouldEscapeHtml]); if (customRenderFunc) { return customRenderFunc({ message, role: roleInfo, defaultContent: node, className: wrapCls, }) as ReactElement; } else { return
{references && references.length > 0 && !editing && } {node} {loadingNode}
; } }); export default DialogueContent;