Files
CamTalk/frontend/src/components/ChatPanel/index.tsx
cfy666 51ed6aa563 feat: 添加文字输入对话功能
- 后端 WsQuery 添加 text 字段,支持文本输入模式
- Pipeline 支持文本查询时跳过 STT 直接使用输入文本
- 前端 ChatPanel 添加文字输入框,连接状态下可用
- useVisionSession 添加 sendTextMessage 方法
- 更新接口文档,添加文本输入模式说明
2026-06-14 12:52:39 +08:00

130 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// ChatPanel — 消息展示面板
// 职责:渲染对话消息列表、流式光标、元数据、自动滚动、文本输入
// ============================================================
import { useEffect, useRef, useState } from "react";
import type { ChatMessage } from "../../types";
import type { ConnectionStatus } from "../../lib/websocket";
interface ChatPanelProps {
messages: ChatMessage[];
currentReply?: string;
connectionStatus: ConnectionStatus;
onSendText?: (text: string) => void;
}
export function ChatPanel({ messages, currentReply, connectionStatus, onSendText }: ChatPanelProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isAutoScroll = useRef(true);
const [inputText, setInputText] = useState("");
const isConnected = connectionStatus === "connected";
// 用户上滚时暂停自动滚动,滚到底部时恢复
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("");
};
// 空状态
if (messages.length === 0 && !currentReply) {
if (!isConnected) {
return (
<div className="chat-panel chat-panel--empty">
<span className="chat-panel--empty-icon">💬</span>
<p></p>
<span className="chat-panel--empty-hint"> AI </span>
</div>
);
}
return (
<div className="chat-panel chat-panel--empty">
<span className="chat-panel--empty-icon">🎙</span>
<p></p>
<span className="chat-panel--empty-hint">AI </span>
</div>
);
}
return (
<div className="chat-panel">
<div className="chat-panel__messages" 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>
{/* 文本输入框 */}
{isConnected && onSendText && (
<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>
)}
</div>
);
}