Merge pull request '添加计时器,麦克风,摄像头开关' (#45) from develop-frontend8 into develop 33分钟前 #46

Merged
huanghaosheng merged 60 commits from develop into main 2026-06-13 20:43:07 +08:00
5 changed files with 267 additions and 18 deletions
Showing only changes of commit 8dc59d2c09 - Show all commits

View File

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

View File

@@ -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">
... ...

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

View File

@@ -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,

View 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 不可用时静默失败
}
}