Files
CamTalk/backend/internal/eino/graph.go
cfy666 1079e22699 feat: 实现自建情景功能
## 功能概述
- 用户可创建、编辑、删除自定义情景
- 支持自定义情景名称、图标、描述、Prompt、首句引导
- 完整的权限隔离,用户只能管理自己的情景
- 深度集成 Eino 框架,动态加载自建情景 Prompt

## 后端实现
### 数据库
- 新增 user_scenarios 表
- 支持用户配额(最多 20 个)
- 字段验证:description 可选,prompt 最小 10 字符

### API
- GET /api/scenarios - 获取用户情景列表
- POST /api/scenarios - 创建情景
- GET /api/scenarios/:id - 获取详情
- PATCH /api/scenarios/:id - 更新情景
- DELETE /api/scenarios/:id - 删除情景

### Eino 集成
- PipelineState 添加 UserID 字段
- nodes_history 动态加载用户自建情景
- GetScenarioPrompt 支持自建情景优先级

## 前端实现
### 组件
- CreateScenarioModal - 创建情景对话框
- EditScenarioModal - 编辑情景对话框
- ConfigPanel 改造 - 分组显示系统预置和自建情景

### Hook
- useScenarios - 合并系统和自建情景,提供 CRUD 接口

### 国际化
- 中文、英文、日文翻译支持

## 问题修复
- 修复 CORS 问题:使用 Vite 代理
- 统一验证规则:description 可选,prompt 最小 10 字符
- 修复数据库约束:使用 NULLIF 处理空字符串

## 文件变更
新增文件: 13 个
修改文件: 14 个

详见文档: docs/自建情景功能完整文档.md
2026-06-21 15:38:28 +08:00

120 lines
3.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package eino
import (
"context"
"time"
openaiImpl "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/compose"
"github.com/hhs/camtalk/internal/ai/stt"
"github.com/hhs/camtalk/internal/ai/tts"
"github.com/hhs/camtalk/internal/config"
"github.com/hhs/camtalk/internal/logger"
"github.com/hhs/camtalk/internal/models"
"github.com/hhs/camtalk/internal/session"
"github.com/hhs/camtalk/internal/store"
)
const (
nodeSTT = "stt"
nodeHistory = "history"
nodeLLM = "llm"
nodeMessageToString = "msg2str"
nodeSplitter = "splitter"
nodeTTS = "tts"
nodeDone = "done"
)
// PipelineGraph 封装编译后的 Eino Graph。
type PipelineGraph struct {
Runnable compose.Runnable[PipelineInput, PipelineOutput]
}
// NewPipelineGraph 构建 CamTalk AI 编排 Graph。
//
// 拓扑START → STT → History → ChatModel → Splitter → TTS → Done → END
//
// Graph 使用 Stream 模式调用ChatModel 实现真正的 token 级流式输出。
// LLM token 通过 Callback 的 OnEndWithStreamOutput 实时推送到客户端。
func NewPipelineGraph(
ctx context.Context,
cfg *config.Config,
sttService stt.Service,
ttsService tts.Service,
sessionMgr session.Manager,
scenarioRepo store.UserScenarioRepository,
) (*PipelineGraph, error) {
log := logger.Log
// 1. 创建 eino-ext ChatModel对接 DashScope OpenAI 兼容接口)
chatModel, err := openaiImpl.NewChatModel(ctx, &openaiImpl.ChatModelConfig{
APIKey: cfg.AI.LLM.APIKey,
Model: cfg.AI.LLM.Model,
BaseURL: cfg.AI.LLM.Endpoint,
Timeout: time.Duration(cfg.AI.LLM.Timeout) * time.Second,
})
if err != nil {
return nil, err
}
log.Infow("Eino ChatModel 初始化成功",
"model", cfg.AI.LLM.Model,
"endpoint", cfg.AI.LLM.Endpoint)
// 2. 构建 Graph值类型非指针
g := compose.NewGraph[PipelineInput, PipelineOutput](
compose.WithGenLocalState(genLocalState),
)
// 3. 添加节点
maxHistory := cfg.Session.MaxHistory
_ = g.AddLambdaNode(nodeSTT, NewSTTLambda(sttService))
_ = g.AddLambdaNode(nodeHistory, NewHistoryLambda(sessionMgr.GetHistory, scenarioRepo, maxHistory))
_ = g.AddChatModelNode(nodeLLM, chatModel)
_ = g.AddLambdaNode(nodeMessageToString, NewMessageToStringLambda())
_ = g.AddLambdaNode(nodeSplitter, NewSplitterLambda())
_ = g.AddLambdaNode(nodeTTS, NewTTSLambda(
ttsService,
cfg.AI.TTS.Voice,
cfg.AI.TTS.Speed,
cfg.AI.TTS.OutputFormat,
cfg.AI.TTS.SampleRate,
))
_ = g.AddLambdaNode(nodeDone, NewDoneLambda(cfg.AI.LLM.Model))
// 4. 连接边
_ = g.AddEdge(compose.START, nodeSTT)
_ = g.AddEdge(nodeSTT, nodeHistory)
_ = g.AddEdge(nodeHistory, nodeLLM)
_ = g.AddEdge(nodeLLM, nodeMessageToString)
_ = g.AddEdge(nodeMessageToString, nodeSplitter)
_ = g.AddEdge(nodeSplitter, nodeTTS)
_ = g.AddEdge(nodeTTS, nodeDone)
_ = g.AddEdge(nodeDone, compose.END)
// 5. 编译(回调在运行时通过 Stream option 传入)
runnable, err := g.Compile(ctx)
if err != nil {
return nil, err
}
log.Infow("Eino Graph 编译成功", "nodes", 6)
return &PipelineGraph{Runnable: runnable}, nil
}
// buildPipelineInput 从 WebSocket 请求和会话配置构建 Graph 输入。
func buildPipelineInput(req models.WsQuery, sessionID string, sess *models.Session, audioData, imageData []byte) PipelineInput {
return PipelineInput{
AudioData: audioData,
ImageData: imageData,
Text: req.Text,
SessionID: sessionID,
RequestID: req.RequestID,
Language: sess.Config.Language,
Scenario: sess.Config.Scenario,
TTSEnabled: sess.Config.TTSEnabled,
UserID: sess.UserID,
}
}