2025-06-25 11:33:35 +08:00

395 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}