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') }}
+