🎨 1.优化前端渲染功能(html和对话消息格式)
2.优化流式传输,新增流式渲染功能 3.优化正则处理逻辑 4.新增context budget管理系统 5.优化对话消息失败处理逻辑 6.新增前端卡功能(待完整测试)
This commit is contained in:
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -371,21 +372,15 @@ func (s *ConversationService) SendMessage(userID, conversationID uint, req *requ
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 获取对话历史(最近10条)
|
||||
// 获取完整对话历史(context 管理由 callAIService 内部处理)
|
||||
var messages []app.Message
|
||||
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
||||
Order("created_at DESC").
|
||||
Limit(10).
|
||||
Order("created_at ASC").
|
||||
Find(&messages).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 反转消息顺序(从旧到新)
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
// 调用 AI 服务获取回复
|
||||
aiResponse, err := s.callAIService(conversation, character, messages)
|
||||
if err != nil {
|
||||
@@ -527,47 +522,26 @@ func (s *ConversationService) callAIService(conversation app.Conversation, chara
|
||||
}
|
||||
}
|
||||
|
||||
// 构建系统提示词(如果预设有系统提示词,则追加到角色卡提示词后)
|
||||
systemPrompt := s.buildSystemPrompt(character)
|
||||
if preset != nil && preset.SystemPrompt != "" {
|
||||
systemPrompt = systemPrompt + "\n\n" + preset.SystemPrompt
|
||||
global.GVA_LOG.Info("已追加预设的系统提示词")
|
||||
// 构建消息列表(含 context 预算管理)
|
||||
var presetSysPrompt string
|
||||
if preset != nil {
|
||||
presetSysPrompt = preset.SystemPrompt
|
||||
}
|
||||
wbEngine := &WorldbookEngine{}
|
||||
apiMessages := s.buildAPIMessagesWithContextManagement(
|
||||
messages, character, presetSysPrompt, wbEngine, conversation, &aiConfig, preset,
|
||||
)
|
||||
|
||||
// 集成世界书触发引擎
|
||||
if conversation.WorldbookEnabled && conversation.WorldbookID != nil {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("世界书已启用,ID: %d", *conversation.WorldbookID))
|
||||
|
||||
// 提取消息内容用于扫描
|
||||
var messageContents []string
|
||||
for _, msg := range messages {
|
||||
messageContents = append(messageContents, msg.Content)
|
||||
}
|
||||
|
||||
// 使用世界书引擎扫描并触发条目
|
||||
engine := &WorldbookEngine{}
|
||||
triggered, err := engine.ScanAndTrigger(*conversation.WorldbookID, messageContents)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("世界书触发失败: %v", err))
|
||||
} else if len(triggered) > 0 {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("触发了 %d 个世界书条目", len(triggered)))
|
||||
// 将触发的世界书内容注入到系统提示词
|
||||
systemPrompt = engine.BuildPromptWithWorldbook(systemPrompt, triggered)
|
||||
} else {
|
||||
global.GVA_LOG.Info("没有触发任何世界书条目")
|
||||
}
|
||||
// 从 apiMessages 中提取 systemPrompt,供 Anthropic 独立参数使用
|
||||
systemPrompt := ""
|
||||
if len(apiMessages) > 0 && apiMessages[0]["role"] == "system" {
|
||||
systemPrompt = apiMessages[0]["content"]
|
||||
}
|
||||
|
||||
// 构建消息列表
|
||||
apiMessages := s.buildAPIMessages(messages, systemPrompt)
|
||||
|
||||
// 打印发送给AI的完整内容
|
||||
global.GVA_LOG.Info("========== 发送给AI的完整内容 ==========")
|
||||
global.GVA_LOG.Info(fmt.Sprintf("系统提示词: %s", systemPrompt))
|
||||
global.GVA_LOG.Info("消息列表:")
|
||||
for i, msg := range apiMessages {
|
||||
global.GVA_LOG.Info(fmt.Sprintf(" [%d] Role: %s, Content: %s", i, msg["role"], msg["content"]))
|
||||
}
|
||||
global.GVA_LOG.Info(fmt.Sprintf("系统提示词长度: %d 字符", len(systemPrompt)))
|
||||
global.GVA_LOG.Info(fmt.Sprintf("历史消息条数: %d", len(apiMessages)-1))
|
||||
global.GVA_LOG.Info("==========================================")
|
||||
|
||||
// 确定使用的模型:如果用户在设置中指定了AI配置,则使用该配置的默认模型
|
||||
@@ -735,7 +709,7 @@ func (s *ConversationService) getWeekdayInChinese(weekday time.Weekday) string {
|
||||
}
|
||||
|
||||
// SendMessageStream 流式发送消息并获取 AI 回复
|
||||
func (s *ConversationService) SendMessageStream(userID, conversationID uint, req *request.SendMessageRequest, streamChan chan string, doneChan chan bool) error {
|
||||
func (s *ConversationService) SendMessageStream(ctx context.Context, userID, conversationID uint, req *request.SendMessageRequest, streamChan chan string, doneChan chan bool) error {
|
||||
defer close(streamChan)
|
||||
defer close(doneChan)
|
||||
|
||||
@@ -796,21 +770,15 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取对话历史(最近10条)
|
||||
// 获取完整对话历史(context 管理由 buildAPIMessagesWithContextManagement 处理)
|
||||
var messages []app.Message
|
||||
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
||||
Order("created_at DESC").
|
||||
Limit(10).
|
||||
Order("created_at ASC").
|
||||
Find(&messages).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 反转消息顺序(从旧到新)
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
// 获取 AI 配置
|
||||
var aiConfig app.AIConfig
|
||||
var configID uint
|
||||
@@ -857,42 +825,26 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
||||
}
|
||||
}
|
||||
|
||||
// 构建系统提示词(应用预设)
|
||||
systemPrompt := s.buildSystemPrompt(character)
|
||||
if streamPreset != nil && streamPreset.SystemPrompt != "" {
|
||||
systemPrompt = systemPrompt + "\n\n" + streamPreset.SystemPrompt
|
||||
// 构建消息列表(含 context 预算管理)
|
||||
var streamPresetSysPrompt string
|
||||
if streamPreset != nil {
|
||||
streamPresetSysPrompt = streamPreset.SystemPrompt
|
||||
}
|
||||
streamWbEngine := &WorldbookEngine{}
|
||||
apiMessages := s.buildAPIMessagesWithContextManagement(
|
||||
messages, character, streamPresetSysPrompt, streamWbEngine, conversation, &aiConfig, streamPreset,
|
||||
)
|
||||
|
||||
// 集成世界书触发引擎(流式传输)
|
||||
if conversation.WorldbookEnabled && conversation.WorldbookID != nil {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 世界书已启用,ID: %d", *conversation.WorldbookID))
|
||||
|
||||
var messageContents []string
|
||||
for _, msg := range messages {
|
||||
messageContents = append(messageContents, msg.Content)
|
||||
}
|
||||
|
||||
engine := &WorldbookEngine{}
|
||||
triggeredEntries, wbErr := engine.ScanAndTrigger(*conversation.WorldbookID, messageContents)
|
||||
if wbErr != nil {
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[流式传输] 世界书触发失败: %v", wbErr))
|
||||
} else if len(triggeredEntries) > 0 {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 触发了 %d 个世界书条目", len(triggeredEntries)))
|
||||
systemPrompt = engine.BuildPromptWithWorldbook(systemPrompt, triggeredEntries)
|
||||
} else {
|
||||
global.GVA_LOG.Info("[流式传输] 没有触发任何世界书条目")
|
||||
}
|
||||
// 从 apiMessages 中提取 systemPrompt,供 Anthropic 独立参数使用
|
||||
systemPrompt := ""
|
||||
if len(apiMessages) > 0 && apiMessages[0]["role"] == "system" {
|
||||
systemPrompt = apiMessages[0]["content"]
|
||||
}
|
||||
|
||||
apiMessages := s.buildAPIMessages(messages, systemPrompt)
|
||||
|
||||
// 打印发送给AI的完整内容(流式传输)
|
||||
global.GVA_LOG.Info("========== [流式传输] 发送给AI的完整内容 ==========")
|
||||
global.GVA_LOG.Info(fmt.Sprintf("系统提示词: %s", systemPrompt))
|
||||
global.GVA_LOG.Info("消息列表:")
|
||||
for i, msg := range apiMessages {
|
||||
global.GVA_LOG.Info(fmt.Sprintf(" [%d] Role: %s, Content: %s", i, msg["role"], msg["content"]))
|
||||
}
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 系统提示词长度: %d 字符", len(systemPrompt)))
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 历史消息条数: %d", len(apiMessages)-1))
|
||||
global.GVA_LOG.Info("==========================================")
|
||||
|
||||
// 确定使用的模型
|
||||
@@ -910,15 +862,21 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
||||
var fullContent string
|
||||
switch aiConfig.Provider {
|
||||
case "openai", "custom":
|
||||
fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, streamPreset, streamChan)
|
||||
fullContent, err = s.callOpenAIAPIStream(ctx, &aiConfig, model, apiMessages, streamPreset, streamChan)
|
||||
case "anthropic":
|
||||
fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, streamPreset, streamChan)
|
||||
fullContent, err = s.callAnthropicAPIStream(ctx, &aiConfig, model, apiMessages, systemPrompt, streamPreset, streamChan)
|
||||
default:
|
||||
return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error(fmt.Sprintf("========== [流式传输] AI返回错误 ==========\n%v\n==========================================", err))
|
||||
// AI 调用失败,回滚已写入的用户消息,避免孤立记录残留在数据库
|
||||
if delErr := global.GVA_DB.Delete(&userMessage).Error; delErr != nil {
|
||||
global.GVA_LOG.Error(fmt.Sprintf("[流式传输] 回滚用户消息失败: %v", delErr))
|
||||
} else {
|
||||
global.GVA_LOG.Info("[流式传输] 已回滚用户消息")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -962,8 +920,9 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
||||
}
|
||||
|
||||
// callOpenAIAPIStream 调用 OpenAI API 流式传输
|
||||
func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model string, messages []map[string]string, preset *app.AIPreset, streamChan chan string) (string, error) {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
func (s *ConversationService) callOpenAIAPIStream(ctx context.Context, config *app.AIConfig, model string, messages []map[string]string, preset *app.AIPreset, streamChan chan string) (string, error) {
|
||||
// 不设 Timeout:生命周期由调用方传入的 ctx 控制(客户端断连时自动取消)
|
||||
client := &http.Client{}
|
||||
|
||||
if model == "" {
|
||||
model = config.DefaultModel
|
||||
@@ -1025,7 +984,7 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
|
||||
}
|
||||
|
||||
endpoint := config.BaseURL + "/chat/completions"
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(bodyBytes))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
@@ -1035,6 +994,10 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 客户端主动断开时 ctx 被取消,不算真正的错误
|
||||
if ctx.Err() != nil {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -1050,49 +1013,51 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
// 先处理本次读到的数据(EOF 时可能仍携带最后一行内容)
|
||||
if line != "" {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" && trimmed != "data: [DONE]" && strings.HasPrefix(trimmed, "data: ") {
|
||||
data := strings.TrimPrefix(trimmed, "data: ")
|
||||
|
||||
var streamResp struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if jsonErr := json.Unmarshal([]byte(data), &streamResp); jsonErr == nil {
|
||||
if len(streamResp.Choices) > 0 {
|
||||
content := streamResp.Choices[0].Delta.Content
|
||||
if content != "" {
|
||||
fullContent.WriteString(content)
|
||||
streamChan <- content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 再检查读取错误
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
// ctx 被取消(客户端断开)时不算真正的流读取错误
|
||||
if ctx.Err() != nil {
|
||||
return fullContent.String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("读取流失败: %v", err)
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || line == "data: [DONE]" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
|
||||
var streamResp struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(streamResp.Choices) > 0 {
|
||||
content := streamResp.Choices[0].Delta.Content
|
||||
if content != "" {
|
||||
fullContent.WriteString(content)
|
||||
streamChan <- content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fullContent.String(), nil
|
||||
}
|
||||
|
||||
// callAnthropicAPIStream 调用 Anthropic API 流式传输
|
||||
func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model string, messages []map[string]string, systemPrompt string, preset *app.AIPreset, streamChan chan string) (string, error) {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
func (s *ConversationService) callAnthropicAPIStream(ctx context.Context, config *app.AIConfig, model string, messages []map[string]string, systemPrompt string, preset *app.AIPreset, streamChan chan string) (string, error) {
|
||||
// 不设 Timeout:生命周期由调用方传入的 ctx 控制(客户端断连时自动取消)
|
||||
client := &http.Client{}
|
||||
|
||||
if model == "" {
|
||||
model = config.DefaultModel
|
||||
@@ -1152,7 +1117,7 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
|
||||
}
|
||||
|
||||
endpoint := config.BaseURL + "/messages"
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(bodyBytes))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
@@ -1163,6 +1128,10 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 客户端主动断开时 ctx 被取消,不算真正的错误
|
||||
if ctx.Err() != nil {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -1178,38 +1147,39 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
// 先处理本次读到的数据(EOF 时可能仍携带最后一行内容)
|
||||
if line != "" {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" && strings.HasPrefix(trimmed, "data: ") {
|
||||
data := strings.TrimPrefix(trimmed, "data: ")
|
||||
|
||||
var streamResp struct {
|
||||
Type string `json:"type"`
|
||||
Delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"delta"`
|
||||
}
|
||||
|
||||
if jsonErr := json.Unmarshal([]byte(data), &streamResp); jsonErr == nil {
|
||||
if streamResp.Type == "content_block_delta" && streamResp.Delta.Text != "" {
|
||||
fullContent.WriteString(streamResp.Delta.Text)
|
||||
streamChan <- streamResp.Delta.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 再检查读取错误
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
// ctx 被取消(客户端断开)时不算真正的流读取错误
|
||||
if ctx.Err() != nil {
|
||||
return fullContent.String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("读取流失败: %v", err)
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
|
||||
var streamResp struct {
|
||||
Type string `json:"type"`
|
||||
Delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"delta"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if streamResp.Type == "content_block_delta" && streamResp.Delta.Text != "" {
|
||||
fullContent.WriteString(streamResp.Delta.Text)
|
||||
streamChan <- streamResp.Delta.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fullContent.String(), nil
|
||||
@@ -1243,19 +1213,16 @@ func (s *ConversationService) RegenerateMessage(userID, conversationID uint) (*r
|
||||
})
|
||||
}
|
||||
|
||||
// 获取删除后的消息历史
|
||||
// 获取删除后的完整消息历史(context 管理由 callAIService 内部处理)
|
||||
var messages []app.Message
|
||||
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
||||
Order("created_at DESC").Limit(10).Find(&messages).Error
|
||||
Order("created_at ASC").Find(&messages).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil, errors.New("没有可用的消息历史")
|
||||
}
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
aiResponse, err := s.callAIService(conversation, character, messages)
|
||||
if err != nil {
|
||||
@@ -1282,7 +1249,7 @@ func (s *ConversationService) RegenerateMessage(userID, conversationID uint) (*r
|
||||
}
|
||||
|
||||
// RegenerateMessageStream 流式重新生成最后一条 AI 回复
|
||||
func (s *ConversationService) RegenerateMessageStream(userID, conversationID uint, streamChan chan string, doneChan chan bool) error {
|
||||
func (s *ConversationService) RegenerateMessageStream(ctx context.Context, userID, conversationID uint, streamChan chan string, doneChan chan bool) error {
|
||||
defer close(streamChan)
|
||||
defer close(doneChan)
|
||||
|
||||
@@ -1312,19 +1279,16 @@ func (s *ConversationService) RegenerateMessageStream(userID, conversationID uin
|
||||
})
|
||||
}
|
||||
|
||||
// 获取删除后的消息历史
|
||||
// 获取删除后的完整消息历史(context 管理由 buildAPIMessagesWithContextManagement 处理)
|
||||
var messages []app.Message
|
||||
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
||||
Order("created_at DESC").Limit(10).Find(&messages).Error
|
||||
Order("created_at ASC").Find(&messages).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return errors.New("没有可用的消息历史")
|
||||
}
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
// 获取 AI 配置
|
||||
var aiConfig app.AIConfig
|
||||
@@ -1367,11 +1331,21 @@ func (s *ConversationService) RegenerateMessageStream(userID, conversationID uin
|
||||
}
|
||||
}
|
||||
|
||||
systemPrompt := s.buildSystemPrompt(character)
|
||||
if preset != nil && preset.SystemPrompt != "" {
|
||||
systemPrompt = systemPrompt + "\n\n" + preset.SystemPrompt
|
||||
// 构建消息列表(含 context 预算管理)
|
||||
var regenPresetSysPrompt string
|
||||
if preset != nil {
|
||||
regenPresetSysPrompt = preset.SystemPrompt
|
||||
}
|
||||
regenWbEngine := &WorldbookEngine{}
|
||||
apiMessages := s.buildAPIMessagesWithContextManagement(
|
||||
messages, character, regenPresetSysPrompt, regenWbEngine, conversation, &aiConfig, preset,
|
||||
)
|
||||
|
||||
// 从 apiMessages 中提取 systemPrompt,供 Anthropic 独立参数使用
|
||||
systemPrompt := ""
|
||||
if len(apiMessages) > 0 && apiMessages[0]["role"] == "system" {
|
||||
systemPrompt = apiMessages[0]["content"]
|
||||
}
|
||||
apiMessages := s.buildAPIMessages(messages, systemPrompt)
|
||||
|
||||
model := aiConfig.DefaultModel
|
||||
if model == "" {
|
||||
@@ -1384,14 +1358,27 @@ func (s *ConversationService) RegenerateMessageStream(userID, conversationID uin
|
||||
var fullContent string
|
||||
switch aiConfig.Provider {
|
||||
case "openai", "custom":
|
||||
fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, preset, streamChan)
|
||||
fullContent, err = s.callOpenAIAPIStream(ctx, &aiConfig, model, apiMessages, preset, streamChan)
|
||||
case "anthropic":
|
||||
fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, preset, streamChan)
|
||||
fullContent, err = s.callAnthropicAPIStream(ctx, &aiConfig, model, apiMessages, systemPrompt, preset, streamChan)
|
||||
default:
|
||||
return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// AI 调用失败,恢复刚才删除的 assistant 消息,避免数据永久丢失
|
||||
if lastAssistantMsg.ID > 0 {
|
||||
if restoreErr := global.GVA_DB.Unscoped().Model(&lastAssistantMsg).Update("deleted_at", nil).Error; restoreErr != nil {
|
||||
global.GVA_LOG.Error(fmt.Sprintf("[重新生成] 恢复 assistant 消息失败: %v", restoreErr))
|
||||
} else {
|
||||
// 回滚 conversation 统计
|
||||
global.GVA_DB.Model(&conversation).Updates(map[string]interface{}{
|
||||
"message_count": gorm.Expr("message_count + 1"),
|
||||
"token_count": gorm.Expr("token_count + ?", lastAssistantMsg.TokenCount),
|
||||
})
|
||||
global.GVA_LOG.Info("[重新生成] 已恢复 assistant 消息")
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1437,9 +1424,333 @@ func (s *ConversationService) buildAPIMessages(messages []app.Message, systemPro
|
||||
return apiMessages
|
||||
}
|
||||
|
||||
// estimateTokens 粗略估算文本的 token 数(字符数 / 3,适用于中英混合文本)
|
||||
func estimateTokens(text string) int {
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
// 中文字符约 1 char = 1 token,英文约 4 chars = 1 token
|
||||
// 取中间值 1 char ≈ 0.75 token,即 chars * 4 / 3 的倒数 ≈ chars / 1.5
|
||||
// 保守估算用 chars / 2 防止超出
|
||||
n := len([]rune(text))
|
||||
return (n + 1) / 2
|
||||
}
|
||||
|
||||
// contextConfig 保存从 AIConfig.Settings 中解析出的上下文配置
|
||||
type contextConfig struct {
|
||||
contextLength int // 模型上下文窗口大小(token 数)
|
||||
maxTokens int // 最大输出 token 数
|
||||
}
|
||||
|
||||
// getContextConfig 从 AIConfig 中读取上下文配置,如果没有配置则使用默认值
|
||||
func getContextConfig(aiConfig *app.AIConfig, preset *app.AIPreset) contextConfig {
|
||||
cfg := contextConfig{
|
||||
contextLength: 200000, // 保守默认值
|
||||
maxTokens: 2000,
|
||||
}
|
||||
|
||||
// 从 preset 读取 max_tokens
|
||||
if preset != nil && preset.MaxTokens > 0 {
|
||||
cfg.maxTokens = preset.MaxTokens
|
||||
}
|
||||
|
||||
// 从 AIConfig.Settings 读取 context_length
|
||||
if len(aiConfig.Settings) > 0 {
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(aiConfig.Settings, &settings); err == nil {
|
||||
if cl, ok := settings["context_length"].(float64); ok && cl > 0 {
|
||||
cfg.contextLength = int(cl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// buildContextManagedSystemPrompt 按优先级构建 system prompt,超出 budget 时截断低优先级内容
|
||||
// 优先级(从高到低):
|
||||
// 1. 核心人设(Name/Description/Personality/Scenario/SystemPrompt)
|
||||
// 2. Preset.SystemPrompt
|
||||
// 3. Worldbook 触发条目
|
||||
// 4. CharacterBook 内嵌条目
|
||||
// 5. MesExample(对话示例,最容易被截断)
|
||||
//
|
||||
// 返回构建好的 systemPrompt 以及消耗的 token 数
|
||||
func (s *ConversationService) buildContextManagedSystemPrompt(
|
||||
character app.AICharacter,
|
||||
presetSystemPrompt string,
|
||||
worldbookEngine *WorldbookEngine,
|
||||
conversation app.Conversation,
|
||||
messageContents []string,
|
||||
budget int,
|
||||
) (string, int) {
|
||||
used := 0
|
||||
|
||||
// ── 优先级1:核心人设 ─────────────────────────────────────────────
|
||||
core := fmt.Sprintf("你是 %s。", character.Name)
|
||||
if character.Description != "" {
|
||||
core += fmt.Sprintf("\n\n描述:%s", character.Description)
|
||||
}
|
||||
if character.Personality != "" {
|
||||
core += fmt.Sprintf("\n\n性格:%s", character.Personality)
|
||||
}
|
||||
if character.Scenario != "" {
|
||||
core += fmt.Sprintf("\n\n场景:%s", character.Scenario)
|
||||
}
|
||||
if character.SystemPrompt != "" {
|
||||
core += fmt.Sprintf("\n\n系统提示:%s", character.SystemPrompt)
|
||||
}
|
||||
core += "\n\n请根据以上设定进行角色扮演,保持角色的性格和说话方式。"
|
||||
core = s.applyMacroVariables(core, character)
|
||||
|
||||
coreTokens := estimateTokens(core)
|
||||
if coreTokens >= budget {
|
||||
// 极端情况:核心人设本身就超出 budget,截断到 budget
|
||||
runes := []rune(core)
|
||||
limit := budget * 2
|
||||
if limit > len(runes) {
|
||||
limit = len(runes)
|
||||
}
|
||||
core = string(runes[:limit])
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[context] 核心人设超出 budget,已截断至 %d chars", limit))
|
||||
return core, budget
|
||||
}
|
||||
used += coreTokens
|
||||
prompt := core
|
||||
|
||||
// ── 优先级2:Preset.SystemPrompt ────────────────────────────────
|
||||
if presetSystemPrompt != "" {
|
||||
tokens := estimateTokens(presetSystemPrompt)
|
||||
if used+tokens <= budget {
|
||||
prompt += "\n\n" + presetSystemPrompt
|
||||
used += tokens
|
||||
} else {
|
||||
// 尝试部分插入
|
||||
remaining := budget - used
|
||||
if remaining > 50 {
|
||||
runes := []rune(presetSystemPrompt)
|
||||
limit := remaining * 2
|
||||
if limit > len(runes) {
|
||||
limit = len(runes)
|
||||
}
|
||||
prompt += "\n\n" + string(runes[:limit])
|
||||
used = budget
|
||||
}
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[context] Preset.SystemPrompt 因 budget 不足被截断(需要 %d tokens,剩余 %d)", tokens, budget-used))
|
||||
}
|
||||
}
|
||||
|
||||
if used >= budget {
|
||||
return prompt, used
|
||||
}
|
||||
|
||||
// ── 优先级3:世界书触发条目 ──────────────────────────────────────
|
||||
if conversation.WorldbookEnabled && conversation.WorldbookID != nil && worldbookEngine != nil {
|
||||
triggeredEntries, wbErr := worldbookEngine.ScanAndTrigger(*conversation.WorldbookID, messageContents)
|
||||
if wbErr != nil {
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[context] 世界书触发失败: %v", wbErr))
|
||||
} else if len(triggeredEntries) > 0 {
|
||||
wbHeader := "\n\n[World Information]"
|
||||
wbSection := wbHeader
|
||||
for _, te := range triggeredEntries {
|
||||
if te.Entry == nil || te.Entry.Content == "" {
|
||||
continue
|
||||
}
|
||||
line := fmt.Sprintf("\n- %s", te.Entry.Content)
|
||||
lineTokens := estimateTokens(line)
|
||||
if used+estimateTokens(wbSection)+lineTokens <= budget {
|
||||
wbSection += line
|
||||
used += lineTokens
|
||||
} else {
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[context] 世界书条目 (id=%d) 因 budget 不足被跳过", te.Entry.ID))
|
||||
break
|
||||
}
|
||||
}
|
||||
if wbSection != wbHeader {
|
||||
prompt += wbSection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if used >= budget {
|
||||
return prompt, used
|
||||
}
|
||||
|
||||
// ── 优先级4:CharacterBook 内嵌条目 ──────────────────────────────
|
||||
if len(character.CharacterBook) > 0 {
|
||||
var characterBook map[string]interface{}
|
||||
if err := json.Unmarshal(character.CharacterBook, &characterBook); err == nil {
|
||||
if entries, ok := characterBook["entries"].([]interface{}); ok && len(entries) > 0 {
|
||||
cbSection := "\n\n世界设定:"
|
||||
addedAny := false
|
||||
for _, entry := range entries {
|
||||
entryMap, ok := entry.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
enabled := true
|
||||
if enabledVal, ok := entryMap["enabled"].(bool); ok {
|
||||
enabled = enabledVal
|
||||
}
|
||||
if !enabled {
|
||||
continue
|
||||
}
|
||||
content, ok := entryMap["content"].(string)
|
||||
if !ok || content == "" {
|
||||
continue
|
||||
}
|
||||
line := fmt.Sprintf("\n- %s", content)
|
||||
lineTokens := estimateTokens(line)
|
||||
if used+estimateTokens(cbSection)+lineTokens <= budget {
|
||||
cbSection += line
|
||||
used += lineTokens
|
||||
addedAny = true
|
||||
} else {
|
||||
global.GVA_LOG.Warn("[context] CharacterBook 条目因 budget 不足被跳过")
|
||||
break
|
||||
}
|
||||
}
|
||||
if addedAny {
|
||||
prompt += cbSection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if used >= budget {
|
||||
return prompt, used
|
||||
}
|
||||
|
||||
// ── 优先级5:MesExample(对话示例,最低优先级)──────────────────
|
||||
if character.MesExample != "" {
|
||||
mesTokens := estimateTokens(character.MesExample)
|
||||
prefix := "\n\n对话示例:\n"
|
||||
prefixTokens := estimateTokens(prefix)
|
||||
if used+prefixTokens+mesTokens <= budget {
|
||||
prompt += prefix + character.MesExample
|
||||
used += prefixTokens + mesTokens
|
||||
} else {
|
||||
// 尝试截断 MesExample
|
||||
remaining := budget - used - prefixTokens
|
||||
if remaining > 100 {
|
||||
runes := []rune(character.MesExample)
|
||||
limit := remaining * 2
|
||||
if limit > len(runes) {
|
||||
limit = len(runes)
|
||||
}
|
||||
prompt += prefix + string(runes[:limit])
|
||||
used = budget
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[context] MesExample 被截断(原始 %d tokens,保留约 %d tokens)", mesTokens, remaining))
|
||||
} else {
|
||||
global.GVA_LOG.Warn("[context] MesExample 因 budget 不足被完全跳过")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prompt, used
|
||||
}
|
||||
|
||||
// trimMessagesToBudget 从历史消息中按 token 预算选取最近的消息
|
||||
// 优先保留最新的消息,从后往前丢弃旧消息直到 token 数在 budget 内
|
||||
func trimMessagesToBudget(messages []app.Message, budget int) []app.Message {
|
||||
if budget <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// messages 已经是从旧到新的顺序
|
||||
// 从最新消息开始往前累加,直到超出 budget
|
||||
selected := make([]app.Message, 0, len(messages))
|
||||
used := 0
|
||||
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
if msg.Role == "system" {
|
||||
continue
|
||||
}
|
||||
t := estimateTokens(msg.Content)
|
||||
if used+t > budget {
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[context] 历史消息已截断,保留最近 %d 条(共 %d 条),使用 %d tokens", len(selected), len(messages), used))
|
||||
break
|
||||
}
|
||||
used += t
|
||||
selected = append([]app.Message{msg}, selected...) // 保持时序
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
// buildAPIMessagesWithContextManagement 整合 context 管理,构建最终的 messages 列表
|
||||
// 返回 apiMessages 及各部分 token 统计日志
|
||||
func (s *ConversationService) buildAPIMessagesWithContextManagement(
|
||||
allMessages []app.Message,
|
||||
character app.AICharacter,
|
||||
presetSystemPrompt string,
|
||||
worldbookEngine *WorldbookEngine,
|
||||
conversation app.Conversation,
|
||||
aiConfig *app.AIConfig,
|
||||
preset *app.AIPreset,
|
||||
) []map[string]string {
|
||||
cfg := getContextConfig(aiConfig, preset)
|
||||
|
||||
// 安全边际:为输出保留 max_tokens,另加 200 token 缓冲
|
||||
inputBudget := cfg.contextLength - cfg.maxTokens - 200
|
||||
if inputBudget <= 0 {
|
||||
inputBudget = cfg.contextLength / 2
|
||||
}
|
||||
|
||||
// 为历史消息分配预算:system prompt 最多占用 60% 的 input budget
|
||||
systemBudget := inputBudget * 60 / 100
|
||||
historyBudget := inputBudget - systemBudget
|
||||
|
||||
// 提取消息内容用于世界书扫描
|
||||
var messageContents []string
|
||||
for _, msg := range allMessages {
|
||||
messageContents = append(messageContents, msg.Content)
|
||||
}
|
||||
|
||||
// 构建 system prompt(含 worldbook 注入,按优先级截断)
|
||||
systemPrompt, systemTokens := s.buildContextManagedSystemPrompt(
|
||||
character,
|
||||
presetSystemPrompt,
|
||||
worldbookEngine,
|
||||
conversation,
|
||||
messageContents,
|
||||
systemBudget,
|
||||
)
|
||||
|
||||
// 如果 system prompt 实际用量比预算少,把节省的预算让给历史消息
|
||||
if systemTokens < systemBudget {
|
||||
historyBudget += systemBudget - systemTokens
|
||||
}
|
||||
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[context] 配置:context_length=%d, max_tokens=%d, input_budget=%d, system=%d tokens, history_budget=%d",
|
||||
cfg.contextLength, cfg.maxTokens, inputBudget, systemTokens, historyBudget))
|
||||
|
||||
// 按 token 预算裁剪历史消息
|
||||
trimmedMessages := trimMessagesToBudget(allMessages, historyBudget)
|
||||
|
||||
// 构建最终 messages
|
||||
apiMessages := make([]map[string]string, 0, len(trimmedMessages)+1)
|
||||
apiMessages = append(apiMessages, map[string]string{
|
||||
"role": "system",
|
||||
"content": systemPrompt,
|
||||
})
|
||||
for _, msg := range trimmedMessages {
|
||||
if msg.Role == "system" {
|
||||
continue
|
||||
}
|
||||
apiMessages = append(apiMessages, map[string]string{
|
||||
"role": msg.Role,
|
||||
"content": msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
return apiMessages
|
||||
}
|
||||
|
||||
// callOpenAIAPI 调用 OpenAI API
|
||||
func (s *ConversationService) callOpenAIAPI(config *app.AIConfig, model string, messages []map[string]string, preset *app.AIPreset) (string, error) {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
|
||||
// 使用配置的模型或默认模型
|
||||
if model == "" {
|
||||
@@ -1559,7 +1870,7 @@ func (s *ConversationService) callOpenAIAPI(config *app.AIConfig, model string,
|
||||
|
||||
// callAnthropicAPI 调用 Anthropic API
|
||||
func (s *ConversationService) callAnthropicAPI(config *app.AIConfig, model string, messages []map[string]string, systemPrompt string, preset *app.AIPreset) (string, error) {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
|
||||
// 使用配置的模型或默认模型
|
||||
if model == "" {
|
||||
|
||||
@@ -69,6 +69,10 @@ func (s *RegexScriptService) GetRegexScriptList(userID uint, req *request.GetReg
|
||||
db = db.Where("scope = ?", *req.Scope)
|
||||
}
|
||||
|
||||
if req.OwnerCharID != nil {
|
||||
db = db.Where("owner_char_id = ?", *req.OwnerCharID)
|
||||
}
|
||||
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user