## 功能概述 - 用户可创建、编辑、删除自定义情景 - 支持自定义情景名称、图标、描述、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
153 lines
4.5 KiB
Go
153 lines
4.5 KiB
Go
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"
|
||
}
|