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.

581 lines
18 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 [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 或 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'
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