From 2a7d4c74d4ebade550a93f93a30853c00eb7573e Mon Sep 17 00:00:00 2001 From: cfy666 <3087823110@qq.com> Date: Sat, 20 Jun 2026 17:35:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat=EF=BC=9A=E5=A2=9E=E5=8A=A0=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E7=99=BB=E5=BD=95=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + frontend/index.html | 7 +- frontend/src/App.tsx | 4 +- .../components/LandingPage/LandingPage.css | 1183 +++++++++++++++++ .../src/components/LandingPage/LoginModal.tsx | 163 +++ frontend/src/components/LandingPage/index.tsx | 390 ++++++ 6 files changed, 1744 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/LandingPage/LandingPage.css create mode 100644 frontend/src/components/LandingPage/LoginModal.tsx create mode 100644 frontend/src/components/LandingPage/index.tsx diff --git a/.gitignore b/.gitignore index 4bb1e75..2427d71 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ Thumbs.db # ---- Obsidian ---- .obsidian/ +.claudian/sessions/conv-1781943335504-q62bzosye.meta.json diff --git a/frontend/index.html b/frontend/index.html index bbbb74b..babf80e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,8 +4,11 @@ - - CamTalk — AI 视觉对话助手 + + CamTalk - AI 视觉对话助手 + + +
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0c3d1d8..73a7906 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,7 @@ import { ChatPanel } from "./components/ChatPanel"; import { ConfigPanel } from "./components/ConfigPanel"; import { SessionSidebar } from "./components/SessionSidebar"; import { ToastContainer } from "./components/Toast"; -import { AuthPage } from "./components/AuthPage"; +import { LandingPage } from "./components/LandingPage"; import { AuthProvider, useAuth } from "./lib/auth"; import { loadConfig, loadTheme, saveTheme } from "./lib/storage"; import { I18nContext, parseLocale, t } from "./lib/i18n"; @@ -232,7 +232,7 @@ function AppContent() { } if (!isAuthenticated) { - return ; + return ; } return ( diff --git a/frontend/src/components/LandingPage/LandingPage.css b/frontend/src/components/LandingPage/LandingPage.css new file mode 100644 index 0000000..b162ecf --- /dev/null +++ b/frontend/src/components/LandingPage/LandingPage.css @@ -0,0 +1,1183 @@ +/* ============================================================ + LandingPage — Daylight Redesign + Typeface: Outfit + Noto Sans SC | Accent: blue | Bg: warm light + Motion: custom cubic-bezier, reduced-motion aware + ============================================================ */ + +/* ========== Root Tokens ========== */ +.landing-page { + --bg-base: #f6f7f9; + --bg-surface: #ffffff; + --bg-elevated: #f0f2f5; + --bg-overlay: rgba(255, 255, 255, 0.92); + + --border-subtle: rgba(0, 0, 0, 0.06); + --border-hover: rgba(0, 0, 0, 0.12); + + --accent: #2563eb; + --accent-dim: rgba(37, 99, 235, 0.08); + --accent-hover: #1d4ed8; + + --text-primary: #111827; + --text-secondary:#4b5563; + --text-muted: #9ca3af; + + --brand-grad: linear-gradient(135deg, #2563eb 0%, #6366f1 55%, #a855f7 100%); + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.04); + --shadow-glow: 0 4px 24px rgba(37, 99, 235, 0.12); + + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --ease-spring: cubic-bezier(0.32, 0.72, 0, 1); + --dur-normal: 500ms; + --dur-slow: 800ms; + + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 20px; + --radius-xl: 28px; + --radius-pill: 999px; + + --font-sans: 'Outfit', 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans SC', 'Microsoft YaHei', 'WenQuanYi Micro Hei', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + + position: relative; + min-height: 100dvh; + background: var(--bg-base); + color: var(--text-primary); + font-family: var(--font-sans); + line-height: 1.8; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + word-break: break-word; + overflow-wrap: break-word; +} + +/* ========== Grain Overlay ========== */ +.landing-page::before { + content: ""; + position: fixed; + inset: 0; + z-index: 60; + pointer-events: none; + opacity: 0.018; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + background-repeat: repeat; + background-size: 200px; +} + +/* ========== Ambient Background ========== */ +.landing-page .lp-bg-grid { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(ellipse 60% 50% at 15% 20%, rgba(37, 99, 235, 0.04) 0%, transparent 70%), + radial-gradient(ellipse 50% 40% at 85% 75%, rgba(99, 102, 241, 0.03) 0%, transparent 70%); +} + +/* ========== Layout ========== */ +.landing-page .lp-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; + position: relative; + z-index: 1; +} + +.landing-page section { + padding: 100px 0; +} + +/* ========== Top Navigation ========== */ +.lp-nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 90; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32px; + height: 64px; + background: rgba(255, 255, 255, 0.72); + backdrop-filter: blur(20px) saturate(1.4); + -webkit-backdrop-filter: blur(20px) saturate(1.4); + border-bottom: 1px solid var(--border-subtle); +} + +.lp-nav__logo { + font-size: 1.2rem; + font-weight: 700; + letter-spacing: -0.01em; + background: var(--brand-grad); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + cursor: default; +} + +.lp-nav__links { + display: flex; + gap: 28px; +} + +.lp-nav__links a { + color: var(--text-muted); + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + letter-spacing: 0; + transition: color var(--dur-normal) var(--ease-out-expo); +} +.lp-nav__links a:hover { + color: var(--text-primary); +} + +.lp-nav__actions { + display: flex; + gap: 8px; +} + +.lp-nav__btn { + padding: 7px 16px; + border-radius: var(--radius-pill); + font-size: 0.82rem; + font-weight: 500; + font-family: var(--font-sans); + cursor: pointer; + border: none; + transition: all var(--dur-normal) var(--ease-spring); +} + +.lp-nav__btn--ghost { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-subtle); +} +.lp-nav__btn--ghost:hover { + color: var(--text-primary); + border-color: var(--border-hover); + background: rgba(0, 0, 0, 0.02); +} +.lp-nav__btn--ghost:active { + transform: scale(0.97); +} + +.lp-nav__btn--primary { + background: var(--accent); + color: #ffffff; + box-shadow: 0 2px 12px rgba(37, 99, 235, 0.2); +} +.lp-nav__btn--primary:hover { + background: var(--accent-hover); + box-shadow: 0 4px 20px rgba(37, 99, 235, 0.3); + transform: translateY(-1px); +} +.lp-nav__btn--primary:active { + transform: scale(0.97) translateY(0); +} + +/* ========== Hero ========== */ +.lp-hero { + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + position: relative; + padding-top: 64px; +} + +.lp-hero-content { + max-width: 780px; +} + +.lp-hero-badge { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-pill); + padding: 6px 16px; + font-size: 0.78rem; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--text-secondary); + margin-bottom: 36px; + box-shadow: var(--shadow-sm); +} + +.lp-hero-badge__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #22c55e; + animation: lpPulse 2.5s ease-in-out infinite; +} +@keyframes lpPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } + +.lp-hero h1 { + font-size: clamp(2.4rem, 5.5vw, 4rem); + font-weight: 800; + line-height: 1.2; + letter-spacing: -0.01em; + margin-bottom: 24px; + color: var(--text-primary); +} + +.lp-hero h1 .lp-gradient { + background: var(--brand-grad); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.lp-hero__sub { + font-size: 1.05rem; + font-weight: 400; + color: var(--text-secondary); + max-width: 520px; + margin: 0 auto 0; + line-height: 1.85; +} + +/* Hero CTA Actions */ +.lp-hero-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + margin: 36px auto 44px; +} + +.lp-hero-actions__links { + display: flex; + gap: 20px; +} + +.lp-hero-actions__links a { + color: var(--text-muted); + text-decoration: none; + font-size: 0.82rem; + font-weight: 500; + transition: color var(--dur-normal) var(--ease-out-expo); +} +.lp-hero-actions__links a:hover { + color: var(--accent); +} + +/* Device Mockup - Double Bezel */ +.lp-hero-visual { + position: relative; + width: 100%; + max-width: 680px; + margin: 64px auto 0; + aspect-ratio: 16/9; + border-radius: var(--radius-xl); + padding: 3px; + background: linear-gradient(145deg, rgba(0,0,0,0.08), rgba(0,0,0,0.03)); + box-shadow: var(--shadow-lg); +} + +.lp-hero-visual__inner { + width: 100%; + height: 100%; + border-radius: calc(var(--radius-xl) - 3px); + overflow: hidden; + background: var(--bg-surface); + position: relative; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); +} + +.lp-hero-visual__screen { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: 1fr 1fr; +} + +.lp-screen-left { + background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +.lp-camera-ring { + width: 120px; + height: 120px; + border-radius: 50%; + border: 1.5px solid rgba(96, 165, 250, 0.35); + display: flex; + align-items: center; + justify-content: center; + animation: lpRingPulse 4s ease-in-out infinite; + position: relative; +} + +.lp-camera-ring::before { + content: ''; + position: absolute; + inset: -10px; + border-radius: 50%; + border: 1px solid rgba(96, 165, 250, 0.12); +} + +.lp-camera-ring svg { + width: 40px; + height: 40px; + fill: none; + stroke: #60a5fa; + stroke-width: 1.2; + opacity: 0.8; +} +@keyframes lpRingPulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.04); opacity: 0.8; } } + +.lp-scan-line { + position: absolute; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, #60a5fa, transparent); + animation: lpScanMove 4s linear infinite; + opacity: 0.5; +} +@keyframes lpScanMove { 0% { top: 0; } 100% { top: 100%; } } + +.lp-screen-right { + background: var(--bg-surface); + padding: 24px; + display: flex; + flex-direction: column; + gap: 10px; + border-left: 1px solid var(--border-subtle); +} + +.lp-chat-bubble { + max-width: 85%; + padding: 10px 14px; + border-radius: var(--radius-md); + font-size: 0.78rem; + line-height: 1.7; + animation: lpFadeInUp var(--dur-slow) var(--ease-out-expo) both; +} + +.lp-chat-bubble--user { + background: var(--accent); + color: #ffffff; + font-weight: 500; + align-self: flex-end; + border-bottom-right-radius: var(--radius-sm); +} + +.lp-chat-bubble--ai { + background: var(--bg-elevated); + color: var(--text-secondary); + align-self: flex-start; + border-bottom-left-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); +} + +.lp-typing { + display: inline-flex; + gap: 4px; + margin-top: 6px; +} + +.lp-typing span { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--accent); + animation: lpBlink 1.4s infinite both; +} +.lp-typing span:nth-child(2) { animation-delay: 0.2s; } +.lp-typing span:nth-child(3) { animation-delay: 0.4s; } +@keyframes lpBlink { 0%, 80%, 100% { opacity: 0.25; } 40% { opacity: 1; } } +@keyframes lpFadeInUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } + +.lp-chat-bubble:nth-child(1) { animation-delay: 0.2s; } +.lp-chat-bubble:nth-child(2) { animation-delay: 0.6s; } +.lp-chat-bubble:nth-child(3) { animation-delay: 1.1s; } + +.lp-audio-wave { + display: flex; + align-items: center; + gap: 2px; + margin-top: auto; + padding-top: 8px; +} + +.lp-audio-wave span { + width: 2px; + border-radius: 2px; + background: var(--accent); + animation: lpWaveBar 1.2s ease-in-out infinite; + opacity: 0.7; +} +.lp-audio-wave span:nth-child(1) { height: 10px; animation-delay: 0s; } +.lp-audio-wave span:nth-child(2) { height: 18px; animation-delay: 0.1s; } +.lp-audio-wave span:nth-child(3) { height: 14px; animation-delay: 0.2s; } +.lp-audio-wave span:nth-child(4) { height: 22px; animation-delay: 0.3s; } +.lp-audio-wave span:nth-child(5) { height: 12px; animation-delay: 0.4s; } +.lp-audio-wave span:nth-child(6) { height: 18px; animation-delay: 0.15s; } +.lp-audio-wave span:nth-child(7) { height: 8px; animation-delay: 0.35s; } +@keyframes lpWaveBar { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1.2); } } + +/* ========== Section Headers ========== */ +.lp-section-header { + text-align: center; + margin-bottom: 64px; +} + +.lp-section-header__tag { + display: inline-block; + font-size: 1.75rem; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--accent); + margin-bottom: 14px; +} + +.lp-section-header h2 { + font-size: clamp(1.8rem, 3.5vw, 2.4rem); + font-weight: 700; + line-height: 1.3; + letter-spacing: -0.01em; + color: var(--text-primary); +} + +.lp-section-header p { + color: var(--text-secondary); + max-width: 520px; + margin: 14px auto 0; + font-size: 0.95rem; + line-height: 1.85; +} + +/* ========== Problem Section ========== */ +.lp-problem-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +.lp-problem-card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 32px; + transition: all var(--dur-normal) var(--ease-spring); + position: relative; + box-shadow: var(--shadow-sm); +} + +.lp-problem-card:hover { + border-color: var(--border-hover); + transform: translateY(-4px); + box-shadow: var(--shadow-lg); +} + +.lp-problem-card__icon { + width: 44px; + height: 44px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.3rem; + margin-bottom: 20px; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); +} + +.lp-problem-card h3 { + font-size: 1.05rem; + font-weight: 600; + letter-spacing: 0; + margin-bottom: 8px; + color: var(--text-primary); +} + +.lp-problem-card p { + color: var(--text-secondary); + font-size: 0.88rem; + line-height: 1.85; +} + +/* ========== Flow Section ========== */ +.lp-flow-wrapper { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-xl); + padding: 48px 40px; + overflow-x: auto; + box-shadow: var(--shadow-sm); +} + +.lp-flow-steps { + display: flex; + align-items: center; + gap: 0; + min-width: 700px; + justify-content: center; +} + +.lp-flow-step { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + min-width: 100px; + text-align: center; +} + +.lp-flow-step__node { + width: 56px; + height: 56px; + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + border: 1px solid var(--border-subtle); + background: var(--bg-elevated); + transition: all var(--dur-normal) var(--ease-spring); +} + +.lp-flow-step:hover .lp-flow-step__node { + border-color: var(--accent-dim); + box-shadow: var(--shadow-glow); + transform: scale(1.08); +} + +.lp-flow-step__label { + font-size: 0.78rem; + color: var(--text-secondary); + font-weight: 500; + letter-spacing: 0; +} + +.lp-flow-arrow { + font-size: 1rem; + color: var(--text-muted); + flex-shrink: 0; + padding: 0 6px; +} + +/* ========== Features (asymmetric 2-col) ========== */ +.lp-features-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.lp-feature-card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 36px 32px; + transition: all var(--dur-normal) var(--ease-spring); + position: relative; + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +.lp-feature-card:hover { + border-color: var(--border-hover); + box-shadow: var(--shadow-lg); +} + +.lp-feature-card:last-child:nth-child(odd) { + grid-column: 1 / -1; + max-width: calc(50% - 8px); + justify-self: center; +} + +.lp-feature-card__number { + position: absolute; + top: 24px; + right: 24px; + font-size: 2.2rem; + font-weight: 800; + color: rgba(0, 0, 0, 0.04); + line-height: 1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.lp-feature-card__icon { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + margin-bottom: 20px; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); +} + +.lp-feature-card h3 { + font-size: 1.05rem; + font-weight: 600; + letter-spacing: 0; + margin-bottom: 10px; + color: var(--text-primary); +} + +.lp-feature-card p { + color: var(--text-secondary); + font-size: 0.88rem; + line-height: 1.85; +} + +/* ========== Users ========== */ +.lp-users-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; +} + +.lp-user-card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 24px 16px; + text-align: center; + transition: all var(--dur-normal) var(--ease-spring); + box-shadow: var(--shadow-sm); +} + +.lp-user-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + border-color: var(--border-hover); +} + +.lp-user-card__avatar { + width: 48px; + height: 48px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + margin: 0 auto 14px; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); +} + +.lp-user-card h4 { + font-size: 0.9rem; + font-weight: 600; + letter-spacing: 0; + margin-bottom: 6px; + color: var(--text-primary); +} + +.lp-user-card p { + color: var(--text-secondary); + font-size: 0.78rem; + line-height: 1.75; +} + +/* ========== Scenes ========== */ +.lp-scenes-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.lp-scene-row { + display: grid; + grid-template-columns: 44px 1fr auto; + gap: 16px; + align-items: center; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 18px 24px; + transition: all var(--dur-normal) var(--ease-spring); + box-shadow: var(--shadow-sm); +} + +.lp-scene-row:hover { + border-color: var(--border-hover); + background: var(--bg-elevated); + box-shadow: var(--shadow-md); +} + +.lp-scene-row__icon { + font-size: 1.3rem; + text-align: center; +} + +.lp-scene-row h4 { + font-size: 0.92rem; + font-weight: 600; + letter-spacing: 0; + margin-bottom: 2px; + color: var(--text-primary); +} + +.lp-scene-row__desc { + color: var(--text-secondary); + font-size: 0.82rem; + line-height: 1.75; +} + +.lp-scene-row__tag { + font-size: 0.7rem; + padding: 3px 10px; + border-radius: var(--radius-pill); + font-weight: 500; + white-space: nowrap; + background: var(--bg-elevated); + color: var(--text-secondary); + border: 1px solid var(--border-subtle); +} + +/* ========== Metrics ========== */ +.lp-metrics-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +.lp-metric-card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 32px 24px; + text-align: center; + transition: all var(--dur-normal) var(--ease-spring); + box-shadow: var(--shadow-sm); +} + +.lp-metric-card:hover { + border-color: var(--border-hover); + box-shadow: var(--shadow-glow); +} + +.lp-metric-card__value { + font-size: 2rem; + font-weight: 800; + letter-spacing: -0.02em; + margin-bottom: 6px; + color: var(--accent); + font-variant-numeric: tabular-nums; +} + +.lp-metric-card__label { + color: var(--text-secondary); + font-size: 0.82rem; + font-weight: 500; + letter-spacing: 0; +} + +/* ========== Tech Stack ========== */ +.lp-tech-layers { + display: flex; + flex-direction: column; + gap: 12px; +} + +.lp-tech-layer { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 24px 28px; + display: grid; + grid-template-columns: 100px 1fr; + gap: 20px; + align-items: center; + transition: all var(--dur-normal) var(--ease-spring); + box-shadow: var(--shadow-sm); +} + +.lp-tech-layer:hover { + border-color: var(--border-hover); + box-shadow: var(--shadow-md); +} + +.lp-tech-layer__badge { + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.04em; + padding: 4px 10px; + border-radius: var(--radius-sm); + display: inline-block; + width: fit-content; + background: var(--bg-elevated); + color: var(--text-secondary); + border: 1px solid var(--border-subtle); +} + +.lp-tech-layer__tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.lp-tech-layer__tags span { + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 5px 12px; + font-size: 0.78rem; + color: var(--text-secondary); + font-weight: 400; + transition: all var(--dur-normal) var(--ease-out-expo); +} + +.lp-tech-layer__tags span:hover { + border-color: var(--accent-dim); + color: var(--text-primary); +} + +/* ========== CTA / Footer ========== */ +.lp-cta-section { + text-align: center; + padding: 120px 0; +} + +.lp-cta-section h2 { + font-size: clamp(1.6rem, 3vw, 2.2rem); + font-weight: 700; + letter-spacing: -0.01em; + margin-bottom: 16px; + color: var(--text-primary); +} + +.lp-cta-section p { + color: var(--text-secondary); + margin-bottom: 36px; + font-size: 1rem; +} + +.lp-cta-btn { + display: inline-flex; + align-items: center; + gap: 10px; + background: var(--accent); + color: #ffffff; + font-size: 0.95rem; + font-weight: 600; + font-family: var(--font-sans); + padding: 14px 32px; + border-radius: var(--radius-pill); + text-decoration: none; + transition: all var(--dur-normal) var(--ease-spring); + border: none; + cursor: pointer; + box-shadow: 0 4px 24px rgba(37, 99, 235, 0.22); +} + +.lp-cta-btn:hover { + background: var(--accent-hover); + transform: translateY(-2px); + box-shadow: 0 8px 36px rgba(37, 99, 235, 0.32); +} + +.lp-cta-btn:active { + transform: scale(0.97); +} + +.lp-cta-links { + margin-top: 24px; + display: flex; + gap: 20px; + justify-content: center; +} + +.lp-cta-links a { + color: var(--text-muted); + text-decoration: none; + font-size: 0.82rem; + font-weight: 500; + transition: color var(--dur-normal) var(--ease-out-expo); +} +.lp-cta-links a:hover { + color: var(--accent); +} + +.lp-footer { + border-top: 1px solid var(--border-subtle); + padding: 32px 0; + text-align: center; + color: var(--text-muted); + font-size: 0.82rem; + font-weight: 400; +} + +/* ========== Scroll Animations ========== */ +.lp-fade-in { + opacity: 0; + transform: translateY(32px); + transition: opacity var(--dur-slow) var(--ease-out-expo), + transform var(--dur-slow) var(--ease-out-expo); +} +.lp-fade-in.lp-visible { + opacity: 1; + transform: translateY(0); +} + +/* ========== Login Modal ========== */ +.lp-modal-overlay { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + animation: lpModalOverlayIn 300ms var(--ease-out-expo); +} + +@keyframes lpModalOverlayIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Double-bezel modal */ +.lp-modal { + width: 100%; + max-width: 380px; + border-radius: var(--radius-xl); + padding: 4px; + background: linear-gradient(160deg, rgba(0,0,0,0.06), rgba(0,0,0,0.02)); + box-shadow: 0 32px 80px rgba(0, 0, 0, 0.15), 0 0 60px rgba(37, 99, 235, 0.06); + animation: lpModalIn 400ms var(--ease-spring); +} + +@keyframes lpModalIn { + from { opacity: 0; transform: translateY(24px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.lp-modal__inner { + background: var(--bg-surface); + border-radius: calc(var(--radius-xl) - 4px); + padding: 36px 28px 28px; + position: relative; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); +} + +.lp-modal__close { + position: absolute; + top: 12px; + right: 12px; + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--text-muted); + font-size: 1.1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--dur-normal) var(--ease-out-expo); +} +.lp-modal__close:hover { + background: rgba(0, 0, 0, 0.04); + color: var(--text-primary); +} + +.lp-modal__header { + text-align: center; + margin-bottom: 28px; +} + +.lp-modal__logo { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.01em; + background: var(--brand-grad); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 4px; +} + +.lp-modal__subtitle { + color: var(--text-muted); + font-size: 0.82rem; + font-weight: 400; +} + +/* Modal form */ +.lp-modal__tabs { + display: flex; + gap: 0; + margin-bottom: 20px; + background: var(--bg-base); + border-radius: var(--radius-md); + padding: 3px; +} + +.lp-modal__tab { + flex: 1; + padding: 8px 0; + border: none; + border-radius: calc(var(--radius-md) - 2px); + font-size: 0.82rem; + font-weight: 500; + font-family: var(--font-sans); + cursor: pointer; + background: transparent; + color: var(--text-muted); + transition: all var(--dur-normal) var(--ease-out-expo); +} + +.lp-modal__tab:hover { + color: var(--text-secondary); +} + +.lp-modal__tab--active { + background: var(--bg-surface); + color: var(--text-primary); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); +} + +.lp-modal__field { + display: block; + margin-bottom: 14px; +} + +.lp-modal__field-label { + display: block; + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 500; +} + +.lp-modal__field-input { + width: 100%; + padding: 10px 14px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--bg-base); + color: var(--text-primary); + font-size: 0.88rem; + font-family: var(--font-sans); + outline: none; + transition: border-color var(--dur-normal) var(--ease-out-expo), + box-shadow var(--dur-normal) var(--ease-out-expo); +} + +.lp-modal__field-input::placeholder { + color: var(--text-muted); +} + +.lp-modal__field-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.lp-modal__error { + background: rgba(239, 68, 68, 0.06); + border: 1px solid rgba(239, 68, 68, 0.18); + color: #dc2626; + font-size: 0.78rem; + padding: 8px 12px; + border-radius: var(--radius-sm); + margin-bottom: 14px; + text-align: center; +} + +.lp-modal__submit { + width: 100%; + padding: 11px 0; + border-radius: var(--radius-pill); + border: none; + background: var(--accent); + color: #ffffff; + font-size: 0.9rem; + font-weight: 600; + font-family: var(--font-sans); + cursor: pointer; + transition: all var(--dur-normal) var(--ease-spring); + box-shadow: 0 2px 16px rgba(37, 99, 235, 0.18); +} + +.lp-modal__submit:hover:not(:disabled) { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 24px rgba(37, 99, 235, 0.28); +} + +.lp-modal__submit:active:not(:disabled) { + transform: scale(0.97); +} + +.lp-modal__submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.lp-modal__footer { + text-align: center; + margin-top: 20px; + font-size: 0.78rem; + color: var(--text-muted); +} + +.lp-modal__link { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + font-size: 0.78rem; + font-weight: 500; + font-family: var(--font-sans); + margin-left: 4px; + padding: 0; + transition: color var(--dur-normal) var(--ease-out-expo); +} +.lp-modal__link:hover { + color: var(--accent-hover); +} + +/* ========== Responsive ========== */ +@media (max-width: 768px) { + .lp-nav__links { display: none; } + .lp-problem-grid { grid-template-columns: 1fr; } + .lp-features-grid { grid-template-columns: 1fr; } + .lp-feature-card:last-child:nth-child(odd) { max-width: 100%; grid-column: auto; } + .lp-users-grid { grid-template-columns: repeat(2, 1fr); } + .lp-metrics-grid { grid-template-columns: repeat(2, 1fr); } + .lp-tech-layer { grid-template-columns: 1fr; } +} + +@media (max-width: 640px) { + .landing-page section { padding: 72px 0; } + .lp-hero-visual { margin-top: 40px; } + .lp-scene-row { grid-template-columns: 40px 1fr; } + .lp-scene-row__tag { grid-column: 2; justify-self: start; } + .lp-flow-steps { justify-content: flex-start; } + .lp-users-grid { grid-template-columns: 1fr; } +} + +/* ========== Reduced Motion ========== */ +@media (prefers-reduced-motion: reduce) { + .lp-fade-in { opacity: 1; transform: none; transition: none; } + .lp-scan-line, .lp-camera-ring, .lp-audio-wave span, .lp-typing span, .lp-hero-badge__dot { animation: none; } + .lp-chat-bubble { animation: none; opacity: 1; transform: none; } + .lp-modal-overlay, .lp-modal { animation-duration: 0.01ms; } + *, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } +} diff --git a/frontend/src/components/LandingPage/LoginModal.tsx b/frontend/src/components/LandingPage/LoginModal.tsx new file mode 100644 index 0000000..fceda39 --- /dev/null +++ b/frontend/src/components/LandingPage/LoginModal.tsx @@ -0,0 +1,163 @@ +// ============================================================ +// LoginModal — 登录 / 注册模态框 +// 职责:在 LandingPage 上弹出的认证表单,复用现有 auth 系统 +// ============================================================ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAuth } from "../../lib/auth"; +import { useI18n } from "../../lib/i18n"; + +type AuthMode = "login" | "register"; + +interface LoginModalProps { + initialMode?: AuthMode; + onClose: () => void; +} + +export function LoginModal({ initialMode = "login", onClose }: LoginModalProps) { + const { login, register } = useAuth(); + const { t } = useI18n(); + const [mode, setMode] = useState(initialMode); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const overlayRef = useRef(null); + + // 点击遮罩关闭 + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === overlayRef.current) onClose(); + }, + [onClose] + ); + + // ESC 关闭 + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + // 阻止 body 滚动 + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { document.body.style.overflow = prev; }; + }, []); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (username.length < 3 || username.length > 64) { + setError(t("auth.error.usernameLength")); + return; + } + if (password.length < 8 || password.length > 72) { + setError(t("auth.error.passwordLength")); + return; + } + + setIsSubmitting(true); + const fn = mode === "login" ? login : register; + const result = await fn(username, password); + setIsSubmitting(false); + + if (result.error) { + setError(result.error); + } + // 登录成功时 auth 状态更新,App 自动切到主界面,modal 自然消失 + }, + [username, password, mode, login, register, t] + ); + + const switchMode = useCallback(() => { + setMode((m) => (m === "login" ? "register" : "login")); + setError(""); + }, []); + + return ( +
+
+
+ + +
+
CamTalk
+
{t("auth.subtitle")}
+
+ +
+
+ + +
+ + + + + + {error &&
{error}
} + + +
+ +
+ {mode === "login" ? t("auth.noAccount") : t("auth.hasAccount")} + +
+
+
+
+ ); +} diff --git a/frontend/src/components/LandingPage/index.tsx b/frontend/src/components/LandingPage/index.tsx new file mode 100644 index 0000000..d305a8c --- /dev/null +++ b/frontend/src/components/LandingPage/index.tsx @@ -0,0 +1,390 @@ +// ============================================================ +// LandingPage — 官网首页(含登录弹窗) +// 职责:未登录用户的落地页,展示产品介绍并提供登录/注册入口 +// ============================================================ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { LoginModal } from "./LoginModal"; +import "./LandingPage.css"; + +type ModalMode = "login" | "register"; + +export function LandingPage() { + const [modal, setModal] = useState<{ open: boolean; mode: ModalMode }>({ + open: false, + mode: "login", + }); + + const openModal = useCallback((mode: ModalMode) => { + setModal({ open: true, mode }); + }, []); + + const closeModal = useCallback(() => { + setModal((prev) => ({ ...prev, open: false })); + }, []); + + // ---- Scroll Reveal ---- + const observerRef = useRef(null); + + useEffect(() => { + observerRef.current = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add("lp-visible"); + } + }); + }, + { threshold: 0.1, rootMargin: "0px 0px -40px 0px" } + ); + + document.querySelectorAll(".lp-fade-in").forEach((el) => { + observerRef.current?.observe(el); + }); + + return () => observerRef.current?.disconnect(); + }, []); + + return ( +
+
+ + {/* ========== Top Navigation ========== */} + + + {/* ========== HERO ========== */} +
+
+
+ + XEngineers +
+

+ CamTalk +
+ 给 AI 装上眼睛和耳朵 +

+

+ 多模态实时 AI 视觉对话助手。打开浏览器,对着摄像头说话,AI 实时理解画面和语音,以文字和语音同步回答你。 +

+ +
+ + +
+ + {/* Device Mockup - Double Bezel */} +
+
+
+
+
+
+ + + + +
+
+
+
这道数学题怎么做?
+
+ 这是一道二次方程求解题。观察方程 x² - 5x + 6 = 0,可以使用因式分解法…… +
+ +
+
+
能用求根公式再算一遍吗?
+
+ +
+
+
+
+
+
+
+ + {/* ========== PROBLEM ========== */} +
+
+
+
痛点分析
+

AI 能说会道,却看不到你眼前的世界

+

传统 AI 助手存在三大断层,割裂了自然交流的直觉

+
+
+
+
+ + + +
+

视觉断层

+

用户必须先拍照、保存、上传、再打字描述上下文,AI 才能「看到」画面。四步操作,割裂了自然交流的直觉。

+
+
+
+ + + +
+

交互断层

+

面对外语菜单、数学公式、电路图等复杂内容,打字描述极其低效。用户脑中的问题转不成文字,AI 也就无法作答。

+
+
+
+ + + +
+

成本断层

+

实时视频流 + 大模型推理的组合让 API 成本居高不下。传统方案月成本高达 $5,000,无法面向普通用户商业化。

+
+
+
+
+ + {/* ========== SOLUTION ========== */} +
+
+
+

看 + 听 + 说 = 真正理解你的 AI

+

CamTalk 用一个对话界面闭合所有断层

+
+
+
+ {[ + { icon: "📷", label: "摄像头采集" }, + { icon: "🧠", label: "边缘预处理" }, + { icon: "🔌", label: "WebSocket" }, + { icon: "⚡", label: "Eino 编排" }, + { icon: "👁️", label: "视觉理解" }, + { icon: "💬", label: "流式回复" }, + { icon: "🔊", label: "语音输出" }, + ].map((step, i, arr) => ( +
+
+
{step.icon}
+
{step.label}
+
+ {i < arr.length - 1 &&
} +
+ ))} +
+
+
+
+ + {/* ========== FEATURES ========== */} +
+
+
+
核心亮点
+

五大技术创新

+

从端到端架构到成本控制,每一层都经过精心设计

+
+
+ {[ + { + num: "01", icon: "⚡", + title: "流式并行推送", + desc: "LLM 文本流与 TTS 音频流并行输出。用户先看到文字,紧接着听到语音,感知延迟低于 0.5 秒,接近真人对话节奏。", + }, + { + num: "02", icon: "🧠", + title: "声明式 AI 编排", + desc: "基于 CloudWeGo Eino Graph 的 7 节点 DAG 流水线(STT → History → ChatModel → Splitter → TTS),类型安全、可扩展、易测试。", + }, + { + num: "03", icon: "💰", + title: "端云协同降本", + desc: "浏览器端 VAD 语音检测 + 关键帧像素比较 + 混合采样策略,节省 70% 带宽,月成本从 $5,000 降至 $300,降幅 90%。", + }, + { + num: "04", icon: "🎯", + title: "多场景智能模式", + desc: "5 种 AI 角色(自由对话 / 模拟面试 / 英语老师 / 辩论对手 / 同声翻译)× 3 种视觉模式 × 观察模式,灵活覆盖学习与工作。", + }, + { + num: "05", icon: "🏗️", + title: "生产级工程架构", + desc: "三级存储自动降级(Memory → Redis → PostgreSQL)、JWT 双 token 认证、Docker Compose 一键部署、完善的错误处理与降级策略。", + }, + ].map((f) => ( +
+
{f.num}
+
+ {f.icon} +
+

{f.title}

+

{f.desc}

+
+ ))} +
+
+
+ + {/* ========== USERS ========== */} +
+
+
+

为每一类学习者而设计

+

无论你是在学英语、准备面试,还是想让 AI 帮你看看眼前的世界

+
+
+ {[ + { avatar: "🧑‍🎓", title: "语言学习者", desc: "对着课本或实物,与 AI 英语外教用英语自由对话,实时纠正语法和发音" }, + { avatar: "💼", title: "面试准备者", desc: "开启模拟面试模式,AI 面试官通过摄像头观察你的表情与状态,给出针对性反馈" }, + { avatar: "🌍", title: "跨境交流者", desc: "出国旅行时对着外文菜单、路牌实时翻译,AI 语音播报翻译结果" }, + { avatar: "👁️", title: "视障人士", desc: "AI 实时描述摄像头画面中的环境、障碍物和文字,提供无障碍信息辅助" }, + { avatar: "🔬", title: "学生 / 教师", desc: "对着题目问「怎么做?」,AI 看到画面后逐步讲解,就像身边有一位私教" }, + ].map((u) => ( +
+
{u.avatar}
+

{u.title}

+

{u.desc}

+
+ ))} +
+
+
+ + {/* ========== SCENES ========== */} +
+
+
+

五种模式,覆盖真实需求

+
+
+ {[ + { icon: "💬", title: "自由对话", desc: "对着摄像头随意聊天,AI 实时理解画面并语音回答", tag: "通用" }, + { icon: "🗣️", title: "英语老师", desc: "AI 外教结合摄像头场景进行英语口语教学,实时纠正语法", tag: "学习" }, + { icon: "🎤", title: "模拟面试", desc: "AI 面试官根据你的回答追问,通过摄像头观察你的表现", tag: "求职" }, + { icon: "⚔️", title: "辩论对手", desc: "AI 反驳你的观点,锻炼你的逻辑思维和表达能力", tag: "思维" }, + { icon: "🌐", title: "同声翻译", desc: "实时识别画面中的外语文字并语音翻译,口语化输出", tag: "工具" }, + ].map((s) => ( +
+
{s.icon}
+
+

{s.title}

+
{s.desc}
+
+
+ {s.tag} +
+
+ ))} +
+
+
+ + {/* ========== METRICS ========== */} +
+
+
+

用数据说话

+
+
+ {[ + { value: "< 2s", label: "端到端响应延迟" }, + { value: "90%", label: "API 成本降幅" }, + { value: "70%", label: "带宽节省率" }, + { value: "5+3", label: "场景 × 视觉模式" }, + ].map((m) => ( +
+
{m.value}
+
{m.label}
+
+ ))} +
+
+
+ + {/* ========== TECH STACK ========== */} +
+
+
+

三层系统,生产级质量

+

前端轻量预处理 → Go 网关智能编排 → 云端 AI 按需调用

+
+
+ {[ + { badge: "前端层", tags: ["React 18", "TypeScript", "Vite", "WebRTC VAD", "Canvas 关键帧检测", "WebSocket", "i18n (中/英/日)"] }, + { badge: "网关层", tags: ["Go + Gin", "gorilla/websocket", "Eino Graph", "JWT 双 Token", "Zap 日志", "Viper 配置"] }, + { badge: "存储层", tags: ["L1 Memory", "L2 Redis", "L3 PostgreSQL", "TieredManager 自动降级"] }, + { badge: "AI 服务", tags: ["qwen3-vl-plus (LLM)", "MiMo ASR (STT)", "MiMo TTS", "Docker Compose"] }, + ].map((layer) => ( +
+
+
+ {layer.badge} +
+
+
+ {layer.tags.map((tag) => {tag})} +
+
+ ))} +
+
+
+ + {/* ========== CTA ========== */} +
+
+

让 AI 看见你看见的世界

+

CamTalk,不只是聊天,而是真正的多模态视觉对话。

+ + +
+
+ + {/* ========== Footer ========== */} +
+
+ CamTalk © 2026 XEngineers +
+
+ + {/* ========== Login Modal ========== */} + {modal.open && ( + + )} +
+ ); +} -- 2.49.1 From ab07e01adf53a3ca11c84d9b725fa8bd90ed83f4 Mon Sep 17 00:00:00 2001 From: cfy666 <3087823110@qq.com> Date: Sat, 20 Jun 2026 19:57:36 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E5=8E=86=E5=8F=B2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/eino/adapter.go | 33 ++- backend/internal/orchestrator/orchestrator.go | 2 - backend/internal/session/manager.go | 1 + backend/internal/session/memory.go | 44 ++- backend/internal/ws/handler.go | 11 +- backend/internal/ws/handler_test.go | 1 - docs/conversation-history-bug-analysis.md | 246 ++++++++++++++++ frontend/src/App.tsx | 64 +++-- frontend/src/components/ChatPanel/index.tsx | 6 +- .../src/components/WebSocketManager/index.tsx | 2 +- frontend/src/hooks/useSessionList.ts | 266 +++++++++++++----- frontend/src/hooks/useVisionSession.ts | 43 ++- frontend/src/lib/api.ts | 93 ++++++ frontend/src/lib/websocket.ts | 14 +- frontend/src/types/index.ts | 1 + 15 files changed, 653 insertions(+), 174 deletions(-) create mode 100644 docs/conversation-history-bug-analysis.md diff --git a/backend/internal/eino/adapter.go b/backend/internal/eino/adapter.go index 75c1b2c..2896b75 100644 --- a/backend/internal/eino/adapter.go +++ b/backend/internal/eino/adapter.go @@ -46,7 +46,6 @@ func (e *EinoOrchestrator) ProcessQuery( ctx context.Context, sessionID string, req models.WsQuery, - history []models.Message, sender orchestrator.Sender, ) error { log := logger.Log @@ -112,15 +111,7 @@ func (e *EinoOrchestrator) ProcessQuery( ctx = WithStartTime(ctx, startTime) ctx = WithPipelineState(ctx, genLocalState(ctx)) - // 6. 追加用户消息到历史 - if req.Text != "" { - _ = e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{ - Role: "user", - Content: req.Text, - }) - } - - // 7. 调用 Graph(Stream 模式 + 运行时 Callback) + // 6. 调用 Graph(Stream 模式 + 运行时 Callback) streamReader, err := e.graph.Runnable.Stream(ctx, input, e.callbacks) if err != nil { log.Errorw("Graph Stream 启动失败", "error", err) @@ -133,7 +124,7 @@ func (e *EinoOrchestrator) ProcessQuery( return err } - // 8. 消费 StreamReader(触发整条链路执行,side effects 推送消息到客户端) + // 7. 消费 StreamReader(触发整条链路执行,side effects 推送消息到客户端) var output PipelineOutput for { o, err := streamReader.Recv() @@ -147,12 +138,28 @@ func (e *EinoOrchestrator) ProcessQuery( output = o } + // 8. 追加用户消息到历史(使用 STT 结果,兼容文本输入和语音输入) + userText := output.TranscribedText + if userText == "" { + userText = req.Text // fallback 到原始文本输入 + } + if userText != "" { + if err := e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{ + Role: "user", + Content: userText, + }); err != nil { + log.Errorw("追加用户消息到历史失败", "session", sessionID, "error", err) + } + } + // 9. 追加助手消息到历史 if output.FullResponse != "" { - _ = e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{ + if err := e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{ Role: "assistant", Content: output.FullResponse, - }) + }); err != nil { + log.Errorw("追加助手消息到历史失败", "session", sessionID, "error", err) + } } latency := time.Since(startTime).Milliseconds() diff --git a/backend/internal/orchestrator/orchestrator.go b/backend/internal/orchestrator/orchestrator.go index d038603..d15e8bf 100644 --- a/backend/internal/orchestrator/orchestrator.go +++ b/backend/internal/orchestrator/orchestrator.go @@ -14,13 +14,11 @@ type Orchestrator interface { // ctx 用于整体超时和中断控制。 // sessionID 用于会话管理和历史获取。 // req 包含图像和音频数据。 - // history 是最近的对话历史。 // sender 用于向客户端推送消息。 ProcessQuery( ctx context.Context, sessionID string, req models.WsQuery, - history []models.Message, sender Sender, ) error } diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index 6a2527e..9507aa8 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -18,6 +18,7 @@ type ConversationSummary struct { Title string `json:"title"` LastMessage string `json:"last_message"` MessageCount int `json:"message_count"` + CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/internal/session/memory.go b/backend/internal/session/memory.go index 8ee3f3b..4dd67ba 100644 --- a/backend/internal/session/memory.go +++ b/backend/internal/session/memory.go @@ -142,11 +142,11 @@ func (m *MemoryManager) Create(ctx context.Context, userID string, config models } m.mu.Unlock() - // Write-Through:异步写 PG + // Write-Through:异步写 PG(使用 Background context,避免 HTTP 请求结束后 context 被取消) if m.sessRepo != nil { go func() { cfgJSON, _ := json.Marshal(config) - if err := m.sessRepo.Save(ctx, store.SessionRecord{ + if err := m.sessRepo.Save(context.Background(), store.SessionRecord{ ID: id, UserID: userID, Title: models.DefaultSessionTitle, Config: cfgJSON, CreatedAt: now, UpdatedAt: now, }); err != nil { @@ -204,11 +204,11 @@ func (m *MemoryManager) UpdateConfig(ctx context.Context, sessionID string, patc cfg := entry.session.Config m.mu.Unlock() - // Write-Through:异步更新 PG + // Write-Through:异步更新 PG(使用 Background context) if m.sessRepo != nil { go func() { cfgJSON, _ := json.Marshal(cfg) - if err := m.sessRepo.UpdateConfig(ctx, sessionID, cfgJSON); err != nil { + if err := m.sessRepo.UpdateConfig(context.Background(), sessionID, cfgJSON); err != nil { logger.Log.Warnw("update session config in DB failed", "session", sessionID, "error", err) } }() @@ -233,10 +233,10 @@ func (m *MemoryManager) UpdateTitle(ctx context.Context, sessionID string, title entry.lastActive = time.Now() m.mu.Unlock() - // Write-Through:异步更新 PG + // Write-Through:异步更新 PG(使用 Background context) if m.sessRepo != nil { go func() { - if err := m.sessRepo.UpdateTitle(ctx, sessionID, title); err != nil { + if err := m.sessRepo.UpdateTitle(context.Background(), sessionID, title); err != nil { logger.Log.Warnw("update session title in DB failed", "session", sessionID, "error", err) } }() @@ -271,6 +271,7 @@ func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, siz list = append(list, ConversationSummary{ ID: rec.ID, Title: rec.Title, + CreatedAt: rec.CreatedAt, UpdatedAt: rec.UpdatedAt, }) sessionIDs = append(sessionIDs, rec.ID) @@ -320,6 +321,7 @@ func (m *MemoryManager) listByUserFromMemory(ctx context.Context, userID string, summary := ConversationSummary{ ID: entry.session.ID, Title: entry.session.Title, + CreatedAt: entry.session.CreatedAt, UpdatedAt: entry.lastActive, } summary.MessageCount = len(entry.history) @@ -394,8 +396,10 @@ func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg m entry.history = append(entry.history, msg) // 自动更新标题:首条 user 消息时,如果标题为默认值,自动更新为消息前 20 字符 + titleUpdated := false if msg.Role == "user" && entry.session.Title == models.DefaultSessionTitle { entry.session.Title = generateTitle(msg.Content) + titleUpdated = true } // 超过上限时裁剪,保留最新的 maxHistory 条 @@ -406,13 +410,31 @@ func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg m now := time.Now() entry.lastActive = now entry.session.UpdatedAt = now + + // 复制标题(释放锁后安全使用) + persistTitle := entry.session.Title m.mu.Unlock() - // Write-Through:异步写冷存储,不阻塞调用方 + // Write-Through:消息同步写入 PostgreSQL(保证调用顺序 = 插入顺序, + // 避免用户消息和 AI 消息的异步 goroutine 执行顺序不确定导致排序错乱) if m.msgRepo != nil { + if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil { + logger.Log.Warnw("persist message failed", "session", sessionID, "error", err) + } + } + + // Write-Through:异步更新会话元数据(标题 + updated_at)到 PostgreSQL + if m.sessRepo != nil { go func() { - if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil { - logger.Log.Warnw("persist message failed", "session", sessionID, "error", err) + if titleUpdated { + if err := m.sessRepo.UpdateTitle(context.Background(), sessionID, persistTitle); err != nil { + logger.Log.Warnw("persist session title failed", "session", sessionID, "error", err) + } + } else { + // 即使标题没变,也要刷新 updated_at(保证列表排序正确) + if err := m.sessRepo.Touch(context.Background(), sessionID); err != nil { + logger.Log.Warnw("touch session in DB failed", "session", sessionID, "error", err) + } } }() } @@ -540,10 +562,10 @@ func (m *MemoryManager) Destroy(ctx context.Context, sessionID string) error { delete(m.sessions, sessionID) m.mu.Unlock() - // Write-Through:异步删除 PG + // Write-Through:异步删除 PG(使用 Background context) if m.sessRepo != nil { go func() { - if err := m.sessRepo.Delete(ctx, sessionID); err != nil { + if err := m.sessRepo.Delete(context.Background(), sessionID); err != nil { logger.Log.Warnw("delete session from DB failed", "session", sessionID, "error", err) } }() diff --git a/backend/internal/ws/handler.go b/backend/internal/ws/handler.go index cb7f25b..0339452 100644 --- a/backend/internal/ws/handler.go +++ b/backend/internal/ws/handler.go @@ -98,15 +98,13 @@ func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *co heartbeatTimeout := time.Duration(cfg.Server.HeartbeatTimeout) * time.Second version := cfg.App.Version - maxHistory := cfg.Session.MaxHistory - return func(c *gin.Context) { - serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, maxHistory, tokenMgr) + serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, tokenMgr) } } func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator, - upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, maxHistory int, tokenMgr *auth.TokenManager) { + upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, tokenMgr *auth.TokenManager) { // --- JWT 认证(upgrade 前完成,失败直接返回 HTTP 错误) --- token := c.Query("token") @@ -236,9 +234,6 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche logger.Log.Warnw("set active request failed", "session", sessionID, "error", err) } - // 获取对话历史 - history, _ := client.sessionMgr.GetHistory(context.Background(), sessionID, maxHistory) - // 创建可取消的 context ctx, cancel := context.WithCancel(context.Background()) client.mu.Lock() @@ -260,7 +255,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche _ = client.sessionMgr.ClearActiveRequest(context.Background(), sessionID) }() - if err := client.orchestrator.ProcessQuery(ctx, sessionID, msg, history, sender); err != nil { + if err := client.orchestrator.ProcessQuery(ctx, sessionID, msg, sender); err != nil { logger.Log.Errorw("process query failed", "session", sessionID, "request", msg.RequestID, "error", err) } }() diff --git a/backend/internal/ws/handler_test.go b/backend/internal/ws/handler_test.go index 69f1207..136f4e7 100644 --- a/backend/internal/ws/handler_test.go +++ b/backend/internal/ws/handler_test.go @@ -48,7 +48,6 @@ func (m *MockOrchestrator) ProcessQuery( ctx context.Context, sessionID string, req models.WsQuery, - history []models.Message, sender orchestrator.Sender, ) error { if m.Err != nil { diff --git a/docs/conversation-history-bug-analysis.md b/docs/conversation-history-bug-analysis.md new file mode 100644 index 0000000..965d942 --- /dev/null +++ b/docs/conversation-history-bug-analysis.md @@ -0,0 +1,246 @@ +## CamTalk 对话历史功能 — Bug 分析与修复方案 + +### 一、整体架构现状 + +当前对话历史系统存在一个**根本性的架构缺陷**:前端和后端各自维护了一套完全独立的会话管理系统,两者之间从未同步。 + +**前端**:`useSessionList` Hook + `localStorage` 管理会话列表和消息存储。会话 ID 由前端 `uuid` 生成,消息通过 `localStorage` 持久化。 + +**后端**:`MemoryManager` (内存) + `PgSessionRepository` / `PgMessageRepository` (PostgreSQL) 管理会话和消息。会话 ID 由后端 `uuid.New()` 生成。 + +前端 `api.ts` 中没有任何对话相关的 REST API 调用,后端提供的 `/api/conversations` 全套接口(List / Create / Get / Patch / Delete / GetMessages)完全未被前端使用。 + +--- + +### 二、Bug 清单 + +#### P0 — 严重级别 + +**Bug 1:前后端会话系统完全脱节** + +前端创建会话(`useSessionList.createSession`)只在 localStorage 中写入一条 `SessionSummary`,后端完全不知道这个会话的存在。后端在 WebSocket 连接时创建的会话(`ws/handler.go` L148)有独立的 ID,前端也无法感知。两套 ID 体系互不关联,导致: + +- 前端切换/删除会话无法影响后端 +- 后端消息持久化到 PG 但前端无法读取 +- 对话历史不能跨设备、跨浏览器同步 +- 清除浏览器数据后所有历史丢失 + +**Bug 2:活跃对话中创建/切换会话导致后端消息写入错误会话** + +复现步骤: +1. 用户在会话 A 中正在对话(WebSocket 已连接,后端 sessionID = A) +2. 用户点击"新建对话" +3. 前端 `handleNewSession` 调用 `createSession()` 创建前端会话 B,调用 `setMessages([])` 清空 UI +4. 由于 `connectionStatus === "connected"`,调用 `stopSession()` 断开 WebSocket +5. 用户在新 UI 中发送消息,前端显示在"新对话"下 +6. 但 WebSocket 重连后,后端创建了一个**全新的**会话 C + +结果:前端认为是会话 B,后端实际是会话 C。如果 `stopSession` 未执行(连接状态判断时序问题),消息甚至会写入旧会话 A。 + +**Bug 3:刷新页面后 activeSessionId 丢失,消息无法自动保存** + +`useSessionList` 中 `activeSessionId` 初始值为 `null`,且不会从 localStorage 恢复: + +```typescript +const [activeSessionId, setActiveSessionId] = useState(null); +``` + +初始化逻辑(`App.tsx` L122-129)仅在 `sessions.length === 0` 时调用 `createSession()`。对于回访用户(sessions 不为空),`activeSessionId` 保持 `null`。 + +自动保存的 `useEffect`(L136-140)需要 `activeSessionId` 非 null: + +```typescript +if (activeSessionId && messages.length > 0) { + persistSession(activeSessionId, messages); +} +``` + +结果:回访用户如果不点击侧边栏选择会话,所有新消息不会被持久化,刷新页面即丢失。 + +#### P1 — 重要级别 + +**Bug 4:切换会话时强制断开 WebSocket,用户体验差** + +`handleSelectSession` 和 `handleNewSession` 都调用 `stopSession()`,而 `stopSession` 会断开 WebSocket 连接。每次切换会话都需要重新建立连接(TCP 握手 + JWT 认证 + VAD 初始化),增加约 1-3 秒延迟。 + +正确做法应该是在切换会话时保持 WebSocket 连接,仅在后端切换 sessionID(通过发送 `conversation_id` 参数重连,或者在协议中增加切换会话的消息类型)。 + +**Bug 5:前端 historyRef 是无效的死代码** + +`useVisionSession` 中的 `historyRef`(L48)被维护但从未被实际使用: + +```typescript +const historyRef = useRef>([]); +``` + +它被 push(`llm_done` 时 L258、`sendTextMessage` 时 L466、`interrupt` 时 L417),但从未被读取或发送到后端。前端的 LLM 上下文完全由后端 `session.Manager.GetHistory` 独立管理。这段代码增加了维护负担却没有任何功能价值。 + +**Bug 6:VAD 语音输入时用户消息未加入 historyRef** + +`onSpeechEnd` 回调(L186-228)添加了用户消息到 `messages` state,但从未 push 到 `historyRef`。同样,`stt_result` 处理器(L236-249)更新消息文本后也未同步到 `historyRef`。 + +虽然 `historyRef` 本身是死代码(Bug 5),但如果未来要利用它,这个遗漏会造成语音消息在前端历史中缺失。 + +**Bug 7:观察模式消息未加入 historyRef** + +`useObservationMode` 的 `onChange` 回调(L82-107)添加了用户消息但未 push 到 `historyRef`。同 Bug 6。 + +#### P2 — 一般级别 + +**Bug 8:ChatPanel 使用数组 index 作为 React key** + +```tsx +{messages.map((msg, index) => ( +
+``` + +当消息列表动态变化时(如 STT 结果更新替换了占位消息),使用 index 作为 key 可能导致 React 无法正确 diff,出现闪烁或渲染异常。应使用稳定唯一的 ID(如 `timestamp` 或生成 UUID)。 + +**Bug 9:后端 AppendMessage 中 tokensUsed 始终为 0** + +`MemoryManager.AppendMessage` 异步写 PG 时硬编码 `tokensUsed` 为 0: + +```go +if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil { +``` + +`WsLLMDone` 中的 `tokens_used` 信息未被传递到持久化层,导致 PG 中所有消息的 token 统计均为 0。 + +**Bug 10:后端 WS Handler 与 Eino 编排器重复获取历史** + +`handler.go` L240 获取了 `history` 并传给 `ProcessQuery`,但 `ProcessQuery`(`adapter.go`)内部并未使用这个参数。Eino Graph 的 History 节点(`nodes_history.go`)会自己重新调用 `sessionMgr.GetHistory`。传入的 `history` 参数被浪费了一次查询。 + +**Bug 11:后端 GetMessages 内存 fallback 的 beforeID 语义不一致** + +PostgreSQL 实现中 `beforeID` 是消息 ID 游标(`WHERE id < $2`),而内存 fallback 将其当作数组索引偏移量: + +```go +if beforeID > 0 && int(beforeID) <= total { + allMessages = allMessages[:beforeID] +} +``` + +两种实现的语义完全不同,切换存储后端时分页行为会不一致。 + +--- + +### 三、修复方案 + +#### 方案核心思路 + +将前端会话管理从 localStorage 迁移到后端 API,实现单一数据源。前端变为"薄客户端",会话 CRUD 和消息持久化全部走后端 `/api/conversations` 接口。 + +#### Phase 1:前端对接后端 API(解决 P0 Bug 1/2/3) + +**1.1 在 api.ts 中增加对话 API 封装** + +```typescript +// 新增对话 API +export async function listConversations(token: string, page = 1, size = 20) { ... } +export async function createConversation(token: string, config?: SessionConfig) { ... } +export async function getConversationMessages(token: string, id: string) { ... } +export async function deleteConversation(token: string, id: string) { ... } +export async function renameConversation(token: string, id: string, title: string) { ... } +``` + +**1.2 重写 useSessionList Hook** + +将所有 CRUD 操作从 localStorage 切换到后端 API: + +- `createSession` → `POST /api/conversations` +- `deleteSession` → `DELETE /api/conversations/:id` +- `renameSession` → `PATCH /api/conversations/:id` +- `selectSession` → `GET /api/conversations/:id/messages` +- 初始化时 → `GET /api/conversations` 加载列表 +- 移除 `saveSessionMessages` / `loadSessionMessages` 等 localStorage 操作 +- 将 `activeSessionId` 持久化到 localStorage(仅用于恢复选中状态) + +**1.3 初始化逻辑修复** + +```typescript +useEffect(() => { + if (!initializedRef.current) { + initializedRef.current = true; + if (sessions.length === 0) { + createSession(); + } else { + // 恢复上次选中的会话 + const lastId = localStorage.getItem('camtalk:last_active_session'); + if (lastId && sessions.find(s => s.id === lastId)) { + setActiveSessionId(lastId); + } + } + } +}, [sessions.length, createSession]); +``` + +#### Phase 2:WebSocket 会话切换(解决 P0 Bug 2, P1 Bug 4) + +**2.1 WebSocket 连接增加 conversation_id 参数** + +后端已支持 `conversation_id` 查询参数(`handler.go` L126-133),前端需要在 `connect` 时传入当前会话 ID: + +```typescript +connect(token?: string, conversationId?: string): void { + const params = new URLSearchParams(); + if (token) params.set('token', token); + if (conversationId) params.set('conversation_id', conversationId); + const url = `${WS_URL}?${params.toString()}`; + // ... +} +``` + +**2.2 切换会话时保持连接** + +在 `handleSelectSession` 中,不再调用 `stopSession()`,而是: +1. 保存当前会话消息到后端(如果需要) +2. 断开当前 WebSocket +3. 用新会话 ID 重新连接 + +或者更优方案:在 WebSocket 协议中增加 `switch_session` 消息类型,允许在保持连接的情况下切换后端会话。 + +#### Phase 3:清理前端冗余代码(解决 P1 Bug 5/6/7, P2 Bug 8) + +**3.1 移除 historyRef** + +删除 `useVisionSession` 中的 `historyRef` 及其所有 push 操作。前端不再维护独立的 LLM 上下文历史,完全依赖后端。 + +**3.2 消息列表使用稳定 key** + +将 `ChatMessage` 类型增加 `id` 字段(UUID),在创建消息时生成,用作文本 diff 和 React key。 + +```typescript +export interface ChatMessage { + id: string; // 新增 + role: "user" | "assistant" | "system"; + content: string; + // ... +} +``` + +#### Phase 4:后端修复(解决 P2 Bug 9/10/11) + +**4.1 传递 tokensUsed 到持久化层** + +修改 `AppendMessage` 接口,增加 `tokensUsed` 参数;或在 `EinoOrchestrator.ProcessQuery` 中,在 `llm_done` 后单独调用一次 `UpdateMessageMeta` 更新 token 信息。 + +**4.2 移除 WS Handler 中多余的 GetHistory 调用** + +删除 `handler.go` L240 的 `history` 获取,同时从 `ProcessQuery` 签名中移除 `history` 参数。 + +**4.3 统一 GetMessages beforeID 语义** + +内存 fallback 中改为基于消息序号的偏移量,或直接移除内存 fallback(生产环境始终使用 PG)。 + +--- + +### 四、实施优先级 + +| 优先级 | 修复项 | 预估工作量 | +|--------|--------|-----------| +| P0 | 前端对接后端 API + 初始化修复 | 2-3 天 | +| P0 | WebSocket 会话切换 | 1-2 天 | +| P1 | 清理 historyRef 死代码 | 0.5 天 | +| P2 | React key + tokensUsed + GetMessages | 1 天 | + +总计约 5-7 天可完成全部修复。Phase 1 是核心,完成后对话历史功能即可正常工作。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 73a7906..afb4729 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ // ============================================================ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { v4 as uuidv4 } from "uuid"; import { useVisionSession } from "./hooks/useVisionSession"; import { useSessionList } from "./hooks/useSessionList"; import { VideoPreview } from "./components/VideoPreview"; @@ -53,12 +54,13 @@ function AppContent() { const { sessions, activeSessionId, + isLoading: sessionsLoading, createSession, deleteSession, renameSession, - persistSession, selectSession, - } = useSessionList(); + loadSessions, + } = useSessionList(accessToken); // ---- 视觉会话 ---- const { @@ -87,7 +89,7 @@ function AppContent() { toggleCamera, toggleMic, sendTextMessage, - } = useVisionSession(accessToken); + } = useVisionSession(accessToken, activeSessionId); const isConnected = connectionStatus === "connected"; @@ -118,58 +120,57 @@ function AppContent() { const { t: tr } = useMemo(() => ({ t: (key: string) => t(key, parseLocale(config.language)) }), [config.language]); - // ---- 初始化:如果没有会话,创建一个 ---- + // ---- 初始化:加载完成后如果没有会话,创建一个 ---- const initializedRef = useRef(false); useEffect(() => { - if (!initializedRef.current) { + if (!sessionsLoading && !initializedRef.current) { initializedRef.current = true; if (sessions.length === 0) { createSession(); } } - }, [sessions.length, createSession]); - - // ---- 自动保存:messages 变化时持久化到当前会话 ---- - const messagesRef = useRef(messages); - useEffect(() => { messagesRef.current = messages; }, [messages]); + }, [sessionsLoading, sessions.length, createSession]); + // ---- 侧边栏打开时刷新会话列表 ---- useEffect(() => { - if (activeSessionId && messages.length > 0) { - persistSession(activeSessionId, messages); + if (sidebarOpen) { + loadSessions(); } - }, [messages, activeSessionId, persistSession]); + }, [sidebarOpen, loadSessions]); // ---- 侧边栏操作 ---- - const handleNewSession = useCallback(() => { - createSession(); - setMessages([]); - // 如果已连接,断开 + const handleNewSession = useCallback(async () => { + // 如果已连接,先断开 if (connectionStatus === "connected") { - stopSession(); + await stopSession(); } + // 通过后端 API 创建会话 + await createSession(); + setMessages([]); setSidebarOpen(false); }, [createSession, setMessages, connectionStatus, stopSession]); - const handleSelectSession = useCallback((id: string) => { - // 保存当前会话 - if (activeSessionId && messagesRef.current.length > 0) { - persistSession(activeSessionId, messagesRef.current); - } - // 如果已连接,断开 + const handleSelectSession = useCallback(async (id: string) => { + // 如果已连接,先断开 if (connectionStatus === "connected") { - stopSession(); + await stopSession(); } - // 加载目标会话 - const loaded = selectSession(id); + // 从后端 API 加载目标会话的消息 + const loaded = await selectSession(id); setMessages(loaded); - }, [activeSessionId, persistSession, connectionStatus, stopSession, selectSession, setMessages]); + setSidebarOpen(false); + }, [connectionStatus, stopSession, selectSession, setMessages]); - const handleDeleteSession = useCallback((id: string) => { - deleteSession(id); + const handleDeleteSession = useCallback(async (id: string) => { + await deleteSession(id); if (id === activeSessionId) { + // 断开连接并清空消息 + if (connectionStatus === "connected") { + await stopSession(); + } setMessages([]); } - }, [deleteSession, activeSessionId, setMessages]); + }, [deleteSession, activeSessionId, setMessages, connectionStatus, stopSession]); // ---- 识别画面 ---- const handleRecognize = useCallback(() => { @@ -194,6 +195,7 @@ function AppContent() { // 插入系统提示消息 const scenarioName = sc ? `${sc.icon} ${tr(sc.nameKey)}` : scenarioId; setMessages(prev => [...prev, { + id: uuidv4(), role: "system", content: tr("chat.scenarioSwitched").replace("{name}", scenarioName), timestamp: Date.now(), diff --git a/frontend/src/components/ChatPanel/index.tsx b/frontend/src/components/ChatPanel/index.tsx index 4336803..27e14cb 100644 --- a/frontend/src/components/ChatPanel/index.tsx +++ b/frontend/src/components/ChatPanel/index.tsx @@ -154,13 +154,13 @@ export function ChatPanel({
)} - {messages.map((msg, index) => ( + {messages.map((msg) => ( msg.role === "system" ? ( -
+
{msg.content}
) : ( -
+
{msg.role === "user" ? t("chat.userLabel") : "AI"}
diff --git a/frontend/src/components/WebSocketManager/index.tsx b/frontend/src/components/WebSocketManager/index.tsx index 48c00d7..acce95f 100644 --- a/frontend/src/components/WebSocketManager/index.tsx +++ b/frontend/src/components/WebSocketManager/index.tsx @@ -25,7 +25,7 @@ export function useWebSocketManager() { return { status, lastMessage, - connect: (token?: string) => wsClient.connect(token), + connect: (token?: string, conversationId?: string) => wsClient.connect(token, conversationId), disconnect: () => wsClient.disconnect(), send: wsClient.send.bind(wsClient), }; diff --git a/frontend/src/hooks/useSessionList.ts b/frontend/src/hooks/useSessionList.ts index 0a49c89..841a3e4 100644 --- a/frontend/src/hooks/useSessionList.ts +++ b/frontend/src/hooks/useSessionList.ts @@ -1,109 +1,227 @@ // ============================================================ -// useSessionList — 会话历史列表管理 -// 职责:会话 CRUD、消息持久化、切换会话 +// useSessionList — 会话历史列表管理(后端 API 驱动) +// 职责:会话 CRUD、消息加载、切换会话 +// 数据源:后端 /api/conversations REST API // ============================================================ import { useCallback, useEffect, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { - loadSessionSummaries, - saveSessionSummaries, - loadSessionMessages, - saveSessionMessages, - deleteSessionMessages, -} from "../lib/storage"; + listConversations, + createConversation, + deleteConversation, + renameConversation, + getConversationMessages, + type ConversationListItem, +} from "../lib/api"; import type { ChatMessage, SessionSummary } from "../types"; +const LAST_ACTIVE_KEY = "camtalk:last_active_session"; + /** 截取预览文本 */ 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(() => loadSessionSummaries()); - const [activeSessionId, setActiveSessionId] = useState(null); +/** 将后端 ConversationListItem 转为前端 SessionSummary */ +function toSessionSummary(item: ConversationListItem): SessionSummary { + return { + id: item.id, + title: item.title || "新对话", + createdAt: new Date(item.created_at).getTime(), + lastActiveAt: new Date(item.updated_at).getTime(), + messageCount: item.message_count, + preview: getPreview(item.last_message || ""), + }; +} + +/** 将后端 StoredMessage 转为前端 ChatMessage */ +function toChatMessage(msg: { + id: number; + role: string; + content: string; + tokens_used: number; + created_at: string; +}): ChatMessage { + return { + id: String(msg.id), + role: msg.role as ChatMessage["role"], + content: msg.content, + timestamp: new Date(msg.created_at).getTime(), + tokensUsed: msg.tokens_used || undefined, + }; +} + +export function useSessionList(accessToken?: string | null) { + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState( + () => localStorage.getItem(LAST_ACTIVE_KEY) + ); + const [isLoading, setIsLoading] = useState(false); const sessionsRef = useRef(sessions); - useEffect(() => { sessionsRef.current = sessions; }, [sessions]); + useEffect(() => { + sessionsRef.current = sessions; + }, [sessions]); + + // 持久化 activeSessionId 到 localStorage(仅用于恢复选中状态) + useEffect(() => { + if (activeSessionId) { + localStorage.setItem(LAST_ACTIVE_KEY, activeSessionId); + } else { + localStorage.removeItem(LAST_ACTIVE_KEY); + } + }, [activeSessionId]); + + /** 从后端加载会话列表 */ + const loadSessions = useCallback(async () => { + if (!accessToken) return; + setIsLoading(true); + try { + const res = await listConversations(accessToken); + if (res.data) { + const list = res.data.conversations.map(toSessionSummary); + setSessions(list); + // 恢复上次选中的会话(如果仍然存在) + const lastId = localStorage.getItem(LAST_ACTIVE_KEY); + if (lastId && list.find((s) => s.id === lastId)) { + setActiveSessionId(lastId); + } else if (list.length > 0) { + setActiveSessionId(list[0].id); + } + } + } catch (err) { + console.error("[SessionList] 加载会话列表失败:", err); + } finally { + setIsLoading(false); + } + }, [accessToken]); + + // 初始化加载 + token 变化时重新加载 + useEffect(() => { + if (accessToken) { + loadSessions(); + } + }, [accessToken]); // eslint-disable-line react-hooks/exhaustive-deps /** 创建新会话 */ - 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 createSession = useCallback(async (): Promise => { + if (!accessToken) { + // 未登录时回退到本地 ID + 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); + return id; + } + + try { + const res = await createConversation(accessToken); + if (res.data) { + const id = res.data.id; + const now = Date.now(); + const summary: SessionSummary = { + id, + title: res.data.title || "新对话", + createdAt: new Date(res.data.created_at).getTime() || now, + lastActiveAt: new Date(res.data.updated_at).getTime() || now, + messageCount: 0, + preview: "", + }; + setSessions((prev) => [summary, ...prev]); + setActiveSessionId(id); + return id; + } + } catch (err) { + console.error("[SessionList] 创建会话失败:", err); + } + return null; + }, [accessToken]); /** 删除会话 */ - 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 deleteSession = useCallback( + async (id: string) => { + setSessions((prev) => prev.filter((s) => s.id !== id)); + if (accessToken) { + try { + await deleteConversation(accessToken, id); + } catch (err) { + console.error("[SessionList] 删除会话失败:", err); + } + } + // 如果删除的是当前会话,切换到第一个或清空 + setActiveSessionId((prev) => { + if (prev === id) { + const remaining = sessionsRef.current.filter((s) => s.id !== id); + return remaining.length > 0 ? remaining[0].id : null; + } + return prev; + }); + }, + [accessToken] + ); /** 重命名会话 */ - 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 renameSession = useCallback( + async (id: string, title: string) => { + setSessions((prev) => + prev.map((s) => (s.id === id ? { ...s, title } : s)) + ); + if (accessToken) { + try { + await renameConversation(accessToken, id, title); + } catch (err) { + console.error("[SessionList] 重命名会话失败:", err); + } + } + }, + [accessToken] + ); - /** 保存指定会话的消息并更新摘要 */ - 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 = { - 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; - }); - }, []); + /** 加载指定会话的消息历史(从后端 API) */ + const loadMessages = useCallback( + async (sessionId: string): Promise => { + if (!accessToken) return []; + try { + const res = await getConversationMessages(accessToken, sessionId); + if (res.data) { + return res.data.messages.map(toChatMessage); + } + } catch (err) { + console.error("[SessionList] 加载消息失败:", err); + } + return []; + }, + [accessToken] + ); - /** 加载指定会话的消息历史 */ - const loadMessages = useCallback((sessionId: string): ChatMessage[] => { - return loadSessionMessages(sessionId); - }, []); - - /** 选择会话(返回需要加载的消息) */ - const selectSession = useCallback((id: string): ChatMessage[] => { - setActiveSessionId(id); - return loadSessionMessages(id); - }, []); + /** 选择会话 */ + const selectSession = useCallback( + async (id: string): Promise => { + setActiveSessionId(id); + return loadMessages(id); + }, + [loadMessages] + ); return { sessions, activeSessionId, setActiveSessionId, + isLoading, createSession, deleteSession, renameSession, - persistSession, loadMessages, selectSession, + loadSessions, }; } diff --git a/frontend/src/hooks/useVisionSession.ts b/frontend/src/hooks/useVisionSession.ts index 0e56558..4177345 100644 --- a/frontend/src/hooks/useVisionSession.ts +++ b/frontend/src/hooks/useVisionSession.ts @@ -22,14 +22,12 @@ import type { ChatMessage, SessionConfig, ServerMessage, LLMDoneMessage } from " export type SessionMode = "dialogue" | "observation"; -const MAX_HISTORY_ROUNDS = 10; - export interface SessionStats { queryCount: number; totalTokens: number; } -export function useVisionSession(accessToken?: string | null) { +export function useVisionSession(accessToken?: string | null, conversationId?: string | null) { const { t } = useI18n(); const [messages, setMessages] = useState([]); const [currentReply, setCurrentReply] = useState(""); @@ -44,9 +42,6 @@ export function useVisionSession(accessToken?: string | null) { // 上一帧采样数据(用于关键帧检测) const prevFrameRef = useRef(null); - // 对话历史(role + content),用于多轮上下文 - const historyRef = useRef>([]); - // 待发消息队列(未连接时暂存,连接后自动发送) const pendingMessagesRef = useRef>([]); @@ -77,6 +72,12 @@ export function useVisionSession(accessToken?: string | null) { statusRef.current = status; }, [status]); + // 用 ref 跟踪 conversationId,避免回调闭包问题 + const conversationIdRef = useRef(conversationId); + useEffect(() => { + conversationIdRef.current = conversationId; + }, [conversationId]); + // 观察模式:画面变化时自动发送 query const { isObserving, startObserving, stopObserving } = useObservationMode({ onChange: useCallback( @@ -94,6 +95,7 @@ export function useVisionSession(accessToken?: string | null) { setMessages((prev) => [ ...prev, { + id: uuidv4(), role: "user", content: t("session.changeDetected"), timestamp: Date.now(), @@ -146,9 +148,8 @@ export function useVisionSession(accessToken?: string | null) { setStats((prev) => ({ ...prev, queryCount: prev.queryCount + 1 })); setMessages((prev) => [ ...prev, - { role: "user", content: msg.text, timestamp: Date.now() }, + { id: uuidv4(), role: "user", content: msg.text, timestamp: Date.now() }, ]); - historyRef.current.push({ role: "user", content: msg.text }); setIsProcessing(true); } } @@ -220,7 +221,7 @@ export function useVisionSession(accessToken?: string | null) { // 添加用户消息(STT 流式结果会逐步更新文本) setMessages((prev) => [ ...prev, - { role: "user", content: t("session.recognizing"), timestamp: Date.now() }, + { id: uuidv4(), role: "user", content: t("session.recognizing"), timestamp: Date.now() }, ]); setIsProcessing(true); }, @@ -254,12 +255,6 @@ export function useVisionSession(accessToken?: string | null) { case "llm_done": { const done = msg as LLMDoneMessage; - // 记录到对话历史 - historyRef.current.push({ role: "assistant", content: done.full_text }); - // 裁剪历史到最近 N 轮 - if (historyRef.current.length > MAX_HISTORY_ROUNDS * 2) { - historyRef.current = historyRef.current.slice(-MAX_HISTORY_ROUNDS * 2); - } // 累计 token 统计 if (done.tokens_used?.total) { @@ -272,6 +267,7 @@ export function useVisionSession(accessToken?: string | null) { setMessages((prev) => [ ...prev, { + id: uuidv4(), role: "assistant", content: done.full_text, timestamp: Date.now(), @@ -317,9 +313,9 @@ export function useVisionSession(accessToken?: string | null) { /** 启动视频通话(摄像头 + 麦克风 + VAD) */ const startSession = useCallback(async () => { - // 1. 确保 WebSocket 已连接 + // 1. 确保 WebSocket 已连接(传入 conversationId 以恢复会话) if (statusRef.current !== "connected") { - connect(accessToken || undefined); + connect(accessToken || undefined, conversationIdRef.current || undefined); // 等待连接完成(通过 status 变化触发后续流程,这里直接继续) } @@ -357,7 +353,6 @@ export function useVisionSession(accessToken?: string | null) { setCurrentReply(""); setIsProcessing(false); setStats({ queryCount: 0, totalTokens: 0 }); - historyRef.current = []; prevFrameRef.current = null; setIsCameraOn(false); setIsMicOn(false); @@ -376,7 +371,7 @@ export function useVisionSession(accessToken?: string | null) { setIsProcessing(false); setIsCameraOn(false); setIsMicOn(false); - // 不断开 WebSocket,不清空消息、历史、统计 + // 不断开 WebSocket,不清空消息、统计 }, [stopObserving, stopVAD, stopMic, stopCamera]); /** 摄像头开关 */ @@ -414,10 +409,9 @@ export function useVisionSession(accessToken?: string | null) { // 将未完成的流式内容保存为最终消息 if (currentReply) { const interrupted = currentReply + t("session.interrupted"); - historyRef.current.push({ role: "assistant", content: interrupted }); setMessages((prev) => [ ...prev, - { role: "assistant", content: interrupted, timestamp: Date.now() }, + { id: uuidv4(), role: "assistant", content: interrupted, timestamp: Date.now() }, ]); } setCurrentReply(""); @@ -439,7 +433,7 @@ export function useVisionSession(accessToken?: string | null) { if (statusRef.current !== "connected") { pendingMessagesRef.current.push({ text: text.trim(), requestId }); // 自动连接 WebSocket(消息在连接成功后由 flush 统一添加到 UI,避免重复) - connect(accessToken || undefined); + connect(accessToken || undefined, conversationIdRef.current || undefined); return; } @@ -459,12 +453,9 @@ export function useVisionSession(accessToken?: string | null) { // 添加用户消息 setMessages((prev) => [ ...prev, - { role: "user", content: text.trim(), timestamp: Date.now() }, + { id: uuidv4(), role: "user", content: text.trim(), timestamp: Date.now() }, ]); - // 记录到对话历史 - historyRef.current.push({ role: "user", content: text.trim() }); - setIsProcessing(true); }, [captureFrame, send, connect, accessToken], diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ea11424..a95c3ce 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -104,3 +104,96 @@ export async function logout( body: JSON.stringify({ refresh_token: refreshToken }), }); } + +// ---- Conversation API ---- + +export interface ConversationListItem { + id: string; + title: string; + last_message: string; + message_count: number; + created_at: string; + updated_at: string; +} + +export interface ConversationListResponse { + conversations: ConversationListItem[]; + total: number; + page: number; + size: number; +} + +export interface CreateConversationResponse { + id: string; + title: string; + created_at: string; + updated_at: string; +} + +export interface StoredMessage { + id: number; + role: string; + content: string; + tokens_used: number; + created_at: string; +} + +export interface MessagesResponse { + messages: StoredMessage[]; + total: number; +} + +export async function listConversations( + token: string, + page = 1, + size = 50 +): Promise> { + return request( + `/conversations?page=${page}&size=${size}`, + { headers: authHeaders(token) } + ); +} + +export async function createConversation( + token: string, + config?: Record +): Promise> { + return request("/conversations", { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify(config ? { config } : {}), + }); +} + +export async function deleteConversation( + token: string, + id: string +): Promise> { + return request(`/conversations/${id}`, { + method: "DELETE", + headers: authHeaders(token), + }); +} + +export async function renameConversation( + token: string, + id: string, + title: string +): Promise> { + return request<{ message: string }>(`/conversations/${id}`, { + method: "PATCH", + headers: authHeaders(token), + body: JSON.stringify({ title }), + }); +} + +export async function getConversationMessages( + token: string, + id: string, + limit = 200 +): Promise> { + return request( + `/conversations/${id}/messages?limit=${limit}`, + { headers: authHeaders(token) } + ); +} diff --git a/frontend/src/lib/websocket.ts b/frontend/src/lib/websocket.ts index 356413a..c9b4946 100644 --- a/frontend/src/lib/websocket.ts +++ b/frontend/src/lib/websocket.ts @@ -25,6 +25,7 @@ export class CamTalkWebSocket { private reconnectTimer: ReturnType | null = null; private shouldReconnect = true; private token: string | undefined; + private conversationId: string | undefined; private messageHandlers = new Set(); private statusHandlers = new Set(); @@ -46,15 +47,20 @@ export class CamTalkWebSocket { return () => this.statusHandlers.delete(handler); } - /** 建立连接,可选传入 JWT token 用于认证 */ - connect(token?: string): void { + /** 建立连接,可选传入 JWT token 和 conversation_id 用于认证和会话恢复 */ + connect(token?: string, conversationId?: string): void { if (this.ws?.readyState === WebSocket.OPEN) return; this.token = token; + this.conversationId = conversationId; this.shouldReconnect = true; this.setStatus("connecting"); - const url = token ? `${WS_URL}?token=${encodeURIComponent(token)}` : WS_URL; + const params = new URLSearchParams(); + if (token) params.set("token", token); + if (conversationId) params.set("conversation_id", conversationId); + const queryString = params.toString(); + const url = queryString ? `${WS_URL}?${queryString}` : WS_URL; const ws = new WebSocket(url); ws.onopen = () => { @@ -133,7 +139,7 @@ export class CamTalkWebSocket { this.reconnectTimer = setTimeout(() => { this.reconnectAttempt++; - this.connect(this.token); + this.connect(this.token, this.conversationId); }, totalDelay); } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index acd0cd3..5ae323e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -33,6 +33,7 @@ export interface SessionSummary { // ---- 聊天消息 ---- export interface ChatMessage { + id: string; role: "user" | "assistant" | "system"; content: string; imageUrl?: string; -- 2.49.1