🎨 1.优化前端渲染功能(html和对话消息格式)

2.优化流式传输,新增流式渲染功能
3.优化正则处理逻辑
4.新增context budget管理系统
5.优化对话消息失败处理逻辑
6.新增前端卡功能(待完整测试)
This commit is contained in:
2026-03-13 15:58:33 +08:00
parent c267b6c76e
commit 4cecfd6589
22 changed files with 2492 additions and 2164 deletions

View File

@@ -255,7 +255,7 @@ func (a *ConversationApi) regenerateMessageStream(c *gin.Context, userID, conver
go func() {
if err := service.ServiceGroupApp.AppServiceGroup.ConversationService.RegenerateMessageStream(
userID, conversationID, streamChan, doneChan,
c.Request.Context(), userID, conversationID, streamChan, doneChan,
); err != nil {
errorChan <- err
}
@@ -347,7 +347,7 @@ func (a *ConversationApi) SendMessageStream(c *gin.Context, userID, conversation
// 启动流式传输
go func() {
err := service.ServiceGroupApp.AppServiceGroup.ConversationService.SendMessageStream(
userID, conversationID, req, streamChan, doneChan,
c.Request.Context(), userID, conversationID, req, streamChan, doneChan,
)
if err != nil {
errorChan <- err

View File

@@ -51,6 +51,13 @@ func (a *RegexScriptApi) GetRegexScriptList(c *gin.Context) {
scopeInt, _ := strconv.Atoi(scope)
req.Scope = &scopeInt
}
if ownerCharID := c.Query("ownerCharId"); ownerCharID != "" {
ownerCharIDUint, err := strconv.ParseUint(ownerCharID, 10, 32)
if err == nil {
v := uint(ownerCharIDUint)
req.OwnerCharID = &v
}
}
if req.Page < 1 {
req.Page = 1

View File

@@ -40,5 +40,5 @@ func RunServer() {
默认MCP Message地址:http://127.0.0.1%s%s
默认前端文件运行地址:http://127.0.0.1:8080
`, address, address, global.GVA_CONFIG.MCP.SSEPath, address, global.GVA_CONFIG.MCP.MessagePath)
initServer(address, Router, 10*time.Minute, 10*time.Minute)
initServer(address, Router, 10*time.Minute, 0)
}

View File

@@ -42,10 +42,11 @@ type UpdateRegexScriptRequest struct {
// GetRegexScriptListRequest 获取正则脚本列表请求
type GetRegexScriptListRequest struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
Keyword string `json:"keyword"`
Scope *int `json:"scope"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Keyword string `json:"keyword"`
Scope *int `json:"scope"`
OwnerCharID *uint `json:"ownerCharId"` // 过滤指定角色的脚本scope=1时有效
}
// TestRegexScriptRequest 测试正则脚本请求

View File

@@ -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
// ── 优先级2Preset.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
}
// ── 优先级4CharacterBook 内嵌条目 ──────────────────────────────
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
}
// ── 优先级5MesExample对话示例最低优先级──────────────────
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 == "" {

View File

@@ -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
}