优化上下文

This commit is contained in:
2026-06-10 18:00:58 +08:00
parent d9ba14e28b
commit 74268a8a07
6 changed files with 180 additions and 66 deletions
+9 -8
View File
@@ -27,14 +27,15 @@ type ConfigsFile_ struct {
} }
type ConfigsAIChatOpenAI_ struct { type ConfigsAIChatOpenAI_ struct {
Name string `mapstructure:"name"` Name string `mapstructure:"name"`
Active bool `mapstructure:"active"` Active bool `mapstructure:"active"`
ApiKey string `mapstructure:"apiKey"` ApiKey string `mapstructure:"apiKey"`
BaseUrl string `mapstructure:"baseUrl"` BaseUrl string `mapstructure:"baseUrl"`
Model string `mapstructure:"model"` Model string `mapstructure:"model"`
Timeout int `mapstructure:"timeout"` Timeout int `mapstructure:"timeout"`
MaxTokens int `mapstructure:"maxTokens"` MaxTokens int `mapstructure:"maxTokens"`
SystemPrompt string `mapstructure:"systemPrompt"` ContextWindowTokens int `mapstructure:"contextWindowTokens"`
SystemPrompt string `mapstructure:"systemPrompt"`
} }
type ConfigsAIChatTool_ struct { type ConfigsAIChatTool_ struct {
+86 -6
View File
@@ -180,12 +180,13 @@ func handleOpenAIProfiles(ctx *gin.Context) {
active = profile.Name active = profile.Name
} }
profiles = append(profiles, map[string]interface{}{ profiles = append(profiles, map[string]interface{}{
"name": profile.Name, "name": profile.Name,
"active": profile.Active, "active": profile.Active,
"baseUrl": profile.BaseUrl, "baseUrl": profile.BaseUrl,
"model": profile.Model, "model": profile.Model,
"timeout": profile.Timeout, "timeout": profile.Timeout,
"maxTokens": profile.MaxTokens, "maxTokens": profile.MaxTokens,
"contextWindowTokens": profile.ContextWindowTokens,
}) })
} }
ReturnJson(ctx, "apiOK", gin.H{ 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...) 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) modelPromptTokens := estimateOpenAIMessagesTokens(apiReq.Messages)
completionTokens := 0 completionTokens := 0
modelUsageReceived := false modelUsageReceived := false
@@ -603,6 +615,74 @@ func normalizeImageDataURI(raw string) (string, error) {
return "data:" + mimeType + ";base64," + payload, nil 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 { type tokenUsageTracker struct {
promptTokens int promptTokens int
completionTokens int completionTokens int
+66 -52
View File
@@ -18,18 +18,19 @@ type TabAIChatSetting struct {
} }
type TabAIChatOpenAIProfile struct { type TabAIChatOpenAIProfile struct {
ID uint `gorm:"primaryKey;autoIncrement"` ID uint `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"size:100;not null;uniqueIndex"` Name string `gorm:"size:100;not null;uniqueIndex"`
Active bool `gorm:"default:false;index"` Active bool `gorm:"default:false;index"`
ApiKey string `gorm:"type:text"` ApiKey string `gorm:"type:text"`
BaseUrl string `gorm:"size:500"` BaseUrl string `gorm:"size:500"`
Model string `gorm:"size:200"` Model string `gorm:"size:200"`
Timeout int `gorm:"default:120"` Timeout int `gorm:"default:120"`
MaxTokens int `gorm:"default:4096"` MaxTokens int `gorm:"default:4096"`
SystemPrompt string `gorm:"type:text"` ContextWindowTokens int `gorm:"default:0"`
SortOrder int `gorm:"default:0;index"` SystemPrompt string `gorm:"type:text"`
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"` SortOrder int `gorm:"default:0;index"`
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"` CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"`
} }
type TabAIChatToolRouter struct { type TabAIChatToolRouter struct {
@@ -120,12 +121,13 @@ func seedAIChatConfigFromYAMLIfEmpty() error {
profiles := cfg.OpenAI profiles := cfg.OpenAI
if len(profiles) == 0 { if len(profiles) == 0 {
profiles = []models.ConfigsAIChatOpenAI_{{ profiles = []models.ConfigsAIChatOpenAI_{{
Name: "default", Name: "default",
Active: true, Active: true,
BaseUrl: "https://ark.cn-beijing.volces.com/api/v3", BaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
Timeout: 120, Timeout: 120,
MaxTokens: 4096, MaxTokens: 4096,
SystemPrompt: "你是一个有帮助的 AI 助手。", ContextWindowTokens: 0,
SystemPrompt: "你是一个有帮助的 AI 助手。",
}} }}
} }
for i, profile := range profiles { for i, profile := range profiles {
@@ -139,15 +141,16 @@ func seedAIChatConfigFromYAMLIfEmpty() error {
profile.MaxTokens = 4096 profile.MaxTokens = 4096
} }
if err := tx.Create(&TabAIChatOpenAIProfile{ if err := tx.Create(&TabAIChatOpenAIProfile{
Name: profile.Name, Name: profile.Name,
Active: profile.Active, Active: profile.Active,
ApiKey: profile.ApiKey, ApiKey: profile.ApiKey,
BaseUrl: profile.BaseUrl, BaseUrl: profile.BaseUrl,
Model: profile.Model, Model: profile.Model,
Timeout: profile.Timeout, Timeout: profile.Timeout,
MaxTokens: profile.MaxTokens, MaxTokens: profile.MaxTokens,
SystemPrompt: profile.SystemPrompt, ContextWindowTokens: nonNegativeInt(profile.ContextWindowTokens),
SortOrder: i, SystemPrompt: profile.SystemPrompt,
SortOrder: i,
}).Error; err != nil { }).Error; err != nil {
return err return err
} }
@@ -232,14 +235,15 @@ func RefreshAIChatConfigCache() error {
for _, profile := range profiles { for _, profile := range profiles {
cfg.OpenAI = append(cfg.OpenAI, models.ConfigsAIChatOpenAI_{ cfg.OpenAI = append(cfg.OpenAI, models.ConfigsAIChatOpenAI_{
Name: profile.Name, Name: profile.Name,
Active: profile.Active, Active: profile.Active,
ApiKey: profile.ApiKey, ApiKey: profile.ApiKey,
BaseUrl: profile.BaseUrl, BaseUrl: profile.BaseUrl,
Model: profile.Model, Model: profile.Model,
Timeout: defaultInt(profile.Timeout, 120), Timeout: defaultInt(profile.Timeout, 120),
MaxTokens: defaultInt(profile.MaxTokens, 4096), MaxTokens: defaultInt(profile.MaxTokens, 4096),
SystemPrompt: profile.SystemPrompt, ContextWindowTokens: nonNegativeInt(profile.ContextWindowTokens),
SystemPrompt: profile.SystemPrompt,
}) })
} }
for _, tool := range tools { for _, tool := range tools {
@@ -269,6 +273,13 @@ func defaultInt(value int, fallback int) int {
return value return value
} }
func nonNegativeInt(value int) int {
if value < 0 {
return 0
}
return value
}
func handleAIChatAdminGetConfig(ctx *gin.Context) { func handleAIChatAdminGetConfig(ctx *gin.Context) {
if ok, _ := requireSysAdmin(ctx); !ok { if ok, _ := requireSysAdmin(ctx); !ok {
return return
@@ -357,6 +368,7 @@ func saveAIChatConfig(req models.ConfigsAIChat_) error {
if profile.MaxTokens <= 0 { if profile.MaxTokens <= 0 {
profile.MaxTokens = 4096 profile.MaxTokens = 4096
} }
profile.ContextWindowTokens = nonNegativeInt(profile.ContextWindowTokens)
if profile.Active { if profile.Active {
if activeSet { if activeSet {
profile.Active = false profile.Active = false
@@ -366,15 +378,16 @@ func saveAIChatConfig(req models.ConfigsAIChat_) error {
} }
tab := TabAIChatOpenAIProfile{ tab := TabAIChatOpenAIProfile{
Name: profile.Name, Name: profile.Name,
Active: profile.Active, Active: profile.Active,
ApiKey: profile.ApiKey, ApiKey: profile.ApiKey,
BaseUrl: profile.BaseUrl, BaseUrl: profile.BaseUrl,
Model: profile.Model, Model: profile.Model,
Timeout: profile.Timeout, Timeout: profile.Timeout,
MaxTokens: profile.MaxTokens, MaxTokens: profile.MaxTokens,
SystemPrompt: profile.SystemPrompt, ContextWindowTokens: profile.ContextWindowTokens,
SortOrder: i, SystemPrompt: profile.SystemPrompt,
SortOrder: i,
} }
if old, ok := existingByName[profile.Name]; ok { if old, ok := existingByName[profile.Name]; ok {
tab.ID = old.ID tab.ID = old.ID
@@ -447,14 +460,15 @@ func maskAIChatProfiles(profiles []models.ConfigsAIChatOpenAI_) []gin.H {
items := make([]gin.H, 0, len(profiles)) items := make([]gin.H, 0, len(profiles))
for _, profile := range profiles { for _, profile := range profiles {
items = append(items, gin.H{ items = append(items, gin.H{
"name": profile.Name, "name": profile.Name,
"active": profile.Active, "active": profile.Active,
"apiKeySet": profile.ApiKey != "", "apiKeySet": profile.ApiKey != "",
"baseUrl": profile.BaseUrl, "baseUrl": profile.BaseUrl,
"model": profile.Model, "model": profile.Model,
"timeout": profile.Timeout, "timeout": profile.Timeout,
"maxTokens": profile.MaxTokens, "maxTokens": profile.MaxTokens,
"systemPrompt": profile.SystemPrompt, "contextWindowTokens": profile.ContextWindowTokens,
"systemPrompt": profile.SystemPrompt,
}) })
} }
return items return items
+3
View File
@@ -113,6 +113,8 @@
"model": "Model", "model": "Model",
"timeout": "Timeout (seconds)", "timeout": "Timeout (seconds)",
"max_tokens": "Max Tokens", "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", "system_prompt": "System Prompt",
"tool_router": "Tool Router", "tool_router": "Tool Router",
"router_profile": "Router Profile", "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_api_key_required": "When AI is enabled, the default profile must have an API key",
"error_timeout": "Timeout must be a positive integer", "error_timeout": "Timeout must be a positive integer",
"error_max_tokens": "Max tokens 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_router_profile": "Tool router profile must exist in profile list",
"error_tool_name_required": "Tool name is required", "error_tool_name_required": "Tool name is required",
"error_tool_name_duplicate": "Tool names must be unique" "error_tool_name_duplicate": "Tool names must be unique"
+3
View File
@@ -113,6 +113,8 @@
"model": "模型", "model": "模型",
"timeout": "超时(秒)", "timeout": "超时(秒)",
"max_tokens": "最大 Token", "max_tokens": "最大 Token",
"context_window_tokens": "上下文窗口 Token",
"context_window_tokens_hint": "0 表示不限制;超过后会保留系统提示词和最新消息,删除最早的问答记录。",
"system_prompt": "系统提示词", "system_prompt": "系统提示词",
"tool_router": "工具路由", "tool_router": "工具路由",
"router_profile": "路由接口", "router_profile": "路由接口",
@@ -132,6 +134,7 @@
"error_api_key_required": "启用 AI 时默认接口必须配置 API Key", "error_api_key_required": "启用 AI 时默认接口必须配置 API Key",
"error_timeout": "超时必须是正整数", "error_timeout": "超时必须是正整数",
"error_max_tokens": "最大 Token 必须是正整数", "error_max_tokens": "最大 Token 必须是正整数",
"error_context_window_tokens": "上下文窗口 Token 必须是非负整数",
"error_router_profile": "工具路由接口必须来自现有接口列表", "error_router_profile": "工具路由接口必须来自现有接口列表",
"error_tool_name_required": "工具名称不能为空", "error_tool_name_required": "工具名称不能为空",
"error_tool_name_duplicate": "工具名称不能重复" "error_tool_name_duplicate": "工具名称不能重复"
@@ -42,6 +42,7 @@ function normalizeProfile(profile = {}) {
model: profile.model || '', model: profile.model || '',
timeout: Number(profile.timeout || 120), timeout: Number(profile.timeout || 120),
maxTokens: Number(profile.maxTokens || 4096), maxTokens: Number(profile.maxTokens || 4096),
contextWindowTokens: Number(profile.contextWindowTokens || 0),
systemPrompt: profile.systemPrompt || '', systemPrompt: profile.systemPrompt || '',
} }
} }
@@ -117,6 +118,7 @@ function addProfile() {
model: '', model: '',
timeout: 120, timeout: 120,
maxTokens: 4096, maxTokens: 4096,
contextWindowTokens: 0,
systemPrompt: '', systemPrompt: '',
}) })
} }
@@ -178,6 +180,7 @@ function validate() {
profile.timeout = Number(profile.timeout) profile.timeout = Number(profile.timeout)
profile.maxTokens = Number(profile.maxTokens) profile.maxTokens = Number(profile.maxTokens)
profile.contextWindowTokens = Number(profile.contextWindowTokens)
if (!Number.isInteger(profile.timeout) || profile.timeout <= 0) { if (!Number.isInteger(profile.timeout) || profile.timeout <= 0) {
toast.error(t('aiconfig.error_timeout')) toast.error(t('aiconfig.error_timeout'))
return false return false
@@ -186,6 +189,10 @@ function validate() {
toast.error(t('aiconfig.error_max_tokens')) toast.error(t('aiconfig.error_max_tokens'))
return false return false
} }
if (!Number.isInteger(profile.contextWindowTokens) || profile.contextWindowTokens < 0) {
toast.error(t('aiconfig.error_context_window_tokens'))
return false
}
} }
if (!hasActive) { if (!hasActive) {
@@ -248,6 +255,7 @@ function buildPayload() {
model: profile.model.trim(), model: profile.model.trim(),
timeout: Number(profile.timeout), timeout: Number(profile.timeout),
maxTokens: Number(profile.maxTokens), maxTokens: Number(profile.maxTokens),
contextWindowTokens: Number(profile.contextWindowTokens),
systemPrompt: profile.systemPrompt || '', systemPrompt: profile.systemPrompt || '',
})), })),
toolRouter: { toolRouter: {
@@ -360,6 +368,11 @@ async function saveConfig() {
<span>{{ t('aiconfig.max_tokens') }}</span> <span>{{ t('aiconfig.max_tokens') }}</span>
<input v-model.number="profile.maxTokens" class="input" type="number" min="1" /> <input v-model.number="profile.maxTokens" class="input" type="number" min="1" />
</label> </label>
<label class="field">
<span>{{ t('aiconfig.context_window_tokens') }}</span>
<input v-model.number="profile.contextWindowTokens" class="input" type="number" min="0" />
<span class="text-xs font-normal text-gray-500 dark:text-dk-subtle">{{ t('aiconfig.context_window_tokens_hint') }}</span>
</label>
<label class="field md:col-span-2"> <label class="field md:col-span-2">
<span>{{ t('aiconfig.system_prompt') }}</span> <span>{{ t('aiconfig.system_prompt') }}</span>
<textarea v-model="profile.systemPrompt" class="input min-h-24 resize-y" /> <textarea v-model="profile.systemPrompt" class="input min-h-24 resize-y" />