// ============================================================ // 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(null); const containerRef = useRef(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 (
{/* 空状态:情景选择 + 场景卡片 */} {isEmpty && (
{scenarios.find(s => s.id === activeScenario)?.icon || "💬"}

{isConnected ? t("chat.welcome.prompt") : t("chat.empty.prompt")}

{isConnected ? t("chat.welcome.hint") : t("chat.empty.hint")} {/* 情景选择卡片(非 free_chat 时隐藏,因为已通过 header 切换) */} {isFreeChat && onSelectScenario && (
{t("scenario.choose")}
{scenarios.filter(s => s.id !== "free_chat").map((sc) => ( ))}
)} {/* 视觉分析快捷卡片(仅 free_chat 模式) */} {isFreeChat && isConnected && (
{sceneCards.map((card) => ( ))}
)}
)} {messages.map((msg, index) => ( msg.role === "system" ? (
{msg.content}
) : (
{msg.role === "user" ? t("chat.userLabel") : "AI"}
{msg.content}
{msg.role === "assistant" && msg.tokensUsed !== undefined && (
{msg.tokensUsed} tokens {msg.latencyMs !== undefined && ` · ${(msg.latencyMs / 1000).toFixed(1)}s`} {msg.model && ` · ${msg.model}`}
)}
) ))} {/* 流式回复(尚未完成) */} {currentReply && (
AI
{currentReply}
)} {/* 连接中状态 */} {connectionStatus === "connecting" && (
{t("chat.connecting")}
)} {/* VAD 初始化中 */} {isConnected && isVADReady === false && !vadError && (
{t("chat.vadInit")}
)} {/* AI 处理中(尚未开始流式输出) */} {isProcessing && !currentReply && (
AI
)}
{/* 文本输入框 - 始终显示 */} {onSendText && (
setInputText(e.target.value)} disabled={connectionStatus === "connecting"} /> {/* 语音输入按钮 */} {onToggleMic && ( )}
)}
); }