聊天框与会话历史栏优化 #91

Merged
cfy777 merged 4 commits from fix/language into develop 2026-06-14 15:46:41 +08:00
14 changed files with 418 additions and 71 deletions
Showing only changes of commit e95b1603c1 - Show all commits

View File

@@ -2,17 +2,20 @@
// CamTalk — 主应用组件Web 端双栏布局)
// ============================================================
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVisionSession } from "./hooks/useVisionSession";
import { VideoPreview } from "./components/VideoPreview";
import { ChatPanel } from "./components/ChatPanel";
import { ConfigPanel } from "./components/ConfigPanel";
import { ToastContainer } from "./components/Toast";
import { 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 "./App.css";
function App() {
/** 内部组件,确保在 I18nContext.Provider 内部使用 useVisionSession */
function AppContent() {
const [showConfig, setShowConfig] = useState(false);
const [theme, setTheme] = useState<Theme>(loadTheme);
const [elapsed, setElapsed] = useState(0);
@@ -88,28 +91,30 @@ function App() {
return () => clearInterval(id);
}, [isConnected]);
const { t: tr } = useMemo(() => ({ t: (key: string) => t(key, parseLocale(config.language)) }), [config.language]);
return (
<div className="app">
{/* ---- 顶部导航栏 ---- */}
<header className="header">
<div className="header__left">
<h1 className="header__title">CamTalk</h1>
<span className="header__subtitle">AI </span>
<span className="header__subtitle">{tr("app.title")}</span>
</div>
<div className="header__right">
{isConnected && (stats.queryCount > 0 || stats.totalTokens > 0) && (
<span className="header__stats">
{stats.queryCount} · {stats.totalTokens} tokens
{stats.queryCount} {tr("app.stats.requests")} · {stats.totalTokens} tokens
</span>
)}
<span className={`badge badge--${connectionStatus}`}>
{isConnected ? "已连接" : connectionStatus === "connecting" ? "连接中..." : "未连接"}
{isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")}
</span>
<button
className="btn-icon"
onClick={() => setShowConfig((v) => !v)}
title="设置"
aria-label="设置"
title={tr("settings.title")}
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">
<circle cx="12" cy="12" r="3" />
@@ -145,16 +150,16 @@ function App() {
<div className="detail-badge">HD</div>
)}
{isObserving && (
<div className="observation-badge">👁 </div>
<div className="observation-badge">{tr("video.observing")}</div>
)}
{isSpeaking && (
<div className="video-indicator">🎤 ...</div>
<div className="video-indicator">{tr("video.listening")}</div>
)}
{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 && (
<div className="video-indicator video-indicator--loading">...</div>
<div className="video-indicator video-indicator--loading">{tr("video.initVad")}</div>
)}
{vadError && (
<div className="video-indicator video-indicator--error"> {vadError}</div>
@@ -170,7 +175,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" />
<circle cx="12" cy="13" r="4" />
</svg>
<span></span>
<span>{tr("video.clickToStart")}</span>
</div>
)}
{isConnected && !isCameraOn && (
@@ -179,8 +184,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" />
<circle cx="12" cy="13" r="4" />
</svg>
<span></span>
<span className="video-placeholder__hint"></span>
<span>{tr("video.cameraOff")}</span>
<span className="video-placeholder__hint">{tr("video.cameraOff.hint")}</span>
</div>
)}
</div>
@@ -189,21 +194,21 @@ function App() {
<div className="video-controls">
{!isConnected ? (
<button className="btn btn--primary btn--lg" onClick={startSession}>
{connectionStatus === "connecting" ? "连接中..." : "🎙️ 开始对话"}
{connectionStatus === "connecting" ? tr("controls.connecting") : tr("controls.start")}
</button>
) : (
<div className="video-controls__row">
<button
className={`btn btn--ctrl ${isCameraOn ? "btn--ctrl-on" : "btn--ctrl-off"}`}
onClick={toggleCamera}
title={isCameraOn ? "关闭摄像头" : "开启摄像头"}
title={isCameraOn ? tr("controls.cameraOff") : tr("controls.cameraOn")}
>
📷
</button>
<button
className={`btn btn--ctrl ${isMicOn ? "btn--ctrl-on" : "btn--ctrl-off"} ${isSpeaking ? "btn--speaking" : ""}`}
onClick={toggleMic}
title={isMicOn ? "关闭麦克风" : "开启麦克风"}
title={isMicOn ? tr("controls.micOff") : tr("controls.micOn")}
>
🎤
</button>
@@ -211,15 +216,15 @@ function App() {
className={`btn ${mode === "observation" ? "btn--active" : "btn--secondary"}`}
onClick={toggleMode}
>
{mode === "observation" ? "👁️ 观察中" : "👁️ 观察模式"}
{mode === "observation" ? tr("controls.observing") : tr("controls.observation")}
</button>
{isProcessing && (
<button className="btn btn--warning" onClick={interrupt}>
{tr("controls.interrupt")}
</button>
)}
<button className="btn btn--danger" onClick={stopSession}>
{tr("controls.stop")}
</button>
</div>
)}
@@ -229,15 +234,15 @@ function App() {
{/* 右侧:聊天面板 */}
<div className="chat-panel-wrapper">
<div className="chat-panel-header">
<span></span>
<span>{tr("chat.title")}</span>
{isConnected && mode === "observation" && (
<span className="chat-panel-header__mode"></span>
<span className="chat-panel-header__mode">{tr("chat.mode.observation")}</span>
)}
</div>
<div className="chat-panel-body">
{connectionStatus === "disconnected" && messages.length > 0 && (
<div className="system-message system-message--warning">
...
{tr("chat.reconnecting")}
</div>
)}
<ChatPanel
@@ -255,4 +260,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;

View File

@@ -4,6 +4,7 @@
// ============================================================
import { useCallback, useRef, useState } from "react";
import { useI18n } from "../../lib/i18n";
export interface CameraManagerHandle {
/** 获取当前视频轨道 */
@@ -13,6 +14,7 @@ export interface CameraManagerHandle {
}
export function useCamera() {
const { t } = useI18n();
const videoRef = useRef<HTMLVideoElement>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -29,7 +31,7 @@ export function useCamera() {
}
setError(null);
} catch (err) {
const message = err instanceof Error ? err.message : "无法访问摄像头";
const message = err instanceof Error ? err.message : t("error.cameraAccess");
setError(message);
console.error("[Camera] 获取摄像头失败:", err);
}

View File

@@ -4,6 +4,7 @@
// ============================================================
import { useEffect, useRef, useState } from "react";
import { useI18n } from "../../lib/i18n";
import type { ChatMessage } from "../../types";
import type { ConnectionStatus } from "../../lib/websocket";
@@ -19,6 +20,7 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
const containerRef = useRef<HTMLDivElement>(null);
const isAutoScroll = useRef(true);
const [inputText, setInputText] = useState("");
const { t } = useI18n();
const isConnected = connectionStatus === "connected";
@@ -56,8 +58,8 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
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>
<p>{t("chat.empty.prompt")}</p>
<span className="chat-panel--empty-hint">{t("chat.empty.hint")}</span>
</div>
);
}
@@ -69,15 +71,15 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
{messages.length === 0 && !currentReply && (
<div className="chat-panel__welcome">
<span className="chat-panel__welcome-icon">💬</span>
<p></p>
<span className="chat-panel__welcome-hint"></span>
<p>{t("chat.welcome.prompt")}</p>
<span className="chat-panel__welcome-hint">{t("chat.welcome.hint")}</span>
</div>
)}
{messages.map((msg, index) => (
<div key={index} className={`chat-message chat-message--${msg.role}`}>
<div className="chat-message__role">
{msg.role === "user" ? "你" : "AI"}
{msg.role === "user" ? t("chat.userLabel") : "AI"}
</div>
<div className="chat-message__content">{msg.content}</div>
{msg.role === "assistant" && msg.tokensUsed !== undefined && (
@@ -110,7 +112,7 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
<input
type="text"
className="chat-input__field"
placeholder="输入文字对话..."
placeholder={t("chat.input.placeholder")}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
/>
@@ -118,7 +120,7 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
type="submit"
className="chat-input__send"
disabled={!inputText.trim()}
title="发送"
title={t("chat.send")}
>
</button>

View File

@@ -3,6 +3,7 @@
// 职责主题切换、TTS 开关、detail level 切换、语言选择
// ============================================================
import { useI18n } from "../../lib/i18n";
import type { SessionConfig, Theme } from "../../types";
interface ConfigPanelProps {
@@ -14,40 +15,42 @@ interface ConfigPanelProps {
}
export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }: ConfigPanelProps) {
const { t } = useI18n();
return (
<div className="drawer-overlay" onClick={onClose}>
<div className="drawer" onClick={(e) => e.stopPropagation()}>
<div className="drawer__header">
<span className="drawer__title"></span>
<span className="drawer__title">{t("settings.title")}</span>
<button className="drawer__close" onClick={onClose}></button>
</div>
<div className="drawer__body">
<div className="config-group">
<div className="config-group__title"></div>
<div className="config-group__title">{t("settings.appearance")}</div>
<label className="config-row">
<div className="config-row__info">
<span className="config-row__label"></span>
<span className="config-row__desc"></span>
<span className="config-row__label">{t("settings.theme")}</span>
<span className="config-row__desc">{t("settings.theme.desc")}</span>
</div>
<select
value={theme}
onChange={(e) => onThemeChange(e.target.value as Theme)}
>
<option value="dark"></option>
<option value="light"></option>
<option value="dark">{t("settings.theme.dark")}</option>
<option value="light">{t("settings.theme.light")}</option>
</select>
</label>
</div>
<div className="config-group">
<div className="config-group__title"></div>
<div className="config-group__title">{t("settings.session")}</div>
<label className="config-row">
<div className="config-row__info">
<span className="config-row__label"></span>
<span className="config-row__desc">AI </span>
<span className="config-row__label">{t("settings.tts")}</span>
<span className="config-row__desc">{t("settings.tts.desc")}</span>
</div>
<input
type="checkbox"
@@ -58,22 +61,22 @@ export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }:
<label className="config-row">
<div className="config-row__info">
<span className="config-row__label"></span>
<span className="config-row__desc"></span>
<span className="config-row__label">{t("settings.detail")}</span>
<span className="config-row__desc">{t("settings.detail.desc")}</span>
</div>
<select
value={config.detailLevel}
onChange={(e) => onUpdate({ detailLevel: e.target.value as "low" | "high" })}
>
<option value="low"></option>
<option value="high"></option>
<option value="low">{t("settings.detail.low")}</option>
<option value="high">{t("settings.detail.high")}</option>
</select>
</label>
<label className="config-row">
<div className="config-row__info">
<span className="config-row__label"></span>
<span className="config-row__desc"></span>
<span className="config-row__label">{t("settings.language")}</span>
<span className="config-row__desc">{t("settings.language.desc")}</span>
</div>
<select
value={config.language}

View File

@@ -6,6 +6,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { MicVAD } from "@ricky0123/vad-web";
import { useI18n } from "../../lib/i18n";
export interface VADOptions {
/** 语音结束回调,携带录音 Float32Array16kHz */
@@ -21,6 +22,7 @@ export interface VADOptions {
* 基于 @ricky0123/vad-web 的 MicVAD检测用户说话并回调
*/
export function useVAD(options?: VADOptions) {
const { t } = useI18n();
const [isSpeaking, setIsSpeaking] = useState(false);
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -79,7 +81,7 @@ export function useVAD(options?: VADOptions) {
setIsReady(true);
setError(null);
} catch (err) {
const message = err instanceof Error ? err.message : "VAD 初始化失败";
const message = err instanceof Error ? err.message : t("error.vadInit");
setError(message);
console.error("[VAD] 初始化失败:", err);
}

View File

@@ -4,8 +4,10 @@
// ============================================================
import { useCallback, useRef, useState } from "react";
import { useI18n } from "../../lib/i18n";
export function useMicrophone() {
const { t } = useI18n();
const [stream, setStream] = useState<MediaStream | null>(null);
const [error, setError] = useState<string | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
@@ -25,7 +27,7 @@ export function useMicrophone() {
setError(null);
return mediaStream;
} catch (err) {
const message = err instanceof Error ? err.message : "无法访问麦克风";
const message = err instanceof Error ? err.message : t("error.micAccess");
setError(message);
console.error("[Mic] 获取麦克风失败:", err);
return null;

View File

@@ -4,6 +4,7 @@
// ============================================================
import { forwardRef } from "react";
import { useI18n } from "../../lib/i18n";
interface VideoPreviewProps {
isStreaming: boolean;
@@ -11,6 +12,8 @@ interface VideoPreviewProps {
export const VideoPreview = forwardRef<HTMLVideoElement, VideoPreviewProps>(
function VideoPreview({ isStreaming }, ref) {
const { t } = useI18n();
return (
<div className="video-preview">
<video
@@ -22,7 +25,7 @@ export const VideoPreview = forwardRef<HTMLVideoElement, VideoPreviewProps>(
/>
{!isStreaming && (
<div className="video-preview__placeholder">
{t("video.cameraOff")}
</div>
)}
</div>

View File

@@ -12,6 +12,7 @@ import { getErrorMessage } from "../lib/errors";
import { TTSPlayer } from "../lib/ttsPlayer";
import { showToast } from "../lib/toast";
import { loadConfig, saveConfig } from "../lib/storage";
import { useI18n } from "../lib/i18n";
import { useCamera } from "../components/CameraManager";
import { useMicrophone } from "../components/MicManager";
import { useVAD, sampleFrame, compareFrames } from "../components/EdgeProcessor";
@@ -29,6 +30,7 @@ export interface SessionStats {
}
export function useVisionSession() {
const { t } = useI18n();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [currentReply, setCurrentReply] = useState<string>("");
const [isProcessing, setIsProcessing] = useState(false);
@@ -84,7 +86,7 @@ export function useVisionSession() {
...prev,
{
role: "user",
content: "👁️ 画面变化检测",
content: t("session.changeDetected"),
timestamp: Date.now(),
},
]);
@@ -199,7 +201,7 @@ export function useVisionSession() {
// 添加用户消息STT 流式结果会逐步更新文本)
setMessages((prev) => [
...prev,
{ role: "user", content: "(语音识别中...", timestamp: Date.now() },
{ role: "user", content: t("session.recognizing"), timestamp: Date.now() },
]);
setIsProcessing(true);
},
@@ -219,7 +221,7 @@ export function useVisionSession() {
if (lastUserIdx >= 0) {
updated[lastUserIdx] = {
...updated[lastUserIdx],
content: msg.text || "(未识别到语音)",
content: msg.text || t("session.noSpeech"),
};
}
return updated;
@@ -271,7 +273,7 @@ export function useVisionSession() {
case "error":
console.error("[Session] 服务端错误:", msg.code, msg.message);
showToast(getErrorMessage(msg.code), "error");
showToast(getErrorMessage(msg.code, t), "error");
setIsProcessing(false);
break;
}
@@ -359,7 +361,7 @@ export function useVisionSession() {
setIsAudioPlaying(false);
// 将未完成的流式内容保存为最终消息
if (currentReply) {
const interrupted = currentReply + "(已打断)";
const interrupted = currentReply + t("session.interrupted");
historyRef.current.push({ role: "assistant", content: interrupted });
setMessages((prev) => [
...prev,

View File

@@ -5,20 +5,13 @@
import type { ErrorCode } from "../types";
const ERROR_MESSAGES: Record<ErrorCode, string> = {
INVALID_MESSAGE: "消息格式异常,请重试",
SESSION_NOT_FOUND: "会话已过期,请重新连接",
RATE_LIMITED: "请求太频繁,请稍后再试",
IMAGE_TOO_LARGE: "图像过大,请降低分辨率",
AUDIO_TOO_SHORT: "语音太短,请再说一句",
LLM_TIMEOUT: "AI 响应超时,请重试",
LLM_ERROR: "AI 服务异常,请稍后重试",
STT_ERROR: "语音识别失败,请重试",
TTS_ERROR: "语音合成失败",
INTERNAL_ERROR: "服务内部错误,请重试",
};
/** 将错误码转为用户友好文案 */
export function getErrorMessage(code: string): string {
return ERROR_MESSAGES[code as ErrorCode] ?? `未知错误: ${code}`;
/** 将错误码转为用户友好文案(需要传入翻译函数) */
export function getErrorMessage(code: string, translate: (key: string) => string): string {
const key = `error.${code}`;
const translated = translate(key);
// 如果翻译函数返回 key 本身(未找到翻译),用 fallback
if (translated === key) {
return translate("error.unknown") + `: ${code}`;
}
return translated;
}

View File

@@ -0,0 +1,86 @@
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.cameraOff": "Camera is off",
"video.cameraOff.hint": "You can type in the chat panel",
// Controls
"controls.connecting": "Connecting...",
"controls.start": "🎙️ Start Session",
"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": "Click the button below to start",
"chat.empty.hint": "Type or speak with AI after connecting",
"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",
// Device errors
"error.vadInit": "VAD initialization failed",
"error.cameraAccess": "Cannot access camera",
"error.micAccess": "Cannot access microphone",
};

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

View File

@@ -0,0 +1,86 @@
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.cameraOff": "カメラがオフです",
"video.cameraOff.hint": "右側のチャットでテキスト対話ができます",
// Controls
"controls.connecting": "接続中...",
"controls.start": "🎙️ 対話を開始",
"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": "不明なエラー",
// Device errors
"error.vadInit": "VAD初期化に失敗しました",
"error.cameraAccess": "カメラにアクセスできません",
"error.micAccess": "マイクにアクセスできません",
};

View File

@@ -0,0 +1,86 @@
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.cameraOff": "摄像头未开启",
"video.cameraOff.hint": "可在右侧聊天框打字对话",
// Controls
"controls.connecting": "连接中...",
"controls.start": "🎙️ 开始对话",
"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": "未知错误",
// Device errors
"error.vadInit": "VAD 初始化失败",
"error.cameraAccess": "无法访问摄像头",
"error.micAccess": "无法访问麦克风",
};

View File

@@ -30,6 +30,8 @@ export function loadConfig(): SessionConfig {
export function saveConfig(config: SessionConfig): void {
try {
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
// 派发自定义事件,通知 App 更新 locale
window.dispatchEvent(new CustomEvent("camtalk-config-changed"));
} catch {
// localStorage 不可用时静默失败
}