feat:添加对话情景功能

This commit is contained in:
2026-06-14 20:32:30 +08:00
parent c3118dd72a
commit b60e513317
20 changed files with 438 additions and 59 deletions

View File

@@ -760,6 +760,37 @@ body {
letter-spacing: 0.02em;
}
/* ---- Scenario Quick Selector (chat header) ---- */
.scenario-selector {
appearance: none;
-webkit-appearance: none;
font-size: 0.7rem;
font-weight: 500;
color: var(--color-text-muted);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 4px 26px 4px 10px;
cursor: pointer;
outline: none;
transition: all var(--transition-fast);
letter-spacing: 0.01em;
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%23888' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
}
.scenario-selector:hover {
border-color: var(--color-primary);
color: var(--color-text);
background-color: var(--color-surface-3);
}
.scenario-selector:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.chat-panel-header__stats {
font-size: 0.68rem;
font-weight: 400;
@@ -877,6 +908,31 @@ body {
opacity: 0.85;
}
/* ---- System message (scenario switch, etc.) ---- */
.chat-message--system {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 6px 0;
background: transparent;
}
.chat-message--system::before,
.chat-message--system::after {
content: "";
flex: 1;
height: 1px;
background: var(--color-border);
}
.chat-message--system__text {
font-size: 0.7rem;
color: var(--color-text-muted);
white-space: nowrap;
letter-spacing: 0.02em;
}
.chat-message__role {
font-size: 0.62rem;
font-weight: 600;
@@ -1505,6 +1561,75 @@ body {
line-height: 1.4;
}
/* ---- Scenario Selection Cards ---- */
.scenario-cards {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 24px;
max-width: 380px;
width: 100%;
margin-bottom: 8px;
}
.scenario-cards__title {
font-size: 0.72rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
padding: 0 4px 4px;
}
.scenario-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 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;
}
.scenario-card:hover {
background: var(--color-surface-3);
border-color: var(--color-primary);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.08);
}
.scenario-card:active {
transform: translateY(0);
}
.scenario-card__icon {
font-size: 1.4rem;
flex-shrink: 0;
margin-top: 1px;
}
.scenario-card__text {
display: flex;
flex-direction: column;
gap: 3px;
}
.scenario-card__title {
font-size: 0.85rem;
font-weight: 500;
color: var(--color-text);
}
.scenario-card__desc {
font-size: 0.72rem;
color: var(--color-text-muted);
line-height: 1.4;
}
/* ---- Voice Input Button ---- */
.chat-input__voice {

View File

@@ -15,6 +15,7 @@ import { AuthPage } from "./components/AuthPage";
import { AuthProvider, useAuth } from "./lib/auth";
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
import { I18nContext, parseLocale, t } from "./lib/i18n";
import { scenarios } from "./lib/scenarios";
import type { Locale } from "./lib/i18n";
import type { Theme } from "./types";
import "./App.css";
@@ -180,6 +181,27 @@ function AppContent() {
sendTextMessage(prompt);
}, [sendTextMessage]);
// ---- 情景选择 ----
const handleSelectScenario = useCallback((scenarioId: string) => {
const sc = scenarios.find(s => s.id === scenarioId);
const updates: Partial<import("./types").SessionConfig> = { scenario: scenarioId };
// 如果情景有默认语言,同步切换
if (sc?.defaultLanguage) {
updates.language = sc.defaultLanguage;
}
updateConfig(updates);
// 插入系统提示消息
const scenarioName = sc ? `${sc.icon} ${tr(sc.nameKey)}` : scenarioId;
setMessages(prev => [...prev, {
role: "system",
content: tr("chat.scenarioSwitched").replace("{name}", scenarioName),
timestamp: Date.now(),
}]);
}, [updateConfig, setMessages, tr]);
// 当前情景对象
const activeScenario = scenarios.find(s => s.id === (config.scenario || "free_chat")) || scenarios[0];
// ---- 键盘快捷键 ----
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@@ -443,6 +465,16 @@ function AppContent() {
<div className="chat-panel-header">
<span>{tr("chat.title")}</span>
<div className="chat-panel-header__right">
<select
className="scenario-selector"
value={activeScenario.id}
onChange={(e) => handleSelectScenario(e.target.value)}
title={tr("settings.scenario")}
>
{scenarios.map((sc) => (
<option key={sc.id} value={sc.id}>{sc.icon} {tr(sc.nameKey)}</option>
))}
</select>
{isConnected && mode === "observation" && (
<span className="chat-panel-header__mode">{tr("chat.mode.observation")}</span>
)}
@@ -465,14 +497,16 @@ function AppContent() {
messages={messages}
currentReply={currentReply}
connectionStatus={connectionStatus}
currentScenario={config.scenario}
isProcessing={isProcessing}
isVADReady={isVADReady}
vadError={vadError}
vadError={vadError ?? undefined}
isMicOn={isMicOn}
isSpeaking={isSpeaking}
onSendText={sendTextMessage}
onToggleMic={toggleMic}
onSceneCard={handleSceneCard}
onSelectScenario={handleSelectScenario}
/>
</div>
</div>

View File

@@ -1,11 +1,12 @@
// ============================================================
// ChatPanel — 消息展示面板
// 职责:渲染对话消息列表、流式光标、元数据、自动滚动、文本输入
// 增强:空状态场景卡片、语音输入按钮
// 增强:空状态情景选择卡片、语音输入按钮
// ============================================================
import { useEffect, useRef, useState } from "react";
import { useI18n } from "../../lib/i18n";
import { scenarios } from "../../lib/scenarios";
import type { ChatMessage } from "../../types";
import type { ConnectionStatus } from "../../lib/websocket";
@@ -13,6 +14,7 @@ interface ChatPanelProps {
messages: ChatMessage[];
currentReply?: string;
connectionStatus: ConnectionStatus;
currentScenario?: string;
isProcessing?: boolean;
isVADReady?: boolean;
vadError?: string;
@@ -21,9 +23,10 @@ interface ChatPanelProps {
onSendText?: (text: string) => void;
onToggleMic?: () => void;
onSceneCard?: (prompt: string) => void;
onSelectScenario?: (scenarioId: string) => void;
}
/** 场景卡片数据 */
/** 场景卡片数据(视觉分析快捷) */
function getSceneCards(t: (key: string) => string) {
return [
{ icon: "👁", titleKey: "scene.describe", descKey: "scene.describe.desc", prompt: t("scene.describe") },
@@ -37,6 +40,7 @@ export function ChatPanel({
messages,
currentReply,
connectionStatus,
currentScenario,
isProcessing,
isVADReady,
vadError,
@@ -45,6 +49,7 @@ export function ChatPanel({
onSendText,
onToggleMic,
onSceneCard,
onSelectScenario,
}: ChatPanelProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -55,6 +60,8 @@ export function ChatPanel({
const isConnected = connectionStatus === "connected";
const isEmpty = messages.length === 0 && !currentReply;
const sceneCards = getSceneCards(t);
const activeScenario = currentScenario || "free_chat";
const isFreeChat = activeScenario === "free_chat";
// 用户上滚时暂停自动滚动,滚到底部时恢复
useEffect(() => {
@@ -97,46 +104,74 @@ export function ChatPanel({
return (
<div className="chat-panel">
<div className="chat-panel__messages" ref={containerRef}>
{/* 空状态:场景卡片 */}
{/* 空状态:情景选择 + 场景卡片 */}
{isEmpty && (
<div className="chat-panel__welcome">
<span className="chat-panel__welcome-icon">💬</span>
<span className="chat-panel__welcome-icon">{scenarios.find(s => s.id === activeScenario)?.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>
{/* 情景选择卡片(非 free_chat 时隐藏,因为已通过 header 切换) */}
{isFreeChat && onSelectScenario && (
<div className="scenario-cards">
<div className="scenario-cards__title">{t("scenario.choose")}</div>
{scenarios.filter(s => s.id !== "free_chat").map((sc) => (
<button
key={sc.id}
className="scenario-card"
onClick={() => onSelectScenario(sc.id)}
>
<span className="scenario-card__icon">{sc.icon}</span>
<div className="scenario-card__text">
<span className="scenario-card__title">{t(sc.nameKey)}</span>
<span className="scenario-card__desc">{t(sc.descKey)}</span>
</div>
</button>
))}
</div>
)}
{/* 视觉分析快捷卡片(仅 free_chat 模式) */}
{isFreeChat && isConnected && (
<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>
)}
{messages.map((msg, index) => (
<div key={index} className={`chat-message chat-message--${msg.role}`}>
<div className="chat-message__role">
{msg.role === "user" ? t("chat.userLabel") : "AI"}
msg.role === "system" ? (
<div key={index} className="chat-message chat-message--system">
<span className="chat-message--system__text">{msg.content}</span>
</div>
<div className="chat-message__content">{msg.content}</div>
{msg.role === "assistant" && msg.tokensUsed !== undefined && (
<div className="chat-message__meta">
{msg.tokensUsed} tokens
{msg.latencyMs !== undefined && ` · ${(msg.latencyMs / 1000).toFixed(1)}s`}
{msg.model && ` · ${msg.model}`}
) : (
<div key={index} className={`chat-message chat-message--${msg.role}`}>
<div className="chat-message__role">
{msg.role === "user" ? t("chat.userLabel") : "AI"}
</div>
)}
</div>
<div className="chat-message__content">{msg.content}</div>
{msg.role === "assistant" && msg.tokensUsed !== undefined && (
<div className="chat-message__meta">
{msg.tokensUsed} tokens
{msg.latencyMs !== undefined && ` · ${(msg.latencyMs / 1000).toFixed(1)}s`}
{msg.model && ` · ${msg.model}`}
</div>
)}
</div>
)
))}
{/* 流式回复(尚未完成) */}

View File

@@ -1,9 +1,10 @@
// ============================================================
// ConfigPanel — 右侧抽屉式配置面板
// 职责主题切换、TTS 开关、detail level 切换、语言选择
// 职责主题切换、TTS 开关、detail level 切换、语言选择、情景选择
// ============================================================
import { useI18n } from "../../lib/i18n";
import { scenarios } from "../../lib/scenarios";
import type { SessionConfig, Theme } from "../../types";
interface ConfigPanelProps {
@@ -89,6 +90,21 @@ export function ConfigPanel({ config, theme, username, onUpdate, onThemeChange,
<option value="ja-JP"></option>
</select>
</label>
<label className="config-row">
<div className="config-row__info">
<span className="config-row__label">{t("settings.scenario")}</span>
<span className="config-row__desc">{t("settings.scenario.desc")}</span>
</div>
<select
value={config.scenario || "free_chat"}
onChange={(e) => onUpdate({ scenario: e.target.value })}
>
{scenarios.map((sc) => (
<option key={sc.id} value={sc.id}>{sc.icon} {t(sc.nameKey)}</option>
))}
</select>
</label>
</div>
{username && onLogout && (

View File

@@ -63,14 +63,6 @@ export function useSessionList() {
saveSessionSummaries(updated);
}, []);
/** 保存当前会话的消息历史 */
const saveCurrentSession = useCallback((messages: ChatMessage[]) => {
const id = sessionsRef.current.length > 0 ? sessionsRef.current[0].id : null;
// 找到 activeSessionId 对应的会话
// 这里不依赖 activeSessionId state而是通过参数传入
return messages;
}, []);
/** 保存指定会话的消息并更新摘要 */
const persistSession = useCallback((sessionId: string, messages: ChatMessage[]) => {
if (!sessionId) return;

View File

@@ -3,7 +3,7 @@
// 来源docs/03-接口文档.md §五 错误码
// ============================================================
import type { ErrorCode } from "../types";
// ErrorCode type reserved for future use
/** 将错误码转为用户友好文案(需要传入翻译函数) */
export function getErrorMessage(code: string, translate: (key: string) => string): string {

View File

@@ -26,6 +26,21 @@ export const enUS: TranslationMap = {
"settings.detail.high": "High",
"settings.language": "Language",
"settings.language.desc": "Interaction language preference",
"settings.scenario": "Scenario",
"settings.scenario.desc": "Choose the AI's role",
// Scenarios
"scenario.choose": "Choose a scenario",
"scenario.freeChat": "Free Chat",
"scenario.freeChat.desc": "AI visual assistant, chat freely",
"scenario.interviewer": "Mock Interviewer",
"scenario.interviewer.desc": "AI plays interviewer for practice",
"scenario.englishTeacher": "English Tutor",
"scenario.englishTeacher.desc": "AI English tutor, corrects grammar",
"scenario.debate": "Debate Opponent",
"scenario.debate.desc": "AI challenges your viewpoints",
"scenario.interpreter": "Interpreter",
"scenario.interpreter.desc": "Real-time Chinese-English translation",
// Video indicators
"video.observing": "👁️ Observing",
@@ -63,6 +78,7 @@ export const enUS: TranslationMap = {
"chat.userLabel": "You",
"chat.input.placeholder": "Type a message...",
"chat.send": "Send",
"chat.scenarioSwitched": "Switched to {name} mode",
// Session messages
"session.changeDetected": "👁️ Scene change detected",

View File

@@ -26,6 +26,21 @@ export const jaJP: TranslationMap = {
"settings.detail.high": "高",
"settings.language": "言語",
"settings.language.desc": "インタラクション言語の設定",
"settings.scenario": "シナリオ",
"settings.scenario.desc": "AIの役割を選択",
// Scenarios
"scenario.choose": "シナリオを選択",
"scenario.freeChat": "フリーチャット",
"scenario.freeChat.desc": "AIビジュアルアシスタントと自由に会話",
"scenario.interviewer": "模擬面接官",
"scenario.interviewer.desc": "AIが面接官として模擬面接を実施",
"scenario.englishTeacher": "英語の先生",
"scenario.englishTeacher.desc": "AI英語教師が文法を修正し会話を導く",
"scenario.debate": "ディベート相手",
"scenario.debate.desc": "AIがあなたの观点に反論し論理的思考を鍛える",
"scenario.interpreter": "同時通訳",
"scenario.interpreter.desc": "リアルタイムで中日相互翻訳",
// Video indicators
"video.observing": "👁️ 観察中",
@@ -63,6 +78,7 @@ export const jaJP: TranslationMap = {
"chat.userLabel": "あなた",
"chat.input.placeholder": "メッセージを入力...",
"chat.send": "送信",
"chat.scenarioSwitched": "{name} モードに切り替えました",
// Session messages
"session.changeDetected": "👁️ シーン変化を検出",

View File

@@ -26,6 +26,21 @@ export const zhCN: TranslationMap = {
"settings.detail.high": "高",
"settings.language": "语言",
"settings.language.desc": "交互语言偏好",
"settings.scenario": "对话情景",
"settings.scenario.desc": "选择 AI 扮演的角色",
// Scenarios
"scenario.choose": "选择对话情景",
"scenario.freeChat": "自由对话",
"scenario.freeChat.desc": "AI 视觉助手,自由聊天",
"scenario.interviewer": "模拟面试官",
"scenario.interviewer.desc": "AI 扮演面试官,进行模拟面试",
"scenario.englishTeacher": "英语老师",
"scenario.englishTeacher.desc": "AI 英语外教,纠正语法并引导对话",
"scenario.debate": "辩论对手",
"scenario.debate.desc": "AI 反驳你的观点,锻炼逻辑思维",
"scenario.interpreter": "同声翻译",
"scenario.interpreter.desc": "实时中英互译,口语化翻译",
// Video indicators
"video.observing": "👁️ 观察中",
@@ -63,6 +78,7 @@ export const zhCN: TranslationMap = {
"chat.userLabel": "你",
"chat.input.placeholder": "输入文字对话...",
"chat.send": "发送",
"chat.scenarioSwitched": "已切换到 {name} 模式",
// Session messages
"session.changeDetected": "👁️ 画面变化检测",

View File

@@ -0,0 +1,47 @@
// ============================================================
// scenarios — 对话情景定义
// 职责:定义预置情景的展示数据和交互行为
// 注意system prompt 由后端 scenarios.go 管理,此处仅定义 UI 展示数据
// ============================================================
export interface Scenario {
id: string;
icon: string;
nameKey: string; // i18n key
descKey: string; // i18n key
defaultLanguage?: string; // 可选:切换情景时自动设置的语言
}
export const scenarios: Scenario[] = [
{
id: "free_chat",
icon: "💬",
nameKey: "scenario.freeChat",
descKey: "scenario.freeChat.desc",
},
{
id: "interviewer",
icon: "🎯",
nameKey: "scenario.interviewer",
descKey: "scenario.interviewer.desc",
},
{
id: "english_teacher",
icon: "📚",
nameKey: "scenario.englishTeacher",
descKey: "scenario.englishTeacher.desc",
defaultLanguage: "en-US",
},
{
id: "debate",
icon: "⚔️",
nameKey: "scenario.debate",
descKey: "scenario.debate.desc",
},
{
id: "interpreter",
icon: "🌐",
nameKey: "scenario.interpreter",
descKey: "scenario.interpreter.desc",
},
];

View File

@@ -15,6 +15,7 @@ const DEFAULT_CONFIG: SessionConfig = {
ttsEnabled: true,
detailLevel: "low",
language: "zh-CN",
scenario: "free_chat",
};
/** 加载配置,无存储时返回默认值 */

View File

@@ -9,6 +9,7 @@ export interface SessionConfig {
ttsEnabled: boolean;
detailLevel: "low" | "high";
language: string;
scenario: string; // 情景 ID如 "free_chat"、"interviewer"
}
export type Theme = "dark" | "light";
@@ -32,7 +33,7 @@ export interface SessionSummary {
// ---- 聊天消息 ----
export interface ChatMessage {
role: "user" | "assistant";
role: "user" | "assistant" | "system";
content: string;
imageUrl?: string;
timestamp: number;