Merge pull request 'feat: 实现摄像头和麦克风设备选择功能' (#184) from develop into v2
All checks were successful
Deploy / deploy (push) Successful in 39s
All checks were successful
Deploy / deploy (push) Successful in 39s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/184
This commit was merged in pull request #184.
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
"github.com/hhs/camtalk/internal/api"
|
"github.com/hhs/camtalk/internal/api"
|
||||||
@@ -55,6 +56,7 @@ func main() {
|
|||||||
var userRepo store.UserRepository
|
var userRepo store.UserRepository
|
||||||
var msgRepo store.MessageRepository
|
var msgRepo store.MessageRepository
|
||||||
var sessRepo store.SessionRepository
|
var sessRepo store.SessionRepository
|
||||||
|
var pool *pgxpool.Pool // 数据库连接池
|
||||||
|
|
||||||
// L3: PostgreSQL(冷数据持久化层)
|
// L3: PostgreSQL(冷数据持久化层)
|
||||||
dsn := cfg.Storage.Persistence.DSN
|
dsn := cfg.Storage.Persistence.DSN
|
||||||
@@ -66,7 +68,8 @@ func main() {
|
|||||||
logger.Log.Fatalw("storage.persistence.dsn is required when persistence is enabled",
|
logger.Log.Fatalw("storage.persistence.dsn is required when persistence is enabled",
|
||||||
"hint", "set CAMTALK_STORAGE_DSN environment variable")
|
"hint", "set CAMTALK_STORAGE_DSN environment variable")
|
||||||
}
|
}
|
||||||
pool, err := store.NewPostgresPool(ctx, dsn)
|
var err error
|
||||||
|
pool, err = store.NewPostgresPool(ctx, dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Fatalw("failed to connect to postgres", "error", err)
|
logger.Log.Fatalw("failed to connect to postgres", "error", err)
|
||||||
}
|
}
|
||||||
@@ -182,7 +185,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 初始化 Eino Graph + Orchestrator
|
// 初始化 Eino Graph + Orchestrator
|
||||||
pipelineGraph, err := eino.NewPipelineGraph(ctx, cfg, sttService, ttsService, sessionMgr)
|
var userScenarioRepo store.UserScenarioRepository
|
||||||
|
if pool != nil {
|
||||||
|
userScenarioRepo = store.NewPostgresUserScenarioRepo(pool)
|
||||||
|
}
|
||||||
|
pipelineGraph, err := eino.NewPipelineGraph(ctx, cfg, sttService, ttsService, sessionMgr, userScenarioRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Fatalw("failed to create eino pipeline graph", "error", err)
|
logger.Log.Fatalw("failed to create eino pipeline graph", "error", err)
|
||||||
}
|
}
|
||||||
@@ -239,8 +246,23 @@ func main() {
|
|||||||
convHandler := api.NewConversationHandler(sessionMgr, tokenMgr, msgRepo)
|
convHandler := api.NewConversationHandler(sessionMgr, tokenMgr, msgRepo)
|
||||||
convHandler.RegisterRoutes(apiGroup)
|
convHandler.RegisterRoutes(apiGroup)
|
||||||
|
|
||||||
|
// UserScenario REST 端点
|
||||||
|
if pool != nil {
|
||||||
|
userScenarioRepo := store.NewPostgresUserScenarioRepo(pool)
|
||||||
|
userScenarioHandler := api.NewUserScenarioHandler(userScenarioRepo)
|
||||||
|
scenarioGroup := apiGroup.Group("/scenarios")
|
||||||
|
scenarioGroup.Use(auth.AuthMiddleware(tokenMgr))
|
||||||
|
{
|
||||||
|
scenarioGroup.GET("", userScenarioHandler.List)
|
||||||
|
scenarioGroup.POST("", userScenarioHandler.Create)
|
||||||
|
scenarioGroup.GET("/:id", userScenarioHandler.Get)
|
||||||
|
scenarioGroup.PATCH("/:id", userScenarioHandler.Update)
|
||||||
|
scenarioGroup.DELETE("/:id", userScenarioHandler.Delete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WebSocket
|
// WebSocket
|
||||||
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr, limiter))
|
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr, limiter, userScenarioRepo))
|
||||||
|
|
||||||
// HTTP Server
|
// HTTP Server
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module github.com/hhs/camtalk
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/alicebob/miniredis/v2 v2.38.0
|
||||||
github.com/cloudwego/eino v0.9.9
|
github.com/cloudwego/eino v0.9.9
|
||||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
|
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
@@ -19,7 +20,6 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alicebob/miniredis/v2 v2.38.0 // indirect
|
|
||||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/buger/jsonparser v1.1.1 // indirect
|
github.com/buger/jsonparser v1.1.1 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
|||||||
@@ -84,41 +84,65 @@ var scenarioPrompts = map[string]scenarioPrompt{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetScenarioPrompt 根据情景 ID 和语言获取对应的 system prompt。
|
// GetScenarioPrompt 根据情景 ID 和语言获取对应的 system prompt。
|
||||||
|
// 支持系统预置情景和用户自建情景。
|
||||||
|
// customScenarios: 用户自建情景映射表(scenarioID → prompt),可为 nil
|
||||||
// 返回空字符串表示无此情景(使用默认 prompt)。
|
// 返回空字符串表示无此情景(使用默认 prompt)。
|
||||||
func GetScenarioPrompt(scenarioID, language string) string {
|
func GetScenarioPrompt(scenarioID, language string, customScenarios map[string]string) string {
|
||||||
if scenarioID == "" || scenarioID == "free_chat" {
|
if scenarioID == "" || scenarioID == "free_chat" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
p, ok := scenarioPrompts[scenarioID]
|
|
||||||
if !ok {
|
// 1. 优先查找系统预置情景
|
||||||
return ""
|
if p, ok := scenarioPrompts[scenarioID]; ok {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(language, "zh"):
|
||||||
|
return p.ZH
|
||||||
|
case strings.HasPrefix(language, "ja"):
|
||||||
|
return p.JA
|
||||||
|
default:
|
||||||
|
return p.EN
|
||||||
|
}
|
||||||
}
|
}
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(language, "zh"):
|
// 2. 查找用户自建情景
|
||||||
return p.ZH
|
if customScenarios != nil {
|
||||||
case strings.HasPrefix(language, "ja"):
|
if customPrompt, ok := customScenarios[scenarioID]; ok {
|
||||||
return p.JA
|
return customPrompt
|
||||||
default:
|
}
|
||||||
return p.EN
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 默认空字符串
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetScenarioGreeting 根据情景 ID 和语言获取对应的首句引导。
|
// GetScenarioGreeting 根据情景 ID 和语言获取对应的首句引导。
|
||||||
|
// 支持系统预置情景和用户自建情景。
|
||||||
|
// customGreetings: 用户自建情景的首句引导映射表(scenarioID → greeting),可为 nil
|
||||||
// 返回空字符串表示无此情景或不需要引导(自由对话)。
|
// 返回空字符串表示无此情景或不需要引导(自由对话)。
|
||||||
func GetScenarioGreeting(scenarioID, language string) string {
|
func GetScenarioGreeting(scenarioID, language string, customGreetings map[string]string) string {
|
||||||
if scenarioID == "" || scenarioID == "free_chat" {
|
if scenarioID == "" || scenarioID == "free_chat" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
p, ok := scenarioPrompts[scenarioID]
|
|
||||||
if !ok {
|
// 1. 优先查找系统预置情景
|
||||||
return ""
|
if p, ok := scenarioPrompts[scenarioID]; ok {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(language, "zh"):
|
||||||
|
return p.GreetingZH
|
||||||
|
case strings.HasPrefix(language, "ja"):
|
||||||
|
return p.GreetingJA
|
||||||
|
default:
|
||||||
|
return p.GreetingEN
|
||||||
|
}
|
||||||
}
|
}
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(language, "zh"):
|
// 2. 查找用户自建情景
|
||||||
return p.GreetingZH
|
if customGreetings != nil {
|
||||||
case strings.HasPrefix(language, "ja"):
|
if customGreeting, ok := customGreetings[scenarioID]; ok {
|
||||||
return p.GreetingJA
|
return customGreeting
|
||||||
default:
|
}
|
||||||
return p.GreetingEN
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 默认空字符串
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
208
backend/internal/api/user_scenario_handler.go
Normal file
208
backend/internal/api/user_scenario_handler.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/hhs/camtalk/internal/logger"
|
||||||
|
"github.com/hhs/camtalk/internal/models"
|
||||||
|
"github.com/hhs/camtalk/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxScenariosPerUser = 20 // 每个用户最多 20 个自建情景
|
||||||
|
MaxPromptLength = 2000 // Prompt 最大长度
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserScenarioHandler 用户情景 API Handler。
|
||||||
|
type UserScenarioHandler struct {
|
||||||
|
repo store.UserScenarioRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserScenarioHandler 创建用户情景 Handler。
|
||||||
|
func NewUserScenarioHandler(repo store.UserScenarioRepository) *UserScenarioHandler {
|
||||||
|
return &UserScenarioHandler{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 获取用户的所有自建情景。
|
||||||
|
// GET /api/scenarios
|
||||||
|
func (h *UserScenarioHandler) List(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios, err := h.repo.FindByUserID(c.Request.Context(), userID.(string))
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Errorw("查询用户情景失败", "user_id", userID, "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if scenarios == nil {
|
||||||
|
scenarios = []*models.UserScenario{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.UserScenarioListResponse{
|
||||||
|
Scenarios: scenarios,
|
||||||
|
Total: len(scenarios),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建用户情景。
|
||||||
|
// POST /api/scenarios
|
||||||
|
func (h *UserScenarioHandler) Create(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.CreateUserScenarioRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否已达上限
|
||||||
|
count, err := h.repo.CountByUserID(c.Request.Context(), userID.(string))
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Errorw("统计用户情景数量失败", "user_id", userID, "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count >= MaxScenariosPerUser {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "已达创建上限(最多 20 个)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建情景
|
||||||
|
scenario := &models.UserScenario{
|
||||||
|
UserID: userID.(string),
|
||||||
|
Name: req.Name,
|
||||||
|
Icon: req.Icon,
|
||||||
|
Description: req.Description,
|
||||||
|
Prompt: req.Prompt,
|
||||||
|
Greeting: req.Greeting,
|
||||||
|
Language: req.Language,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.repo.Create(c.Request.Context(), scenario); err != nil {
|
||||||
|
logger.Log.Errorw("创建用户情景失败", "user_id", userID, "error", err)
|
||||||
|
if err.Error() == "duplicate key value violates unique constraint" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "情景名称已存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Log.Infow("创建用户情景成功", "user_id", userID, "scenario_id", scenario.ID)
|
||||||
|
c.JSON(http.StatusCreated, scenario)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 获取单个情景详情。
|
||||||
|
// GET /api/scenarios/:id
|
||||||
|
func (h *UserScenarioHandler) Get(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarioID := c.Param("id")
|
||||||
|
scenario, err := h.repo.FindByIDAndUserID(c.Request.Context(), scenarioID, userID.(string))
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Errorw("查询用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "情景不存在或无权限"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, scenario)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新用户情景。
|
||||||
|
// PATCH /api/scenarios/:id
|
||||||
|
func (h *UserScenarioHandler) Update(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarioID := c.Param("id")
|
||||||
|
|
||||||
|
// 查询并校验所有权
|
||||||
|
scenario, err := h.repo.FindByIDAndUserID(c.Request.Context(), scenarioID, userID.(string))
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Errorw("查询用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "情景不存在或无权限"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.UpdateUserScenarioRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字段
|
||||||
|
if req.Name != nil {
|
||||||
|
scenario.Name = *req.Name
|
||||||
|
}
|
||||||
|
if req.Icon != nil {
|
||||||
|
scenario.Icon = *req.Icon
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
scenario.Description = *req.Description
|
||||||
|
}
|
||||||
|
if req.Prompt != nil {
|
||||||
|
scenario.Prompt = *req.Prompt
|
||||||
|
}
|
||||||
|
if req.Greeting != nil {
|
||||||
|
scenario.Greeting = *req.Greeting
|
||||||
|
}
|
||||||
|
if req.Language != nil {
|
||||||
|
scenario.Language = *req.Language
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.repo.Update(c.Request.Context(), scenario); err != nil {
|
||||||
|
logger.Log.Errorw("更新用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Log.Infow("更新用户情景成功", "user_id", userID, "scenario_id", scenarioID)
|
||||||
|
c.JSON(http.StatusOK, scenario)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除用户情景。
|
||||||
|
// DELETE /api/scenarios/:id
|
||||||
|
func (h *UserScenarioHandler) Delete(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarioID := c.Param("id")
|
||||||
|
|
||||||
|
// 查询并校验所有权
|
||||||
|
_, err := h.repo.FindByIDAndUserID(c.Request.Context(), scenarioID, userID.(string))
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Errorw("查询用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "情景不存在或无权限"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.repo.Delete(c.Request.Context(), scenarioID); err != nil {
|
||||||
|
logger.Log.Errorw("删除用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Log.Infow("删除用户情景成功", "user_id", userID, "scenario_id", scenarioID)
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -119,6 +119,7 @@ func (e *EinoOrchestrator) ProcessQuery(
|
|||||||
state.Language = input.Language
|
state.Language = input.Language
|
||||||
state.DetailLevel = sess.Config.DetailLevel
|
state.DetailLevel = sess.Config.DetailLevel
|
||||||
state.TTSEnabled = input.TTSEnabled
|
state.TTSEnabled = input.TTSEnabled
|
||||||
|
state.UserID = input.UserID
|
||||||
ctx = WithPipelineState(ctx, state)
|
ctx = WithPipelineState(ctx, state)
|
||||||
|
|
||||||
// 6. 调用 Graph(Stream 模式 + 运行时 Callback)
|
// 6. 调用 Graph(Stream 模式 + 运行时 Callback)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/hhs/camtalk/internal/logger"
|
"github.com/hhs/camtalk/internal/logger"
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
"github.com/hhs/camtalk/internal/session"
|
"github.com/hhs/camtalk/internal/session"
|
||||||
|
"github.com/hhs/camtalk/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -42,6 +43,7 @@ func NewPipelineGraph(
|
|||||||
sttService stt.Service,
|
sttService stt.Service,
|
||||||
ttsService tts.Service,
|
ttsService tts.Service,
|
||||||
sessionMgr session.Manager,
|
sessionMgr session.Manager,
|
||||||
|
scenarioRepo store.UserScenarioRepository,
|
||||||
) (*PipelineGraph, error) {
|
) (*PipelineGraph, error) {
|
||||||
log := logger.Log
|
log := logger.Log
|
||||||
|
|
||||||
@@ -68,7 +70,7 @@ func NewPipelineGraph(
|
|||||||
maxHistory := cfg.Session.MaxHistory
|
maxHistory := cfg.Session.MaxHistory
|
||||||
|
|
||||||
_ = g.AddLambdaNode(nodeSTT, NewSTTLambda(sttService))
|
_ = g.AddLambdaNode(nodeSTT, NewSTTLambda(sttService))
|
||||||
_ = g.AddLambdaNode(nodeHistory, NewHistoryLambda(sessionMgr.GetHistory, maxHistory))
|
_ = g.AddLambdaNode(nodeHistory, NewHistoryLambda(sessionMgr.GetHistory, scenarioRepo, maxHistory))
|
||||||
_ = g.AddChatModelNode(nodeLLM, chatModel)
|
_ = g.AddChatModelNode(nodeLLM, chatModel)
|
||||||
_ = g.AddLambdaNode(nodeMessageToString, NewMessageToStringLambda())
|
_ = g.AddLambdaNode(nodeMessageToString, NewMessageToStringLambda())
|
||||||
_ = g.AddLambdaNode(nodeSplitter, NewSplitterLambda())
|
_ = g.AddLambdaNode(nodeSplitter, NewSplitterLambda())
|
||||||
@@ -112,5 +114,6 @@ func buildPipelineInput(req models.WsQuery, sessionID string, sess *models.Sessi
|
|||||||
Language: sess.Config.Language,
|
Language: sess.Config.Language,
|
||||||
Scenario: sess.Config.Scenario,
|
Scenario: sess.Config.Scenario,
|
||||||
TTSEnabled: sess.Config.TTSEnabled,
|
TTSEnabled: sess.Config.TTSEnabled,
|
||||||
|
UserID: sess.UserID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ func TestNewHistoryLambda_ReturnsNonNil(t *testing.T) {
|
|||||||
fetcher := func(ctx context.Context, sessionID string, limit int) ([]models.Message, error) {
|
fetcher := func(ctx context.Context, sessionID string, limit int) ([]models.Message, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
lambda := NewHistoryLambda(fetcher, 10)
|
lambda := NewHistoryLambda(fetcher, nil, 10)
|
||||||
require.NotNil(t, lambda)
|
require.NotNil(t, lambda)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/hhs/camtalk/internal/ai/llm"
|
"github.com/hhs/camtalk/internal/ai/llm"
|
||||||
"github.com/hhs/camtalk/internal/logger"
|
"github.com/hhs/camtalk/internal/logger"
|
||||||
"github.com/hhs/camtalk/internal/models"
|
"github.com/hhs/camtalk/internal/models"
|
||||||
|
"github.com/hhs/camtalk/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewHistoryLambda 创建历史组装 Lambda 节点。
|
// NewHistoryLambda 创建历史组装 Lambda 节点。
|
||||||
@@ -17,7 +18,11 @@ import (
|
|||||||
//
|
//
|
||||||
// 从 PipelineState 读取请求元数据(SessionID、Scenario、ImageData 等),
|
// 从 PipelineState 读取请求元数据(SessionID、Scenario、ImageData 等),
|
||||||
// 构建系统提示词,组装历史消息和当前用户输入(含多模态图片)。
|
// 构建系统提示词,组装历史消息和当前用户输入(含多模态图片)。
|
||||||
func NewHistoryLambda(historyFetcher func(ctx context.Context, sessionID string, limit int) ([]models.Message, error), maxHistory int) *compose.Lambda {
|
func NewHistoryLambda(
|
||||||
|
historyFetcher func(ctx context.Context, sessionID string, limit int) ([]models.Message, error),
|
||||||
|
scenarioRepo store.UserScenarioRepository,
|
||||||
|
maxHistory int,
|
||||||
|
) *compose.Lambda {
|
||||||
return compose.InvokableLambda(func(ctx context.Context, sttOut STTOutput) ([]*schema.Message, error) {
|
return compose.InvokableLambda(func(ctx context.Context, sttOut STTOutput) ([]*schema.Message, error) {
|
||||||
log := logger.Log
|
log := logger.Log
|
||||||
|
|
||||||
@@ -34,10 +39,31 @@ func NewHistoryLambda(historyFetcher func(ctx context.Context, sessionID string,
|
|||||||
scenario := state.Scenario
|
scenario := state.Scenario
|
||||||
detailLevel := state.DetailLevel
|
detailLevel := state.DetailLevel
|
||||||
language := sttOut.Language
|
language := sttOut.Language
|
||||||
|
userID := state.UserID
|
||||||
state.mu.Unlock()
|
state.mu.Unlock()
|
||||||
|
|
||||||
// 构建系统提示词
|
// 加载用户自建情景(如果有 userID 和 scenarioRepo)
|
||||||
scenarioPrompt := llm.GetScenarioPrompt(scenario, language)
|
var customScenarios map[string]string
|
||||||
|
var customGreetings map[string]string
|
||||||
|
if userID != "" && scenarioRepo != nil {
|
||||||
|
scenarios, err := scenarioRepo.FindByUserID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnw("加载用户自建情景失败", "user_id", userID, "error", err)
|
||||||
|
} else if len(scenarios) > 0 {
|
||||||
|
customScenarios = make(map[string]string, len(scenarios))
|
||||||
|
customGreetings = make(map[string]string, len(scenarios))
|
||||||
|
for _, s := range scenarios {
|
||||||
|
customScenarios[s.ID] = s.Prompt
|
||||||
|
if s.Greeting != "" {
|
||||||
|
customGreetings[s.ID] = s.Greeting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Debugw("加载用户自建情景", "user_id", userID, "count", len(scenarios))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建系统提示词(支持用户自建情景)
|
||||||
|
scenarioPrompt := llm.GetScenarioPrompt(scenario, language, customScenarios)
|
||||||
systemPrompt := llm.BuildSystemPrompt(language, detailLevel, scenarioPrompt)
|
systemPrompt := llm.BuildSystemPrompt(language, detailLevel, scenarioPrompt)
|
||||||
|
|
||||||
// 构建 system message(仅文本,多模态内容只能放在 user 角色)
|
// 构建 system message(仅文本,多模态内容只能放在 user 角色)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type PipelineState struct {
|
|||||||
DetailLevel string
|
DetailLevel string
|
||||||
Language string
|
Language string
|
||||||
TTSEnabled bool
|
TTSEnabled bool
|
||||||
|
UserID string // 新增:用户 ID,用于加载自建情景
|
||||||
}
|
}
|
||||||
|
|
||||||
// genLocalState 创建每请求的 PipelineState 实例。
|
// genLocalState 创建每请求的 PipelineState 实例。
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type PipelineInput struct {
|
|||||||
Language string // zh / en
|
Language string // zh / en
|
||||||
Scenario string // free_chat, interviewer, etc.
|
Scenario string // free_chat, interviewer, etc.
|
||||||
TTSEnabled bool
|
TTSEnabled bool
|
||||||
|
UserID string // 用户 ID,用于加载自建情景
|
||||||
}
|
}
|
||||||
|
|
||||||
// PipelineOutput Graph 统一输出。
|
// PipelineOutput Graph 统一输出。
|
||||||
|
|||||||
43
backend/internal/models/user_scenario.go
Normal file
43
backend/internal/models/user_scenario.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// UserScenario 用户自建情景。
|
||||||
|
type UserScenario struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Greeting string `json:"greeting,omitempty"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUserScenarioRequest 创建用户情景请求。
|
||||||
|
type CreateUserScenarioRequest struct {
|
||||||
|
Name string `json:"name" binding:"required,min=2,max=50"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Description string `json:"description,omitempty" binding:"omitempty,max=100"`
|
||||||
|
Prompt string `json:"prompt" binding:"required,min=10,max=2000"`
|
||||||
|
Greeting string `json:"greeting,omitempty" binding:"omitempty,max=500"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserScenarioRequest 更新用户情景请求。
|
||||||
|
type UpdateUserScenarioRequest struct {
|
||||||
|
Name *string `json:"name,omitempty" binding:"omitempty,min=2,max=50"`
|
||||||
|
Icon *string `json:"icon,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty" binding:"omitempty,max=100"`
|
||||||
|
Prompt *string `json:"prompt,omitempty" binding:"omitempty,min=10,max=2000"`
|
||||||
|
Greeting *string `json:"greeting,omitempty" binding:"omitempty,max=500"`
|
||||||
|
Language *string `json:"language,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserScenarioListResponse 用户情景列表响应。
|
||||||
|
type UserScenarioListResponse struct {
|
||||||
|
Scenarios []*UserScenario `json:"scenarios"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
238
backend/internal/store/user_scenario_repository.go
Normal file
238
backend/internal/store/user_scenario_repository.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/hhs/camtalk/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserScenarioRepository 用户自建情景仓储接口。
|
||||||
|
type UserScenarioRepository interface {
|
||||||
|
Create(ctx context.Context, scenario *models.UserScenario) error
|
||||||
|
FindByID(ctx context.Context, id string) (*models.UserScenario, error)
|
||||||
|
FindByIDAndUserID(ctx context.Context, id, userID string) (*models.UserScenario, error)
|
||||||
|
FindByUserID(ctx context.Context, userID string) ([]*models.UserScenario, error)
|
||||||
|
Update(ctx context.Context, scenario *models.UserScenario) error
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
CountByUserID(ctx context.Context, userID string) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgresUserScenarioRepo PostgreSQL 实现。
|
||||||
|
type PostgresUserScenarioRepo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPostgresUserScenarioRepo 创建 PostgreSQL 用户情景仓储。
|
||||||
|
func NewPostgresUserScenarioRepo(pool *pgxpool.Pool) UserScenarioRepository {
|
||||||
|
return &PostgresUserScenarioRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建用户情景。
|
||||||
|
func (r *PostgresUserScenarioRepo) Create(ctx context.Context, scenario *models.UserScenario) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO user_scenarios (id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6, NULLIF($7, ''), $8, $9, $10)
|
||||||
|
RETURNING id, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
scenario.CreatedAt = now
|
||||||
|
scenario.UpdatedAt = now
|
||||||
|
|
||||||
|
if scenario.ID == "" {
|
||||||
|
scenario.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
if scenario.Icon == "" {
|
||||||
|
scenario.Icon = "✨"
|
||||||
|
}
|
||||||
|
if scenario.Language == "" {
|
||||||
|
scenario.Language = "zh-CN"
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, query,
|
||||||
|
scenario.ID,
|
||||||
|
scenario.UserID,
|
||||||
|
scenario.Name,
|
||||||
|
scenario.Icon,
|
||||||
|
scenario.Description,
|
||||||
|
scenario.Prompt,
|
||||||
|
scenario.Greeting,
|
||||||
|
scenario.Language,
|
||||||
|
scenario.CreatedAt,
|
||||||
|
scenario.UpdatedAt,
|
||||||
|
).Scan(&scenario.ID, &scenario.CreatedAt, &scenario.UpdatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create user scenario: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID 根据 ID 查找情景。
|
||||||
|
func (r *PostgresUserScenarioRepo) FindByID(ctx context.Context, id string) (*models.UserScenario, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
||||||
|
FROM user_scenarios
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
var scenario models.UserScenario
|
||||||
|
err := r.pool.QueryRow(ctx, query, id).Scan(
|
||||||
|
&scenario.ID,
|
||||||
|
&scenario.UserID,
|
||||||
|
&scenario.Name,
|
||||||
|
&scenario.Icon,
|
||||||
|
&scenario.Description,
|
||||||
|
&scenario.Prompt,
|
||||||
|
&scenario.Greeting,
|
||||||
|
&scenario.Language,
|
||||||
|
&scenario.CreatedAt,
|
||||||
|
&scenario.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("user scenario not found: %s", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find user scenario: %w", err)
|
||||||
|
}
|
||||||
|
return &scenario, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByIDAndUserID 根据 ID 和用户 ID 查找情景(权限校验)。
|
||||||
|
func (r *PostgresUserScenarioRepo) FindByIDAndUserID(ctx context.Context, id, userID string) (*models.UserScenario, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
||||||
|
FROM user_scenarios
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
var scenario models.UserScenario
|
||||||
|
err := r.pool.QueryRow(ctx, query, id, userID).Scan(
|
||||||
|
&scenario.ID,
|
||||||
|
&scenario.UserID,
|
||||||
|
&scenario.Name,
|
||||||
|
&scenario.Icon,
|
||||||
|
&scenario.Description,
|
||||||
|
&scenario.Prompt,
|
||||||
|
&scenario.Greeting,
|
||||||
|
&scenario.Language,
|
||||||
|
&scenario.CreatedAt,
|
||||||
|
&scenario.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("user scenario not found or no permission")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find user scenario: %w", err)
|
||||||
|
}
|
||||||
|
return &scenario, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByUserID 查找用户的所有情景。
|
||||||
|
func (r *PostgresUserScenarioRepo) FindByUserID(ctx context.Context, userID string) ([]*models.UserScenario, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
||||||
|
FROM user_scenarios
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, query, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find user scenarios: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var scenarios []*models.UserScenario
|
||||||
|
for rows.Next() {
|
||||||
|
var s models.UserScenario
|
||||||
|
err := rows.Scan(
|
||||||
|
&s.ID,
|
||||||
|
&s.UserID,
|
||||||
|
&s.Name,
|
||||||
|
&s.Icon,
|
||||||
|
&s.Description,
|
||||||
|
&s.Prompt,
|
||||||
|
&s.Greeting,
|
||||||
|
&s.Language,
|
||||||
|
&s.CreatedAt,
|
||||||
|
&s.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan user scenario: %w", err)
|
||||||
|
}
|
||||||
|
scenarios = append(scenarios, &s)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate user scenarios: %w", err)
|
||||||
|
}
|
||||||
|
return scenarios, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新用户情景。
|
||||||
|
func (r *PostgresUserScenarioRepo) Update(ctx context.Context, scenario *models.UserScenario) error {
|
||||||
|
query := `
|
||||||
|
UPDATE user_scenarios
|
||||||
|
SET name = $1, icon = $2, description = $3, prompt = $4, greeting = $5, language = $6, updated_at = $7
|
||||||
|
WHERE id = $8 AND user_id = $9
|
||||||
|
RETURNING updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
scenario.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, query,
|
||||||
|
scenario.Name,
|
||||||
|
scenario.Icon,
|
||||||
|
scenario.Description,
|
||||||
|
scenario.Prompt,
|
||||||
|
scenario.Greeting,
|
||||||
|
scenario.Language,
|
||||||
|
scenario.UpdatedAt,
|
||||||
|
scenario.ID,
|
||||||
|
scenario.UserID,
|
||||||
|
).Scan(&scenario.UpdatedAt)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return fmt.Errorf("user scenario not found or no permission")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update user scenario: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除用户情景。
|
||||||
|
func (r *PostgresUserScenarioRepo) Delete(ctx context.Context, id string) error {
|
||||||
|
query := `DELETE FROM user_scenarios WHERE id = $1`
|
||||||
|
|
||||||
|
result, err := r.pool.Exec(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete user scenario: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("user scenario not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByUserID 统计用户的情景数量。
|
||||||
|
func (r *PostgresUserScenarioRepo) CountByUserID(ctx context.Context, userID string) (int, error) {
|
||||||
|
query := `SELECT COUNT(*) FROM user_scenarios WHERE user_id = $1`
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := r.pool.QueryRow(ctx, query, userID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count user scenarios: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/hhs/camtalk/internal/orchestrator"
|
"github.com/hhs/camtalk/internal/orchestrator"
|
||||||
"github.com/hhs/camtalk/internal/ratelimit"
|
"github.com/hhs/camtalk/internal/ratelimit"
|
||||||
"github.com/hhs/camtalk/internal/session"
|
"github.com/hhs/camtalk/internal/session"
|
||||||
|
"github.com/hhs/camtalk/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newUpgrader 根据配置创建 WebSocket upgrader。
|
// newUpgrader 根据配置创建 WebSocket upgrader。
|
||||||
@@ -43,12 +44,12 @@ func newUpgrader(cfg *config.Config) websocket.Upgrader {
|
|||||||
|
|
||||||
// Client 代表一个 WebSocket 客户端连接。
|
// Client 代表一个 WebSocket 客户端连接。
|
||||||
type Client struct {
|
type Client struct {
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
sessionID string
|
sessionID string
|
||||||
sessionMgr session.Manager
|
sessionMgr session.Manager
|
||||||
orchestrator orchestrator.Orchestrator
|
orchestrator orchestrator.Orchestrator
|
||||||
cancelFuncs map[string]context.CancelFunc // requestID → cancel func
|
cancelFuncs map[string]context.CancelFunc // requestID → cancel func
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendJSON 向客户端发送 JSON 消息(公开以便 errors 包调用)。
|
// SendJSON 向客户端发送 JSON 消息(公开以便 errors 包调用)。
|
||||||
@@ -95,19 +96,19 @@ func (w *WSClient) SendError(err models.WsError) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ServeWS 处理 WebSocket 升级请求。
|
// ServeWS 处理 WebSocket 升级请求。
|
||||||
func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config, tokenMgr *auth.TokenManager, limiter ratelimit.Limiter) gin.HandlerFunc {
|
func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config, tokenMgr *auth.TokenManager, limiter ratelimit.Limiter, scenarioRepo store.UserScenarioRepository) gin.HandlerFunc {
|
||||||
upgrader := newUpgrader(cfg)
|
upgrader := newUpgrader(cfg)
|
||||||
heartbeatInterval := time.Duration(cfg.Server.HeartbeatInterval) * time.Second
|
heartbeatInterval := time.Duration(cfg.Server.HeartbeatInterval) * time.Second
|
||||||
heartbeatTimeout := time.Duration(cfg.Server.HeartbeatTimeout) * time.Second
|
heartbeatTimeout := time.Duration(cfg.Server.HeartbeatTimeout) * time.Second
|
||||||
version := cfg.App.Version
|
version := cfg.App.Version
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, tokenMgr, limiter)
|
serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, tokenMgr, limiter, scenarioRepo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator,
|
func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator,
|
||||||
upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, tokenMgr *auth.TokenManager, limiter ratelimit.Limiter) {
|
upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, tokenMgr *auth.TokenManager, limiter ratelimit.Limiter, scenarioRepo store.UserScenarioRepository) {
|
||||||
|
|
||||||
// --- JWT 认证(upgrade 前完成,失败直接返回 HTTP 错误) ---
|
// --- JWT 认证(upgrade 前完成,失败直接返回 HTTP 错误) ---
|
||||||
token := c.Query("token")
|
token := c.Query("token")
|
||||||
@@ -303,7 +304,21 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
|||||||
if scenarioID != "" && scenarioID != "free_chat" {
|
if scenarioID != "" && scenarioID != "free_chat" {
|
||||||
sess, err := client.sessionMgr.Get(context.Background(), sessionID)
|
sess, err := client.sessionMgr.Get(context.Background(), sessionID)
|
||||||
if err == nil && sess != nil {
|
if err == nil && sess != nil {
|
||||||
greeting := llm.GetScenarioGreeting(scenarioID, sess.Config.Language)
|
// 加载用户自建情景
|
||||||
|
var customGreetings map[string]string
|
||||||
|
if sess.UserID != "" && scenarioRepo != nil {
|
||||||
|
scenarios, err := scenarioRepo.FindByUserID(context.Background(), sess.UserID)
|
||||||
|
if err == nil && len(scenarios) > 0 {
|
||||||
|
customGreetings = make(map[string]string, len(scenarios))
|
||||||
|
for _, s := range scenarios {
|
||||||
|
if s.Greeting != "" {
|
||||||
|
customGreetings[s.ID] = s.Greeting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
greeting := llm.GetScenarioGreeting(scenarioID, sess.Config.Language, customGreetings)
|
||||||
if greeting != "" {
|
if greeting != "" {
|
||||||
// 发送首句作为 AI 消息
|
// 发送首句作为 AI 消息
|
||||||
_ = client.SendJSON(models.WsLLMChunk{
|
_ = client.SendJSON(models.WsLLMChunk{
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ func setupTestServer(t *testing.T, orch orchestrator.Orchestrator) (*httptest.Se
|
|||||||
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
||||||
Session: config.SessionConfig{MaxHistory: 20},
|
Session: config.SessionConfig{MaxHistory: 20},
|
||||||
}
|
}
|
||||||
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr, nil))
|
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr, nil, nil))
|
||||||
|
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
|
|
||||||
@@ -591,7 +591,7 @@ func setupTestServerEx(t *testing.T, orch orchestrator.Orchestrator) (*httptest.
|
|||||||
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
||||||
Session: config.SessionConfig{MaxHistory: 20},
|
Session: config.SessionConfig{MaxHistory: 20},
|
||||||
}
|
}
|
||||||
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr, nil))
|
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr, nil, nil))
|
||||||
|
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
return srv, tokenMgr, sessionMgr
|
return srv, tokenMgr, sessionMgr
|
||||||
|
|||||||
6
backend/migrations/004_user_scenarios.down.sql
Normal file
6
backend/migrations/004_user_scenarios.down.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- 004_user_scenarios.down.sql
|
||||||
|
-- 回滚用户自建情景表
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_user_scenarios_created_at;
|
||||||
|
DROP INDEX IF EXISTS idx_user_scenarios_user_id;
|
||||||
|
DROP TABLE IF EXISTS user_scenarios;
|
||||||
36
backend/migrations/004_user_scenarios.up.sql
Normal file
36
backend/migrations/004_user_scenarios.up.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
-- 004_user_scenarios.up.sql
|
||||||
|
-- 用户自建情景表
|
||||||
|
|
||||||
|
CREATE TABLE user_scenarios (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
icon VARCHAR(10) DEFAULT '✨',
|
||||||
|
description VARCHAR(100) NOT NULL,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
greeting VARCHAR(200),
|
||||||
|
language VARCHAR(10) DEFAULT 'zh-CN',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT unique_user_scenario UNIQUE(user_id, name),
|
||||||
|
CONSTRAINT check_name_length CHECK (char_length(name) >= 2 AND char_length(name) <= 50),
|
||||||
|
CONSTRAINT check_description_length CHECK (char_length(description) >= 5 AND char_length(description) <= 100),
|
||||||
|
CONSTRAINT check_prompt_length CHECK (char_length(prompt) >= 50 AND char_length(prompt) <= 2000)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 为用户 ID 创建索引,加速查询
|
||||||
|
CREATE INDEX idx_user_scenarios_user_id ON user_scenarios(user_id);
|
||||||
|
|
||||||
|
-- 为创建时间创建索引,用于排序
|
||||||
|
CREATE INDEX idx_user_scenarios_created_at ON user_scenarios(created_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE user_scenarios IS '用户自建情景表';
|
||||||
|
COMMENT ON COLUMN user_scenarios.id IS '情景唯一标识';
|
||||||
|
COMMENT ON COLUMN user_scenarios.user_id IS '所属用户 ID,外键关联 users 表';
|
||||||
|
COMMENT ON COLUMN user_scenarios.name IS '情景名称,如"创意写作导师"';
|
||||||
|
COMMENT ON COLUMN user_scenarios.icon IS 'Emoji 图标,如"🎨"';
|
||||||
|
COMMENT ON COLUMN user_scenarios.description IS '简短描述,显示在情景卡片上';
|
||||||
|
COMMENT ON COLUMN user_scenarios.prompt IS '角色 System Prompt,定义 AI 行为';
|
||||||
|
COMMENT ON COLUMN user_scenarios.greeting IS '首句引导,可选';
|
||||||
|
COMMENT ON COLUMN user_scenarios.language IS '默认语言,如 zh-CN、en-US';
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
# CamTalk Eino 框架与编排设计
|
# CamTalk Eino 框架与编排设计
|
||||||
|
|
||||||
> 创建日期:2026-06-19
|
|
||||||
> 状态:已实施
|
|
||||||
> 合并自:`10-Eino重构方案.md` + `11-Eino框架技术文档.md`
|
|
||||||
|
|
||||||
## 1. 概述
|
## 1. 概述
|
||||||
|
|
||||||
### 1.1 为什么选择 Eino
|
### 1.1 为什么选择 Eino
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
# 情景切换功能
|
# 情景切换功能
|
||||||
|
|
||||||
**状态**: ✅ 已完成
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 功能概述
|
## 功能概述
|
||||||
|
|
||||||
情景切换功能允许用户选择不同的对话场景,AI 会根据选择的情景扮演不同的角色:
|
情景切换功能允许用户选择不同的对话场景,AI 会根据选择的情景扮演不同的角色:
|
||||||
|
|||||||
425
docs/12-自定义情景.md
Normal file
425
docs/12-自定义情景.md
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
# 自建情景功能
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
用户可以创建自己的情景,而不仅限于系统预置的 5 种情景。
|
||||||
|
|
||||||
|
**系统预置情景**(不可修改):
|
||||||
|
- 💬 自由对话
|
||||||
|
- 🎯 模拟面试官
|
||||||
|
- 📚 英语老师
|
||||||
|
- ⚔️ 辩论对手
|
||||||
|
- 🌐 同声翻译
|
||||||
|
|
||||||
|
**用户自建情景**(可增删改):
|
||||||
|
- 🎨 创意写作导师
|
||||||
|
- 🧘 心理咨询师
|
||||||
|
- 👨🍳 私人厨师
|
||||||
|
- 📖 历史学家
|
||||||
|
- ... (用户自由创建)
|
||||||
|
|
||||||
|
**用户旅程**:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 用户点击"创建情景"按钮
|
||||||
|
↓
|
||||||
|
2. 弹出创建对话框
|
||||||
|
↓
|
||||||
|
3. 填写表单:
|
||||||
|
- 情景名称(必填)
|
||||||
|
- 情景图标(可选)
|
||||||
|
- 简短描述(可选)
|
||||||
|
- 角色 Prompt(必填,最少 10 字)
|
||||||
|
- 首句引导(可选)
|
||||||
|
↓
|
||||||
|
4. 点击"创建"
|
||||||
|
↓
|
||||||
|
5. 情景保存到数据库
|
||||||
|
↓
|
||||||
|
6. 情景出现在选择列表中
|
||||||
|
↓
|
||||||
|
7. 用户切换到自建情景
|
||||||
|
↓
|
||||||
|
8. AI 按照用户设定的 Prompt 扮演角色
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心特性**:完整 CRUD 操作(创建/查看/编辑/删除),通过 `user_id` 实现用户数据完全隔离,Eino Graph 管线深度集成(动态加载自建情景 Prompt),中文/英文/日文全覆盖,Modal 对话框 + 图标选择器 + Prompt 编写指南,创建后立即可用无需刷新。
|
||||||
|
|
||||||
|
## 技术架构
|
||||||
|
|
||||||
|
### 数据流
|
||||||
|
|
||||||
|
**创建情景**:
|
||||||
|
|
||||||
|
```
|
||||||
|
用户填写表单 → POST /api/scenarios → Handler 验证
|
||||||
|
→ Repository.Create → PostgreSQL 插入 → 返回情景对象
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI 对话使用自建情景**:
|
||||||
|
|
||||||
|
```
|
||||||
|
WebSocket 连接 → ServeWS 获取 userID
|
||||||
|
→ Eino Graph 初始化 → nodes_history 查询 user_scenarios
|
||||||
|
→ GetScenarioPrompt(customScenarios) → 构建 System Prompt
|
||||||
|
→ LLM 生成回复
|
||||||
|
```
|
||||||
|
|
||||||
|
### Eino 框架集成
|
||||||
|
|
||||||
|
**数据传递链路**:
|
||||||
|
|
||||||
|
```
|
||||||
|
JWT Token → userID
|
||||||
|
↓
|
||||||
|
Session.UserID
|
||||||
|
↓
|
||||||
|
PipelineInput.UserID
|
||||||
|
↓
|
||||||
|
PipelineState.UserID
|
||||||
|
↓
|
||||||
|
nodes_history.go: scenarioRepo.FindByUserID(userID)
|
||||||
|
↓
|
||||||
|
构建 customScenarios map[string]string
|
||||||
|
↓
|
||||||
|
llm.GetScenarioPrompt(scenarioID, language, customScenarios)
|
||||||
|
↓
|
||||||
|
LLM 使用自建情景 Prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键修改文件**:
|
||||||
|
|
||||||
|
| 文件 | 变更说明 |
|
||||||
|
|------|----------|
|
||||||
|
| `backend/internal/eino/state.go` | PipelineState 添加 `UserID` |
|
||||||
|
| `backend/internal/eino/types.go` | PipelineInput 添加 `UserID` |
|
||||||
|
| `backend/internal/eino/graph.go` | 接受 `scenarioRepo` 参数 |
|
||||||
|
| `backend/internal/eino/adapter.go` | 设置 UserID |
|
||||||
|
| `backend/internal/eino/nodes_history.go` | 查询自建情景 |
|
||||||
|
| `backend/internal/ws/handler.go` | 首句引导支持自建情景 |
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
### 数据库表结构
|
||||||
|
|
||||||
|
**表名**: `user_scenarios`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_scenarios (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
icon VARCHAR(10) DEFAULT '✨',
|
||||||
|
description VARCHAR(100), -- 可选
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
greeting VARCHAR(500), -- 可选
|
||||||
|
language VARCHAR(10) DEFAULT 'zh-CN',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT unique_user_scenario UNIQUE(user_id, name),
|
||||||
|
CONSTRAINT check_name_length CHECK (char_length(name) >= 2 AND char_length(name) <= 50),
|
||||||
|
CONSTRAINT check_description_length CHECK (description IS NULL OR char_length(description) <= 100),
|
||||||
|
CONSTRAINT check_prompt_length CHECK (char_length(prompt) >= 10 AND char_length(prompt) <= 2000),
|
||||||
|
CONSTRAINT check_greeting_length CHECK (greeting IS NULL OR char_length(greeting) <= 500)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_scenarios_user_id ON user_scenarios(user_id);
|
||||||
|
CREATE INDEX idx_user_scenarios_created_at ON user_scenarios(created_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明**:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `id` | 情景唯一标识 |
|
||||||
|
| `user_id` | 所属用户,实现数据隔离 |
|
||||||
|
| `name` | 情景名称(2-50 字符) |
|
||||||
|
| `icon` | Emoji 图标(默认 ✨) |
|
||||||
|
| `description` | 简短描述(可选,最多 100 字符) |
|
||||||
|
| `prompt` | 角色 System Prompt(10-2000 字符) |
|
||||||
|
| `greeting` | 首句引导(可选,最多 500 字符) |
|
||||||
|
| `language` | 默认语言(zh-CN / en-US / ja-JP) |
|
||||||
|
|
||||||
|
### 后端数据模型
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backend/internal/models/user_scenario.go
|
||||||
|
|
||||||
|
type UserScenario struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Greeting string `json:"greeting,omitempty"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserScenarioRequest struct {
|
||||||
|
Name string `json:"name" binding:"required,min=2,max=50"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Description string `json:"description,omitempty" binding:"omitempty,max=100"`
|
||||||
|
Prompt string `json:"prompt" binding:"required,min=10,max=2000"`
|
||||||
|
Greeting string `json:"greeting,omitempty" binding:"omitempty,max=500"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端数据结构
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/src/lib/api/scenarios.ts
|
||||||
|
|
||||||
|
export interface UserScenario {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
prompt: string;
|
||||||
|
greeting?: string;
|
||||||
|
language: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// frontend/src/hooks/useScenarios.ts
|
||||||
|
|
||||||
|
export interface ExtendedScenario {
|
||||||
|
id: string;
|
||||||
|
icon: string;
|
||||||
|
name: string;
|
||||||
|
nameKey?: string;
|
||||||
|
description?: string;
|
||||||
|
descKey?: string;
|
||||||
|
isCustom: boolean;
|
||||||
|
prompt?: string;
|
||||||
|
greeting?: string;
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
### API 端点
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 | 权限 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| GET | `/api/scenarios` | 获取用户的所有自建情景 | 需登录 |
|
||||||
|
| POST | `/api/scenarios` | 创建新情景 | 需登录 |
|
||||||
|
| GET | `/api/scenarios/:id` | 获取单个情景详情 | 需登录 |
|
||||||
|
| PATCH | `/api/scenarios/:id` | 更新情景 | 需登录 |
|
||||||
|
| DELETE | `/api/scenarios/:id` | 删除情景 | 需登录 |
|
||||||
|
|
||||||
|
### API 示例
|
||||||
|
|
||||||
|
**创建情景**:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/scenarios
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "创意写作导师",
|
||||||
|
"icon": "✨",
|
||||||
|
"description": "帮助构思故事情节和写作技巧",
|
||||||
|
"prompt": "你是一位创意写作导师,帮助用户构思故事情节、人物设定和写作技巧...",
|
||||||
|
"greeting": "你好!我是你的创意写作导师。今天想聊聊什么故事创意呢?",
|
||||||
|
"language": "zh-CN"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应 201 Created:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid-xxx",
|
||||||
|
"user_id": "uuid-user",
|
||||||
|
"name": "创意写作导师",
|
||||||
|
"icon": "✨"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**获取列表**:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/scenarios
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
响应 200 OK:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scenarios": [],
|
||||||
|
"total": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前端实现
|
||||||
|
|
||||||
|
### 组件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── components/
|
||||||
|
│ ├── CreateScenarioModal/
|
||||||
|
│ │ └── index.tsx # 创建情景对话框
|
||||||
|
│ ├── EditScenarioModal/
|
||||||
|
│ │ └── index.tsx # 编辑情景对话框
|
||||||
|
│ └── ConfigPanel/
|
||||||
|
│ └── index.tsx # 设置面板(改造)
|
||||||
|
├── hooks/
|
||||||
|
│ └── useScenarios.ts # 情景管理 Hook
|
||||||
|
└── lib/
|
||||||
|
└── api/
|
||||||
|
└── scenarios.ts # API 调用封装
|
||||||
|
```
|
||||||
|
|
||||||
|
### 核心 Hook
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useScenarios.ts
|
||||||
|
|
||||||
|
export function useScenarios(token: string | null) {
|
||||||
|
const [allScenarios, setAllScenarios] = useState<ExtendedScenario[]>([]);
|
||||||
|
|
||||||
|
// 合并系统预置 + 用户自建
|
||||||
|
useEffect(() => {
|
||||||
|
const systemScenarios = scenarios.map(s => ({...s, isCustom: false}));
|
||||||
|
const customScenarios = customList.map(s => ({...s, isCustom: true}));
|
||||||
|
setAllScenarios([...systemScenarios, ...customScenarios]);
|
||||||
|
}, [customList]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allScenarios,
|
||||||
|
createScenario,
|
||||||
|
updateScenario,
|
||||||
|
deleteScenario,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 创建情景表单
|
||||||
|
|
||||||
|
**表单字段**:
|
||||||
|
|
||||||
|
- 名称(必填,2-50 字符)
|
||||||
|
- 图标(可选,24 个预设 emoji)
|
||||||
|
- 描述(可选,最多 100 字符)
|
||||||
|
- Prompt(必填,10-2000 字符)
|
||||||
|
- 首句引导(可选,最多 500 字符)
|
||||||
|
- 语言(可选,默认 zh-CN)
|
||||||
|
|
||||||
|
**表单验证**:
|
||||||
|
|
||||||
|
- 实时字符计数
|
||||||
|
- 长度限制提示
|
||||||
|
- 必填项高亮
|
||||||
|
|
||||||
|
## 使用指南
|
||||||
|
|
||||||
|
### 后端 API 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 注册用户
|
||||||
|
curl -X POST http://localhost:8080/api/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"testuser","password":"test12345"}'
|
||||||
|
|
||||||
|
# 2. 创建情景
|
||||||
|
TOKEN="<access_token>"
|
||||||
|
curl -X POST http://localhost:8080/api/scenarios \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "创意写作导师",
|
||||||
|
"icon": "✨",
|
||||||
|
"prompt": "你是一位创意写作导师...",
|
||||||
|
"language": "zh-CN"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 3. 获取列表
|
||||||
|
curl -X GET http://localhost:8080/api/scenarios \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# 4. 更新情景
|
||||||
|
curl -X PATCH http://localhost:8080/api/scenarios/<id> \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"高级写作导师"}'
|
||||||
|
|
||||||
|
# 5. 删除情景
|
||||||
|
curl -X DELETE http://localhost:8080/api/scenarios/<id> \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端功能测试
|
||||||
|
|
||||||
|
1. 刷新浏览器(Cmd+Shift+R)
|
||||||
|
2. 登录账户
|
||||||
|
3. 打开设置面板(右上角齿轮)
|
||||||
|
4. 滚动到"我的情景"区域
|
||||||
|
5. 点击"+ 创建新情景"
|
||||||
|
6. 填写表单并提交
|
||||||
|
7. 验证列表中出现新情景
|
||||||
|
8. 切换到自建情景,验证首句引导
|
||||||
|
9. 发送消息,验证 AI 使用自建 Prompt
|
||||||
|
10. 编辑情景,验证数据预填充
|
||||||
|
11. 删除情景,验证二次确认
|
||||||
|
|
||||||
|
## 安全与限制
|
||||||
|
|
||||||
|
### 用户配额
|
||||||
|
|
||||||
|
```go
|
||||||
|
const MaxScenariosPerUser = 20 // 每个用户最多 20 个自建情景
|
||||||
|
```
|
||||||
|
|
||||||
|
### 权限控制
|
||||||
|
|
||||||
|
- 只能查看/编辑/删除自己的情景
|
||||||
|
- 系统预置情景不可编辑/删除
|
||||||
|
- 后端验证 `user_id` 匹配
|
||||||
|
|
||||||
|
### 数据验证
|
||||||
|
|
||||||
|
**后端**:
|
||||||
|
|
||||||
|
- 名称:2-50 字符
|
||||||
|
- 描述:可选,最多 100 字符
|
||||||
|
- Prompt:10-2000 字符
|
||||||
|
- 首句引导:可选,最多 500 字符
|
||||||
|
|
||||||
|
**前端**:
|
||||||
|
|
||||||
|
- 实时字符计数
|
||||||
|
- 超长提示
|
||||||
|
- 必填项高亮
|
||||||
|
|
||||||
|
## 未来优化方向
|
||||||
|
|
||||||
|
**V1.1**:
|
||||||
|
|
||||||
|
- Prompt 模板库
|
||||||
|
- 实时预览效果
|
||||||
|
- 导入导出功能
|
||||||
|
- 情景搜索和筛选
|
||||||
|
|
||||||
|
**V2.0**:
|
||||||
|
|
||||||
|
- 情景市场
|
||||||
|
- 情景分享链接
|
||||||
|
- AI 辅助优化 Prompt
|
||||||
|
- 协作编辑(团队情景)
|
||||||
|
|
||||||
|
## 参考资料
|
||||||
|
|
||||||
|
- [CLAUDE.md](../CLAUDE.md) — 项目开发指南
|
||||||
|
- [02-接口文档.md](./02-接口文档.md) — WebSocket 和 REST API
|
||||||
|
- [自建情景功能-权限隔离说明.md](./自建情景功能-权限隔离说明.md) — 安全设计
|
||||||
@@ -644,12 +644,6 @@ body {
|
|||||||
transition: all var(--transition-fast);
|
transition: all var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-vision-indicator--active {
|
|
||||||
color: var(--color-success);
|
|
||||||
background: rgba(52, 211, 153, 0.15);
|
|
||||||
animation: pulse 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 视频控制栏 ---- */
|
/* ---- 视频控制栏 ---- */
|
||||||
|
|
||||||
.video-controls {
|
.video-controls {
|
||||||
@@ -750,16 +744,6 @@ body {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-panel-header__mode {
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-success);
|
|
||||||
background: rgba(52, 211, 153, 0.08);
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Scenario Quick Selector (chat header) ---- */
|
/* ---- Scenario Quick Selector (chat header) ---- */
|
||||||
.scenario-selector {
|
.scenario-selector {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@@ -1473,38 +1457,6 @@ body {
|
|||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mode switcher */
|
|
||||||
.video-controls__mode {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
background: var(--color-surface-2);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 2px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 5px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-btn:hover {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-btn--active {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Text-only mode (video ended, chat preserved) ---- */
|
/* ---- Text-only mode (video ended, chat preserved) ---- */
|
||||||
|
|
||||||
.video-controls__text-only {
|
.video-controls__text-only {
|
||||||
@@ -1972,3 +1924,421 @@ body {
|
|||||||
background: rgba(248, 113, 113, 0.15);
|
background: rgba(248, 113, 113, 0.15);
|
||||||
border-color: rgba(248, 113, 113, 0.4);
|
border-color: rgba(248, 113, 113, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Custom Scenarios Styles
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* Scenario list */
|
||||||
|
.scenario-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item input[type="radio"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__icon {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scenario with edit/delete buttons */
|
||||||
|
.scenario-item--custom {
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__radio {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-action-btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-action-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-action-btn--confirm {
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
border-color: rgba(248, 113, 113, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-empty {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Create scenario button */
|
||||||
|
.config-create-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(196, 97, 47, 0.15);
|
||||||
|
border: 1px solid rgba(196, 97, 47, 0.3);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #C4612F;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-create-btn:hover {
|
||||||
|
background: rgba(196, 97, 47, 0.25);
|
||||||
|
border-color: rgba(196, 97, 47, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-group__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal overlay */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
animation: fadeIn 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #1F2421;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
animation: slideUp 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal--large {
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__body {
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form elements */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-required {
|
||||||
|
color: #C4612F;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-textarea,
|
||||||
|
.form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-textarea:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(196, 97, 47, 0.5);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #f87171;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon picker */
|
||||||
|
.icon-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-picker__item {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-picker__item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-picker__item--active {
|
||||||
|
background: rgba(196, 97, 47, 0.2);
|
||||||
|
border-color: rgba(196, 97, 47, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prompt guide */
|
||||||
|
.form-guide-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #C4612F;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-guide-btn:hover {
|
||||||
|
background: rgba(196, 97, 47, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-guide {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-guide strong {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-guide ul {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-guide__code {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: #C4612F;
|
||||||
|
color: white;
|
||||||
|
border-color: #C4612F;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary:hover:not(:disabled) {
|
||||||
|
background: #A94E22;
|
||||||
|
border-color: #A94E22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,33 +7,42 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { useVisionSession } from "./hooks/useVisionSession";
|
import { useVisionSession } from "./hooks/useVisionSession";
|
||||||
import { useSessionList } from "./hooks/useSessionList";
|
import { useSessionList } from "./hooks/useSessionList";
|
||||||
|
import { useScenarios } from "./hooks/useScenarios";
|
||||||
import { VideoPreview } from "./components/VideoPreview";
|
import { VideoPreview } from "./components/VideoPreview";
|
||||||
import { ChatPanel } from "./components/ChatPanel";
|
import { ChatPanel } from "./components/ChatPanel";
|
||||||
import { ConfigPanel } from "./components/ConfigPanel";
|
import { ConfigPanel } from "./components/ConfigPanel";
|
||||||
import { SessionSidebar } from "./components/SessionSidebar";
|
import { SessionSidebar } from "./components/SessionSidebar";
|
||||||
import { ToastContainer } from "./components/Toast";
|
import { ToastContainer } from "./components/Toast";
|
||||||
import { LandingPage } from "./components/LandingPage";
|
import { LandingPage } from "./components/LandingPage";
|
||||||
|
import { CreateScenarioModal } from "./components/CreateScenarioModal";
|
||||||
|
import { EditScenarioModal } from "./components/EditScenarioModal";
|
||||||
import { AuthProvider, useAuth } from "./lib/auth";
|
import { AuthProvider, useAuth } from "./lib/auth";
|
||||||
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
|
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
|
||||||
import { I18nContext, parseLocale, t } from "./lib/i18n";
|
import { I18nContext, parseLocale, t } from "./lib/i18n";
|
||||||
import { scenarios } from "./lib/scenarios";
|
|
||||||
import type { Locale } from "./lib/i18n";
|
import type { Locale } from "./lib/i18n";
|
||||||
import type { Theme } from "./types";
|
import type { Theme } from "./types";
|
||||||
|
import type { UserScenario } from "./lib/api/scenarios";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
/** AI 视觉模式 */
|
|
||||||
type VisionMode = "realtime" | "ondemand" | "chat";
|
|
||||||
|
|
||||||
/** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */
|
/** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { isAuthenticated, isLoading, user, logout, accessToken } = useAuth();
|
const { isAuthenticated, isLoading, user, logout, accessToken } = useAuth();
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [showCreateScenario, setShowCreateScenario] = useState(false);
|
||||||
|
const [editingScenario, setEditingScenario] = useState<UserScenario | null>(null);
|
||||||
const [theme, setTheme] = useState<Theme>(loadTheme);
|
const [theme, setTheme] = useState<Theme>(loadTheme);
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [visionMode, setVisionMode] = useState<VisionMode>("ondemand");
|
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// ---- 自建情景管理 ----
|
||||||
|
const {
|
||||||
|
allScenarios,
|
||||||
|
createScenario,
|
||||||
|
updateScenario,
|
||||||
|
deleteScenario,
|
||||||
|
} = useScenarios(accessToken);
|
||||||
|
|
||||||
// 切换主题时更新 <html> 的 data-theme 属性
|
// 切换主题时更新 <html> 的 data-theme 属性
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute("data-theme", theme);
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
@@ -78,8 +87,6 @@ function AppContent() {
|
|||||||
config,
|
config,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
stats,
|
stats,
|
||||||
mode,
|
|
||||||
isObserving,
|
|
||||||
startSession,
|
startSession,
|
||||||
stopSession,
|
stopSession,
|
||||||
stopVideo,
|
stopVideo,
|
||||||
@@ -89,6 +96,9 @@ function AppContent() {
|
|||||||
toggleCamera,
|
toggleCamera,
|
||||||
toggleMic,
|
toggleMic,
|
||||||
sendTextMessage,
|
sendTextMessage,
|
||||||
|
cameras,
|
||||||
|
mics,
|
||||||
|
switchDevice,
|
||||||
} = useVisionSession(accessToken, activeSessionId);
|
} = useVisionSession(accessToken, activeSessionId);
|
||||||
|
|
||||||
const isConnected = connectionStatus === "connected";
|
const isConnected = connectionStatus === "connected";
|
||||||
@@ -185,15 +195,20 @@ function AppContent() {
|
|||||||
|
|
||||||
// ---- 情景选择 ----
|
// ---- 情景选择 ----
|
||||||
const handleSelectScenario = useCallback((scenarioId: string) => {
|
const handleSelectScenario = useCallback((scenarioId: string) => {
|
||||||
const sc = scenarios.find(s => s.id === scenarioId);
|
const sc = allScenarios.find((s) => s.id === scenarioId);
|
||||||
const updates: Partial<import("./types").SessionConfig> = { scenario: scenarioId };
|
const updates: Partial<import("./types").SessionConfig> = { scenario: scenarioId };
|
||||||
// 如果情景有默认语言,同步切换
|
// 如果情景有默认语言,同步切换(仅系统预置情景)
|
||||||
if (sc?.defaultLanguage) {
|
if (sc && !sc.isCustom) {
|
||||||
updates.language = sc.defaultLanguage;
|
const systemScenario = sc as any;
|
||||||
|
if (systemScenario.defaultLanguage) {
|
||||||
|
updates.language = systemScenario.defaultLanguage;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updateConfig(updates);
|
updateConfig(updates);
|
||||||
// 插入系统提示消息
|
// 插入系统提示消息
|
||||||
const scenarioName = sc ? `${sc.icon} ${tr(sc.nameKey)}` : scenarioId;
|
const scenarioName = sc
|
||||||
|
? `${sc.icon} ${sc.nameKey ? tr(sc.nameKey) : sc.name}`
|
||||||
|
: scenarioId;
|
||||||
setMessages(prev => [...prev, {
|
setMessages(prev => [...prev, {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
role: "system",
|
role: "system",
|
||||||
@@ -203,7 +218,7 @@ function AppContent() {
|
|||||||
}, [updateConfig, setMessages, tr]);
|
}, [updateConfig, setMessages, tr]);
|
||||||
|
|
||||||
// 当前情景对象
|
// 当前情景对象
|
||||||
const activeScenario = scenarios.find(s => s.id === (config.scenario || "free_chat")) || scenarios[0];
|
const activeScenario = allScenarios.find((s) => s.id === (config.scenario || "free_chat")) || allScenarios[0];
|
||||||
|
|
||||||
// ---- 键盘快捷键 ----
|
// ---- 键盘快捷键 ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -293,10 +308,41 @@ function AppContent() {
|
|||||||
config={config}
|
config={config}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
username={user?.username}
|
username={user?.username}
|
||||||
|
allScenarios={allScenarios}
|
||||||
onUpdate={updateConfig}
|
onUpdate={updateConfig}
|
||||||
onThemeChange={handleThemeChange}
|
onThemeChange={handleThemeChange}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
onClose={() => setShowConfig(false)}
|
onClose={() => setShowConfig(false)}
|
||||||
|
onCreateScenario={() => {
|
||||||
|
setShowConfig(false);
|
||||||
|
setShowCreateScenario(true);
|
||||||
|
}}
|
||||||
|
onEditScenario={(scenario) => {
|
||||||
|
setShowConfig(false);
|
||||||
|
setEditingScenario(scenario);
|
||||||
|
}}
|
||||||
|
onDeleteScenario={deleteScenario}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 创建情景 Modal */}
|
||||||
|
{showCreateScenario && (
|
||||||
|
<CreateScenarioModal
|
||||||
|
onClose={() => setShowCreateScenario(false)}
|
||||||
|
onSubmit={async (data) => {
|
||||||
|
await createScenario(data);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 编辑情景 Modal */}
|
||||||
|
{editingScenario && (
|
||||||
|
<EditScenarioModal
|
||||||
|
scenario={editingScenario}
|
||||||
|
onClose={() => setEditingScenario(null)}
|
||||||
|
onSubmit={async (id, data) => {
|
||||||
|
await updateScenario(id, data);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -316,13 +362,13 @@ function AppContent() {
|
|||||||
<div className="detail-badge">HD</div>
|
<div className="detail-badge">HD</div>
|
||||||
)}
|
)}
|
||||||
{/* AI 视觉状态指示 */}
|
{/* AI 视觉状态指示 */}
|
||||||
{isConnected && visionMode !== "chat" && (
|
{isConnected && (
|
||||||
<div className={`ai-vision-indicator ${isObserving ? "ai-vision-indicator--active" : ""}`}>
|
<div className="ai-vision-indicator">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{isObserving ? tr("video.observing") : "AI"}</span>
|
<span>AI</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isSpeaking && (
|
{isSpeaking && (
|
||||||
@@ -363,42 +409,6 @@ function AppContent() {
|
|||||||
<button className="btn btn--primary btn--lg" onClick={startSession}>
|
<button className="btn btn--primary btn--lg" onClick={startSession}>
|
||||||
{connectionStatus === "connecting" ? tr("controls.connecting") : tr("controls.startVideo")}
|
{connectionStatus === "connecting" ? tr("controls.connecting") : tr("controls.startVideo")}
|
||||||
</button>
|
</button>
|
||||||
{/* 设备选择器 */}
|
|
||||||
<div className="video-controls__devices">
|
|
||||||
<div className="device-select-wrapper">
|
|
||||||
<label className="device-select-label">📷 {tr("controls.device.camera")}</label>
|
|
||||||
<select className="device-select" defaultValue="default">
|
|
||||||
<option value="default">{tr("controls.device.default")}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="device-select-wrapper">
|
|
||||||
<label className="device-select-label">🎤 {tr("controls.device.mic")}</label>
|
|
||||||
<select className="device-select" defaultValue="default">
|
|
||||||
<option value="default">{tr("controls.device.default")}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 模式切换器 */}
|
|
||||||
<div className="video-controls__mode">
|
|
||||||
<button
|
|
||||||
className={`mode-btn ${visionMode === "realtime" ? "mode-btn--active" : ""}`}
|
|
||||||
onClick={() => setVisionMode("realtime")}
|
|
||||||
>
|
|
||||||
{tr("controls.mode.realtime")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`mode-btn ${visionMode === "ondemand" ? "mode-btn--active" : ""}`}
|
|
||||||
onClick={() => setVisionMode("ondemand")}
|
|
||||||
>
|
|
||||||
{tr("controls.mode.ondemand")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`mode-btn ${visionMode === "chat" ? "mode-btn--active" : ""}`}
|
|
||||||
onClick={() => setVisionMode("chat")}
|
|
||||||
>
|
|
||||||
{tr("controls.mode.chat")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
) : isCameraOn ? (
|
) : isCameraOn ? (
|
||||||
<>
|
<>
|
||||||
@@ -418,16 +428,14 @@ function AppContent() {
|
|||||||
>
|
>
|
||||||
🎤
|
🎤
|
||||||
</button>
|
</button>
|
||||||
{/* 识别画面按钮(按需模式下显示) */}
|
{/* 识别画面按钮 */}
|
||||||
{visionMode === "ondemand" && (
|
<button
|
||||||
<button
|
className="btn--recognize"
|
||||||
className="btn--recognize"
|
onClick={handleRecognize}
|
||||||
onClick={handleRecognize}
|
disabled={isProcessing}
|
||||||
disabled={isProcessing}
|
>
|
||||||
>
|
🔍 {tr("controls.recognize")}
|
||||||
🔍 {tr("controls.recognize")}
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<button className="btn btn--warning" onClick={interrupt}>
|
<button className="btn btn--warning" onClick={interrupt}>
|
||||||
{tr("controls.interrupt")}
|
{tr("controls.interrupt")}
|
||||||
@@ -437,26 +445,34 @@ function AppContent() {
|
|||||||
{tr("controls.stopVideo")}
|
{tr("controls.stopVideo")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* 通话态模式切换 */}
|
{/* 设备选择器 */}
|
||||||
<div className="video-controls__mode">
|
<div className="video-controls__devices">
|
||||||
<button
|
<div className="device-select-wrapper">
|
||||||
className={`mode-btn ${visionMode === "realtime" ? "mode-btn--active" : ""}`}
|
<label className="device-select-label">📷</label>
|
||||||
onClick={() => setVisionMode("realtime")}
|
<select
|
||||||
>
|
className="device-select"
|
||||||
{tr("controls.mode.realtime")}
|
value={config.cameraDeviceId || "default"}
|
||||||
</button>
|
onChange={(e) => switchDevice("camera", e.target.value === "default" ? "" : e.target.value)}
|
||||||
<button
|
>
|
||||||
className={`mode-btn ${visionMode === "ondemand" ? "mode-btn--active" : ""}`}
|
<option value="default">{tr("controls.device.default")}</option>
|
||||||
onClick={() => setVisionMode("ondemand")}
|
{cameras.map((d) => (
|
||||||
>
|
<option key={d.deviceId} value={d.deviceId}>{d.label}</option>
|
||||||
{tr("controls.mode.ondemand")}
|
))}
|
||||||
</button>
|
</select>
|
||||||
<button
|
</div>
|
||||||
className={`mode-btn ${visionMode === "chat" ? "mode-btn--active" : ""}`}
|
<div className="device-select-wrapper">
|
||||||
onClick={() => setVisionMode("chat")}
|
<label className="device-select-label">🎤</label>
|
||||||
>
|
<select
|
||||||
{tr("controls.mode.chat")}
|
className="device-select"
|
||||||
</button>
|
value={config.micDeviceId || "default"}
|
||||||
|
onChange={(e) => switchDevice("mic", e.target.value === "default" ? "" : e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="default">{tr("controls.device.default")}</option>
|
||||||
|
{mics.map((d) => (
|
||||||
|
<option key={d.deviceId} value={d.deviceId}>{d.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -492,13 +508,12 @@ function AppContent() {
|
|||||||
onChange={(e) => handleSelectScenario(e.target.value)}
|
onChange={(e) => handleSelectScenario(e.target.value)}
|
||||||
title={tr("settings.scenario")}
|
title={tr("settings.scenario")}
|
||||||
>
|
>
|
||||||
{scenarios.map((sc) => (
|
{allScenarios.map((sc) => (
|
||||||
<option key={sc.id} value={sc.id}>{sc.icon} {tr(sc.nameKey)}</option>
|
<option key={sc.id} value={sc.id}>
|
||||||
|
{sc.icon} {sc.nameKey ? tr(sc.nameKey) : sc.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{isConnected && mode === "observation" && (
|
|
||||||
<span className="chat-panel-header__mode">{tr("chat.mode.observation")}</span>
|
|
||||||
)}
|
|
||||||
{isConnected && stats.queryCount > 0 && (
|
{isConnected && stats.queryCount > 0 && (
|
||||||
<span className="chat-panel-header__stats">
|
<span className="chat-panel-header__stats">
|
||||||
{stats.queryCount} {tr("statusbar.recognitions")}
|
{stats.queryCount} {tr("statusbar.recognitions")}
|
||||||
|
|||||||
@@ -19,10 +19,13 @@ export function useCamera() {
|
|||||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const startCamera = useCallback(async () => {
|
const startCamera = useCallback(async (deviceId?: string) => {
|
||||||
try {
|
try {
|
||||||
|
const videoConstraints: MediaTrackConstraints = deviceId
|
||||||
|
? { deviceId: { exact: deviceId }, width: 640, height: 480 }
|
||||||
|
: { facingMode: "environment", width: 640, height: 480 };
|
||||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
video: { facingMode: "environment", width: 640, height: 480 },
|
video: videoConstraints,
|
||||||
audio: false,
|
audio: false,
|
||||||
});
|
});
|
||||||
setStream(mediaStream);
|
setStream(mediaStream);
|
||||||
|
|||||||
@@ -1,31 +1,71 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ConfigPanel — 右侧抽屉式配置面板
|
// ConfigPanel — 右侧抽屉式配置面板
|
||||||
// 职责:主题切换、TTS 开关、detail level 切换、语言选择、情景选择
|
// 职责:主题切换、TTS 开关、detail level 切换、语言选择、情景选择(含自建)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
import { scenarios } from "../../lib/scenarios";
|
|
||||||
import type { SessionConfig, Theme } from "../../types";
|
import type { SessionConfig, Theme } from "../../types";
|
||||||
|
import type { ExtendedScenario } from "../../hooks/useScenarios";
|
||||||
|
import type { UserScenario } from "../../lib/api/scenarios";
|
||||||
|
|
||||||
interface ConfigPanelProps {
|
interface ConfigPanelProps {
|
||||||
config: SessionConfig;
|
config: SessionConfig;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
allScenarios: ExtendedScenario[];
|
||||||
onUpdate: (partial: Partial<SessionConfig>) => void;
|
onUpdate: (partial: Partial<SessionConfig>) => void;
|
||||||
onThemeChange: (theme: Theme) => void;
|
onThemeChange: (theme: Theme) => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onCreateScenario: () => void;
|
||||||
|
onEditScenario: (scenario: UserScenario) => void;
|
||||||
|
onDeleteScenario: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigPanel({ config, theme, username, onUpdate, onThemeChange, onLogout, onClose }: ConfigPanelProps) {
|
export function ConfigPanel({
|
||||||
|
config,
|
||||||
|
theme,
|
||||||
|
username,
|
||||||
|
allScenarios,
|
||||||
|
onUpdate,
|
||||||
|
onThemeChange,
|
||||||
|
onLogout,
|
||||||
|
onClose,
|
||||||
|
onCreateScenario,
|
||||||
|
onEditScenario,
|
||||||
|
onDeleteScenario,
|
||||||
|
}: ConfigPanelProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 分组:系统预置 vs 自建
|
||||||
|
const systemScenarios = allScenarios.filter((s) => !s.isCustom);
|
||||||
|
const customScenarios = allScenarios.filter((s) => s.isCustom);
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (deleteConfirm === id) {
|
||||||
|
onDeleteScenario(id);
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
// 如果当前选中的情景被删除,切换回自由对话
|
||||||
|
if (config.scenario === id) {
|
||||||
|
onUpdate({ scenario: "free_chat" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDeleteConfirm(id);
|
||||||
|
// 3秒后自动取消确认
|
||||||
|
setTimeout(() => setDeleteConfirm(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="drawer-overlay" onClick={onClose}>
|
<div className="drawer-overlay" onClick={onClose}>
|
||||||
<div className="drawer" onClick={(e) => e.stopPropagation()}>
|
<div className="drawer" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="drawer__header">
|
<div className="drawer__header">
|
||||||
<span className="drawer__title">{t("settings.title")}</span>
|
<span className="drawer__title">{t("settings.title")}</span>
|
||||||
<button className="drawer__close" onClick={onClose}>✕</button>
|
<button className="drawer__close" onClick={onClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="drawer__body">
|
<div className="drawer__body">
|
||||||
@@ -90,21 +130,89 @@ export function ConfigPanel({ config, theme, username, onUpdate, onThemeChange,
|
|||||||
<option value="ja-JP">日本語</option>
|
<option value="ja-JP">日本語</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="config-row">
|
{/* 系统预置情景 */}
|
||||||
<div className="config-row__info">
|
<div className="config-group">
|
||||||
<span className="config-row__label">{t("settings.scenario")}</span>
|
<div className="config-group__title">{t("settings.scenario.system")}</div>
|
||||||
<span className="config-row__desc">{t("settings.scenario.desc")}</span>
|
<div className="scenario-list">
|
||||||
</div>
|
{systemScenarios.map((sc) => (
|
||||||
<select
|
<label key={sc.id} className="scenario-item">
|
||||||
value={config.scenario || "free_chat"}
|
<input
|
||||||
onChange={(e) => onUpdate({ scenario: e.target.value })}
|
type="radio"
|
||||||
|
name="scenario"
|
||||||
|
value={sc.id}
|
||||||
|
checked={config.scenario === sc.id}
|
||||||
|
onChange={(e) => onUpdate({ scenario: e.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="scenario-item__icon">{sc.icon}</span>
|
||||||
|
<span className="scenario-item__name">
|
||||||
|
{sc.nameKey ? t(sc.nameKey) : sc.name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 自建情景 */}
|
||||||
|
<div className="config-group">
|
||||||
|
<div className="config-group__title">
|
||||||
|
{t("settings.scenario.custom")}
|
||||||
|
<button
|
||||||
|
className="config-create-btn"
|
||||||
|
onClick={onCreateScenario}
|
||||||
|
title={t("scenario.create.button")}
|
||||||
>
|
>
|
||||||
{scenarios.map((sc) => (
|
+ {t("scenario.create.button")}
|
||||||
<option key={sc.id} value={sc.id}>{sc.icon} {t(sc.nameKey)}</option>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customScenarios.length === 0 ? (
|
||||||
|
<div className="scenario-empty">
|
||||||
|
{t("settings.scenario.empty")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="scenario-list">
|
||||||
|
{customScenarios.map((sc) => (
|
||||||
|
<div key={sc.id} className="scenario-item scenario-item--custom">
|
||||||
|
<label className="scenario-item__radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="scenario"
|
||||||
|
value={sc.id}
|
||||||
|
checked={config.scenario === sc.id}
|
||||||
|
onChange={(e) => onUpdate({ scenario: e.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="scenario-item__icon">{sc.icon}</span>
|
||||||
|
<div className="scenario-item__info">
|
||||||
|
<span className="scenario-item__name">{sc.name}</span>
|
||||||
|
{sc.description && (
|
||||||
|
<span className="scenario-item__desc">{sc.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div className="scenario-item__actions">
|
||||||
|
<button
|
||||||
|
className="scenario-action-btn scenario-action-btn--edit"
|
||||||
|
onClick={() => onEditScenario(sc as unknown as UserScenario)}
|
||||||
|
title={t("common.edit")}
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`scenario-action-btn scenario-action-btn--delete ${
|
||||||
|
deleteConfirm === sc.id ? "scenario-action-btn--confirm" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleDelete(sc.id)}
|
||||||
|
title={deleteConfirm === sc.id ? t("common.confirmDelete") : t("common.delete")}
|
||||||
|
>
|
||||||
|
{deleteConfirm === sc.id ? "✓" : "🗑"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>
|
||||||
</label>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{username && onLogout && (
|
{username && onLogout && (
|
||||||
@@ -116,10 +224,7 @@ export function ConfigPanel({ config, theme, username, onUpdate, onThemeChange,
|
|||||||
<span className="config-row__desc">{username}</span>
|
<span className="config-row__desc">{username}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button className="config-logout-btn" onClick={onLogout}>
|
||||||
className="config-logout-btn"
|
|
||||||
onClick={onLogout}
|
|
||||||
>
|
|
||||||
{t("auth.logout")}
|
{t("auth.logout")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
263
frontend/src/components/CreateScenarioModal/index.tsx
Normal file
263
frontend/src/components/CreateScenarioModal/index.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
// ============================================================
|
||||||
|
// CreateScenarioModal — 创建自建情景对话框
|
||||||
|
// 职责:提供表单让用户创建新的自定义情景
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
import type { CreateScenarioRequest } from "../../lib/api/scenarios";
|
||||||
|
|
||||||
|
interface CreateScenarioModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: CreateScenarioRequest) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预设常用图标
|
||||||
|
const PRESET_ICONS = [
|
||||||
|
"✨", "🎭", "🎨", "🎯", "🎪", "🎬",
|
||||||
|
"📖", "📚", "📝", "📋", "📌", "📍",
|
||||||
|
"🔬", "🔭", "🔮", "💡", "💼", "💻",
|
||||||
|
"🎓", "🎤", "🎵", "🎸", "🎹", "🎺",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CreateScenarioModal({ onClose, onSubmit }: CreateScenarioModalProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [icon, setIcon] = useState("✨");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [greeting, setGreeting] = useState("");
|
||||||
|
const [language, setLanguage] = useState("zh-CN");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [showGuide, setShowGuide] = useState(false);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 点击遮罩关闭
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (e.target === overlayRef.current) onClose();
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ESC 关闭
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// 阻止 body 滚动
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
// 表单验证
|
||||||
|
if (name.length < 2 || name.length > 50) {
|
||||||
|
setError(t("scenario.error.nameLength"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (prompt.length < 10 || prompt.length > 2000) {
|
||||||
|
setError(t("scenario.error.promptLength"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (greeting && greeting.length > 500) {
|
||||||
|
setError(t("scenario.error.greetingLength"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit({
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
description: description || undefined,
|
||||||
|
prompt,
|
||||||
|
greeting: greeting || undefined,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Unknown error");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[name, icon, description, prompt, greeting, language, onSubmit, onClose, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
className="modal-overlay"
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
>
|
||||||
|
<div className="modal modal--large" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal__header">
|
||||||
|
<span className="modal__title">{t("scenario.create.title")}</span>
|
||||||
|
<button className="modal__close" onClick={onClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="modal__body" onSubmit={handleSubmit}>
|
||||||
|
{/* 名称 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">
|
||||||
|
{t("scenario.create.name")} <span className="form-required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("scenario.create.namePlaceholder")}
|
||||||
|
maxLength={50}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className="form-hint">{name.length}/50</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图标选择 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t("scenario.create.icon")}</label>
|
||||||
|
<div className="icon-picker">
|
||||||
|
{PRESET_ICONS.map((ic) => (
|
||||||
|
<button
|
||||||
|
key={ic}
|
||||||
|
type="button"
|
||||||
|
className={`icon-picker__item ${icon === ic ? "icon-picker__item--active" : ""}`}
|
||||||
|
onClick={() => setIcon(ic)}
|
||||||
|
>
|
||||||
|
{ic}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t("scenario.create.description")}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t("scenario.create.descriptionPlaceholder")}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
<span className="form-hint">{description.length}/100</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">
|
||||||
|
{t("scenario.create.prompt")} <span className="form-required">*</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="form-guide-btn"
|
||||||
|
onClick={() => setShowGuide(!showGuide)}
|
||||||
|
>
|
||||||
|
{showGuide ? "▼" : "▶"} {t("scenario.create.promptGuide")}
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
{showGuide && (
|
||||||
|
<div className="form-guide">
|
||||||
|
<p><strong>{t("scenario.create.promptGuide.tips")}</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>{t("scenario.create.promptGuide.tip1")}</li>
|
||||||
|
<li>{t("scenario.create.promptGuide.tip2")}</li>
|
||||||
|
<li>{t("scenario.create.promptGuide.tip3")}</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>{t("scenario.create.promptGuide.example")}</strong></p>
|
||||||
|
<pre className="form-guide__code">
|
||||||
|
{`你是一位创意写作导师。
|
||||||
|
帮助用户构思故事情节、人物设定和写作技巧。
|
||||||
|
|
||||||
|
【角色定位】
|
||||||
|
- 你是导师,不是代笔人
|
||||||
|
- 激发用户创意,不直接给答案
|
||||||
|
|
||||||
|
【交互规则】
|
||||||
|
1. 提出启发性问题
|
||||||
|
2. 给出具体、可操作的建议
|
||||||
|
3. 回答控制在3-5句话`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
className="form-textarea"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={t("scenario.create.promptPlaceholder")}
|
||||||
|
rows={8}
|
||||||
|
maxLength={2000}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className="form-hint">{prompt.length}/2000</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 首句引导 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t("scenario.create.greeting")}</label>
|
||||||
|
<textarea
|
||||||
|
className="form-textarea"
|
||||||
|
value={greeting}
|
||||||
|
onChange={(e) => setGreeting(e.target.value)}
|
||||||
|
placeholder={t("scenario.create.greetingPlaceholder")}
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<span className="form-hint">{greeting.length}/500</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 语言 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t("scenario.create.language")}</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={language}
|
||||||
|
onChange={(e) => setLanguage(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="zh-CN">中文</option>
|
||||||
|
<option value="en-US">English</option>
|
||||||
|
<option value="ja-JP">日本語</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="form-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="modal__footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn--primary"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? t("common.creating") : t("common.create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
frontend/src/components/EditScenarioModal/index.tsx
Normal file
264
frontend/src/components/EditScenarioModal/index.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
// ============================================================
|
||||||
|
// EditScenarioModal — 编辑自建情景对话框
|
||||||
|
// 职责:提供表单让用户编辑现有的自定义情景
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
import type { UpdateScenarioRequest, UserScenario } from "../../lib/api/scenarios";
|
||||||
|
|
||||||
|
interface EditScenarioModalProps {
|
||||||
|
scenario: UserScenario;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (id: string, data: UpdateScenarioRequest) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预设常用图标
|
||||||
|
const PRESET_ICONS = [
|
||||||
|
"✨", "🎭", "🎨", "🎯", "🎪", "🎬",
|
||||||
|
"📖", "📚", "📝", "📋", "📌", "📍",
|
||||||
|
"🔬", "🔭", "🔮", "💡", "💼", "💻",
|
||||||
|
"🎓", "🎤", "🎵", "🎸", "🎹", "🎺",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function EditScenarioModal({ scenario, onClose, onSubmit }: EditScenarioModalProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [name, setName] = useState(scenario.name);
|
||||||
|
const [icon, setIcon] = useState(scenario.icon);
|
||||||
|
const [description, setDescription] = useState(scenario.description);
|
||||||
|
const [prompt, setPrompt] = useState(scenario.prompt);
|
||||||
|
const [greeting, setGreeting] = useState(scenario.greeting);
|
||||||
|
const [language, setLanguage] = useState(scenario.language);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [showGuide, setShowGuide] = useState(false);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 点击遮罩关闭
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (e.target === overlayRef.current) onClose();
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ESC 关闭
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// 阻止 body 滚动
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
// 表单验证
|
||||||
|
if (name.length < 2 || name.length > 50) {
|
||||||
|
setError(t("scenario.error.nameLength"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (prompt.length < 10 || prompt.length > 2000) {
|
||||||
|
setError(t("scenario.error.promptLength"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (greeting && greeting.length > 500) {
|
||||||
|
setError(t("scenario.error.greetingLength"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit(scenario.id, {
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
description: description || undefined,
|
||||||
|
prompt,
|
||||||
|
greeting: greeting || undefined,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Unknown error");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scenario.id, name, icon, description, prompt, greeting, language, onSubmit, onClose, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
className="modal-overlay"
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
>
|
||||||
|
<div className="modal modal--large" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal__header">
|
||||||
|
<span className="modal__title">{t("scenario.edit.title")}</span>
|
||||||
|
<button className="modal__close" onClick={onClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="modal__body" onSubmit={handleSubmit}>
|
||||||
|
{/* 名称 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">
|
||||||
|
{t("scenario.create.name")} <span className="form-required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("scenario.create.namePlaceholder")}
|
||||||
|
maxLength={50}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className="form-hint">{name.length}/50</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图标选择 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t("scenario.create.icon")}</label>
|
||||||
|
<div className="icon-picker">
|
||||||
|
{PRESET_ICONS.map((ic) => (
|
||||||
|
<button
|
||||||
|
key={ic}
|
||||||
|
type="button"
|
||||||
|
className={`icon-picker__item ${icon === ic ? "icon-picker__item--active" : ""}`}
|
||||||
|
onClick={() => setIcon(ic)}
|
||||||
|
>
|
||||||
|
{ic}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t("scenario.create.description")}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t("scenario.create.descriptionPlaceholder")}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
<span className="form-hint">{description.length}/100</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">
|
||||||
|
{t("scenario.create.prompt")} <span className="form-required">*</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="form-guide-btn"
|
||||||
|
onClick={() => setShowGuide(!showGuide)}
|
||||||
|
>
|
||||||
|
{showGuide ? "▼" : "▶"} {t("scenario.create.promptGuide")}
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
{showGuide && (
|
||||||
|
<div className="form-guide">
|
||||||
|
<p><strong>{t("scenario.create.promptGuide.tips")}</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>{t("scenario.create.promptGuide.tip1")}</li>
|
||||||
|
<li>{t("scenario.create.promptGuide.tip2")}</li>
|
||||||
|
<li>{t("scenario.create.promptGuide.tip3")}</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>{t("scenario.create.promptGuide.example")}</strong></p>
|
||||||
|
<pre className="form-guide__code">
|
||||||
|
{`你是一位创意写作导师。
|
||||||
|
帮助用户构思故事情节、人物设定和写作技巧。
|
||||||
|
|
||||||
|
【角色定位】
|
||||||
|
- 你是导师,不是代笔人
|
||||||
|
- 激发用户创意,不直接给答案
|
||||||
|
|
||||||
|
【交互规则】
|
||||||
|
1. 提出启发性问题
|
||||||
|
2. 给出具体、可操作的建议
|
||||||
|
3. 回答控制在3-5句话`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
className="form-textarea"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={t("scenario.create.promptPlaceholder")}
|
||||||
|
rows={8}
|
||||||
|
maxLength={2000}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className="form-hint">{prompt.length}/2000</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 首句引导 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t("scenario.create.greeting")}</label>
|
||||||
|
<textarea
|
||||||
|
className="form-textarea"
|
||||||
|
value={greeting}
|
||||||
|
onChange={(e) => setGreeting(e.target.value)}
|
||||||
|
placeholder={t("scenario.create.greetingPlaceholder")}
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<span className="form-hint">{greeting.length}/500</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 语言 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t("scenario.create.language")}</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={language}
|
||||||
|
onChange={(e) => setLanguage(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="zh-CN">中文</option>
|
||||||
|
<option value="en-US">English</option>
|
||||||
|
<option value="ja-JP">日本語</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="form-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="modal__footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn--primary"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? t("common.saving") : t("common.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,15 +12,13 @@ export function useMicrophone() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
|
||||||
const startMic = useCallback(async () => {
|
const startMic = useCallback(async (deviceId?: string) => {
|
||||||
try {
|
try {
|
||||||
|
const audioConstraints: MediaTrackConstraints = deviceId
|
||||||
|
? { deviceId: { exact: deviceId }, sampleRate: 16000, channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
||||||
|
: { sampleRate: 16000, channelCount: 1, echoCancellation: true, noiseSuppression: true };
|
||||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: audioConstraints,
|
||||||
sampleRate: 16000,
|
|
||||||
channelCount: 1,
|
|
||||||
echoCancellation: true,
|
|
||||||
noiseSuppression: true,
|
|
||||||
},
|
|
||||||
video: false,
|
video: false,
|
||||||
});
|
});
|
||||||
setStream(mediaStream);
|
setStream(mediaStream);
|
||||||
|
|||||||
44
frontend/src/hooks/useDeviceList.ts
Normal file
44
frontend/src/hooks/useDeviceList.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// ============================================================
|
||||||
|
// useDeviceList — 设备枚举 Hook
|
||||||
|
// 职责:枚举摄像头/麦克风设备列表,监听设备热插拔
|
||||||
|
// 注意:首次枚举需要先有一次成功的 getUserMedia 授权
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
deviceId: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeviceList() {
|
||||||
|
const [cameras, setCameras] = useState<DeviceInfo[]>([]);
|
||||||
|
const [mics, setMics] = useState<DeviceInfo[]>([]);
|
||||||
|
|
||||||
|
/** 枚举当前可用的音视频输入设备 */
|
||||||
|
const refreshDevices = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
setCameras(
|
||||||
|
devices
|
||||||
|
.filter((d) => d.kind === "videoinput")
|
||||||
|
.map((d) => ({ deviceId: d.deviceId, label: d.label || `摄像头 ${d.deviceId.slice(0, 4)}` }))
|
||||||
|
);
|
||||||
|
setMics(
|
||||||
|
devices
|
||||||
|
.filter((d) => d.kind === "audioinput")
|
||||||
|
.map((d) => ({ deviceId: d.deviceId, label: d.label || `麦克风 ${d.deviceId.slice(0, 4)}` }))
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[DeviceList] 枚举设备失败:", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 监听设备热插拔
|
||||||
|
useEffect(() => {
|
||||||
|
navigator.mediaDevices.addEventListener("devicechange", refreshDevices);
|
||||||
|
return () => navigator.mediaDevices.removeEventListener("devicechange", refreshDevices);
|
||||||
|
}, [refreshDevices]);
|
||||||
|
|
||||||
|
return { cameras, mics, refreshDevices };
|
||||||
|
}
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// useObservationMode — 观察模式 Hook
|
|
||||||
// 职责:定时采帧 → 关键帧检测 → 画面变化时触发回调
|
|
||||||
// 来源:docs/05-用户故事.md US-05(持续场景监控)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { sampleFrame, compareFrames } from "../components/EdgeProcessor";
|
|
||||||
|
|
||||||
/** 画面变化显著阈值 */
|
|
||||||
const CHANGE_THRESHOLD = 0.85;
|
|
||||||
/** 采样间隔(ms) */
|
|
||||||
const SAMPLE_INTERVAL = 5000;
|
|
||||||
|
|
||||||
export interface ObservationOptions {
|
|
||||||
/** 画面变化回调,携带当前帧的 DataURL */
|
|
||||||
onChange?: (frameDataUrl: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useObservationMode(options?: ObservationOptions) {
|
|
||||||
const [isObserving, setIsObserving] = useState(false);
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const prevFrameRef = useRef<Uint8ClampedArray | null>(null);
|
|
||||||
const optionsRef = useRef(options);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
optionsRef.current = options;
|
|
||||||
}, [options]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动观察模式
|
|
||||||
* @param video 摄像头 video 元素
|
|
||||||
* @param captureFrame 从 video 捕获 DataURL 的函数
|
|
||||||
*/
|
|
||||||
const startObserving = useCallback(
|
|
||||||
(video: HTMLVideoElement | null, captureFrame: () => string | null) => {
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
// 立即采一帧作为基准
|
|
||||||
prevFrameRef.current = sampleFrame(video);
|
|
||||||
|
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
const current = sampleFrame(video);
|
|
||||||
if (!current) return;
|
|
||||||
|
|
||||||
if (prevFrameRef.current) {
|
|
||||||
const { similarity } = compareFrames(prevFrameRef.current, current);
|
|
||||||
|
|
||||||
if (similarity < CHANGE_THRESHOLD) {
|
|
||||||
console.log(
|
|
||||||
`[Observation] 画面变化 (similarity=${similarity.toFixed(2)})`,
|
|
||||||
);
|
|
||||||
const frameDataUrl = captureFrame();
|
|
||||||
if (frameDataUrl) {
|
|
||||||
optionsRef.current?.onChange?.(frameDataUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prevFrameRef.current = current;
|
|
||||||
}, SAMPLE_INTERVAL);
|
|
||||||
|
|
||||||
setIsObserving(true);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
/** 停止观察模式 */
|
|
||||||
const stopObserving = useCallback(() => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
prevFrameRef.current = null;
|
|
||||||
setIsObserving(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 组件卸载时清理
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { isObserving, startObserving, stopObserving };
|
|
||||||
}
|
|
||||||
147
frontend/src/hooks/useScenarios.ts
Normal file
147
frontend/src/hooks/useScenarios.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// ============================================================
|
||||||
|
// useScenarios — 用户自建情景管理 Hook
|
||||||
|
// 职责:封装自建情景的加载、创建、更新、删除逻辑,合并系统预置情景
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { scenarios as systemScenarios } from "../lib/scenarios";
|
||||||
|
import * as api from "../lib/api/scenarios";
|
||||||
|
|
||||||
|
export interface ExtendedScenario {
|
||||||
|
id: string;
|
||||||
|
icon: string;
|
||||||
|
name: string; // 直接显示的名称(系统情景用 nameKey,自建情景用 name)
|
||||||
|
nameKey?: string; // i18n key(仅系统预置情景有)
|
||||||
|
descKey?: string; // i18n key(仅系统预置情景有)
|
||||||
|
description?: string; // 直接显示的描述(自建情景)
|
||||||
|
isCustom: boolean; // true = 自建情景,false = 系统预置
|
||||||
|
prompt?: string; // 仅自建情景有
|
||||||
|
greeting?: string; // 仅自建情景有
|
||||||
|
language?: string; // 仅自建情景有
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScenarios(token: string | null) {
|
||||||
|
const [customScenarios, setCustomScenarios] = useState<api.UserScenario[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 合并系统预置 + 自建情景
|
||||||
|
const allScenarios: ExtendedScenario[] = [
|
||||||
|
// 系统预置情景
|
||||||
|
...systemScenarios.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
icon: s.icon,
|
||||||
|
name: s.nameKey,
|
||||||
|
nameKey: s.nameKey,
|
||||||
|
descKey: s.descKey,
|
||||||
|
isCustom: false,
|
||||||
|
})),
|
||||||
|
// 用户自建情景
|
||||||
|
...customScenarios.map((s: api.UserScenario) => ({
|
||||||
|
id: s.id,
|
||||||
|
icon: s.icon,
|
||||||
|
name: s.name,
|
||||||
|
description: s.description,
|
||||||
|
isCustom: true,
|
||||||
|
prompt: s.prompt,
|
||||||
|
greeting: s.greeting,
|
||||||
|
language: s.language,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 加载用户自建情景
|
||||||
|
const loadCustomScenarios = useCallback(async () => {
|
||||||
|
if (!token) {
|
||||||
|
setCustomScenarios([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await api.listUserScenarios(token);
|
||||||
|
setCustomScenarios(result.scenarios || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load custom scenarios:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "Unknown error");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
// 创建新情景
|
||||||
|
const createScenario = useCallback(
|
||||||
|
async (data: api.CreateScenarioRequest) => {
|
||||||
|
if (!token) throw new Error("Not authenticated");
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const newScenario = await api.createUserScenario(token, data);
|
||||||
|
setCustomScenarios((prev) => [...prev, newScenario]);
|
||||||
|
return newScenario;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新情景
|
||||||
|
const updateScenario = useCallback(
|
||||||
|
async (id: string, data: api.UpdateScenarioRequest) => {
|
||||||
|
if (!token) throw new Error("Not authenticated");
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await api.updateUserScenario(token, id, data);
|
||||||
|
setCustomScenarios((prev) =>
|
||||||
|
prev.map((s) => (s.id === id ? updated : s))
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 删除情景
|
||||||
|
const deleteScenario = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
if (!token) throw new Error("Not authenticated");
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.deleteUserScenario(token, id);
|
||||||
|
setCustomScenarios((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 登录后自动加载
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
loadCustomScenarios();
|
||||||
|
}
|
||||||
|
}, [token, loadCustomScenarios]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allScenarios,
|
||||||
|
customScenarios,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loadCustomScenarios,
|
||||||
|
createScenario,
|
||||||
|
updateScenario,
|
||||||
|
deleteScenario,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,11 +17,9 @@ import { useCamera } from "../components/CameraManager";
|
|||||||
import { useMicrophone } from "../components/MicManager";
|
import { useMicrophone } from "../components/MicManager";
|
||||||
import { useVAD, sampleFrame } from "../components/EdgeProcessor";
|
import { useVAD, sampleFrame } from "../components/EdgeProcessor";
|
||||||
import { useWebSocketManager } from "../components/WebSocketManager";
|
import { useWebSocketManager } from "../components/WebSocketManager";
|
||||||
import { useObservationMode } from "./useObservationMode";
|
import { useDeviceList } from "./useDeviceList";
|
||||||
import type { ChatMessage, SessionConfig, ServerMessage, LLMDoneMessage } from "../types";
|
import type { ChatMessage, SessionConfig, ServerMessage, LLMDoneMessage } from "../types";
|
||||||
|
|
||||||
export type SessionMode = "dialogue" | "observation";
|
|
||||||
|
|
||||||
export interface SessionStats {
|
export interface SessionStats {
|
||||||
queryCount: number;
|
queryCount: number;
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
@@ -35,7 +33,6 @@ export function useVisionSession(accessToken?: string | null, conversationId?: s
|
|||||||
const [isAudioPlaying, setIsAudioPlaying] = useState(false);
|
const [isAudioPlaying, setIsAudioPlaying] = useState(false);
|
||||||
const [config, setConfig] = useState<SessionConfig>(loadConfig);
|
const [config, setConfig] = useState<SessionConfig>(loadConfig);
|
||||||
const [stats, setStats] = useState<SessionStats>({ queryCount: 0, totalTokens: 0 });
|
const [stats, setStats] = useState<SessionStats>({ queryCount: 0, totalTokens: 0 });
|
||||||
const [mode, setMode] = useState<SessionMode>("dialogue");
|
|
||||||
const [isCameraOn, setIsCameraOn] = useState(false);
|
const [isCameraOn, setIsCameraOn] = useState(false);
|
||||||
const [isMicOn, setIsMicOn] = useState(false);
|
const [isMicOn, setIsMicOn] = useState(false);
|
||||||
|
|
||||||
@@ -59,6 +56,7 @@ export function useVisionSession(accessToken?: string | null, conversationId?: s
|
|||||||
const { videoRef, captureFrame, startCamera, stopCamera, stream } = useCamera();
|
const { videoRef, captureFrame, startCamera, stopCamera, stream } = useCamera();
|
||||||
const { startMic, stopMic } = useMicrophone();
|
const { startMic, stopMic } = useMicrophone();
|
||||||
const { status, connect, disconnect, send } = useWebSocketManager();
|
const { status, connect, disconnect, send } = useWebSocketManager();
|
||||||
|
const { cameras, mics, refreshDevices } = useDeviceList();
|
||||||
|
|
||||||
// 用 ref 跟踪 isProcessing,避免 VAD 回调闭包问题
|
// 用 ref 跟踪 isProcessing,避免 VAD 回调闭包问题
|
||||||
const isProcessingRef = useRef(false);
|
const isProcessingRef = useRef(false);
|
||||||
@@ -78,49 +76,6 @@ export function useVisionSession(accessToken?: string | null, conversationId?: s
|
|||||||
conversationIdRef.current = conversationId;
|
conversationIdRef.current = conversationId;
|
||||||
}, [conversationId]);
|
}, [conversationId]);
|
||||||
|
|
||||||
// 观察模式:画面变化时自动发送 query
|
|
||||||
const { isObserving, startObserving, stopObserving } = useObservationMode({
|
|
||||||
onChange: useCallback(
|
|
||||||
(frameDataUrl: string) => {
|
|
||||||
if (isProcessingRef.current) return;
|
|
||||||
|
|
||||||
const requestId = uuidv4();
|
|
||||||
send({
|
|
||||||
type: "query",
|
|
||||||
request_id: requestId,
|
|
||||||
image: dataUrlToBase64(frameDataUrl),
|
|
||||||
audio: "", // 观察模式无音频
|
|
||||||
});
|
|
||||||
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: uuidv4(),
|
|
||||||
role: "user",
|
|
||||||
content: t("session.changeDetected"),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setStats((prev) => ({ ...prev, queryCount: prev.queryCount + 1 }));
|
|
||||||
setIsProcessing(true);
|
|
||||||
},
|
|
||||||
[send],
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 切换对话/观察模式 */
|
|
||||||
const toggleMode = useCallback(() => {
|
|
||||||
setMode((prev) => {
|
|
||||||
const next = prev === "dialogue" ? "observation" : "dialogue";
|
|
||||||
if (next === "observation") {
|
|
||||||
startObserving(videoRef.current, captureFrame);
|
|
||||||
} else {
|
|
||||||
stopObserving();
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [videoRef, captureFrame, startObserving, stopObserving]);
|
|
||||||
|
|
||||||
// WebSocket 连接成功后发送 config + flush 待发消息
|
// WebSocket 连接成功后发送 config + flush 待发消息
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "connected") {
|
if (status === "connected") {
|
||||||
@@ -324,14 +279,14 @@ export function useVisionSession(accessToken?: string | null, conversationId?: s
|
|||||||
|
|
||||||
// 2. 尝试获取摄像头(可选)
|
// 2. 尝试获取摄像头(可选)
|
||||||
try {
|
try {
|
||||||
await startCamera();
|
await startCamera(config.cameraDeviceId || undefined);
|
||||||
setIsCameraOn(true);
|
setIsCameraOn(true);
|
||||||
} catch {
|
} catch {
|
||||||
console.warn("[Session] 无法获取摄像头权限,将以纯文本模式运行");
|
console.warn("[Session] 无法获取摄像头权限,将以纯文本模式运行");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 尝试获取麦克风(可选)
|
// 3. 尝试获取麦克风(可选)
|
||||||
const micStream = await startMic();
|
const micStream = await startMic(config.micDeviceId || undefined);
|
||||||
if (micStream) {
|
if (micStream) {
|
||||||
setIsMicOn(true);
|
setIsMicOn(true);
|
||||||
// 4. 启动 VAD(仅在麦克风可用时)
|
// 4. 启动 VAD(仅在麦克风可用时)
|
||||||
@@ -339,12 +294,13 @@ export function useVisionSession(accessToken?: string | null, conversationId?: s
|
|||||||
} else {
|
} else {
|
||||||
console.warn("[Session] 无法获取麦克风权限,将以文本输入模式运行");
|
console.warn("[Session] 无法获取麦克风权限,将以文本输入模式运行");
|
||||||
}
|
}
|
||||||
}, [startCamera, startMic, connect, startVAD, accessToken]);
|
|
||||||
|
// 5. 授权后刷新设备列表
|
||||||
|
refreshDevices();
|
||||||
|
}, [startCamera, startMic, connect, startVAD, accessToken, config.cameraDeviceId, config.micDeviceId, refreshDevices]);
|
||||||
|
|
||||||
/** 结束会话 */
|
/** 结束会话 */
|
||||||
const stopSession = useCallback(async () => {
|
const stopSession = useCallback(async () => {
|
||||||
stopObserving();
|
|
||||||
setMode("dialogue");
|
|
||||||
await stopVAD();
|
await stopVAD();
|
||||||
stopMic();
|
stopMic();
|
||||||
stopCamera();
|
stopCamera();
|
||||||
@@ -359,12 +315,10 @@ export function useVisionSession(accessToken?: string | null, conversationId?: s
|
|||||||
prevFrameRef.current = null;
|
prevFrameRef.current = null;
|
||||||
setIsCameraOn(false);
|
setIsCameraOn(false);
|
||||||
setIsMicOn(false);
|
setIsMicOn(false);
|
||||||
}, [stopObserving, stopVAD, stopMic, stopCamera, disconnect]);
|
}, [stopVAD, stopMic, stopCamera, disconnect]);
|
||||||
|
|
||||||
/** 结束视频,保留聊天和连接 */
|
/** 结束视频,保留聊天和连接 */
|
||||||
const stopVideo = useCallback(async () => {
|
const stopVideo = useCallback(async () => {
|
||||||
stopObserving();
|
|
||||||
setMode("dialogue");
|
|
||||||
await stopVAD();
|
await stopVAD();
|
||||||
stopMic();
|
stopMic();
|
||||||
stopCamera();
|
stopCamera();
|
||||||
@@ -375,7 +329,7 @@ export function useVisionSession(accessToken?: string | null, conversationId?: s
|
|||||||
setIsCameraOn(false);
|
setIsCameraOn(false);
|
||||||
setIsMicOn(false);
|
setIsMicOn(false);
|
||||||
// 不断开 WebSocket,不清空消息、统计
|
// 不断开 WebSocket,不清空消息、统计
|
||||||
}, [stopObserving, stopVAD, stopMic, stopCamera]);
|
}, [stopVAD, stopMic, stopCamera]);
|
||||||
|
|
||||||
/** 摄像头开关 */
|
/** 摄像头开关 */
|
||||||
const toggleCamera = useCallback(async () => {
|
const toggleCamera = useCallback(async () => {
|
||||||
@@ -383,10 +337,10 @@ export function useVisionSession(accessToken?: string | null, conversationId?: s
|
|||||||
stopCamera();
|
stopCamera();
|
||||||
setIsCameraOn(false);
|
setIsCameraOn(false);
|
||||||
} else {
|
} else {
|
||||||
await startCamera();
|
await startCamera(config.cameraDeviceId || undefined);
|
||||||
setIsCameraOn(true);
|
setIsCameraOn(true);
|
||||||
}
|
}
|
||||||
}, [isCameraOn, startCamera, stopCamera]);
|
}, [isCameraOn, startCamera, stopCamera, config.cameraDeviceId]);
|
||||||
|
|
||||||
/** 麦克风开关 */
|
/** 麦克风开关 */
|
||||||
const toggleMic = useCallback(async () => {
|
const toggleMic = useCallback(async () => {
|
||||||
@@ -395,13 +349,13 @@ export function useVisionSession(accessToken?: string | null, conversationId?: s
|
|||||||
stopMic();
|
stopMic();
|
||||||
setIsMicOn(false);
|
setIsMicOn(false);
|
||||||
} else {
|
} else {
|
||||||
const micStream = await startMic();
|
const micStream = await startMic(config.micDeviceId || undefined);
|
||||||
if (micStream) {
|
if (micStream) {
|
||||||
await startVAD(micStream);
|
await startVAD(micStream);
|
||||||
setIsMicOn(true);
|
setIsMicOn(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isMicOn, startMic, stopMic, startVAD, stopVAD]);
|
}, [isMicOn, startMic, stopMic, startVAD, stopVAD, config.micDeviceId]);
|
||||||
|
|
||||||
/** 打断当前回复 */
|
/** 打断当前回复 */
|
||||||
const interrupt = useCallback(() => {
|
const interrupt = useCallback(() => {
|
||||||
@@ -421,6 +375,31 @@ export function useVisionSession(accessToken?: string | null, conversationId?: s
|
|||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}, [send, currentReply]);
|
}, [send, currentReply]);
|
||||||
|
|
||||||
|
/** 切换设备(摄像头或麦克风) */
|
||||||
|
const switchDevice = useCallback(async (kind: "camera" | "mic", deviceId: string) => {
|
||||||
|
if (kind === "camera") {
|
||||||
|
updateConfig({ cameraDeviceId: deviceId || undefined });
|
||||||
|
if (isCameraOn) {
|
||||||
|
stopCamera();
|
||||||
|
try {
|
||||||
|
await startCamera(deviceId || undefined);
|
||||||
|
} catch {
|
||||||
|
console.warn("[Session] 切换摄像头失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateConfig({ micDeviceId: deviceId || undefined });
|
||||||
|
if (isMicOn) {
|
||||||
|
await stopVAD();
|
||||||
|
stopMic();
|
||||||
|
const micStream = await startMic(deviceId || undefined);
|
||||||
|
if (micStream) {
|
||||||
|
await startVAD(micStream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isCameraOn, isMicOn, stopCamera, startCamera, stopMic, startMic, stopVAD, startVAD, updateConfig]);
|
||||||
|
|
||||||
/** 发送文本消息(手动输入) */
|
/** 发送文本消息(手动输入) */
|
||||||
const sendTextMessage = useCallback(
|
const sendTextMessage = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
@@ -480,9 +459,6 @@ export function useVisionSession(accessToken?: string | null, conversationId?: s
|
|||||||
config,
|
config,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
stats,
|
stats,
|
||||||
mode,
|
|
||||||
isObserving,
|
|
||||||
toggleMode,
|
|
||||||
startSession,
|
startSession,
|
||||||
stopSession,
|
stopSession,
|
||||||
stopVideo,
|
stopVideo,
|
||||||
@@ -493,5 +469,8 @@ export function useVisionSession(accessToken?: string | null, conversationId?: s
|
|||||||
toggleMic,
|
toggleMic,
|
||||||
sendTextMessage,
|
sendTextMessage,
|
||||||
captureFrame,
|
captureFrame,
|
||||||
|
cameras,
|
||||||
|
mics,
|
||||||
|
switchDevice,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
120
frontend/src/lib/api/scenarios.ts
Normal file
120
frontend/src/lib/api/scenarios.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// ============================================================
|
||||||
|
// scenarios API — 用户自建情景 API 调用
|
||||||
|
// 职责:封装 /api/scenarios 的 CRUD 操作
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// 开发环境通过 Vite 代理,生产环境使用同域名
|
||||||
|
const API_BASE = "";
|
||||||
|
|
||||||
|
export interface UserScenario {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
prompt: string;
|
||||||
|
greeting: string;
|
||||||
|
language: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateScenarioRequest {
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
description?: string;
|
||||||
|
prompt: string;
|
||||||
|
greeting?: string;
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateScenarioRequest {
|
||||||
|
name?: string;
|
||||||
|
icon?: string;
|
||||||
|
description?: string;
|
||||||
|
prompt?: string;
|
||||||
|
greeting?: string;
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenariosListResponse {
|
||||||
|
scenarios: UserScenario[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户的所有自建情景
|
||||||
|
export async function listUserScenarios(token: string): Promise<ScenariosListResponse> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/scenarios`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: "Network error" }));
|
||||||
|
throw new Error(err.error || "Failed to list scenarios");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新情景
|
||||||
|
export async function createUserScenario(
|
||||||
|
token: string,
|
||||||
|
data: CreateScenarioRequest
|
||||||
|
): Promise<UserScenario> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/scenarios`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: "Network error" }));
|
||||||
|
throw new Error(err.error || "Failed to create scenario");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个情景详情
|
||||||
|
export async function getUserScenario(token: string, id: string): Promise<UserScenario> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/scenarios/${id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: "Network error" }));
|
||||||
|
throw new Error(err.error || "Failed to get scenario");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新情景
|
||||||
|
export async function updateUserScenario(
|
||||||
|
token: string,
|
||||||
|
id: string,
|
||||||
|
data: UpdateScenarioRequest
|
||||||
|
): Promise<UserScenario> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/scenarios/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: "Network error" }));
|
||||||
|
throw new Error(err.error || "Failed to update scenario");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除情景
|
||||||
|
export async function deleteUserScenario(token: string, id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/scenarios/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: "Network error" }));
|
||||||
|
throw new Error(err.error || "Failed to delete scenario");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,6 @@ export const enUS: TranslationMap = {
|
|||||||
"scenario.interpreter.hint": "AI will translate your speech in real-time (Chinese-English), without explanations",
|
"scenario.interpreter.hint": "AI will translate your speech in real-time (Chinese-English), without explanations",
|
||||||
|
|
||||||
// Video indicators
|
// Video indicators
|
||||||
"video.observing": "👁️ Observing",
|
|
||||||
"video.listening": "🎤 Listening...",
|
"video.listening": "🎤 Listening...",
|
||||||
"video.playing": "🔊 Playing...",
|
"video.playing": "🔊 Playing...",
|
||||||
"video.initVad": "Initializing voice detection...",
|
"video.initVad": "Initializing voice detection...",
|
||||||
@@ -67,8 +66,6 @@ export const enUS: TranslationMap = {
|
|||||||
"controls.cameraOn": "Turn on camera",
|
"controls.cameraOn": "Turn on camera",
|
||||||
"controls.micOff": "Turn off microphone",
|
"controls.micOff": "Turn off microphone",
|
||||||
"controls.micOn": "Turn on microphone",
|
"controls.micOn": "Turn on microphone",
|
||||||
"controls.observing": "👁️ Observing",
|
|
||||||
"controls.observation": "👁️ Observe",
|
|
||||||
"controls.interrupt": "⏹ Interrupt",
|
"controls.interrupt": "⏹ Interrupt",
|
||||||
"controls.stop": "End Session",
|
"controls.stop": "End Session",
|
||||||
"controls.stopVideo": "End Video",
|
"controls.stopVideo": "End Video",
|
||||||
@@ -77,7 +74,6 @@ export const enUS: TranslationMap = {
|
|||||||
|
|
||||||
// Chat panel
|
// Chat panel
|
||||||
"chat.title": "Chat",
|
"chat.title": "Chat",
|
||||||
"chat.mode.observation": "Observing",
|
|
||||||
"chat.reconnecting": "Connection lost, reconnecting...",
|
"chat.reconnecting": "Connection lost, reconnecting...",
|
||||||
"chat.connecting": "Connecting to server...",
|
"chat.connecting": "Connecting to server...",
|
||||||
"chat.vadInit": "Initializing voice detection...",
|
"chat.vadInit": "Initializing voice detection...",
|
||||||
@@ -91,7 +87,6 @@ export const enUS: TranslationMap = {
|
|||||||
"chat.scenarioSwitched": "Switched to {name} mode",
|
"chat.scenarioSwitched": "Switched to {name} mode",
|
||||||
|
|
||||||
// Session messages
|
// Session messages
|
||||||
"session.changeDetected": "👁️ Scene change detected",
|
|
||||||
"session.recognizing": "(Recognizing speech...)",
|
"session.recognizing": "(Recognizing speech...)",
|
||||||
"session.sttFailed": "(Speech recognition failed, please try again)",
|
"session.sttFailed": "(Speech recognition failed, please try again)",
|
||||||
"session.noSpeech": "(No speech detected)",
|
"session.noSpeech": "(No speech detected)",
|
||||||
@@ -138,12 +133,7 @@ export const enUS: TranslationMap = {
|
|||||||
|
|
||||||
// Video controls (enhanced)
|
// Video controls (enhanced)
|
||||||
"controls.recognize": "Analyze Scene",
|
"controls.recognize": "Analyze Scene",
|
||||||
"controls.device.camera": "Camera",
|
|
||||||
"controls.device.mic": "Microphone",
|
|
||||||
"controls.device.default": "Default",
|
"controls.device.default": "Default",
|
||||||
"controls.mode.realtime": "Realtime",
|
|
||||||
"controls.mode.ondemand": "On-demand",
|
|
||||||
"controls.mode.chat": "Chat only",
|
|
||||||
|
|
||||||
// Status bar
|
// Status bar
|
||||||
"statusbar.ready": "Ready · Select devices to start",
|
"statusbar.ready": "Ready · Select devices to start",
|
||||||
@@ -178,4 +168,41 @@ export const enUS: TranslationMap = {
|
|||||||
// Account (in settings)
|
// Account (in settings)
|
||||||
"settings.account": "Account",
|
"settings.account": "Account",
|
||||||
"settings.account.user": "Current user",
|
"settings.account.user": "Current user",
|
||||||
|
|
||||||
|
// Custom scenarios
|
||||||
|
"settings.scenario.system": "System Scenarios",
|
||||||
|
"settings.scenario.custom": "My Scenarios",
|
||||||
|
"settings.scenario.empty": "No custom scenarios yet. Click the button above to create one.",
|
||||||
|
"scenario.create.button": "New Scenario",
|
||||||
|
"scenario.create.title": "Create Custom Scenario",
|
||||||
|
"scenario.edit.title": "Edit Scenario",
|
||||||
|
"scenario.create.name": "Scenario Name",
|
||||||
|
"scenario.create.namePlaceholder": "e.g. Creative Writing Coach",
|
||||||
|
"scenario.create.icon": "Icon",
|
||||||
|
"scenario.create.description": "Brief Description",
|
||||||
|
"scenario.create.descriptionPlaceholder": "One-line summary of this scenario",
|
||||||
|
"scenario.create.prompt": "System Prompt",
|
||||||
|
"scenario.create.promptPlaceholder": "Define the AI's role and interaction rules...",
|
||||||
|
"scenario.create.promptGuide": "View Guide",
|
||||||
|
"scenario.create.promptGuide.tips": "Writing tips:",
|
||||||
|
"scenario.create.promptGuide.tip1": "Clearly define the role: who you are, who you are not",
|
||||||
|
"scenario.create.promptGuide.tip2": "List interaction rules: how to respond, how many sentences",
|
||||||
|
"scenario.create.promptGuide.tip3": "Add constraints: what not to do",
|
||||||
|
"scenario.create.promptGuide.example": "Example:",
|
||||||
|
"scenario.create.greeting": "Greeting (Optional)",
|
||||||
|
"scenario.create.greetingPlaceholder": "The first message when switching to this scenario...",
|
||||||
|
"scenario.create.language": "Default Language",
|
||||||
|
"scenario.error.nameLength": "Scenario name must be 2-50 characters",
|
||||||
|
"scenario.error.promptLength": "Prompt must be 10-2000 characters",
|
||||||
|
"scenario.error.greetingLength": "Greeting must not exceed 500 characters",
|
||||||
|
|
||||||
|
// Common actions
|
||||||
|
"common.cancel": "Cancel",
|
||||||
|
"common.create": "Create",
|
||||||
|
"common.creating": "Creating...",
|
||||||
|
"common.save": "Save",
|
||||||
|
"common.saving": "Saving...",
|
||||||
|
"common.edit": "Edit",
|
||||||
|
"common.delete": "Delete",
|
||||||
|
"common.confirmDelete": "Click again to confirm",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export const jaJP: TranslationMap = {
|
|||||||
"scenario.interpreter.hint": "AIがあなたの発言をリアルタイムで翻訳します(中日相互翻訳、説明なし)",
|
"scenario.interpreter.hint": "AIがあなたの発言をリアルタイムで翻訳します(中日相互翻訳、説明なし)",
|
||||||
|
|
||||||
// Video indicators
|
// Video indicators
|
||||||
"video.observing": "👁️ 観察中",
|
|
||||||
"video.listening": "🎤 聞き取り中...",
|
"video.listening": "🎤 聞き取り中...",
|
||||||
"video.playing": "🔊 再生中...",
|
"video.playing": "🔊 再生中...",
|
||||||
"video.initVad": "音声検出を初期化中...",
|
"video.initVad": "音声検出を初期化中...",
|
||||||
@@ -67,8 +66,6 @@ export const jaJP: TranslationMap = {
|
|||||||
"controls.cameraOn": "カメラをオン",
|
"controls.cameraOn": "カメラをオン",
|
||||||
"controls.micOff": "マイクをオフ",
|
"controls.micOff": "マイクをオフ",
|
||||||
"controls.micOn": "マイクをオン",
|
"controls.micOn": "マイクをオン",
|
||||||
"controls.observing": "👁️ 観察中",
|
|
||||||
"controls.observation": "👁️ 観察モード",
|
|
||||||
"controls.interrupt": "⏹ 中断",
|
"controls.interrupt": "⏹ 中断",
|
||||||
"controls.stop": "対話を終了",
|
"controls.stop": "対話を終了",
|
||||||
"controls.stopVideo": "ビデオ終了",
|
"controls.stopVideo": "ビデオ終了",
|
||||||
@@ -77,7 +74,6 @@ export const jaJP: TranslationMap = {
|
|||||||
|
|
||||||
// Chat panel
|
// Chat panel
|
||||||
"chat.title": "チャット",
|
"chat.title": "チャット",
|
||||||
"chat.mode.observation": "観察モード",
|
|
||||||
"chat.reconnecting": "接続が切断されました。再接続中...",
|
"chat.reconnecting": "接続が切断されました。再接続中...",
|
||||||
"chat.connecting": "サーバーに接続中...",
|
"chat.connecting": "サーバーに接続中...",
|
||||||
"chat.vadInit": "音声検出を初期化中...",
|
"chat.vadInit": "音声検出を初期化中...",
|
||||||
@@ -91,7 +87,6 @@ export const jaJP: TranslationMap = {
|
|||||||
"chat.scenarioSwitched": "{name} モードに切り替えました",
|
"chat.scenarioSwitched": "{name} モードに切り替えました",
|
||||||
|
|
||||||
// Session messages
|
// Session messages
|
||||||
"session.changeDetected": "👁️ シーン変化を検出",
|
|
||||||
"session.recognizing": "(音声認識中...)",
|
"session.recognizing": "(音声認識中...)",
|
||||||
"session.sttFailed": "(音声認識に失敗しました。もう一度お試しください)",
|
"session.sttFailed": "(音声認識に失敗しました。もう一度お試しください)",
|
||||||
"session.noSpeech": "(音声が検出されませんでした)",
|
"session.noSpeech": "(音声が検出されませんでした)",
|
||||||
@@ -138,12 +133,7 @@ export const jaJP: TranslationMap = {
|
|||||||
|
|
||||||
// Video controls (enhanced)
|
// Video controls (enhanced)
|
||||||
"controls.recognize": "シーンを分析",
|
"controls.recognize": "シーンを分析",
|
||||||
"controls.device.camera": "カメラ",
|
|
||||||
"controls.device.mic": "マイク",
|
|
||||||
"controls.device.default": "デフォルト",
|
"controls.device.default": "デフォルト",
|
||||||
"controls.mode.realtime": "リアルタイム",
|
|
||||||
"controls.mode.ondemand": "オンデマンド",
|
|
||||||
"controls.mode.chat": "チャットのみ",
|
|
||||||
|
|
||||||
// Status bar
|
// Status bar
|
||||||
"statusbar.ready": "準備完了 · デバイスを選択して開始",
|
"statusbar.ready": "準備完了 · デバイスを選択して開始",
|
||||||
@@ -178,4 +168,41 @@ export const jaJP: TranslationMap = {
|
|||||||
// Account (in settings)
|
// Account (in settings)
|
||||||
"settings.account": "アカウント",
|
"settings.account": "アカウント",
|
||||||
"settings.account.user": "現在のユーザー",
|
"settings.account.user": "現在のユーザー",
|
||||||
|
|
||||||
|
// Custom scenarios
|
||||||
|
"settings.scenario.system": "システムシナリオ",
|
||||||
|
"settings.scenario.custom": "マイシナリオ",
|
||||||
|
"settings.scenario.empty": "カスタムシナリオはまだありません。上のボタンをクリックして作成してください。",
|
||||||
|
"scenario.create.button": "新しいシナリオ",
|
||||||
|
"scenario.create.title": "カスタムシナリオを作成",
|
||||||
|
"scenario.edit.title": "シナリオを編集",
|
||||||
|
"scenario.create.name": "シナリオ名",
|
||||||
|
"scenario.create.namePlaceholder": "例:クリエイティブライティングコーチ",
|
||||||
|
"scenario.create.icon": "アイコン",
|
||||||
|
"scenario.create.description": "簡単な説明",
|
||||||
|
"scenario.create.descriptionPlaceholder": "このシナリオの概要を一文で",
|
||||||
|
"scenario.create.prompt": "システムプロンプト",
|
||||||
|
"scenario.create.promptPlaceholder": "AIの役割と対話ルールを定義...",
|
||||||
|
"scenario.create.promptGuide": "ガイドを見る",
|
||||||
|
"scenario.create.promptGuide.tips": "作成のヒント:",
|
||||||
|
"scenario.create.promptGuide.tip1": "役割を明確に定義:何者で、何者でないか",
|
||||||
|
"scenario.create.promptGuide.tip2": "対話ルールをリスト化:応答方法、文数",
|
||||||
|
"scenario.create.promptGuide.tip3": "制約を追加:何をしないか",
|
||||||
|
"scenario.create.promptGuide.example": "例:",
|
||||||
|
"scenario.create.greeting": "挨拶(オプション)",
|
||||||
|
"scenario.create.greetingPlaceholder": "このシナリオに切り替えた時の最初のメッセージ...",
|
||||||
|
"scenario.create.language": "デフォルト言語",
|
||||||
|
"scenario.error.nameLength": "シナリオ名は2〜50文字で入力してください",
|
||||||
|
"scenario.error.promptLength": "プロンプトは10〜2000文字で入力してください",
|
||||||
|
"scenario.error.greetingLength": "挨拶は500文字以内で入力してください",
|
||||||
|
|
||||||
|
// Common actions
|
||||||
|
"common.cancel": "キャンセル",
|
||||||
|
"common.create": "作成",
|
||||||
|
"common.creating": "作成中...",
|
||||||
|
"common.save": "保存",
|
||||||
|
"common.saving": "保存中...",
|
||||||
|
"common.edit": "編集",
|
||||||
|
"common.delete": "削除",
|
||||||
|
"common.confirmDelete": "もう一度クリックして確認",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export const zhCN: TranslationMap = {
|
|||||||
"scenario.interpreter.hint": "AI 会实时翻译你的话(中英互译),无解释评论",
|
"scenario.interpreter.hint": "AI 会实时翻译你的话(中英互译),无解释评论",
|
||||||
|
|
||||||
// Video indicators
|
// Video indicators
|
||||||
"video.observing": "👁️ 观察中",
|
|
||||||
"video.listening": "🎤 正在聆听...",
|
"video.listening": "🎤 正在聆听...",
|
||||||
"video.playing": "🔊 正在播放...",
|
"video.playing": "🔊 正在播放...",
|
||||||
"video.initVad": "正在初始化语音检测...",
|
"video.initVad": "正在初始化语音检测...",
|
||||||
@@ -67,8 +66,6 @@ export const zhCN: TranslationMap = {
|
|||||||
"controls.cameraOn": "开启摄像头",
|
"controls.cameraOn": "开启摄像头",
|
||||||
"controls.micOff": "关闭麦克风",
|
"controls.micOff": "关闭麦克风",
|
||||||
"controls.micOn": "开启麦克风",
|
"controls.micOn": "开启麦克风",
|
||||||
"controls.observing": "👁️ 观察中",
|
|
||||||
"controls.observation": "👁️ 观察模式",
|
|
||||||
"controls.interrupt": "⏹ 打断",
|
"controls.interrupt": "⏹ 打断",
|
||||||
"controls.stop": "结束对话",
|
"controls.stop": "结束对话",
|
||||||
"controls.stopVideo": "结束视频",
|
"controls.stopVideo": "结束视频",
|
||||||
@@ -77,7 +74,6 @@ export const zhCN: TranslationMap = {
|
|||||||
|
|
||||||
// Chat panel
|
// Chat panel
|
||||||
"chat.title": "对话",
|
"chat.title": "对话",
|
||||||
"chat.mode.observation": "观察模式",
|
|
||||||
"chat.reconnecting": "连接已断开,正在重连...",
|
"chat.reconnecting": "连接已断开,正在重连...",
|
||||||
"chat.connecting": "正在连接服务...",
|
"chat.connecting": "正在连接服务...",
|
||||||
"chat.vadInit": "正在初始化语音检测...",
|
"chat.vadInit": "正在初始化语音检测...",
|
||||||
@@ -91,7 +87,6 @@ export const zhCN: TranslationMap = {
|
|||||||
"chat.scenarioSwitched": "已切换到 {name} 模式",
|
"chat.scenarioSwitched": "已切换到 {name} 模式",
|
||||||
|
|
||||||
// Session messages
|
// Session messages
|
||||||
"session.changeDetected": "👁️ 画面变化检测",
|
|
||||||
"session.recognizing": "(语音识别中...)",
|
"session.recognizing": "(语音识别中...)",
|
||||||
"session.sttFailed": "(语音识别失败,请重试)",
|
"session.sttFailed": "(语音识别失败,请重试)",
|
||||||
"session.noSpeech": "(未识别到语音)",
|
"session.noSpeech": "(未识别到语音)",
|
||||||
@@ -138,12 +133,7 @@ export const zhCN: TranslationMap = {
|
|||||||
|
|
||||||
// Video controls (enhanced)
|
// Video controls (enhanced)
|
||||||
"controls.recognize": "识别画面",
|
"controls.recognize": "识别画面",
|
||||||
"controls.device.camera": "摄像头",
|
|
||||||
"controls.device.mic": "麦克风",
|
|
||||||
"controls.device.default": "默认",
|
"controls.device.default": "默认",
|
||||||
"controls.mode.realtime": "实时分析",
|
|
||||||
"controls.mode.ondemand": "按需识别",
|
|
||||||
"controls.mode.chat": "纯聊天",
|
|
||||||
|
|
||||||
// Status bar
|
// Status bar
|
||||||
"statusbar.ready": "就绪 · 选择设备后开始通话",
|
"statusbar.ready": "就绪 · 选择设备后开始通话",
|
||||||
@@ -178,4 +168,41 @@ export const zhCN: TranslationMap = {
|
|||||||
// Account (in settings)
|
// Account (in settings)
|
||||||
"settings.account": "账号",
|
"settings.account": "账号",
|
||||||
"settings.account.user": "当前用户",
|
"settings.account.user": "当前用户",
|
||||||
|
|
||||||
|
// Custom scenarios
|
||||||
|
"settings.scenario.system": "系统预置情景",
|
||||||
|
"settings.scenario.custom": "我的情景",
|
||||||
|
"settings.scenario.empty": "还没有自建情景,点击上方按钮创建",
|
||||||
|
"scenario.create.button": "创建新情景",
|
||||||
|
"scenario.create.title": "创建自建情景",
|
||||||
|
"scenario.edit.title": "编辑情景",
|
||||||
|
"scenario.create.name": "情景名称",
|
||||||
|
"scenario.create.namePlaceholder": "例如:创意写作导师",
|
||||||
|
"scenario.create.icon": "图标",
|
||||||
|
"scenario.create.description": "简短描述",
|
||||||
|
"scenario.create.descriptionPlaceholder": "一句话介绍这个情景的作用",
|
||||||
|
"scenario.create.prompt": "System Prompt",
|
||||||
|
"scenario.create.promptPlaceholder": "定义 AI 的角色和交互规则...",
|
||||||
|
"scenario.create.promptGuide": "查看编写指南",
|
||||||
|
"scenario.create.promptGuide.tips": "编写提示:",
|
||||||
|
"scenario.create.promptGuide.tip1": "明确角色定位:你是谁,不是谁",
|
||||||
|
"scenario.create.promptGuide.tip2": "列出交互规则:如何回答,每次几句话",
|
||||||
|
"scenario.create.promptGuide.tip3": "添加约束条件:不要做什么",
|
||||||
|
"scenario.create.promptGuide.example": "示例:",
|
||||||
|
"scenario.create.greeting": "首句引导(可选)",
|
||||||
|
"scenario.create.greetingPlaceholder": "切换到此情景时,AI 的第一句话...",
|
||||||
|
"scenario.create.language": "默认语言",
|
||||||
|
"scenario.error.nameLength": "情景名称需要 2-50 个字符",
|
||||||
|
"scenario.error.promptLength": "Prompt 需要 10-2000 个字符",
|
||||||
|
"scenario.error.greetingLength": "首句引导不超过 500 个字符",
|
||||||
|
|
||||||
|
// Common actions
|
||||||
|
"common.cancel": "取消",
|
||||||
|
"common.create": "创建",
|
||||||
|
"common.creating": "创建中...",
|
||||||
|
"common.save": "保存",
|
||||||
|
"common.saving": "保存中...",
|
||||||
|
"common.edit": "编辑",
|
||||||
|
"common.delete": "删除",
|
||||||
|
"common.confirmDelete": "再次点击确认删除",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface SessionConfig {
|
|||||||
detailLevel: "low" | "high";
|
detailLevel: "low" | "high";
|
||||||
language: string;
|
language: string;
|
||||||
scenario: string; // 情景 ID,如 "free_chat"、"interviewer"
|
scenario: string; // 情景 ID,如 "free_chat"、"interviewer"
|
||||||
|
cameraDeviceId?: string; // 摄像头设备 ID,空 = 系统默认
|
||||||
|
micDeviceId?: string; // 麦克风设备 ID,空 = 系统默认
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Theme = "dark" | "light";
|
export type Theme = "dark" | "light";
|
||||||
|
|||||||
Reference in New Issue
Block a user