Merge pull request '添加计时器,麦克风,摄像头开关' (#45) from develop-frontend8 into develop 33分钟前 #46
@@ -53,6 +53,26 @@ body {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
@@ -364,3 +384,67 @@ body {
|
|||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Config Panel ---- */
|
||||||
|
|
||||||
|
.config-panel {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel__close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel__close:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-row select,
|
||||||
|
.config-row input[type="checkbox"] {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Detail Badge ---- */
|
||||||
|
|
||||||
|
.detail-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(37, 99, 235, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,17 @@
|
|||||||
// CamTalk — 主应用组件
|
// CamTalk — 主应用组件
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useVisionSession } from "./hooks/useVisionSession";
|
import { useVisionSession } from "./hooks/useVisionSession";
|
||||||
import { VideoPreview } from "./components/VideoPreview";
|
import { VideoPreview } from "./components/VideoPreview";
|
||||||
import { ChatPanel } from "./components/ChatPanel";
|
import { ChatPanel } from "./components/ChatPanel";
|
||||||
|
import { ConfigPanel } from "./components/ConfigPanel";
|
||||||
import { ToastContainer } from "./components/Toast";
|
import { ToastContainer } from "./components/Toast";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
currentReply,
|
currentReply,
|
||||||
@@ -20,6 +24,8 @@ function App() {
|
|||||||
connectionStatus,
|
connectionStatus,
|
||||||
videoRef,
|
videoRef,
|
||||||
stream,
|
stream,
|
||||||
|
config,
|
||||||
|
updateConfig,
|
||||||
startSession,
|
startSession,
|
||||||
stopSession,
|
stopSession,
|
||||||
interrupt,
|
interrupt,
|
||||||
@@ -31,20 +37,44 @@ function App() {
|
|||||||
<div className="app">
|
<div className="app">
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<h1>CamTalk</h1>
|
<h1>CamTalk</h1>
|
||||||
<span className={`status status--${connectionStatus}`}>
|
<div className="header-right">
|
||||||
{isConnected
|
<span className={`status status--${connectionStatus}`}>
|
||||||
? "已连接"
|
{isConnected
|
||||||
: connectionStatus === "connecting"
|
? "已连接"
|
||||||
? "连接中..."
|
: connectionStatus === "connecting"
|
||||||
: "未连接"}
|
? "连接中..."
|
||||||
</span>
|
: "未连接"}
|
||||||
|
</span>
|
||||||
|
{isConnected && (
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={() => setShowConfig((v) => !v)}
|
||||||
|
title="设置"
|
||||||
|
>
|
||||||
|
⚙️
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{showConfig && (
|
||||||
|
<ConfigPanel
|
||||||
|
config={config}
|
||||||
|
onUpdate={updateConfig}
|
||||||
|
onClose={() => setShowConfig(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<div className="video-section">
|
<div className="video-section">
|
||||||
<VideoPreview ref={videoRef} isStreaming={!!stream} />
|
<VideoPreview ref={videoRef} isStreaming={!!stream} />
|
||||||
|
{config.detailLevel === "high" && (
|
||||||
|
<div className="detail-badge">HD</div>
|
||||||
|
)}
|
||||||
{isSpeaking && <div className="vad-indicator">🎤 正在聆听...</div>}
|
{isSpeaking && <div className="vad-indicator">🎤 正在聆听...</div>}
|
||||||
{isAudioPlaying && <div className="vad-indicator vad-indicator--audio">🔊 正在播放...</div>}
|
{isAudioPlaying && config.ttsEnabled && (
|
||||||
|
<div className="vad-indicator vad-indicator--audio">🔊 正在播放...</div>
|
||||||
|
)}
|
||||||
{isConnected && !isVADReady && !vadError && (
|
{isConnected && !isVADReady && !vadError && (
|
||||||
<div className="vad-indicator vad-indicator--loading">
|
<div className="vad-indicator vad-indicator--loading">
|
||||||
正在初始化语音检测...
|
正在初始化语音检测...
|
||||||
|
|||||||
55
frontend/src/components/ConfigPanel/index.tsx
Normal file
55
frontend/src/components/ConfigPanel/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ConfigPanel — 会话配置面板
|
||||||
|
// 职责:TTS 开关、detail level 切换、语言选择
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { SessionConfig } from "../../types";
|
||||||
|
|
||||||
|
interface ConfigPanelProps {
|
||||||
|
config: SessionConfig;
|
||||||
|
onUpdate: (partial: Partial<SessionConfig>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigPanel({ config, onUpdate, onClose }: ConfigPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="config-panel">
|
||||||
|
<div className="config-panel__header">
|
||||||
|
<span>设置</span>
|
||||||
|
<button className="config-panel__close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="config-row">
|
||||||
|
<span>语音回答</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.ttsEnabled}
|
||||||
|
onChange={(e) => onUpdate({ ttsEnabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="config-row">
|
||||||
|
<span>图像精度</span>
|
||||||
|
<select
|
||||||
|
value={config.detailLevel}
|
||||||
|
onChange={(e) => onUpdate({ detailLevel: e.target.value as "low" | "high" })}
|
||||||
|
>
|
||||||
|
<option value="low">低(省流量)</option>
|
||||||
|
<option value="high">高(细节丰富)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="config-row">
|
||||||
|
<span>语言</span>
|
||||||
|
<select
|
||||||
|
value={config.language}
|
||||||
|
onChange={(e) => onUpdate({ language: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="zh-CN">中文</option>
|
||||||
|
<option value="en-US">English</option>
|
||||||
|
<option value="ja-JP">日本語</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,17 +11,24 @@ import { encodeAudioToBase64, dataUrlToBase64 } from "../lib/audio";
|
|||||||
import { getErrorMessage } from "../lib/errors";
|
import { getErrorMessage } from "../lib/errors";
|
||||||
import { TTSPlayer } from "../lib/ttsPlayer";
|
import { TTSPlayer } from "../lib/ttsPlayer";
|
||||||
import { showToast } from "../lib/toast";
|
import { showToast } from "../lib/toast";
|
||||||
|
import { loadConfig, saveConfig } from "../lib/storage";
|
||||||
import { useCamera } from "../components/CameraManager";
|
import { useCamera } from "../components/CameraManager";
|
||||||
import { useMicrophone } from "../components/MicManager";
|
import { useMicrophone } from "../components/MicManager";
|
||||||
import { useVAD } from "../components/EdgeProcessor";
|
import { useVAD } from "../components/EdgeProcessor";
|
||||||
import { useWebSocketManager } from "../components/WebSocketManager";
|
import { useWebSocketManager } from "../components/WebSocketManager";
|
||||||
import type { ChatMessage, ServerMessage, LLMDoneMessage } from "../types";
|
import type { ChatMessage, SessionConfig, ServerMessage, LLMDoneMessage } from "../types";
|
||||||
|
|
||||||
|
const MAX_HISTORY_ROUNDS = 10;
|
||||||
|
|
||||||
export function useVisionSession() {
|
export function useVisionSession() {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [currentReply, setCurrentReply] = useState<string>("");
|
const [currentReply, setCurrentReply] = useState<string>("");
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isAudioPlaying, setIsAudioPlaying] = useState(false);
|
const [isAudioPlaying, setIsAudioPlaying] = useState(false);
|
||||||
|
const [config, setConfig] = useState<SessionConfig>(loadConfig);
|
||||||
|
|
||||||
|
// 对话历史(role + content),用于多轮上下文
|
||||||
|
const historyRef = useRef<Array<{ role: string; content: string }>>([]);
|
||||||
|
|
||||||
// TTS 播放器
|
// TTS 播放器
|
||||||
const ttsPlayerRef = useRef<TTSPlayer | null>(null);
|
const ttsPlayerRef = useRef<TTSPlayer | null>(null);
|
||||||
@@ -44,6 +51,40 @@ export function useVisionSession() {
|
|||||||
isProcessingRef.current = isProcessing;
|
isProcessingRef.current = isProcessing;
|
||||||
}, [isProcessing]);
|
}, [isProcessing]);
|
||||||
|
|
||||||
|
// WebSocket 连接成功后发送 config
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "connected") {
|
||||||
|
send({
|
||||||
|
type: "config",
|
||||||
|
payload: {
|
||||||
|
tts_enabled: config.ttsEnabled,
|
||||||
|
detail_level: config.detailLevel,
|
||||||
|
language: config.language,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [status]); // eslint-disable-line react-hooks/exhaustive-deps -- 仅在连接状态变化时发送
|
||||||
|
|
||||||
|
/** 更新会话配置 */
|
||||||
|
const updateConfig = useCallback((partial: Partial<SessionConfig>) => {
|
||||||
|
setConfig((prev) => {
|
||||||
|
const next = { ...prev, ...partial };
|
||||||
|
saveConfig(next);
|
||||||
|
// 如果已连接,立即发送更新
|
||||||
|
if (status === "connected") {
|
||||||
|
send({
|
||||||
|
type: "config",
|
||||||
|
payload: {
|
||||||
|
tts_enabled: next.ttsEnabled,
|
||||||
|
detail_level: next.detailLevel,
|
||||||
|
language: next.language,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [status, send]);
|
||||||
|
|
||||||
// VAD:语音结束时自动发送 query
|
// VAD:语音结束时自动发送 query
|
||||||
const {
|
const {
|
||||||
isSpeaking,
|
isSpeaking,
|
||||||
@@ -111,6 +152,13 @@ export function useVisionSession() {
|
|||||||
|
|
||||||
case "llm_done": {
|
case "llm_done": {
|
||||||
const done = msg as LLMDoneMessage;
|
const done = msg as LLMDoneMessage;
|
||||||
|
// 记录到对话历史
|
||||||
|
historyRef.current.push({ role: "assistant", content: done.full_text });
|
||||||
|
// 裁剪历史到最近 N 轮
|
||||||
|
if (historyRef.current.length > MAX_HISTORY_ROUNDS * 2) {
|
||||||
|
historyRef.current = historyRef.current.slice(-MAX_HISTORY_ROUNDS * 2);
|
||||||
|
}
|
||||||
|
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -145,14 +193,6 @@ export function useVisionSession() {
|
|||||||
return unsub;
|
return unsub;
|
||||||
}, [getTTSPlayer]);
|
}, [getTTSPlayer]);
|
||||||
|
|
||||||
// 连接断开时显示提示
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === "disconnected") {
|
|
||||||
// 只在非主动断开时提示(通过检查是否有活跃会话判断)
|
|
||||||
// 这里简单处理,由 App 层根据状态显示
|
|
||||||
}
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
/** 启动会话 */
|
/** 启动会话 */
|
||||||
const startSession = useCallback(async () => {
|
const startSession = useCallback(async () => {
|
||||||
// 1. 获取摄像头和麦克风
|
// 1. 获取摄像头和麦克风
|
||||||
@@ -182,6 +222,7 @@ export function useVisionSession() {
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
setCurrentReply("");
|
setCurrentReply("");
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
historyRef.current = [];
|
||||||
}, [stopVAD, stopMic, stopCamera, disconnect]);
|
}, [stopVAD, stopMic, stopCamera, disconnect]);
|
||||||
|
|
||||||
/** 打断当前回复 */
|
/** 打断当前回复 */
|
||||||
@@ -192,9 +233,11 @@ export function useVisionSession() {
|
|||||||
setIsAudioPlaying(false);
|
setIsAudioPlaying(false);
|
||||||
// 将未完成的流式内容保存为最终消息
|
// 将未完成的流式内容保存为最终消息
|
||||||
if (currentReply) {
|
if (currentReply) {
|
||||||
|
const interrupted = currentReply + "(已打断)";
|
||||||
|
historyRef.current.push({ role: "assistant", content: interrupted });
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ role: "assistant", content: currentReply + "(已打断)", timestamp: Date.now() },
|
{ role: "assistant", content: interrupted, timestamp: Date.now() },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
setCurrentReply("");
|
setCurrentReply("");
|
||||||
@@ -212,6 +255,8 @@ export function useVisionSession() {
|
|||||||
connectionStatus: status,
|
connectionStatus: status,
|
||||||
videoRef,
|
videoRef,
|
||||||
stream,
|
stream,
|
||||||
|
config,
|
||||||
|
updateConfig,
|
||||||
startSession,
|
startSession,
|
||||||
stopSession,
|
stopSession,
|
||||||
interrupt,
|
interrupt,
|
||||||
|
|||||||
35
frontend/src/lib/storage.ts
Normal file
35
frontend/src/lib/storage.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Storage — localStorage 封装
|
||||||
|
// 职责:会话配置持久化
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { SessionConfig } from "../types";
|
||||||
|
|
||||||
|
const CONFIG_KEY = "camtalk:config";
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: SessionConfig = {
|
||||||
|
ttsEnabled: true,
|
||||||
|
detailLevel: "low",
|
||||||
|
language: "zh-CN",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 加载配置,无存储时返回默认值 */
|
||||||
|
export function loadConfig(): SessionConfig {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CONFIG_KEY);
|
||||||
|
if (!raw) return DEFAULT_CONFIG;
|
||||||
|
const parsed = JSON.parse(raw) as Partial<SessionConfig>;
|
||||||
|
return { ...DEFAULT_CONFIG, ...parsed };
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_CONFIG;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存配置到 localStorage */
|
||||||
|
export function saveConfig(config: SessionConfig): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
||||||
|
} catch {
|
||||||
|
// localStorage 不可用时静默失败
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user