聊天框与会话历史栏优化 #91
@@ -208,6 +208,193 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- 会话历史侧边栏 ---- */
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 260px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-surface-1);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed {
|
||||||
|
width: 48px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__toggle:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__new-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__new-btn:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__new-btn--full {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__empty {
|
||||||
|
padding: 24px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__item:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__item:hover .sidebar__item-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__item--active {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__item-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__item-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__item-meta {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__action-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__action-btn:hover {
|
||||||
|
background: var(--color-surface-3);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__action-btn--danger:hover {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__edit {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__edit-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-surface-1);
|
||||||
|
color: var(--color-text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__edit-input:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__edit-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__edit-btn:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- 左侧视频面板 ---- */
|
/* ---- 左侧视频面板 ---- */
|
||||||
|
|
||||||
.video-panel {
|
.video-panel {
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// CamTalk — 主应用组件(Web 端双栏布局)
|
// CamTalk — 主应用组件(Web 端三栏布局:侧边栏 + 视频 + 聊天)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useVisionSession } from "./hooks/useVisionSession";
|
import { useVisionSession } from "./hooks/useVisionSession";
|
||||||
|
import { useSessionList } from "./hooks/useSessionList";
|
||||||
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 { ConfigPanel } from "./components/ConfigPanel";
|
||||||
|
import { SessionSidebar } from "./components/SessionSidebar";
|
||||||
import { ToastContainer } from "./components/Toast";
|
import { ToastContainer } from "./components/Toast";
|
||||||
import { loadTheme, saveTheme } from "./lib/storage";
|
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
|
||||||
|
import { I18nContext, parseLocale, t } from "./lib/i18n";
|
||||||
|
import type { Locale } from "./lib/i18n";
|
||||||
import type { Theme } from "./types";
|
import type { Theme } from "./types";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
function App() {
|
/** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */
|
||||||
|
function AppContent() {
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [theme, setTheme] = useState<Theme>(loadTheme);
|
const [theme, setTheme] = useState<Theme>(loadTheme);
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// 切换主题时更新 <html> 的 data-theme 属性
|
// 切换主题时更新 <html> 的 data-theme 属性
|
||||||
@@ -34,8 +40,21 @@ function App() {
|
|||||||
return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
|
return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- 会话列表 ----
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
createSession,
|
||||||
|
deleteSession,
|
||||||
|
renameSession,
|
||||||
|
persistSession,
|
||||||
|
selectSession,
|
||||||
|
} = useSessionList();
|
||||||
|
|
||||||
|
// ---- 视觉会话 ----
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
|
setMessages,
|
||||||
currentReply,
|
currentReply,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
isAudioPlaying,
|
isAudioPlaying,
|
||||||
@@ -88,28 +107,82 @@ function App() {
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [isConnected]);
|
}, [isConnected]);
|
||||||
|
|
||||||
|
const { t: tr } = useMemo(() => ({ t: (key: string) => t(key, parseLocale(config.language)) }), [config.language]);
|
||||||
|
|
||||||
|
// ---- 初始化:如果没有会话,创建一个 ----
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initializedRef.current) {
|
||||||
|
initializedRef.current = true;
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
createSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [sessions.length, createSession]);
|
||||||
|
|
||||||
|
// ---- 自动保存:messages 变化时持久化到当前会话 ----
|
||||||
|
const messagesRef = useRef(messages);
|
||||||
|
useEffect(() => { messagesRef.current = messages; }, [messages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeSessionId && messages.length > 0) {
|
||||||
|
persistSession(activeSessionId, messages);
|
||||||
|
}
|
||||||
|
}, [messages, activeSessionId, persistSession]);
|
||||||
|
|
||||||
|
// ---- 侧边栏操作 ----
|
||||||
|
const handleNewSession = useCallback(() => {
|
||||||
|
createSession();
|
||||||
|
setMessages([]);
|
||||||
|
// 如果已连接,断开
|
||||||
|
if (connectionStatus === "connected") {
|
||||||
|
stopSession();
|
||||||
|
}
|
||||||
|
}, [createSession, setMessages, connectionStatus, stopSession]);
|
||||||
|
|
||||||
|
const handleSelectSession = useCallback((id: string) => {
|
||||||
|
// 保存当前会话
|
||||||
|
if (activeSessionId && messagesRef.current.length > 0) {
|
||||||
|
persistSession(activeSessionId, messagesRef.current);
|
||||||
|
}
|
||||||
|
// 如果已连接,断开
|
||||||
|
if (connectionStatus === "connected") {
|
||||||
|
stopSession();
|
||||||
|
}
|
||||||
|
// 加载目标会话
|
||||||
|
const loaded = selectSession(id);
|
||||||
|
setMessages(loaded);
|
||||||
|
}, [activeSessionId, persistSession, connectionStatus, stopSession, selectSession, setMessages]);
|
||||||
|
|
||||||
|
const handleDeleteSession = useCallback((id: string) => {
|
||||||
|
deleteSession(id);
|
||||||
|
if (id === activeSessionId) {
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
}, [deleteSession, activeSessionId, setMessages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
{/* ---- 顶部导航栏 ---- */}
|
{/* ---- 顶部导航栏 ---- */}
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<div className="header__left">
|
<div className="header__left">
|
||||||
<h1 className="header__title">CamTalk</h1>
|
<h1 className="header__title">CamTalk</h1>
|
||||||
<span className="header__subtitle">AI 视觉对话助手</span>
|
<span className="header__subtitle">{tr("app.title")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="header__right">
|
<div className="header__right">
|
||||||
{isConnected && (stats.queryCount > 0 || stats.totalTokens > 0) && (
|
{isConnected && stats.queryCount > 0 && (
|
||||||
<span className="header__stats">
|
<span className="header__stats">
|
||||||
{stats.queryCount} 次请求 · {stats.totalTokens} tokens
|
{stats.queryCount} {tr("app.stats.requests")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={`badge badge--${connectionStatus}`}>
|
<span className={`badge badge--${connectionStatus}`}>
|
||||||
{isConnected ? "已连接" : connectionStatus === "connecting" ? "连接中..." : "未连接"}
|
{isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="btn-icon"
|
className="btn-icon"
|
||||||
onClick={() => setShowConfig((v) => !v)}
|
onClick={() => setShowConfig((v) => !v)}
|
||||||
title="设置"
|
title={tr("settings.title")}
|
||||||
aria-label="设置"
|
aria-label={tr("settings.title")}
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
@@ -129,9 +202,21 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---- 主体:左侧视频 + 右侧聊天 ---- */}
|
{/* ---- 主体:侧边栏 + 视频 + 聊天 ---- */}
|
||||||
<div className="workspace">
|
<div className="workspace">
|
||||||
{/* 左侧:视频预览 + 控制栏 */}
|
{/* 最左侧:会话历史侧边栏 */}
|
||||||
|
<SessionSidebar
|
||||||
|
sessions={sessions}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onToggleCollapse={() => setSidebarCollapsed((v) => !v)}
|
||||||
|
onNewSession={handleNewSession}
|
||||||
|
onSelectSession={handleSelectSession}
|
||||||
|
onDeleteSession={handleDeleteSession}
|
||||||
|
onRenameSession={renameSession}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 中间:视频预览 + 控制栏 */}
|
||||||
<div className="video-panel">
|
<div className="video-panel">
|
||||||
<div className="video-container">
|
<div className="video-container">
|
||||||
<VideoPreview ref={videoRef} isStreaming={!!stream} />
|
<VideoPreview ref={videoRef} isStreaming={!!stream} />
|
||||||
@@ -145,16 +230,16 @@ function App() {
|
|||||||
<div className="detail-badge">HD</div>
|
<div className="detail-badge">HD</div>
|
||||||
)}
|
)}
|
||||||
{isObserving && (
|
{isObserving && (
|
||||||
<div className="observation-badge">👁️ 观察中</div>
|
<div className="observation-badge">{tr("video.observing")}</div>
|
||||||
)}
|
)}
|
||||||
{isSpeaking && (
|
{isSpeaking && (
|
||||||
<div className="video-indicator">🎤 正在聆听...</div>
|
<div className="video-indicator">{tr("video.listening")}</div>
|
||||||
)}
|
)}
|
||||||
{isAudioPlaying && config.ttsEnabled && (
|
{isAudioPlaying && config.ttsEnabled && (
|
||||||
<div className="video-indicator video-indicator--audio">🔊 正在播放...</div>
|
<div className="video-indicator video-indicator--audio">{tr("video.playing")}</div>
|
||||||
)}
|
)}
|
||||||
{isConnected && !isVADReady && !vadError && (
|
{isConnected && !isVADReady && !vadError && (
|
||||||
<div className="video-indicator video-indicator--loading">正在初始化语音检测...</div>
|
<div className="video-indicator video-indicator--loading">{tr("video.initVad")}</div>
|
||||||
)}
|
)}
|
||||||
{vadError && (
|
{vadError && (
|
||||||
<div className="video-indicator video-indicator--error">⚠️ {vadError}</div>
|
<div className="video-indicator video-indicator--error">⚠️ {vadError}</div>
|
||||||
@@ -170,7 +255,7 @@ function App() {
|
|||||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||||
<circle cx="12" cy="13" r="4" />
|
<circle cx="12" cy="13" r="4" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>点击下方按钮开始对话</span>
|
<span>{tr("video.placeholder")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isConnected && !isCameraOn && (
|
{isConnected && !isCameraOn && (
|
||||||
@@ -179,8 +264,8 @@ function App() {
|
|||||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||||
<circle cx="12" cy="13" r="4" />
|
<circle cx="12" cy="13" r="4" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>摄像头未开启</span>
|
<span>{tr("video.cameraOff")}</span>
|
||||||
<span className="video-placeholder__hint">可在右侧聊天框打字对话</span>
|
<span className="video-placeholder__hint">{tr("video.cameraOff.hint")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -189,21 +274,21 @@ function App() {
|
|||||||
<div className="video-controls">
|
<div className="video-controls">
|
||||||
{!isConnected ? (
|
{!isConnected ? (
|
||||||
<button className="btn btn--primary btn--lg" onClick={startSession}>
|
<button className="btn btn--primary btn--lg" onClick={startSession}>
|
||||||
{connectionStatus === "connecting" ? "连接中..." : "🎙️ 开始对话"}
|
{connectionStatus === "connecting" ? tr("controls.connecting") : tr("controls.startVideo")}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="video-controls__row">
|
<div className="video-controls__row">
|
||||||
<button
|
<button
|
||||||
className={`btn btn--ctrl ${isCameraOn ? "btn--ctrl-on" : "btn--ctrl-off"}`}
|
className={`btn btn--ctrl ${isCameraOn ? "btn--ctrl-on" : "btn--ctrl-off"}`}
|
||||||
onClick={toggleCamera}
|
onClick={toggleCamera}
|
||||||
title={isCameraOn ? "关闭摄像头" : "开启摄像头"}
|
title={isCameraOn ? tr("controls.cameraOff") : tr("controls.cameraOn")}
|
||||||
>
|
>
|
||||||
📷
|
📷
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`btn btn--ctrl ${isMicOn ? "btn--ctrl-on" : "btn--ctrl-off"} ${isSpeaking ? "btn--speaking" : ""}`}
|
className={`btn btn--ctrl ${isMicOn ? "btn--ctrl-on" : "btn--ctrl-off"} ${isSpeaking ? "btn--speaking" : ""}`}
|
||||||
onClick={toggleMic}
|
onClick={toggleMic}
|
||||||
title={isMicOn ? "关闭麦克风" : "开启麦克风"}
|
title={isMicOn ? tr("controls.micOff") : tr("controls.micOn")}
|
||||||
>
|
>
|
||||||
🎤
|
🎤
|
||||||
</button>
|
</button>
|
||||||
@@ -211,15 +296,15 @@ function App() {
|
|||||||
className={`btn ${mode === "observation" ? "btn--active" : "btn--secondary"}`}
|
className={`btn ${mode === "observation" ? "btn--active" : "btn--secondary"}`}
|
||||||
onClick={toggleMode}
|
onClick={toggleMode}
|
||||||
>
|
>
|
||||||
{mode === "observation" ? "👁️ 观察中" : "👁️ 观察模式"}
|
{mode === "observation" ? tr("controls.observing") : tr("controls.observation")}
|
||||||
</button>
|
</button>
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<button className="btn btn--warning" onClick={interrupt}>
|
<button className="btn btn--warning" onClick={interrupt}>
|
||||||
⏹ 打断
|
{tr("controls.interrupt")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn--danger" onClick={stopSession}>
|
<button className="btn btn--danger" onClick={stopSession}>
|
||||||
结束对话
|
{tr("controls.stop")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -229,15 +314,15 @@ function App() {
|
|||||||
{/* 右侧:聊天面板 */}
|
{/* 右侧:聊天面板 */}
|
||||||
<div className="chat-panel-wrapper">
|
<div className="chat-panel-wrapper">
|
||||||
<div className="chat-panel-header">
|
<div className="chat-panel-header">
|
||||||
<span>对话</span>
|
<span>{tr("chat.title")}</span>
|
||||||
{isConnected && mode === "observation" && (
|
{isConnected && mode === "observation" && (
|
||||||
<span className="chat-panel-header__mode">观察模式</span>
|
<span className="chat-panel-header__mode">{tr("chat.mode.observation")}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-panel-body">
|
<div className="chat-panel-body">
|
||||||
{connectionStatus === "disconnected" && messages.length > 0 && (
|
{connectionStatus === "disconnected" && messages.length > 0 && (
|
||||||
<div className="system-message system-message--warning">
|
<div className="system-message system-message--warning">
|
||||||
连接已断开,正在重连...
|
{tr("chat.reconnecting")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ChatPanel
|
<ChatPanel
|
||||||
@@ -255,4 +340,30 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [locale, setLocale] = useState<Locale>(() => parseLocale(loadConfig().language));
|
||||||
|
|
||||||
|
const i18nValue = useMemo(() => ({
|
||||||
|
locale,
|
||||||
|
t: (key: string) => t(key, locale),
|
||||||
|
}), [locale]);
|
||||||
|
|
||||||
|
// 监听配置变化以更新 locale
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
setLocale(parseLocale(cfg.language));
|
||||||
|
};
|
||||||
|
// 自定义事件,由 saveConfig 触发
|
||||||
|
window.addEventListener("camtalk-config-changed", handler);
|
||||||
|
return () => window.removeEventListener("camtalk-config-changed", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<I18nContext.Provider value={i18nValue}>
|
||||||
|
<AppContent />
|
||||||
|
</I18nContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
|
||||||
export interface CameraManagerHandle {
|
export interface CameraManagerHandle {
|
||||||
/** 获取当前视频轨道 */
|
/** 获取当前视频轨道 */
|
||||||
@@ -13,6 +14,7 @@ export interface CameraManagerHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useCamera() {
|
export function useCamera() {
|
||||||
|
const { t } = useI18n();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -29,7 +31,7 @@ export function useCamera() {
|
|||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "无法访问摄像头";
|
const message = err instanceof Error ? err.message : t("error.cameraAccess");
|
||||||
setError(message);
|
setError(message);
|
||||||
console.error("[Camera] 获取摄像头失败:", err);
|
console.error("[Camera] 获取摄像头失败:", err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
import type { ChatMessage } from "../../types";
|
import type { ChatMessage } from "../../types";
|
||||||
import type { ConnectionStatus } from "../../lib/websocket";
|
import type { ConnectionStatus } from "../../lib/websocket";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const isAutoScroll = useRef(true);
|
const isAutoScroll = useRef(true);
|
||||||
const [inputText, setInputText] = useState("");
|
const [inputText, setInputText] = useState("");
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const isConnected = connectionStatus === "connected";
|
const isConnected = connectionStatus === "connected";
|
||||||
|
|
||||||
@@ -51,17 +53,6 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
|||||||
setInputText("");
|
setInputText("");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 未连接时的空状态
|
|
||||||
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 (
|
return (
|
||||||
<div className="chat-panel">
|
<div className="chat-panel">
|
||||||
<div className="chat-panel__messages" ref={containerRef}>
|
<div className="chat-panel__messages" ref={containerRef}>
|
||||||
@@ -69,15 +60,15 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
|||||||
{messages.length === 0 && !currentReply && (
|
{messages.length === 0 && !currentReply && (
|
||||||
<div className="chat-panel__welcome">
|
<div className="chat-panel__welcome">
|
||||||
<span className="chat-panel__welcome-icon">💬</span>
|
<span className="chat-panel__welcome-icon">💬</span>
|
||||||
<p>在下方输入文字开始对话</p>
|
<p>{isConnected ? t("chat.welcome.prompt") : t("chat.empty.prompt")}</p>
|
||||||
<span className="chat-panel__welcome-hint">也可以开启麦克风用语音对话</span>
|
<span className="chat-panel__welcome-hint">{isConnected ? t("chat.welcome.hint") : t("chat.empty.hint")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.map((msg, index) => (
|
{messages.map((msg, index) => (
|
||||||
<div key={index} className={`chat-message chat-message--${msg.role}`}>
|
<div key={index} className={`chat-message chat-message--${msg.role}`}>
|
||||||
<div className="chat-message__role">
|
<div className="chat-message__role">
|
||||||
{msg.role === "user" ? "你" : "AI"}
|
{msg.role === "user" ? t("chat.userLabel") : "AI"}
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-message__content">{msg.content}</div>
|
<div className="chat-message__content">{msg.content}</div>
|
||||||
{msg.role === "assistant" && msg.tokensUsed !== undefined && (
|
{msg.role === "assistant" && msg.tokensUsed !== undefined && (
|
||||||
@@ -104,21 +95,22 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
|||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 文本输入框 - 连接后始终显示 */}
|
{/* 文本输入框 - 始终显示 */}
|
||||||
{onSendText && (
|
{onSendText && (
|
||||||
<form className="chat-input" onSubmit={handleSubmit}>
|
<form className="chat-input" onSubmit={handleSubmit}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="chat-input__field"
|
className="chat-input__field"
|
||||||
placeholder="输入文字对话..."
|
placeholder={t("chat.input.placeholder")}
|
||||||
value={inputText}
|
value={inputText}
|
||||||
onChange={(e) => setInputText(e.target.value)}
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
|
disabled={connectionStatus === "connecting"}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="chat-input__send"
|
className="chat-input__send"
|
||||||
disabled={!inputText.trim()}
|
disabled={!inputText.trim() || connectionStatus === "connecting"}
|
||||||
title="发送"
|
title={t("chat.send")}
|
||||||
>
|
>
|
||||||
➤
|
➤
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// 职责:主题切换、TTS 开关、detail level 切换、语言选择
|
// 职责:主题切换、TTS 开关、detail level 切换、语言选择
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
import type { SessionConfig, Theme } from "../../types";
|
import type { SessionConfig, Theme } from "../../types";
|
||||||
|
|
||||||
interface ConfigPanelProps {
|
interface ConfigPanelProps {
|
||||||
@@ -14,40 +15,42 @@ interface ConfigPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }: ConfigPanelProps) {
|
export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }: ConfigPanelProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="drawer-overlay" onClick={onClose}>
|
<div className="drawer-overlay" onClick={onClose}>
|
||||||
<div className="drawer" onClick={(e) => e.stopPropagation()}>
|
<div className="drawer" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="drawer__header">
|
<div className="drawer__header">
|
||||||
<span className="drawer__title">设置</span>
|
<span className="drawer__title">{t("settings.title")}</span>
|
||||||
<button className="drawer__close" onClick={onClose}>✕</button>
|
<button className="drawer__close" onClick={onClose}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="drawer__body">
|
<div className="drawer__body">
|
||||||
<div className="config-group">
|
<div className="config-group">
|
||||||
<div className="config-group__title">外观</div>
|
<div className="config-group__title">{t("settings.appearance")}</div>
|
||||||
|
|
||||||
<label className="config-row">
|
<label className="config-row">
|
||||||
<div className="config-row__info">
|
<div className="config-row__info">
|
||||||
<span className="config-row__label">主题</span>
|
<span className="config-row__label">{t("settings.theme")}</span>
|
||||||
<span className="config-row__desc">切换明暗主题</span>
|
<span className="config-row__desc">{t("settings.theme.desc")}</span>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={theme}
|
value={theme}
|
||||||
onChange={(e) => onThemeChange(e.target.value as Theme)}
|
onChange={(e) => onThemeChange(e.target.value as Theme)}
|
||||||
>
|
>
|
||||||
<option value="dark">深色</option>
|
<option value="dark">{t("settings.theme.dark")}</option>
|
||||||
<option value="light">浅色</option>
|
<option value="light">{t("settings.theme.light")}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="config-group">
|
<div className="config-group">
|
||||||
<div className="config-group__title">会话</div>
|
<div className="config-group__title">{t("settings.session")}</div>
|
||||||
|
|
||||||
<label className="config-row">
|
<label className="config-row">
|
||||||
<div className="config-row__info">
|
<div className="config-row__info">
|
||||||
<span className="config-row__label">语音回答</span>
|
<span className="config-row__label">{t("settings.tts")}</span>
|
||||||
<span className="config-row__desc">AI 回答时同步播放语音</span>
|
<span className="config-row__desc">{t("settings.tts.desc")}</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -58,22 +61,22 @@ export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }:
|
|||||||
|
|
||||||
<label className="config-row">
|
<label className="config-row">
|
||||||
<div className="config-row__info">
|
<div className="config-row__info">
|
||||||
<span className="config-row__label">图像精度</span>
|
<span className="config-row__label">{t("settings.detail")}</span>
|
||||||
<span className="config-row__desc">高精度适合识别文字,低精度省流量</span>
|
<span className="config-row__desc">{t("settings.detail.desc")}</span>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={config.detailLevel}
|
value={config.detailLevel}
|
||||||
onChange={(e) => onUpdate({ detailLevel: e.target.value as "low" | "high" })}
|
onChange={(e) => onUpdate({ detailLevel: e.target.value as "low" | "high" })}
|
||||||
>
|
>
|
||||||
<option value="low">低</option>
|
<option value="low">{t("settings.detail.low")}</option>
|
||||||
<option value="high">高</option>
|
<option value="high">{t("settings.detail.high")}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="config-row">
|
<label className="config-row">
|
||||||
<div className="config-row__info">
|
<div className="config-row__info">
|
||||||
<span className="config-row__label">语言</span>
|
<span className="config-row__label">{t("settings.language")}</span>
|
||||||
<span className="config-row__desc">交互语言偏好</span>
|
<span className="config-row__desc">{t("settings.language.desc")}</span>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={config.language}
|
value={config.language}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { MicVAD } from "@ricky0123/vad-web";
|
import { MicVAD } from "@ricky0123/vad-web";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
|
||||||
export interface VADOptions {
|
export interface VADOptions {
|
||||||
/** 语音结束回调,携带录音 Float32Array(16kHz) */
|
/** 语音结束回调,携带录音 Float32Array(16kHz) */
|
||||||
@@ -21,6 +22,7 @@ export interface VADOptions {
|
|||||||
* 基于 @ricky0123/vad-web 的 MicVAD,检测用户说话并回调
|
* 基于 @ricky0123/vad-web 的 MicVAD,检测用户说话并回调
|
||||||
*/
|
*/
|
||||||
export function useVAD(options?: VADOptions) {
|
export function useVAD(options?: VADOptions) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -79,7 +81,7 @@ export function useVAD(options?: VADOptions) {
|
|||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "VAD 初始化失败";
|
const message = err instanceof Error ? err.message : t("error.vadInit");
|
||||||
setError(message);
|
setError(message);
|
||||||
console.error("[VAD] 初始化失败:", err);
|
console.error("[VAD] 初始化失败:", err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
|
||||||
export function useMicrophone() {
|
export function useMicrophone() {
|
||||||
|
const { t } = useI18n();
|
||||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
@@ -25,7 +27,7 @@ export function useMicrophone() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
return mediaStream;
|
return mediaStream;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "无法访问麦克风";
|
const message = err instanceof Error ? err.message : t("error.micAccess");
|
||||||
setError(message);
|
setError(message);
|
||||||
console.error("[Mic] 获取麦克风失败:", err);
|
console.error("[Mic] 获取麦克风失败:", err);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
160
frontend/src/components/SessionSidebar/index.tsx
Normal file
160
frontend/src/components/SessionSidebar/index.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// ============================================================
|
||||||
|
// SessionSidebar — 会话历史侧边栏
|
||||||
|
// 职责:展示会话列表、新建/切换/删除/重命名会话
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
import type { SessionSummary } from "../../types";
|
||||||
|
|
||||||
|
interface SessionSidebarProps {
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
activeSessionId: string | null;
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggleCollapse: () => void;
|
||||||
|
onNewSession: () => void;
|
||||||
|
onSelectSession: (id: string) => void;
|
||||||
|
onDeleteSession: (id: string) => void;
|
||||||
|
onRenameSession: (id: string, title: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化相对时间 */
|
||||||
|
function formatRelativeTime(ts: number): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - ts;
|
||||||
|
const min = 60 * 1000;
|
||||||
|
const hour = 60 * min;
|
||||||
|
const day = 24 * hour;
|
||||||
|
|
||||||
|
if (diff < min) return "刚刚";
|
||||||
|
if (diff < hour) return `${Math.floor(diff / min)}分钟前`;
|
||||||
|
if (diff < day) return `${Math.floor(diff / hour)}小时前`;
|
||||||
|
if (diff < 2 * day) return "昨天";
|
||||||
|
if (diff < 7 * day) return `${Math.floor(diff / day)}天前`;
|
||||||
|
const d = new Date(ts);
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionSidebar({
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
collapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
onNewSession,
|
||||||
|
onSelectSession,
|
||||||
|
onDeleteSession,
|
||||||
|
onRenameSession,
|
||||||
|
}: SessionSidebarProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editTitle, setEditTitle] = useState("");
|
||||||
|
|
||||||
|
const handleStartRename = (id: string, currentTitle: string) => {
|
||||||
|
setEditingId(id);
|
||||||
|
setEditTitle(currentTitle);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmRename = () => {
|
||||||
|
if (editingId && editTitle.trim()) {
|
||||||
|
onRenameSession(editingId, editTitle.trim());
|
||||||
|
}
|
||||||
|
setEditingId(null);
|
||||||
|
setEditTitle("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelRename = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditTitle("");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<div className="sidebar sidebar--collapsed">
|
||||||
|
<button className="sidebar__toggle" onClick={onToggleCollapse} title={t("sidebar.expand")}>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<button className="sidebar__new-btn" onClick={onNewSession} title={t("sidebar.new")}>
|
||||||
|
✚
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar">
|
||||||
|
<div className="sidebar__header">
|
||||||
|
<button className="sidebar__new-btn sidebar__new-btn--full" onClick={onNewSession}>
|
||||||
|
✚ {t("sidebar.new")}
|
||||||
|
</button>
|
||||||
|
<button className="sidebar__toggle" onClick={onToggleCollapse} title={t("sidebar.collapse")}>
|
||||||
|
◀
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar__list">
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<div className="sidebar__empty">{t("sidebar.empty")}</div>
|
||||||
|
) : (
|
||||||
|
sessions.map((session) => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={`sidebar__item ${session.id === activeSessionId ? "sidebar__item--active" : ""}`}
|
||||||
|
onClick={() => onSelectSession(session.id)}
|
||||||
|
>
|
||||||
|
{editingId === session.id ? (
|
||||||
|
<div className="sidebar__edit" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
className="sidebar__edit-input"
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleConfirmRename();
|
||||||
|
if (e.key === "Escape") handleCancelRename();
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className="sidebar__edit-btn" onClick={handleConfirmRename}>✓</button>
|
||||||
|
<button className="sidebar__edit-btn" onClick={handleCancelRename}>✕</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="sidebar__item-content">
|
||||||
|
<div className="sidebar__item-title">{session.title}</div>
|
||||||
|
<div className="sidebar__item-meta">
|
||||||
|
{session.messageCount > 0 && (
|
||||||
|
<span>{session.messageCount}{t("sidebar.messages")}</span>
|
||||||
|
)}
|
||||||
|
<span>{formatRelativeTime(session.lastActiveAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sidebar__item-actions">
|
||||||
|
<button
|
||||||
|
className="sidebar__action-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleStartRename(session.id, session.title);
|
||||||
|
}}
|
||||||
|
title={t("sidebar.rename")}
|
||||||
|
>
|
||||||
|
✏
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="sidebar__action-btn sidebar__action-btn--danger"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteSession(session.id);
|
||||||
|
}}
|
||||||
|
title={t("sidebar.delete")}
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
|
||||||
interface VideoPreviewProps {
|
interface VideoPreviewProps {
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
@@ -11,6 +12,8 @@ interface VideoPreviewProps {
|
|||||||
|
|
||||||
export const VideoPreview = forwardRef<HTMLVideoElement, VideoPreviewProps>(
|
export const VideoPreview = forwardRef<HTMLVideoElement, VideoPreviewProps>(
|
||||||
function VideoPreview({ isStreaming }, ref) {
|
function VideoPreview({ isStreaming }, ref) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="video-preview">
|
<div className="video-preview">
|
||||||
<video
|
<video
|
||||||
@@ -22,7 +25,7 @@ export const VideoPreview = forwardRef<HTMLVideoElement, VideoPreviewProps>(
|
|||||||
/>
|
/>
|
||||||
{!isStreaming && (
|
{!isStreaming && (
|
||||||
<div className="video-preview__placeholder">
|
<div className="video-preview__placeholder">
|
||||||
摄像头未开启
|
{t("video.cameraOff")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
117
frontend/src/hooks/useSessionList.ts
Normal file
117
frontend/src/hooks/useSessionList.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// ============================================================
|
||||||
|
// useSessionList — 会话历史列表管理
|
||||||
|
// 职责:会话 CRUD、消息持久化、切换会话
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import {
|
||||||
|
loadSessionSummaries,
|
||||||
|
saveSessionSummaries,
|
||||||
|
loadSessionMessages,
|
||||||
|
saveSessionMessages,
|
||||||
|
deleteSessionMessages,
|
||||||
|
} from "../lib/storage";
|
||||||
|
import type { ChatMessage, SessionSummary } from "../types";
|
||||||
|
|
||||||
|
/** 截取预览文本 */
|
||||||
|
function getPreview(text: string, maxLen = 50): string {
|
||||||
|
const clean = text.replace(/[\n\r]/g, " ").trim();
|
||||||
|
return clean.length > maxLen ? clean.slice(0, maxLen) + "…" : clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionList() {
|
||||||
|
const [sessions, setSessions] = useState<SessionSummary[]>(() => loadSessionSummaries());
|
||||||
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||||
|
const sessionsRef = useRef(sessions);
|
||||||
|
useEffect(() => { sessionsRef.current = sessions; }, [sessions]);
|
||||||
|
|
||||||
|
/** 创建新会话 */
|
||||||
|
const createSession = useCallback((): string => {
|
||||||
|
const id = uuidv4();
|
||||||
|
const now = Date.now();
|
||||||
|
const summary: SessionSummary = {
|
||||||
|
id,
|
||||||
|
title: "新对话",
|
||||||
|
createdAt: now,
|
||||||
|
lastActiveAt: now,
|
||||||
|
messageCount: 0,
|
||||||
|
preview: "",
|
||||||
|
};
|
||||||
|
setSessions((prev) => [summary, ...prev]);
|
||||||
|
setActiveSessionId(id);
|
||||||
|
// 持久化
|
||||||
|
const all = [summary, ...sessionsRef.current];
|
||||||
|
saveSessionSummaries(all);
|
||||||
|
return id;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 删除会话 */
|
||||||
|
const deleteSession = useCallback((id: string) => {
|
||||||
|
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
deleteSessionMessages(id);
|
||||||
|
const remaining = sessionsRef.current.filter((s) => s.id !== id);
|
||||||
|
saveSessionSummaries(remaining);
|
||||||
|
// 如果删除的是当前会话,清空 active
|
||||||
|
setActiveSessionId((prev) => (prev === id ? null : prev));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 重命名会话 */
|
||||||
|
const renameSession = useCallback((id: string, title: string) => {
|
||||||
|
setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, title } : s)));
|
||||||
|
const updated = sessionsRef.current.map((s) => (s.id === id ? { ...s, title } : s));
|
||||||
|
saveSessionSummaries(updated);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 保存当前会话的消息历史 */
|
||||||
|
const saveCurrentSession = useCallback((messages: ChatMessage[]) => {
|
||||||
|
const id = sessionsRef.current.length > 0 ? sessionsRef.current[0].id : null;
|
||||||
|
// 找到 activeSessionId 对应的会话
|
||||||
|
// 这里不依赖 activeSessionId state,而是通过参数传入
|
||||||
|
return messages;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 保存指定会话的消息并更新摘要 */
|
||||||
|
const persistSession = useCallback((sessionId: string, messages: ChatMessage[]) => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
saveSessionMessages(sessionId, messages);
|
||||||
|
// 更新摘要
|
||||||
|
const firstUserMsg = messages.find((m) => m.role === "user");
|
||||||
|
const title = firstUserMsg ? getPreview(firstUserMsg.content, 20) : "新对话";
|
||||||
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
const summary: Partial<SessionSummary> = {
|
||||||
|
title,
|
||||||
|
messageCount: messages.length,
|
||||||
|
lastActiveAt: lastMsg?.timestamp || Date.now(),
|
||||||
|
preview: lastMsg ? getPreview(lastMsg.content) : "",
|
||||||
|
};
|
||||||
|
setSessions((prev) => {
|
||||||
|
const updated = prev.map((s) => (s.id === sessionId ? { ...s, ...summary } : s));
|
||||||
|
saveSessionSummaries(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 加载指定会话的消息历史 */
|
||||||
|
const loadMessages = useCallback((sessionId: string): ChatMessage[] => {
|
||||||
|
return loadSessionMessages(sessionId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 选择会话(返回需要加载的消息) */
|
||||||
|
const selectSession = useCallback((id: string): ChatMessage[] => {
|
||||||
|
setActiveSessionId(id);
|
||||||
|
return loadSessionMessages(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
setActiveSessionId,
|
||||||
|
createSession,
|
||||||
|
deleteSession,
|
||||||
|
renameSession,
|
||||||
|
persistSession,
|
||||||
|
loadMessages,
|
||||||
|
selectSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ 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 { loadConfig, saveConfig } from "../lib/storage";
|
||||||
|
import { useI18n } from "../lib/i18n";
|
||||||
import { useCamera } from "../components/CameraManager";
|
import { useCamera } from "../components/CameraManager";
|
||||||
import { useMicrophone } from "../components/MicManager";
|
import { useMicrophone } from "../components/MicManager";
|
||||||
import { useVAD, sampleFrame, compareFrames } from "../components/EdgeProcessor";
|
import { useVAD, sampleFrame, compareFrames } from "../components/EdgeProcessor";
|
||||||
@@ -29,6 +30,7 @@ export interface SessionStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useVisionSession() {
|
export function useVisionSession() {
|
||||||
|
const { t } = useI18n();
|
||||||
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);
|
||||||
@@ -45,6 +47,9 @@ export function useVisionSession() {
|
|||||||
// 对话历史(role + content),用于多轮上下文
|
// 对话历史(role + content),用于多轮上下文
|
||||||
const historyRef = useRef<Array<{ role: string; content: string }>>([]);
|
const historyRef = useRef<Array<{ role: string; content: string }>>([]);
|
||||||
|
|
||||||
|
// 待发消息队列(未连接时暂存,连接后自动发送)
|
||||||
|
const pendingMessagesRef = useRef<Array<{ text: string; requestId: string }>>([]);
|
||||||
|
|
||||||
// TTS 播放器
|
// TTS 播放器
|
||||||
const ttsPlayerRef = useRef<TTSPlayer | null>(null);
|
const ttsPlayerRef = useRef<TTSPlayer | null>(null);
|
||||||
const getTTSPlayer = useCallback(() => {
|
const getTTSPlayer = useCallback(() => {
|
||||||
@@ -66,6 +71,12 @@ export function useVisionSession() {
|
|||||||
isProcessingRef.current = isProcessing;
|
isProcessingRef.current = isProcessing;
|
||||||
}, [isProcessing]);
|
}, [isProcessing]);
|
||||||
|
|
||||||
|
// 用 ref 跟踪连接状态,避免回调闭包问题
|
||||||
|
const statusRef = useRef(status);
|
||||||
|
useEffect(() => {
|
||||||
|
statusRef.current = status;
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
// 观察模式:画面变化时自动发送 query
|
// 观察模式:画面变化时自动发送 query
|
||||||
const { isObserving, startObserving, stopObserving } = useObservationMode({
|
const { isObserving, startObserving, stopObserving } = useObservationMode({
|
||||||
onChange: useCallback(
|
onChange: useCallback(
|
||||||
@@ -84,7 +95,7 @@ export function useVisionSession() {
|
|||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: "👁️ 画面变化检测",
|
content: t("session.changeDetected"),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -108,7 +119,7 @@ export function useVisionSession() {
|
|||||||
});
|
});
|
||||||
}, [videoRef, captureFrame, startObserving, stopObserving]);
|
}, [videoRef, captureFrame, startObserving, stopObserving]);
|
||||||
|
|
||||||
// WebSocket 连接成功后发送 config
|
// WebSocket 连接成功后发送 config + flush 待发消息
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "connected") {
|
if (status === "connected") {
|
||||||
send({
|
send({
|
||||||
@@ -119,6 +130,27 @@ export function useVisionSession() {
|
|||||||
language: config.language,
|
language: config.language,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// flush 待发消息队列
|
||||||
|
const pending = pendingMessagesRef.current;
|
||||||
|
pendingMessagesRef.current = [];
|
||||||
|
for (const msg of pending) {
|
||||||
|
const frame = captureFrame();
|
||||||
|
send({
|
||||||
|
type: "query",
|
||||||
|
request_id: msg.requestId,
|
||||||
|
image: frame ? dataUrlToBase64(frame) : "",
|
||||||
|
audio: "",
|
||||||
|
text: msg.text,
|
||||||
|
});
|
||||||
|
setStats((prev) => ({ ...prev, queryCount: prev.queryCount + 1 }));
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: "user", content: msg.text, timestamp: Date.now() },
|
||||||
|
]);
|
||||||
|
historyRef.current.push({ role: "user", content: msg.text });
|
||||||
|
setIsProcessing(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [status]); // eslint-disable-line react-hooks/exhaustive-deps -- 仅在连接状态变化时发送
|
}, [status]); // eslint-disable-line react-hooks/exhaustive-deps -- 仅在连接状态变化时发送
|
||||||
|
|
||||||
@@ -199,7 +231,7 @@ export function useVisionSession() {
|
|||||||
// 添加用户消息(STT 流式结果会逐步更新文本)
|
// 添加用户消息(STT 流式结果会逐步更新文本)
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ role: "user", content: "(语音识别中...)", timestamp: Date.now() },
|
{ role: "user", content: t("session.recognizing"), timestamp: Date.now() },
|
||||||
]);
|
]);
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
},
|
},
|
||||||
@@ -219,7 +251,7 @@ export function useVisionSession() {
|
|||||||
if (lastUserIdx >= 0) {
|
if (lastUserIdx >= 0) {
|
||||||
updated[lastUserIdx] = {
|
updated[lastUserIdx] = {
|
||||||
...updated[lastUserIdx],
|
...updated[lastUserIdx],
|
||||||
content: msg.text || "(未识别到语音)",
|
content: msg.text || t("session.noSpeech"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
@@ -271,7 +303,7 @@ export function useVisionSession() {
|
|||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
console.error("[Session] 服务端错误:", msg.code, msg.message);
|
console.error("[Session] 服务端错误:", msg.code, msg.message);
|
||||||
showToast(getErrorMessage(msg.code), "error");
|
showToast(getErrorMessage(msg.code, t), "error");
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -280,10 +312,13 @@ export function useVisionSession() {
|
|||||||
return unsub;
|
return unsub;
|
||||||
}, [getTTSPlayer]);
|
}, [getTTSPlayer]);
|
||||||
|
|
||||||
/** 启动会话 */
|
/** 启动视频通话(摄像头 + 麦克风 + VAD) */
|
||||||
const startSession = useCallback(async () => {
|
const startSession = useCallback(async () => {
|
||||||
// 1. 连接 WebSocket(必须)
|
// 1. 确保 WebSocket 已连接
|
||||||
connect();
|
if (statusRef.current !== "connected") {
|
||||||
|
connect();
|
||||||
|
// 等待连接完成(通过 status 变化触发后续流程,这里直接继续)
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 尝试获取摄像头(可选)
|
// 2. 尝试获取摄像头(可选)
|
||||||
try {
|
try {
|
||||||
@@ -359,7 +394,7 @@ export function useVisionSession() {
|
|||||||
setIsAudioPlaying(false);
|
setIsAudioPlaying(false);
|
||||||
// 将未完成的流式内容保存为最终消息
|
// 将未完成的流式内容保存为最终消息
|
||||||
if (currentReply) {
|
if (currentReply) {
|
||||||
const interrupted = currentReply + "(已打断)";
|
const interrupted = currentReply + t("session.interrupted");
|
||||||
historyRef.current.push({ role: "assistant", content: interrupted });
|
historyRef.current.push({ role: "assistant", content: interrupted });
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
@@ -379,10 +414,23 @@ export function useVisionSession() {
|
|||||||
ttsPlayerRef.current?.stop();
|
ttsPlayerRef.current?.stop();
|
||||||
setIsAudioPlaying(false);
|
setIsAudioPlaying(false);
|
||||||
|
|
||||||
// 捕获当前摄像头画面
|
|
||||||
const frame = captureFrame();
|
|
||||||
|
|
||||||
const requestId = uuidv4();
|
const requestId = uuidv4();
|
||||||
|
|
||||||
|
// 未连接时:自动连接,消息加入待发队列
|
||||||
|
if (statusRef.current !== "connected") {
|
||||||
|
pendingMessagesRef.current.push({ text: text.trim(), requestId });
|
||||||
|
// 添加用户消息到 UI(立即反馈)
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: "user", content: text.trim(), timestamp: Date.now() }],
|
||||||
|
);
|
||||||
|
// 自动连接 WebSocket
|
||||||
|
connect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已连接:直接发送
|
||||||
|
const frame = captureFrame();
|
||||||
send({
|
send({
|
||||||
type: "query",
|
type: "query",
|
||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
@@ -405,11 +453,12 @@ export function useVisionSession() {
|
|||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
},
|
},
|
||||||
[captureFrame, send],
|
[captureFrame, send, connect],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
|
setMessages,
|
||||||
currentReply,
|
currentReply,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
isAudioPlaying,
|
isAudioPlaying,
|
||||||
|
|||||||
@@ -5,20 +5,13 @@
|
|||||||
|
|
||||||
import type { ErrorCode } from "../types";
|
import type { ErrorCode } from "../types";
|
||||||
|
|
||||||
const ERROR_MESSAGES: Record<ErrorCode, string> = {
|
/** 将错误码转为用户友好文案(需要传入翻译函数) */
|
||||||
INVALID_MESSAGE: "消息格式异常,请重试",
|
export function getErrorMessage(code: string, translate: (key: string) => string): string {
|
||||||
SESSION_NOT_FOUND: "会话已过期,请重新连接",
|
const key = `error.${code}`;
|
||||||
RATE_LIMITED: "请求太频繁,请稍后再试",
|
const translated = translate(key);
|
||||||
IMAGE_TOO_LARGE: "图像过大,请降低分辨率",
|
// 如果翻译函数返回 key 本身(未找到翻译),用 fallback
|
||||||
AUDIO_TOO_SHORT: "语音太短,请再说一句",
|
if (translated === key) {
|
||||||
LLM_TIMEOUT: "AI 响应超时,请重试",
|
return translate("error.unknown") + `: ${code}`;
|
||||||
LLM_ERROR: "AI 服务异常,请稍后重试",
|
}
|
||||||
STT_ERROR: "语音识别失败,请重试",
|
return translated;
|
||||||
TTS_ERROR: "语音合成失败",
|
|
||||||
INTERNAL_ERROR: "服务内部错误,请重试",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 将错误码转为用户友好文案 */
|
|
||||||
export function getErrorMessage(code: string): string {
|
|
||||||
return ERROR_MESSAGES[code as ErrorCode] ?? `未知错误: ${code}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
97
frontend/src/lib/i18n/en-US.ts
Normal file
97
frontend/src/lib/i18n/en-US.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { TranslationMap } from "./index";
|
||||||
|
|
||||||
|
export const enUS: TranslationMap = {
|
||||||
|
// App header
|
||||||
|
"app.title": "AI Vision Assistant",
|
||||||
|
"app.stats.requests": "requests",
|
||||||
|
|
||||||
|
// Connection status
|
||||||
|
"status.connected": "Connected",
|
||||||
|
"status.connecting": "Connecting...",
|
||||||
|
"status.disconnected": "Disconnected",
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.appearance": "Appearance",
|
||||||
|
"settings.theme": "Theme",
|
||||||
|
"settings.theme.desc": "Switch between light and dark theme",
|
||||||
|
"settings.theme.dark": "Dark",
|
||||||
|
"settings.theme.light": "Light",
|
||||||
|
"settings.session": "Session",
|
||||||
|
"settings.tts": "Voice Response",
|
||||||
|
"settings.tts.desc": "Play voice along with AI response",
|
||||||
|
"settings.detail": "Image Quality",
|
||||||
|
"settings.detail.desc": "High quality for text recognition, low saves bandwidth",
|
||||||
|
"settings.detail.low": "Low",
|
||||||
|
"settings.detail.high": "High",
|
||||||
|
"settings.language": "Language",
|
||||||
|
"settings.language.desc": "Interaction language preference",
|
||||||
|
|
||||||
|
// Video indicators
|
||||||
|
"video.observing": "👁️ Observing",
|
||||||
|
"video.listening": "🎤 Listening...",
|
||||||
|
"video.playing": "🔊 Playing...",
|
||||||
|
"video.initVad": "Initializing voice detection...",
|
||||||
|
"video.clickToStart": "Click the button below to start",
|
||||||
|
"video.placeholder": "Type in the chat panel to start",
|
||||||
|
"video.cameraOff": "Camera is off",
|
||||||
|
"video.cameraOff.hint": "You can type in the chat panel",
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
"controls.connecting": "Connecting...",
|
||||||
|
"controls.start": "🎙️ Start Session",
|
||||||
|
"controls.startVideo": "🎙️ Start Video Call",
|
||||||
|
"controls.cameraOff": "Turn off camera",
|
||||||
|
"controls.cameraOn": "Turn on camera",
|
||||||
|
"controls.micOff": "Turn off microphone",
|
||||||
|
"controls.micOn": "Turn on microphone",
|
||||||
|
"controls.observing": "👁️ Observing",
|
||||||
|
"controls.observation": "👁️ Observe",
|
||||||
|
"controls.interrupt": "⏹ Interrupt",
|
||||||
|
"controls.stop": "End Session",
|
||||||
|
|
||||||
|
// Chat panel
|
||||||
|
"chat.title": "Chat",
|
||||||
|
"chat.mode.observation": "Observing",
|
||||||
|
"chat.reconnecting": "Connection lost, reconnecting...",
|
||||||
|
"chat.empty.prompt": "Type below to start chatting",
|
||||||
|
"chat.empty.hint": "Type to chat with AI, or click the button on the left to start video",
|
||||||
|
"chat.welcome.prompt": "Type below to start chatting",
|
||||||
|
"chat.welcome.hint": "Or enable microphone for voice chat",
|
||||||
|
"chat.userLabel": "You",
|
||||||
|
"chat.input.placeholder": "Type a message...",
|
||||||
|
"chat.send": "Send",
|
||||||
|
|
||||||
|
// Session messages
|
||||||
|
"session.changeDetected": "👁️ Scene change detected",
|
||||||
|
"session.recognizing": "(Recognizing speech...)",
|
||||||
|
"session.noSpeech": "(No speech detected)",
|
||||||
|
"session.interrupted": "(Interrupted)",
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
"error.INVALID_MESSAGE": "Invalid message format, please retry",
|
||||||
|
"error.SESSION_NOT_FOUND": "Session expired, please reconnect",
|
||||||
|
"error.RATE_LIMITED": "Too many requests, please try again later",
|
||||||
|
"error.IMAGE_TOO_LARGE": "Image too large, please reduce resolution",
|
||||||
|
"error.AUDIO_TOO_SHORT": "Audio too short, please try again",
|
||||||
|
"error.LLM_TIMEOUT": "AI response timed out, please retry",
|
||||||
|
"error.LLM_ERROR": "AI service error, please try again later",
|
||||||
|
"error.STT_ERROR": "Speech recognition failed, please retry",
|
||||||
|
"error.TTS_ERROR": "Voice synthesis failed",
|
||||||
|
"error.INTERNAL_ERROR": "Internal server error, please retry",
|
||||||
|
"error.unknown": "Unknown error",
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
"sidebar.new": "New Chat",
|
||||||
|
"sidebar.expand": "Expand sidebar",
|
||||||
|
"sidebar.collapse": "Collapse sidebar",
|
||||||
|
"sidebar.empty": "No chat history",
|
||||||
|
"sidebar.messages": " messages",
|
||||||
|
"sidebar.rename": "Rename",
|
||||||
|
"sidebar.delete": "Delete",
|
||||||
|
|
||||||
|
// Device errors
|
||||||
|
"error.vadInit": "VAD initialization failed",
|
||||||
|
"error.cameraAccess": "Cannot access camera",
|
||||||
|
"error.micAccess": "Cannot access microphone",
|
||||||
|
};
|
||||||
47
frontend/src/lib/i18n/index.ts
Normal file
47
frontend/src/lib/i18n/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// ============================================================
|
||||||
|
// i18n — 轻量国际化模块
|
||||||
|
// 职责:提供翻译函数和 React Context,根据 locale 返回对应语言文本
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import { zhCN } from "./zh-CN";
|
||||||
|
import { enUS } from "./en-US";
|
||||||
|
import { jaJP } from "./ja-JP";
|
||||||
|
|
||||||
|
export type Locale = "zh-CN" | "en-US" | "ja-JP";
|
||||||
|
|
||||||
|
export type TranslationMap = Record<string, string>;
|
||||||
|
|
||||||
|
const translations: Record<Locale, TranslationMap> = {
|
||||||
|
"zh-CN": zhCN,
|
||||||
|
"en-US": enUS,
|
||||||
|
"ja-JP": jaJP,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 翻译函数:根据 key 和 locale 返回翻译文本 */
|
||||||
|
export function t(key: string, locale: Locale): string {
|
||||||
|
return translations[locale]?.[key] ?? translations["zh-CN"][key] ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 config.language 值解析为合法 Locale */
|
||||||
|
export function parseLocale(lang: string): Locale {
|
||||||
|
if (lang === "en-US" || lang === "ja-JP") return lang;
|
||||||
|
return "zh-CN";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- React Context ----
|
||||||
|
|
||||||
|
export interface I18nContextValue {
|
||||||
|
locale: Locale;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const I18nContext = createContext<I18nContextValue>({
|
||||||
|
locale: "zh-CN",
|
||||||
|
t: (key) => t(key, "zh-CN"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 组件内获取 i18n 的便捷 Hook */
|
||||||
|
export function useI18n() {
|
||||||
|
return useContext(I18nContext);
|
||||||
|
}
|
||||||
97
frontend/src/lib/i18n/ja-JP.ts
Normal file
97
frontend/src/lib/i18n/ja-JP.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { TranslationMap } from "./index";
|
||||||
|
|
||||||
|
export const jaJP: TranslationMap = {
|
||||||
|
// App header
|
||||||
|
"app.title": "AI ビジョンアシスタント",
|
||||||
|
"app.stats.requests": "リクエスト",
|
||||||
|
|
||||||
|
// Connection status
|
||||||
|
"status.connected": "接続済み",
|
||||||
|
"status.connecting": "接続中...",
|
||||||
|
"status.disconnected": "未接続",
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
"settings.title": "設定",
|
||||||
|
"settings.appearance": "外観",
|
||||||
|
"settings.theme": "テーマ",
|
||||||
|
"settings.theme.desc": "ライト/ダークテーマを切り替え",
|
||||||
|
"settings.theme.dark": "ダーク",
|
||||||
|
"settings.theme.light": "ライト",
|
||||||
|
"settings.session": "セッション",
|
||||||
|
"settings.tts": "音声応答",
|
||||||
|
"settings.tts.desc": "AI応答と同時に音声を再生",
|
||||||
|
"settings.detail": "画像品質",
|
||||||
|
"settings.detail.desc": "高品質は文字認識に最適、低品質は帯域節約",
|
||||||
|
"settings.detail.low": "低",
|
||||||
|
"settings.detail.high": "高",
|
||||||
|
"settings.language": "言語",
|
||||||
|
"settings.language.desc": "インタラクション言語の設定",
|
||||||
|
|
||||||
|
// Video indicators
|
||||||
|
"video.observing": "👁️ 観察中",
|
||||||
|
"video.listening": "🎤 聞き取り中...",
|
||||||
|
"video.playing": "🔊 再生中...",
|
||||||
|
"video.initVad": "音声検出を初期化中...",
|
||||||
|
"video.clickToStart": "下のボタンをクリックして開始",
|
||||||
|
"video.placeholder": "右側のチャットに入力して開始",
|
||||||
|
"video.cameraOff": "カメラがオフです",
|
||||||
|
"video.cameraOff.hint": "右側のチャットでテキスト対話ができます",
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
"controls.connecting": "接続中...",
|
||||||
|
"controls.start": "🎙️ 対話を開始",
|
||||||
|
"controls.startVideo": "🎙️ ビデオ通話を開始",
|
||||||
|
"controls.cameraOff": "カメラをオフ",
|
||||||
|
"controls.cameraOn": "カメラをオン",
|
||||||
|
"controls.micOff": "マイクをオフ",
|
||||||
|
"controls.micOn": "マイクをオン",
|
||||||
|
"controls.observing": "👁️ 観察中",
|
||||||
|
"controls.observation": "👁️ 観察モード",
|
||||||
|
"controls.interrupt": "⏹ 中断",
|
||||||
|
"controls.stop": "対話を終了",
|
||||||
|
|
||||||
|
// Chat panel
|
||||||
|
"chat.title": "チャット",
|
||||||
|
"chat.mode.observation": "観察モード",
|
||||||
|
"chat.reconnecting": "接続が切断されました。再接続中...",
|
||||||
|
"chat.empty.prompt": "下にテキストを入力して対話を開始",
|
||||||
|
"chat.empty.hint": "テキストでAIと対話、または左のボタンでビデオ通話を開始",
|
||||||
|
"chat.welcome.prompt": "下にテキストを入力して対話を開始",
|
||||||
|
"chat.welcome.hint": "マイクを有効にして音声対話もできます",
|
||||||
|
"chat.userLabel": "あなた",
|
||||||
|
"chat.input.placeholder": "メッセージを入力...",
|
||||||
|
"chat.send": "送信",
|
||||||
|
|
||||||
|
// Session messages
|
||||||
|
"session.changeDetected": "👁️ シーン変化を検出",
|
||||||
|
"session.recognizing": "(音声認識中...)",
|
||||||
|
"session.noSpeech": "(音声が検出されませんでした)",
|
||||||
|
"session.interrupted": "(中断済み)",
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
"error.INVALID_MESSAGE": "メッセージ形式が無効です。再試行してください",
|
||||||
|
"error.SESSION_NOT_FOUND": "セッションが期限切れです。再接続してください",
|
||||||
|
"error.RATE_LIMITED": "リクエストが多すぎます。しばらく待ってから再試行してください",
|
||||||
|
"error.IMAGE_TOO_LARGE": "画像が大きすぎます。解像度を下げてください",
|
||||||
|
"error.AUDIO_TOO_SHORT": "音声が短すぎます。もう一度お試しください",
|
||||||
|
"error.LLM_TIMEOUT": "AI応答がタイムアウトしました。再試行してください",
|
||||||
|
"error.LLM_ERROR": "AIサービスエラー。しばらく待ってから再試行してください",
|
||||||
|
"error.STT_ERROR": "音声認識に失敗しました。再試行してください",
|
||||||
|
"error.TTS_ERROR": "音声合成に失敗しました",
|
||||||
|
"error.INTERNAL_ERROR": "サーバー内部エラー。再試行してください",
|
||||||
|
"error.unknown": "不明なエラー",
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
"sidebar.new": "新しいチャット",
|
||||||
|
"sidebar.expand": "サイドバーを展開",
|
||||||
|
"sidebar.collapse": "サイドバーを折りたたむ",
|
||||||
|
"sidebar.empty": "会話履歴がありません",
|
||||||
|
"sidebar.messages": "件のメッセージ",
|
||||||
|
"sidebar.rename": "名前を変更",
|
||||||
|
"sidebar.delete": "削除",
|
||||||
|
|
||||||
|
// Device errors
|
||||||
|
"error.vadInit": "VAD初期化に失敗しました",
|
||||||
|
"error.cameraAccess": "カメラにアクセスできません",
|
||||||
|
"error.micAccess": "マイクにアクセスできません",
|
||||||
|
};
|
||||||
97
frontend/src/lib/i18n/zh-CN.ts
Normal file
97
frontend/src/lib/i18n/zh-CN.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { TranslationMap } from "./index";
|
||||||
|
|
||||||
|
export const zhCN: TranslationMap = {
|
||||||
|
// App header
|
||||||
|
"app.title": "AI 视觉对话助手",
|
||||||
|
"app.stats.requests": "次请求",
|
||||||
|
|
||||||
|
// Connection status
|
||||||
|
"status.connected": "已连接",
|
||||||
|
"status.connecting": "连接中...",
|
||||||
|
"status.disconnected": "未连接",
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
"settings.title": "设置",
|
||||||
|
"settings.appearance": "外观",
|
||||||
|
"settings.theme": "主题",
|
||||||
|
"settings.theme.desc": "切换明暗主题",
|
||||||
|
"settings.theme.dark": "深色",
|
||||||
|
"settings.theme.light": "浅色",
|
||||||
|
"settings.session": "会话",
|
||||||
|
"settings.tts": "语音回答",
|
||||||
|
"settings.tts.desc": "AI 回答时同步播放语音",
|
||||||
|
"settings.detail": "图像精度",
|
||||||
|
"settings.detail.desc": "高精度适合识别文字,低精度省流量",
|
||||||
|
"settings.detail.low": "低",
|
||||||
|
"settings.detail.high": "高",
|
||||||
|
"settings.language": "语言",
|
||||||
|
"settings.language.desc": "交互语言偏好",
|
||||||
|
|
||||||
|
// Video indicators
|
||||||
|
"video.observing": "👁️ 观察中",
|
||||||
|
"video.listening": "🎤 正在聆听...",
|
||||||
|
"video.playing": "🔊 正在播放...",
|
||||||
|
"video.initVad": "正在初始化语音检测...",
|
||||||
|
"video.clickToStart": "点击下方按钮开始对话",
|
||||||
|
"video.placeholder": "在右侧聊天框输入即可开始对话",
|
||||||
|
"video.cameraOff": "摄像头未开启",
|
||||||
|
"video.cameraOff.hint": "可在右侧聊天框打字对话",
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
"controls.connecting": "连接中...",
|
||||||
|
"controls.start": "🎙️ 开始对话",
|
||||||
|
"controls.startVideo": "🎙️ 开始视频通话",
|
||||||
|
"controls.cameraOff": "关闭摄像头",
|
||||||
|
"controls.cameraOn": "开启摄像头",
|
||||||
|
"controls.micOff": "关闭麦克风",
|
||||||
|
"controls.micOn": "开启麦克风",
|
||||||
|
"controls.observing": "👁️ 观察中",
|
||||||
|
"controls.observation": "👁️ 观察模式",
|
||||||
|
"controls.interrupt": "⏹ 打断",
|
||||||
|
"controls.stop": "结束对话",
|
||||||
|
|
||||||
|
// Chat panel
|
||||||
|
"chat.title": "对话",
|
||||||
|
"chat.mode.observation": "观察模式",
|
||||||
|
"chat.reconnecting": "连接已断开,正在重连...",
|
||||||
|
"chat.empty.prompt": "在下方输入文字开始对话",
|
||||||
|
"chat.empty.hint": "输入文字即可与 AI 交互,也可点击左侧按钮开启视频",
|
||||||
|
"chat.welcome.prompt": "在下方输入文字开始对话",
|
||||||
|
"chat.welcome.hint": "也可以开启麦克风用语音对话",
|
||||||
|
"chat.userLabel": "你",
|
||||||
|
"chat.input.placeholder": "输入文字对话...",
|
||||||
|
"chat.send": "发送",
|
||||||
|
|
||||||
|
// Session messages
|
||||||
|
"session.changeDetected": "👁️ 画面变化检测",
|
||||||
|
"session.recognizing": "(语音识别中...)",
|
||||||
|
"session.noSpeech": "(未识别到语音)",
|
||||||
|
"session.interrupted": "(已打断)",
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
"error.INVALID_MESSAGE": "消息格式异常,请重试",
|
||||||
|
"error.SESSION_NOT_FOUND": "会话已过期,请重新连接",
|
||||||
|
"error.RATE_LIMITED": "请求太频繁,请稍后再试",
|
||||||
|
"error.IMAGE_TOO_LARGE": "图像过大,请降低分辨率",
|
||||||
|
"error.AUDIO_TOO_SHORT": "语音太短,请再说一句",
|
||||||
|
"error.LLM_TIMEOUT": "AI 响应超时,请重试",
|
||||||
|
"error.LLM_ERROR": "AI 服务异常,请稍后重试",
|
||||||
|
"error.STT_ERROR": "语音识别失败,请重试",
|
||||||
|
"error.TTS_ERROR": "语音合成失败",
|
||||||
|
"error.INTERNAL_ERROR": "服务内部错误,请重试",
|
||||||
|
"error.unknown": "未知错误",
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
"sidebar.new": "新对话",
|
||||||
|
"sidebar.expand": "展开侧边栏",
|
||||||
|
"sidebar.collapse": "收起侧边栏",
|
||||||
|
"sidebar.empty": "暂无会话记录",
|
||||||
|
"sidebar.messages": "条消息",
|
||||||
|
"sidebar.rename": "重命名",
|
||||||
|
"sidebar.delete": "删除",
|
||||||
|
|
||||||
|
// Device errors
|
||||||
|
"error.vadInit": "VAD 初始化失败",
|
||||||
|
"error.cameraAccess": "无法访问摄像头",
|
||||||
|
"error.micAccess": "无法访问麦克风",
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// 职责:会话配置持久化
|
// 职责:会话配置持久化
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import type { SessionConfig, Theme } from "../types";
|
import type { ChatMessage, SessionConfig, SessionSummary, Theme } from "../types";
|
||||||
|
|
||||||
const CONFIG_KEY = "camtalk:config";
|
const CONFIG_KEY = "camtalk:config";
|
||||||
const THEME_KEY = "camtalk:theme";
|
const THEME_KEY = "camtalk:theme";
|
||||||
@@ -30,6 +30,8 @@ export function loadConfig(): SessionConfig {
|
|||||||
export function saveConfig(config: SessionConfig): void {
|
export function saveConfig(config: SessionConfig): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
||||||
|
// 派发自定义事件,通知 App 更新 locale
|
||||||
|
window.dispatchEvent(new CustomEvent("camtalk-config-changed"));
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage 不可用时静默失败
|
// localStorage 不可用时静默失败
|
||||||
}
|
}
|
||||||
@@ -50,3 +52,51 @@ export function saveTheme(theme: Theme): void {
|
|||||||
localStorage.setItem(THEME_KEY, theme);
|
localStorage.setItem(THEME_KEY, theme);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 会话历史存储 ----
|
||||||
|
|
||||||
|
const SESSIONS_KEY = "camtalk:sessions";
|
||||||
|
const SESSION_MSG_PREFIX = "camtalk:session:";
|
||||||
|
|
||||||
|
/** 加载所有会话摘要(按 lastActiveAt 倒序) */
|
||||||
|
export function loadSessionSummaries(): SessionSummary[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SESSIONS_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
return (JSON.parse(raw) as SessionSummary[]).sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存会话摘要列表 */
|
||||||
|
export function saveSessionSummaries(sessions: SessionSummary[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载单个会话的消息历史 */
|
||||||
|
export function loadSessionMessages(sessionId: string): ChatMessage[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SESSION_MSG_PREFIX + sessionId);
|
||||||
|
if (!raw) return [];
|
||||||
|
return JSON.parse(raw) as ChatMessage[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存单个会话的消息历史 */
|
||||||
|
export function saveSessionMessages(sessionId: string, messages: ChatMessage[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SESSION_MSG_PREFIX + sessionId, JSON.stringify(messages));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除单个会话的消息历史 */
|
||||||
|
export function deleteSessionMessages(sessionId: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(SESSION_MSG_PREFIX + sessionId);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export interface Session {
|
|||||||
config: SessionConfig;
|
config: SessionConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 会话摘要(侧边栏列表用) */
|
||||||
|
export interface SessionSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: number;
|
||||||
|
lastActiveAt: number;
|
||||||
|
messageCount: number;
|
||||||
|
preview: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 聊天消息 ----
|
// ---- 聊天消息 ----
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
|
|||||||
Reference in New Issue
Block a user