style: 样式优化
This commit is contained in:
@@ -200,7 +200,7 @@ body {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ---- Workspace (双栏) ---- */
|
||||
/* ---- Workspace (双栏,侧边栏为 overlay 不在 flex 流中) ---- */
|
||||
|
||||
.workspace {
|
||||
flex: 1;
|
||||
@@ -208,30 +208,48 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- 会话历史侧边栏 ---- */
|
||||
/* ---- 会话历史侧边栏(Overlay 抽屉式) ---- */
|
||||
|
||||
.sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: rgba(10, 10, 15, 0.45);
|
||||
backdrop-filter: blur(3px);
|
||||
-webkit-backdrop-filter: blur(3px);
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
z-index: 51;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface-1);
|
||||
background: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
animation: slideInLeft 0.3s var(--transition-smooth);
|
||||
box-shadow: 8px 0 40px rgba(10, 10, 15, 0.4);
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.sidebar--collapsed {
|
||||
width: 48px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
gap: 8px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -245,6 +263,7 @@ body {
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__toggle:hover {
|
||||
@@ -258,9 +277,11 @@ body {
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
padding: 6px 10px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition-fast);
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sidebar__new-btn:hover {
|
||||
@@ -272,10 +293,38 @@ body {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Sidebar Search */
|
||||
.sidebar__search {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
font-size: 0.78rem;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar__search-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.sidebar__search-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Sidebar List */
|
||||
.sidebar__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.sidebar__empty {
|
||||
@@ -285,6 +334,17 @@ body {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* Time group header */
|
||||
.sidebar__group {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
padding: 12px 12px 6px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sidebar__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -309,6 +369,13 @@ body {
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.sidebar__item-icon {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -371,7 +438,7 @@ body {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-1);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
}
|
||||
@@ -395,6 +462,31 @@ body {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Sidebar footer */
|
||||
.sidebar__footer {
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar__clear-btn:hover {
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* ---- 左侧视频面板 ---- */
|
||||
|
||||
.video-panel {
|
||||
@@ -402,8 +494,8 @@ body {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
@@ -533,6 +625,32 @@ body {
|
||||
animation: pulse 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
||||
}
|
||||
|
||||
/* AI Vision Indicator (eye icon in top-right of video) */
|
||||
.ai-vision-indicator {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: rgba(10, 10, 15, 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.ai-vision-indicator--active {
|
||||
color: var(--color-success);
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
animation: pulse 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -558,8 +676,10 @@ body {
|
||||
.video-controls {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.video-controls__row {
|
||||
@@ -645,6 +765,12 @@ body {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.chat-panel-header__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-panel-header__mode {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
@@ -655,6 +781,17 @@ body {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.chat-panel-header__stats {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
padding: 3px 10px;
|
||||
background: var(--color-surface-2);
|
||||
border-radius: 12px;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-panel-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
@@ -1178,3 +1315,222 @@ body {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ---- Enhanced Video Controls (3-layer) ---- */
|
||||
|
||||
.video-controls__toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn--recognize {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||
padding: 7px 14px;
|
||||
font-size: 0.78rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn--recognize:hover {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.btn--recognize:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* Device selectors */
|
||||
.video-controls__devices {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.device-select-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.device-select-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.device-select {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.72rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.device-select:hover {
|
||||
border-color: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.device-select:focus {
|
||||
outline: 2px solid rgba(59, 130, 246, 0.3);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Mode switcher */
|
||||
.video-controls__mode {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--color-surface-2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mode-btn--active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ---- Scene Cards (empty state) ---- */
|
||||
|
||||
.scene-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 24px;
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scene-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.scene-card:hover {
|
||||
background: var(--color-surface-3);
|
||||
border-color: var(--color-surface-3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.scene-card:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.scene-card__icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.scene-card__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.scene-card__title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.scene-card__desc {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ---- Voice Input Button ---- */
|
||||
|
||||
.chat-input__voice {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input__voice:hover {
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.chat-input__voice--active {
|
||||
background: rgba(52, 211, 153, 0.12);
|
||||
color: var(--color-success);
|
||||
border-color: rgba(52, 211, 153, 0.3);
|
||||
animation: micPulse 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
||||
}
|
||||
|
||||
/* ---- Header sidebar toggle ---- */
|
||||
|
||||
.header__menu-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.header__menu-btn:hover {
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.header__menu-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// ============================================================
|
||||
// CamTalk — 主应用组件(Web 端三栏布局:侧边栏 + 视频 + 聊天)
|
||||
// CamTalk — 主应用组件(Web 端两栏布局:视频 + 聊天)
|
||||
// 侧边栏为 overlay 抽屉式,不挤压主界面空间
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -16,12 +17,16 @@ import type { Locale } from "./lib/i18n";
|
||||
import type { Theme } from "./types";
|
||||
import "./App.css";
|
||||
|
||||
/** AI 视觉模式 */
|
||||
type VisionMode = "realtime" | "ondemand" | "chat";
|
||||
|
||||
/** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */
|
||||
function AppContent() {
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [theme, setTheme] = useState<Theme>(loadTheme);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [visionMode, setVisionMode] = useState<VisionMode>("ondemand");
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// 切换主题时更新 <html> 的 data-theme 属性
|
||||
@@ -137,6 +142,7 @@ function AppContent() {
|
||||
if (connectionStatus === "connected") {
|
||||
stopSession();
|
||||
}
|
||||
setSidebarOpen(false);
|
||||
}, [createSession, setMessages, connectionStatus, stopSession]);
|
||||
|
||||
const handleSelectSession = useCallback((id: string) => {
|
||||
@@ -160,20 +166,56 @@ function AppContent() {
|
||||
}
|
||||
}, [deleteSession, activeSessionId, setMessages]);
|
||||
|
||||
// ---- 识别画面 ----
|
||||
const handleRecognize = useCallback(() => {
|
||||
if (isProcessing) return;
|
||||
sendTextMessage(tr("controls.recognize"));
|
||||
}, [isProcessing, sendTextMessage, tr]);
|
||||
|
||||
// ---- 场景卡片点击 ----
|
||||
const handleSceneCard = useCallback((prompt: string) => {
|
||||
sendTextMessage(prompt);
|
||||
}, [sendTextMessage]);
|
||||
|
||||
// ---- 键盘快捷键 ----
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
// Cmd/Ctrl+H: 打开/关闭历史侧边栏
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "h") {
|
||||
e.preventDefault();
|
||||
setSidebarOpen((v) => !v);
|
||||
}
|
||||
// Escape: 关闭侧边栏
|
||||
if (e.key === "Escape" && sidebarOpen) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [sidebarOpen]);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* ---- 顶部导航栏 ---- */}
|
||||
<header className="header">
|
||||
<div className="header__left">
|
||||
{/* 侧边栏开关 */}
|
||||
<button
|
||||
className="header__menu-btn"
|
||||
onClick={() => setSidebarOpen((v) => !v)}
|
||||
title={tr("sidebar.expand")}
|
||||
aria-label={tr("sidebar.expand")}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="header__title">CamTalk</h1>
|
||||
<span className="header__subtitle">{tr("app.title")}</span>
|
||||
</div>
|
||||
<div className="header__right">
|
||||
{isConnected && stats.queryCount > 0 && (
|
||||
<span className="header__stats">
|
||||
{stats.queryCount} {tr("app.stats.requests")}
|
||||
</span>
|
||||
)}
|
||||
<span className={`badge badge--${connectionStatus}`}>
|
||||
{isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")}
|
||||
</span>
|
||||
@@ -191,6 +233,18 @@ function AppContent() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ---- 会话历史侧边栏(Overlay 抽屉式) ---- */}
|
||||
<SessionSidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
open={sidebarOpen}
|
||||
onToggle={() => setSidebarOpen((v) => !v)}
|
||||
onNewSession={handleNewSession}
|
||||
onSelectSession={handleSelectSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onRenameSession={renameSession}
|
||||
/>
|
||||
|
||||
{showConfig && (
|
||||
<ConfigPanel
|
||||
config={config}
|
||||
@@ -201,21 +255,9 @@ function AppContent() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ---- 主体:侧边栏 + 视频 + 聊天 ---- */}
|
||||
{/* ---- 主体:视频 + 聊天(两栏) ---- */}
|
||||
<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-container">
|
||||
<VideoPreview ref={videoRef} isStreaming={!!stream} />
|
||||
@@ -228,8 +270,15 @@ function AppContent() {
|
||||
{config.detailLevel === "high" && (
|
||||
<div className="detail-badge">HD</div>
|
||||
)}
|
||||
{isObserving && (
|
||||
<div className="observation-badge">{tr("video.observing")}</div>
|
||||
{/* AI 视觉状态指示 */}
|
||||
{isConnected && visionMode !== "chat" && (
|
||||
<div className={`ai-vision-indicator ${isObserving ? "ai-vision-indicator--active" : ""}`}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<span>{isObserving ? tr("video.observing") : "AI"}</span>
|
||||
</div>
|
||||
)}
|
||||
{isSpeaking && (
|
||||
<div className="video-indicator">{tr("video.listening")}</div>
|
||||
@@ -269,37 +318,110 @@ function AppContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 视频下方控制栏 */}
|
||||
{/* 视频下方控制区(三层结构) */}
|
||||
<div className="video-controls">
|
||||
{!isConnected ? (
|
||||
<button className="btn btn--primary btn--lg" onClick={startSession}>
|
||||
{connectionStatus === "connecting" ? tr("controls.connecting") : tr("controls.startVideo")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="video-controls__row">
|
||||
<button
|
||||
className={`btn btn--ctrl ${isCameraOn ? "btn--ctrl-on" : "btn--ctrl-off"}`}
|
||||
onClick={toggleCamera}
|
||||
title={isCameraOn ? tr("controls.cameraOff") : tr("controls.cameraOn")}
|
||||
>
|
||||
📷
|
||||
<>
|
||||
{/* 未连接态:主 CTA */}
|
||||
<button className="btn btn--primary btn--lg" onClick={startSession}>
|
||||
{connectionStatus === "connecting" ? tr("controls.connecting") : tr("controls.startVideo")}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn--ctrl ${isMicOn ? "btn--ctrl-on" : "btn--ctrl-off"} ${isSpeaking ? "btn--speaking" : ""}`}
|
||||
onClick={toggleMic}
|
||||
title={isMicOn ? tr("controls.micOff") : tr("controls.micOn")}
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
{isProcessing && (
|
||||
<button className="btn btn--warning" onClick={interrupt}>
|
||||
{tr("controls.interrupt")}
|
||||
{/* 设备选择器 */}
|
||||
<div className="video-controls__devices">
|
||||
<div className="device-select-wrapper">
|
||||
<label className="device-select-label">📷 {tr("controls.device.camera")}</label>
|
||||
<select className="device-select" defaultValue="default">
|
||||
<option value="default">{tr("controls.device.default")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="device-select-wrapper">
|
||||
<label className="device-select-label">🎤 {tr("controls.device.mic")}</label>
|
||||
<select className="device-select" defaultValue="default">
|
||||
<option value="default">{tr("controls.device.default")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 模式切换器 */}
|
||||
<div className="video-controls__mode">
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "realtime" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("realtime")}
|
||||
>
|
||||
{tr("controls.mode.realtime")}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn--danger" onClick={stopSession}>
|
||||
{tr("controls.stop")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "ondemand" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("ondemand")}
|
||||
>
|
||||
{tr("controls.mode.ondemand")}
|
||||
</button>
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "chat" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("chat")}
|
||||
>
|
||||
{tr("controls.mode.chat")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 通话态:核心控制工具栏 */}
|
||||
<div className="video-controls__toolbar">
|
||||
<button
|
||||
className={`btn btn--ctrl ${isCameraOn ? "btn--ctrl-on" : "btn--ctrl-off"}`}
|
||||
onClick={toggleCamera}
|
||||
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 ? tr("controls.micOff") : tr("controls.micOn")}
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
{/* 识别画面按钮(按需模式下显示) */}
|
||||
{visionMode === "ondemand" && (
|
||||
<button
|
||||
className="btn--recognize"
|
||||
onClick={handleRecognize}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
🔍 {tr("controls.recognize")}
|
||||
</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>
|
||||
{/* 通话态模式切换 */}
|
||||
<div className="video-controls__mode">
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "realtime" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("realtime")}
|
||||
>
|
||||
{tr("controls.mode.realtime")}
|
||||
</button>
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "ondemand" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("ondemand")}
|
||||
>
|
||||
{tr("controls.mode.ondemand")}
|
||||
</button>
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "chat" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("chat")}
|
||||
>
|
||||
{tr("controls.mode.chat")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,9 +430,18 @@ function AppContent() {
|
||||
<div className="chat-panel-wrapper">
|
||||
<div className="chat-panel-header">
|
||||
<span>{tr("chat.title")}</span>
|
||||
{isConnected && mode === "observation" && (
|
||||
<span className="chat-panel-header__mode">{tr("chat.mode.observation")}</span>
|
||||
)}
|
||||
<div className="chat-panel-header__right">
|
||||
{isConnected && mode === "observation" && (
|
||||
<span className="chat-panel-header__mode">{tr("chat.mode.observation")}</span>
|
||||
)}
|
||||
{isConnected && stats.queryCount > 0 && (
|
||||
<span className="chat-panel-header__stats">
|
||||
{stats.queryCount} {tr("statusbar.recognitions")}
|
||||
{stats.totalTokens > 0 && ` · ${stats.totalTokens.toLocaleString()} ${tr("statusbar.tokens")}`}
|
||||
{` · ${formatTime(elapsed)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-panel-body">
|
||||
{connectionStatus === "disconnected" && messages.length > 0 && (
|
||||
@@ -322,7 +453,11 @@ function AppContent() {
|
||||
messages={messages}
|
||||
currentReply={currentReply}
|
||||
connectionStatus={connectionStatus}
|
||||
isMicOn={isMicOn}
|
||||
isSpeaking={isSpeaking}
|
||||
onSendText={sendTextMessage}
|
||||
onToggleMic={toggleMic}
|
||||
onSceneCard={handleSceneCard}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// ============================================================
|
||||
// ChatPanel — 消息展示面板
|
||||
// 职责:渲染对话消息列表、流式光标、元数据、自动滚动、文本输入
|
||||
// 增强:空状态场景卡片、语音输入按钮
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -12,10 +13,33 @@ interface ChatPanelProps {
|
||||
messages: ChatMessage[];
|
||||
currentReply?: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
isMicOn?: boolean;
|
||||
isSpeaking?: boolean;
|
||||
onSendText?: (text: string) => void;
|
||||
onToggleMic?: () => void;
|
||||
onSceneCard?: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export function ChatPanel({ messages, currentReply, connectionStatus, onSendText }: ChatPanelProps) {
|
||||
/** 场景卡片数据 */
|
||||
function getSceneCards(t: (key: string) => string) {
|
||||
return [
|
||||
{ icon: "👁", titleKey: "scene.describe", descKey: "scene.describe.desc", prompt: t("scene.describe") },
|
||||
{ icon: "🔤", titleKey: "scene.text", descKey: "scene.text.desc", prompt: t("scene.text") },
|
||||
{ icon: "📦", titleKey: "scene.object", descKey: "scene.object.desc", prompt: t("scene.object") },
|
||||
{ icon: "💡", titleKey: "scene.suggest", descKey: "scene.suggest.desc", prompt: t("scene.suggest") },
|
||||
];
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
messages,
|
||||
currentReply,
|
||||
connectionStatus,
|
||||
isMicOn,
|
||||
isSpeaking,
|
||||
onSendText,
|
||||
onToggleMic,
|
||||
onSceneCard,
|
||||
}: ChatPanelProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isAutoScroll = useRef(true);
|
||||
@@ -23,6 +47,8 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
||||
const { t } = useI18n();
|
||||
|
||||
const isConnected = connectionStatus === "connected";
|
||||
const isEmpty = messages.length === 0 && !currentReply;
|
||||
const sceneCards = getSceneCards(t);
|
||||
|
||||
// 用户上滚时暂停自动滚动,滚到底部时恢复
|
||||
useEffect(() => {
|
||||
@@ -53,15 +79,41 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
||||
setInputText("");
|
||||
};
|
||||
|
||||
// 点击场景卡片
|
||||
const handleSceneCard = (prompt: string) => {
|
||||
if (onSceneCard) {
|
||||
onSceneCard(prompt);
|
||||
} else if (onSendText) {
|
||||
onSendText(prompt);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-panel__messages" ref={containerRef}>
|
||||
{/* 空状态提示 */}
|
||||
{messages.length === 0 && !currentReply && (
|
||||
{/* 空状态:场景卡片 */}
|
||||
{isEmpty && (
|
||||
<div className="chat-panel__welcome">
|
||||
<span className="chat-panel__welcome-icon">💬</span>
|
||||
<p>{isConnected ? t("chat.welcome.prompt") : t("chat.empty.prompt")}</p>
|
||||
<span className="chat-panel__welcome-hint">{isConnected ? t("chat.welcome.hint") : t("chat.empty.hint")}</span>
|
||||
|
||||
{/* 场景快捷卡片 */}
|
||||
<div className="scene-cards">
|
||||
{sceneCards.map((card) => (
|
||||
<button
|
||||
key={card.titleKey}
|
||||
className="scene-card"
|
||||
onClick={() => handleSceneCard(card.prompt)}
|
||||
>
|
||||
<span className="scene-card__icon">{card.icon}</span>
|
||||
<div className="scene-card__text">
|
||||
<span className="scene-card__title">{t(card.titleKey)}</span>
|
||||
<span className="scene-card__desc">{t(card.descKey)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -106,6 +158,17 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
disabled={connectionStatus === "connecting"}
|
||||
/>
|
||||
{/* 语音输入按钮 */}
|
||||
{onToggleMic && (
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-input__voice ${isMicOn ? "chat-input__voice--active" : ""} ${isSpeaking ? "btn--speaking" : ""}`}
|
||||
onClick={onToggleMic}
|
||||
title={t("chat.input.voice")}
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="chat-input__send"
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
// ============================================================
|
||||
// SessionSidebar — 会话历史侧边栏
|
||||
// 职责:展示会话列表、新建/切换/删除/重命名会话
|
||||
// SessionSidebar — 会话历史侧边栏(Overlay 抽屉式)
|
||||
// 职责:展示会话列表、搜索、新建/切换/删除/重命名会话
|
||||
// 设计:overlay 覆盖在主界面之上,不挤压主界面空间
|
||||
// ============================================================
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import type { SessionSummary } from "../../types";
|
||||
|
||||
interface SessionSidebarProps {
|
||||
sessions: SessionSummary[];
|
||||
activeSessionId: string | null;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onNewSession: () => void;
|
||||
onSelectSession: (id: string) => void;
|
||||
onDeleteSession: (id: string) => void;
|
||||
@@ -35,11 +36,27 @@ function formatRelativeTime(ts: number): string {
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
|
||||
/** 判断两个时间戳是否为同一天 */
|
||||
function isSameDay(ts: number, ref: Date): boolean {
|
||||
const d = new Date(ts);
|
||||
return d.getFullYear() === ref.getFullYear() &&
|
||||
d.getMonth() === ref.getMonth() &&
|
||||
d.getDate() === ref.getDate();
|
||||
}
|
||||
|
||||
type TimeGroup = "today" | "yesterday" | "earlier";
|
||||
|
||||
function getTimeGroup(ts: number, today: Date, yesterday: Date): TimeGroup {
|
||||
if (isSameDay(ts, today)) return "today";
|
||||
if (isSameDay(ts, yesterday)) return "yesterday";
|
||||
return "earlier";
|
||||
}
|
||||
|
||||
export function SessionSidebar({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
open,
|
||||
onToggle,
|
||||
onNewSession,
|
||||
onSelectSession,
|
||||
onDeleteSession,
|
||||
@@ -48,6 +65,7 @@ export function SessionSidebar({
|
||||
const { t } = useI18n();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const handleStartRename = (id: string, currentTitle: string) => {
|
||||
setEditingId(id);
|
||||
@@ -67,94 +85,151 @@ export function SessionSidebar({
|
||||
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>
|
||||
);
|
||||
}
|
||||
const handleSelect = (id: string) => {
|
||||
onSelectSession(id);
|
||||
// 选择后自动收起侧边栏
|
||||
onToggle();
|
||||
};
|
||||
|
||||
// 过滤 + 分组
|
||||
const grouped = useMemo(() => {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const filtered = searchQuery.trim()
|
||||
? sessions.filter((s) =>
|
||||
s.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.preview.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: sessions;
|
||||
|
||||
const groups: Record<TimeGroup, SessionSummary[]> = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
earlier: [],
|
||||
};
|
||||
|
||||
for (const session of filtered) {
|
||||
const group = getTimeGroup(session.lastActiveAt, today, yesterday);
|
||||
groups[group].push(session);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [sessions, searchQuery]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const groupLabels: Record<TimeGroup, string> = {
|
||||
today: t("sidebar.today"),
|
||||
yesterday: t("sidebar.yesterday"),
|
||||
earlier: t("sidebar.earlier"),
|
||||
};
|
||||
|
||||
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-backdrop" onClick={onToggle} />
|
||||
<div className="sidebar">
|
||||
{/* 头部:新建 + 收起 */}
|
||||
<div className="sidebar__header">
|
||||
<button className="sidebar__new-btn" onClick={onNewSession}>
|
||||
✚ {t("sidebar.new")}
|
||||
</button>
|
||||
<button className="sidebar__toggle" onClick={onToggle} 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>
|
||||
{/* 搜索栏 */}
|
||||
<div className="sidebar__search">
|
||||
<input
|
||||
className="sidebar__search-input"
|
||||
type="text"
|
||||
placeholder={t("sidebar.search")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 会话列表(按时间分组) */}
|
||||
<div className="sidebar__list">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="sidebar__empty">{t("sidebar.empty")}</div>
|
||||
) : (
|
||||
(["today", "yesterday", "earlier"] as TimeGroup[]).map((groupKey) => {
|
||||
const items = grouped[groupKey];
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={groupKey}>
|
||||
<div className="sidebar__group">{groupLabels[groupKey]}</div>
|
||||
{items.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`sidebar__item ${session.id === activeSessionId ? "sidebar__item--active" : ""}`}
|
||||
onClick={() => handleSelect(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>
|
||||
) : (
|
||||
<>
|
||||
{/* 视频标记图标 */}
|
||||
{session.messageCount > 5 && (
|
||||
<span className="sidebar__item-icon" title={t("sidebar.video")}>📹</span>
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -482,5 +482,6 @@ export function useVisionSession() {
|
||||
toggleCamera,
|
||||
toggleMic,
|
||||
sendTextMessage,
|
||||
captureFrame,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,6 +89,42 @@ export const enUS: TranslationMap = {
|
||||
"sidebar.messages": " messages",
|
||||
"sidebar.rename": "Rename",
|
||||
"sidebar.delete": "Delete",
|
||||
"sidebar.search": "Search conversations...",
|
||||
"sidebar.today": "Today",
|
||||
"sidebar.yesterday": "Yesterday",
|
||||
"sidebar.earlier": "Earlier",
|
||||
"sidebar.pinned": "Pinned",
|
||||
"sidebar.video": "Video",
|
||||
"sidebar.clearHistory": "Clear History",
|
||||
|
||||
// Scene cards (empty state)
|
||||
"scene.describe": "Describe my surroundings",
|
||||
"scene.describe.desc": "Let AI observe and describe the current scene",
|
||||
"scene.text": "Recognize text in view",
|
||||
"scene.text.desc": "Extract and translate text from the scene",
|
||||
"scene.object": "Analyze this object",
|
||||
"scene.object.desc": "Identify objects and provide information",
|
||||
"scene.suggest": "Give me suggestions",
|
||||
"scene.suggest.desc": "Practical suggestions based on the current scene",
|
||||
|
||||
// Video controls (enhanced)
|
||||
"controls.recognize": "Analyze Scene",
|
||||
"controls.device.camera": "Camera",
|
||||
"controls.device.mic": "Microphone",
|
||||
"controls.device.default": "Default",
|
||||
"controls.mode.realtime": "Realtime",
|
||||
"controls.mode.ondemand": "On-demand",
|
||||
"controls.mode.chat": "Chat only",
|
||||
|
||||
// Status bar
|
||||
"statusbar.ready": "Ready · Select devices to start",
|
||||
"statusbar.calling": "In call",
|
||||
"statusbar.duration": "Duration",
|
||||
"statusbar.recognitions": "recognitions",
|
||||
"statusbar.tokens": "Tokens",
|
||||
|
||||
// Input enhanced
|
||||
"chat.input.voice": "Voice input",
|
||||
|
||||
// Device errors
|
||||
"error.vadInit": "VAD initialization failed",
|
||||
|
||||
@@ -89,6 +89,42 @@ export const jaJP: TranslationMap = {
|
||||
"sidebar.messages": "件のメッセージ",
|
||||
"sidebar.rename": "名前を変更",
|
||||
"sidebar.delete": "削除",
|
||||
"sidebar.search": "会話を検索...",
|
||||
"sidebar.today": "今日",
|
||||
"sidebar.yesterday": "昨日",
|
||||
"sidebar.earlier": "それ以前",
|
||||
"sidebar.pinned": "ピン留め",
|
||||
"sidebar.video": "ビデオ",
|
||||
"sidebar.clearHistory": "履歴をクリア",
|
||||
|
||||
// Scene cards (empty state)
|
||||
"scene.describe": "周囲のシーンを説明して",
|
||||
"scene.describe.desc": "AIが現在のシーンを観察して説明します",
|
||||
"scene.text": "画面のテキストを認識",
|
||||
"scene.text.desc": "シーン内のテキストを抽出・翻訳します",
|
||||
"scene.object": "この物を分析して",
|
||||
"scene.object.desc": "物体を識別して関連情報を提供します",
|
||||
"scene.suggest": "アドバイスをください",
|
||||
"scene.suggest.desc": "現在のシーンに基づいた実用的な提案",
|
||||
|
||||
// Video controls (enhanced)
|
||||
"controls.recognize": "シーンを分析",
|
||||
"controls.device.camera": "カメラ",
|
||||
"controls.device.mic": "マイク",
|
||||
"controls.device.default": "デフォルト",
|
||||
"controls.mode.realtime": "リアルタイム",
|
||||
"controls.mode.ondemand": "オンデマンド",
|
||||
"controls.mode.chat": "チャットのみ",
|
||||
|
||||
// Status bar
|
||||
"statusbar.ready": "準備完了 · デバイスを選択して開始",
|
||||
"statusbar.calling": "通話中",
|
||||
"statusbar.duration": "通話時間",
|
||||
"statusbar.recognitions": "回の認識",
|
||||
"statusbar.tokens": "トークン",
|
||||
|
||||
// Input enhanced
|
||||
"chat.input.voice": "音声入力",
|
||||
|
||||
// Device errors
|
||||
"error.vadInit": "VAD初期化に失敗しました",
|
||||
|
||||
@@ -89,6 +89,42 @@ export const zhCN: TranslationMap = {
|
||||
"sidebar.messages": "条消息",
|
||||
"sidebar.rename": "重命名",
|
||||
"sidebar.delete": "删除",
|
||||
"sidebar.search": "搜索对话...",
|
||||
"sidebar.today": "今天",
|
||||
"sidebar.yesterday": "昨天",
|
||||
"sidebar.earlier": "更早",
|
||||
"sidebar.pinned": "置顶",
|
||||
"sidebar.video": "视频",
|
||||
"sidebar.clearHistory": "清除历史",
|
||||
|
||||
// Scene cards (empty state)
|
||||
"scene.describe": "描述我面前的场景",
|
||||
"scene.describe.desc": "让 AI 观察并描述当前画面",
|
||||
"scene.text": "识别画面中的文字",
|
||||
"scene.text.desc": "提取并翻译画面里的文字",
|
||||
"scene.object": "帮我分析这个物品",
|
||||
"scene.object.desc": "识别物体并给出相关信息",
|
||||
"scene.suggest": "给我一些建议",
|
||||
"scene.suggest.desc": "基于当前场景给出实用建议",
|
||||
|
||||
// Video controls (enhanced)
|
||||
"controls.recognize": "识别画面",
|
||||
"controls.device.camera": "摄像头",
|
||||
"controls.device.mic": "麦克风",
|
||||
"controls.device.default": "默认",
|
||||
"controls.mode.realtime": "实时分析",
|
||||
"controls.mode.ondemand": "按需识别",
|
||||
"controls.mode.chat": "纯聊天",
|
||||
|
||||
// Status bar
|
||||
"statusbar.ready": "就绪 · 选择设备后开始通话",
|
||||
"statusbar.calling": "通话中",
|
||||
"statusbar.duration": "通话时长",
|
||||
"statusbar.recognitions": "次识别",
|
||||
"statusbar.tokens": "Token",
|
||||
|
||||
// Input enhanced
|
||||
"chat.input.voice": "语音输入",
|
||||
|
||||
// Device errors
|
||||
"error.vadInit": "VAD 初始化失败",
|
||||
|
||||
Reference in New Issue
Block a user