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:
@@ -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 流式推理的一个增量片段。
|
||||
|
||||
@@ -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)}},
|
||||
})
|
||||
|
||||
// 历史消息
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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句话以内,除非用户要求详细说明。")
|
||||
|
||||
55
backend/internal/ai/llm/scenarios.go
Normal file
55
backend/internal/ai/llm/scenarios.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
))}
|
||||
|
||||
{/* 流式回复(尚未完成) */}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// 来源:docs/03-接口文档.md §五 错误码
|
||||
// ============================================================
|
||||
|
||||
|
||||
/** 将错误码转为用户友好文案(需要传入翻译函数) */
|
||||
export function getErrorMessage(code: string, translate: (key: string) => string): string {
|
||||
const key = `error.${code}`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "👁️ シーン変化を検出",
|
||||
|
||||
@@ -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": "👁️ 画面变化检测",
|
||||
|
||||
47
frontend/src/lib/scenarios.ts
Normal file
47
frontend/src/lib/scenarios.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
@@ -15,6 +15,7 @@ const DEFAULT_CONFIG: SessionConfig = {
|
||||
ttsEnabled: true,
|
||||
detailLevel: "low",
|
||||
language: "zh-CN",
|
||||
scenario: "free_chat",
|
||||
};
|
||||
|
||||
/** 加载配置,无存储时返回默认值 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user