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

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 [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)
1 month ago
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" })
}
1 month ago
// 打字机效果函数
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
}, [])
1 month ago
// 清理定时器
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)
1 month ago
setIsStreaming(true)
setStreamingThought('')
setStreamingMessage('')
setDisplayedThought('')
setDisplayedMessage('')
try {
1 month ago
// 调用本地后端接口(流式版本)
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('')
}
})
1 month ago
// 如果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}`)
1 month ago
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>
)
}
1 month ago
// 渲染流式输出消息
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>
)}
1 month ago
{/* 历史消息 */}
{currentMessages.map(renderMessage)}
1 month ago
{/* 流式输出消息 */}
{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>
{/* 模型切换 */}
2 months ago
{/* <Select
2 months ago
defaultValue="智能定性"
style={{
minWidth: 120, maxWidth: 200, marginRight: 8
}}
2 months ago
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"
2 months ago
defaultValue="模型反馈"
style={{
2 months ago
minWidth: 120, maxWidth: 200, marginLeft: 'auto',marginRight: 8
}}
onchange={handleChange}
options={[
{ value: "正面反馈", label: "正面反馈" },
{ value: "负面反馈", label: "负面反馈" },
]}>
</Select>
</div>
</div>
</div>
)
}
export default ChatConversation