feat: add ChatDrawer side panel with streaming SSE
This commit is contained in:
parent
3ac6ec586a
commit
11c79472ce
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue