diff --git a/src/components/ChatDrawer/index.jsx b/src/components/ChatDrawer/index.jsx new file mode 100644 index 0000000..21724d8 --- /dev/null +++ b/src/components/ChatDrawer/index.jsx @@ -0,0 +1,184 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import ChatMessage from '../ChatMessage'; +import styles from './styles.module.css'; + +const WELCOME_MESSAGE = { + role: 'assistant', + content: '你好!我是 GitLink 帮助中心 AI 助手。\n\n你可以问我关于 GitLink 平台使用的问题,比如如何创建仓库、管理合并请求、配置 CI/CD 等。', + sources: [], +}; + +export default function ChatDrawer({ isOpen, onClose }) { + const [messages, setMessages] = useState([WELCOME_MESSAGE]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [streamingIndex, setStreamingIndex] = useState(-1); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const abortRef = useRef(null); + + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, []); + + useEffect(() => { + scrollToBottom(); + }, [messages, scrollToBottom]); + + useEffect(() => { + if (isOpen) { + document.body.classList.add('chat-drawer-open'); + setTimeout(() => inputRef.current?.focus(), 300); + } else { + document.body.classList.remove('chat-drawer-open'); + } + return () => document.body.classList.remove('chat-drawer-open'); + }, [isOpen]); + + useEffect(() => { + return () => { + if (abortRef.current) abortRef.current.abort(); + }; + }, []); + + async function sendMessage() { + const text = input.trim(); + if (!text || isLoading) return; + + const userMessage = { role: 'user', content: text }; + const newMessages = [...messages, userMessage]; + setMessages(newMessages); + setInput(''); + setIsLoading(true); + setStreamingIndex(newMessages.length); + + const history = newMessages.slice(1, -1).map(m => ({ + role: m.role, + content: m.content, + })); + + const assistantMessage = { role: 'assistant', content: '', sources: [] }; + setMessages(prev => [...prev, assistantMessage]); + + try { + const res = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text, history }), + signal: abortRef.current?.signal, + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith('data: ')) continue; + try { + const data = JSON.parse(trimmed.slice(6)); + + if (data.type === 'content') { + assistantMessage.content += data.text; + setMessages(prev => { + const updated = [...prev]; + updated[updated.length - 1] = { ...assistantMessage }; + return updated; + }); + } else if (data.type === 'sources') { + assistantMessage.sources = data.links; + setMessages(prev => { + const updated = [...prev]; + updated[updated.length - 1] = { ...assistantMessage }; + return updated; + }); + } else if (data.type === 'error') { + assistantMessage.content += `\n\n⚠️ ${data.message}`; + setMessages(prev => { + const updated = [...prev]; + updated[updated.length - 1] = { ...assistantMessage }; + return updated; + }); + } + } catch { + // skip malformed lines + } + } + } + } catch (err) { + if (err.name !== 'AbortError') { + assistantMessage.content = '抱歉,发生了错误,请稍后重试。'; + setMessages(prev => { + const updated = [...prev]; + updated[updated.length - 1] = { ...assistantMessage }; + return updated; + }); + } + } finally { + setIsLoading(false); + setStreamingIndex(-1); + } + } + + function handleKeyDown(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + } + + return ( +
+
+ GitLink AI 助手 + +
+ +
+ {messages.map((msg, i) => ( + + ))} +
+
+ +
+