From 74268a8a07d2eb789517248fd70eaef4275323d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E5=B3=B0?= Date: Wed, 10 Jun 2026 18:00:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/my_work/models/configs.go | 17 +-- backend/my_work/routers/apiAIChat.go | 92 +++++++++++++- backend/my_work/routers/apiAIChatConfig.go | 118 ++++++++++-------- frontend/ops_vue_js/src/i18n/en.json | 3 + frontend/ops_vue_js/src/i18n/zh-CN.json | 3 + .../src/views/admin/AIConfigView.vue | 13 ++ 6 files changed, 180 insertions(+), 66 deletions(-) diff --git a/backend/my_work/models/configs.go b/backend/my_work/models/configs.go index 01f67d0..a57ca1e 100644 --- a/backend/my_work/models/configs.go +++ b/backend/my_work/models/configs.go @@ -27,14 +27,15 @@ type ConfigsFile_ struct { } type ConfigsAIChatOpenAI_ struct { - Name string `mapstructure:"name"` - Active bool `mapstructure:"active"` - ApiKey string `mapstructure:"apiKey"` - BaseUrl string `mapstructure:"baseUrl"` - Model string `mapstructure:"model"` - Timeout int `mapstructure:"timeout"` - MaxTokens int `mapstructure:"maxTokens"` - SystemPrompt string `mapstructure:"systemPrompt"` + Name string `mapstructure:"name"` + Active bool `mapstructure:"active"` + ApiKey string `mapstructure:"apiKey"` + BaseUrl string `mapstructure:"baseUrl"` + Model string `mapstructure:"model"` + Timeout int `mapstructure:"timeout"` + MaxTokens int `mapstructure:"maxTokens"` + ContextWindowTokens int `mapstructure:"contextWindowTokens"` + SystemPrompt string `mapstructure:"systemPrompt"` } type ConfigsAIChatTool_ struct { diff --git a/backend/my_work/routers/apiAIChat.go b/backend/my_work/routers/apiAIChat.go index ca45f63..a5a0ddb 100644 --- a/backend/my_work/routers/apiAIChat.go +++ b/backend/my_work/routers/apiAIChat.go @@ -180,12 +180,13 @@ func handleOpenAIProfiles(ctx *gin.Context) { active = profile.Name } profiles = append(profiles, map[string]interface{}{ - "name": profile.Name, - "active": profile.Active, - "baseUrl": profile.BaseUrl, - "model": profile.Model, - "timeout": profile.Timeout, - "maxTokens": profile.MaxTokens, + "name": profile.Name, + "active": profile.Active, + "baseUrl": profile.BaseUrl, + "model": profile.Model, + "timeout": profile.Timeout, + "maxTokens": profile.MaxTokens, + "contextWindowTokens": profile.ContextWindowTokens, }) } ReturnJson(ctx, "apiOK", gin.H{ @@ -317,6 +318,17 @@ func handleChat(ctx *gin.Context) { apiReq.Messages = append([]openaiMessage{{Role: "system", Content: profile.SystemPrompt}}, apiReq.Messages...) } + trimmedMessages, trimStats := trimOpenAIMessagesToContextWindow(apiReq.Messages, profile.ContextWindowTokens) + apiReq.Messages = trimmedMessages + if trimStats.RemovedMessages > 0 { + emitTrace("model", "context_window", "success", "上下文窗口已裁剪旧消息", map[string]interface{}{ + "limit": trimStats.Limit, + "before_tokens": trimStats.BeforeTokens, + "after_tokens": trimStats.AfterTokens, + "removed_messages": trimStats.RemovedMessages, + }) + } + modelPromptTokens := estimateOpenAIMessagesTokens(apiReq.Messages) completionTokens := 0 modelUsageReceived := false @@ -603,6 +615,74 @@ func normalizeImageDataURI(raw string) (string, error) { return "data:" + mimeType + ";base64," + payload, nil } +type contextWindowTrimStats struct { + Enabled bool + Limit int + BeforeTokens int + AfterTokens int + RemovedMessages int +} + +func trimOpenAIMessagesToContextWindow(messages []openaiMessage, maxTokens int) ([]openaiMessage, contextWindowTrimStats) { + stats := contextWindowTrimStats{Enabled: maxTokens > 0, Limit: maxTokens} + if maxTokens <= 0 || len(messages) == 0 { + stats.BeforeTokens = estimateOpenAIMessagesTokens(messages) + stats.AfterTokens = stats.BeforeTokens + return messages, stats + } + + result := append([]openaiMessage(nil), messages...) + stats.BeforeTokens = estimateOpenAIMessagesTokens(result) + stats.AfterTokens = stats.BeforeTokens + if stats.BeforeTokens <= maxTokens { + return result, stats + } + + for stats.AfterTokens > maxTokens { + startIndex := 0 + if len(result) > 0 && result[0].Role == "system" { + startIndex = 1 + } + latestUserIndex := latestUserMessageIndex(result) + removeIndex := -1 + for i := startIndex; i < len(result); i++ { + if i == latestUserIndex { + continue + } + if result[i].Role == "system" { + continue + } + removeIndex = i + break + } + if removeIndex == -1 { + break + } + + removeCount := 1 + if result[removeIndex].Role == "user" { + nextIndex := removeIndex + 1 + if nextIndex < len(result) && nextIndex != latestUserIndex && result[nextIndex].Role == "assistant" { + removeCount = 2 + } + } + result = append(result[:removeIndex], result[removeIndex+removeCount:]...) + stats.RemovedMessages += removeCount + stats.AfterTokens = estimateOpenAIMessagesTokens(result) + } + + return result, stats +} + +func latestUserMessageIndex(messages []openaiMessage) int { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "user" { + return i + } + } + return -1 +} + type tokenUsageTracker struct { promptTokens int completionTokens int diff --git a/backend/my_work/routers/apiAIChatConfig.go b/backend/my_work/routers/apiAIChatConfig.go index cbdb12b..a453df3 100644 --- a/backend/my_work/routers/apiAIChatConfig.go +++ b/backend/my_work/routers/apiAIChatConfig.go @@ -18,18 +18,19 @@ type TabAIChatSetting struct { } type TabAIChatOpenAIProfile struct { - ID uint `gorm:"primaryKey;autoIncrement"` - Name string `gorm:"size:100;not null;uniqueIndex"` - Active bool `gorm:"default:false;index"` - ApiKey string `gorm:"type:text"` - BaseUrl string `gorm:"size:500"` - Model string `gorm:"size:200"` - Timeout int `gorm:"default:120"` - MaxTokens int `gorm:"default:4096"` - SystemPrompt string `gorm:"type:text"` - SortOrder int `gorm:"default:0;index"` - CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"` - UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"` + ID uint `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"size:100;not null;uniqueIndex"` + Active bool `gorm:"default:false;index"` + ApiKey string `gorm:"type:text"` + BaseUrl string `gorm:"size:500"` + Model string `gorm:"size:200"` + Timeout int `gorm:"default:120"` + MaxTokens int `gorm:"default:4096"` + ContextWindowTokens int `gorm:"default:0"` + SystemPrompt string `gorm:"type:text"` + SortOrder int `gorm:"default:0;index"` + CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"` + UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"` } type TabAIChatToolRouter struct { @@ -120,12 +121,13 @@ func seedAIChatConfigFromYAMLIfEmpty() error { profiles := cfg.OpenAI if len(profiles) == 0 { profiles = []models.ConfigsAIChatOpenAI_{{ - Name: "default", - Active: true, - BaseUrl: "https://ark.cn-beijing.volces.com/api/v3", - Timeout: 120, - MaxTokens: 4096, - SystemPrompt: "你是一个有帮助的 AI 助手。", + Name: "default", + Active: true, + BaseUrl: "https://ark.cn-beijing.volces.com/api/v3", + Timeout: 120, + MaxTokens: 4096, + ContextWindowTokens: 0, + SystemPrompt: "你是一个有帮助的 AI 助手。", }} } for i, profile := range profiles { @@ -139,15 +141,16 @@ func seedAIChatConfigFromYAMLIfEmpty() error { profile.MaxTokens = 4096 } if err := tx.Create(&TabAIChatOpenAIProfile{ - Name: profile.Name, - Active: profile.Active, - ApiKey: profile.ApiKey, - BaseUrl: profile.BaseUrl, - Model: profile.Model, - Timeout: profile.Timeout, - MaxTokens: profile.MaxTokens, - SystemPrompt: profile.SystemPrompt, - SortOrder: i, + Name: profile.Name, + Active: profile.Active, + ApiKey: profile.ApiKey, + BaseUrl: profile.BaseUrl, + Model: profile.Model, + Timeout: profile.Timeout, + MaxTokens: profile.MaxTokens, + ContextWindowTokens: nonNegativeInt(profile.ContextWindowTokens), + SystemPrompt: profile.SystemPrompt, + SortOrder: i, }).Error; err != nil { return err } @@ -232,14 +235,15 @@ func RefreshAIChatConfigCache() error { for _, profile := range profiles { cfg.OpenAI = append(cfg.OpenAI, models.ConfigsAIChatOpenAI_{ - Name: profile.Name, - Active: profile.Active, - ApiKey: profile.ApiKey, - BaseUrl: profile.BaseUrl, - Model: profile.Model, - Timeout: defaultInt(profile.Timeout, 120), - MaxTokens: defaultInt(profile.MaxTokens, 4096), - SystemPrompt: profile.SystemPrompt, + Name: profile.Name, + Active: profile.Active, + ApiKey: profile.ApiKey, + BaseUrl: profile.BaseUrl, + Model: profile.Model, + Timeout: defaultInt(profile.Timeout, 120), + MaxTokens: defaultInt(profile.MaxTokens, 4096), + ContextWindowTokens: nonNegativeInt(profile.ContextWindowTokens), + SystemPrompt: profile.SystemPrompt, }) } for _, tool := range tools { @@ -269,6 +273,13 @@ func defaultInt(value int, fallback int) int { return value } +func nonNegativeInt(value int) int { + if value < 0 { + return 0 + } + return value +} + func handleAIChatAdminGetConfig(ctx *gin.Context) { if ok, _ := requireSysAdmin(ctx); !ok { return @@ -357,6 +368,7 @@ func saveAIChatConfig(req models.ConfigsAIChat_) error { if profile.MaxTokens <= 0 { profile.MaxTokens = 4096 } + profile.ContextWindowTokens = nonNegativeInt(profile.ContextWindowTokens) if profile.Active { if activeSet { profile.Active = false @@ -366,15 +378,16 @@ func saveAIChatConfig(req models.ConfigsAIChat_) error { } tab := TabAIChatOpenAIProfile{ - Name: profile.Name, - Active: profile.Active, - ApiKey: profile.ApiKey, - BaseUrl: profile.BaseUrl, - Model: profile.Model, - Timeout: profile.Timeout, - MaxTokens: profile.MaxTokens, - SystemPrompt: profile.SystemPrompt, - SortOrder: i, + Name: profile.Name, + Active: profile.Active, + ApiKey: profile.ApiKey, + BaseUrl: profile.BaseUrl, + Model: profile.Model, + Timeout: profile.Timeout, + MaxTokens: profile.MaxTokens, + ContextWindowTokens: profile.ContextWindowTokens, + SystemPrompt: profile.SystemPrompt, + SortOrder: i, } if old, ok := existingByName[profile.Name]; ok { tab.ID = old.ID @@ -447,14 +460,15 @@ func maskAIChatProfiles(profiles []models.ConfigsAIChatOpenAI_) []gin.H { items := make([]gin.H, 0, len(profiles)) for _, profile := range profiles { items = append(items, gin.H{ - "name": profile.Name, - "active": profile.Active, - "apiKeySet": profile.ApiKey != "", - "baseUrl": profile.BaseUrl, - "model": profile.Model, - "timeout": profile.Timeout, - "maxTokens": profile.MaxTokens, - "systemPrompt": profile.SystemPrompt, + "name": profile.Name, + "active": profile.Active, + "apiKeySet": profile.ApiKey != "", + "baseUrl": profile.BaseUrl, + "model": profile.Model, + "timeout": profile.Timeout, + "maxTokens": profile.MaxTokens, + "contextWindowTokens": profile.ContextWindowTokens, + "systemPrompt": profile.SystemPrompt, }) } return items diff --git a/frontend/ops_vue_js/src/i18n/en.json b/frontend/ops_vue_js/src/i18n/en.json index e9c109b..82922a2 100644 --- a/frontend/ops_vue_js/src/i18n/en.json +++ b/frontend/ops_vue_js/src/i18n/en.json @@ -113,6 +113,8 @@ "model": "Model", "timeout": "Timeout (seconds)", "max_tokens": "Max Tokens", + "context_window_tokens": "Context Window Tokens", + "context_window_tokens_hint": "0 means unlimited. When exceeded, the system prompt and latest messages are kept while oldest Q/A turns are removed.", "system_prompt": "System Prompt", "tool_router": "Tool Router", "router_profile": "Router Profile", @@ -132,6 +134,7 @@ "error_api_key_required": "When AI is enabled, the default profile must have an API key", "error_timeout": "Timeout must be a positive integer", "error_max_tokens": "Max tokens must be a positive integer", + "error_context_window_tokens": "Context window tokens must be a non-negative integer", "error_router_profile": "Tool router profile must exist in profile list", "error_tool_name_required": "Tool name is required", "error_tool_name_duplicate": "Tool names must be unique" diff --git a/frontend/ops_vue_js/src/i18n/zh-CN.json b/frontend/ops_vue_js/src/i18n/zh-CN.json index 5407759..33c39d9 100644 --- a/frontend/ops_vue_js/src/i18n/zh-CN.json +++ b/frontend/ops_vue_js/src/i18n/zh-CN.json @@ -113,6 +113,8 @@ "model": "模型", "timeout": "超时(秒)", "max_tokens": "最大 Token", + "context_window_tokens": "上下文窗口 Token", + "context_window_tokens_hint": "0 表示不限制;超过后会保留系统提示词和最新消息,删除最早的问答记录。", "system_prompt": "系统提示词", "tool_router": "工具路由", "router_profile": "路由接口", @@ -132,6 +134,7 @@ "error_api_key_required": "启用 AI 时默认接口必须配置 API Key", "error_timeout": "超时必须是正整数", "error_max_tokens": "最大 Token 必须是正整数", + "error_context_window_tokens": "上下文窗口 Token 必须是非负整数", "error_router_profile": "工具路由接口必须来自现有接口列表", "error_tool_name_required": "工具名称不能为空", "error_tool_name_duplicate": "工具名称不能重复" diff --git a/frontend/ops_vue_js/src/views/admin/AIConfigView.vue b/frontend/ops_vue_js/src/views/admin/AIConfigView.vue index bf84bfa..1f02ddc 100644 --- a/frontend/ops_vue_js/src/views/admin/AIConfigView.vue +++ b/frontend/ops_vue_js/src/views/admin/AIConfigView.vue @@ -42,6 +42,7 @@ function normalizeProfile(profile = {}) { model: profile.model || '', timeout: Number(profile.timeout || 120), maxTokens: Number(profile.maxTokens || 4096), + contextWindowTokens: Number(profile.contextWindowTokens || 0), systemPrompt: profile.systemPrompt || '', } } @@ -117,6 +118,7 @@ function addProfile() { model: '', timeout: 120, maxTokens: 4096, + contextWindowTokens: 0, systemPrompt: '', }) } @@ -178,6 +180,7 @@ function validate() { profile.timeout = Number(profile.timeout) profile.maxTokens = Number(profile.maxTokens) + profile.contextWindowTokens = Number(profile.contextWindowTokens) if (!Number.isInteger(profile.timeout) || profile.timeout <= 0) { toast.error(t('aiconfig.error_timeout')) return false @@ -186,6 +189,10 @@ function validate() { toast.error(t('aiconfig.error_max_tokens')) return false } + if (!Number.isInteger(profile.contextWindowTokens) || profile.contextWindowTokens < 0) { + toast.error(t('aiconfig.error_context_window_tokens')) + return false + } } if (!hasActive) { @@ -248,6 +255,7 @@ function buildPayload() { model: profile.model.trim(), timeout: Number(profile.timeout), maxTokens: Number(profile.maxTokens), + contextWindowTokens: Number(profile.contextWindowTokens), systemPrompt: profile.systemPrompt || '', })), toolRouter: { @@ -360,6 +368,11 @@ async function saveConfig() { {{ t('aiconfig.max_tokens') }} +