Files
CamTalk/frontend/src/App.tsx
cfy666 f5536005f6 feat: 实现观察模式,支持持续场景监控(US-05/US-10)
- 新增 useObservationMode Hook:定时 5s 采帧,similarity < 0.85 触发变化回调
- useVisionSession 新增 dialogue/observation 模式切换
- App Footer 加观察模式切换按钮(激活时蓝色脉冲)
- 视频区左上角显示'👁️ 观察中'绿色角标
- 观察模式下画面变化自动发送 query

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-13 14:46:42 +08:00

154 lines
4.5 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.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// CamTalk — 主应用组件
// ============================================================
import { useState } from "react";
import { useVisionSession } from "./hooks/useVisionSession";
import { VideoPreview } from "./components/VideoPreview";
import { ChatPanel } from "./components/ChatPanel";
import { ConfigPanel } from "./components/ConfigPanel";
import { ToastContainer } from "./components/Toast";
import "./App.css";
function App() {
const [showConfig, setShowConfig] = useState(false);
const {
messages,
currentReply,
isProcessing,
isAudioPlaying,
isSpeaking,
isVADReady,
vadError,
connectionStatus,
videoRef,
stream,
config,
updateConfig,
stats,
mode,
isObserving,
toggleMode,
startSession,
stopSession,
interrupt,
} = useVisionSession();
const isConnected = connectionStatus === "connected";
return (
<div className="app">
<header className="app-header">
<h1>CamTalk</h1>
<div className="header-right">
<span className={`status status--${connectionStatus}`}>
{isConnected
? "已连接"
: connectionStatus === "connecting"
? "连接中..."
: "未连接"}
</span>
{isConnected && (
<button
className="btn-icon"
onClick={() => setShowConfig((v) => !v)}
title="设置"
>
</button>
)}
</div>
</header>
{showConfig && (
<ConfigPanel
config={config}
onUpdate={updateConfig}
onClose={() => setShowConfig(false)}
/>
)}
<main className="app-main">
<div className="video-section">
<VideoPreview ref={videoRef} isStreaming={!!stream} />
{config.detailLevel === "high" && (
<div className="detail-badge">HD</div>
)}
{isObserving && (
<div className="observation-badge">👁 </div>
)}
{isSpeaking && <div className="vad-indicator">🎤 ...</div>}
{isAudioPlaying && config.ttsEnabled && (
<div className="vad-indicator vad-indicator--audio">🔊 ...</div>
)}
{isConnected && !isVADReady && !vadError && (
<div className="vad-indicator vad-indicator--loading">
...
</div>
)}
{vadError && (
<div className="vad-indicator vad-indicator--error">
{vadError}
</div>
)}
{isProcessing && (
<div className="video-overlay">
<div className="video-overlay__spinner" />
</div>
)}
</div>
<div className="chat-section">
{connectionStatus === "disconnected" && messages.length > 0 && (
<div className="system-message system-message--warning">
...
</div>
)}
<ChatPanel
messages={messages}
currentReply={currentReply}
connectionStatus={connectionStatus}
/>
</div>
</main>
{isConnected && (stats.queryCount > 0 || stats.totalTokens > 0) && (
<div className="stats-bar">
{stats.queryCount > 0 && <span>{stats.queryCount} </span>}
{stats.totalTokens > 0 && <span>· {stats.totalTokens} tokens</span>}
</div>
)}
<footer className="app-footer">
{!isConnected ? (
<button className="btn btn--primary" onClick={startSession}>
{connectionStatus === "connecting" ? "连接中..." : "开始对话"}
</button>
) : (
<>
<button
className={`btn ${mode === "observation" ? "btn--active" : "btn--secondary"}`}
onClick={toggleMode}
>
{mode === "observation" ? "👁️ 观察中" : "👁️ 观察模式"}
</button>
<button className="btn btn--secondary" onClick={stopSession}>
</button>
{isProcessing && (
<button className="btn btn--warning" onClick={interrupt}>
</button>
)}
</>
)}
</footer>
<ToastContainer />
</div>
);
}
export default App;