Files
leaudit-platform-frontend/app/components/dify-chat/chat-message.tsx
T

250 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { LikeOutlined, LikeFilled, DislikeOutlined, DislikeFilled, CopyOutlined } from '@ant-design/icons';
import { Button, Card, Spin, Tooltip } from 'antd';
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, { SourcesPanel } from './markdown';
import ThinkingBlock from './thinking-block';
import ThoughtProcess from './thought-process';
interface ChatMessageProps {
message: ChatItem;
onFeedback?: (messageId: string, feedback: Feedbacktype) => void;
isResponding?: boolean;
onRegenerate?: (messageId: string) => void;
}
/**
* 聊天消息组件
*/
export default function ChatMessage({
message,
onFeedback,
isResponding = false,
onRegenerate
}: ChatMessageProps) {
const [feedback, setFeedback] = useState<'like' | 'dislike' | null>(
message.feedback?.rating || null
);
const [copied, setCopied] = useState(false);
/**
* 处理复制
*/
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('复制失败:', err);
}
};
const { id, content, isAnswer, agent_thoughts, message_files, isOpeningStatement, suggestedQuestions, more, retriever_resources } = message;
const isAgentMode = !!agent_thoughts && agent_thoughts.length > 0;
/**
* 处理反馈
*/
const handleFeedback = async (type: 'like' | 'dislike') => {
if (!id || id.startsWith('placeholder-') || id.startsWith('question-') || id.startsWith('opening-'))
return;
// 如果已经选择了相同的反馈,则取消选择
const newFeedback = feedback === type ? null : type;
setFeedback(newFeedback);
await onFeedback?.(id, {
rating: newFeedback,
});
};
/**
* 渲染AI回答内容
*/
const renderAnswerContent = () => {
// console.log('🎨 渲染AI回答内容:', { content, isResponding, isAgentMode });
// 如果正在响应且没有内容
if (isResponding && (isAgentMode ? (!content && (agent_thoughts || []).filter(item => !!item.thought || !!item.tool).length === 0) : !content)) {
return (
<div className="responding-indicator">
<Spin size="small" />
<span>AI ...</span>
</div>
);
}
// Agent模式(有思考过程)
if (isAgentMode) {
return (
<div className="space-y-3">
{agent_thoughts?.map((thought, index) => (
<div key={index}>
{thought.thought && (
<div className={isResponding && index === agent_thoughts.length - 1 ? 'streaming-text' : ''}>
<Markdown
content={thought.thought}
retrieverResources={index === agent_thoughts.length - 1 ? retriever_resources : undefined}
/>
</div>
)}
{thought.tool && (
<ThoughtProcess
thought={thought}
isFinished={!!thought.observation || !isResponding}
/>
)}
</div>
))}
</div>
);
}
// 普通模式 - 解析内容,检查是否包含思考过程
const parsed = parseMessageContent(content);
return (
<div>
{/* 思考过程区域 */}
{parsed.hasThinking && (
<ThinkingBlock content={parsed.thinking} defaultExpanded={false} />
)}
{/* 实际回复内容 */}
{parsed.response && (
<div className={isResponding ? 'streaming-text' : ''}>
<Markdown content={parsed.response} retrieverResources={retriever_resources} />
</div>
)}
</div>
);
};
/**
* 渲染建议问题
*/
const renderSuggestedQuestions = () => {
if (!suggestedQuestions || suggestedQuestions.length === 0) return null;
return (
<div className="suggested-questions">
<div className="text-sm text-gray-500 mb-2"></div>
<div className="flex flex-wrap gap-2">
{suggestedQuestions.map((question, index) => (
<Button
key={index}
size="small"
type="dashed"
className="question-button text-left"
onClick={() => {
// 这里可以添加点击建议问题的处理逻辑
// console.log('Suggested question clicked:', question);
}}
>
{question}
</Button>
))}
</div>
</div>
);
};
/**
* 渲染消息元信息
*/
const renderMessageMeta = () => {
if (!more) return null;
return (
<div className="message-timestamp">
{more.time && <span>: {more.time}</span>}
{more.tokens && <span className="ml-2">Token: {more.tokens}</span>}
{more.latency && <span className="ml-2">: {more.latency}</span>}
</div>
);
};
// 如果是开场白,特殊处理
if (isOpeningStatement) {
return (
<div className="chat-message">
<Card className="message-card assistant" size="small">
<div className="flex items-start gap-3">
<div className="flex-1">
<Markdown content={content} />
{renderSuggestedQuestions()}
</div>
</div>
</Card>
</div>
);
}
// 判断是否可以显示反馈按钮(AI回答、非占位符、非正在响应)
const canShowFeedback = isAnswer &&
!id.startsWith('placeholder-') &&
!id.startsWith('opening-') &&
!id.startsWith('response-') &&
!isResponding;
return (
<div className="chat-message">
<div className={`flex ${isAnswer ? 'justify-start' : 'justify-end'} mb-2`}>
<div className={`message-card ${isAnswer ? 'assistant' : 'user'}`}>
<Card size="small" className="shadow-sm">
<div className="flex items-start gap-2">
{/* 消息内容 */}
<div className="flex-1 min-w-0">
{isAnswer ? renderAnswerContent() : (
<div>
<Markdown content={content} />
{/* {renderImages(message_files)} */}
</div>
)}
</div>
</div>
{/* 反馈按钮 - 仅在AI回答且非占位符时显示 */}
{canShowFeedback && (
<div className="message-feedback">
<Tooltip title={copied ? '已复制' : '复制'}>
<Button
size="small"
type="text"
icon={<CopyOutlined />}
onClick={handleCopy}
className={copied ? 'message-copy-btn copied' : 'message-copy-btn'}
/>
</Tooltip>
<div className="feedback-actions-right">
<Button
size="small"
type="text"
icon={feedback === 'like' ? <LikeFilled /> : <LikeOutlined />}
onClick={() => handleFeedback('like')}
className={feedback === 'like' ? 'feedback-active-like' : ''}
/>
<Button
size="small"
type="text"
icon={feedback === 'dislike' ? <DislikeFilled /> : <DislikeOutlined />}
onClick={() => handleFeedback('dislike')}
className={feedback === 'dislike' ? 'feedback-active-dislike' : ''}
/>
</div>
</div>
)}
</Card>
</div>
</div>
{/* 引用来源面板 - 放在气泡外面 */}
{isAnswer && retriever_resources && retriever_resources.length > 0 && (
<div className="sources-panel-wrapper">
<SourcesPanel resources={retriever_resources} />
</div>
)}
</div>
);
}