5bee9288b9
1、修复了若干无权限时的失败提示语 2、新增了一个生成后续建议问题的功能 3、重构了知识问答部分的权限管理模块 4、修复了若干渲染不恰当的样式渲染
279 lines
12 KiB
TypeScript
279 lines
12 KiB
TypeScript
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;
|
|
onSuggestedQuestionClick?: (question: string) => void;
|
|
}
|
|
|
|
/**
|
|
* 聊天消息组件
|
|
*/
|
|
export default function ChatMessage({
|
|
message,
|
|
onFeedback,
|
|
isResponding = false,
|
|
onRegenerate,
|
|
onSuggestedQuestionClick,
|
|
}: 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" style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid #f0f0f0' }}>
|
|
<div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<i className="ri-compass-3-line" />
|
|
<span>继续探索</span>
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
{suggestedQuestions.map((question, index) => (
|
|
<button
|
|
key={index}
|
|
onClick={() => onSuggestedQuestionClick?.(question)}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
padding: '6px 10px',
|
|
border: '1px solid #e8e8e8',
|
|
borderRadius: 6,
|
|
background: '#fafafa',
|
|
cursor: 'pointer',
|
|
textAlign: 'left',
|
|
fontSize: 13,
|
|
color: '#595959',
|
|
transition: 'all 0.2s',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = '#00684a';
|
|
e.currentTarget.style.color = '#00684a';
|
|
e.currentTarget.style.background = 'rgba(0,104,74,0.04)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = '#e8e8e8';
|
|
e.currentTarget.style.color = '#595959';
|
|
e.currentTarget.style.background = '#fafafa';
|
|
}}
|
|
>
|
|
<i className="ri-search-line" style={{ color: '#8c8c8c', flexShrink: 0, fontSize: 12 }} />
|
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{question}</span>
|
|
<i className="ri-arrow-right-line" style={{ color: '#bfbfbf', flexShrink: 0, fontSize: 12 }} />
|
|
</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()}
|
|
{!isResponding && renderSuggestedQuestions()}
|
|
</>
|
|
) : (
|
|
<div>
|
|
<Markdown content={content} />
|
|
</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>
|
|
);
|
|
}
|