2026-06-12 17:46:25 +08:00
|
|
|
// ============================================================
|
|
|
|
|
// ChatPanel — 消息展示面板
|
2026-06-14 12:52:39 +08:00
|
|
|
// 职责:渲染对话消息列表、流式光标、元数据、自动滚动、文本输入
|
2026-06-12 17:46:25 +08:00
|
|
|
// ============================================================
|
|
|
|
|
|
2026-06-14 12:52:39 +08:00
|
|
|
import { useEffect, useRef, useState } from "react";
|
2026-06-12 17:46:25 +08:00
|
|
|
import type { ChatMessage } from "../../types";
|
2026-06-13 11:43:16 +08:00
|
|
|
import type { ConnectionStatus } from "../../lib/websocket";
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
|
|
|
interface ChatPanelProps {
|
|
|
|
|
messages: ChatMessage[];
|
2026-06-13 11:43:16 +08:00
|
|
|
currentReply?: string;
|
|
|
|
|
connectionStatus: ConnectionStatus;
|
2026-06-14 12:52:39 +08:00
|
|
|
onSendText?: (text: string) => void;
|
2026-06-12 17:46:25 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-14 12:52:39 +08:00
|
|
|
export function ChatPanel({ messages, currentReply, connectionStatus, onSendText }: ChatPanelProps) {
|
2026-06-13 11:43:16 +08:00
|
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const isAutoScroll = useRef(true);
|
2026-06-14 12:52:39 +08:00
|
|
|
const [inputText, setInputText] = useState("");
|
|
|
|
|
|
|
|
|
|
const isConnected = connectionStatus === "connected";
|
2026-06-13 11:43:16 +08:00
|
|
|
|
|
|
|
|
// 用户上滚时暂停自动滚动,滚到底部时恢复
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const container = containerRef.current;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
const handleScroll = () => {
|
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
|
|
|
isAutoScroll.current = scrollHeight - scrollTop - clientHeight < 60;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
container.addEventListener("scroll", handleScroll);
|
|
|
|
|
return () => container.removeEventListener("scroll", handleScroll);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 新消息或流式更新时自动滚动
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isAutoScroll.current) {
|
|
|
|
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
|
|
|
}
|
|
|
|
|
}, [messages, currentReply]);
|
|
|
|
|
|
2026-06-14 12:52:39 +08:00
|
|
|
// 提交文本消息
|
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!inputText.trim() || !onSendText) return;
|
|
|
|
|
onSendText(inputText);
|
|
|
|
|
setInputText("");
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-14 13:17:14 +08:00
|
|
|
// 未连接时的空状态
|
|
|
|
|
if (!isConnected) {
|
2026-06-12 17:46:25 +08:00
|
|
|
return (
|
|
|
|
|
<div className="chat-panel chat-panel--empty">
|
2026-06-14 13:13:29 +08:00
|
|
|
<span className="chat-panel--empty-icon">💬</span>
|
2026-06-14 13:17:14 +08:00
|
|
|
<p>点击下方按钮开始对话</p>
|
|
|
|
|
<span className="chat-panel--empty-hint">连接后可打字或语音与 AI 交互</span>
|
2026-06-12 17:46:25 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-14 12:52:39 +08:00
|
|
|
<div className="chat-panel">
|
|
|
|
|
<div className="chat-panel__messages" ref={containerRef}>
|
2026-06-14 13:17:14 +08:00
|
|
|
{/* 空状态提示 */}
|
|
|
|
|
{messages.length === 0 && !currentReply && (
|
|
|
|
|
<div className="chat-panel__welcome">
|
|
|
|
|
<span className="chat-panel__welcome-icon">💬</span>
|
|
|
|
|
<p>在下方输入文字开始对话</p>
|
|
|
|
|
<span className="chat-panel__welcome-hint">也可以开启麦克风用语音对话</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-14 12:52:39 +08:00
|
|
|
{messages.map((msg, index) => (
|
|
|
|
|
<div key={index} className={`chat-message chat-message--${msg.role}`}>
|
|
|
|
|
<div className="chat-message__role">
|
|
|
|
|
{msg.role === "user" ? "你" : "AI"}
|
2026-06-13 11:43:16 +08:00
|
|
|
</div>
|
2026-06-14 12:52:39 +08:00
|
|
|
<div className="chat-message__content">{msg.content}</div>
|
|
|
|
|
{msg.role === "assistant" && msg.tokensUsed !== undefined && (
|
|
|
|
|
<div className="chat-message__meta">
|
|
|
|
|
{msg.tokensUsed} tokens
|
|
|
|
|
{msg.latencyMs !== undefined && ` · ${(msg.latencyMs / 1000).toFixed(1)}s`}
|
|
|
|
|
{msg.model && ` · ${msg.model}`}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
2026-06-13 11:43:16 +08:00
|
|
|
|
2026-06-14 12:52:39 +08:00
|
|
|
{/* 流式回复(尚未完成) */}
|
|
|
|
|
{currentReply && (
|
|
|
|
|
<div className="chat-message chat-message--assistant chat-message--streaming">
|
|
|
|
|
<div className="chat-message__role">AI</div>
|
|
|
|
|
<div className="chat-message__content">
|
|
|
|
|
{currentReply}
|
|
|
|
|
<span className="cursor">▌</span>
|
|
|
|
|
</div>
|
2026-06-13 11:43:16 +08:00
|
|
|
</div>
|
2026-06-14 12:52:39 +08:00
|
|
|
)}
|
2026-06-13 11:43:16 +08:00
|
|
|
|
2026-06-14 12:52:39 +08:00
|
|
|
<div ref={bottomRef} />
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-14 13:17:14 +08:00
|
|
|
{/* 文本输入框 - 连接后始终显示 */}
|
|
|
|
|
{onSendText && (
|
2026-06-14 12:52:39 +08:00
|
|
|
<form className="chat-input" onSubmit={handleSubmit}>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
className="chat-input__field"
|
|
|
|
|
placeholder="输入文字对话..."
|
|
|
|
|
value={inputText}
|
|
|
|
|
onChange={(e) => setInputText(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
className="chat-input__send"
|
|
|
|
|
disabled={!inputText.trim()}
|
|
|
|
|
title="发送"
|
|
|
|
|
>
|
|
|
|
|
➤
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
2026-06-12 17:46:25 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|