|
|
|
|
|
import React, { useState, useRef, useEffect } from 'react'
|
|
|
|
|
|
import ReactMarkdown from 'react-markdown'
|
|
|
|
|
|
import remarkGfm from 'remark-gfm'
|
|
|
|
|
|
import {
|
|
|
|
|
|
Input,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Typography,
|
|
|
|
|
|
Spin,
|
|
|
|
|
|
message,
|
|
|
|
|
|
Layout,
|
|
|
|
|
|
Tooltip,
|
|
|
|
|
|
Input as AntInput,
|
|
|
|
|
|
Modal,
|
|
|
|
|
|
Upload,
|
|
|
|
|
|
Dropdown,
|
|
|
|
|
|
Space,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
} from 'antd'
|
|
|
|
|
|
import {
|
|
|
|
|
|
ArrowUpOutlined,
|
|
|
|
|
|
LinuxOutlined,
|
|
|
|
|
|
RobotOutlined,
|
|
|
|
|
|
LoadingOutlined,
|
|
|
|
|
|
CopyOutlined,
|
|
|
|
|
|
ReloadOutlined,
|
|
|
|
|
|
PaperClipOutlined,
|
|
|
|
|
|
} from '@ant-design/icons'
|
|
|
|
|
|
import { callLocalChatAPI, handleAPIError } from '@/config/api'
|
|
|
|
|
|
import { conversationStore } from '@/utils/pageConversationStore'
|
|
|
|
|
|
import './ChatConversation.less'
|
|
|
|
|
|
|
|
|
|
|
|
const { Text } = Typography
|
|
|
|
|
|
const { TextArea } = Input
|
|
|
|
|
|
const { Sider, Content } = Layout
|
|
|
|
|
|
|
|
|
|
|
|
function ChatConversation() {
|
|
|
|
|
|
// 会话相关状态 - 使用独立的存储
|
|
|
|
|
|
const [conversations, setConversations] = useState(conversationStore.getConversations())
|
|
|
|
|
|
const [currentConversationId, setCurrentConversationId] = useState(conversationStore.getCurrentConversationId())
|
|
|
|
|
|
const [inputValue, setInputValue] = useState('')
|
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
|
const [sidebarExpanded, setSidebarExpanded] = useState(false)
|
|
|
|
|
|
const [editModalVisible, setEditModalVisible] = useState(false)
|
|
|
|
|
|
const [editingConversation, setEditingConversation] = useState(null)
|
|
|
|
|
|
const [newTitle, setNewTitle] = useState('')
|
|
|
|
|
|
|
|
|
|
|
|
// 流式输出状态
|
|
|
|
|
|
const [streamingThought, setStreamingThought] = useState('')
|
|
|
|
|
|
const [streamingMessage, setStreamingMessage] = useState('')
|
|
|
|
|
|
const [isStreaming, setIsStreaming] = useState(false)
|
|
|
|
|
|
const [displayedThought, setDisplayedThought] = useState('')
|
|
|
|
|
|
const [displayedMessage, setDisplayedMessage] = useState('')
|
|
|
|
|
|
|
|
|
|
|
|
// 流式显示控制
|
|
|
|
|
|
const [isTyping, setIsTyping] = useState(false)
|
|
|
|
|
|
|
|
|
|
|
|
const messagesEndRef = useRef(null)
|
|
|
|
|
|
const inputRef = useRef(null)
|
|
|
|
|
|
const typewriterTimeoutRef = useRef(null)
|
|
|
|
|
|
|
|
|
|
|
|
// 获取当前会话
|
|
|
|
|
|
const currentConversation = conversations.find(conv => conv.id === currentConversationId)
|
|
|
|
|
|
const currentMessages = currentConversation?.messages || []
|
|
|
|
|
|
// 自动滚动到底部
|
|
|
|
|
|
const chatScrollRef = useRef(null);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (chatScrollRef.current) {
|
|
|
|
|
|
chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [currentMessages, loading]); // currentMessages 是你的消息数组
|
|
|
|
|
|
|
|
|
|
|
|
const scrollToBottom = () => {
|
|
|
|
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 打字机效果函数
|
|
|
|
|
|
const typewriterEffect = (targetText, setDisplayedText, delay = 20) => {
|
|
|
|
|
|
let currentIndex = 0
|
|
|
|
|
|
const currentDisplayed = displayedThought + displayedMessage
|
|
|
|
|
|
|
|
|
|
|
|
const typeNextChar = () => {
|
|
|
|
|
|
if (currentIndex < targetText.length) {
|
|
|
|
|
|
setDisplayedText(targetText.substring(0, currentIndex + 1))
|
|
|
|
|
|
currentIndex++
|
|
|
|
|
|
typewriterTimeoutRef.current = setTimeout(typeNextChar, delay)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
typeNextChar()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 监听流式内容变化,实现真正的流式显示
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (streamingThought && streamingThought !== displayedThought) {
|
|
|
|
|
|
// 直接显示新内容,实现真正的流式显示
|
|
|
|
|
|
setDisplayedThought(streamingThought)
|
|
|
|
|
|
setIsTyping(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [streamingThought])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (streamingMessage && streamingMessage !== displayedMessage) {
|
|
|
|
|
|
// 直接显示新内容,实现真正的流式显示
|
|
|
|
|
|
setDisplayedMessage(streamingMessage)
|
|
|
|
|
|
setIsTyping(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [streamingMessage])
|
|
|
|
|
|
|
|
|
|
|
|
// 当流式输出停止时,停止打字状态
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!isStreaming) {
|
|
|
|
|
|
setIsTyping(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isStreaming])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const handleChange = value => {
|
|
|
|
|
|
console.log(`selected ${value}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 订阅对话状态变化
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const unsubscribe = conversationStore.subscribe(({ conversations, currentConversationId }) => {
|
|
|
|
|
|
setConversations(conversations)
|
|
|
|
|
|
setCurrentConversationId(currentConversationId)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return unsubscribe
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
// 清理定时器
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (typewriterTimeoutRef.current) {
|
|
|
|
|
|
clearTimeout(typewriterTimeoutRef.current)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
|
}, [currentMessages])
|
|
|
|
|
|
|
|
|
|
|
|
// 创建新对话
|
|
|
|
|
|
const createNewConversation = () => {
|
|
|
|
|
|
conversationStore.createNewConversation()
|
|
|
|
|
|
setInputValue('')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 切换对话
|
|
|
|
|
|
const switchConversation = (conversationId) => {
|
|
|
|
|
|
conversationStore.switchConversation(conversationId)
|
|
|
|
|
|
setInputValue('')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 删除对话
|
|
|
|
|
|
const deleteConversation = (conversationId) => {
|
|
|
|
|
|
if (conversations.length <= 1) {
|
|
|
|
|
|
message.warning('至少需要保留一个对话')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conversationStore.deleteConversation(conversationId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 编辑对话标题
|
|
|
|
|
|
const editConversationTitle = (conversation) => {
|
|
|
|
|
|
setEditingConversation(conversation)
|
|
|
|
|
|
setNewTitle(conversation.title)
|
|
|
|
|
|
setEditModalVisible(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 保存对话标题
|
|
|
|
|
|
const saveConversationTitle = () => {
|
|
|
|
|
|
if (!newTitle.trim()) {
|
|
|
|
|
|
message.warning('标题不能为空')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
conversationStore.updateConversationTitle(editingConversation.id, newTitle.trim())
|
|
|
|
|
|
|
|
|
|
|
|
setEditModalVisible(false)
|
|
|
|
|
|
setEditingConversation(null)
|
|
|
|
|
|
setNewTitle('')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 发送消息
|
|
|
|
|
|
const sendMessage = async () => {
|
|
|
|
|
|
const messageText = inputValue.trim()
|
|
|
|
|
|
if (!messageText || loading) return
|
|
|
|
|
|
|
|
|
|
|
|
// 添加用户消息到独立存储
|
|
|
|
|
|
conversationStore.addMessage('user', messageText)
|
|
|
|
|
|
|
|
|
|
|
|
setInputValue('')
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
setIsStreaming(true)
|
|
|
|
|
|
setStreamingThought('')
|
|
|
|
|
|
setStreamingMessage('')
|
|
|
|
|
|
setDisplayedThought('')
|
|
|
|
|
|
setDisplayedMessage('')
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 调用本地后端接口(流式版本)
|
|
|
|
|
|
const result = await callLocalChatAPI(messageText, (streamData) => {
|
|
|
|
|
|
console.log('Stream data received:', streamData)
|
|
|
|
|
|
if (streamData.type === 'thought') {
|
|
|
|
|
|
console.log('Setting streamingThought:', streamData.content)
|
|
|
|
|
|
// 使用totalContent作为完整内容,content作为新增内容
|
|
|
|
|
|
setStreamingThought(streamData.totalContent || streamData.content)
|
|
|
|
|
|
} else if (streamData.type === 'message') {
|
|
|
|
|
|
console.log('Setting streamingMessage:', streamData.content)
|
|
|
|
|
|
// 使用totalContent作为完整内容,content作为新增内容
|
|
|
|
|
|
setStreamingMessage(streamData.totalContent || streamData.content)
|
|
|
|
|
|
} else if (streamData.type === 'complete') {
|
|
|
|
|
|
// 流式输出完成,将最终内容添加到对话存储
|
|
|
|
|
|
// 使用complete事件中传递的最终内容
|
|
|
|
|
|
const finalThought = streamData.thoughtContent || streamingThought
|
|
|
|
|
|
const finalMessage = streamData.messageContent || streamingMessage
|
|
|
|
|
|
const finalContent = finalThought + (finalMessage ? '\n\n' + finalMessage : '')
|
|
|
|
|
|
|
|
|
|
|
|
console.log('Complete event - finalThought:', finalThought)
|
|
|
|
|
|
console.log('Complete event - finalMessage:', finalMessage)
|
|
|
|
|
|
console.log('Complete event - finalContent:', finalContent)
|
|
|
|
|
|
|
|
|
|
|
|
if (finalContent.trim()) {
|
|
|
|
|
|
conversationStore.addMessage('assistant', finalContent)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
conversationStore.addMessage('assistant', '抱歉,我无法生成回复内容。')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setIsStreaming(false)
|
|
|
|
|
|
setStreamingThought('')
|
|
|
|
|
|
setStreamingMessage('')
|
|
|
|
|
|
setDisplayedThought('')
|
|
|
|
|
|
setDisplayedMessage('')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 如果API没有返回流式数据,直接处理结果
|
|
|
|
|
|
if (result && !isStreaming) {
|
|
|
|
|
|
const thought = result?.thought || ''
|
|
|
|
|
|
const message = result?.message || ''
|
|
|
|
|
|
const finalContent = thought + (message ? '\n\n' + message : '')
|
|
|
|
|
|
|
|
|
|
|
|
if (finalContent.trim()) {
|
|
|
|
|
|
conversationStore.addMessage('assistant', finalContent)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
conversationStore.addMessage('assistant', '抱歉,我无法生成回复内容。')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
const errorMessage = handleAPIError(error)
|
|
|
|
|
|
message.error(errorMessage)
|
|
|
|
|
|
|
|
|
|
|
|
// 添加错误消息到独立存储
|
|
|
|
|
|
conversationStore.addMessage('assistant', `抱歉,我遇到了一个问题:${errorMessage}`)
|
|
|
|
|
|
setIsStreaming(false)
|
|
|
|
|
|
setStreamingThought('')
|
|
|
|
|
|
setStreamingMessage('')
|
|
|
|
|
|
setDisplayedThought('')
|
|
|
|
|
|
setDisplayedMessage('')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理回车键发送
|
|
|
|
|
|
const handleKeyPress = (e) => {
|
|
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
sendMessage()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清空当前对话
|
|
|
|
|
|
const clearCurrentChat = () => {
|
|
|
|
|
|
conversationStore.clearCurrentConversation()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 兼容剪贴板
|
|
|
|
|
|
const copyToClipboard = async (text) => {
|
|
|
|
|
|
if (!text) return Promise.reject(new Error("exmpty text"))
|
|
|
|
|
|
|
|
|
|
|
|
// 现代 API,需 HTTPS 或 localhost(window.isSecureContext)
|
|
|
|
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(text)
|
|
|
|
|
|
return
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// 若失败,继续使用回退方案
|
|
|
|
|
|
console.warn('navigator.clipboard failed, fallback to execCommand', e)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 回退方案
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ta = document.createElement('textarea')
|
|
|
|
|
|
ta.value = text
|
|
|
|
|
|
ta.setAttribute('readonly', '')
|
|
|
|
|
|
ta.style.position = 'fixed'
|
|
|
|
|
|
ta.style.left = '-9999px'
|
|
|
|
|
|
document.body.appendChild(ta)
|
|
|
|
|
|
ta.select()
|
|
|
|
|
|
ta.setSelectionRange(0, ta.value.length)
|
|
|
|
|
|
const ok = document.execCommand('copy')
|
|
|
|
|
|
document.body.removeChild(ta)
|
|
|
|
|
|
if (ok) resolve()
|
|
|
|
|
|
else reject(new Error('execCommand failed'))
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
reject(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染单条消息 ==================================
|
|
|
|
|
|
const renderMessage = (msg, index) => {
|
|
|
|
|
|
const isUser = msg.role === 'user'
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={index}
|
|
|
|
|
|
className={`ds-message-row ${isUser ? 'from-user' : 'from-assistant'}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{!isUser && (
|
|
|
|
|
|
<div className='ds-avatar'>
|
|
|
|
|
|
<RobotOutlined />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{isUser && <div className='ds-avatar user'><LinuxOutlined /></div>}
|
|
|
|
|
|
|
|
|
|
|
|
<div className='ds-message-body'>
|
|
|
|
|
|
<div className='ds-message-meta'>
|
|
|
|
|
|
<span className='role'>{isUser ? '' : 'AI对话'}</span>
|
|
|
|
|
|
<span className='time'>
|
|
|
|
|
|
{msg.timestamp
|
|
|
|
|
|
? (typeof msg.timestamp === 'string'
|
|
|
|
|
|
? msg.timestamp
|
|
|
|
|
|
: msg.timestamp.toLocaleString())
|
|
|
|
|
|
: ''}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{!isUser && (
|
|
|
|
|
|
<span className='actions'>
|
|
|
|
|
|
<Tooltip title='复制'>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size='small'
|
|
|
|
|
|
type='text'
|
|
|
|
|
|
icon={<CopyOutlined />}
|
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await copyToClipboard(msg.content || '')
|
|
|
|
|
|
message.success('已复制')
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('copy failed', err)
|
|
|
|
|
|
message.error('复制失败,请在 HTTPS / localhost 或允许剪贴板权限的环境下重试')
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
<Tooltip title='重新生成(示例)'>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size='small'
|
|
|
|
|
|
type='text'
|
|
|
|
|
|
icon={<ReloadOutlined />}
|
|
|
|
|
|
disabled={loading}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
// 可复用最后一条用户消息再请求
|
|
|
|
|
|
const lastUser = [...currentMessages].reverse().find(m => m.role === 'user')
|
|
|
|
|
|
if (lastUser) {
|
|
|
|
|
|
setInputValue(lastUser.content)
|
|
|
|
|
|
sendMessage()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 回答框 */}
|
|
|
|
|
|
<div className='ds-message-content'>
|
|
|
|
|
|
<ReactMarkdown
|
|
|
|
|
|
remarkPlugins={[remarkGfm]}
|
|
|
|
|
|
>
|
|
|
|
|
|
{msg.content || ''}
|
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染流式输出消息
|
|
|
|
|
|
const renderStreamingMessage = () => {
|
|
|
|
|
|
if (!isStreaming && !displayedThought && !displayedMessage) return null
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className='ds-message-row from-assistant streaming'>
|
|
|
|
|
|
<div className='ds-avatar'>
|
|
|
|
|
|
<RobotOutlined />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='ds-message-body'>
|
|
|
|
|
|
<div className='ds-message-meta'>
|
|
|
|
|
|
<span className='role'>AI对话</span>
|
|
|
|
|
|
<span className='time'>正在思考…</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className='ds-message-content'>
|
|
|
|
|
|
{/* 思考内容 - 浅灰色 */}
|
|
|
|
|
|
{displayedThought && (
|
|
|
|
|
|
<div className='ds-thought-content'>
|
|
|
|
|
|
<ReactMarkdown
|
|
|
|
|
|
remarkPlugins={[remarkGfm]}
|
|
|
|
|
|
>
|
|
|
|
|
|
{displayedThought}
|
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 回复内容 - 正常颜色 */}
|
|
|
|
|
|
{displayedMessage && (
|
|
|
|
|
|
<div className='ds-response-content'>
|
|
|
|
|
|
<ReactMarkdown
|
|
|
|
|
|
remarkPlugins={[remarkGfm]}
|
|
|
|
|
|
>
|
|
|
|
|
|
{displayedMessage}
|
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
|
{/* 流式输入光标 */}
|
|
|
|
|
|
{isTyping && isStreaming && (
|
|
|
|
|
|
<span className='ds-typing-cursor'>|</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 流式输出指示器 */}
|
|
|
|
|
|
{isStreaming && (
|
|
|
|
|
|
<div className='ds-streaming-indicator'>
|
|
|
|
|
|
<Spin
|
|
|
|
|
|
indicator={<LoadingOutlined style={{ fontSize: 14 }} spin />}
|
|
|
|
|
|
size='small'
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className='streaming-text'>
|
|
|
|
|
|
{isTyping ? '正在输入...' : '正在思考...'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
// =================================================
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className='ds-chat-page'>
|
|
|
|
|
|
<div className='ds-chat-scroll' ref={chatScrollRef}>
|
|
|
|
|
|
<div className='ds-chat-inner'>
|
|
|
|
|
|
{currentMessages.length === 0 && !loading && (
|
|
|
|
|
|
// 已有默认初始化对话,无需此处提示
|
|
|
|
|
|
<div className='ds-empty'>
|
|
|
|
|
|
<h3>开始对话吧</h3>
|
|
|
|
|
|
<p>提出一个问题,比如:请帮我总结一段文本。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 历史消息 */}
|
|
|
|
|
|
{currentMessages.map(renderMessage)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 流式输出消息 */}
|
|
|
|
|
|
{renderStreamingMessage()}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 加载状态(当没有流式输出时显示) */}
|
|
|
|
|
|
{loading && !isStreaming && (
|
|
|
|
|
|
<div className='ds-message-row from-assistant thinking'>
|
|
|
|
|
|
<div className='ds-avatar'>
|
|
|
|
|
|
<RobotOutlined />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='ds-message-body'>
|
|
|
|
|
|
<div className='ds-message-meta'>
|
|
|
|
|
|
<span className='role'>AI</span>
|
|
|
|
|
|
<span className='time'>正在思考…</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='ds-message-content typing'>
|
|
|
|
|
|
<Spin
|
|
|
|
|
|
indicator={<LoadingOutlined style={{ fontSize: 16 }} spin />}
|
|
|
|
|
|
size='small'
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className='typing-dots'>
|
|
|
|
|
|
<i /><i /><i />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div ref={messagesEndRef} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* 问答输入框 */}
|
|
|
|
|
|
<div className='ds-input-bar'>
|
|
|
|
|
|
<div className='ds-input-inner'>
|
|
|
|
|
|
<div className='ds-textarea-wrap'>
|
|
|
|
|
|
<TextArea
|
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
|
value={inputValue}
|
|
|
|
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
sendMessage()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
placeholder='给AI助手发送消息'
|
|
|
|
|
|
autoSize={{ minRows: 3, maxRows: 8 }}
|
|
|
|
|
|
disabled={loading}
|
|
|
|
|
|
bordered={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 模型切换 */}
|
|
|
|
|
|
{/* <Select
|
|
|
|
|
|
defaultValue="智能定性"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
minWidth: 120, maxWidth: 200, marginRight: 8
|
|
|
|
|
|
}}
|
|
|
|
|
|
onchange={handleChange}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "智能定性", label: "智能定性" },
|
|
|
|
|
|
// { value: "GPT-5 mini", label: "GPT-5 mini" },
|
|
|
|
|
|
// { value: "Claude Sonnet 4", label: "Claude Sonnet 4" },
|
|
|
|
|
|
// { value: "Kimi Senior", label: "Kimi Senior" },
|
|
|
|
|
|
]}>
|
|
|
|
|
|
</Select> */}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 上传文件 */}
|
|
|
|
|
|
<Upload className='ds-uploading'>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
shape='circle'
|
|
|
|
|
|
icon={<PaperClipOutlined />}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Upload>
|
|
|
|
|
|
{/* 发送按钮 */}
|
|
|
|
|
|
<div className='ds-input-actions'>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type='primary'
|
|
|
|
|
|
shape='circle'
|
|
|
|
|
|
icon={<ArrowUpOutlined />}
|
|
|
|
|
|
onClick={sendMessage}
|
|
|
|
|
|
disabled={!inputValue.trim() || loading}
|
|
|
|
|
|
>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='ds-input-hint'>
|
|
|
|
|
|
Enter 发送 · Shift+Enter 换行
|
|
|
|
|
|
<span className='sep'>|</span>
|
|
|
|
|
|
<a onClick={clearCurrentChat}>清空当前对话</a>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
className='ds-select'
|
|
|
|
|
|
dropdownClassName="ds-select-dropdown"
|
|
|
|
|
|
defaultValue="模型反馈"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
minWidth: 120, maxWidth: 200, marginLeft: 'auto',marginRight: 8
|
|
|
|
|
|
}}
|
|
|
|
|
|
onchange={handleChange}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "正面反馈", label: "正面反馈" },
|
|
|
|
|
|
{ value: "负面反馈", label: "负面反馈" },
|
|
|
|
|
|
]}>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default ChatConversation
|