From 4cc8c27a52041b837c834313c91b2e197669fae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E5=9F=BA=E5=B2=A9?= Date: Sat, 11 Jul 2026 19:59:35 +0800 Subject: [PATCH] feat: add model-level system prompt with multi-language support - Add system_prompt and system_prompt_mode fields to models table - Support four modes: disabled, inject, override, append - Apply model-level system prompt before channel-level in OpenAI/Claude/Gemini handlers - Multi-language JSON format: {"en":"...","zh":"..."}, auto-matched to user language - Frontend: System Prompt section in model edit drawer - Frontend: System Prompt column in /models/metadata table --- constant/context_key.go | 4 + middleware/distributor.go | 3 + model/model_meta.go | 39 ++- relay/claude_handler.go | 3 + relay/compatible_handler.go | 7 + relay/gemini_handler.go | 3 + relay/helper/system_prompt.go | 237 ++++++++++++++++++ .../drawers/model-mutate-drawer.tsx | 96 +++++++ .../models/components/models-columns.tsx | 69 +++++ .../src/features/models/lib/model-form.ts | 6 + web/default/src/features/models/types.ts | 2 + 11 files changed, 456 insertions(+), 13 deletions(-) create mode 100644 relay/helper/system_prompt.go diff --git a/constant/context_key.go b/constant/context_key.go index b856bc3dda14..41dd05e4485b 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -56,6 +56,10 @@ const ( ContextKeySystemPromptOverride ContextKey = "system_prompt_override" + // ContextKeyModelSystemPrompt stores model-level system prompt (JSON: {"en":"...", "zh":"..."}) + ContextKeyModelSystemPrompt ContextKey = "model_system_prompt" + ContextKeyModelSystemPromptMode ContextKey = "model_system_prompt_mode" + // ContextKeyFileSourcesToCleanup stores file sources that need cleanup when request ends ContextKeyFileSourcesToCleanup ContextKey = "file_sources_to_cleanup" diff --git a/middleware/distributor.go b/middleware/distributor.go index cf5caa06d513..e778302fdffd 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -16,6 +16,7 @@ import ( "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/model" relayconstant "github.com/QuantumNous/new-api/relay/constant" + relayhelper "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" @@ -501,6 +502,8 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode case constant.ChannelTypeCoze: c.Set("bot_id", channel.Other) } + // 设置模型级系统提示词(优先级低于渠道级) + relayhelper.SetupModelSystemPrompt(c, modelName) return nil } diff --git a/model/model_meta.go b/model/model_meta.go index 864212771624..391b28f97450 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -22,18 +22,20 @@ type BoundChannel struct { } type Model struct { - Id int `json:"id"` - ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"` - Description string `json:"description,omitempty" gorm:"type:text"` - Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` - Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` - VendorID int `json:"vendor_id,omitempty" gorm:"index"` - Endpoints string `json:"endpoints,omitempty" gorm:"type:text"` - Status int `json:"status" gorm:"default:1"` - SyncOfficial int `json:"sync_official" gorm:"default:1"` - CreatedTime int64 `json:"created_time" gorm:"bigint"` - UpdatedTime int64 `json:"updated_time" gorm:"bigint"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"` + Id int `json:"id"` + ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"` + Description string `json:"description,omitempty" gorm:"type:text"` + Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` + Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` + VendorID int `json:"vendor_id,omitempty" gorm:"index"` + Endpoints string `json:"endpoints,omitempty" gorm:"type:text"` + Status int `json:"status" gorm:"default:1"` + SyncOfficial int `json:"sync_official" gorm:"default:1"` + SystemPrompt string `json:"system_prompt,omitempty" gorm:"type:text"` + SystemPromptMode int `json:"system_prompt_mode" gorm:"default:0"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"` BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"` EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"` @@ -78,7 +80,7 @@ func (mi *Model) Update() error { mi.UpdatedTime = common.GetTimestamp() // 使用 Select 强制更新所有字段,包括零值 return DB.Model(&Model{}).Where("id = ?", mi.Id). - Select("model_name", "description", "icon", "tags", "vendor_id", "endpoints", "status", "sync_official", "name_rule", "updated_time"). + Select("model_name", "description", "icon", "tags", "vendor_id", "endpoints", "status", "sync_official", "system_prompt", "system_prompt_mode", "name_rule", "updated_time"). Updates(mi).Error } @@ -215,3 +217,14 @@ func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Mode } return models, total, nil } + +// GetModelSystemPrompt 根据模型名查找系统提示词配置 +// 返回 systemPrompt(可能为多语言JSON: {"en":"...", "zh":"..."})和 mode(0=禁用,1=注入,2=覆写,3=附加) +func GetModelSystemPrompt(modelName string) (systemPrompt string, mode int) { + var m Model + err := DB.Where("model_name = ? AND status = ?", modelName, 1).First(&m).Error + if err != nil || m.SystemPrompt == "" || m.SystemPromptMode == 0 { + return "", 0 + } + return m.SystemPrompt, m.SystemPromptMode +} diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 527363205a1f..f0f16c5cdccb 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -107,6 +107,9 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ info.UpstreamModelName = request.Model } + // 模型级系统提示词(在渠道级之前) + helper.ApplyModelSystemPromptToClaude(c, request) + if info.ChannelSetting.SystemPrompt != "" { if request.System == nil { request.SetStringSystem(info.ChannelSetting.SystemPrompt) diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index a68cfe730f60..345d17e785d9 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -75,6 +75,8 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types !passThroughGlobal && !info.ChannelSetting.PassThroughBodyEnabled && service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) { + // 先应用模型级系统提示词 + helper.ApplyModelSystemPromptToOpenAI(c, request) applySystemPromptIfNeeded(c, info, request) usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request) if newApiErr != nil { @@ -112,6 +114,11 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types } relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) + // 先应用模型级系统提示词 + if request, ok := convertedRequest.(*dto.GeneralOpenAIRequest); ok { + helper.ApplyModelSystemPromptToOpenAI(c, request) + } + if info.ChannelSetting.SystemPrompt != "" { // 如果有系统提示,则将其添加到请求中 request, ok := convertedRequest.(*dto.GeneralOpenAIRequest) diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 8f64552a696d..eab4c1747f8a 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -95,6 +95,9 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ adaptor.Init(info) + // 模型级系统提示词(在渠道级之前) + helper.ApplyModelSystemPromptToGemini(c, request) + if info.ChannelSetting.SystemPrompt != "" { if request.SystemInstructions == nil { request.SystemInstructions = &dto.GeminiChatContent{ diff --git a/relay/helper/system_prompt.go b/relay/helper/system_prompt.go new file mode 100644 index 000000000000..8b7fae9f162c --- /dev/null +++ b/relay/helper/system_prompt.go @@ -0,0 +1,237 @@ +package helper + +import ( + "encoding/json" + "strings" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +// modelSystemPromptMode values +const ( + ModelSystemPromptModeDisabled = iota // 0: 禁用 + ModelSystemPromptModeInject // 1: 注入(只有无system消息时才注入) + ModelSystemPromptModeOverride // 2: 覆写(始终替换原有system消息) + ModelSystemPromptModeAppend // 3: 附加(拼接到原有system消息前面) +) + +// resolveModelSystemPrompt 解析多语言系统提示词,按语言匹配 +// systemPromptJSON 格式: {"en":"Hello", "zh":"你好"} +func resolveModelSystemPrompt(systemPromptJSON, language string) string { + if systemPromptJSON == "" { + return "" + } + // 尝试作为 JSON 解析多语言消息 + var jsonValue map[string]string + if err := json.Unmarshal([]byte(systemPromptJSON), &jsonValue); err != nil || len(jsonValue) == 0 { + // 不是 JSON,直接作为纯文本返回 + return systemPromptJSON + } + // 尝试匹配用户语言 + if text, ok := jsonValue[language]; ok && text != "" { + return text + } + // 回退到 en + if text, ok := jsonValue["en"]; ok && text != "" { + return text + } + // 回退到 zh + if text, ok := jsonValue["zh"]; ok && text != "" { + return text + } + // 回退到第一个非空值 + for _, text := range jsonValue { + if text != "" { + return text + } + } + return "" +} + +// GetResolvedModelSystemPrompt 从请求上下文获取已解析的模型系统提示词 +func GetResolvedModelSystemPrompt(c *gin.Context) string { + systemPromptJSON := c.GetString(string(constant.ContextKeyModelSystemPrompt)) + if systemPromptJSON == "" { + return "" + } + language := c.GetString(string(constant.ContextKeyLanguage)) + if language == "" { + language = "en" + } + return resolveModelSystemPrompt(systemPromptJSON, language) +} + +// getModelSystemPromptMode 获取模型系统提示词的匹配模式 +func getModelSystemPromptMode(c *gin.Context) int { + mode := c.GetInt(string(constant.ContextKeyModelSystemPromptMode)) + return mode +} + +// SetupModelSystemPrompt 在分发阶段设置模型系统提示词上下文 +func SetupModelSystemPrompt(c *gin.Context, modelName string) { + systemPrompt, mode := model.GetModelSystemPrompt(modelName) + if systemPrompt == "" || mode == ModelSystemPromptModeDisabled { + return + } + c.Set(string(constant.ContextKeyModelSystemPrompt), systemPrompt) + c.Set(string(constant.ContextKeyModelSystemPromptMode), mode) +} + +// ApplyModelSystemPromptToOpenAI 对 OpenAI 格式请求应用模型级系统提示词 +func ApplyModelSystemPromptToOpenAI(c *gin.Context, request *dto.GeneralOpenAIRequest) { + mode := getModelSystemPromptMode(c) + if mode == ModelSystemPromptModeDisabled { + return + } + systemPrompt := GetResolvedModelSystemPrompt(c) + if systemPrompt == "" { + return + } + + systemRole := request.GetSystemRoleName() + containSystemPrompt := false + for _, message := range request.Messages { + if message.Role == systemRole { + containSystemPrompt = true + break + } + } + + if !containSystemPrompt { + // 无 system 消息时,注入新的 + systemMessage := dto.Message{ + Role: systemRole, + Content: systemPrompt, + } + request.Messages = append([]dto.Message{systemMessage}, request.Messages...) + return + } + + // 已有 system 消息,根据模式处理 + switch mode { + case ModelSystemPromptModeInject: + // 注入模式:已有则不处理 + return + case ModelSystemPromptModeOverride: + // 覆写模式:替换原有内容 + for i, message := range request.Messages { + if message.Role == systemRole { + request.Messages[i].SetStringContent(systemPrompt) + return + } + } + case ModelSystemPromptModeAppend: + // 附加模式:拼接到原有内容前面 + for i, message := range request.Messages { + if message.Role == systemRole { + if message.IsStringContent() { + existing := strings.TrimSpace(message.StringContent()) + if existing == "" { + request.Messages[i].SetStringContent(systemPrompt) + } else { + request.Messages[i].SetStringContent(systemPrompt + "\n" + existing) + } + } else { + contents := message.ParseContent() + contents = append([]dto.MediaContent{ + {Type: dto.ContentTypeText, Text: systemPrompt}, + }, contents...) + request.Messages[i].Content = contents + } + return + } + } + } +} + +// ApplyModelSystemPromptToGemini 对 Gemini 格式请求应用模型级系统提示词 +func ApplyModelSystemPromptToGemini(c *gin.Context, request *dto.GeminiChatRequest) { + mode := getModelSystemPromptMode(c) + if mode == ModelSystemPromptModeDisabled { + return + } + systemPrompt := GetResolvedModelSystemPrompt(c) + if systemPrompt == "" { + return + } + + switch mode { + case ModelSystemPromptModeInject: + if request.SystemInstructions == nil { + request.SystemInstructions = &dto.GeminiChatContent{ + Parts: []dto.GeminiPart{{Text: systemPrompt}}, + } + } + case ModelSystemPromptModeOverride: + request.SystemInstructions = &dto.GeminiChatContent{ + Parts: []dto.GeminiPart{{Text: systemPrompt}}, + } + case ModelSystemPromptModeAppend: + if request.SystemInstructions == nil || len(request.SystemInstructions.Parts) == 0 { + request.SystemInstructions = &dto.GeminiChatContent{ + Parts: []dto.GeminiPart{{Text: systemPrompt}}, + } + } else { + merged := false + for i := range request.SystemInstructions.Parts { + if request.SystemInstructions.Parts[i].Text == "" { + continue + } + request.SystemInstructions.Parts[i].Text = systemPrompt + "\n" + request.SystemInstructions.Parts[i].Text + merged = true + break + } + if !merged { + request.SystemInstructions.Parts = append([]dto.GeminiPart{{Text: systemPrompt}}, request.SystemInstructions.Parts...) + } + } + } +} + +// ApplyModelSystemPromptToClaude 对 Claude 格式请求应用模型级系统提示词 +func ApplyModelSystemPromptToClaude(c *gin.Context, request *dto.ClaudeRequest) { + mode := getModelSystemPromptMode(c) + if mode == ModelSystemPromptModeDisabled { + return + } + systemPrompt := GetResolvedModelSystemPrompt(c) + if systemPrompt == "" { + return + } + + switch mode { + case ModelSystemPromptModeInject: + // 注入模式:只在无 system 时注入 + if request.System == nil { + request.SetStringSystem(systemPrompt) + } + case ModelSystemPromptModeOverride: + // 覆写模式:始终替换 + request.SetStringSystem(systemPrompt) + case ModelSystemPromptModeAppend: + // 附加模式:拼接到原有内容前面 + if request.System == nil { + request.SetStringSystem(systemPrompt) + } else if request.IsStringSystem() { + existing := strings.TrimSpace(request.GetStringSystem()) + if existing == "" { + request.SetStringSystem(systemPrompt) + } else { + request.SetStringSystem(systemPrompt + "\n" + existing) + } + } else { + systemContents := request.ParseSystem() + newSystem := dto.ClaudeMediaMessage{Type: dto.ContentTypeText} + newSystem.SetText(systemPrompt) + if len(systemContents) == 0 { + request.System = []dto.ClaudeMediaMessage{newSystem} + } else { + request.System = append([]dto.ClaudeMediaMessage{newSystem}, systemContents...) + } + } + } +} diff --git a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx index f54d2851a147..e5f358bc52a5 100644 --- a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx +++ b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx @@ -98,6 +98,8 @@ const extendedModelFormSchema = z.object({ name_rule: z.number(), status: z.boolean(), sync_official: z.boolean(), + system_prompt: z.string(), + system_prompt_mode: z.number().min(0).max(3), price: z.string().optional(), ratio: z.string().optional(), cacheRatio: z.string().optional(), @@ -112,6 +114,13 @@ type ExtendedModelFormValues = z.infer type PricingMode = 'per-token' | 'per-request' type PricingSubMode = 'ratio' | 'price' +function getSystemPromptModeDescription(mode: number, t: ReturnType['t']): string { + if (mode === 0) return t('Disabled: system prompt will not be applied') + if (mode === 1) return t('Inject: add system prompt only when request has no system message') + if (mode === 2) return t('Override: always replace request system message') + return t('Append: prepend to existing system message') +} + type ModelMutateDrawerProps = { open: boolean onOpenChange: (open: boolean) => void @@ -237,6 +246,8 @@ export function ModelMutateDrawer({ name_rule: 0, status: true, sync_official: true, + system_prompt: '', + system_prompt_mode: 0, price: '', ratio: '', cacheRatio: '', @@ -297,6 +308,8 @@ export function ModelMutateDrawer({ name_rule: model.name_rule || 0, status: model.status === 1, sync_official: model.sync_official === 1, + system_prompt: model.system_prompt || '', + system_prompt_mode: model.system_prompt_mode || 0, price: '', ratio: '', cacheRatio: '', @@ -401,6 +414,8 @@ export function ModelMutateDrawer({ name_rule: 0, status: true, sync_official: true, + system_prompt: '', + system_prompt_mode: 0, price: '', ratio: '', cacheRatio: '', @@ -1257,6 +1272,87 @@ export function ModelMutateDrawer({ )} + {/* System Prompt Configuration */} + +

+ {t('System Prompt')} +

+ + ( + + {t('Mode')} + + + field.onChange(Number.parseInt(value)) + } + value={String(field.value)} + className='grid grid-cols-2 gap-4' + > + {[ + { value: '0', label: t('Disabled') }, + { value: '1', label: t('Inject') }, + { value: '2', label: t('Override') }, + { value: '3', label: t('Append') }, + ].map((option) => ( +
+ + +
+ ))} +
+
+ + {getSystemPromptModeDescription(field.value, t)} + + +
+ )} + /> + + ( + + {t('Content')} + + {t( + 'Supports multi-language JSON: {"en":"Hello", "zh":"你好"}. Auto-matched to user language.' + )} + + +