You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

534 lines
16 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 或 localhostwindow.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