refertor:使用antdX重构dify聊天渲染组件,到处引用文件
This commit is contained in:
@@ -3,7 +3,7 @@ import { useState } from 'react';
|
||||
import type { ChatItem, Feedbacktype } from '~/api/dify-chat';
|
||||
import '../../styles/components/chat-with-llm/chat-message.css';
|
||||
import { parseMessageContent } from '../../utils/message-parser';
|
||||
import Markdown from './markdown';
|
||||
import Markdown, { SourcesPanel } from './markdown';
|
||||
import ThinkingBlock from './thinking-block';
|
||||
import ThoughtProcess from './thought-process';
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function ChatMessage({
|
||||
message.feedback?.rating || null
|
||||
);
|
||||
|
||||
const { id, content, isAnswer, agent_thoughts, message_files, isOpeningStatement, suggestedQuestions, more } = message;
|
||||
const { id, content, isAnswer, agent_thoughts, message_files, isOpeningStatement, suggestedQuestions, more, retriever_resources } = message;
|
||||
const isAgentMode = !!agent_thoughts && agent_thoughts.length > 0;
|
||||
|
||||
/**
|
||||
@@ -70,7 +70,10 @@ export default function ChatMessage({
|
||||
<div key={index}>
|
||||
{thought.thought && (
|
||||
<div className={isResponding && index === agent_thoughts.length - 1 ? 'streaming-text' : ''}>
|
||||
<Markdown content={thought.thought} />
|
||||
<Markdown
|
||||
content={thought.thought}
|
||||
retrieverResources={index === agent_thoughts.length - 1 ? retriever_resources : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{thought.tool && (
|
||||
@@ -98,7 +101,7 @@ export default function ChatMessage({
|
||||
{/* 实际回复内容 */}
|
||||
{parsed.response && (
|
||||
<div className={isResponding ? 'streaming-text' : ''}>
|
||||
<Markdown content={parsed.response} />
|
||||
<Markdown content={parsed.response} retrieverResources={retriever_resources} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -184,6 +187,12 @@ export default function ChatMessage({
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{/* 引用来源面板 - 放在气泡外面 */}
|
||||
{isAnswer && retriever_resources && retriever_resources.length > 0 && (
|
||||
<div className="sources-panel-wrapper">
|
||||
<SourcesPanel resources={retriever_resources} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -227,7 +227,7 @@ export default function Chat() {
|
||||
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
|
||||
});
|
||||
|
||||
// 添加AI回答
|
||||
// 添加AI回答(包含检索资源)
|
||||
newChatList.push({
|
||||
id: item.id,
|
||||
content: item.answer,
|
||||
@@ -235,6 +235,7 @@ export default function Chat() {
|
||||
feedback: item.feedback,
|
||||
isAnswer: true,
|
||||
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
|
||||
retriever_resources: item.retriever_resources || [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,89 +1,138 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import RemarkMath from 'remark-math';
|
||||
import RemarkBreaks from 'remark-breaks';
|
||||
import RehypeKatex from 'rehype-katex';
|
||||
import RemarkGfm from 'remark-gfm';
|
||||
import { Sources } from '@ant-design/x';
|
||||
import XMarkdown, { type ComponentProps } from '@ant-design/x-markdown';
|
||||
import { Tooltip } from 'antd';
|
||||
import React, { useMemo } from 'react';
|
||||
import type { RetrieverResource } from '~/api/dify-chat';
|
||||
import '../../styles/components/chat-with-llm/markdown.css';
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
retrieverResources?: RetrieverResource[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 渲染组件
|
||||
* 使用 react-markdown 库进行标准 Markdown 解析,支持流式渲染
|
||||
* 引用索引组件 - 在文本中显示可点击的引用标记
|
||||
*/
|
||||
export default function Markdown({ content, className = '' }: MarkdownProps) {
|
||||
// console.log('🎨 [Markdown] 渲染组件:', {
|
||||
// contentLength: content?.length || 0,
|
||||
// contentPreview: content?.substring(0, 100) + (content && content.length > 100 ? '...' : ''),
|
||||
// className,
|
||||
// hasContent: !!content
|
||||
// });
|
||||
const SourceRefComponent = React.memo(({
|
||||
children,
|
||||
resources
|
||||
}: ComponentProps & { resources?: RetrieverResource[] }) => {
|
||||
const refNumber = parseInt(`${children}` || '0', 10);
|
||||
|
||||
// 如果没有资源数据或引用号无效,只显示上标数字
|
||||
if (!resources || resources.length === 0 || refNumber <= 0) {
|
||||
return <sup className="source-ref-plain">[{children}]</sup>;
|
||||
}
|
||||
|
||||
// 查找对应的资源
|
||||
const resource = resources.find(r => r.position === refNumber);
|
||||
if (!resource) {
|
||||
return <sup className="source-ref-plain">[{children}]</sup>;
|
||||
}
|
||||
|
||||
// 构建引用项列表 - 显示完整内容
|
||||
const items = resources.map((r) => ({
|
||||
title: `${r.position}. ${r.document_name}`,
|
||||
key: r.position,
|
||||
description: r.content || '',
|
||||
}));
|
||||
|
||||
return (
|
||||
<Sources
|
||||
activeKey={refNumber}
|
||||
title={`[${children}]`}
|
||||
items={items}
|
||||
inline={true}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SourceRefComponent.displayName = 'SourceRefComponent';
|
||||
|
||||
/**
|
||||
* 引用来源面板组件 - 在消息下方显示所有引用(独立导出)
|
||||
*/
|
||||
export const SourcesPanel = React.memo(({ resources }: { resources: RetrieverResource[] }) => {
|
||||
if (!resources || resources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sources-panel">
|
||||
<div className="sources-panel-header">
|
||||
<i className="ri-file-text-line"></i>
|
||||
<span>引用来源</span>
|
||||
</div>
|
||||
<div className="sources-panel-list">
|
||||
{resources.map((resource) => (
|
||||
<Tooltip
|
||||
key={`${resource.segment_id}-${resource.position}`}
|
||||
title={
|
||||
<div className="source-tooltip">
|
||||
<div className="source-tooltip-header">
|
||||
<strong>{resource.document_name}</strong>
|
||||
</div>
|
||||
<div className="source-tooltip-content">
|
||||
{resource.content}
|
||||
</div>
|
||||
<div className="source-tooltip-meta">
|
||||
<span>相关度: {(resource.score * 100).toFixed(0)}%</span>
|
||||
<span style={{ marginLeft: 12 }}>来源: {resource.dataset_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
placement="topLeft"
|
||||
autoAdjustOverflow={false}
|
||||
color="rgba(0, 104, 74, 0.92)"
|
||||
classNames={{ root: 'source-tooltip-overlay' }}
|
||||
>
|
||||
<div className="source-item">
|
||||
<span className="source-item-number">{resource.position}</span>
|
||||
<span className="source-item-name">{resource.document_name}</span>
|
||||
<span className="source-item-score">
|
||||
{(resource.score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SourcesPanel.displayName = 'SourcesPanel';
|
||||
|
||||
/**
|
||||
* Markdown 渲染组件
|
||||
* 使用 @ant-design/x-markdown 进行 Markdown 解析,支持流式渲染
|
||||
*/
|
||||
export default function Markdown({
|
||||
content,
|
||||
className = '',
|
||||
retrieverResources
|
||||
}: MarkdownProps) {
|
||||
// 创建自定义的 sup 组件,传入引用资源
|
||||
const customComponents = useMemo(() => {
|
||||
return {
|
||||
sup: (props: ComponentProps) => (
|
||||
<SourceRefComponent {...props} resources={retrieverResources} />
|
||||
),
|
||||
};
|
||||
}, [retrieverResources]);
|
||||
|
||||
if (!content) {
|
||||
console.log('⚠️ [Markdown] 内容为空,返回null');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`markdown-content ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
||||
rehypePlugins={[RehypeKatex]}
|
||||
components={{
|
||||
code({ className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isCodeBlock = match;
|
||||
|
||||
if (isCodeBlock) {
|
||||
// 代码块
|
||||
return (
|
||||
<pre style={{
|
||||
backgroundColor: '#f6f8fa',
|
||||
padding: '16px',
|
||||
borderRadius: '6px',
|
||||
overflow: 'auto',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.45',
|
||||
margin: '1em 0'
|
||||
}}>
|
||||
<code style={{
|
||||
backgroundColor: 'transparent',
|
||||
padding: '0',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace'
|
||||
}}>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
} else {
|
||||
// 内联代码
|
||||
return (
|
||||
<code
|
||||
className={className}
|
||||
style={{
|
||||
backgroundColor: 'rgba(175, 184, 193, 0.2)',
|
||||
padding: '0.2em 0.4em',
|
||||
borderRadius: '3px',
|
||||
fontSize: '85%',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace'
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
},
|
||||
}}
|
||||
<XMarkdown
|
||||
components={customComponents}
|
||||
paragraphTag="div"
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</XMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user