264 lines
9.5 KiB
TypeScript
264 lines
9.5 KiB
TypeScript
// ============================================================
|
|
// ChatPanel — 消息展示面板
|
|
// 职责:渲染对话消息列表、流式光标、元数据、自动滚动、文本输入
|
|
// 增强:空状态情景选择卡片、语音输入按钮
|
|
// ============================================================
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useI18n } from "../../lib/i18n";
|
|
import { scenarios } from "../../lib/scenarios";
|
|
import type { ChatMessage } from "../../types";
|
|
import type { ConnectionStatus } from "../../lib/websocket";
|
|
|
|
interface ChatPanelProps {
|
|
messages: ChatMessage[];
|
|
currentReply?: string;
|
|
connectionStatus: ConnectionStatus;
|
|
currentScenario?: string;
|
|
isProcessing?: boolean;
|
|
isVADReady?: boolean;
|
|
vadError?: string | null;
|
|
isMicOn?: boolean;
|
|
isSpeaking?: boolean;
|
|
onSendText?: (text: string) => void;
|
|
onToggleMic?: () => void;
|
|
onSceneCard?: (prompt: string) => void;
|
|
onSelectScenario?: (scenarioId: string) => void;
|
|
}
|
|
|
|
/** 场景卡片数据(视觉分析快捷) */
|
|
function getSceneCards(t: (key: string) => string) {
|
|
return [
|
|
{ icon: "👁", titleKey: "scene.describe", descKey: "scene.describe.desc", prompt: t("scene.describe") },
|
|
{ icon: "🔤", titleKey: "scene.text", descKey: "scene.text.desc", prompt: t("scene.text") },
|
|
{ icon: "📦", titleKey: "scene.object", descKey: "scene.object.desc", prompt: t("scene.object") },
|
|
{ icon: "💡", titleKey: "scene.suggest", descKey: "scene.suggest.desc", prompt: t("scene.suggest") },
|
|
];
|
|
}
|
|
|
|
export function ChatPanel({
|
|
messages,
|
|
currentReply,
|
|
connectionStatus,
|
|
currentScenario,
|
|
isProcessing,
|
|
isVADReady,
|
|
vadError,
|
|
isMicOn,
|
|
isSpeaking,
|
|
onSendText,
|
|
onToggleMic,
|
|
onSceneCard,
|
|
onSelectScenario,
|
|
}: ChatPanelProps) {
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const isAutoScroll = useRef(true);
|
|
const [inputText, setInputText] = useState("");
|
|
const { t } = useI18n();
|
|
|
|
const isConnected = connectionStatus === "connected";
|
|
const isEmpty = messages.length === 0 && !currentReply;
|
|
const sceneCards = getSceneCards(t);
|
|
const activeScenario = currentScenario || "free_chat";
|
|
const isFreeChat = activeScenario === "free_chat";
|
|
|
|
// 用户上滚时暂停自动滚动,滚到底部时恢复
|
|
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]);
|
|
|
|
// 提交文本消息
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!inputText.trim() || !onSendText) return;
|
|
onSendText(inputText);
|
|
setInputText("");
|
|
};
|
|
|
|
// 点击场景卡片
|
|
const handleSceneCard = (prompt: string) => {
|
|
if (onSceneCard) {
|
|
onSceneCard(prompt);
|
|
} else if (onSendText) {
|
|
onSendText(prompt);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="chat-panel">
|
|
<div className="chat-panel__messages" ref={containerRef}>
|
|
{/* 空状态:情景选择 + 场景卡片 */}
|
|
{isEmpty && (
|
|
<div className="chat-panel__welcome">
|
|
<span className="chat-panel__welcome-icon">{scenarios.find(s => s.id === activeScenario)?.icon || "💬"}</span>
|
|
<p>{isConnected ? t("chat.welcome.prompt") : t("chat.empty.prompt")}</p>
|
|
<span className="chat-panel__welcome-hint">{isConnected ? t("chat.welcome.hint") : t("chat.empty.hint")}</span>
|
|
|
|
{/* 情景选择卡片(非 free_chat 时隐藏,因为已通过 header 切换) */}
|
|
{isFreeChat && onSelectScenario && (
|
|
<div className="scenario-cards">
|
|
<div className="scenario-cards__title">{t("scenario.choose")}</div>
|
|
{scenarios.filter(s => s.id !== "free_chat").map((sc) => (
|
|
<button
|
|
key={sc.id}
|
|
className="scenario-card"
|
|
onClick={() => onSelectScenario(sc.id)}
|
|
>
|
|
<span className="scenario-card__icon">{sc.icon}</span>
|
|
<div className="scenario-card__text">
|
|
<span className="scenario-card__title">{t(sc.nameKey)}</span>
|
|
<span className="scenario-card__desc">{t(sc.descKey)}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 视觉分析快捷卡片(仅 free_chat 模式) */}
|
|
{isFreeChat && isConnected && (
|
|
<div className="scene-cards">
|
|
{sceneCards.map((card) => (
|
|
<button
|
|
key={card.titleKey}
|
|
className="scene-card"
|
|
onClick={() => handleSceneCard(card.prompt)}
|
|
>
|
|
<span className="scene-card__icon">{card.icon}</span>
|
|
<div className="scene-card__text">
|
|
<span className="scene-card__title">{t(card.titleKey)}</span>
|
|
<span className="scene-card__desc">{t(card.descKey)}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((msg, index) => (
|
|
msg.role === "system" ? (
|
|
<div key={index} className="chat-message chat-message--system">
|
|
<span className="chat-message--system__text">{msg.content}</span>
|
|
</div>
|
|
) : (
|
|
<div key={index} className={`chat-message chat-message--${msg.role}`}>
|
|
<div className="chat-message__role">
|
|
{msg.role === "user" ? t("chat.userLabel") : "AI"}
|
|
</div>
|
|
<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>
|
|
)
|
|
))}
|
|
|
|
{/* 流式回复(尚未完成) */}
|
|
{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>
|
|
</div>
|
|
)}
|
|
|
|
{/* 连接中状态 */}
|
|
{connectionStatus === "connecting" && (
|
|
<div className="system-message system-message--info">
|
|
<span className="typing-indicator">
|
|
<span className="typing-indicator__dot" />
|
|
<span className="typing-indicator__dot" />
|
|
<span className="typing-indicator__dot" />
|
|
</span>
|
|
<span>{t("chat.connecting")}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* VAD 初始化中 */}
|
|
{isConnected && isVADReady === false && !vadError && (
|
|
<div className="system-message system-message--info">
|
|
<span className="typing-indicator">
|
|
<span className="typing-indicator__dot" />
|
|
<span className="typing-indicator__dot" />
|
|
<span className="typing-indicator__dot" />
|
|
</span>
|
|
<span>{t("chat.vadInit")}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* AI 处理中(尚未开始流式输出) */}
|
|
{isProcessing && !currentReply && (
|
|
<div className="chat-message chat-message--assistant">
|
|
<div className="chat-message__role">AI</div>
|
|
<div className="chat-message__content">
|
|
<span className="typing-indicator">
|
|
<span className="typing-indicator__dot" />
|
|
<span className="typing-indicator__dot" />
|
|
<span className="typing-indicator__dot" />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
|
|
{/* 文本输入框 - 始终显示 */}
|
|
{onSendText && (
|
|
<form className="chat-input" onSubmit={handleSubmit}>
|
|
<input
|
|
type="text"
|
|
className="chat-input__field"
|
|
placeholder={t("chat.input.placeholder")}
|
|
value={inputText}
|
|
onChange={(e) => setInputText(e.target.value)}
|
|
disabled={connectionStatus === "connecting"}
|
|
/>
|
|
{/* 语音输入按钮 */}
|
|
{onToggleMic && (
|
|
<button
|
|
type="button"
|
|
className={`chat-input__voice ${isMicOn ? "chat-input__voice--active" : ""} ${isSpeaking ? "btn--speaking" : ""}`}
|
|
onClick={onToggleMic}
|
|
title={t("chat.input.voice")}
|
|
>
|
|
🎤
|
|
</button>
|
|
)}
|
|
<button
|
|
type="submit"
|
|
className="chat-input__send"
|
|
disabled={!inputText.trim() || connectionStatus === "connecting"}
|
|
title={t("chat.send")}
|
|
>
|
|
➤
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|