实现了AI助手

This commit is contained in:
2025-06-24 14:09:12 +08:00
parent 7d139b22db
commit 8a4ccd5ef2
31 changed files with 2883 additions and 252 deletions
+394
View File
@@ -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>
)
}