实现了AI助手
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
import MarkdownItCjs from "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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user