From 22e951b4c43ea4f096c375dae36339cd694b2926 Mon Sep 17 00:00:00 2001 From: CJACK Date: Sun, 26 Apr 2026 14:21:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=B8=8D=E5=90=8C=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.MD | 2 +- config.example.json | 3 +- docs/prompt-compatibility.md | 2 +- internal/config/codec.go | 3 +- internal/config/config.go | 3 +- internal/config/store_accessors.go | 6 ++++ internal/config/store_accessors_test.go | 5 ++++ .../httpapi/admin/handler_settings_test.go | 10 +++++++ .../admin/settings/handler_settings_parse.go | 3 ++ .../admin/settings/handler_settings_read.go | 5 +++- .../admin/settings/handler_settings_write.go | 1 + internal/httpapi/admin/shared/deps.go | 1 + .../httpapi/openai/chat/test_helpers_test.go | 2 ++ .../httpapi/openai/deps_injection_test.go | 2 ++ internal/httpapi/openai/history_split_test.go | 30 +++++++++++++++++++ internal/httpapi/openai/shared/deps.go | 1 + .../openai/shared/thinking_injection.go | 2 +- internal/promptcompat/thinking_injection.go | 21 +++++++++---- .../promptcompat/thinking_injection_test.go | 15 ++++++++++ .../src/features/settings/BehaviorSection.jsx | 17 +++++++++++ .../src/features/settings/useSettingsForm.js | 5 +++- webui/src/locales/en.json | 2 ++ webui/src/locales/zh.json | 2 ++ 23 files changed, 129 insertions(+), 14 deletions(-) diff --git a/README.MD b/README.MD index 6d404e0..329add9 100644 --- a/README.MD +++ b/README.MD @@ -284,7 +284,7 @@ go run ./cmd/ds2api - `history_split`:轮次拆分策略;默认关闭,开启后默认从第二轮开始将旧历史上传为 `HISTORY.txt`。 - `current_input_file`:独立拆分策略;默认开启且阈值为 `0`,触发时将完整上下文合并上传为隐藏上下文文件,并跳过 `HISTORY.txt`。 - `history_split` 与 `current_input_file` 互斥,最多启用一个;两者都关闭时请求直接透传。 -- `thinking_injection`:默认开启;在最新 user 消息末尾追加思考格式增强提示,提高工具调用前的思考结构稳定性。 +- `thinking_injection`:默认开启;在最新 user 消息末尾追加思考格式增强提示,提高工具调用前的思考结构稳定性;`prompt` 留空时使用内置默认提示词。 环境变量完整列表见 [部署指南](docs/DEPLOY.md),接口鉴权规则见 [API.md](API.md#鉴权规则)。 diff --git a/config.example.json b/config.example.json index ce621b3..14a25c5 100644 --- a/config.example.json +++ b/config.example.json @@ -59,7 +59,8 @@ "min_chars": 0 }, "thinking_injection": { - "enabled": true + "enabled": true, + "prompt": "" }, "embeddings": { "provider": "deterministic" diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index a4c9397..fd4a371 100644 --- a/docs/prompt-compatibility.md +++ b/docs/prompt-compatibility.md @@ -103,7 +103,7 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools` ## 5. prompt 是怎么拼出来的 -OpenAI Chat / Responses 在标准化后、history split / current input file 之前,会默认执行 `thinking_injection` 增强。它参考 DeepSeek V4 “把控制指令放在 user 消息末尾更稳定”的用法,在最新 user message 后追加 `【思维链格式要求】...`,要求模型在 `` 内按分析、构思、工具调用、XML 工具格式回顾这几个阶段组织思考。该开关默认启用,可通过 `thinking_injection.enabled=false` 关闭。 +OpenAI Chat / Responses 在标准化后、history split / current input file 之前,会默认执行 `thinking_injection` 增强。它参考 DeepSeek V4 “把控制指令放在 user 消息末尾更稳定”的用法,在最新 user message 后追加思考格式提示词,默认内容以 `【思维链格式要求】...` 开头,要求模型在 `` 内按分析、构思、工具调用、XML 工具格式回顾这几个阶段组织思考。该开关默认启用,可通过 `thinking_injection.enabled=false` 关闭;也可以通过 `thinking_injection.prompt` 自定义提示词,留空时使用内置默认提示词。 这段增强属于 prompt 可见上下文: diff --git a/internal/config/codec.go b/internal/config/codec.go index ea6d711..1cf078b 100644 --- a/internal/config/codec.go +++ b/internal/config/codec.go @@ -51,7 +51,7 @@ func (c Config) MarshalJSON() ([]byte, error) { if c.CurrentInputFile.Enabled != nil || c.CurrentInputFile.MinChars != 0 { m["current_input_file"] = c.CurrentInputFile } - if c.ThinkingInjection.Enabled != nil { + if c.ThinkingInjection.Enabled != nil || strings.TrimSpace(c.ThinkingInjection.Prompt) != "" { m["thinking_injection"] = c.ThinkingInjection } if c.VercelSyncHash != "" { @@ -177,6 +177,7 @@ func (c Config) Clone() Config { }, ThinkingInjection: ThinkingInjectionConfig{ Enabled: cloneBoolPtr(c.ThinkingInjection.Enabled), + Prompt: c.ThinkingInjection.Prompt, }, VercelSyncHash: c.VercelSyncHash, VercelSyncTime: c.VercelSyncTime, diff --git a/internal/config/config.go b/internal/config/config.go index 32a44aa..cd0ae1c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -184,5 +184,6 @@ type CurrentInputFileConfig struct { } type ThinkingInjectionConfig struct { - Enabled *bool `json:"enabled,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Prompt string `json:"prompt,omitempty"` } diff --git a/internal/config/store_accessors.go b/internal/config/store_accessors.go index 78b6549..f5b8369 100644 --- a/internal/config/store_accessors.go +++ b/internal/config/store_accessors.go @@ -208,3 +208,9 @@ func (s *Store) ThinkingInjectionEnabled() bool { } return *s.cfg.ThinkingInjection.Enabled } + +func (s *Store) ThinkingInjectionPrompt() string { + s.mu.RLock() + defer s.mu.RUnlock() + return strings.TrimSpace(s.cfg.ThinkingInjection.Prompt) +} diff --git a/internal/config/store_accessors_test.go b/internal/config/store_accessors_test.go index 950c4f9..9b88e15 100644 --- a/internal/config/store_accessors_test.go +++ b/internal/config/store_accessors_test.go @@ -83,4 +83,9 @@ func TestStoreThinkingInjectionAccessors(t *testing.T) { if store.ThinkingInjectionEnabled() { t.Fatal("expected thinking injection disabled by explicit config") } + + store.cfg.ThinkingInjection.Prompt = " custom thinking prompt " + if got := store.ThinkingInjectionPrompt(); got != "custom thinking prompt" { + t.Fatalf("thinking injection prompt=%q want custom thinking prompt", got) + } } diff --git a/internal/httpapi/admin/handler_settings_test.go b/internal/httpapi/admin/handler_settings_test.go index 5d2b1d7..9ca5ba5 100644 --- a/internal/httpapi/admin/handler_settings_test.go +++ b/internal/httpapi/admin/handler_settings_test.go @@ -75,6 +75,12 @@ func TestGetSettingsIncludesHistorySplitDefaults(t *testing.T) { if got := boolFrom(thinkingInjection["enabled"]); !got { t.Fatalf("expected thinking_injection.enabled=true, body=%v", body) } + if got, _ := thinkingInjection["prompt"].(string); got != "" { + t.Fatalf("expected empty custom thinking prompt, got %q body=%v", got, body) + } + if got, _ := thinkingInjection["default_prompt"].(string); got == "" { + t.Fatalf("expected default thinking prompt, body=%v", body) + } } func TestUpdateSettingsValidation(t *testing.T) { @@ -264,6 +270,7 @@ func TestUpdateSettingsThinkingInjection(t *testing.T) { payload := map[string]any{ "thinking_injection": map[string]any{ "enabled": false, + "prompt": " custom thinking prompt ", }, } b, _ := json.Marshal(payload) @@ -280,6 +287,9 @@ func TestUpdateSettingsThinkingInjection(t *testing.T) { if h.Store.ThinkingInjectionEnabled() { t.Fatal("expected thinking injection accessor to reflect disabled config") } + if got := h.Store.ThinkingInjectionPrompt(); got != "custom thinking prompt" { + t.Fatalf("expected custom thinking prompt, got %q", got) + } } func TestUpdateSettingsAutoDeleteMode(t *testing.T) { diff --git a/internal/httpapi/admin/settings/handler_settings_parse.go b/internal/httpapi/admin/settings/handler_settings_parse.go index 12cb726..bd26c7f 100644 --- a/internal/httpapi/admin/settings/handler_settings_parse.go +++ b/internal/httpapi/admin/settings/handler_settings_parse.go @@ -199,6 +199,9 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi b := boolFrom(v) cfg.Enabled = &b } + if v, exists := raw["prompt"]; exists { + cfg.Prompt = strings.TrimSpace(fmt.Sprintf("%v", v)) + } thinkingInjCfg = cfg } diff --git a/internal/httpapi/admin/settings/handler_settings_read.go b/internal/httpapi/admin/settings/handler_settings_read.go index 0692178..6944d3d 100644 --- a/internal/httpapi/admin/settings/handler_settings_read.go +++ b/internal/httpapi/admin/settings/handler_settings_read.go @@ -6,6 +6,7 @@ import ( authn "ds2api/internal/auth" "ds2api/internal/config" + "ds2api/internal/promptcompat" ) func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) { @@ -39,7 +40,9 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) { "min_chars": h.Store.CurrentInputFileMinChars(), }, "thinking_injection": map[string]any{ - "enabled": h.Store.ThinkingInjectionEnabled(), + "enabled": h.Store.ThinkingInjectionEnabled(), + "prompt": h.Store.ThinkingInjectionPrompt(), + "default_prompt": promptcompat.DefaultThinkingInjectionPrompt, }, "model_aliases": snap.ModelAliases, "env_backed": h.Store.IsEnvBacked(), diff --git a/internal/httpapi/admin/settings/handler_settings_write.go b/internal/httpapi/admin/settings/handler_settings_write.go index 26b72c4..1a28589 100644 --- a/internal/httpapi/admin/settings/handler_settings_write.go +++ b/internal/httpapi/admin/settings/handler_settings_write.go @@ -89,6 +89,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { } if thinkingInjCfg != nil { c.ThinkingInjection.Enabled = thinkingInjCfg.Enabled + c.ThinkingInjection.Prompt = thinkingInjCfg.Prompt } if aliasMap != nil { c.ModelAliases = aliasMap diff --git a/internal/httpapi/admin/shared/deps.go b/internal/httpapi/admin/shared/deps.go index 2f834f6..8ae8bf7 100644 --- a/internal/httpapi/admin/shared/deps.go +++ b/internal/httpapi/admin/shared/deps.go @@ -38,6 +38,7 @@ type ConfigStore interface { CurrentInputFileEnabled() bool CurrentInputFileMinChars() int ThinkingInjectionEnabled() bool + ThinkingInjectionPrompt() string CompatStripReferenceMarkers() bool AutoDeleteSessions() bool } diff --git a/internal/httpapi/openai/chat/test_helpers_test.go b/internal/httpapi/openai/chat/test_helpers_test.go index 605d9a7..e382a37 100644 --- a/internal/httpapi/openai/chat/test_helpers_test.go +++ b/internal/httpapi/openai/chat/test_helpers_test.go @@ -23,6 +23,7 @@ type mockOpenAIConfig struct { currentInputEnabled bool currentInputMin int thinkingInjection *bool + thinkingPrompt string } func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases } @@ -58,6 +59,7 @@ func (m mockOpenAIConfig) ThinkingInjectionEnabled() bool { } return *m.thinkingInjection } +func (m mockOpenAIConfig) ThinkingInjectionPrompt() string { return m.thinkingPrompt } type streamStatusAuthStub struct{} diff --git a/internal/httpapi/openai/deps_injection_test.go b/internal/httpapi/openai/deps_injection_test.go index 9aa68fa..17ee0a9 100644 --- a/internal/httpapi/openai/deps_injection_test.go +++ b/internal/httpapi/openai/deps_injection_test.go @@ -19,6 +19,7 @@ type mockOpenAIConfig struct { currentInputEnabled bool currentInputMin int thinkingInjection *bool + thinkingPrompt string } func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases } @@ -54,6 +55,7 @@ func (m mockOpenAIConfig) ThinkingInjectionEnabled() bool { } return *m.thinkingInjection } +func (m mockOpenAIConfig) ThinkingInjectionPrompt() string { return m.thinkingPrompt } func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) { cfg := mockOpenAIConfig{ diff --git a/internal/httpapi/openai/history_split_test.go b/internal/httpapi/openai/history_split_test.go index cfee319..babfd13 100644 --- a/internal/httpapi/openai/history_split_test.go +++ b/internal/httpapi/openai/history_split_test.go @@ -183,6 +183,36 @@ func TestApplyThinkingInjectionAppendsLatestUserPrompt(t *testing.T) { } } +func TestApplyThinkingInjectionUsesCustomPrompt(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &openAITestSurface{ + Store: mockOpenAIConfig{ + wideInput: true, + thinkingInjection: boolPtr(true), + thinkingPrompt: "custom thinking format", + }, + DS: ds, + } + req := map[string]any{ + "model": "deepseek-v4-flash", + "messages": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + } + stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "") + if err != nil { + t.Fatalf("normalize failed: %v", err) + } + + out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq) + if err != nil { + t.Fatalf("apply thinking injection failed: %v", err) + } + if !strings.Contains(out.FinalPrompt, "hello\n\ncustom thinking format") { + t.Fatalf("expected custom thinking injection after latest user message, got %s", out.FinalPrompt) + } +} + func TestApplyHistorySplitDirectPassThroughWhenBothSplitsDisabled(t *testing.T) { ds := &inlineUploadDSStub{} h := &openAITestSurface{ diff --git a/internal/httpapi/openai/shared/deps.go b/internal/httpapi/openai/shared/deps.go index 9983618..6315541 100644 --- a/internal/httpapi/openai/shared/deps.go +++ b/internal/httpapi/openai/shared/deps.go @@ -48,6 +48,7 @@ type ConfigReader interface { CurrentInputFileEnabled() bool CurrentInputFileMinChars() int ThinkingInjectionEnabled() bool + ThinkingInjectionPrompt() string } type Deps struct { diff --git a/internal/httpapi/openai/shared/thinking_injection.go b/internal/httpapi/openai/shared/thinking_injection.go index 4cd3811..13cb7b4 100644 --- a/internal/httpapi/openai/shared/thinking_injection.go +++ b/internal/httpapi/openai/shared/thinking_injection.go @@ -6,7 +6,7 @@ func ApplyThinkingInjection(store ConfigReader, stdReq promptcompat.StandardRequ if store == nil || !store.ThinkingInjectionEnabled() || !stdReq.Thinking { return stdReq } - messages, changed := promptcompat.AppendThinkingInjectionToLatestUser(stdReq.Messages) + messages, changed := promptcompat.AppendThinkingInjectionPromptToLatestUser(stdReq.Messages, store.ThinkingInjectionPrompt()) if !changed { return stdReq } diff --git a/internal/promptcompat/thinking_injection.go b/internal/promptcompat/thinking_injection.go index 65d8353..573faa7 100644 --- a/internal/promptcompat/thinking_injection.go +++ b/internal/promptcompat/thinking_injection.go @@ -12,9 +12,17 @@ const ( ) func AppendThinkingInjectionToLatestUser(messages []any) ([]any, bool) { + return AppendThinkingInjectionPromptToLatestUser(messages, "") +} + +func AppendThinkingInjectionPromptToLatestUser(messages []any, injectionPrompt string) ([]any, bool) { if len(messages) == 0 { return messages, false } + injectionPrompt = strings.TrimSpace(injectionPrompt) + if injectionPrompt == "" { + injectionPrompt = DefaultThinkingInjectionPrompt + } for i := len(messages) - 1; i >= 0; i-- { msg, ok := messages[i].(map[string]any) if !ok { @@ -24,10 +32,11 @@ func AppendThinkingInjectionToLatestUser(messages []any) ([]any, bool) { continue } content := msg["content"] - if strings.Contains(NormalizeOpenAIContentForPrompt(content), ThinkingInjectionMarker) { + normalizedContent := NormalizeOpenAIContentForPrompt(content) + if strings.Contains(normalizedContent, ThinkingInjectionMarker) || strings.Contains(normalizedContent, injectionPrompt) { return messages, false } - updatedContent := appendThinkingInjectionToContent(content) + updatedContent := appendThinkingInjectionToContent(content, injectionPrompt) out := append([]any(nil), messages...) cloned := make(map[string]any, len(msg)) for k, v := range msg { @@ -40,20 +49,20 @@ func AppendThinkingInjectionToLatestUser(messages []any) ([]any, bool) { return messages, false } -func appendThinkingInjectionToContent(content any) any { +func appendThinkingInjectionToContent(content any, injectionPrompt string) any { switch x := content.(type) { case string: - return appendTextBlock(x, DefaultThinkingInjectionPrompt) + return appendTextBlock(x, injectionPrompt) case []any: out := append([]any(nil), x...) out = append(out, map[string]any{ "type": "text", - "text": DefaultThinkingInjectionPrompt, + "text": injectionPrompt, }) return out default: text := NormalizeOpenAIContentForPrompt(content) - return appendTextBlock(text, DefaultThinkingInjectionPrompt) + return appendTextBlock(text, injectionPrompt) } } diff --git a/internal/promptcompat/thinking_injection_test.go b/internal/promptcompat/thinking_injection_test.go index 66cc956..542dbe8 100644 --- a/internal/promptcompat/thinking_injection_test.go +++ b/internal/promptcompat/thinking_injection_test.go @@ -51,6 +51,21 @@ func TestAppendThinkingInjectionToLatestUserArrayContent(t *testing.T) { } } +func TestAppendThinkingInjectionToLatestUserCustomPrompt(t *testing.T) { + messages := []any{ + map[string]any{"role": "user", "content": "latest"}, + } + + out, changed := AppendThinkingInjectionPromptToLatestUser(messages, "custom thinking format") + if !changed { + t.Fatal("expected custom thinking injection to be appended") + } + content, _ := out[0].(map[string]any)["content"].(string) + if !strings.Contains(content, "latest\n\ncustom thinking format") { + t.Fatalf("expected custom injection after latest user text, got %q", content) + } +} + func TestAppendThinkingInjectionToLatestUserSkipsDuplicate(t *testing.T) { messages := []any{ map[string]any{"role": "user", "content": "latest\n\n" + DefaultThinkingInjectionPrompt}, diff --git a/webui/src/features/settings/BehaviorSection.jsx b/webui/src/features/settings/BehaviorSection.jsx index f4a848c..6b907af 100644 --- a/webui/src/features/settings/BehaviorSection.jsx +++ b/webui/src/features/settings/BehaviorSection.jsx @@ -46,6 +46,23 @@ export default function BehaviorSection({ t, form, setForm }) { {t('settings.thinkingInjectionDesc')} +