Merge pull request 'feat:添加对话情景功能' (#112) from feat/optimize into develop

Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/112
This commit was merged in pull request #112.
This commit is contained in:
2026-06-14 20:41:50 +08:00
19 changed files with 437 additions and 51 deletions

View File

@@ -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 流式推理的一个增量片段。

View File

@@ -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)}},
})
// 历史消息

View File

@@ -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)
}
})
}

View File

@@ -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句话以内除非用户要求详细说明。")

View File

@@ -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
}
}

View File

@@ -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"`
}

View File

@@ -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)

View File

@@ -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)

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 | 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<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

@@ -3,7 +3,6 @@
// 来源docs/03-接口文档.md §五 错误码
// ============================================================
/** 将错误码转为用户友好文案(需要传入翻译函数) */
export function getErrorMessage(code: string, translate: (key: string) => string): string {
const key = `error.${code}`;

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;