|
|
|
|
|
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 messagesEndRef = useRef(null)
|
|
|
|
|
|
const inputRef = 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 handleChange = value => {
|
|
|
|
|
|
console.log(`selected ${value}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 订阅对话状态变化
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const unsubscribe = conversationStore.subscribe(({ conversations, currentConversationId }) => {
|
|
|
|
|
|
setConversations(conversations)
|
|
|
|
|
|
setCurrentConversationId(currentConversationId)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return unsubscribe
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 准备发送给API的消息历史
|
|
|
|
|
|
const apiMessages = currentMessages.map(msg => ({
|
|
|
|
|
|
role: msg.role,
|
|
|
|
|
|
content: msg.content
|
|
|
|
|
|
})).concat({
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
content: messageText
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 调用本地后端接口(把用户输入作为 prompt 传给后端)
|
|
|
|
|
|
// 注意:callLocalChatAPI 目前只发送 messageText;如果后端需要历史,可改为序列化 apiMessages 并后端解析
|
|
|
|
|
|
const assistantResponse = await callLocalChatAPI(messageText)
|
|
|
|
|
|
|
|
|
|
|
|
// 添加助手回复到独立存储
|
|
|
|
|
|
conversationStore.addMessage('assistant', assistantResponse)
|
|
|
|
|
|
} 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'
|
|
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
// =================================================
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 模型切换 */}
|
|
|
|
|
|
<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
|