diff --git a/backend/internal/ai/llm/llm.go b/backend/internal/ai/llm/llm.go index 13f3990..8aa2e8b 100644 --- a/backend/internal/ai/llm/llm.go +++ b/backend/internal/ai/llm/llm.go @@ -15,10 +15,11 @@ type Service interface { // Request 推理请求。 type Request struct { - Image []byte // JPEG 图片(已从 Base64 解码) - Text string // 用户语音识别后的文本 - History []models.Message // 最近 N 轮对话历史 - Language string // 语言,如 "zh-CN" + Image []byte // JPEG 图片(已从 Base64 解码) + Text string // 用户语音识别后的文本 + History []models.Message // 最近 N 轮对话历史 + Language string // 语言,如 "zh-CN" + SystemPrompt string // 情景自定义 system prompt(非空时覆盖默认 prompt) } // Chunk 流式推理的一个增量片段。 diff --git a/backend/internal/ai/llm/openai.go b/backend/internal/ai/llm/openai.go index e421d7e..982264d 100644 --- a/backend/internal/ai/llm/openai.go +++ b/backend/internal/ai/llm/openai.go @@ -209,10 +209,10 @@ func (o *OpenAIService) parseSSEStream(body io.Reader, ch chan<- Chunk) { func (o *OpenAIService) buildMessages(req Request) []chatMessage { var messages []chatMessage - // System prompt + // System prompt(情景覆盖优先) messages = append(messages, chatMessage{ Role: "system", - Content: []contentPart{{Type: "text", Text: BuildSystemPrompt(req.Language, "")}}, + Content: []contentPart{{Type: "text", Text: BuildSystemPrompt(req.Language, "", req.SystemPrompt)}}, }) // 历史消息 diff --git a/backend/internal/ai/llm/openai_test.go b/backend/internal/ai/llm/openai_test.go index 240e523..f75cbb3 100644 --- a/backend/internal/ai/llm/openai_test.go +++ b/backend/internal/ai/llm/openai_test.go @@ -242,9 +242,9 @@ func TestBuildSystemPrompt(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := BuildSystemPrompt(tt.language, tt.detailLevel) + got := BuildSystemPrompt(tt.language, tt.detailLevel, "") if !strings.Contains(got, tt.wantContain) { - t.Errorf("BuildSystemPrompt(%q, %q) should contain %q", tt.language, tt.detailLevel, tt.wantContain) + t.Errorf("BuildSystemPrompt(%q, %q, \"\") should contain %q", tt.language, tt.detailLevel, tt.wantContain) } }) } diff --git a/backend/internal/ai/llm/prompt.go b/backend/internal/ai/llm/prompt.go index 417288a..f98e6ce 100644 --- a/backend/internal/ai/llm/prompt.go +++ b/backend/internal/ai/llm/prompt.go @@ -2,10 +2,26 @@ package llm import "strings" -// BuildSystemPrompt 根据语言和细节级别构建系统提示词。 -func BuildSystemPrompt(language, detailLevel string) string { +// BuildSystemPrompt 根据语言、细节级别和情景 prompt 构建系统提示词。 +// scenarioPrompt 非空时,覆盖默认视觉助手 prompt。 +func BuildSystemPrompt(language, detailLevel, scenarioPrompt string) string { isChinese := strings.HasPrefix(language, "zh") + // 情景模式:使用自定义 prompt 作为基础 + if scenarioPrompt != "" { + var prompt strings.Builder + prompt.WriteString(scenarioPrompt) + if detailLevel == "high" { + if isChinese { + prompt.WriteString(" 请在涉及视觉内容时提供更详细的描述,包括颜色、位置、数量等细节。") + } else { + prompt.WriteString(" When describing visual content, provide detailed descriptions including colors, positions, quantities, and other details.") + } + } + return prompt.String() + } + + // 默认模式:视觉助手 var prompt strings.Builder if isChinese { prompt.WriteString("你是一个视觉助手。用户通过摄像头看到一个场景,并用语音向你提问。请用简洁自然的中文回答。如果涉及视觉描述,先说\"我看到……\"。回答控制在3-5句话以内,除非用户要求详细说明。") diff --git a/backend/internal/ai/llm/scenarios.go b/backend/internal/ai/llm/scenarios.go new file mode 100644 index 0000000..d11252a --- /dev/null +++ b/backend/internal/ai/llm/scenarios.go @@ -0,0 +1,55 @@ +package llm + +import "strings" + +// scenarioPrompt 定义单个情景的多语言 system prompt。 +type scenarioPrompt struct { + ZH string + EN string + JA string +} + +// scenarioPrompts 预置情景 → prompt 映射表。 +// key 为情景 ID(与前端 Scenario.id 对齐)。 +var scenarioPrompts = map[string]scenarioPrompt{ + "interviewer": { + ZH: "你是一位资深面试官。你通过摄像头观察面试者,并根据他们的背景和表现提出面试问题。规则:1) 每次只问一个问题,等用户回答后再追问;2) 问题要有层次,从自我介绍到专业问题再到情景题;3) 对用户的回答给出简短点评然后追问;4) 如果摄像头能看到用户的环境,可以结合环境提出相关话题;5) 回答控制在2-4句话。", + EN: "You are a senior interviewer. You observe the interviewee through their camera and ask interview questions based on their background and performance. Rules: 1) Ask one question at a time, wait for the answer before following up; 2) Questions should progress from self-introduction to professional questions to situational questions; 3) Give brief feedback on answers then follow up; 4) If the camera shows the user's environment, incorporate it into the conversation; 5) Keep responses to 2-4 sentences.", + JA: "あなたはベテラン面接官です。カメラで面接者を見て、バックグラウンドと実績に基づいて面接質問をします。ルール:1) 一度に一つの質問だけし、回答を待ってから追及する;2) 質問は自己紹介から専門質問、シチュエーション質問へと段階的に;3) 回答に短いコメントをしてから次の質問へ;4) 回答は2〜4文以内。", + }, + "english_teacher": { + ZH: "You are a friendly and patient English tutor. Speak in English with the user. Rules: 1) Always respond in English; 2) If the user makes grammar or vocabulary mistakes, gently point them out and suggest corrections; 3) Ask follow-up questions to keep the conversation going; 4) Adjust your language complexity based on the user's level; 5) If the camera shows objects or scenes, use them as teaching material (e.g., 'I can see a bookshelf behind you. What's your favorite book?'); 6) Keep responses to 3-5 sentences.", + EN: "You are a friendly and patient English tutor. Speak in English with the user. Rules: 1) Always respond in English; 2) If the user makes grammar or vocabulary mistakes, gently point them out and suggest corrections; 3) Ask follow-up questions to keep the conversation going; 4) Adjust your language complexity based on the user's level; 5) If the camera shows objects or scenes, use them as teaching material; 6) Keep responses to 3-5 sentences.", + JA: "You are a friendly and patient English tutor. Speak in English with the user. Rules: 1) Always respond in English; 2) If the user makes grammar or vocabulary mistakes, gently point them out and suggest corrections; 3) Ask follow-up questions to keep the conversation going; 4) Adjust your language complexity based on the user's level; 5) If the camera shows objects or scenes, use them as teaching material; 6) Keep responses to 3-5 sentences.", + }, + "debate": { + ZH: "你是一位辩论赛对手。用户提出一个观点,你需要站在反方进行反驳。规则:1) 逻辑严密,用事实和论据反驳,不要人身攻击;2) 每次提出1-2个核心反驳点,并给出简要论据;3) 如果用户论证有力,承认其合理性但仍要寻找突破口;4) 适时提出反问,引导用户深入思考;5) 回答控制在3-5句话。", + EN: "You are a debate opponent. The user presents a viewpoint, and you argue against it. Rules: 1) Use logic and evidence, no personal attacks; 2) Present 1-2 core counterarguments with brief evidence; 3) Acknowledge strong points but look for weaknesses; 4) Ask counter-questions to provoke deeper thinking; 5) Keep responses to 3-5 sentences.", + JA: "あなたはディベートの相手です。ユーザーが提示した观点に対して反論します。ルール:1) 論理と証拠で反論し、人格攻撃はしない;2) 1〜2つの核心的な反論を提示する;3) 相手の有力な論点は認めつつも突破口を探す;4) 深い思考を促す反问をする;5) 回答は3〜5文以内。", + }, + "interpreter": { + ZH: "你是一名同声翻译员。将用户说的话实时翻译为目标语言。规则:1) 只输出翻译结果,不加任何解释或评论;2) 保持口语化,自然流畅;3) 如果用户说中文,翻译成英文;如果用户说英文,翻译成中文;4) 如果不确定目标语言,默认中英互译;5) 对于专有名词,首次翻译时在括号中注明原文。", + EN: "You are a simultaneous interpreter. Translate what the user says in real-time. Rules: 1) Only output the translation, no explanations or comments; 2) Keep it conversational and natural; 3) If the user speaks Chinese, translate to English; if English, translate to Chinese; 4) Default to Chinese-English translation if the target language is unclear; 5) For proper nouns, note the original in parentheses on first use.", + JA: "あなたは同時通訳者です。ユーザーの発言をリアルタイムで翻訳します。ルール:1) 翻訳結果のみ出力し、説明やコメントは加えない;2) 口語的で自然な表現を維持する;3) ユーザーが中国語を話せば英語に、英語を話せば中国語に翻訳する;4) 固有名詞は初出時に原文を括弧で注記する。", + }, +} + +// GetScenarioPrompt 根据情景 ID 和语言获取对应的 system prompt。 +// 返回空字符串表示无此情景(使用默认 prompt)。 +func GetScenarioPrompt(scenarioID, language string) string { + if scenarioID == "" || scenarioID == "free_chat" { + return "" + } + p, ok := scenarioPrompts[scenarioID] + if !ok { + return "" + } + switch { + case strings.HasPrefix(language, "zh"): + return p.ZH + case strings.HasPrefix(language, "ja"): + return p.JA + default: + return p.EN + } +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index e6bf9f3..d3bc94f 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -17,6 +17,7 @@ type SessionConfig struct { TTSEnabled bool `json:"tts_enabled"` DetailLevel string `json:"detail_level"` // "low" | "high" Language string `json:"language"` + Scenario string `json:"scenario"` // 情景 ID,如 "free_chat"、"interviewer" } // DefaultSessionTitle 默认会话标题。 @@ -24,7 +25,7 @@ const DefaultSessionTitle = "新对话" // DefaultConfig 默认会话配置。 func DefaultConfig() SessionConfig { - return SessionConfig{TTSEnabled: true, DetailLevel: "low", Language: "zh-CN"} + return SessionConfig{TTSEnabled: true, DetailLevel: "low", Language: "zh-CN", Scenario: "free_chat"} } // SessionConfigPatch 会话配置增量更新(指针字段表示"未传则不更新")。 @@ -32,6 +33,7 @@ type SessionConfigPatch struct { TTSEnabled *bool `json:"tts_enabled,omitempty"` DetailLevel *string `json:"detail_level,omitempty"` Language *string `json:"language,omitempty"` + Scenario *string `json:"scenario,omitempty"` } // Apply 将 patch 中的非 nil 字段覆盖到 cfg。 @@ -45,6 +47,9 @@ func (p SessionConfigPatch) Apply(cfg *SessionConfig) { if p.Language != nil { cfg.Language = *p.Language } + if p.Scenario != nil { + cfg.Scenario = *p.Scenario + } } // User 用户。 @@ -81,6 +86,7 @@ type WsConfig struct { TTSEnabled *bool `json:"tts_enabled,omitempty"` DetailLevel *string `json:"detail_level,omitempty"` Language *string `json:"language,omitempty"` + Scenario *string `json:"scenario,omitempty"` } `json:"payload"` } diff --git a/backend/internal/orchestrator/pipeline.go b/backend/internal/orchestrator/pipeline.go index 87d8146..5ed94e6 100644 --- a/backend/internal/orchestrator/pipeline.go +++ b/backend/internal/orchestrator/pipeline.go @@ -193,12 +193,13 @@ func (p *Pipeline) ProcessQuery( }) // Step 2+3: LLM 流式推理 + TTS 并行合成 - log.Infow("开始 LLM 推理", "request_id", req.RequestID) + log.Infow("开始 LLM 推理", "request_id", req.RequestID, "scenario", sess.Config.Scenario) llmReq := llm.Request{ - Image: image, - Text: userText, - History: history, - Language: sess.Config.Language, + Image: image, + Text: userText, + History: history, + Language: sess.Config.Language, + SystemPrompt: llm.GetScenarioPrompt(sess.Config.Scenario, sess.Config.Language), } llmStream, err := p.llmService.ChatStream(ctx, llmReq) diff --git a/backend/internal/ws/handler.go b/backend/internal/ws/handler.go index feff0ac..cb7f25b 100644 --- a/backend/internal/ws/handler.go +++ b/backend/internal/ws/handler.go @@ -276,6 +276,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche TTSEnabled: msg.Payload.TTSEnabled, DetailLevel: msg.Payload.DetailLevel, Language: msg.Payload.Language, + Scenario: msg.Payload.Scenario, } if err := client.sessionMgr.UpdateConfig(context.Background(), sessionID, patch); err != nil { errors.SendWSError(client, errors.CodeInternalError, "", err) diff --git a/frontend/src/App.css b/frontend/src/App.css index 7264e5b..e31c238 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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 { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b82bdca..389b569 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = { 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() {
{tr("chat.title")}
+ {isConnected && mode === "observation" && ( {tr("chat.mode.observation")} )} @@ -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} />
diff --git a/frontend/src/components/ChatPanel/index.tsx b/frontend/src/components/ChatPanel/index.tsx index 0007bda..41a945c 100644 --- a/frontend/src/components/ChatPanel/index.tsx +++ b/frontend/src/components/ChatPanel/index.tsx @@ -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 | null; @@ -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(null); const containerRef = useRef(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 (
- {/* 空状态:场景卡片 */} + {/* 空状态:情景选择 + 场景卡片 */} {isEmpty && (
- 💬 + {scenarios.find(s => s.id === activeScenario)?.icon || "💬"}

{isConnected ? t("chat.welcome.prompt") : t("chat.empty.prompt")}

{isConnected ? t("chat.welcome.hint") : t("chat.empty.hint")} - {/* 场景快捷卡片 */} -
- {sceneCards.map((card) => ( - - ))} -
+ {/* 情景选择卡片(非 free_chat 时隐藏,因为已通过 header 切换) */} + {isFreeChat && onSelectScenario && ( +
+
{t("scenario.choose")}
+ {scenarios.filter(s => s.id !== "free_chat").map((sc) => ( + + ))} +
+ )} + + {/* 视觉分析快捷卡片(仅 free_chat 模式) */} + {isFreeChat && isConnected && ( +
+ {sceneCards.map((card) => ( + + ))} +
+ )}
)} {messages.map((msg, index) => ( -
-
- {msg.role === "user" ? t("chat.userLabel") : "AI"} + msg.role === "system" ? ( +
+ {msg.content}
-
{msg.content}
- {msg.role === "assistant" && msg.tokensUsed !== undefined && ( -
- {msg.tokensUsed} tokens - {msg.latencyMs !== undefined && ` · ${(msg.latencyMs / 1000).toFixed(1)}s`} - {msg.model && ` · ${msg.model}`} + ) : ( +
+
+ {msg.role === "user" ? t("chat.userLabel") : "AI"}
- )} -
+
{msg.content}
+ {msg.role === "assistant" && msg.tokensUsed !== undefined && ( +
+ {msg.tokensUsed} tokens + {msg.latencyMs !== undefined && ` · ${(msg.latencyMs / 1000).toFixed(1)}s`} + {msg.model && ` · ${msg.model}`} +
+ )} +
+ ) ))} {/* 流式回复(尚未完成) */} diff --git a/frontend/src/components/ConfigPanel/index.tsx b/frontend/src/components/ConfigPanel/index.tsx index 031106e..104057e 100644 --- a/frontend/src/components/ConfigPanel/index.tsx +++ b/frontend/src/components/ConfigPanel/index.tsx @@ -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, + +
{username && onLogout && ( diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index 44be571..54b70d8 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -3,7 +3,6 @@ // 来源:docs/03-接口文档.md §五 错误码 // ============================================================ - /** 将错误码转为用户友好文案(需要传入翻译函数) */ export function getErrorMessage(code: string, translate: (key: string) => string): string { const key = `error.${code}`; diff --git a/frontend/src/lib/i18n/en-US.ts b/frontend/src/lib/i18n/en-US.ts index 0ac2f10..9d4087d 100644 --- a/frontend/src/lib/i18n/en-US.ts +++ b/frontend/src/lib/i18n/en-US.ts @@ -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", diff --git a/frontend/src/lib/i18n/ja-JP.ts b/frontend/src/lib/i18n/ja-JP.ts index 8eb152f..574fdb3 100644 --- a/frontend/src/lib/i18n/ja-JP.ts +++ b/frontend/src/lib/i18n/ja-JP.ts @@ -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": "👁️ シーン変化を検出", diff --git a/frontend/src/lib/i18n/zh-CN.ts b/frontend/src/lib/i18n/zh-CN.ts index a851b89..ca939ab 100644 --- a/frontend/src/lib/i18n/zh-CN.ts +++ b/frontend/src/lib/i18n/zh-CN.ts @@ -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": "👁️ 画面变化检测", diff --git a/frontend/src/lib/scenarios.ts b/frontend/src/lib/scenarios.ts new file mode 100644 index 0000000..6db4480 --- /dev/null +++ b/frontend/src/lib/scenarios.ts @@ -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", + }, +]; diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 13673f1..f5d6d9d 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -15,6 +15,7 @@ const DEFAULT_CONFIG: SessionConfig = { ttsEnabled: true, detailLevel: "low", language: "zh-CN", + scenario: "free_chat", }; /** 加载配置,无存储时返回默认值 */ diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 14e94b8..acd0cd3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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;