Files
CamTalk/backend/internal/eino/nodes_history.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

153 lines
4.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"
"encoding/base64"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"github.com/hhs/camtalk/internal/ai/llm"
"github.com/hhs/camtalk/internal/logger"
"github.com/hhs/camtalk/internal/models"
"github.com/hhs/camtalk/internal/store"
)
// NewHistoryLambda 创建历史组装 Lambda 节点。
// 输入: *STTOutput → 输出: []*schema.Message
//
// 从 PipelineState 读取请求元数据SessionID、Scenario、ImageData 等),
// 构建系统提示词,组装历史消息和当前用户输入(含多模态图片)。
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) {
log := logger.Log
// 从 State 读取请求元数据
state := stateFromCtx(ctx)
if state == nil {
return []*schema.Message{}, nil
}
state.mu.Lock()
sessionID := state.SessionID
requestID := state.RequestID
imageData := state.ImageData
scenario := state.Scenario
detailLevel := state.DetailLevel
language := sttOut.Language
userID := state.UserID
state.mu.Unlock()
// 加载用户自建情景(如果有 userID 和 scenarioRepo
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)
// 构建 system message仅文本多模态内容只能放在 user 角色)
systemMsg := &schema.Message{
Role: schema.System,
Content: systemPrompt,
}
messages := []*schema.Message{systemMsg}
// 获取并追加历史消息
if historyFetcher != nil && sessionID != "" {
history, err := historyFetcher(ctx, sessionID, maxHistory)
if err != nil {
log.Warnw("获取历史消息失败,继续处理", "error", err, "request_id", requestID)
} else {
for _, msg := range history {
messages = append(messages, &schema.Message{
Role: schema.RoleType(msg.Role),
Content: msg.Content,
})
}
}
}
// 追加当前用户输入(含图片,多模态内容只能放在 user 角色)
// 注意:不能同时设置 Content 和 UserInputMultiContent需要统一放到 MultiContent 中
if len(imageData) > 0 {
base64Str := base64.StdEncoding.EncodeToString(imageData)
mimeType := detectImageMimeType(imageData)
parts := []schema.MessageInputPart{
{
Type: schema.ChatMessagePartTypeText,
Text: sttOut.Text,
},
{
Type: schema.ChatMessagePartTypeImageURL,
Image: &schema.MessageInputImage{
MessagePartCommon: schema.MessagePartCommon{
Base64Data: &base64Str,
MIMEType: mimeType,
},
Detail: schema.ImageURLDetailAuto,
},
},
}
messages = append(messages, &schema.Message{
Role: schema.User,
UserInputMultiContent: parts,
})
} else {
messages = append(messages, &schema.Message{
Role: schema.User,
Content: sttOut.Text,
})
}
log.Infow("历史组装完成",
"request_id", requestID,
"message_count", len(messages),
"has_image", len(imageData) > 0,
"scenario", scenario)
return messages, nil
})
}
// detectImageMimeType 简单检测图片 MIME 类型。
func detectImageMimeType(data []byte) string {
if len(data) < 4 {
return "image/jpeg"
}
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "image/jpeg"
}
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
return "image/png"
}
if data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 {
return "image/gif"
}
if data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 {
return "image/webp"
}
return "image/jpeg"
}