feat: add ChatDrawer side panel with streaming SSE

This commit is contained in:
z2_cc 2026-05-13 11:43:39 +08:00
parent 3ac6ec586a
commit 11c79472ce
2 changed files with 309 additions and 0 deletions

View File

@ -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 (
<div className={`${styles.overlay} ${isOpen ? styles.overlayOpen : ''}`}>
<div className={styles.header}>
<span>GitLink AI 助手</span>
<button className={styles.closeBtn} onClick={onClose} aria-label="关闭">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className={styles.messages}>
{messages.map((msg, i) => (
<ChatMessage
key={i}
message={msg}
isStreaming={i === streamingIndex}
/>
))}
<div ref={messagesEndRef} />
</div>
<div className={styles.inputArea}>
<textarea
ref={inputRef}
className={styles.input}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入你的问题..."
rows={1}
disabled={isLoading}
/>
<button
className={styles.sendBtn}
onClick={sendMessage}
disabled={isLoading || !input.trim()}
>
发送
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,125 @@
.overlay {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 400px;
background: #fff;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
z-index: 999;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.3s ease;
}
.overlayOpen {
transform: translateX(0);
}
[data-theme='dark'] .overlay {
background: #1b1b1b;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5);
}
.header {
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
font-weight: 600;
font-size: 16px;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
[data-theme='dark'] .header {
border-bottom-color: #333;
}
.closeBtn {
background: none;
border: none;
color: #999;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
}
.closeBtn:hover {
color: #666;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.inputArea {
padding: 12px 16px;
border-top: 1px solid #e0e0e0;
display: flex;
gap: 8px;
flex-shrink: 0;
}
[data-theme='dark'] .inputArea {
border-top-color: #333;
}
.input {
flex: 1;
border: 1px solid #ddd;
border-radius: 8px;
padding: 8px 12px;
font-size: 14px;
outline: none;
resize: none;
font-family: inherit;
min-height: 40px;
max-height: 120px;
line-height: 1.5;
}
.input:focus {
border-color: #466aff;
}
[data-theme='dark'] .input {
background: #2a2a2a;
border-color: #444;
color: #e0e0e0;
}
[data-theme='dark'] .input:focus {
border-color: #466aff;
}
.sendBtn {
background: #466aff;
color: #fff;
border: none;
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s;
}
.sendBtn:hover {
background: #3558e0;
}
.sendBtn:disabled {
background: #aaa;
cursor: not-allowed;
}
@media (max-width: 768px) {
.overlay {
width: 100%;
}
}