395 lines
14 KiB
TypeScript
395 lines
14 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||
|
||
const MarkdownItCjs = require("markdown-it/dist/markdown-it.js");
|
||
import { ComponentCard } from '@/components/ComponentCard';
|
||
import { Component } from '@prisma/client';
|
||
import { nextTick } from 'process';
|
||
const md = MarkdownItCjs()
|
||
|
||
|
||
interface Conversation {
|
||
id: string
|
||
title: string | null
|
||
createdAt: string
|
||
updatedAt: string
|
||
}
|
||
|
||
type Message = {
|
||
role: 'user' | 'assistant'
|
||
content: string
|
||
id?: string // 添加唯一标识符
|
||
} | {
|
||
role: 'card'
|
||
components: Component[]
|
||
id?: string // 添加唯一标识符
|
||
}
|
||
|
||
export default function AIAssistantPage() {
|
||
const [messages, setMessages] = useState<Message[]>([])
|
||
const [input, setInput] = useState('')
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
const [conversations, setConversations] = useState<Conversation[]>([])
|
||
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null)
|
||
const [isLoadingConversations, setIsLoadingConversations] = useState(true)
|
||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||
|
||
const scrollToBottom = () => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||
}
|
||
|
||
useEffect(() => {
|
||
scrollToBottom()
|
||
}, [messages])
|
||
|
||
useEffect(() => {
|
||
loadConversations()
|
||
}, [])
|
||
const loadConversations = async () => {
|
||
try {
|
||
setIsLoadingConversations(true)
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch('/api/ai/conversations', {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
})
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setConversations(data.conversations)
|
||
}
|
||
} catch (error) {
|
||
console.error('加载对话历史失败:', error)
|
||
} finally {
|
||
setIsLoadingConversations(false)
|
||
}
|
||
}
|
||
const loadConversation = async (conversationId: string) => {
|
||
try {
|
||
setIsLoading(true)
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch(`/api/ai/conversations/${conversationId}`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
})
|
||
if (response.ok) {
|
||
const conversation = await response.json()
|
||
setCurrentConversationId(conversationId)
|
||
|
||
setMessages(conversation)
|
||
}
|
||
} catch (error) {
|
||
console.error('加载对话失败:', error)
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
const startNewConversation = () => {
|
||
setMessages([])
|
||
setCurrentConversationId(null)
|
||
}
|
||
const deleteConversation = async (conversationId: string) => {
|
||
if (!confirm('确定要删除这个对话吗?')) return
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch(`/api/ai/conversations/${conversationId}`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
})
|
||
|
||
if (response.ok) {
|
||
setConversations(prev => prev.filter(c => c.id !== conversationId))
|
||
if (currentConversationId === conversationId) {
|
||
startNewConversation()
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('删除对话失败:', error)
|
||
}
|
||
}
|
||
|
||
const sendMessage = async () => {
|
||
if (!input.trim() || isLoading) return
|
||
|
||
const userInput = input.trim() // 保存并清理输入内容
|
||
const userMessage: Message = {
|
||
role: 'user',
|
||
content: userInput,
|
||
id: Date.now().toString() + Math.random().toString(36).substr(2, 9)
|
||
}
|
||
|
||
setMessages(prev => [...prev, userMessage])
|
||
setInput('')
|
||
setIsLoading(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch('/api/ai', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: JSON.stringify({
|
||
prompt: userInput, // 使用保存的输入内容
|
||
conversationId: currentConversationId
|
||
})
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error('AI服务请求失败')
|
||
}
|
||
|
||
const reader = response.body?.getReader()
|
||
const decoder = new TextDecoder()
|
||
|
||
if (!reader) {
|
||
throw new Error('无法读取响应流')
|
||
}
|
||
setMessages(prev => [...prev, {
|
||
role: 'assistant',
|
||
content: '',
|
||
id: Date.now().toString() + Math.random().toString(36).substr(2, 9)
|
||
}])
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
|
||
const chunk = decoder.decode(value)
|
||
const lines = chunk.split('\n')
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('data: ')) {
|
||
const data = line.slice(6)
|
||
if (data === '[DONE]') {
|
||
// 重新加载对话列表以显示新对话
|
||
loadConversations()
|
||
return
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(data)
|
||
if (parsed.conversationId) {
|
||
// 设置新对话ID
|
||
setCurrentConversationId(parsed.conversationId)
|
||
} else if (parsed.content) {
|
||
console.log(parsed.content); if (parsed.content == 'next_block') {
|
||
console.log("next_block");
|
||
|
||
setMessages(prev => [...prev, {
|
||
role: 'assistant',
|
||
content: '',
|
||
id: Date.now().toString() + Math.random().toString(36).substr(2, 9)
|
||
}])
|
||
continue
|
||
} if (parsed.content.startsWith("show_card")) {
|
||
console.log("show_card");
|
||
let comps = JSON.parse(parsed.content.split("show_card:")[1].trim())
|
||
setMessages(prev => [...prev, {
|
||
role: 'card',
|
||
components: comps,
|
||
id: Date.now().toString() + Math.random().toString(36).substr(2, 9)
|
||
}])
|
||
continue
|
||
}
|
||
// 使用函数式更新,确保状态更新的原子性
|
||
setMessages(prev => {
|
||
const newMessages = [...prev]
|
||
const lastMessageIndex = newMessages.length - 1
|
||
const lastMessage = newMessages[lastMessageIndex]
|
||
|
||
if (lastMessage && lastMessage.role === 'assistant') {
|
||
// 创建新的消息对象而不是直接修改
|
||
newMessages[lastMessageIndex] = {
|
||
...lastMessage,
|
||
content: lastMessage.content + parsed.content
|
||
}
|
||
} else {
|
||
console.warn('No assistant message to update')
|
||
}
|
||
return newMessages
|
||
})
|
||
} else if (parsed.error) {
|
||
throw new Error(parsed.error)
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
}
|
||
} catch (error) {
|
||
console.error('发送消息失败:', error)
|
||
setMessages(prev => [
|
||
...prev,
|
||
{
|
||
role: 'assistant',
|
||
content: '抱歉,发生了错误,请稍后再试。',
|
||
}
|
||
])
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault()
|
||
sendMessage()
|
||
}
|
||
}
|
||
|
||
|
||
return (
|
||
<div className=" bg-gray-50">
|
||
<div className="container mx-auto px-4 py-8">
|
||
<div className="flex gap-6 h-[calc(100vh-140px)]">
|
||
{/* 对话历史侧边栏 */}
|
||
<div className=" bg-white rounded-lg shadow-sm border p-4 w-80 flex flex-col">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h2 className="text-lg font-semibold text-gray-800">对话历史</h2>
|
||
<button
|
||
onClick={startNewConversation}
|
||
className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm"
|
||
>
|
||
新对话
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-2 overflow-y-auto">
|
||
{isLoadingConversations ? (
|
||
<div className="text-center py-4 text-gray-500">加载中...</div>
|
||
) : conversations.length === 0 ? (
|
||
<div className="text-center py-4 text-gray-500">暂无对话历史</div>
|
||
) : (
|
||
conversations.map((conversation) => (
|
||
<div
|
||
key={conversation.id}
|
||
className={`p-2 rounded cursor-pointer hover:bg-gray-50 ${currentConversationId === conversation.id ? 'bg-blue-50 border-blue-200' : ''
|
||
}`}
|
||
>
|
||
<div
|
||
onClick={() => loadConversation(conversation.id)}
|
||
className="flex-1"
|
||
>
|
||
<div className="text-sm font-medium text-gray-800 truncate">
|
||
{conversation.title || '新对话'}
|
||
</div>
|
||
<div className="text-xs text-gray-500">
|
||
{new Date(conversation.updatedAt).toLocaleDateString()}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
deleteConversation(conversation.id)
|
||
}}
|
||
className="mt-1 text-xs text-red-500 hover:text-red-700"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 主聊天区域 */}
|
||
<div className=" bg-white grow rounded-lg shadow-sm border flex flex-col">
|
||
{/* 聊天标题 */}
|
||
<div className="p-4 border-b">
|
||
<h1 className="text-xl font-semibold text-gray-800">PC DIY 智能助手</h1>
|
||
<p className="text-gray-600 text-sm">我可以帮助您选择和了解PC配件</p>
|
||
</div>
|
||
|
||
{/* 消息列表 */}
|
||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||
{messages.length === 0 ? (
|
||
<div className="text-center py-8">
|
||
<div className="text-gray-500 mb-4">
|
||
👋 您好!我是PC DIY商店的智能助手
|
||
</div>
|
||
<div className="text-sm text-gray-400">
|
||
您可以询问关于PC配件的任何问题,比如:
|
||
<ul className="mt-2 space-y-1">
|
||
<li>• "推荐一些价格在1000-2000元的显卡"</li>
|
||
<li>• "查找华硕品牌的主板"</li>
|
||
<li>• "什么是最新的CPU配件"</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
) : (messages.map((message, index) => (
|
||
<div
|
||
key={message.id || index}
|
||
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||
>
|
||
{message.role == "card"
|
||
? <div className='max-w-[80%] grid grid-cols-2 gap-2'>
|
||
{message.components.map((component) => (
|
||
<ComponentCard key={component.id} component={component} />
|
||
))
|
||
}
|
||
</div>
|
||
: <div
|
||
className={`max-w-[70%] p-3 rounded-lg ${message.role === 'user'
|
||
? 'bg-blue-600 text-white'
|
||
: 'bg-gray-100 text-gray-800'}`}>
|
||
<div className="markdown-content"
|
||
dangerouslySetInnerHTML={{ __html: md.render(message.content) }}
|
||
/>
|
||
<div className={`text-xs mt-1 ${message.role === 'user' ? 'text-blue-200' : 'text-gray-500'}`}>
|
||
</div>
|
||
</div>}
|
||
</div>
|
||
))
|
||
)}
|
||
{isLoading && (
|
||
<div className="flex justify-start">
|
||
<div className="bg-gray-100 text-gray-800 p-3 rounded-lg">
|
||
<div className="flex items-center space-x-2">
|
||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||
<span>AI正在思考...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
|
||
{/* 输入区域 */}
|
||
<div className="p-4 border-t">
|
||
<div className="flex space-x-2">
|
||
<textarea
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyPress={handleKeyPress}
|
||
placeholder="输入您的问题..."
|
||
className="flex-1 p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||
rows={2}
|
||
disabled={isLoading}
|
||
/>
|
||
<button
|
||
onClick={sendMessage}
|
||
disabled={!input.trim() || isLoading}
|
||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
发送
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|