feat: 完善端到端对话闭环,增强消息渲染与错误处理
- ChatPanel:流式光标动画、token/延迟元数据、自动滚动、三种空状态 - Toast 组件:错误码映射为中文友好文案,3 秒自动消失 - useVisionSession:stt_result 流式更新、防重复发送、打断保存未完成内容、结束清理全部状态 - App:断线提示、视频 loading 遮罩、按钮连接中状态 - ChatMessage 增加 latencyMs、model 字段 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,33 +1,90 @@
|
||||
// ============================================================
|
||||
// ChatPanel — 消息展示面板
|
||||
// 职责:渲染对话消息列表(用户提问 + AI 回复)
|
||||
// 职责:渲染对话消息列表、流式光标、元数据、自动滚动
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ChatMessage } from "../../types";
|
||||
import type { ConnectionStatus } from "../../lib/websocket";
|
||||
|
||||
interface ChatPanelProps {
|
||||
messages: ChatMessage[];
|
||||
currentReply?: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
}
|
||||
|
||||
export function ChatPanel({ messages }: ChatPanelProps) {
|
||||
if (messages.length === 0) {
|
||||
export function ChatPanel({ messages, currentReply, connectionStatus }: ChatPanelProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isAutoScroll = useRef(true);
|
||||
|
||||
// 用户上滚时暂停自动滚动,滚到底部时恢复
|
||||
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]);
|
||||
|
||||
// 空状态
|
||||
if (messages.length === 0 && !currentReply) {
|
||||
if (connectionStatus !== "connected") {
|
||||
return (
|
||||
<div className="chat-panel chat-panel--empty">
|
||||
<p>点击下方按钮开始对话</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="chat-panel chat-panel--empty">
|
||||
<p>开始对话:对着摄像头说话即可</p>
|
||||
<p>对着摄像头说话即可</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-panel" ref={containerRef}>
|
||||
{messages.map((msg, index) => (
|
||||
<div key={index} className={`chat-message chat-message--${msg.role}`}>
|
||||
<div className="chat-message__role">
|
||||
{msg.role === "user" ? "你" : "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>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
51
frontend/src/components/Toast/index.tsx
Normal file
51
frontend/src/components/Toast/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
// ============================================================
|
||||
// Toast — 轻量通知组件
|
||||
// 职责:3 秒自动消失的通知提示
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { registerToastSetter, type ToastItem } from "../../lib/toast";
|
||||
|
||||
export type { ToastItem };
|
||||
|
||||
/** Toast 容器组件,放在 App 根部 */
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
// 注册全局 setter
|
||||
useEffect(() => {
|
||||
registerToastSetter(setToasts);
|
||||
return () => registerToastSetter(null);
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="toast-container">
|
||||
{toasts.map((t) => (
|
||||
<ToastItemView key={t.id} item={t} onDismiss={dismiss} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItemView({
|
||||
item,
|
||||
onDismiss,
|
||||
}: {
|
||||
item: ToastItem;
|
||||
onDismiss: (id: number) => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => onDismiss(item.id), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [item.id, onDismiss]);
|
||||
|
||||
return (
|
||||
<div className={`toast toast--${item.type}`} onClick={() => onDismiss(item.id)}>
|
||||
{item.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user