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

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'
2 months ago
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('')
1 month ago
const [deepThinkingEnabled, setDeepThinkingEnabled] = useState(false)
const [typingMessage, setTypingMessage] = useState('')
1 month ago
const [isTyping, setIsTyping] = useState(false)
1 month ago
const [streamingContent, setStreamingContent] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [currentStreamingMessageId, setCurrentStreamingMessageId] = useState(null)
1 month ago
// 流式输出状态 - 已移除
const messagesEndRef = useRef(null)
const inputRef = useRef(null)
1 month ago
const typewriterTimeoutRef = useRef(null)
// 获取当前会话
const currentConversation = conversations.find(conv => conv.id === currentConversationId)
1 month ago
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" })
}
1 month ago
// 流式输出相关函数 - 已移除
1 month ago
const handleChange = value => {
console.log(`selected ${value}`);
}
// 订阅对话状态变化
useEffect(() => {
const unsubscribe = conversationStore.subscribe(({ conversations, currentConversationId }) => {
setConversations(conversations)
setCurrentConversationId(currentConversationId)
})
return unsubscribe
}, [])
1 month ago
// 清理定时器
useEffect(() => {
return () => {
if (typewriterTimeoutRef.current) {
clearTimeout(typewriterTimeoutRef.current)
}
}
}, [])
1 month ago
// 打字机效果函数 - 简化版本用于测试
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()
}
1 month ago
// 模拟流式效果
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 {
1 month ago
// 调用本地后端接口
1 month ago
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()) {
1 month ago
// 先添加一个空的助手消息
const assistantMessageId = conversationStore.addMessage('assistant', '')
setCurrentStreamingMessageId(assistantMessageId)
// 开始模拟流式效果
simulateStreamingEffect(finalContent, assistantMessageId)
1 month ago
} else {
console.log('No content, adding fallback message')
conversationStore.addMessage('assistant', '抱歉,我无法生成回复内容。')
1 month ago
}
} 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'
1 month ago
// 如果是当前正在流式输出的消息,显示流式内容
const isCurrentStreaming = isStreaming &&
currentStreamingMessageId === msg.id &&
msg.role === 'assistant'
const displayContent = isCurrentStreaming ? streamingContent : (msg.content || '')
1 month ago
// 调试信息
if (msg.role === 'assistant') {
console.log('Rendering assistant message:', {
index,
content: msg.content,
1 month ago
displayContent,
isCurrentStreaming,
streamingContent
1 month ago
})
}
return (
<div
key={index}
className={`ds-message-row ${isUser ? 'from-user' : 'from-assistant'}`}
>
{!isUser && (
<div className='ds-avatar'>
1 month ago
<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
1 month ago
: msg.timestamp.toLocaleString()) // 时间
: ''}
</span>
{!isUser && (
1 month ago
<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>
1 month ago
{/* 重新生成按钮 */}
<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'>
1 month ago
{displayContent ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
>
{displayContent}
</ReactMarkdown>
) : (
<div style={{ color: '#999', fontStyle: 'italic' }}>
暂无内容
1 month ago
</div>
)}
</div>
</div>
</div>
)
}
1 month ago
// =================================================
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>
)}
1 month ago
{/* 历史消息 */}
{currentMessages.map(renderMessage)}
1 month ago
{/* 加载状态 */}
{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>
1 month ago
{/* 深度思考按钮 */}
<Button
type={deepThinkingEnabled ? 'primary' : 'default'}
style={{
1 month ago
minWidth: 100, maxWidth: 120, marginRight: 8
}}
1 month ago
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"
1 month ago
defaultValue="模型反馈"
style={{
1 month ago
minWidth: 120, maxWidth: 200, marginLeft: 'auto', marginRight: 8
}}
onchange={handleChange}
options={[
{ value: "正面反馈", label: "正面反馈" },
{ value: "负面反馈", label: "负面反馈" },
]}>
</Select>
</div>
</div>
</div>
)
}
export default ChatConversation