|
|
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 [deepThinkingEnabled, setDeepThinkingEnabled] = useState(false)
|
|
|
const [typingMessage, setTypingMessage] = useState('')
|
|
|
const [isTyping, setIsTyping] = useState(false)
|
|
|
|
|
|
|
|
|
const [streamingContent, setStreamingContent] = useState('')
|
|
|
const [isStreaming, setIsStreaming] = useState(false)
|
|
|
const [currentStreamingMessageId, setCurrentStreamingMessageId] = useState(null)
|
|
|
|
|
|
|
|
|
|
|
|
// 流式输出状态 - 已移除
|
|
|
|
|
|
const messagesEndRef = useRef(null)
|
|
|
const inputRef = useRef(null)
|
|
|
const typewriterTimeoutRef = useRef(null)
|
|
|
|
|
|
// 获取当前会话
|
|
|
const currentConversation = conversations.find(conv => conv.id === currentConversationId)
|
|
|
const currentMessages = currentConversation?.messages || [] // 当前会话消息
|
|
|
|
|
|
// 调试信息
|
|
|
console.log('Current conversation state:', {
|
|
|
conversations: conversations.length,
|
|
|
currentConversationId,
|
|
|
currentConversation: !!currentConversation,
|
|
|
currentMessages: currentMessages.length
|
|
|
})
|
|
|
|
|
|
// console.log(currentMessages,"=========================333333")
|
|
|
const chatScrollRef = useRef(null);
|
|
|
|
|
|
const scrollToBottom = () => {
|
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
|
}
|
|
|
|
|
|
// 流式输出相关函数 - 已移除
|
|
|
|
|
|
|
|
|
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)
|
|
|
}
|
|
|
}
|
|
|
}, [])
|
|
|
|
|
|
// 打字机效果函数 - 简化版本用于测试
|
|
|
const typewriterEffect = (text, onComplete) => {
|
|
|
console.log('Typewriter effect started with text:', text)
|
|
|
let index = 0
|
|
|
setTypingMessage('')
|
|
|
setIsTyping(true)
|
|
|
|
|
|
const typeNextWords = () => {
|
|
|
if (index < text.length) {
|
|
|
// 简化:每次显示3个字符
|
|
|
const nextIndex = Math.min(index + 3, text.length)
|
|
|
const currentText = text.substring(0, nextIndex)
|
|
|
console.log('Typing progress:', currentText)
|
|
|
setTypingMessage(currentText)
|
|
|
index = nextIndex
|
|
|
|
|
|
// 500ms间隔
|
|
|
typewriterTimeoutRef.current = setTimeout(typeNextWords, 500)
|
|
|
} else {
|
|
|
console.log('Typewriter effect completed')
|
|
|
setIsTyping(false)
|
|
|
onComplete(text)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
typeNextWords()
|
|
|
}
|
|
|
|
|
|
// 模拟流式效果
|
|
|
const simulateStreamingEffect = (fullContent, messageId) => {
|
|
|
setIsStreaming(true)
|
|
|
setStreamingContent('')
|
|
|
|
|
|
let currentIndex = 0
|
|
|
const chunkSize = 3 // 每次显示3个字符
|
|
|
const delay = 50 // 50ms间隔,可以调整速度
|
|
|
|
|
|
const streamNext = () => {
|
|
|
if (currentIndex < fullContent.length) {
|
|
|
const nextIndex = Math.min(currentIndex + chunkSize, fullContent.length)
|
|
|
const currentContent = fullContent.substring(0, nextIndex)
|
|
|
|
|
|
setStreamingContent(currentContent)
|
|
|
conversationStore.updateMessage(messageId, currentContent)
|
|
|
|
|
|
currentIndex = nextIndex
|
|
|
setTimeout(streamNext, delay)
|
|
|
} else {
|
|
|
// 流式效果完成
|
|
|
setIsStreaming(false)
|
|
|
setStreamingContent('')
|
|
|
setCurrentStreamingMessageId(null)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
streamNext()
|
|
|
}
|
|
|
|
|
|
|
|
|
// 创建新对话
|
|
|
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)
|
|
|
|
|
|
try {
|
|
|
// 调用本地后端接口
|
|
|
const result = await callLocalChatAPI(messageText)
|
|
|
console.log('API result:', result)
|
|
|
|
|
|
// 直接处理结果
|
|
|
const thought = result?.thought || ''
|
|
|
const message = result?.message || ''
|
|
|
console.log('Processed content:', { thought, message, deepThinkingEnabled })
|
|
|
|
|
|
// 根据深度思考按钮状态决定是否包含思考内容
|
|
|
const finalContent = deepThinkingEnabled
|
|
|
? thought + (message ? '\n\n' + message : '')
|
|
|
: message
|
|
|
|
|
|
console.log('Final content:', finalContent)
|
|
|
|
|
|
if (finalContent.trim()) {
|
|
|
// 先添加一个空的助手消息
|
|
|
const assistantMessageId = conversationStore.addMessage('assistant', '')
|
|
|
setCurrentStreamingMessageId(assistantMessageId)
|
|
|
|
|
|
// 开始模拟流式效果
|
|
|
simulateStreamingEffect(finalContent, assistantMessageId)
|
|
|
} else {
|
|
|
console.log('No content, adding fallback message')
|
|
|
conversationStore.addMessage('assistant', '抱歉,我无法生成回复内容。')
|
|
|
}
|
|
|
} catch (error) {
|
|
|
const errorMessage = handleAPIError(error)
|
|
|
message.error(errorMessage)
|
|
|
|
|
|
// 添加错误消息到独立存储
|
|
|
conversationStore.addMessage('assistant', `抱歉,我遇到了一个问题:${errorMessage}`)
|
|
|
} 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'
|
|
|
|
|
|
// 如果是当前正在流式输出的消息,显示流式内容
|
|
|
const isCurrentStreaming = isStreaming &&
|
|
|
currentStreamingMessageId === msg.id &&
|
|
|
msg.role === 'assistant'
|
|
|
|
|
|
const displayContent = isCurrentStreaming ? streamingContent : (msg.content || '')
|
|
|
|
|
|
// 调试信息
|
|
|
if (msg.role === 'assistant') {
|
|
|
console.log('Rendering assistant message:', {
|
|
|
index,
|
|
|
content: msg.content,
|
|
|
displayContent,
|
|
|
isCurrentStreaming,
|
|
|
streamingContent
|
|
|
})
|
|
|
}
|
|
|
|
|
|
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'>
|
|
|
{displayContent ? (
|
|
|
<ReactMarkdown
|
|
|
remarkPlugins={[remarkGfm]}
|
|
|
>
|
|
|
{displayContent}
|
|
|
</ReactMarkdown>
|
|
|
) : (
|
|
|
<div style={{ color: '#999', fontStyle: 'italic' }}>
|
|
|
暂无内容
|
|
|
</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)}
|
|
|
|
|
|
{/* 加载状态 */}
|
|
|
{loading && (
|
|
|
<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>
|
|
|
|
|
|
{/* 深度思考按钮 */}
|
|
|
<Button
|
|
|
type={deepThinkingEnabled ? 'primary' : 'default'}
|
|
|
style={{
|
|
|
minWidth: 100, maxWidth: 120, marginRight: 8
|
|
|
}}
|
|
|
onClick={() => setDeepThinkingEnabled(!deepThinkingEnabled)}
|
|
|
>
|
|
|
深度思考
|
|
|
</Button>
|
|
|
|
|
|
{/* 上传文件 */}
|
|
|
<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
|