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:
2026-06-13 11:43:16 +08:00
parent 9ccd4d6238
commit 734c93a866
8 changed files with 357 additions and 31 deletions

View File

@@ -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>
);
}