luckfun233 commited on
Commit
fcdd4aa
·
1 Parent(s): fdd5251

feat(prompt): 新增 prompt_style 风格档位与自定义模型列表

Browse files

- 新增 prompt_style:v2(缺省)/ legacy。legacy 把衔接指令换回 ce5a019 的
短文案,且不注入内置执行契约(自己配置的思考提示词仍然生效),用于恢复
旧版「几乎不加约束」的手感;未知值回落 v2,后台 PUT 传错返回 400
- 新增 model_profiles 自定义模型列表(缺省空,最多 50 条):id + base +
name/remark + 该模型专属 prompt;模型解析优先于内置别名映射,
/v1/models 一并列出(owned_by=ds2api)
- 模型提示词与全局 prompt_profile 互斥(命中优先,否则回落),与技能层合成
单个 System 段;注入点仍在历史拆分之后,不动 Messages
- 新增 GET/PUT /admin/model_profiles(整体替换语义 + 字符集/基模/上限校验)
- /admin/settings 增加 prompt_style 与 model_profiles 摘要
- 后台新增「自定义模型」板块与「内置提示词风格」下拉,中英文案同步
- 同步 prompt-compatibility.md / prompt-design.md / API(.*).md /
README.en.md / config.example.json

Files changed (38) hide show
  1. API.en.md +93 -0
  2. API.md +94 -1
  3. README.en.md +2 -0
  4. config.example.json +3 -0
  5. docs/prompt-compatibility.md +42 -3
  6. docs/prompt-design.md +42 -3
  7. internal/auth/request.go +15 -0
  8. internal/config/codec.go +26 -0
  9. internal/config/config.go +38 -7
  10. internal/config/model_profile.go +207 -0
  11. internal/config/model_profile_test.go +222 -0
  12. internal/config/models.go +59 -0
  13. internal/config/store_accessors.go +35 -0
  14. internal/httpapi/admin/handler.go +3 -0
  15. internal/httpapi/admin/handler_settings_prompt_style_test.go +87 -0
  16. internal/httpapi/admin/modelprofiles/deps.go +16 -0
  17. internal/httpapi/admin/modelprofiles/handler_model_profiles.go +67 -0
  18. internal/httpapi/admin/modelprofiles/handler_model_profiles_test.go +140 -0
  19. internal/httpapi/admin/modelprofiles/routes.go +8 -0
  20. internal/httpapi/admin/settings/handler_settings_parse.go +12 -0
  21. internal/httpapi/admin/settings/handler_settings_read.go +15 -0
  22. internal/httpapi/admin/settings/handler_settings_write.go +10 -0
  23. internal/httpapi/admin/shared/deps.go +3 -0
  24. internal/httpapi/openai/history/current_input_file.go +68 -24
  25. internal/httpapi/openai/history/current_input_file_overlay_test.go +182 -0
  26. internal/httpapi/openai/history/current_input_file_prompt_test.go +26 -5
  27. internal/httpapi/openai/shared/models.go +1 -1
  28. internal/promptcompat/prompt_texts.go +28 -3
  29. internal/promptcompat/prompt_texts_test.go +3 -3
  30. internal/promptcompat/thinking_injection.go +22 -10
  31. internal/promptcompat/thinking_injection_test.go +41 -7
  32. webui/src/features/settings/BehaviorSection.jsx +23 -0
  33. webui/src/features/settings/ModelProfilesSection.jsx +275 -0
  34. webui/src/features/settings/SettingsContainer.jsx +3 -0
  35. webui/src/features/settings/settingsApi.js +22 -0
  36. webui/src/features/settings/useSettingsForm.js +9 -0
  37. webui/src/locales/en.json +29 -0
  38. webui/src/locales/zh.json +29 -0
API.en.md CHANGED
@@ -867,6 +867,53 @@ Every `api_keys` entry may carry `prompt_profile` to pick a preset or body for t
867
 
868
  When the global `prompt_profile.enabled` is `false`, none of the above applies to any key.
869
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
870
  ### `api_keys[].skills` (per-key skill injection)
871
 
872
  Every `api_keys` entry may carry `skills` to choose which skills that single API key injects. It sits next to `thinking_injection`; the legacy `keys` form does not support the field.
@@ -909,6 +956,8 @@ Reads runtime settings and status, including:
909
  - `current_input_file` (`enabled` defaults to `true`, plus `min_chars`)
910
  - `thinking_injection` (`enabled` defaults to `true`, plus `prompt` and `default_prompt`); an empty `prompt` uses the built-in default, and a single key can override it through `api_keys[].thinking_injection`
911
  - `prompt_profile` (`enabled` defaults to `false`, `enabled_default` always `false`, plus `name`, `prompt` and `presets`); `presets` is the built-in `[{name, body}]` list, so the panel can show the body a blank input falls back to
 
 
912
  - `model_aliases`
913
  - `tools` (`enabled`: master switch for tool calling, defaults to `false`; `default_enabled`: always `false`, used by the panel to show the default)
914
  - `env_backed`, `needs_vercel_sync`
@@ -926,6 +975,7 @@ Hot-updates runtime settings. Supported fields:
926
  - `current_input_file.enabled` / `current_input_file.min_chars`
927
  - `thinking_injection.enabled` / `thinking_injection.prompt`
928
  - `prompt_profile.enabled` / `prompt_profile.name` / `prompt_profile.prompt`
 
929
  - `tools.enabled`
930
  - `model_aliases`
931
  - `toolcall` policy is fixed and is no longer writable through settings
@@ -938,6 +988,14 @@ Hot-updates runtime settings. Supported fields:
938
 
939
  For the injection position, layering order and preset bodies see [docs/prompt-compatibility.md](docs/prompt-compatibility.md) and [docs/prompt-design.md](docs/prompt-design.md).
940
 
 
 
 
 
 
 
 
 
941
  `tools.enabled` only changes the field you actually send; the existing semantics of every other setting stay the same:
942
 
943
  ```json
@@ -1017,6 +1075,41 @@ A plain-text skill library, experimental and off by default (`skills.enabled=fal
1017
 
1018
  Validation runs on write and is independent of `enabled`, so you can author and fix the content with the switch still off, then turn it on. Over-limit bodies, duplicate ids, and invalid ids are never silently fixed.
1019
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1020
  ### `POST /admin/config/import`
1021
 
1022
  Imports full config with:
 
867
 
868
  When the global `prompt_profile.enabled` is `false`, none of the above applies to any key.
869
 
870
+ ### `prompt_style` (built-in prompt style, global)
871
+
872
+ A top-level string. It only switches the built-in texts; anything you configure yourself is unaffected.
873
+
874
+ ```json
875
+ {"prompt_style": "legacy"}
876
+ ```
877
+
878
+ | Value | Behaviour |
879
+ | --- | --- |
880
+ | `v2` (default, empty string also means this) | After a history split the continuation prompt carries an "execution requirements" block (honour earlier requirements item by item) and the built-in execution contract (thinking injection) is injected |
881
+ | `legacy` | Continuation goes back to the short pre-`ce5a019` wording and the built-in contract is **not** injected; a `thinking_injection.prompt` you configured explicitly is still injected |
882
+
883
+ An unknown value never breaks config loading: it falls back to `v2` with a warning. `PUT /admin/settings` rejects unknown values with 400.
884
+
885
+ ### `model_profiles` (custom model list, global)
886
+
887
+ Expose several public model names on top of the same flash base model, each carrying its own prompt. Whichever name the client sends decides the prompt; entries never affect each other.
888
+
889
+ ```json
890
+ {
891
+ "model_profiles": [
892
+ {
893
+ "id": "deepseek-writer",
894
+ "base": "deepseek-v4-flash",
895
+ "name": "Writing",
896
+ "remark": "Long-form and rewrites",
897
+ "prompt": "## Writing rules\n…"
898
+ }
899
+ ]
900
+ }
901
+ ```
902
+
903
+ | Field | Notes |
904
+ | --- | --- |
905
+ | `id` | Required public model name. Charset `[A-Za-z0-9._-]`, 1-64 characters, must not collide with a built-in model name (e.g. `deepseek-v4-flash`), unique across entries (case-insensitive) |
906
+ | `base` | Required base model: `deepseek-v4-flash` / `-search` / `-nothinking` / `-search-nothinking` |
907
+ | `name` / `remark` | Optional, only for readability in the panel and in exports |
908
+ | `prompt` | Optional prompt that applies to this model only, max 20000 characters; empty adds no extra spec |
909
+
910
+ Resolution and injection:
911
+
912
+ - Model resolution prefers custom entries over built-in aliases: a matching `id` calls upstream with its `base` (`/v1/models`, `/v1/models/{id}`, `/v1/chat/completions`, Claude and Gemini entries all share the same resolver).
913
+ - `/v1/models` lists the custom models too, with `owned_by` = `ds2api`.
914
+ - The prompt is injected at the same point as `prompt_profile` (after history splitting, before the skill layer) and the two are **mutually exclusive**: a non-empty model `prompt` wins, otherwise the global `prompt_profile` applies.
915
+ - At most 50 entries. See [`GET` / `PUT /admin/model_profiles`](#get--put-adminmodel_profiles).
916
+
917
  ### `api_keys[].skills` (per-key skill injection)
918
 
919
  Every `api_keys` entry may carry `skills` to choose which skills that single API key injects. It sits next to `thinking_injection`; the legacy `keys` form does not support the field.
 
956
  - `current_input_file` (`enabled` defaults to `true`, plus `min_chars`)
957
  - `thinking_injection` (`enabled` defaults to `true`, plus `prompt` and `default_prompt`); an empty `prompt` uses the built-in default, and a single key can override it through `api_keys[].thinking_injection`
958
  - `prompt_profile` (`enabled` defaults to `false`, `enabled_default` always `false`, plus `name`, `prompt` and `presets`); `presets` is the built-in `[{name, body}]` list, so the panel can show the body a blank input falls back to
959
+ - `prompt_style` (`mode`: effective style `v2` / `legacy`; `modes`: allowed values; `default`: `v2`; `value_raw`: the value stored in config)
960
+ - `model_profiles` (`count`, `max_count`, `base_models`, `max_id_chars`, `max_prompt_chars`); the entries themselves are managed through `/admin/model_profiles`
961
  - `model_aliases`
962
  - `tools` (`enabled`: master switch for tool calling, defaults to `false`; `default_enabled`: always `false`, used by the panel to show the default)
963
  - `env_backed`, `needs_vercel_sync`
 
975
  - `current_input_file.enabled` / `current_input_file.min_chars`
976
  - `thinking_injection.enabled` / `thinking_injection.prompt`
977
  - `prompt_profile.enabled` / `prompt_profile.name` / `prompt_profile.prompt`
978
+ - `prompt_style`
979
  - `tools.enabled`
980
  - `model_aliases`
981
  - `toolcall` policy is fixed and is no longer writable through settings
 
988
 
989
  For the injection position, layering order and preset bodies see [docs/prompt-compatibility.md](docs/prompt-compatibility.md) and [docs/prompt-design.md](docs/prompt-design.md).
990
 
991
+ `prompt_style` is a top-level string (not an object); it accepts `v2` / `legacy` / an empty string (empty means back to v2):
992
+
993
+ ```json
994
+ {"prompt_style": "legacy"}
995
+ ```
996
+
997
+ The custom model list does not go through settings — use `/admin/model_profiles` below, which replaces the whole list.
998
+
999
  `tools.enabled` only changes the field you actually send; the existing semantics of every other setting stay the same:
1000
 
1001
  ```json
 
1075
 
1076
  Validation runs on write and is independent of `enabled`, so you can author and fix the content with the switch still off, then turn it on. Over-limit bodies, duplicate ids, and invalid ids are never silently fixed.
1077
 
1078
+ ### `GET` / `PUT /admin/model_profiles`
1079
+
1080
+ The custom model list (public name + base model + per-model prompt). Like the skill library it is a standalone endpoint with whole-list replacement semantics: the `items` you send become the final list, and an empty array clears it.
1081
+
1082
+ **GET response**:
1083
+
1084
+ ```json
1085
+ {
1086
+ "items": [
1087
+ {"id": "deepseek-writer", "base": "deepseek-v4-flash", "name": "Writing", "remark": "long-form", "prompt": "## Writing rules\n…"}
1088
+ ],
1089
+ "base_models": ["deepseek-v4-flash", "deepseek-v4-flash-search", "deepseek-v4-flash-nothinking", "deepseek-v4-flash-search-nothinking"],
1090
+ "max_count": 50,
1091
+ "max_id_chars": 64,
1092
+ "max_prompt_chars": 20000
1093
+ }
1094
+ ```
1095
+
1096
+ **PUT request**:
1097
+
1098
+ ```json
1099
+ {"items": [{"id": "deepseek-writer", "base": "deepseek-v4-flash", "prompt": "…"}]}
1100
+ ```
1101
+
1102
+ **Validation** (any failure returns `400` with `{"detail": "..."}`):
1103
+
1104
+ | Field | Rule |
1105
+ | --- | --- |
1106
+ | `items` | At most 50 entries |
1107
+ | `id` | Required, unique (case-insensitive), charset `[A-Za-z0-9._-]`, 1-64 characters, must not shadow a built-in model name |
1108
+ | `base` | Required, one of the four `deepseek-v4-flash` names |
1109
+ | `prompt` | Optional, ≤ 20000 characters |
1110
+
1111
+ Saving takes effect immediately: `/v1/models` and model-name resolution pick up the new entries without a restart.
1112
+
1113
  ### `POST /admin/config/import`
1114
 
1115
  Imports full config with:
API.md CHANGED
@@ -872,6 +872,53 @@ data: {"type":"message_stop"}
872
 
873
  全局 `prompt_profile.enabled=false` 时上表全部不生效,任何 Key 都不注入。
874
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
875
  ### `api_keys[].skills`(单 Key 覆盖技能注入)
876
 
877
  `api_keys` 的每一项都可以带 `skills`,用来指定这个 API Key 注入哪些技能,与 `thinking_injection` 并列;`keys` 旧格式不支持该字段。
@@ -908,12 +955,14 @@ data: {"type":"message_stop"}
908
  - `admin`(`has_password_hash`、`jwt_expire_hours`、`jwt_valid_after_unix`、`default_password_warning`)
909
  - `runtime`(`account_max_inflight`、`account_max_queue`、`global_max_inflight`、`token_refresh_interval_hours`)
910
  - `runtime.token_refresh_interval_hours` 的语义是「会话体检间隔」:到期只做一次无密码复核(`GET /api/v0/users/current`),只有服务端明确拒绝会话时才回退到密码登录
911
- - `config_snapshot`:原始配置快照(`admin` / `runtime` / `responses` / `embeddings` / `auto_delete` / `current_input_file` / `thinking_injection` / `prompt_profile` / `model_aliases`),用于区分环境变量覆盖前的配置
912
  - `responses` / `embeddings`
913
  - `auto_delete`(`mode`:`none` / `single` / `all`;旧配置 `sessions=true` 仍按 `all` 处理)
914
  - `current_input_file`(`enabled` 默认返回 `true`、`min_chars`)
915
  - `thinking_injection`(`enabled` 缺省为 `true`、`prompt`、`default_prompt`);`prompt` 为空时使用内置默认提示词,单 Key 可用 `api_keys[].thinking_injection` 覆盖
916
  - `prompt_profile`(`enabled` 缺省 `false`、`enabled_default` 固定 `false`、`name`、`prompt`、`presets`);`presets` 是 `[{name, body}]` 的内置预设清单,`body` 为该预设正文,供面板在输入框里显示「留空时使用的默认正文」
 
 
917
  - `model_aliases`
918
  - `tools`(`enabled`:工具调用总开关,缺省 `false`;`default_enabled`:固定 `false`,供面板显示默认值)
919
  - `env_backed`、`needs_vercel_sync`
@@ -931,6 +980,7 @@ data: {"type":"message_stop"}
931
  - `current_input_file.enabled` / `current_input_file.min_chars`
932
  - `thinking_injection.enabled` / `thinking_injection.prompt`
933
  - `prompt_profile.enabled` / `prompt_profile.name` / `prompt_profile.prompt`
 
934
  - `tools.enabled`
935
  - `model_aliases`
936
  - `toolcall` 策略已固定,不再作为可写入字段
@@ -943,6 +993,14 @@ data: {"type":"message_stop"}
943
 
944
  注入位置、拼接顺序与预设正文见 [docs/prompt-compatibility.md](docs/prompt-compatibility.md) 与 [docs/prompt-design.md](docs/prompt-design.md)。
945
 
 
 
 
 
 
 
 
 
946
  `tools.enabled` 只改请求体里传了的字段,其它设置的现有语义不变:
947
 
948
  ```json
@@ -1022,6 +1080,41 @@ data: {"type":"message_stop"}
1022
 
1023
  校验在写入时执行,与 `enabled` 无关:先关着开关把内容配好、改好,再打开即可。超限、重复 `id`、非法 `id` 都不会被静默修正。
1024
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1025
  ### `POST /admin/config/import`
1026
 
1027
  导入完整配置,支持:
 
872
 
873
  全局 `prompt_profile.enabled=false` 时上表全部不生效,任何 Key 都不注入。
874
 
875
+ ### `prompt_style`(内置提示词风格,全局)
876
+
877
+ 顶层字符串字段,只影响内置文案,不影响你自己配置的提示词:
878
+
879
+ ```json
880
+ {"prompt_style": "legacy"}
881
+ ```
882
+
883
+ | 值 | 行为 |
884
+ | --- | --- |
885
+ | `v2`(缺省,也接受空串) | 历史拆分后的衔接指令带「执行要求」(上文要求逐条满足)并注入内置执行契约(思考注入) |
886
+ | `legacy` | 衔接指令换回 ce5a019 时代的短文案,且**不注入内置执行契约**;显式配置过 `thinking_injection.prompt` 时仍然注入自定义内容 |
887
+
888
+ 未知值不会让配置加载失败:按 `v2` 处理并打一条告警。后台 `PUT /admin/settings` 传未知值直接返回 400。
889
+
890
+ ### `model_profiles`(自定义模型列表,全局)
891
+
892
+ 给同一个 flash 基模挂出多个对外模型名,每个名字带自己的提示词。客户端请求里写哪个模型名,就用哪一份提示词,互不影响。
893
+
894
+ ```json
895
+ {
896
+ "model_profiles": [
897
+ {
898
+ "id": "deepseek-writer",
899
+ "base": "deepseek-v4-flash",
900
+ "name": "写作",
901
+ "remark": "长文与改稿",
902
+ "prompt": "## 写作要求\n…"
903
+ }
904
+ ]
905
+ }
906
+ ```
907
+
908
+ | 字段 | 说明 |
909
+ | --- | --- |
910
+ | `id` | 必填,对外模型名。字符集 `[A-Za-z0-9._-]`,长度 1-64,不可与内置模型名(如 `deepseek-v4-flash`)冲突,条目间不可重复(大小写不敏感) |
911
+ | `base` | 必填,基模,只能是 `deepseek-v4-flash` / `-search` / `-nothinking` / `-search-nothinking` |
912
+ | `name` / `remark` | 可选,仅用于后台与导出可读性 |
913
+ | `prompt` | 可选,该模型专属提示词,上限 20000 字符;为空表示不额外注入 |
914
+
915
+ 解析与注入语义:
916
+
917
+ - 模型名解析优先于内置别名映射:`id` 命中自定义模型时按它的 `base` 调用上游(`/v1/models`、`/v1/models/{id}`、`/v1/chat/completions`、Claude / Gemini 入口共用同一套解析)。
918
+ - `/v1/models` 会把自定义模型一起列出来,`owned_by` 为 `ds2api`。
919
+ - 提示词注入点与 `prompt_profile` 相同(历史拆分之后、技能层之前),两者**互斥**:该模型配了非空 `prompt` 就用它,否则回落到全局 `prompt_profile`。
920
+ - 数量上限 50 条。接口见 [`GET` / `PUT /admin/model_profiles`](#get--put-adminmodel_profiles)。
921
+
922
  ### `api_keys[].skills`(单 Key 覆盖技能注入)
923
 
924
  `api_keys` 的每一项都可以带 `skills`,用来指定这个 API Key 注入哪些技能,与 `thinking_injection` 并列;`keys` 旧格式不支持该字段。
 
955
  - `admin`(`has_password_hash`、`jwt_expire_hours`、`jwt_valid_after_unix`、`default_password_warning`)
956
  - `runtime`(`account_max_inflight`、`account_max_queue`、`global_max_inflight`、`token_refresh_interval_hours`)
957
  - `runtime.token_refresh_interval_hours` 的语义是「会话体检间隔」:到期只做一次无密码复核(`GET /api/v0/users/current`),只有服务端明确拒绝会话时才回退到密码登录
958
+ - `config_snapshot`:原始配置快照(`admin` / `runtime` / `responses` / `embeddings` / `auto_delete` / `current_input_file` / `thinking_injection` / `prompt_profile` / `prompt_style` / `model_profiles` / `model_aliases`),用于区分环境变量覆盖前的配置
959
  - `responses` / `embeddings`
960
  - `auto_delete`(`mode`:`none` / `single` / `all`;旧配置 `sessions=true` 仍按 `all` 处理)
961
  - `current_input_file`(`enabled` 默认返回 `true`、`min_chars`)
962
  - `thinking_injection`(`enabled` 缺省为 `true`、`prompt`、`default_prompt`);`prompt` 为空时使用内置默认提示词,单 Key 可用 `api_keys[].thinking_injection` 覆盖
963
  - `prompt_profile`(`enabled` 缺省 `false`、`enabled_default` 固定 `false`、`name`、`prompt`、`presets`);`presets` 是 `[{name, body}]` 的内置预设清单,`body` 为该预设正文,供面板在输入框里显示「留空时使用的默认正文」
964
+ - `prompt_style`(`mode`:生效风格 `v2` / `legacy`;`modes`:可选值;`default`:`v2`;`value_raw`:配置里原样存的值)
965
+ - `model_profiles`(`count`、`max_count`、`base_models`、`max_id_chars`、`max_prompt_chars`);条目本身用 `/admin/model_profiles` 读写
966
  - `model_aliases`
967
  - `tools`(`enabled`:工具调用总开关,缺省 `false`;`default_enabled`:固定 `false`,供面板显示默认值)
968
  - `env_backed`、`needs_vercel_sync`
 
980
  - `current_input_file.enabled` / `current_input_file.min_chars`
981
  - `thinking_injection.enabled` / `thinking_injection.prompt`
982
  - `prompt_profile.enabled` / `prompt_profile.name` / `prompt_profile.prompt`
983
+ - `prompt_style`
984
  - `tools.enabled`
985
  - `model_aliases`
986
  - `toolcall` 策略已固定,不再作为可写入字段
 
993
 
994
  注入位置、拼接顺序与预设正文见 [docs/prompt-compatibility.md](docs/prompt-compatibility.md) 与 [docs/prompt-design.md](docs/prompt-design.md)。
995
 
996
+ `prompt_style` 是顶层字符串(不是对象),只接受 `v2` / `legacy` / 空串(空串等于回到 v2):
997
+
998
+ ```json
999
+ {"prompt_style": "legacy"}
1000
+ ```
1001
+
1002
+ 自定义模型列表不走 settings,用下面的 `/admin/model_profiles` 整体替换。
1003
+
1004
  `tools.enabled` 只改请求体里传了的字段,其它设置的现有语义不变:
1005
 
1006
  ```json
 
1080
 
1081
  校验在写入时执行,与 `enabled` 无关:先关着开关把内容配好、改好,再打开即可。超限、重复 `id`、非法 `id` 都不会被静默修正。
1082
 
1083
+ ### `GET` / `PUT /admin/model_profiles`
1084
+
1085
+ 自定义模型列表(模型名 + 基模 + 该模型专属提示词)。与技能库同样是独立接口、整体替换语义:PUT 请求里的 `items` 就是最终列表,传空数组表示清空。
1086
+
1087
+ **GET 响应**:
1088
+
1089
+ ```json
1090
+ {
1091
+ "items": [
1092
+ {"id": "deepseek-writer", "base": "deepseek-v4-flash", "name": "写作", "remark": "长文", "prompt": "## 写作要求\n…"}
1093
+ ],
1094
+ "base_models": ["deepseek-v4-flash", "deepseek-v4-flash-search", "deepseek-v4-flash-nothinking", "deepseek-v4-flash-search-nothinking"],
1095
+ "max_count": 50,
1096
+ "max_id_chars": 64,
1097
+ "max_prompt_chars": 20000
1098
+ }
1099
+ ```
1100
+
1101
+ **PUT 请求**:
1102
+
1103
+ ```json
1104
+ {"items": [{"id": "deepseek-writer", "base": "deepseek-v4-flash", "prompt": "…"}]}
1105
+ ```
1106
+
1107
+ **校验规则**(任一条不满足返回 `400`,响应体为 `{"detail": "..."}`):
1108
+
1109
+ | 字段 | 规则 |
1110
+ | --- | --- |
1111
+ | `items` | 最多 50 条 |
1112
+ | `id` | 必填、唯一(大小写不敏感),字符集 `[A-Za-z0-9._-]`,长度 1-64,不可与内置模型名冲突 |
1113
+ | `base` | 必填,只能是 `deepseek-v4-flash` 系列的四个名字之一 |
1114
+ | `prompt` | 可空;≤ 20000 字符 |
1115
+
1116
+ 保存后立即生效:`/v1/models` 列表与模型名解析都会包含新条目,无需重启。
1117
+
1118
  ### `POST /admin/config/import`
1119
 
1120
  导入完整配置,支持:
README.en.md CHANGED
@@ -339,6 +339,8 @@ Common fields:
339
  - If you turn off `current_input_file`, requests pass through directly without uploading any split context file.
340
  - `thinking_injection`: global thinking-format injection (the "execution contract"); `enabled` defaults to `true` and an empty `prompt` uses the built-in default. A single API key can override it with `api_keys[].thinking_injection` (`custom` / `enabled` / `prompt`), and `custom=false` fully follows the global setting. When a request triggers history splitting, the contract is re-applied to the visible prompt so it never gets buried in the uploaded transcript.
341
  - `prompt_profile`: prompt-profile layer, **off by default** (`enabled: false`). It prepends one server-side spec block (`name`: `default` / `writing`, or a custom `prompt` up to 20000 characters) to the final prompt, merged with the skill block into a single `System:` section. Presets defer to the user/client persona when they conflict. A single key can override it with `api_keys[].prompt_profile` (`custom` / `enabled` / `name` / `prompt`).
 
 
342
  - `tools.enabled`: master switch for tool calling, **off by default** (a missing field is treated as `false`). While it is off, requests carrying `tools` are handled as plain chat: no tool instructions are injected and no `tool_calls` are parsed or returned. Turn it on only if you accept the risk — DSML tool output can trigger upstream risk control and get the DeepSeek account banned. Safer alternatives: the plain-text skill library below, or a per-key custom injection prompt.
343
  - `skills`: experimental plain-text skill library, **off by default** (`enabled: false`). Skill bodies are concatenated into the prompt as a leading system block, so the model needs no tool call at all. `max_chars` (default `8000`) truncates by character and appends a truncation marker; `0` means unlimited. At most 50 items, each with a unique `id` from `[a-z0-9_-]` and a required `body`. A single key can narrow the set with `api_keys[].skills` (`custom` / `enabled` / `ids`). Manage it through `GET/PUT /admin/skills` or `config.json`.
344
 
 
339
  - If you turn off `current_input_file`, requests pass through directly without uploading any split context file.
340
  - `thinking_injection`: global thinking-format injection (the "execution contract"); `enabled` defaults to `true` and an empty `prompt` uses the built-in default. A single API key can override it with `api_keys[].thinking_injection` (`custom` / `enabled` / `prompt`), and `custom=false` fully follows the global setting. When a request triggers history splitting, the contract is re-applied to the visible prompt so it never gets buried in the uploaded transcript.
341
  - `prompt_profile`: prompt-profile layer, **off by default** (`enabled: false`). It prepends one server-side spec block (`name`: `default` / `writing`, or a custom `prompt` up to 20000 characters) to the final prompt, merged with the skill block into a single `System:` section. Presets defer to the user/client persona when they conflict. A single key can override it with `api_keys[].prompt_profile` (`custom` / `enabled` / `name` / `prompt`).
342
+ - `prompt_style`: built-in prompt style, `v2` (default) or `legacy`. `v2` keeps the requirement-carrying continuation prompt and injects the built-in execution contract; `legacy` restores the short pre-`ce5a019` continuation wording and injects no built-in contract (a `thinking_injection.prompt` you set yourself still applies). Switch to `legacy` when replies feel too short or too customer-service-like.
343
+ - `model_profiles`: custom model list (empty by default), at most 50 entries. Each entry maps a public model name (`id`) to a flash-family `base` plus an optional per-model `prompt` (max 20000 characters). Custom names take precedence over built-in aliases, show up in `/v1/models`, and their prompt replaces — never stacks with — `prompt_profile`. Managed through `GET/PUT /admin/model_profiles`.
344
  - `tools.enabled`: master switch for tool calling, **off by default** (a missing field is treated as `false`). While it is off, requests carrying `tools` are handled as plain chat: no tool instructions are injected and no `tool_calls` are parsed or returned. Turn it on only if you accept the risk — DSML tool output can trigger upstream risk control and get the DeepSeek account banned. Safer alternatives: the plain-text skill library below, or a per-key custom injection prompt.
345
  - `skills`: experimental plain-text skill library, **off by default** (`enabled: false`). Skill bodies are concatenated into the prompt as a leading system block, so the model needs no tool call at all. `max_chars` (default `8000`) truncates by character and appends a truncation marker; `0` means unlimited. At most 50 items, each with a unique `id` from `[a-z0-9_-]` and a required `body`. A single key can narrow the set with `api_keys[].skills` (`custom` / `enabled` / `ids`). Manage it through `GET/PUT /admin/skills` or `config.json`.
346
 
config.example.json CHANGED
@@ -65,6 +65,9 @@
65
  "name": "default",
66
  "prompt": ""
67
  },
 
 
 
68
  "tools": {
69
  "enabled": false
70
  },
 
65
  "name": "default",
66
  "prompt": ""
67
  },
68
+ "prompt_style": "v2",
69
+ "_prompt_style_comment": "内置提示词风格:v2(缺省,衔接指令带执行要求并注入执行契约)/ legacy(旧版短文案,不注入执行契约)",
70
+ "model_profiles": [],
71
  "tools": {
72
  "enabled": false
73
  },
docs/prompt-compatibility.md CHANGED
@@ -32,8 +32,9 @@ StandardRequest(messages、final_prompt、thinking、search、tool 策略、re
32
  │ tools.enabled=false 时不注入工具说明(也不上传工具说明文件),不解析 tool_calls
33
  │ 只有本轮真的发生了拆分,才把执行契约重新贴回可见 prompt(见第 4 节)
34
  ▼
35
- prompt 级叠加(提示词特化层 → 技能层)
36
- │ prompt_profile(缺省关闭)与 skills(缺省关闭)合并成一个 System 段前置到 prompt 最前面
 
37
  │ 只改 prompt / PromptTokenText,不改 messages
38
  ▼
39
  组装 DeepSeek completion payload
@@ -237,10 +238,38 @@ DeepSeek web-chat 只接受一段 prompt 字符串和一组文件引用,没有
237
 
238
  ### System 段拼接(ApplySystemOverlays)
239
 
240
- `promptcompat.ApplySystemOverlays` 是 prompt 级叠加的唯一入口:多个非空片段用空行连接、共用**一个** `System: ` 前缀,顺序固定为 **特化层 → 技能层**(外层通用规范在前,更贴近当前任务的技能在后)。单片段时的输出与旧版技能注入逐字节一致。
241
 
242
  只改 `FinalPrompt` 与 `PromptTokenText`,不动 `Messages`,所以特化层与技能正文都不会出现在对话记录里。
243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  ### 技能注入(skills)
245
 
246
  技能是一段服务端配置的纯文本 markdown,直接拼进 prompt。模型全程不需要输出工具调用,看到的只是普通上下文,因此不会碰到 DSML 风控。
@@ -378,6 +407,14 @@ Kelivo 这类客户端把界面上的「思维强度」翻译成 `reasoning_effo
378
 
379
  现状说明:Kelivo 对「自定义 OpenAI 兼容服务」不会发送原生搜索标记,所以要用联网请选 `-search` 模型;一旦客户端开始发送上述显式信号,服务端会自动跟随,不需要改配置。
380
 
 
 
 
 
 
 
 
 
381
  ---
382
 
383
  ## 7. 协议适配器边界
@@ -429,6 +466,8 @@ Kelivo 这类客户端把界面上的「思维强度」翻译成 `reasoning_effo
429
  | 修复:历史拆分后执行契约被埋在附件里 | 拆分发生时把契约重贴到可见 `FinalPrompt`(只改 prompt,不改 `Messages` / `PromptTokenText`)。关闭思考注入或未发生拆分时行为不变 |
430
  | 新增 `prompt_profile` 提示词特化层(缺省关闭) | 默认不注入,现有请求逐字节不变;开启后只改 `FinalPrompt` / `PromptTokenText`,`Messages` 与对话记录不受影响 |
431
  | 技能注入改为走 `ApplySystemOverlays` | 单片段输出与旧实现逐字节一致;只有与特化层同时开启时才合并成一个 `System:` 段 |
 
 
432
 
433
  ---
434
 
 
32
  │ tools.enabled=false 时不注入工具说明(也不上传工具说明文件),不解析 tool_calls
33
  │ 只有本轮真的发生了拆分,才把执行契约重新贴回可见 prompt(见第 4 节)
34
  ▼
35
+ prompt 级叠加(模型提示词 / 特化层 → 技能层)
36
+ │ 自定义模型带提示词时用它,否则用 prompt_profile(两者互斥,都缺省关闭)
37
+ │ skills(缺省关闭)在内层;全部合并成一个 System 段前置到 prompt 最前面
38
  │ 只改 prompt / PromptTokenText,不改 messages
39
  ▼
40
  组装 DeepSeek completion payload
 
238
 
239
  ### System 段拼接(ApplySystemOverlays)
240
 
241
+ `promptcompat.ApplySystemOverlays` 是 prompt 级叠加的唯一入口:多个非空片段用空行连接、共用**一个** `System: ` 前缀,顺序固定为 **模型提示词 / 特化层(二选一)→ 技能层**(外层通用规范在前,更贴近当前任务的技能在后)。单片段时的输出与旧版技能注入逐字节一致。
242
 
243
  只改 `FinalPrompt` 与 `PromptTokenText`,不动 `Messages`,所以特化层与技能正文都不会出现在对话记录里。
244
 
245
+ ### 提示词风格(prompt_style)
246
+
247
+ 顶层字段,只切换**内置文案**,不改你自己配置的提示词:
248
+
249
+ | 值 | 衔接指令(第 3 节) | 内置执行契约(第 4 节) |
250
+ | --- | --- | --- |
251
+ | `v2`(缺省) | 开头变体 + 「执行要求」 | 注入 |
252
+ | `legacy` | ce5a019 时代的短文案 | **不注入**;若显式配置了 `thinking_injection.prompt`,照样注入该自定义内容 |
253
+
254
+ 两点需要知道:
255
+
256
+ - `legacy` 的短衔接指令保留了防泄漏要求,只把那三句原文里偏弱的第二句统一成「不要提及文件、上下文或提示词本身」——防泄漏不属于风格约束,不该因为切旧版而放松。
257
+ - 对话记录文件的格式(标题 `# 对话记录`、`[role=...]` 行)不随风格变化。与旧版的实际差距主要来自「有没有执行契约」和「衔接指令有多长」。
258
+
259
+ ### 模型专属提示词(model_profiles)
260
+
261
+ 自定义模型条目把「对外模型名 → 基模 + 提示词」绑在一起,适合把不同风格并排放给客户端选,而不是反复改全局开关。
262
+
263
+ | 项目 | 行为 |
264
+ | --- | --- |
265
+ | 配置 | 顶层 `model_profiles`,数组,最多 50 条 |
266
+ | 解析优先 | 自定义模型名优先于内置别名映射;命中后按其 `base` 调上游 |
267
+ | 列表 | `/v1/models` 一并列出,`owned_by=ds2api`;Claude / Gemini 的模型列表接口不列 |
268
+ | 注入点 | 与 `prompt_profile` 相同(拆分之后、技能之前) |
269
+ | 与特化层关系 | **互斥**:该模型 `prompt` 非空就用它,否则回落到全局 `prompt_profile` |
270
+ | 生效范围 | 只对该模型名生效;客户端换回 `deepseek-v4-flash` 就等于关掉它 |
271
+ | 管理接口 | `GET/PUT /admin/model_profiles`,整体替换语义 |
272
+
273
  ### 技能注入(skills)
274
 
275
  技能是一段服务端配置的纯文本 markdown,直接拼进 prompt。模型全程不需要输出工具调用,看到的只是普通上下文,因此不会碰到 DSML 风控。
 
407
 
408
  现状说明:Kelivo 对「自定义 OpenAI 兼容服务」不会发送原生搜索标记,所以要用联网请选 `-search` 模型;一旦客户端开始发送上述显式信号,服务端会自动跟随,不需要改配置。
409
 
410
+ ### 自定义模型(model_profiles)
411
+
412
+ `model_profiles` 里的模型名与内置模型走同一套解析:`config.ResolveModel` 先查自定义条目,再退回内置模型与别名映射。因此:
413
+
414
+ - `-nothinking` / `-search` 这类后缀语义仍然只作用于**基模**:`base` 直接写完整名字(如 `deepseek-v4-flash-search-nothinking`),自定义模型名本身不再二次解释后缀。
415
+ - 思考与联网的默认值同样按解析后的基模决定,因此「某个自定义模型强制关思考」的做法是给它选 `-nothinking` 基模,而不是在条目上加开关。
416
+ - `/v1/models` 会列出全部内置模型 + 自定义模型;`/anthropic/v1/models` 与 Ollama `/api/tags` 仍只列内置模型。
417
+
418
  ---
419
 
420
  ## 7. 协议适配器边界
 
466
  | 修复:历史拆分后执行契约被埋在附件里 | 拆分发生时把契约重贴到可见 `FinalPrompt`(只改 prompt,不改 `Messages` / `PromptTokenText`)。关闭思考注入或未发生拆分时行为不变 |
467
  | 新增 `prompt_profile` 提示词特化层(缺省关闭) | 默认不注入,现有请求逐字节不变;开启后只改 `FinalPrompt` / `PromptTokenText`,`Messages` 与对话记录不受影响 |
468
  | 技能注入改为走 `ApplySystemOverlays` | 单片段输出与旧实现逐字节一致;只有与特化层同时开启时才合并成一个 `System:` 段 |
469
+ | 新增 `prompt_style`(`v2` 缺省 / `legacy`) | 缺省仍是 `v2`,现有请求不变;切 `legacy` 时衔接指令回到短文案且不注入内置执行契约(自定义思考提示词仍生效)。未知值回落 `v2`,不会让配置加载失败 |
470
+ | 新增 `model_profiles` 自定义模型列表(缺省空) | 空列表时行为与之前完全一致;条目只影响请求里出现该模型名的调用,其他请求不受影响。自定义模型名优先于内置别名映射 |
471
 
472
  ---
473
 
docs/prompt-design.md CHANGED
@@ -43,11 +43,26 @@ L4 技能层 前置 System 段:<skill> 正文块 BuildSki
43
 
44
  | 层 | 生效方式 | 缺省 | 改哪里 |
45
  | --- | --- | --- | --- |
46
- | L1 | 永远生效(历史拆分触发时) | 开 | `prompt_texts.go` 的 `currentInputContinuationOpenings` / `currentInputContinuationRequirements` |
47
- | L2 | `thinking_injection.enabled` | 开 | `thinking_injection.go` 的 `DefaultThinkingInjectionPrompt` |
48
- | L3 | `prompt_profile.enabled` | 关 | `prompt_texts.go` 的 `promptProfileDefaultBody` / `promptProfileWritingBody` |
49
  | L4 | `skills.enabled` | 关 | 后台「技能库」或配置文件 `skills.items` |
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  ---
52
 
53
  ## 3. 各层文案
@@ -215,6 +230,29 @@ L4 技能层 前置 System 段:<skill> 正文块 BuildSki
215
 
216
  差别:`preset` 是全局开关、作用于所有请求;`skills` 条目可被单个 API Key 挑选(`api_keys[].skills.ids`),适合「只有某个项目要长文规范」的场景。
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  ---
219
 
220
  ## 7. 调优建议
@@ -227,6 +265,7 @@ L4 技能层 前置 System 段:<skill> 正文块 BuildSki
227
  4. **会不会被当成输出格式?** 凡是要模型「先列清单/先核对」的,都要补一句「内部完成、不要输出」。
228
  5. **会不会盖掉用户设定?** 任何前置 System 段都要声明与服务端规范冲突时以用户为准。
229
  6. **会不会泄漏?** 不要出现「附件」「文件」「上下文记录」「提示词」这类可以在回复里被复述的名词,除非紧跟着禁止提及。
 
230
 
231
  参考的外部实践(写法上的依据,不是直接抄):
232
 
 
43
 
44
  | 层 | 生效方式 | 缺省 | 改哪里 |
45
  | --- | --- | --- | --- |
46
+ | L1 | 永远生效(历史拆分触发时) | 开 | `prompt_texts.go` 的 `currentInputContinuationOpenings` / `currentInputContinuationRequirements`(旧版见 `legacyContinuationOpenings`) |
47
+ | L2 | `thinking_injection.enabled` × 风格 | 开 | `thinking_injection.go` 的 `DefaultThinkingInjectionPrompt`、`ResolveThinkingInjectionPrompt` |
48
+ | L3 | `prompt_profile.enabled`,或自定义模型的 `prompt` | 关 | `prompt_texts.go` 的 `promptProfileDefaultBody` / `promptProfileWritingBody` |
49
  | L4 | `skills.enabled` | 关 | 后台「技能库」或配置文件 `skills.items` |
50
 
51
+ 另有一个**风格档位** `prompt_style`(`v2` 缺省 / `legacy`),它不改层次结构,只决定 L1 用哪版文案、以及是否注入 L2 的内置契约:
52
+
53
+ | 风格 | L1 | L2 内置契约 |
54
+ | --- | --- | --- |
55
+ | `v2`(缺省) | 开头变体 + 「执行要求」 | 注入 |
56
+ | `legacy` | 下面这段短文案 | **不注入**(你自己配的 `thinking_injection.prompt` 仍会注入) |
57
+
58
+ ```text
59
+ 已附上下文文件「ctx-xxxxxxxxxxxx.txt」。请直接回答最新问题,不要提及文件、上下文或提示词本身。
60
+ ```
61
+
62
+ 为什么保留旧版:`v2` 的约束对「做事」有效,但对闲聊会压掉模型的默认表达。实践反馈是——切到 `v2` 后回答变短、更像客服;旧版因为几乎不加约束,回答更长更放得开。两者都留着,由用户按场景选,而不是替他决定。
63
+
64
+ ---
65
+
66
  ---
67
 
68
  ## 3. 各层文案
 
230
 
231
  差别:`preset` 是全局开关、作用于所有请求;`skills` 条目可被单个 API Key 挑选(`api_keys[].skills.ids`),适合「只有某个项目要长文规范」的场景。
232
 
233
+ ### 用法 C:自定义模型(把多套风格并排放给客户端)
234
+
235
+ 给同一个 flash 基模挂多个对外模型名,每个名字带自己的提示词。客户端在模型列表里就能直接选风格,不用来回改全局配置,回滚也只是换回原模型名。
236
+
237
+ ```json
238
+ {
239
+ "model_profiles": [
240
+ {"id": "deepseek-writer", "base": "deepseek-v4-flash", "name": "写作", "prompt": "<writing 预设正文>"},
241
+ {"id": "deepseek-chat", "base": "deepseek-v4-flash", "name": "日常对话", "prompt": ""}
242
+ ]
243
+ }
244
+ ```
245
+
246
+ 规则:模型命中且 `prompt` 非空时,**不会**再叠加全局 `prompt_profile`(两者互斥);`prompt` 留空则回落到全局特化层。基模带 `-nothinking` / `-search` 后缀时,思考与联网默认值跟着基模走。后台在「自定义模型」板块维护,接口是 `GET/PUT /admin/model_profiles`。
247
+
248
+ 三种用法怎么选:
249
+
250
+ | 需求 | 用哪个 |
251
+ | --- | --- |
252
+ | 所有请求统一换一套规范 | 用法 A(全局 preset) |
253
+ | 只有某个项目/某个 Key 要额外规范 | 用法 B(skills 按 Key 挑) |
254
+ | 想让客户端自己选风格,或要能一键退回原样 | 用法 C(自定义模型) |
255
+
256
  ---
257
 
258
  ## 7. 调优建议
 
265
  4. **会不会被当成输出格式?** 凡是要模型「先列清单/先核对」的,都要补一句「内部完成、不要输出」。
266
  5. **会不会盖掉用户设定?** 任何前置 System 段都要声明与服务端规范冲突时以用户为准。
267
  6. **会不会泄漏?** 不要出现「附件」「文件」「上下文记录」「提示词」这类可以在回复里被复述的名词,除非紧跟着禁止提及。
268
+ 7. **要不要更放得开?** 如果你觉得回答太短、太像客服,不是文案该继续加约束,而是把 `prompt_style` 切到 `legacy`:它去掉执行契约、缩短衔接指令,让模型回到自己的默认风格。反过来,只有明确要「上文要求逐条兑现」时才留在 `v2`。
269
 
270
  参考的外部实践(写法上的依据,不是直接抄):
271
 
internal/auth/request.go CHANGED
@@ -45,6 +45,9 @@ type RequestAuth struct {
45
  // PromptProfile 是本次请求生效的「提示词特化层」设置:全局设置叠加该 API Key
46
  // 的自定义覆盖后得到的最终值。
47
  PromptProfile config.PromptProfileSettings
 
 
 
48
  // Skills 是本次请求生效的「纯文本技能库」设置:全局设置叠加该 API Key
49
  // 的自定义覆盖后得到的最终值(Items 已按 Key 过滤)。
50
  Skills config.SkillSettings
@@ -95,6 +98,7 @@ func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) {
95
  callerLabel := r.callerLabel(callerKey)
96
  injection := r.resolveInjection(callerKey)
97
  promptProfile := r.resolvePromptProfile(callerKey)
 
98
  skills := r.resolveSkills(callerKey)
99
  toolsEnabled := r.toolsEnabled()
100
  ctx := req.Context()
@@ -106,6 +110,7 @@ func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) {
106
  CallerLabel: callerLabel,
107
  Injection: injection,
108
  PromptProfile: promptProfile,
 
109
  Skills: skills,
110
  ToolsEnabled: toolsEnabled,
111
  resolver: r,
@@ -120,6 +125,7 @@ func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) {
120
  a.CallerLabel = callerLabel
121
  a.Injection = injection
122
  a.PromptProfile = promptProfile
 
123
  a.Skills = skills
124
  a.ToolsEnabled = toolsEnabled
125
  return a, nil
@@ -159,6 +165,14 @@ func (r *Resolver) resolvePromptProfile(callerKey string) config.PromptProfileSe
159
  return r.Store.ResolvePromptProfile(callerKey)
160
  }
161
 
 
 
 
 
 
 
 
 
162
  // resolveSkills 解析全局 + 单 Key 自定义后的技能设置。
163
  func (r *Resolver) resolveSkills(callerKey string) config.SkillSettings {
164
  if r == nil || r.Store == nil {
@@ -230,6 +244,7 @@ func (r *Resolver) DetermineCaller(req *http.Request) (*RequestAuth, error) {
230
  CallerLabel: r.callerLabel(callerKey),
231
  Injection: r.resolveInjection(callerKey),
232
  PromptProfile: r.resolvePromptProfile(callerKey),
 
233
  Skills: r.resolveSkills(callerKey),
234
  ToolsEnabled: r.toolsEnabled(),
235
  resolver: r,
 
45
  // PromptProfile 是本次请求生效的「提示词特化层」设置:全局设置叠加该 API Key
46
  // 的自定义覆盖后得到的最终值。
47
  PromptProfile config.PromptProfileSettings
48
+ // PromptStyle 是本次请求生效的内置提示词风格:v2(缺省)或 legacy。
49
+ // 只有全局开关,没有单 Key 覆盖。
50
+ PromptStyle string
51
  // Skills 是本次请求生效的「纯文本技能库」设置:全局设置叠加该 API Key
52
  // 的自定义覆盖后得到的最终值(Items 已按 Key 过滤)。
53
  Skills config.SkillSettings
 
98
  callerLabel := r.callerLabel(callerKey)
99
  injection := r.resolveInjection(callerKey)
100
  promptProfile := r.resolvePromptProfile(callerKey)
101
+ promptStyle := r.promptStyle()
102
  skills := r.resolveSkills(callerKey)
103
  toolsEnabled := r.toolsEnabled()
104
  ctx := req.Context()
 
110
  CallerLabel: callerLabel,
111
  Injection: injection,
112
  PromptProfile: promptProfile,
113
+ PromptStyle: promptStyle,
114
  Skills: skills,
115
  ToolsEnabled: toolsEnabled,
116
  resolver: r,
 
125
  a.CallerLabel = callerLabel
126
  a.Injection = injection
127
  a.PromptProfile = promptProfile
128
+ a.PromptStyle = promptStyle
129
  a.Skills = skills
130
  a.ToolsEnabled = toolsEnabled
131
  return a, nil
 
165
  return r.Store.ResolvePromptProfile(callerKey)
166
  }
167
 
168
+ // promptStyle 返回全局生效的内置提示词风格;未配置时按 v2 处理。
169
+ func (r *Resolver) promptStyle() string {
170
+ if r == nil || r.Store == nil {
171
+ return config.PromptStyleV2
172
+ }
173
+ return r.Store.PromptStyleMode()
174
+ }
175
+
176
  // resolveSkills 解析全局 + 单 Key 自定义后的技能设置。
177
  func (r *Resolver) resolveSkills(callerKey string) config.SkillSettings {
178
  if r == nil || r.Store == nil {
 
244
  CallerLabel: r.callerLabel(callerKey),
245
  Injection: r.resolveInjection(callerKey),
246
  PromptProfile: r.resolvePromptProfile(callerKey),
247
+ PromptStyle: r.promptStyle(),
248
  Skills: r.resolveSkills(callerKey),
249
  ToolsEnabled: r.toolsEnabled(),
250
  resolver: r,
internal/config/codec.go CHANGED
@@ -54,6 +54,12 @@ func (c Config) MarshalJSON() ([]byte, error) {
54
  if c.PromptProfile.Enabled != nil || strings.TrimSpace(c.PromptProfile.Name) != "" || strings.TrimSpace(c.PromptProfile.Prompt) != "" {
55
  m["prompt_profile"] = c.PromptProfile
56
  }
 
 
 
 
 
 
57
  if c.Skills.Enabled != nil || c.Skills.MaxChars != nil || len(c.Skills.Items) > 0 {
58
  m["skills"] = c.Skills
59
  }
@@ -151,6 +157,14 @@ func (c *Config) UnmarshalJSON(b []byte) error {
151
  if err := json.Unmarshal(v, &c.PromptProfile); err != nil {
152
  return fmt.Errorf("invalid field %q: %w", k, err)
153
  }
 
 
 
 
 
 
 
 
154
  case "skills":
155
  if err := json.Unmarshal(v, &c.Skills); err != nil {
156
  return fmt.Errorf("invalid field %q: %w", k, err)
@@ -210,6 +224,8 @@ func (c Config) Clone() Config {
210
  Prompt: c.ThinkingInjection.Prompt,
211
  },
212
  PromptProfile: c.PromptProfile.Clone(),
 
 
213
  Skills: c.Skills.Clone(),
214
  Tools: c.Tools.Clone(),
215
  Vercel: c.Vercel,
@@ -224,6 +240,16 @@ func (c Config) Clone() Config {
224
  return clone
225
  }
226
 
 
 
 
 
 
 
 
 
 
 
227
  func cloneAPIKeys(in []APIKey) []APIKey {
228
  if len(in) == 0 {
229
  return nil
 
54
  if c.PromptProfile.Enabled != nil || strings.TrimSpace(c.PromptProfile.Name) != "" || strings.TrimSpace(c.PromptProfile.Prompt) != "" {
55
  m["prompt_profile"] = c.PromptProfile
56
  }
57
+ if strings.TrimSpace(c.PromptStyle) != "" {
58
+ m["prompt_style"] = c.PromptStyle
59
+ }
60
+ if len(c.ModelProfiles) > 0 {
61
+ m["model_profiles"] = c.ModelProfiles
62
+ }
63
  if c.Skills.Enabled != nil || c.Skills.MaxChars != nil || len(c.Skills.Items) > 0 {
64
  m["skills"] = c.Skills
65
  }
 
157
  if err := json.Unmarshal(v, &c.PromptProfile); err != nil {
158
  return fmt.Errorf("invalid field %q: %w", k, err)
159
  }
160
+ case "prompt_style":
161
+ if err := json.Unmarshal(v, &c.PromptStyle); err != nil {
162
+ return fmt.Errorf("invalid field %q: %w", k, err)
163
+ }
164
+ case "model_profiles":
165
+ if err := json.Unmarshal(v, &c.ModelProfiles); err != nil {
166
+ return fmt.Errorf("invalid field %q: %w", k, err)
167
+ }
168
  case "skills":
169
  if err := json.Unmarshal(v, &c.Skills); err != nil {
170
  return fmt.Errorf("invalid field %q: %w", k, err)
 
224
  Prompt: c.ThinkingInjection.Prompt,
225
  },
226
  PromptProfile: c.PromptProfile.Clone(),
227
+ PromptStyle: c.PromptStyle,
228
+ ModelProfiles: cloneModelProfiles(c.ModelProfiles),
229
  Skills: c.Skills.Clone(),
230
  Tools: c.Tools.Clone(),
231
  Vercel: c.Vercel,
 
240
  return clone
241
  }
242
 
243
+ // cloneModelProfiles 深拷贝自定义模型列表(字段均为值类型,复制切片即可)。
244
+ func cloneModelProfiles(in []ModelProfileConfig) []ModelProfileConfig {
245
+ if len(in) == 0 {
246
+ return nil
247
+ }
248
+ out := make([]ModelProfileConfig, len(in))
249
+ copy(out, in)
250
+ return out
251
+ }
252
+
253
  func cloneAPIKeys(in []APIKey) []APIKey {
254
  if len(in) == 0 {
255
  return nil
internal/config/config.go CHANGED
@@ -23,13 +23,17 @@ type Config struct {
23
  FileAudit FileAuditConfig `json:"file_audit,omitempty"`
24
  ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
25
  PromptProfile PromptProfileConfig `json:"prompt_profile,omitempty"`
26
- Skills SkillsConfig `json:"skills,omitempty"`
27
- Tools ToolsConfig `json:"tools,omitempty"`
28
- Vercel VercelConfig `json:"vercel,omitempty"`
29
- Platform PlatformConfig `json:"platform,omitempty"`
30
- VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
31
- VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
32
- AdditionalFields map[string]any `json:"-"`
 
 
 
 
33
  }
34
 
35
  type Account struct {
@@ -392,6 +396,33 @@ func (c *Config) NormalizeCredentials() {
392
  Logger.Warn("[config] unknown prompt_profile.name, falling back to default",
393
  "name", name, "allowed", strings.Join(PromptProfileNames(), ", "))
394
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  }
396
 
397
  // DropInvalidAccounts removes accounts that cannot be addressed by admin APIs
 
23
  FileAudit FileAuditConfig `json:"file_audit,omitempty"`
24
  ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
25
  PromptProfile PromptProfileConfig `json:"prompt_profile,omitempty"`
26
+ // PromptStyle 是内置提示词风格档位:v2(缺省)/ legacy。
27
+ PromptStyle string `json:"prompt_style,omitempty"`
28
+ // ModelProfiles 是自定义模型列表:对外模型名 + 基模 + 该模型专属提示词。
29
+ ModelProfiles []ModelProfileConfig `json:"model_profiles,omitempty"`
30
+ Skills SkillsConfig `json:"skills,omitempty"`
31
+ Tools ToolsConfig `json:"tools,omitempty"`
32
+ Vercel VercelConfig `json:"vercel,omitempty"`
33
+ Platform PlatformConfig `json:"platform,omitempty"`
34
+ VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
35
+ VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
36
+ AdditionalFields map[string]any `json:"-"`
37
  }
38
 
39
  type Account struct {
 
396
  Logger.Warn("[config] unknown prompt_profile.name, falling back to default",
397
  "name", name, "allowed", strings.Join(PromptProfileNames(), ", "))
398
  }
399
+ c.normalizePromptStyle()
400
+ c.ModelProfiles = NormalizeModelProfiles(c.ModelProfiles)
401
+ }
402
+
403
+ // normalizePromptStyle 归一化提示词风格档位;写错时回落 v2 并提示一次。
404
+ func (c *Config) normalizePromptStyle() {
405
+ if c == nil {
406
+ return
407
+ }
408
+ raw := strings.TrimSpace(c.PromptStyle)
409
+ if raw == "" {
410
+ c.PromptStyle = ""
411
+ return
412
+ }
413
+ if !IsKnownPromptStyle(raw) && Logger != nil {
414
+ Logger.Warn("[config] unknown prompt_style, falling back to v2",
415
+ "value", raw, "allowed", strings.Join(PromptStyleNames(), ", "))
416
+ }
417
+ c.PromptStyle = NormalizePromptStyle(raw)
418
+ }
419
+
420
+ // PromptStyleMode 返回生效的提示词风格;未配置时按 v2 处理。
421
+ func (c *Config) PromptStyleMode() string {
422
+ if c == nil {
423
+ return PromptStyleV2
424
+ }
425
+ return NormalizePromptStyle(c.PromptStyle)
426
  }
427
 
428
  // DropInvalidAccounts removes accounts that cannot be addressed by admin APIs
internal/config/model_profile.go ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+ "unicode/utf8"
7
+ )
8
+
9
+ // 内置提示词风格档位。
10
+ //
11
+ // 「风格」只决定两件事:历史拆分后的衔接指令用哪一版文案、以及是否注入内置执行契约。
12
+ // 它不涉及特化层(prompt_profile)与技能层,那些仍然是独立开关。
13
+ const (
14
+ // PromptStyleV2 是当前版本:衔接指令带「执行要求」,并注入内置执行契约。
15
+ PromptStyleV2 = "v2"
16
+ // PromptStyleLegacy 是旧版:衔接指令用短文案,且不注入内置执行契约
17
+ // (调用方显式配置过 thinking_injection.prompt 时仍然注入)。
18
+ PromptStyleLegacy = "legacy"
19
+ )
20
+
21
+ // PromptStyleNames 返回可选风格名,顺序固定,供后台下拉与文档对照。
22
+ func PromptStyleNames() []string {
23
+ return []string{PromptStyleV2, PromptStyleLegacy}
24
+ }
25
+
26
+ // NormalizePromptStyle 归一化风格名:空或未知一律回落 v2。
27
+ //
28
+ // 配置文件里写错不会让加载失败(与 model_type 未知回落 default 一致),
29
+ // 只有后台 PUT 会显式报 400。
30
+ func NormalizePromptStyle(style string) string {
31
+ switch strings.ToLower(strings.TrimSpace(style)) {
32
+ case PromptStyleLegacy:
33
+ return PromptStyleLegacy
34
+ default:
35
+ return PromptStyleV2
36
+ }
37
+ }
38
+
39
+ // IsKnownPromptStyle 判断是否为可选风格名。
40
+ func IsKnownPromptStyle(style string) bool {
41
+ switch strings.ToLower(strings.TrimSpace(style)) {
42
+ case PromptStyleV2, PromptStyleLegacy:
43
+ return true
44
+ default:
45
+ return false
46
+ }
47
+ }
48
+
49
+ // 自定义模型条目(model_profiles)的限制。
50
+ const (
51
+ // MaxModelProfiles 是自定义模型条目的数量上限。
52
+ MaxModelProfiles = 50
53
+ // MaxModelProfileIDChars 是自定义模型名的字符上限。
54
+ MaxModelProfileIDChars = 64
55
+ )
56
+
57
+ // ModelProfileConfig 是一条自定义模型:对外名称 + 基模 + 该模型专属提示词。
58
+ //
59
+ // 用途:同一个 flash 基模可以挂出多个「模型名」,每个名字带自己的提示词,
60
+ // 客户端按模型名选用,互不影响,比改全局开关更好回滚。
61
+ type ModelProfileConfig struct {
62
+ // ID 是对外模型名,客户端在请求里写的就是它。
63
+ ID string `json:"id"`
64
+ // Base 是实际调用上游的基模,必须是当前支持的 flash 系列之一。
65
+ Base string `json:"base"`
66
+ // Name 是展示名(可选),仅用于后台与 /v1/models 的可读性。
67
+ Name string `json:"name,omitempty"`
68
+ // Remark 是备注(可选)。
69
+ Remark string `json:"remark,omitempty"`
70
+ // Prompt 是该模型专属的提示词正文;为空表示不额外注入(回落到全局 prompt_profile)。
71
+ Prompt string `json:"prompt,omitempty"`
72
+ }
73
+
74
+ // ModelProfileReader 由配置中心实现,供模型解析与列表接口读取自定义模型。
75
+ type ModelProfileReader interface {
76
+ ModelProfiles() []ModelProfileConfig
77
+ }
78
+
79
+ // SupportedBaseModels 返回可作为基模的模型名。
80
+ //
81
+ // 只列 flash 系列:pro / vision 已经并进 flash,作为基模没有意义。
82
+ func SupportedBaseModels() []string {
83
+ return []string{
84
+ "deepseek-v4-flash",
85
+ "deepseek-v4-flash-search",
86
+ "deepseek-v4-flash-nothinking",
87
+ "deepseek-v4-flash-search-nothinking",
88
+ }
89
+ }
90
+
91
+ // Normalize 归一化单条自定义模型:裁剪空白、基模转小写。
92
+ func (m ModelProfileConfig) Normalize() ModelProfileConfig {
93
+ return ModelProfileConfig{
94
+ ID: strings.TrimSpace(m.ID),
95
+ Base: strings.ToLower(strings.TrimSpace(m.Base)),
96
+ Name: strings.TrimSpace(m.Name),
97
+ Remark: strings.TrimSpace(m.Remark),
98
+ Prompt: TruncatePromptProfileText(m.Prompt),
99
+ }
100
+ }
101
+
102
+ // Clone 深拷贝(字段全是值类型,等价于 Normalize)。
103
+ func (m ModelProfileConfig) Clone() ModelProfileConfig {
104
+ return m.Normalize()
105
+ }
106
+
107
+ // IsValidModelProfileID 判断模型名是否符合字符集要求:字母、数字、点、下划线、短横线。
108
+ func IsValidModelProfileID(id string) bool {
109
+ id = strings.TrimSpace(id)
110
+ if id == "" || utf8.RuneCountInString(id) > MaxModelProfileIDChars {
111
+ return false
112
+ }
113
+ for _, r := range id {
114
+ switch {
115
+ case r >= 'a' && r <= 'z':
116
+ case r >= 'A' && r <= 'Z':
117
+ case r >= '0' && r <= '9':
118
+ case r == '.', r == '_', r == '-':
119
+ default:
120
+ return false
121
+ }
122
+ }
123
+ return true
124
+ }
125
+
126
+ // FindModelProfile 按模型名查找自定义模型,大小写不敏感。
127
+ func FindModelProfile(profiles []ModelProfileConfig, id string) (ModelProfileConfig, bool) {
128
+ wanted := strings.ToLower(strings.TrimSpace(id))
129
+ if wanted == "" {
130
+ return ModelProfileConfig{}, false
131
+ }
132
+ for _, profile := range profiles {
133
+ if strings.ToLower(strings.TrimSpace(profile.ID)) == wanted {
134
+ return profile, true
135
+ }
136
+ }
137
+ return ModelProfileConfig{}, false
138
+ }
139
+
140
+ // NormalizeModelProfiles 归一化自定义模型列表:裁剪、去重(按模型名,先到先得)。
141
+ func NormalizeModelProfiles(items []ModelProfileConfig) []ModelProfileConfig {
142
+ if len(items) == 0 {
143
+ return nil
144
+ }
145
+ out := make([]ModelProfileConfig, 0, len(items))
146
+ seen := make(map[string]struct{}, len(items))
147
+ for _, item := range items {
148
+ normalized := item.Normalize()
149
+ if normalized.ID == "" {
150
+ continue
151
+ }
152
+ key := strings.ToLower(normalized.ID)
153
+ if _, ok := seen[key]; ok {
154
+ continue
155
+ }
156
+ seen[key] = struct{}{}
157
+ out = append(out, normalized)
158
+ }
159
+ if len(out) == 0 {
160
+ return nil
161
+ }
162
+ return out
163
+ }
164
+
165
+ // ValidateModelProfiles 严格校验自定义模型列表,供后台写入使用。
166
+ func ValidateModelProfiles(items []ModelProfileConfig) error {
167
+ if len(items) > MaxModelProfiles {
168
+ return fmt.Errorf("model_profiles cannot exceed %d entries", MaxModelProfiles)
169
+ }
170
+ baseModels := make(map[string]struct{}, 4)
171
+ for _, base := range SupportedBaseModels() {
172
+ baseModels[base] = struct{}{}
173
+ }
174
+ seen := make(map[string]struct{}, len(items))
175
+ for i, raw := range items {
176
+ // 长度上限要拿原始值判断:Normalize 会先截断,截断后再比永远不超限。
177
+ if utf8.RuneCountInString(strings.TrimSpace(raw.Prompt)) > MaxPromptProfileChars {
178
+ return fmt.Errorf("model_profiles[%d].prompt cannot exceed %d characters",
179
+ i, MaxPromptProfileChars)
180
+ }
181
+ item := raw.Normalize()
182
+ if item.ID == "" {
183
+ return fmt.Errorf("model_profiles[%d].id cannot be empty", i)
184
+ }
185
+ if !IsValidModelProfileID(item.ID) {
186
+ return fmt.Errorf("model_profiles[%d].id %q must be 1-%d characters of [A-Za-z0-9._-]",
187
+ i, item.ID, MaxModelProfileIDChars)
188
+ }
189
+ key := strings.ToLower(item.ID)
190
+ if _, ok := seen[key]; ok {
191
+ return fmt.Errorf("duplicate model_profiles id: %s", item.ID)
192
+ }
193
+ seen[key] = struct{}{}
194
+ // 不允许覆盖内置模型名:这会静默改变 flash 系列的行为。
195
+ if _, builtin := resolveSupportedModel(item.ID); builtin {
196
+ return fmt.Errorf("model_profiles id %q shadows a built-in model name", item.ID)
197
+ }
198
+ if item.Base == "" {
199
+ return fmt.Errorf("model_profiles[%d].base cannot be empty", i)
200
+ }
201
+ if _, ok := baseModels[item.Base]; !ok {
202
+ return fmt.Errorf("model_profiles[%d].base must be one of %s",
203
+ i, strings.Join(SupportedBaseModels(), ", "))
204
+ }
205
+ }
206
+ return nil
207
+ }
internal/config/model_profile_test.go ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "encoding/json"
5
+ "strings"
6
+ "testing"
7
+ )
8
+
9
+ func TestPromptStyleDefaultsToV2(t *testing.T) {
10
+ var cfg Config
11
+ if got := cfg.PromptStyleMode(); got != PromptStyleV2 {
12
+ t.Fatalf("prompt style=%q want %q", got, PromptStyleV2)
13
+ }
14
+ store := &Store{cfg: Config{}}
15
+ if got := store.PromptStyleMode(); got != PromptStyleV2 {
16
+ t.Fatalf("store prompt style=%q want %q", got, PromptStyleV2)
17
+ }
18
+ cfg.PromptStyle = "legacy"
19
+ if got := cfg.PromptStyleMode(); got != PromptStyleLegacy {
20
+ t.Fatalf("prompt style=%q want legacy", got)
21
+ }
22
+ // 未知风格不报错,回落 v2(与 model_type 未知回落 default 一致)。
23
+ cfg.PromptStyle = "v3"
24
+ if got := cfg.PromptStyleMode(); got != PromptStyleV2 {
25
+ t.Fatalf("unknown style must fall back to v2, got %q", got)
26
+ }
27
+ }
28
+
29
+ func TestNormalizeCredentialsKeepsKnownPromptStyle(t *testing.T) {
30
+ cfg := Config{PromptStyle: " LEGACY "}
31
+ cfg.NormalizeCredentials()
32
+ if cfg.PromptStyle != PromptStyleLegacy {
33
+ t.Fatalf("expected legacy style normalized to lower case, got %q", cfg.PromptStyle)
34
+ }
35
+ }
36
+
37
+ func TestModelProfilesRoundTrip(t *testing.T) {
38
+ raw := `{
39
+ "prompt_style": "legacy",
40
+ "model_profiles": [
41
+ {"id": "deepseek-writer", "base": "deepseek-v4-flash", "name": "写作", "remark": "长文", "prompt": " writing spec "},
42
+ {"id": "deepseek-quiet", "base": "deepseek-v4-flash-nothinking"}
43
+ ]
44
+ }`
45
+ var cfg Config
46
+ if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
47
+ t.Fatalf("unmarshal: %v", err)
48
+ }
49
+ cfg.NormalizeCredentials()
50
+ if len(cfg.ModelProfiles) != 2 {
51
+ t.Fatalf("expected 2 profiles, got %#v", cfg.ModelProfiles)
52
+ }
53
+ first := cfg.ModelProfiles[0]
54
+ if first.Prompt != "writing spec" {
55
+ t.Fatalf("expected trimmed prompt, got %q", first.Prompt)
56
+ }
57
+ if first.Base != "deepseek-v4-flash" {
58
+ t.Fatalf("unexpected base %q", first.Base)
59
+ }
60
+
61
+ encoded, err := json.Marshal(cfg)
62
+ if err != nil {
63
+ t.Fatalf("marshal: %v", err)
64
+ }
65
+ var decoded Config
66
+ if err := json.Unmarshal(encoded, &decoded); err != nil {
67
+ t.Fatalf("re-unmarshal: %v", err)
68
+ }
69
+ decoded.NormalizeCredentials()
70
+ if len(decoded.ModelProfiles) != 2 || decoded.PromptStyle != PromptStyleLegacy {
71
+ t.Fatalf("profiles lost in round trip: %#v style=%q", decoded.ModelProfiles, decoded.PromptStyle)
72
+ }
73
+ }
74
+
75
+ func TestNormalizeModelProfilesDedupesAndDropsEmpty(t *testing.T) {
76
+ items := NormalizeModelProfiles([]ModelProfileConfig{
77
+ {ID: " a ", Base: " deepseek-v4-flash "},
78
+ {ID: "A", Base: "deepseek-v4-flash-search"},
79
+ {ID: "", Base: "deepseek-v4-flash"},
80
+ {ID: "b", Base: "deepseek-v4-flash"},
81
+ })
82
+ if len(items) != 2 {
83
+ t.Fatalf("expected 2 profiles after dedupe, got %#v", items)
84
+ }
85
+ if items[0].ID != "a" || items[0].Base != "deepseek-v4-flash" {
86
+ t.Fatalf("unexpected first profile: %#v", items[0])
87
+ }
88
+ if items[1].ID != "b" {
89
+ t.Fatalf("unexpected second profile: %#v", items[1])
90
+ }
91
+ if got := NormalizeModelProfiles(nil); got != nil {
92
+ t.Fatalf("expected nil for empty input, got %#v", got)
93
+ }
94
+ }
95
+
96
+ func TestValidateModelProfiles(t *testing.T) {
97
+ ok := []ModelProfileConfig{{ID: "deepseek-writer", Base: "deepseek-v4-flash"}}
98
+ if err := ValidateModelProfiles(ok); err != nil {
99
+ t.Fatalf("expected valid profiles, got %v", err)
100
+ }
101
+ cases := []struct {
102
+ name string
103
+ items []ModelProfileConfig
104
+ want string
105
+ }{
106
+ {name: "empty id", items: []ModelProfileConfig{{Base: "deepseek-v4-flash"}}, want: "id cannot be empty"},
107
+ {name: "bad charset", items: []ModelProfileConfig{{ID: "bad id", Base: "deepseek-v4-flash"}}, want: "must be 1-64"},
108
+ {name: "duplicate", items: []ModelProfileConfig{
109
+ {ID: "a", Base: "deepseek-v4-flash"},
110
+ {ID: "A", Base: "deepseek-v4-flash"},
111
+ }, want: "duplicate"},
112
+ {name: "shadows builtin", items: []ModelProfileConfig{{ID: "deepseek-v4-flash", Base: "deepseek-v4-flash"}}, want: "shadows a built-in"},
113
+ {name: "missing base", items: []ModelProfileConfig{{ID: "a"}}, want: "base cannot be empty"},
114
+ {name: "bad base", items: []ModelProfileConfig{{ID: "a", Base: "deepseek-v4-pro"}}, want: "base must be one of"},
115
+ {name: "too many", items: makeManyProfiles(MaxModelProfiles + 1), want: "cannot exceed"},
116
+ {name: "prompt too long", items: []ModelProfileConfig{
117
+ {ID: "a", Base: "deepseek-v4-flash", Prompt: strings.Repeat("字", MaxPromptProfileChars+1)},
118
+ }, want: "prompt cannot exceed"},
119
+ }
120
+ for _, tc := range cases {
121
+ t.Run(tc.name, func(t *testing.T) {
122
+ err := ValidateModelProfiles(tc.items)
123
+ if err == nil || !strings.Contains(err.Error(), tc.want) {
124
+ t.Fatalf("error=%v want contains %q", err, tc.want)
125
+ }
126
+ })
127
+ }
128
+ }
129
+
130
+ func makeManyProfiles(n int) []ModelProfileConfig {
131
+ items := make([]ModelProfileConfig, 0, n)
132
+ for i := 0; i < n; i++ {
133
+ items = append(items, ModelProfileConfig{
134
+ ID: "m" + string(rune('a'+i%26)) + string(rune('a'+i/26)),
135
+ Base: "deepseek-v4-flash",
136
+ })
137
+ }
138
+ return items
139
+ }
140
+
141
+ func TestResolveModelUsesModelProfiles(t *testing.T) {
142
+ store := &Store{cfg: Config{
143
+ ModelProfiles: []ModelProfileConfig{
144
+ {ID: "deepseek-writer", Base: "deepseek-v4-flash"},
145
+ {ID: "DeepSeek-Quiet", Base: "deepseek-v4-flash-nothinking"},
146
+ },
147
+ }}
148
+ if got, ok := ResolveModel(store, "deepseek-writer"); !ok || got != "deepseek-v4-flash" {
149
+ t.Fatalf("resolved=%q ok=%v", got, ok)
150
+ }
151
+ // 大小写不敏感。
152
+ if got, ok := ResolveModel(store, "deepseek-quiet"); !ok || got != "deepseek-v4-flash-nothinking" {
153
+ t.Fatalf("resolved=%q ok=%v", got, ok)
154
+ }
155
+ // 内置模型与别名解析不受影响。
156
+ if got, ok := ResolveModel(store, "deepseek-v4-flash"); !ok || got != "deepseek-v4-flash" {
157
+ t.Fatalf("builtin resolved=%q ok=%v", got, ok)
158
+ }
159
+ if _, ok := ResolveModel(store, "not-a-model"); ok {
160
+ t.Fatal("expected unknown model to fail")
161
+ }
162
+ }
163
+
164
+ func TestStoreResolveModelPrompt(t *testing.T) {
165
+ store := &Store{cfg: Config{ModelProfiles: []ModelProfileConfig{
166
+ {ID: "deepseek-writer", Base: "deepseek-v4-flash", Prompt: " writing spec "},
167
+ {ID: "deepseek-plain", Base: "deepseek-v4-flash"},
168
+ }}}
169
+ if body, ok := store.ResolveModelPrompt("deepseek-writer"); !ok || body != "writing spec" {
170
+ t.Fatalf("body=%q ok=%v", body, ok)
171
+ }
172
+ // 没有提示词的条目不算命中,调用方应回落到全局特化层。
173
+ if _, ok := store.ResolveModelPrompt("deepseek-plain"); ok {
174
+ t.Fatal("expected empty prompt to be treated as no match")
175
+ }
176
+ if _, ok := store.ResolveModelPrompt("deepseek-v4-flash"); ok {
177
+ t.Fatal("expected builtin model to have no model prompt")
178
+ }
179
+ }
180
+
181
+ func TestOpenAIModelsIncludeProfiles(t *testing.T) {
182
+ store := &Store{cfg: Config{ModelProfiles: []ModelProfileConfig{
183
+ {ID: "deepseek-writer", Base: "deepseek-v4-flash", Name: "写作"},
184
+ }}}
185
+ models := OpenAIModelsFor(store)
186
+ found := false
187
+ for _, model := range models {
188
+ if model.ID == "deepseek-writer" {
189
+ found = true
190
+ if model.OwnedBy != "ds2api" {
191
+ t.Fatalf("unexpected owned_by %q", model.OwnedBy)
192
+ }
193
+ }
194
+ if model.ID == "deepseek-v4-flash" {
195
+ if model.OwnedBy != "deepseek" {
196
+ t.Fatalf("builtin must keep its owned_by, got %q", model.OwnedBy)
197
+ }
198
+ }
199
+ }
200
+ if !found {
201
+ t.Fatalf("expected custom model in list, got %#v", models)
202
+ }
203
+ info, ok := OpenAIModelByID(store, "deepseek-writer")
204
+ if !ok || info.ID != "deepseek-writer" {
205
+ t.Fatalf("expected profile by id, got %#v ok=%v", info, ok)
206
+ }
207
+ if _, ok := OpenAIModelByID(store, "not-a-model"); ok {
208
+ t.Fatal("expected unknown model to be missing")
209
+ }
210
+ }
211
+
212
+ func TestModelProfilesCloneIsIndependent(t *testing.T) {
213
+ cfg := Config{ModelProfiles: []ModelProfileConfig{{ID: "a", Base: "deepseek-v4-flash"}}}
214
+ clone := cfg.Clone()
215
+ if len(clone.ModelProfiles) != 1 {
216
+ t.Fatalf("clone lost profiles: %#v", clone.ModelProfiles)
217
+ }
218
+ clone.ModelProfiles[0].ID = "b"
219
+ if cfg.ModelProfiles[0].ID != "a" {
220
+ t.Fatal("mutating the clone must not affect the original")
221
+ }
222
+ }
internal/config/models.go CHANGED
@@ -257,6 +257,10 @@ func ResolveModel(store ModelAliasReader, requested string) (string, bool) {
257
  if model == "" {
258
  return "", false
259
  }
 
 
 
 
260
  if resolved, ok := resolveSupportedModel(model); ok {
261
  return resolved, true
262
  }
@@ -275,6 +279,46 @@ func ResolveModel(store ModelAliasReader, requested string) (string, bool) {
275
  return "", false
276
  }
277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  func lower(s string) string {
279
  b := []byte(s)
280
  for i, c := range b {
@@ -289,7 +333,22 @@ func OpenAIModelsResponse() map[string]any {
289
  return map[string]any{"object": "list", "data": DeepSeekModels}
290
  }
291
 
 
 
 
 
 
292
  func OpenAIModelByID(store ModelAliasReader, id string) (ModelInfo, bool) {
 
 
 
 
 
 
 
 
 
 
293
  canonical, ok := ResolveModel(store, id)
294
  if !ok {
295
  return ModelInfo{}, false
 
257
  if model == "" {
258
  return "", false
259
  }
260
+ // 自定义模型优先:用户在后台显式建的模型名应当盖过内置别名映射。
261
+ if base, ok := resolveModelProfileBase(store, model); ok {
262
+ return resolveSupportedModel(base)
263
+ }
264
  if resolved, ok := resolveSupportedModel(model); ok {
265
  return resolved, true
266
  }
 
279
  return "", false
280
  }
281
 
282
+ // modelProfilesOf 返回配置里的自定义模型列表;store 不实现 ModelProfileReader 时返回 nil。
283
+ func modelProfilesOf(store any) []ModelProfileConfig {
284
+ reader, ok := store.(ModelProfileReader)
285
+ if !ok || reader == nil {
286
+ return nil
287
+ }
288
+ return reader.ModelProfiles()
289
+ }
290
+
291
+ // resolveModelProfileBase 把自定义模型名解析成基模。
292
+ func resolveModelProfileBase(store any, model string) (string, bool) {
293
+ profile, ok := FindModelProfile(modelProfilesOf(store), model)
294
+ if !ok {
295
+ return "", false
296
+ }
297
+ base := lower(strings.TrimSpace(profile.Base))
298
+ if base == "" {
299
+ return "", false
300
+ }
301
+ return base, true
302
+ }
303
+
304
+ // OpenAIModelsFor 返回 /v1/models 的模型列表:内置 flash 系列 + 自定义模型。
305
+ func OpenAIModelsFor(store ModelAliasReader) []ModelInfo {
306
+ profiles := modelProfilesOf(store)
307
+ out := make([]ModelInfo, 0, len(DeepSeekModels)+len(profiles))
308
+ out = append(out, DeepSeekModels...)
309
+ for _, profile := range profiles {
310
+ info := ModelInfo{
311
+ ID: profile.ID,
312
+ Object: "model",
313
+ Created: 1677610602,
314
+ OwnedBy: "ds2api",
315
+ Permission: []any{},
316
+ }
317
+ out = append(out, info)
318
+ }
319
+ return out
320
+ }
321
+
322
  func lower(s string) string {
323
  b := []byte(s)
324
  for i, c := range b {
 
333
  return map[string]any{"object": "list", "data": DeepSeekModels}
334
  }
335
 
336
+ // OpenAIModelsResponseFor 返回含自定义模型的列表响应;handler 用这个版本。
337
+ func OpenAIModelsResponseFor(store ModelAliasReader) map[string]any {
338
+ return map[string]any{"object": "list", "data": OpenAIModelsFor(store)}
339
+ }
340
+
341
  func OpenAIModelByID(store ModelAliasReader, id string) (ModelInfo, bool) {
342
+ // 自定义模型按原样返回自己的名字,不要暴露背后的 flash 基模。
343
+ if profile, ok := FindModelProfile(modelProfilesOf(store), id); ok {
344
+ return ModelInfo{
345
+ ID: profile.ID,
346
+ Object: "model",
347
+ Created: 1677610602,
348
+ OwnedBy: "ds2api",
349
+ Permission: []any{},
350
+ }, true
351
+ }
352
  canonical, ok := ResolveModel(store, id)
353
  if !ok {
354
  return ModelInfo{}, false
internal/config/store_accessors.go CHANGED
@@ -335,6 +335,41 @@ func (s *Store) PromptProfileEnabled() bool {
335
  return s.cfg.PromptProfile.IsEnabled()
336
  }
337
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  // PromptProfileSettings 是「提示词特化层」对单次请求生效的设置。
339
  // Prompt 为空表示使用 Name 指向的内置预设正文。
340
  type PromptProfileSettings struct {
 
335
  return s.cfg.PromptProfile.IsEnabled()
336
  }
337
 
338
+ // PromptStyleMode 返回生效的内置提示词风格:v2(缺省)或 legacy。
339
+ func (s *Store) PromptStyleMode() string {
340
+ s.mu.RLock()
341
+ defer s.mu.RUnlock()
342
+ return s.cfg.PromptStyleMode()
343
+ }
344
+
345
+ // ModelProfiles 返回自定义模型列表副本。
346
+ func (s *Store) ModelProfiles() []ModelProfileConfig {
347
+ s.mu.RLock()
348
+ defer s.mu.RUnlock()
349
+ return cloneModelProfiles(s.cfg.ModelProfiles)
350
+ }
351
+
352
+ // ResolveModelPrompt 返回某个模型名对应的专属提示词。
353
+ //
354
+ // 按客户端请求里的模型名(大小写不敏感)匹配自定义模型条目;命中且正文非空时
355
+ // ok=true。未命中或正文为空时调用方应回落到全局 prompt_profile。
356
+ func (s *Store) ResolveModelPrompt(modelID string) (string, bool) {
357
+ if s == nil {
358
+ return "", false
359
+ }
360
+ s.mu.RLock()
361
+ profile, ok := FindModelProfile(s.cfg.ModelProfiles, modelID)
362
+ s.mu.RUnlock()
363
+ if !ok {
364
+ return "", false
365
+ }
366
+ body := strings.TrimSpace(profile.Prompt)
367
+ if body == "" {
368
+ return "", false
369
+ }
370
+ return body, true
371
+ }
372
+
373
  // PromptProfileSettings 是「提示词特化层」对单次请求生效的设置。
374
  // Prompt 为空表示使用 Name 指向的内置预设正文。
375
  type PromptProfileSettings struct {
internal/httpapi/admin/handler.go CHANGED
@@ -9,6 +9,7 @@ import (
9
  adminconfig "ds2api/internal/httpapi/admin/configmgmt"
10
  admindevcapture "ds2api/internal/httpapi/admin/devcapture"
11
  adminhistory "ds2api/internal/httpapi/admin/history"
 
12
  adminproxies "ds2api/internal/httpapi/admin/proxies"
13
  adminrawsamples "ds2api/internal/httpapi/admin/rawsamples"
14
  adminsettings "ds2api/internal/httpapi/admin/settings"
@@ -39,6 +40,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
39
  vercelHandler := &adminvercel.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
40
  historyHandler := &adminhistory.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
41
  skillsHandler := &adminskills.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
 
42
  devCaptureHandler := &admindevcapture.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
43
  versionHandler := &adminversion.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
44
 
@@ -49,6 +51,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
49
  adminconfig.RegisterRoutes(pr, configHandler)
50
  adminsettings.RegisterRoutes(pr, settingsHandler)
51
  adminskills.RegisterRoutes(pr, skillsHandler)
 
52
  adminproxies.RegisterRoutes(pr, proxiesHandler)
53
  adminaccounts.RegisterRoutes(pr, accountsHandler)
54
  adminrawsamples.RegisterRoutes(pr, rawSamplesHandler)
 
9
  adminconfig "ds2api/internal/httpapi/admin/configmgmt"
10
  admindevcapture "ds2api/internal/httpapi/admin/devcapture"
11
  adminhistory "ds2api/internal/httpapi/admin/history"
12
+ adminmodelprofiles "ds2api/internal/httpapi/admin/modelprofiles"
13
  adminproxies "ds2api/internal/httpapi/admin/proxies"
14
  adminrawsamples "ds2api/internal/httpapi/admin/rawsamples"
15
  adminsettings "ds2api/internal/httpapi/admin/settings"
 
40
  vercelHandler := &adminvercel.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
41
  historyHandler := &adminhistory.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
42
  skillsHandler := &adminskills.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
43
+ modelProfilesHandler := &adminmodelprofiles.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
44
  devCaptureHandler := &admindevcapture.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
45
  versionHandler := &adminversion.Handler{Store: deps.Store, Pool: deps.Pool, DS: deps.DS, OpenAI: deps.OpenAI, ChatHistory: deps.ChatHistory}
46
 
 
51
  adminconfig.RegisterRoutes(pr, configHandler)
52
  adminsettings.RegisterRoutes(pr, settingsHandler)
53
  adminskills.RegisterRoutes(pr, skillsHandler)
54
+ adminmodelprofiles.RegisterRoutes(pr, modelProfilesHandler)
55
  adminproxies.RegisterRoutes(pr, proxiesHandler)
56
  adminaccounts.RegisterRoutes(pr, accountsHandler)
57
  adminrawsamples.RegisterRoutes(pr, rawSamplesHandler)
internal/httpapi/admin/handler_settings_prompt_style_test.go ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package admin
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "testing"
8
+
9
+ "ds2api/internal/config"
10
+ )
11
+
12
+ func TestGetSettingsIncludesPromptStyleDefaults(t *testing.T) {
13
+ h := newAdminTestHandler(t, `{"keys":["k1"]}`)
14
+ req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
15
+ rec := httptest.NewRecorder()
16
+ h.getSettings(rec, req)
17
+ if rec.Code != http.StatusOK {
18
+ t.Fatalf("expected 200, got %d", rec.Code)
19
+ }
20
+ var body map[string]any
21
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
22
+ t.Fatalf("decode body: %v", err)
23
+ }
24
+ style, ok := body["prompt_style"].(map[string]any)
25
+ if !ok {
26
+ t.Fatalf("expected prompt_style in settings response, got %#v", body["prompt_style"])
27
+ }
28
+ // 默认必须是 v2:不配置就不改变行为。
29
+ if mode, _ := style["mode"].(string); mode != config.PromptStyleV2 {
30
+ t.Fatalf("expected default mode v2, got %#v", style["mode"])
31
+ }
32
+ modes, ok := style["modes"].([]any)
33
+ if !ok || len(modes) != len(config.PromptStyleNames()) {
34
+ t.Fatalf("expected %d modes, got %#v", len(config.PromptStyleNames()), style["modes"])
35
+ }
36
+ profiles, ok := body["model_profiles"].(map[string]any)
37
+ if !ok {
38
+ t.Fatalf("expected model_profiles summary, got %#v", body["model_profiles"])
39
+ }
40
+ if count, _ := profiles["count"].(float64); count != 0 {
41
+ t.Fatalf("expected empty model list, got %#v", profiles["count"])
42
+ }
43
+ }
44
+
45
+ func TestUpdateSettingsPromptStyle(t *testing.T) {
46
+ h := newAdminTestHandler(t, `{"keys":["k1"]}`)
47
+ rec := putSettings(t, h, map[string]any{"prompt_style": "legacy"})
48
+ if rec.Code != http.StatusOK {
49
+ t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
50
+ }
51
+ if got := h.Store.PromptStyleMode(); got != config.PromptStyleLegacy {
52
+ t.Fatalf("prompt style=%q want legacy", got)
53
+ }
54
+ }
55
+
56
+ func TestUpdateSettingsPromptStyleRejectsUnknown(t *testing.T) {
57
+ h := newAdminTestHandler(t, `{"keys":["k1"]}`)
58
+ rec := putSettings(t, h, map[string]any{"prompt_style": "v3"})
59
+ if rec.Code != http.StatusBadRequest {
60
+ t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
61
+ }
62
+ if got := h.Store.PromptStyleMode(); got != config.PromptStyleV2 {
63
+ t.Fatalf("rejected payload must not be applied, got %q", got)
64
+ }
65
+ }
66
+
67
+ func TestUpdateSettingsWithoutPromptStyleKeepsValue(t *testing.T) {
68
+ h := newAdminTestHandler(t, `{"keys":["k1"],"prompt_style":"legacy"}`)
69
+ rec := putSettings(t, h, map[string]any{"runtime": map[string]any{"account_max_inflight": 3}})
70
+ if rec.Code != http.StatusOK {
71
+ t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
72
+ }
73
+ if got := h.Store.PromptStyleMode(); got != config.PromptStyleLegacy {
74
+ t.Fatalf("expected legacy preserved, got %q", got)
75
+ }
76
+ }
77
+
78
+ func TestUpdateSettingsEmptyPromptStyleFallsBackToV2(t *testing.T) {
79
+ h := newAdminTestHandler(t, `{"keys":["k1"],"prompt_style":"legacy"}`)
80
+ rec := putSettings(t, h, map[string]any{"prompt_style": ""})
81
+ if rec.Code != http.StatusOK {
82
+ t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
83
+ }
84
+ if got := h.Store.PromptStyleMode(); got != config.PromptStyleV2 {
85
+ t.Fatalf("empty style should fall back to v2, got %q", got)
86
+ }
87
+ }
internal/httpapi/admin/modelprofiles/deps.go ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package modelprofiles
2
+
3
+ import (
4
+ "ds2api/internal/chathistory"
5
+ adminshared "ds2api/internal/httpapi/admin/shared"
6
+ )
7
+
8
+ type Handler struct {
9
+ Store adminshared.ConfigStore
10
+ Pool adminshared.PoolController
11
+ DS adminshared.DeepSeekCaller
12
+ OpenAI adminshared.OpenAIChatCaller
13
+ ChatHistory *chathistory.Store
14
+ }
15
+
16
+ var writeJSON = adminshared.WriteJSON
internal/httpapi/admin/modelprofiles/handler_model_profiles.go ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package modelprofiles
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+
7
+ "ds2api/internal/config"
8
+ )
9
+
10
+ // modelProfilesPayload 是 GET/PUT /admin/model_profiles 的响应形状。
11
+ // PUT 是整体替换语义:请求里的 items 就是最终列表。
12
+ type modelProfilesPayload struct {
13
+ Items []config.ModelProfileConfig `json:"items"`
14
+ BaseModels []string `json:"base_models"`
15
+ MaxCount int `json:"max_count"`
16
+ MaxIDChars int `json:"max_id_chars"`
17
+ MaxPromptChars int `json:"max_prompt_chars"`
18
+ }
19
+
20
+ func (h *Handler) getModelProfiles(w http.ResponseWriter, _ *http.Request) {
21
+ items := h.currentModelProfiles()
22
+ writeJSON(w, http.StatusOK, modelProfilesPayload{
23
+ Items: items,
24
+ BaseModels: config.SupportedBaseModels(),
25
+ MaxCount: config.MaxModelProfiles,
26
+ MaxIDChars: config.MaxModelProfileIDChars,
27
+ MaxPromptChars: config.MaxPromptProfileChars,
28
+ })
29
+ }
30
+
31
+ func (h *Handler) updateModelProfiles(w http.ResponseWriter, r *http.Request) {
32
+ var req struct {
33
+ Items []config.ModelProfileConfig `json:"items"`
34
+ }
35
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
36
+ writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json: " + err.Error()})
37
+ return
38
+ }
39
+ if err := config.ValidateModelProfiles(req.Items); err != nil {
40
+ writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
41
+ return
42
+ }
43
+ stored := config.NormalizeModelProfiles(req.Items)
44
+ if err := h.Store.Update(func(c *config.Config) error {
45
+ c.ModelProfiles = stored
46
+ return nil
47
+ }); err != nil {
48
+ writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
49
+ return
50
+ }
51
+ writeJSON(w, http.StatusOK, map[string]any{
52
+ "success": true,
53
+ "count": len(stored),
54
+ })
55
+ }
56
+
57
+ // currentModelProfiles 返回当前自定义模型列表(已归一化,永不为 nil)。
58
+ func (h *Handler) currentModelProfiles() []config.ModelProfileConfig {
59
+ if h == nil || h.Store == nil {
60
+ return []config.ModelProfileConfig{}
61
+ }
62
+ items := config.NormalizeModelProfiles(h.Store.Snapshot().ModelProfiles)
63
+ if items == nil {
64
+ return []config.ModelProfileConfig{}
65
+ }
66
+ return items
67
+ }
internal/httpapi/admin/modelprofiles/handler_model_profiles_test.go ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package modelprofiles
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "testing"
9
+
10
+ "ds2api/internal/config"
11
+ )
12
+
13
+ func newTestHandler(t *testing.T, raw string) *Handler {
14
+ t.Helper()
15
+ t.Setenv("DS2API_CONFIG_JSON", raw)
16
+ return &Handler{Store: config.LoadStore()}
17
+ }
18
+
19
+ func TestGetModelProfilesReturnsItemsAndLimits(t *testing.T) {
20
+ h := newTestHandler(t, `{
21
+ "keys": ["k1"],
22
+ "model_profiles": [
23
+ {"id": "deepseek-writer", "base": "deepseek-v4-flash", "name": "写作", "prompt": "writing spec"}
24
+ ]
25
+ }`)
26
+ rec := httptest.NewRecorder()
27
+ h.getModelProfiles(rec, httptest.NewRequest(http.MethodGet, "/admin/model_profiles", nil))
28
+ if rec.Code != http.StatusOK {
29
+ t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
30
+ }
31
+ var body modelProfilesPayload
32
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
33
+ t.Fatalf("decode body: %v", err)
34
+ }
35
+ if len(body.Items) != 1 || body.Items[0].ID != "deepseek-writer" {
36
+ t.Fatalf("unexpected items: %#v", body.Items)
37
+ }
38
+ if len(body.BaseModels) == 0 {
39
+ t.Fatal("expected base model options")
40
+ }
41
+ if body.MaxCount != config.MaxModelProfiles {
42
+ t.Fatalf("max_count=%d want %d", body.MaxCount, config.MaxModelProfiles)
43
+ }
44
+ if body.MaxPromptChars != config.MaxPromptProfileChars {
45
+ t.Fatalf("max_prompt_chars=%d want %d", body.MaxPromptChars, config.MaxPromptProfileChars)
46
+ }
47
+ }
48
+
49
+ func TestGetModelProfilesEmptyReturnsArray(t *testing.T) {
50
+ h := newTestHandler(t, `{"keys": ["k1"]}`)
51
+ rec := httptest.NewRecorder()
52
+ h.getModelProfiles(rec, httptest.NewRequest(http.MethodGet, "/admin/model_profiles", nil))
53
+ if rec.Code != http.StatusOK {
54
+ t.Fatalf("expected 200, got %d", rec.Code)
55
+ }
56
+ if !bytes.Contains(rec.Body.Bytes(), []byte(`"items":[]`)) {
57
+ t.Fatalf("expected empty array rather than null, got %s", rec.Body.String())
58
+ }
59
+ }
60
+
61
+ func putProfiles(t *testing.T, h *Handler, payload map[string]any) *httptest.ResponseRecorder {
62
+ t.Helper()
63
+ b, _ := json.Marshal(payload)
64
+ rec := httptest.NewRecorder()
65
+ h.updateModelProfiles(rec, httptest.NewRequest(http.MethodPut, "/admin/model_profiles", bytes.NewReader(b)))
66
+ return rec
67
+ }
68
+
69
+ func TestUpdateModelProfilesReplacesList(t *testing.T) {
70
+ h := newTestHandler(t, `{"keys": ["k1"]}`)
71
+ rec := putProfiles(t, h, map[string]any{
72
+ "items": []map[string]any{
73
+ {"id": "deepseek-writer", "base": "deepseek-v4-flash", "name": "写作", "prompt": " spec "},
74
+ },
75
+ })
76
+ if rec.Code != http.StatusOK {
77
+ t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
78
+ }
79
+ items := h.Store.ModelProfiles()
80
+ if len(items) != 1 {
81
+ t.Fatalf("expected 1 profile, got %#v", items)
82
+ }
83
+ if items[0].Prompt != "spec" {
84
+ t.Fatalf("expected trimmed prompt, got %q", items[0].Prompt)
85
+ }
86
+ // 保存后解析立即生效。
87
+ if resolved, ok := config.ResolveModel(h.Store, "deepseek-writer"); !ok || resolved != "deepseek-v4-flash" {
88
+ t.Fatalf("resolved=%q ok=%v", resolved, ok)
89
+ }
90
+ }
91
+
92
+ func TestUpdateModelProfilesValidatesInput(t *testing.T) {
93
+ h := newTestHandler(t, `{"keys": ["k1"]}`)
94
+ cases := []struct {
95
+ name string
96
+ items []map[string]any
97
+ }{
98
+ {name: "empty id", items: []map[string]any{{"base": "deepseek-v4-flash"}}},
99
+ {name: "bad base", items: []map[string]any{{"id": "a", "base": "gpt-4o"}}},
100
+ {name: "builtin shadow", items: []map[string]any{{"id": "deepseek-v4-flash", "base": "deepseek-v4-flash"}}},
101
+ {name: "duplicate", items: []map[string]any{
102
+ {"id": "a", "base": "deepseek-v4-flash"},
103
+ {"id": "a", "base": "deepseek-v4-flash"},
104
+ }},
105
+ }
106
+ for _, tc := range cases {
107
+ t.Run(tc.name, func(t *testing.T) {
108
+ rec := putProfiles(t, h, map[string]any{"items": tc.items})
109
+ if rec.Code != http.StatusBadRequest {
110
+ t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
111
+ }
112
+ if len(h.Store.ModelProfiles()) != 0 {
113
+ t.Fatal("rejected payload must not be persisted")
114
+ }
115
+ })
116
+ }
117
+ }
118
+
119
+ func TestUpdateModelProfilesRejectsInvalidJSON(t *testing.T) {
120
+ h := newTestHandler(t, `{"keys": ["k1"]}`)
121
+ rec := httptest.NewRecorder()
122
+ h.updateModelProfiles(rec, httptest.NewRequest(http.MethodPut, "/admin/model_profiles", bytes.NewReader([]byte("{"))))
123
+ if rec.Code != http.StatusBadRequest {
124
+ t.Fatalf("expected 400, got %d", rec.Code)
125
+ }
126
+ }
127
+
128
+ func TestUpdateModelProfilesCanClearList(t *testing.T) {
129
+ h := newTestHandler(t, `{
130
+ "keys": ["k1"],
131
+ "model_profiles": [{"id": "deepseek-writer", "base": "deepseek-v4-flash"}]
132
+ }`)
133
+ rec := putProfiles(t, h, map[string]any{"items": []map[string]any{}})
134
+ if rec.Code != http.StatusOK {
135
+ t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
136
+ }
137
+ if len(h.Store.ModelProfiles()) != 0 {
138
+ t.Fatalf("expected empty list, got %#v", h.Store.ModelProfiles())
139
+ }
140
+ }
internal/httpapi/admin/modelprofiles/routes.go ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ package modelprofiles
2
+
3
+ import "github.com/go-chi/chi/v5"
4
+
5
+ func RegisterRoutes(r chi.Router, h *Handler) {
6
+ r.Get("/model_profiles", h.getModelProfiles)
7
+ r.Put("/model_profiles", h.updateModelProfiles)
8
+ }
internal/httpapi/admin/settings/handler_settings_parse.go CHANGED
@@ -33,6 +33,7 @@ type settingsUpdatePayload struct {
33
  CurrentInputFile *config.CurrentInputFileConfig
34
  ThinkingInjection *config.ThinkingInjectionConfig
35
  PromptProfile *config.PromptProfileConfig
 
36
  Tools *config.ToolsConfig
37
  Platform *config.PlatformConfig
38
  Aliases map[string]string
@@ -214,6 +215,17 @@ func parseSettingsUpdateRequest(req map[string]any) (settingsUpdatePayload, erro
214
  out.PromptProfile = cfg
215
  }
216
 
 
 
 
 
 
 
 
 
 
 
 
217
  if raw, ok := req["tools"].(map[string]any); ok {
218
  cfg := &config.ToolsConfig{}
219
  if v, exists := raw["enabled"]; exists {
 
33
  CurrentInputFile *config.CurrentInputFileConfig
34
  ThinkingInjection *config.ThinkingInjectionConfig
35
  PromptProfile *config.PromptProfileConfig
36
+ PromptStyle *string
37
  Tools *config.ToolsConfig
38
  Platform *config.PlatformConfig
39
  Aliases map[string]string
 
215
  out.PromptProfile = cfg
216
  }
217
 
218
+ if raw, exists := req["prompt_style"]; exists {
219
+ style := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", raw)))
220
+ if style != "" && !config.IsKnownPromptStyle(style) {
221
+ return settingsUpdatePayload{}, fmt.Errorf(
222
+ "prompt_style must be one of %s",
223
+ strings.Join(config.PromptStyleNames(), ", "),
224
+ )
225
+ }
226
+ out.PromptStyle = &style
227
+ }
228
+
229
  if raw, ok := req["tools"].(map[string]any); ok {
230
  cfg := &config.ToolsConfig{}
231
  if v, exists := raw["enabled"]; exists {
internal/httpapi/admin/settings/handler_settings_read.go CHANGED
@@ -42,6 +42,8 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
42
  "name": snap.PromptProfile.Name,
43
  "prompt": snap.PromptProfile.Prompt,
44
  },
 
 
45
  "skills": map[string]any{
46
  "enabled": snap.Skills.Enabled,
47
  "max_chars": snap.Skills.EffectiveMaxChars(),
@@ -85,6 +87,19 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
85
  "prompt": snap.PromptProfile.Prompt,
86
  "presets": promptProfilePresetsPayload(),
87
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  "skills": map[string]any{
89
  "enabled": h.Store.SkillsEnabled(),
90
  "max_chars": h.Store.SkillsMaxChars(),
 
42
  "name": snap.PromptProfile.Name,
43
  "prompt": snap.PromptProfile.Prompt,
44
  },
45
+ "prompt_style": snap.PromptStyle,
46
+ "model_profiles": snap.ModelProfiles,
47
  "skills": map[string]any{
48
  "enabled": snap.Skills.Enabled,
49
  "max_chars": snap.Skills.EffectiveMaxChars(),
 
87
  "prompt": snap.PromptProfile.Prompt,
88
  "presets": promptProfilePresetsPayload(),
89
  },
90
+ "prompt_style": map[string]any{
91
+ "mode": snap.PromptStyleMode(),
92
+ "modes": config.PromptStyleNames(),
93
+ "default": config.PromptStyleV2,
94
+ "value_raw": snap.PromptStyle,
95
+ },
96
+ "model_profiles": map[string]any{
97
+ "count": len(h.Store.ModelProfiles()),
98
+ "max_count": config.MaxModelProfiles,
99
+ "base_models": config.SupportedBaseModels(),
100
+ "max_id_chars": config.MaxModelProfileIDChars,
101
+ "max_prompt_chars": config.MaxPromptProfileChars,
102
+ },
103
  "skills": map[string]any{
104
  "enabled": h.Store.SkillsEnabled(),
105
  "max_chars": h.Store.SkillsMaxChars(),
internal/httpapi/admin/settings/handler_settings_write.go CHANGED
@@ -35,6 +35,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
35
  promptProfileEnabledSet := hasNestedSettingsKey(req, "prompt_profile", "enabled")
36
  promptProfileNameSet := hasNestedSettingsKey(req, "prompt_profile", "name")
37
  promptProfilePromptSet := hasNestedSettingsKey(req, "prompt_profile", "prompt")
 
38
  toolsEnabledSet := hasNestedSettingsKey(req, "tools", "enabled")
39
 
40
  if err := h.Store.Update(func(c *config.Config) error {
@@ -98,6 +99,9 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
98
  c.PromptProfile.Prompt = payload.PromptProfile.Prompt
99
  }
100
  }
 
 
 
101
  if payload.Tools != nil && toolsEnabledSet {
102
  c.Tools.Enabled = payload.Tools.Enabled
103
  }
@@ -170,3 +174,9 @@ func hasNestedSettingsKey(req map[string]any, section, key string) bool {
170
  _, exists := raw[key]
171
  return exists
172
  }
 
 
 
 
 
 
 
35
  promptProfileEnabledSet := hasNestedSettingsKey(req, "prompt_profile", "enabled")
36
  promptProfileNameSet := hasNestedSettingsKey(req, "prompt_profile", "name")
37
  promptProfilePromptSet := hasNestedSettingsKey(req, "prompt_profile", "prompt")
38
+ promptStyleSet := hasTopLevelSettingsKey(req, "prompt_style")
39
  toolsEnabledSet := hasNestedSettingsKey(req, "tools", "enabled")
40
 
41
  if err := h.Store.Update(func(c *config.Config) error {
 
99
  c.PromptProfile.Prompt = payload.PromptProfile.Prompt
100
  }
101
  }
102
+ if payload.PromptStyle != nil && promptStyleSet {
103
+ c.PromptStyle = *payload.PromptStyle
104
+ }
105
  if payload.Tools != nil && toolsEnabledSet {
106
  c.Tools.Enabled = payload.Tools.Enabled
107
  }
 
174
  _, exists := raw[key]
175
  return exists
176
  }
177
+
178
+ // hasTopLevelSettingsKey 判断顶层是否出现某个简单字段(如 prompt_style)。
179
+ func hasTopLevelSettingsKey(req map[string]any, key string) bool {
180
+ _, exists := req[key]
181
+ return exists
182
+ }
internal/httpapi/admin/shared/deps.go CHANGED
@@ -39,6 +39,9 @@ type ConfigStore interface {
39
  ThinkingInjectionEnabled() bool
40
  ThinkingInjectionPrompt() string
41
  PromptProfileEnabled() bool
 
 
 
42
  SkillsEnabled() bool
43
  SkillsMaxChars() int
44
  ToolsEnabled() bool
 
39
  ThinkingInjectionEnabled() bool
40
  ThinkingInjectionPrompt() string
41
  PromptProfileEnabled() bool
42
+ PromptStyleMode() string
43
+ ModelAliases() map[string]string
44
+ ModelProfiles() []config.ModelProfileConfig
45
  SkillsEnabled() bool
46
  SkillsMaxChars() int
47
  ToolsEnabled() bool
internal/httpapi/openai/history/current_input_file.go CHANGED
@@ -37,23 +37,23 @@ type Service struct {
37
  }
38
 
39
  func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
40
- // 思考格式注入会写进最新一条用户消息,必须发生在历史拆分之前,
41
- // 否则拆分后上传的上下文文件里就缺了这段要求。
42
  stdReq = applyThinkingInjection(a, stdReq)
43
  appliedBefore := stdReq.CurrentInputFileApplied
44
  stdReq, err := s.applyCurrentInputFile(ctx, a, stdReq)
45
  if err != nil {
46
  return stdReq, err
47
  }
48
- // 拆分发生时会用衔接指令整段替换 FinalPrompt,拆之前注入的思考要求因此只剩
49
- // 在附件里。这里把同一段要求贴回可见 prompt,否则「执行契约」在长对话中
50
- // 实际上等于没注入。只在本轮新发生拆分时贴一次,避免切号重传重复叠加。
51
  if !appliedBefore && stdReq.CurrentInputFileApplied {
52
  stdReq = applyThinkingInjectionToVisiblePrompt(a, stdReq)
53
  }
54
- // 纯文本增强(提示词特化层、技能库)都是 prompt 级叠加,必须放在历史拆分
55
- // 之后:拆分会把 prompt 整段替换成上下文文件说明,提前写入会被覆盖。
56
- return applyOverlays(a, stdReq), nil
57
  }
58
 
59
  func (s Service) applyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
@@ -142,13 +142,15 @@ func (s Service) applyCurrentInputFile(ctx context.Context, a *auth.RequestAuth,
142
  } else if len(stdReq.ToolNames) == 0 && len(toolNames) > 0 {
143
  stdReq.ToolNames = toolNames
144
  }
 
 
145
  promptMessages = append(promptMessages, map[string]any{
146
  "role": "user",
147
- "content": currentInputFilePrompt(filename, hasToolsFile),
148
  })
149
  finalPrompt, _ := promptcompat.BuildOpenAIPrompt(promptMessages, nil, "", stdReq.ToolChoice, stdReq.Thinking)
150
  if strings.TrimSpace(finalPrompt) == "" {
151
- finalPrompt = currentInputFilePrompt(filename, hasToolsFile)
152
  }
153
 
154
  promptTokenText := historyText
@@ -235,40 +237,82 @@ func (s Service) ReuploadAppliedCurrentInputFile(ctx context.Context, a *auth.Re
235
  return stdReq, nil
236
  }
237
 
 
 
 
 
 
 
 
 
238
  // applyThinkingInjection 应用本次请求生效的「思考格式注入」设置。
239
  // 设置已在鉴权阶段完成全局 + 单 Key 自定义的合并,这里只负责落盘到请求里。
240
  func applyThinkingInjection(a *auth.RequestAuth, stdReq promptcompat.StandardRequest) promptcompat.StandardRequest {
241
  if a == nil {
242
  return stdReq
243
  }
244
- return promptcompat.ApplyThinkingInjectionToRequest(stdReq, a.Injection.Enabled, a.Injection.Prompt)
 
245
  }
246
 
247
  // applyThinkingInjectionToVisiblePrompt 在历史拆分替换掉 prompt 之后,
248
- // 把思考契约重新贴到可见 prompt 上(只改 FinalPrompt)。
249
  func applyThinkingInjectionToVisiblePrompt(a *auth.RequestAuth, stdReq promptcompat.StandardRequest) promptcompat.StandardRequest {
250
  if a == nil {
251
  return stdReq
252
  }
253
- return promptcompat.ReapplyThinkingInjectionToPrompt(stdReq, a.Injection.Enabled, a.Injection.Prompt)
 
254
  }
255
 
256
- // applyOverlays 把 prompt 级叠加层(提示词特化层、纯文本技能库)合进最终 prompt。
 
 
 
 
 
 
257
  //
258
- // 两段合成一个 System 段,顺序固定:特化层在外层(通用规范),技能在内层
259
- // (更贴近当前任务)。设置已在鉴权阶段完成全局 + 单 Key 自定义的合并与过滤。
260
- func applyOverlays(a *auth.RequestAuth, stdReq promptcompat.StandardRequest) promptcompat.StandardRequest {
261
- if a == nil {
262
- return stdReq
263
- }
264
  blocks := make([]string, 0, 2)
265
- blocks = append(blocks, promptcompat.PromptProfileBlock(a.PromptProfile))
266
- if a.Skills.Enabled && len(a.Skills.Items) > 0 {
 
 
 
 
267
  blocks = append(blocks, promptcompat.BuildSkillsPrompt(a.Skills.Items, a.Skills.MaxChars))
268
  }
269
  return promptcompat.ApplySystemOverlays(stdReq, blocks...)
270
  }
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  func latestUserInputForFile(messages []any) (int, string) {
273
  for i := len(messages) - 1; i >= 0; i-- {
274
  msg, ok := messages[i].(map[string]any)
@@ -288,8 +332,8 @@ func latestUserInputForFile(messages []any) (int, string) {
288
  return -1, ""
289
  }
290
 
291
- func currentInputFilePrompt(filename string, hasToolsFile bool) string {
292
- return promptcompat.BuildCurrentInputContinuationPrompt(filename, hasToolsFile)
293
  }
294
 
295
  func randomContextFilename() string {
 
37
  }
38
 
39
  func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
40
+ // 执行契约会写进最新一条用户消息,必须发生在历史拆分之前,
41
+ // 否则拆分后上传的上下文文件里就缺了这段要求(旧版风格不注入内置契约)。
42
  stdReq = applyThinkingInjection(a, stdReq)
43
  appliedBefore := stdReq.CurrentInputFileApplied
44
  stdReq, err := s.applyCurrentInputFile(ctx, a, stdReq)
45
  if err != nil {
46
  return stdReq, err
47
  }
48
+ // 拆分发生时会用衔接指令整段替换 FinalPrompt,拆之前注入的契约因此只剩
49
+ // 在附件里。这里把同一段文本贴回可见 prompt,否则它在长对话中实际上
50
+ // 等于没注入。只在本轮新发生拆分时贴一次,避免切号重传重复叠加。
51
  if !appliedBefore && stdReq.CurrentInputFileApplied {
52
  stdReq = applyThinkingInjectionToVisiblePrompt(a, stdReq)
53
  }
54
+ // 纯文本增强(模型提示词 / 提示词特化层 / 技能库)都是 prompt 级叠加,必须
55
+ // 放在历史拆分之后:拆分会把 prompt 整段替换成上下文文件说明,提前写入会被覆盖。
56
+ return s.applyOverlays(a, stdReq), nil
57
  }
58
 
59
  func (s Service) applyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
 
142
  } else if len(stdReq.ToolNames) == 0 && len(toolNames) > 0 {
143
  stdReq.ToolNames = toolNames
144
  }
145
+ style := promptStyleOf(a)
146
+ continuation := currentInputFilePrompt(style, filename, hasToolsFile)
147
  promptMessages = append(promptMessages, map[string]any{
148
  "role": "user",
149
+ "content": continuation,
150
  })
151
  finalPrompt, _ := promptcompat.BuildOpenAIPrompt(promptMessages, nil, "", stdReq.ToolChoice, stdReq.Thinking)
152
  if strings.TrimSpace(finalPrompt) == "" {
153
+ finalPrompt = continuation
154
  }
155
 
156
  promptTokenText := historyText
 
237
  return stdReq, nil
238
  }
239
 
240
+ // promptStyleOf 返回本次请求生效的内置提示词风格;旧版风格不注入内置执行契约。
241
+ func promptStyleOf(a *auth.RequestAuth) string {
242
+ if a == nil {
243
+ return config.PromptStyleV2
244
+ }
245
+ return config.NormalizePromptStyle(a.PromptStyle)
246
+ }
247
+
248
  // applyThinkingInjection 应用本次请求生效的「思考格式注入」设置。
249
  // 设置已在鉴权阶段完成全局 + 单 Key 自定义的合并,这里只负责落盘到请求里。
250
  func applyThinkingInjection(a *auth.RequestAuth, stdReq promptcompat.StandardRequest) promptcompat.StandardRequest {
251
  if a == nil {
252
  return stdReq
253
  }
254
+ return promptcompat.ApplyThinkingInjectionToRequest(
255
+ stdReq, promptStyleOf(a), a.Injection.Enabled, a.Injection.Prompt)
256
  }
257
 
258
  // applyThinkingInjectionToVisiblePrompt 在历史拆分替换掉 prompt 之后,
259
+ // 把执行契约重新贴到可见 prompt 上(只改 FinalPrompt)。
260
  func applyThinkingInjectionToVisiblePrompt(a *auth.RequestAuth, stdReq promptcompat.StandardRequest) promptcompat.StandardRequest {
261
  if a == nil {
262
  return stdReq
263
  }
264
+ return promptcompat.ReapplyThinkingInjectionToPrompt(
265
+ stdReq, promptStyleOf(a), a.Injection.Enabled, a.Injection.Prompt)
266
  }
267
 
268
+ // modelPromptReader 由配置中心实现,用于读取自定义模型条目的专属提示词。
269
+ // 用可选接口而不是写进 CurrentInputConfigReader,是为了不打断测试里的轻量假实现。
270
+ type modelPromptReader interface {
271
+ ResolveModelPrompt(modelID string) (string, bool)
272
+ }
273
+
274
+ // applyOverlays 把 prompt 级叠加层(模型专属提示词 / 提示词特化层 / 技能库)合进最终 prompt��
275
  //
276
+ // 分段规则:
277
+ // - 外层规范二选一:命中的自定义模型若带提示词,就用它的,否则回落到全局 prompt_profile;
278
+ // - 内层是技能库(更贴近当前任务);
279
+ // - 全部合成一个 System 段。
280
+ func (s Service) applyOverlays(a *auth.RequestAuth, stdReq promptcompat.StandardRequest) promptcompat.StandardRequest {
 
281
  blocks := make([]string, 0, 2)
282
+ if body := s.modelProfilePrompt(stdReq); body != "" {
283
+ blocks = append(blocks, body)
284
+ } else if a != nil {
285
+ blocks = append(blocks, promptcompat.PromptProfileBlock(a.PromptProfile))
286
+ }
287
+ if a != nil && a.Skills.Enabled && len(a.Skills.Items) > 0 {
288
  blocks = append(blocks, promptcompat.BuildSkillsPrompt(a.Skills.Items, a.Skills.MaxChars))
289
  }
290
  return promptcompat.ApplySystemOverlays(stdReq, blocks...)
291
  }
292
 
293
+ // modelProfilePrompt 返回客户端所选自定义模型自带的提示词;没有则返回空串。
294
+ //
295
+ // 用 RequestedModel 而不是 ResolvedModel:解析后的模型名已经是 flash 基模,
296
+ // 拿不回「客户端到底选了哪个自定义模型」。
297
+ func (s Service) modelProfilePrompt(stdReq promptcompat.StandardRequest) string {
298
+ if s.Store == nil {
299
+ return ""
300
+ }
301
+ reader, ok := s.Store.(modelPromptReader)
302
+ if !ok {
303
+ return ""
304
+ }
305
+ modelID := strings.TrimSpace(stdReq.RequestedModel)
306
+ if modelID == "" {
307
+ return ""
308
+ }
309
+ body, ok := reader.ResolveModelPrompt(modelID)
310
+ if !ok {
311
+ return ""
312
+ }
313
+ return strings.TrimSpace(body)
314
+ }
315
+
316
  func latestUserInputForFile(messages []any) (int, string) {
317
  for i := len(messages) - 1; i >= 0; i-- {
318
  msg, ok := messages[i].(map[string]any)
 
332
  return -1, ""
333
  }
334
 
335
+ func currentInputFilePrompt(style, filename string, hasToolsFile bool) string {
336
+ return promptcompat.BuildCurrentInputContinuationPrompt(style, filename, hasToolsFile)
337
  }
338
 
339
  func randomContextFilename() string {
internal/httpapi/openai/history/current_input_file_overlay_test.go ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package history
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+ "testing"
7
+
8
+ "ds2api/internal/auth"
9
+ "ds2api/internal/config"
10
+ "ds2api/internal/promptcompat"
11
+ )
12
+
13
+ // overlayTestStore 让历史拆分保持开启,并可按模型名返回专属提示词。
14
+ type overlayTestStore struct {
15
+ enabled bool
16
+ minChars int
17
+ modelPrompts map[string]string
18
+ }
19
+
20
+ func (s overlayTestStore) CurrentInputFileEnabled() bool { return s.enabled }
21
+ func (s overlayTestStore) CurrentInputFileMinChars() int { return s.minChars }
22
+
23
+ func (s overlayTestStore) ResolveModelPrompt(modelID string) (string, bool) {
24
+ for id, prompt := range s.modelPrompts {
25
+ if strings.EqualFold(strings.TrimSpace(id), strings.TrimSpace(modelID)) {
26
+ if strings.TrimSpace(prompt) == "" {
27
+ return "", false
28
+ }
29
+ return prompt, true
30
+ }
31
+ }
32
+ return "", false
33
+ }
34
+
35
+ func overlayTestRequest(requestedModel string) promptcompat.StandardRequest {
36
+ return promptcompat.StandardRequest{
37
+ RequestedModel: requestedModel,
38
+ ResolvedModel: "deepseek-v4-flash",
39
+ Messages: []any{
40
+ map[string]any{"role": "user", "content": "先看这个仓库"},
41
+ map[string]any{"role": "assistant", "content": "看完了"},
42
+ map[string]any{"role": "user", "content": "按刚才的结论写三段"},
43
+ },
44
+ FinalPrompt: "User: 按刚才的结论写三段\nAssistant: ",
45
+ PromptTokenText: "User: 按刚才的结论写三段\nAssistant: ",
46
+ }
47
+ }
48
+
49
+ // 选中带提示词的自定义模型时,用模型自己的提示词,不再叠加全局特化层。
50
+ func TestModelProfilePromptWinsOverGlobalProfile(t *testing.T) {
51
+ svc := Service{
52
+ Store: overlayTestStore{
53
+ enabled: true,
54
+ modelPrompts: map[string]string{"deepseek-writer": "model spec"},
55
+ },
56
+ DS: &skillsTestUploader{},
57
+ }
58
+ a := &auth.RequestAuth{
59
+ PromptProfile: config.PromptProfileSettings{Enabled: true, Name: config.PromptProfileNameWriting},
60
+ }
61
+ out, err := svc.ApplyCurrentInputFile(context.Background(), a, overlayTestRequest("deepseek-writer"))
62
+ if err != nil {
63
+ t.Fatalf("apply current input file failed: %v", err)
64
+ }
65
+ if !strings.Contains(out.FinalPrompt, "model spec") {
66
+ t.Fatalf("expected model prompt in final prompt, got %q", out.FinalPrompt)
67
+ }
68
+ globalBody := promptcompat.BuildPromptProfilePrompt(config.PromptProfileNameWriting, "")
69
+ if strings.Contains(out.FinalPrompt, globalBody) {
70
+ t.Fatalf("global prompt profile must not stack with the model prompt, got %q", out.FinalPrompt)
71
+ }
72
+ if got := strings.Count(out.FinalPrompt, "System: "); got != 1 {
73
+ t.Fatalf("expected one System marker, got %d", got)
74
+ }
75
+ }
76
+
77
+ // 模型名没命中(或该模型没配提示词)时回落到全局特化层。
78
+ func TestModelProfilePromptFallsBackToGlobalProfile(t *testing.T) {
79
+ cases := []struct {
80
+ name string
81
+ requested string
82
+ modelPrompts map[string]string
83
+ }{
84
+ {name: "unknown model", requested: "deepseek-v4-flash"},
85
+ {name: "model without prompt", requested: "deepseek-plain", modelPrompts: map[string]string{"deepseek-plain": " "}},
86
+ }
87
+ for _, tc := range cases {
88
+ t.Run(tc.name, func(t *testing.T) {
89
+ svc := Service{
90
+ Store: overlayTestStore{enabled: true, modelPrompts: tc.modelPrompts},
91
+ DS: &skillsTestUploader{},
92
+ }
93
+ a := &auth.RequestAuth{
94
+ PromptProfile: config.PromptProfileSettings{Enabled: true, Name: config.PromptProfileNameWriting},
95
+ }
96
+ out, err := svc.ApplyCurrentInputFile(context.Background(), a, overlayTestRequest(tc.requested))
97
+ if err != nil {
98
+ t.Fatalf("apply current input file failed: %v", err)
99
+ }
100
+ if !strings.Contains(out.FinalPrompt, promptcompat.BuildPromptProfilePrompt(config.PromptProfileNameWriting, "")) {
101
+ t.Fatalf("expected global profile fallback, got %q", out.FinalPrompt)
102
+ }
103
+ })
104
+ }
105
+ }
106
+
107
+ // 模型提示词与技能层合并成一个 System 段,顺序:模型 → 技能。
108
+ func TestModelProfilePromptCombinesWithSkills(t *testing.T) {
109
+ svc := Service{
110
+ Store: overlayTestStore{
111
+ enabled: true,
112
+ modelPrompts: map[string]string{"deepseek-writer": "model spec"},
113
+ },
114
+ DS: &skillsTestUploader{},
115
+ }
116
+ a := &auth.RequestAuth{
117
+ Skills: config.SkillSettings{
118
+ Enabled: true,
119
+ Items: []config.SkillConfig{{ID: "sql-report", Body: "sql body"}},
120
+ },
121
+ }
122
+ out, err := svc.ApplyCurrentInputFile(context.Background(), a, overlayTestRequest("deepseek-writer"))
123
+ if err != nil {
124
+ t.Fatalf("apply current input file failed: %v", err)
125
+ }
126
+ if got := strings.Count(out.FinalPrompt, "System: "); got != 1 {
127
+ t.Fatalf("expected one System marker, got %d in %q", got, out.FinalPrompt)
128
+ }
129
+ modelIdx := strings.Index(out.FinalPrompt, "model spec")
130
+ skillIdx := strings.Index(out.FinalPrompt, `<skill id="sql-report">`)
131
+ if modelIdx < 0 || skillIdx < 0 || modelIdx > skillIdx {
132
+ t.Fatalf("unexpected layer order in %q", out.FinalPrompt)
133
+ }
134
+ }
135
+
136
+ // 旧版风格:历史拆分后既不带「执行要求」,也不注入内置执行契约。
137
+ func TestLegacyStyleKeepsSplitPromptMinimal(t *testing.T) {
138
+ svc := Service{Store: overlayTestStore{enabled: true}, DS: &skillsTestUploader{}}
139
+ a := &auth.RequestAuth{
140
+ Injection: config.ThinkingInjectionSettings{Enabled: true},
141
+ PromptStyle: config.PromptStyleLegacy,
142
+ }
143
+ out, err := svc.ApplyCurrentInputFile(context.Background(), a, overlayTestRequest("deepseek-v4-flash"))
144
+ if err != nil {
145
+ t.Fatalf("apply current input file failed: %v", err)
146
+ }
147
+ if !out.CurrentInputFileApplied {
148
+ t.Fatal("expected history split to be applied")
149
+ }
150
+ if strings.Contains(out.FinalPrompt, "## 执行要求") {
151
+ t.Fatalf("legacy prompt must not carry the requirement block, got %q", out.FinalPrompt)
152
+ }
153
+ if strings.Contains(out.FinalPrompt, promptcompat.ThinkingInjectionMarker) {
154
+ t.Fatalf("legacy prompt must not carry the built-in contract, got %q", out.FinalPrompt)
155
+ }
156
+ if !strings.Contains(out.FinalPrompt, out.CurrentInputFilename) {
157
+ t.Fatalf("expected continuation to reference the context file, got %q", out.FinalPrompt)
158
+ }
159
+ // 对话记录文件里也不应出现契约:旧版风格下压根没注入。
160
+ if strings.Contains(out.HistoryText, promptcompat.ThinkingInjectionMarker) {
161
+ t.Fatalf("legacy transcript must not carry the contract, got %q", out.HistoryText)
162
+ }
163
+ }
164
+
165
+ // 旧版风格 + 用户自定义提示词:自定义内容仍然注入并重贴到可见 prompt。
166
+ func TestLegacyStyleStillHonoursCustomInjection(t *testing.T) {
167
+ svc := Service{Store: overlayTestStore{enabled: true}, DS: &skillsTestUploader{}}
168
+ a := &auth.RequestAuth{
169
+ Injection: config.ThinkingInjectionSettings{Enabled: true, Prompt: "custom contract"},
170
+ PromptStyle: config.PromptStyleLegacy,
171
+ }
172
+ out, err := svc.ApplyCurrentInputFile(context.Background(), a, overlayTestRequest("deepseek-v4-flash"))
173
+ if err != nil {
174
+ t.Fatalf("apply current input file failed: %v", err)
175
+ }
176
+ if !strings.Contains(out.FinalPrompt, "custom contract") {
177
+ t.Fatalf("expected custom contract in the visible prompt, got %q", out.FinalPrompt)
178
+ }
179
+ if !strings.HasSuffix(out.FinalPrompt, "Assistant: ") {
180
+ t.Fatalf("prompt must still end with the assistant marker, got %q", out.FinalPrompt)
181
+ }
182
+ }
internal/httpapi/openai/history/current_input_file_prompt_test.go CHANGED
@@ -3,14 +3,16 @@ package history
3
  import (
4
  "strings"
5
  "testing"
 
 
6
  )
7
 
8
  // 续接指令的正文已经收敛到 promptcompat(唯一来源),这里只钉住 history 这一层
9
- // 的委托行为:文件名插值、反泄漏说明、工具说明文件分支。
10
  func TestCurrentInputFilePromptDelegatesToPromptcompat(t *testing.T) {
11
  const filename = "ctx-abc123def456.txt"
12
  for i := 0; i < 16; i++ {
13
- prompt := currentInputFilePrompt(filename, false)
14
  if !strings.Contains(prompt, filename) {
15
  t.Fatalf("expected filename in prompt, got %q", prompt)
16
  }
@@ -32,16 +34,16 @@ func TestCurrentInputFilePromptDelegatesToPromptcompat(t *testing.T) {
32
  }
33
 
34
  func TestCurrentInputFilePromptMentionsToolsFileWhenPresent(t *testing.T) {
35
- prompt := currentInputFilePrompt("ctx-abc123def456.txt", true)
36
  if !strings.Contains(prompt, "context_tools.txt") {
37
  t.Fatalf("expected tools file reference, got %q", prompt)
38
  }
39
  }
40
 
41
  func TestCurrentInputFilePromptMatchesPromptcompatBuilder(t *testing.T) {
42
- // 防止有人把文案又抄回 history 包:包装函数的输出必须以 promptcompat
43
  // 共享要求块收尾,说明只有一份文案在生效。
44
- prompt := currentInputFilePrompt("ctx-1.txt", false)
45
  if !strings.HasSuffix(prompt, "直接从正文开始。") {
46
  t.Fatalf("expected the shared requirement block at the tail, got %q", prompt)
47
  }
@@ -49,3 +51,22 @@ func TestCurrentInputFilePromptMatchesPromptcompatBuilder(t *testing.T) {
49
  t.Fatalf("expected a single requirement block, got %q", prompt)
50
  }
51
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import (
4
  "strings"
5
  "testing"
6
+
7
+ "ds2api/internal/config"
8
  )
9
 
10
  // 续接指令的正文已经收敛到 promptcompat(唯一来源),这里只钉住 history 这一层
11
+ // 的委托行为:文件名插值、反泄漏说明、工具说明文件分支、风格切换。
12
  func TestCurrentInputFilePromptDelegatesToPromptcompat(t *testing.T) {
13
  const filename = "ctx-abc123def456.txt"
14
  for i := 0; i < 16; i++ {
15
+ prompt := currentInputFilePrompt(config.PromptStyleV2, filename, false)
16
  if !strings.Contains(prompt, filename) {
17
  t.Fatalf("expected filename in prompt, got %q", prompt)
18
  }
 
34
  }
35
 
36
  func TestCurrentInputFilePromptMentionsToolsFileWhenPresent(t *testing.T) {
37
+ prompt := currentInputFilePrompt(config.PromptStyleV2, "ctx-abc123def456.txt", true)
38
  if !strings.Contains(prompt, "context_tools.txt") {
39
  t.Fatalf("expected tools file reference, got %q", prompt)
40
  }
41
  }
42
 
43
  func TestCurrentInputFilePromptMatchesPromptcompatBuilder(t *testing.T) {
44
+ // 防止有人把文案又抄回 history 包:v2 包装函数的输出必须以 promptcompat
45
  // 共享要求块收尾,说明只有一份文案在生效。
46
+ prompt := currentInputFilePrompt(config.PromptStyleV2, "ctx-1.txt", false)
47
  if !strings.HasSuffix(prompt, "直接从正文开始。") {
48
  t.Fatalf("expected the shared requirement block at the tail, got %q", prompt)
49
  }
 
51
  t.Fatalf("expected a single requirement block, got %q", prompt)
52
  }
53
  }
54
+
55
+ // 旧版风格:短文案、没有「执行要求」,仍然带反泄漏说明。
56
+ func TestCurrentInputFilePromptLegacyStyleIsShortAndLeakSafe(t *testing.T) {
57
+ for i := 0; i < 16; i++ {
58
+ prompt := currentInputFilePrompt(config.PromptStyleLegacy, "ctx-1.txt", false)
59
+ if strings.Contains(prompt, "## 执行要求") {
60
+ t.Fatalf("legacy prompt must not carry the requirement block, got %q", prompt)
61
+ }
62
+ if !strings.Contains(prompt, "ctx-1.txt") {
63
+ t.Fatalf("expected filename in legacy prompt, got %q", prompt)
64
+ }
65
+ if !strings.Contains(prompt, "不要") {
66
+ t.Fatalf("expected a no-leak clause in legacy prompt, got %q", prompt)
67
+ }
68
+ if len([]rune(prompt)) > 120 {
69
+ t.Fatalf("legacy prompt should stay short, got %q", prompt)
70
+ }
71
+ }
72
+ }
internal/httpapi/openai/shared/models.go CHANGED
@@ -14,7 +14,7 @@ type ModelsHandler struct {
14
  }
15
 
16
  func (h *ModelsHandler) ListModels(w http.ResponseWriter, _ *http.Request) {
17
- WriteJSON(w, http.StatusOK, config.OpenAIModelsResponse())
18
  }
19
 
20
  func (h *ModelsHandler) GetModel(w http.ResponseWriter, r *http.Request) {
 
14
  }
15
 
16
  func (h *ModelsHandler) ListModels(w http.ResponseWriter, _ *http.Request) {
17
+ WriteJSON(w, http.StatusOK, config.OpenAIModelsResponseFor(h.Store))
18
  }
19
 
20
  func (h *ModelsHandler) GetModel(w http.ResponseWriter, r *http.Request) {
internal/promptcompat/prompt_texts.go CHANGED
@@ -57,12 +57,37 @@ const currentInputContinuationRequirements = "\n\n## 执行要求\n" +
57
  // 工具说明文件当前不再生成(见 tool_prompt.go),这里保留兜底措辞。
58
  const currentInputContinuationToolsHint = "\n本轮可用的工具与参数说明在 context_tools.txt 中,只能使用该文件列出的工具。"
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  // BuildCurrentInputContinuationPrompt 构造历史拆分后唯一可见的用户轮次指令。
61
  //
62
- // filename 是上传到上游的对话记录文件名;hasToolsFile 表示本轮是否额外上传了
63
- // 工具说明文件(当前恒为 false,见 tool_prompt.go)。
64
- func BuildCurrentInputContinuationPrompt(filename string, hasToolsFile bool) string {
 
65
  filename = strings.TrimSpace(filename)
 
 
 
 
 
 
 
66
  opening := pickPromptVariant(currentInputContinuationOpenings)
67
  text := strings.ReplaceAll(opening, "%s", filename) + currentInputContinuationRequirements
68
  if hasToolsFile {
 
57
  // 工具说明文件当前不再生成(见 tool_prompt.go),这里保留兜底措辞。
58
  const currentInputContinuationToolsHint = "\n本轮可用的工具与参数说明在 context_tools.txt 中,只能使用该文件列出的工具。"
59
 
60
+ // legacyContinuationOpenings 是「旧版」风格的衔接指令。
61
+ //
62
+ // 这三句取自 ce5a019 之前的原文,刻意保持极短:旧版手感来自「几乎不加约束」,
63
+ // 模型按自己的默认风格作答,不会被要求先清点要求再动笔。
64
+ //
65
+ // 唯一做的规范化:旧文案里只有第一句明确写了「不要提及文件/上下文/提示词」,
66
+ // 第二句只写了「避免说明你在使用上下文文件」。这里统一成同一句防泄漏要求,
67
+ // 措辞强度一致,长度基本不变——防泄漏不属于「风格约束」,不该因为切旧版而放松。
68
+ var legacyContinuationOpenings = []string{
69
+ "已附上下文文件「%s」。请直接回答最新问题,不要提及文件、上下文或提示词本身。",
70
+ "请从文件 %s 继续处理本轮任务,给出答案,不要提及文件、上下文或提示词本身。",
71
+ "上下文已存入 %s,请将其视为唯一上下文并直接答复;不要复述提示或提到文件。",
72
+ }
73
+
74
+ // legacyContinuationToolsHint 是旧版在带工具说明文件时的补充说明。
75
+ const legacyContinuationToolsHint = " 可用工具与参数说明在 context_tools.txt 中,仅可依据该文件调用工具。"
76
+
77
  // BuildCurrentInputContinuationPrompt 构造历史拆分后唯一可见的用户轮次指令。
78
  //
79
+ // style 决定用哪一版文案:config.PromptStyleLegacy 用旧版短文案,其余(含空值
80
+ // 与未知值)一律按 v2 处理。filename 是上传到上游的对话记录文件名;
81
+ // hasToolsFile 表示本轮是否额外上传了工具说明文件(当前恒为 false,见 tool_prompt.go)。
82
+ func BuildCurrentInputContinuationPrompt(style, filename string, hasToolsFile bool) string {
83
  filename = strings.TrimSpace(filename)
84
+ if config.NormalizePromptStyle(style) == config.PromptStyleLegacy {
85
+ text := strings.ReplaceAll(pickPromptVariant(legacyContinuationOpenings), "%s", filename)
86
+ if hasToolsFile {
87
+ text += legacyContinuationToolsHint
88
+ }
89
+ return text
90
+ }
91
  opening := pickPromptVariant(currentInputContinuationOpenings)
92
  text := strings.ReplaceAll(opening, "%s", filename) + currentInputContinuationRequirements
93
  if hasToolsFile {
internal/promptcompat/prompt_texts_test.go CHANGED
@@ -11,7 +11,7 @@ func TestBuildCurrentInputContinuationPromptMentionsFilenameAndRequirements(t *t
11
  const filename = "ctx-abc123def456.txt"
12
  seenOpenings := map[string]struct{}{}
13
  for i := 0; i < 64; i++ {
14
- prompt := BuildCurrentInputContinuationPrompt(filename, false)
15
  if !strings.Contains(prompt, filename) {
16
  t.Fatalf("expected filename in prompt, got %q", prompt)
17
  }
@@ -73,7 +73,7 @@ func TestBuildCurrentInputContinuationPromptSharesRequirementBlock(t *testing.T)
73
  }
74
 
75
  func TestBuildCurrentInputContinuationPromptMentionsToolsFileWhenPresent(t *testing.T) {
76
- prompt := BuildCurrentInputContinuationPrompt("ctx-abc123def456.txt", true)
77
  if !strings.Contains(prompt, "context_tools.txt") {
78
  t.Fatalf("expected tools file reference, got %q", prompt)
79
  }
@@ -82,7 +82,7 @@ func TestBuildCurrentInputContinuationPromptMentionsToolsFileWhenPresent(t *test
82
  func TestBuildCurrentInputContinuationPromptTrimsFilename(t *testing.T) {
83
  // 调用方保证 filename 非空(随机 ctx-*.txt 兜底),这里只要求两侧空白被裁掉,
84
  // 不要出现「附件「 ctx-1.txt 」里」这种带空格的怪句子。
85
- prompt := BuildCurrentInputContinuationPrompt(" ctx-1.txt ", false)
86
  if !strings.Contains(prompt, "「ctx-1.txt」") {
87
  t.Fatalf("expected trimmed filename inside the quotes, got %q", prompt)
88
  }
 
11
  const filename = "ctx-abc123def456.txt"
12
  seenOpenings := map[string]struct{}{}
13
  for i := 0; i < 64; i++ {
14
+ prompt := BuildCurrentInputContinuationPrompt(config.PromptStyleV2, filename, false)
15
  if !strings.Contains(prompt, filename) {
16
  t.Fatalf("expected filename in prompt, got %q", prompt)
17
  }
 
73
  }
74
 
75
  func TestBuildCurrentInputContinuationPromptMentionsToolsFileWhenPresent(t *testing.T) {
76
+ prompt := BuildCurrentInputContinuationPrompt(config.PromptStyleV2, "ctx-abc123def456.txt", true)
77
  if !strings.Contains(prompt, "context_tools.txt") {
78
  t.Fatalf("expected tools file reference, got %q", prompt)
79
  }
 
82
  func TestBuildCurrentInputContinuationPromptTrimsFilename(t *testing.T) {
83
  // 调用方保证 filename 非空(随机 ctx-*.txt 兜底),这里只要求两侧空白被裁掉,
84
  // 不要出现「附件「 ctx-1.txt 」里」这种带空格的怪句子。
85
+ prompt := BuildCurrentInputContinuationPrompt(config.PromptStyleV2, " ctx-1.txt ", false)
86
  if !strings.Contains(prompt, "「ctx-1.txt」") {
87
  t.Fatalf("expected trimmed filename inside the quotes, got %q", prompt)
88
  }
internal/promptcompat/thinking_injection.go CHANGED
@@ -4,6 +4,7 @@ import (
4
  "fmt"
5
  "strings"
6
 
 
7
  "ds2api/internal/prompt"
8
  )
9
 
@@ -33,15 +34,25 @@ const (
33
 
34
  // resolveThinkingInjectionPrompt 返回本次请求实际生效的注入文本。
35
  // 未启用时 ok=false;提示词为空或全空白时回落到内置默认提示词。
36
- func resolveThinkingInjectionPrompt(enabled bool, injectionPrompt string) (string, bool) {
 
 
 
 
 
 
 
 
37
  if !enabled {
38
  return "", false
39
  }
40
- injectionPrompt = strings.TrimSpace(injectionPrompt)
41
- if injectionPrompt == "" {
42
- injectionPrompt = DefaultThinkingInjectionPrompt
 
 
43
  }
44
- return injectionPrompt, true
45
  }
46
 
47
  // AppendThinkingInjectionToLatestUser 使用内置默认提示词注入。
@@ -52,9 +63,10 @@ func AppendThinkingInjectionToLatestUser(messages []any) ([]any, bool) {
52
  // ApplyThinkingInjectionToRequest 把注入提示词写入标准请求:
53
  // 既追加到最新一条 user 消息(历史拆分时会被带进上下文文件),
54
  // 也追加到已经拼装好的最终 prompt(落在助手起笔标记之前)。
55
- // 未启用、提示词为空或已经注入过时原样返回。
56
- func ApplyThinkingInjectionToRequest(stdReq StandardRequest, enabled bool, injectionPrompt string) StandardRequest {
57
- injectionPrompt, ok := resolveThinkingInjectionPrompt(enabled, injectionPrompt)
 
58
  if !ok {
59
  return stdReq
60
  }
@@ -79,8 +91,8 @@ func ApplyThinkingInjectionToRequest(stdReq StandardRequest, enabled bool, injec
79
  // 这里刻意只改 FinalPrompt:
80
  // - 不改 Messages:拆分后它们就是那条衔接指令,改它会让存档与记录失真;
81
  // - 不改 PromptTokenText:对话记录文件里已经有一份,重复计入会高估 token。
82
- func ReapplyThinkingInjectionToPrompt(stdReq StandardRequest, enabled bool, injectionPrompt string) StandardRequest {
83
- injectionPrompt, ok := resolveThinkingInjectionPrompt(enabled, injectionPrompt)
84
  if !ok {
85
  return stdReq
86
  }
 
4
  "fmt"
5
  "strings"
6
 
7
+ "ds2api/internal/config"
8
  "ds2api/internal/prompt"
9
  )
10
 
 
34
 
35
  // resolveThinkingInjectionPrompt 返回本次请求实际生效的注入文本。
36
  // 未启用时 ok=false;提示词为空或全空白时回落到内置默认提示词。
37
+ // ResolveThinkingInjectionPrompt 返回本次请求实际生效的注入文本。
38
+ //
39
+ // 判定顺序:
40
+ // 1. 开关关闭 → 不注入;
41
+ // 2. 显式配置了自定义提示词 → 注入自定义内容(任何风格都尊重用户意图);
42
+ // 3. 旧版风格(config.PromptStyleLegacy)→ 不注入内置执行契约,这正是旧版
43
+ // 「几乎不加约束」的手感;
44
+ // 4. 其余情况 → 注入内置默认执行契约。
45
+ func ResolveThinkingInjectionPrompt(style string, enabled bool, injectionPrompt string) (string, bool) {
46
  if !enabled {
47
  return "", false
48
  }
49
+ if custom := strings.TrimSpace(injectionPrompt); custom != "" {
50
+ return custom, true
51
+ }
52
+ if config.NormalizePromptStyle(style) == config.PromptStyleLegacy {
53
+ return "", false
54
  }
55
+ return DefaultThinkingInjectionPrompt, true
56
  }
57
 
58
  // AppendThinkingInjectionToLatestUser 使用内置默认提示词注入。
 
63
  // ApplyThinkingInjectionToRequest 把注入提示词写入标准请求:
64
  // 既追加到最新一条 user 消息(历史拆分时会被带进上下文文件),
65
  // 也追加到已经拼装好的最终 prompt(落在助手起笔标记之前)。
66
+ // style 决定是否注入内置执行契约(旧版风格不注入)。未启用、没有可注入内容
67
+ // 或已经注入过时原样返回。
68
+ func ApplyThinkingInjectionToRequest(stdReq StandardRequest, style string, enabled bool, injectionPrompt string) StandardRequest {
69
+ injectionPrompt, ok := ResolveThinkingInjectionPrompt(style, enabled, injectionPrompt)
70
  if !ok {
71
  return stdReq
72
  }
 
91
  // 这里刻意只改 FinalPrompt:
92
  // - 不改 Messages:拆分后它们就是那条衔接指令,改它会让存档与记录失真;
93
  // - 不改 PromptTokenText:对话记录文件里已经有一份,重复计入会高估 token。
94
+ func ReapplyThinkingInjectionToPrompt(stdReq StandardRequest, style string, enabled bool, injectionPrompt string) StandardRequest {
95
+ injectionPrompt, ok := ResolveThinkingInjectionPrompt(style, enabled, injectionPrompt)
96
  if !ok {
97
  return stdReq
98
  }
internal/promptcompat/thinking_injection_test.go CHANGED
@@ -3,6 +3,8 @@ package promptcompat
3
  import (
4
  "strings"
5
  "testing"
 
 
6
  )
7
 
8
  func TestAppendThinkingInjectionToLatestUserStringContent(t *testing.T) {
@@ -103,7 +105,7 @@ func TestApplyThinkingInjectionToRequestPatchesPromptAndMessages(t *testing.T) {
103
  FinalPrompt: "User: hello\nAssistant: ",
104
  PromptTokenText: "User: hello\nAssistant: ",
105
  }
106
- out := ApplyThinkingInjectionToRequest(stdReq, true, "think first")
107
  if !strings.Contains(out.FinalPrompt, "User: hello\nthink first\nAssistant: ") {
108
  t.Fatalf("expected injection before assistant marker, got %q", out.FinalPrompt)
109
  }
@@ -121,7 +123,7 @@ func TestApplyThinkingInjectionToRequestDisabled(t *testing.T) {
121
  Messages: []any{map[string]any{"role": "user", "content": "hello"}},
122
  FinalPrompt: "User: hello\nAssistant: ",
123
  }
124
- out := ApplyThinkingInjectionToRequest(stdReq, false, "think first")
125
  if out.FinalPrompt != stdReq.FinalPrompt {
126
  t.Fatalf("expected disabled injection to leave prompt untouched, got %q", out.FinalPrompt)
127
  }
@@ -141,7 +143,7 @@ func TestReapplyThinkingInjectionToPromptPatchesVisiblePromptOnly(t *testing.T)
141
  FinalPrompt: "User: 接着上文写完\nAssistant: ",
142
  PromptTokenText: "附件全文",
143
  }
144
- out := ReapplyThinkingInjectionToPrompt(stdReq, true, "think first")
145
  if !strings.Contains(out.FinalPrompt, "User: 接着上文写完\nthink first\nAssistant: ") {
146
  t.Fatalf("expected contract before the assistant marker, got %q", out.FinalPrompt)
147
  }
@@ -161,8 +163,8 @@ func TestReapplyThinkingInjectionToPromptPatchesVisiblePromptOnly(t *testing.T)
161
 
162
  func TestReapplyThinkingInjectionToPromptIsIdempotent(t *testing.T) {
163
  stdReq := StandardRequest{FinalPrompt: "User: hi\nAssistant: ", PromptTokenText: "token"}
164
- once := ReapplyThinkingInjectionToPrompt(stdReq, true, DefaultThinkingInjectionPrompt)
165
- twice := ReapplyThinkingInjectionToPrompt(once, true, DefaultThinkingInjectionPrompt)
166
  if strings.Count(twice.FinalPrompt, DefaultThinkingInjectionPrompt) != 1 {
167
  t.Fatalf("expected single injection, got %q", twice.FinalPrompt)
168
  }
@@ -170,19 +172,51 @@ func TestReapplyThinkingInjectionToPromptIsIdempotent(t *testing.T) {
170
 
171
  func TestReapplyThinkingInjectionToPromptSkipsWhenDisabled(t *testing.T) {
172
  stdReq := StandardRequest{FinalPrompt: "User: hi\nAssistant: "}
173
- out := ReapplyThinkingInjectionToPrompt(stdReq, false, "think first")
174
  if out.FinalPrompt != stdReq.FinalPrompt {
175
  t.Fatalf("expected disabled reapply to be a no-op, got %q", out.FinalPrompt)
176
  }
177
  }
178
 
179
  func TestReapplyThinkingInjectionToPromptEmptyPromptUsesBuiltinDefault(t *testing.T) {
180
- out := ReapplyThinkingInjectionToPrompt(StandardRequest{FinalPrompt: "User: hi\nAssistant: "}, true, " ")
181
  if !strings.Contains(out.FinalPrompt, ThinkingInjectionMarker) {
182
  t.Fatalf("expected built-in default contract, got %q", out.FinalPrompt)
183
  }
184
  }
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  // 执行契约必须做到三件事:上文真提过要求时逐条对上、语气跟着对方走、回复里不复述本段。
187
  func TestDefaultThinkingInjectionPromptCoversRequirementRecall(t *testing.T) {
188
  for _, want := range []string{
 
3
  import (
4
  "strings"
5
  "testing"
6
+
7
+ "ds2api/internal/config"
8
  )
9
 
10
  func TestAppendThinkingInjectionToLatestUserStringContent(t *testing.T) {
 
105
  FinalPrompt: "User: hello\nAssistant: ",
106
  PromptTokenText: "User: hello\nAssistant: ",
107
  }
108
+ out := ApplyThinkingInjectionToRequest(stdReq, config.PromptStyleV2, true, "think first")
109
  if !strings.Contains(out.FinalPrompt, "User: hello\nthink first\nAssistant: ") {
110
  t.Fatalf("expected injection before assistant marker, got %q", out.FinalPrompt)
111
  }
 
123
  Messages: []any{map[string]any{"role": "user", "content": "hello"}},
124
  FinalPrompt: "User: hello\nAssistant: ",
125
  }
126
+ out := ApplyThinkingInjectionToRequest(stdReq, config.PromptStyleV2, false, "think first")
127
  if out.FinalPrompt != stdReq.FinalPrompt {
128
  t.Fatalf("expected disabled injection to leave prompt untouched, got %q", out.FinalPrompt)
129
  }
 
143
  FinalPrompt: "User: 接着上文写完\nAssistant: ",
144
  PromptTokenText: "附件全文",
145
  }
146
+ out := ReapplyThinkingInjectionToPrompt(stdReq, config.PromptStyleV2, true, "think first")
147
  if !strings.Contains(out.FinalPrompt, "User: 接着上文写完\nthink first\nAssistant: ") {
148
  t.Fatalf("expected contract before the assistant marker, got %q", out.FinalPrompt)
149
  }
 
163
 
164
  func TestReapplyThinkingInjectionToPromptIsIdempotent(t *testing.T) {
165
  stdReq := StandardRequest{FinalPrompt: "User: hi\nAssistant: ", PromptTokenText: "token"}
166
+ once := ReapplyThinkingInjectionToPrompt(stdReq, config.PromptStyleV2, true, DefaultThinkingInjectionPrompt)
167
+ twice := ReapplyThinkingInjectionToPrompt(once, config.PromptStyleV2, true, DefaultThinkingInjectionPrompt)
168
  if strings.Count(twice.FinalPrompt, DefaultThinkingInjectionPrompt) != 1 {
169
  t.Fatalf("expected single injection, got %q", twice.FinalPrompt)
170
  }
 
172
 
173
  func TestReapplyThinkingInjectionToPromptSkipsWhenDisabled(t *testing.T) {
174
  stdReq := StandardRequest{FinalPrompt: "User: hi\nAssistant: "}
175
+ out := ReapplyThinkingInjectionToPrompt(stdReq, config.PromptStyleV2, false, "think first")
176
  if out.FinalPrompt != stdReq.FinalPrompt {
177
  t.Fatalf("expected disabled reapply to be a no-op, got %q", out.FinalPrompt)
178
  }
179
  }
180
 
181
  func TestReapplyThinkingInjectionToPromptEmptyPromptUsesBuiltinDefault(t *testing.T) {
182
+ out := ReapplyThinkingInjectionToPrompt(StandardRequest{FinalPrompt: "User: hi\nAssistant: "}, config.PromptStyleV2, true, " ")
183
  if !strings.Contains(out.FinalPrompt, ThinkingInjectionMarker) {
184
  t.Fatalf("expected built-in default contract, got %q", out.FinalPrompt)
185
  }
186
  }
187
 
188
+ // 旧版风格:不注入内置执行契约;用户显式配置的自定义提示词仍然生效。
189
+ func TestLegacyStyleSkipsBuiltinContract(t *testing.T) {
190
+ stdReq := StandardRequest{
191
+ Messages: []any{map[string]any{"role": "user", "content": "hello"}},
192
+ FinalPrompt: "User: hello\nAssistant: ",
193
+ PromptTokenText: "User: hello\nAssistant: ",
194
+ }
195
+ out := ApplyThinkingInjectionToRequest(stdReq, config.PromptStyleLegacy, true, "")
196
+ if strings.Contains(out.FinalPrompt, ThinkingInjectionMarker) {
197
+ t.Fatalf("legacy style must not inject the built-in contract, got %q", out.FinalPrompt)
198
+ }
199
+ if strings.Contains(out.FinalPrompt, ThinkingInjectionMarker) {
200
+ t.Fatalf("legacy style must not touch the prompt at all, got %q", out.FinalPrompt)
201
+ }
202
+ msg, _ := out.Messages[0].(map[string]any)
203
+ if msg["content"] != "hello" {
204
+ t.Fatalf("legacy style must not patch messages, got %#v", msg)
205
+ }
206
+
207
+ // 自定义提示词优先于风格:旧版风格下用户自己的要求依然注入。
208
+ custom := ApplyThinkingInjectionToRequest(stdReq, config.PromptStyleLegacy, true, "custom contract")
209
+ if !strings.Contains(custom.FinalPrompt, "custom contract") {
210
+ t.Fatalf("expected custom prompt to win over legacy style, got %q", custom.FinalPrompt)
211
+ }
212
+
213
+ // 未知风格按 v2 处理。
214
+ unknown := ApplyThinkingInjectionToRequest(stdReq, "nope", true, "")
215
+ if !strings.Contains(unknown.FinalPrompt, ThinkingInjectionMarker) {
216
+ t.Fatalf("unknown style must fall back to v2 contract, got %q", unknown.FinalPrompt)
217
+ }
218
+ }
219
+
220
  // 执行契约必须做到三件事:上文真提过要求时逐条对上、语气跟着对方走、回复里不复述本段。
221
  func TestDefaultThinkingInjectionPromptCoversRequirementRecall(t *testing.T) {
222
  for _, want := range []string{
webui/src/features/settings/BehaviorSection.jsx CHANGED
@@ -83,6 +83,29 @@ export default function BehaviorSection({ t, form, setForm }) {
83
  />
84
  <p className="text-xs text-muted-foreground">{t('settings.thinkingInjectionPromptHelp')}</p>
85
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  </div>
87
  </div>
88
  )
 
83
  />
84
  <p className="text-xs text-muted-foreground">{t('settings.thinkingInjectionPromptHelp')}</p>
85
  </div>
86
+ <div className="text-sm space-y-2 md:col-span-2">
87
+ <label htmlFor="prompt-style" className="text-muted-foreground">
88
+ {t('settings.promptStyleTitle')}
89
+ </label>
90
+ <select
91
+ id="prompt-style"
92
+ value={form.prompt_style?.mode || 'v2'}
93
+ onChange={(e) => setForm((prev) => ({
94
+ ...prev,
95
+ prompt_style: { ...prev.prompt_style, mode: e.target.value },
96
+ }))}
97
+ className="w-full bg-background border border-border rounded-lg px-3 py-2"
98
+ >
99
+ {(form.prompt_style?.modes || ['v2', 'legacy']).map((mode) => (
100
+ <option key={mode} value={mode}>
101
+ {mode === 'legacy'
102
+ ? t('settings.promptStyleLegacy')
103
+ : t('settings.promptStyleV2')}
104
+ </option>
105
+ ))}
106
+ </select>
107
+ <p className="text-xs text-muted-foreground">{t('settings.promptStyleHelp')}</p>
108
+ </div>
109
  </div>
110
  </div>
111
  )
webui/src/features/settings/ModelProfilesSection.jsx ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import { ChevronDown, ChevronUp, Plus, Save, Trash2 } from 'lucide-react'
3
+
4
+ import { fetchModelProfiles, putModelProfiles } from './settingsApi'
5
+
6
+ const FALLBACK_BASE_MODELS = [
7
+ 'deepseek-v4-flash',
8
+ 'deepseek-v4-flash-search',
9
+ 'deepseek-v4-flash-nothinking',
10
+ 'deepseek-v4-flash-search-nothinking',
11
+ ]
12
+
13
+ function emptyProfile() {
14
+ return { id: '', base: FALLBACK_BASE_MODELS[0], name: '', remark: '', prompt: '' }
15
+ }
16
+
17
+ function normalizeItems(data) {
18
+ const items = Array.isArray(data?.items) ? data.items : []
19
+ return items.map((item) => ({
20
+ id: String(item?.id || ''),
21
+ base: String(item?.base || FALLBACK_BASE_MODELS[0]),
22
+ name: String(item?.name || ''),
23
+ remark: String(item?.remark || ''),
24
+ prompt: String(item?.prompt || ''),
25
+ }))
26
+ }
27
+
28
+ export default function ModelProfilesSection({ t, apiFetch, onMessage }) {
29
+ const [loading, setLoading] = useState(false)
30
+ const [saving, setSaving] = useState(false)
31
+ const [items, setItems] = useState([])
32
+ const [baseModels, setBaseModels] = useState(FALLBACK_BASE_MODELS)
33
+ const [maxCount, setMaxCount] = useState(50)
34
+ const [maxPromptChars, setMaxPromptChars] = useState(20000)
35
+ const [expandedRows, setExpandedRows] = useState({})
36
+ const [error, setError] = useState('')
37
+
38
+ const loadProfiles = useCallback(async () => {
39
+ setLoading(true)
40
+ try {
41
+ const { res, data } = await fetchModelProfiles(apiFetch)
42
+ if (!res.ok) {
43
+ setError(data?.detail || t('settings.modelProfilesLoadFailed'))
44
+ return
45
+ }
46
+ setItems(normalizeItems(data))
47
+ if (Array.isArray(data?.base_models) && data.base_models.length > 0) {
48
+ setBaseModels(data.base_models)
49
+ }
50
+ if (Number(data?.max_count) > 0) {
51
+ setMaxCount(Number(data.max_count))
52
+ }
53
+ if (Number(data?.max_prompt_chars) > 0) {
54
+ setMaxPromptChars(Number(data.max_prompt_chars))
55
+ }
56
+ setError('')
57
+ } catch (_e) {
58
+ setError(t('settings.modelProfilesLoadFailed'))
59
+ } finally {
60
+ setLoading(false)
61
+ }
62
+ }, [apiFetch, t])
63
+
64
+ useEffect(() => {
65
+ loadProfiles()
66
+ }, [loadProfiles])
67
+
68
+ const updateItem = useCallback((index, patch) => {
69
+ setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)))
70
+ }, [])
71
+
72
+ const removeItem = useCallback((index) => {
73
+ setItems((prev) => prev.filter((_item, i) => i !== index))
74
+ setExpandedRows({})
75
+ }, [])
76
+
77
+ const addItem = useCallback(() => {
78
+ setItems((prev) => (prev.length >= maxCount ? prev : [...prev, emptyProfile()]))
79
+ }, [maxCount])
80
+
81
+ const toggleRow = useCallback((index) => {
82
+ setExpandedRows((prev) => ({ ...prev, [index]: !prev[index] }))
83
+ }, [])
84
+
85
+ const validate = useCallback(() => {
86
+ const seen = new Set()
87
+ for (let i = 0; i < items.length; i += 1) {
88
+ const item = items[i]
89
+ const id = String(item.id || '').trim()
90
+ if (!id) {
91
+ return t('settings.modelProfilesIdRequired', { index: i + 1 })
92
+ }
93
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(id)) {
94
+ return t('settings.modelProfilesIdInvalid', { id })
95
+ }
96
+ const key = id.toLowerCase()
97
+ if (seen.has(key)) {
98
+ return t('settings.modelProfilesIdDuplicate', { id })
99
+ }
100
+ seen.add(key)
101
+ if (!String(item.base || '').trim()) {
102
+ return t('settings.modelProfilesBaseRequired', { id })
103
+ }
104
+ if (String(item.prompt || '').length > maxPromptChars) {
105
+ return t('settings.modelProfilesPromptTooLong', { id, count: maxPromptChars })
106
+ }
107
+ }
108
+ return ''
109
+ }, [items, maxPromptChars, t])
110
+
111
+ const save = useCallback(async () => {
112
+ const message = validate()
113
+ if (message) {
114
+ setError(message)
115
+ onMessage?.('error', message)
116
+ return
117
+ }
118
+ setSaving(true)
119
+ try {
120
+ const payload = {
121
+ items: items.map((item) => ({
122
+ id: String(item.id || '').trim(),
123
+ base: String(item.base || '').trim(),
124
+ name: String(item.name || '').trim(),
125
+ remark: String(item.remark || '').trim(),
126
+ prompt: String(item.prompt || ''),
127
+ })),
128
+ }
129
+ const { res, data } = await putModelProfiles(apiFetch, payload)
130
+ if (!res.ok) {
131
+ const detail = data?.detail || t('settings.modelProfilesSaveFailed')
132
+ setError(detail)
133
+ onMessage?.('error', detail)
134
+ return
135
+ }
136
+ setError('')
137
+ onMessage?.('success', t('settings.modelProfilesSaveSuccess', { count: items.length }))
138
+ await loadProfiles()
139
+ } catch (_e) {
140
+ setError(t('settings.modelProfilesSaveFailed'))
141
+ onMessage?.('error', t('settings.modelProfilesSaveFailed'))
142
+ } finally {
143
+ setSaving(false)
144
+ }
145
+ }, [apiFetch, items, loadProfiles, maxPromptChars, onMessage, t, validate])
146
+
147
+ return (
148
+ <div className="bg-card border border-border rounded-xl p-5 space-y-4">
149
+ <div className="space-y-1">
150
+ <div className="flex items-center justify-between gap-3">
151
+ <h3 className="font-semibold">{t('settings.modelProfilesTitle')}</h3>
152
+ <span className="text-xs text-muted-foreground">
153
+ {t('settings.modelProfilesCount', { count: items.length })}
154
+ </span>
155
+ </div>
156
+ <p className="text-xs text-muted-foreground">{t('settings.modelProfilesDesc')}</p>
157
+ </div>
158
+
159
+ {error && <p className="text-xs text-destructive">{error}</p>}
160
+
161
+ {loading && <p className="text-xs text-muted-foreground">{t('actions.loading')}</p>}
162
+
163
+ {!loading && items.length === 0 && (
164
+ <p className="text-xs text-muted-foreground">{t('settings.modelProfilesEmpty')}</p>
165
+ )}
166
+
167
+ <div className="space-y-3">
168
+ {items.map((item, index) => {
169
+ const expanded = Boolean(expandedRows[index])
170
+ return (
171
+ <div key={`${index}-${item.id}`} className="rounded-lg border border-border bg-background/60 p-4 space-y-3">
172
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-3">
173
+ <label className="text-xs space-y-1">
174
+ <span className="text-muted-foreground block">{t('settings.modelProfilesId')}</span>
175
+ <input
176
+ type="text"
177
+ value={item.id}
178
+ onChange={(e) => updateItem(index, { id: e.target.value })}
179
+ placeholder={t('settings.modelProfilesIdPlaceholder')}
180
+ className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm"
181
+ />
182
+ </label>
183
+ <label className="text-xs space-y-1">
184
+ <span className="text-muted-foreground block">{t('settings.modelProfilesBase')}</span>
185
+ <select
186
+ value={item.base}
187
+ onChange={(e) => updateItem(index, { base: e.target.value })}
188
+ className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm"
189
+ >
190
+ {baseModels.map((model) => (
191
+ <option key={model} value={model}>{model}</option>
192
+ ))}
193
+ </select>
194
+ </label>
195
+ <label className="text-xs space-y-1">
196
+ <span className="text-muted-foreground block">{t('settings.modelProfilesName')}</span>
197
+ <input
198
+ type="text"
199
+ value={item.name}
200
+ onChange={(e) => updateItem(index, { name: e.target.value })}
201
+ placeholder={t('settings.modelProfilesNamePlaceholder')}
202
+ className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm"
203
+ />
204
+ </label>
205
+ <label className="text-xs space-y-1">
206
+ <span className="text-muted-foreground block">{t('settings.modelProfilesRemark')}</span>
207
+ <input
208
+ type="text"
209
+ value={item.remark}
210
+ onChange={(e) => updateItem(index, { remark: e.target.value })}
211
+ className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm"
212
+ />
213
+ </label>
214
+ </div>
215
+ <div className="flex items-center justify-between gap-2">
216
+ <button
217
+ type="button"
218
+ onClick={() => toggleRow(index)}
219
+ className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
220
+ >
221
+ {expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
222
+ {expanded ? t('settings.skillsCollapse') : t('settings.modelProfilesPrompt')}
223
+ </button>
224
+ <button
225
+ type="button"
226
+ onClick={() => removeItem(index)}
227
+ className="text-xs text-destructive hover:opacity-80 flex items-center gap-1"
228
+ >
229
+ <Trash2 className="w-3.5 h-3.5" />
230
+ {t('settings.modelProfilesRemove')}
231
+ </button>
232
+ </div>
233
+ {expanded && (
234
+ <div className="space-y-1">
235
+ <textarea
236
+ rows={8}
237
+ value={item.prompt}
238
+ onChange={(e) => updateItem(index, { prompt: e.target.value })}
239
+ placeholder={t('settings.modelProfilesPromptPlaceholder')}
240
+ className="w-full bg-background border border-border rounded-lg px-3 py-2 resize-y min-h-32 text-sm leading-6"
241
+ />
242
+ <p className="text-xs text-muted-foreground">
243
+ {t('settings.modelProfilesPromptHint')}
244
+ </p>
245
+ </div>
246
+ )}
247
+ </div>
248
+ )
249
+ })}
250
+ </div>
251
+
252
+ <div className="flex flex-wrap items-center gap-2">
253
+ <button
254
+ type="button"
255
+ onClick={addItem}
256
+ disabled={items.length >= maxCount}
257
+ className="h-9 px-3 rounded-lg border border-border bg-background text-sm hover:bg-secondary/70 disabled:opacity-50 flex items-center gap-1"
258
+ >
259
+ <Plus className="w-4 h-4" />
260
+ {t('settings.modelProfilesAdd')}
261
+ </button>
262
+ <button
263
+ type="button"
264
+ onClick={save}
265
+ disabled={saving}
266
+ className="h-9 px-3 rounded-lg bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50 flex items-center gap-1"
267
+ >
268
+ <Save className="w-4 h-4" />
269
+ {saving ? t('settings.skillsSaving') : t('settings.modelProfilesSave')}
270
+ </button>
271
+ <span className="text-xs text-muted-foreground">{t('settings.modelProfilesHint')}</span>
272
+ </div>
273
+ </div>
274
+ )
275
+ }
webui/src/features/settings/SettingsContainer.jsx CHANGED
@@ -9,6 +9,7 @@ import BehaviorSection from './BehaviorSection'
9
  import PromptProfileSection from './PromptProfileSection'
10
  import ToolCallsSection from './ToolCallsSection'
11
  import SkillsSection from './SkillsSection'
 
12
  import CurrentInputFileSection from './CurrentInputFileSection'
13
  import FileAuditSection from './FileAuditSection'
14
  import AutoDeleteSection from './AutoDeleteSection'
@@ -106,6 +107,8 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
106
 
107
  <ToolCallsSection t={t} form={form} setForm={setForm} />
108
 
 
 
109
  <SkillsSection t={t} apiFetch={apiFetch} onMessage={onMessage} />
110
 
111
  <CurrentInputFileSection t={t} form={form} setForm={setForm} />
 
9
  import PromptProfileSection from './PromptProfileSection'
10
  import ToolCallsSection from './ToolCallsSection'
11
  import SkillsSection from './SkillsSection'
12
+ import ModelProfilesSection from './ModelProfilesSection'
13
  import CurrentInputFileSection from './CurrentInputFileSection'
14
  import FileAuditSection from './FileAuditSection'
15
  import AutoDeleteSection from './AutoDeleteSection'
 
107
 
108
  <ToolCallsSection t={t} form={form} setForm={setForm} />
109
 
110
+ <ModelProfilesSection t={t} apiFetch={apiFetch} onMessage={onMessage} />
111
+
112
  <SkillsSection t={t} apiFetch={apiFetch} onMessage={onMessage} />
113
 
114
  <CurrentInputFileSection t={t} form={form} setForm={setForm} />
webui/src/features/settings/settingsApi.js CHANGED
@@ -69,3 +69,25 @@ export async function putSkills(apiFetch, payload) {
69
  }
70
  return { res, data }
71
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  }
70
  return { res, data }
71
  }
72
+
73
+ // 自定义模型列表是独立接口:整体读取、整体覆盖。
74
+ export async function fetchModelProfiles(apiFetch) {
75
+ const res = await apiFetch('/admin/model_profiles')
76
+ const data = await res.json()
77
+ return { res, data }
78
+ }
79
+
80
+ export async function putModelProfiles(apiFetch, payload) {
81
+ const res = await apiFetch('/admin/model_profiles', {
82
+ method: 'PUT',
83
+ headers: { 'Content-Type': 'application/json' },
84
+ body: JSON.stringify(payload),
85
+ })
86
+ let data = {}
87
+ try {
88
+ data = await res.json()
89
+ } catch (_e) {
90
+ data = {}
91
+ }
92
+ return { res, data }
93
+ }
webui/src/features/settings/useSettingsForm.js CHANGED
@@ -20,6 +20,7 @@ const DEFAULT_FORM = {
20
  current_input_file: { enabled: true, min_chars: 0 },
21
  thinking_injection: { enabled: true, prompt: '', default_prompt: '' },
22
  prompt_profile: { enabled: false, name: 'default', prompt: '', presets: [] },
 
23
  tools: { enabled: false },
24
  model_aliases_text: '{}',
25
  }
@@ -63,6 +64,7 @@ function fromServerForm(data) {
63
  const currentInputSource = snapshot.current_input_file || data.current_input_file || {}
64
  const thinkingSource = snapshot.thinking_injection || data.thinking_injection || {}
65
  const promptProfileSource = snapshot.prompt_profile || data.prompt_profile || {}
 
66
  const toolsSource = snapshot.tools || data.tools || {}
67
  const modelAliasesSource = snapshot.model_aliases || data.model_aliases || {}
68
  const currentInputFileEnabled = currentInputSource?.enabled ?? true
@@ -107,6 +109,12 @@ function fromServerForm(data) {
107
  prompt: promptProfileSource?.prompt || '',
108
  presets: Array.isArray(data.prompt_profile?.presets) ? data.prompt_profile.presets : [],
109
  },
 
 
 
 
 
 
110
  // 工具调用默认关闭:上游风控严格,加工具说明有封号风险。
111
  tools: {
112
  enabled: Boolean(toolsSource?.enabled),
@@ -148,6 +156,7 @@ function toServerPayload(form) {
148
  name: String(form.prompt_profile?.name || 'default').trim() || 'default',
149
  prompt: String(form.prompt_profile?.prompt || '').trim(),
150
  },
 
151
  tools: {
152
  enabled: Boolean(form.tools?.enabled),
153
  },
 
20
  current_input_file: { enabled: true, min_chars: 0 },
21
  thinking_injection: { enabled: true, prompt: '', default_prompt: '' },
22
  prompt_profile: { enabled: false, name: 'default', prompt: '', presets: [] },
23
+ prompt_style: { mode: 'v2', modes: ['v2', 'legacy'] },
24
  tools: { enabled: false },
25
  model_aliases_text: '{}',
26
  }
 
64
  const currentInputSource = snapshot.current_input_file || data.current_input_file || {}
65
  const thinkingSource = snapshot.thinking_injection || data.thinking_injection || {}
66
  const promptProfileSource = snapshot.prompt_profile || data.prompt_profile || {}
67
+ const promptStyleSource = data.prompt_style || {}
68
  const toolsSource = snapshot.tools || data.tools || {}
69
  const modelAliasesSource = snapshot.model_aliases || data.model_aliases || {}
70
  const currentInputFileEnabled = currentInputSource?.enabled ?? true
 
109
  prompt: promptProfileSource?.prompt || '',
110
  presets: Array.isArray(data.prompt_profile?.presets) ? data.prompt_profile.presets : [],
111
  },
112
+ prompt_style: {
113
+ mode: promptStyleSource?.mode || 'v2',
114
+ modes: Array.isArray(promptStyleSource?.modes) && promptStyleSource.modes.length > 0
115
+ ? promptStyleSource.modes
116
+ : ['v2', 'legacy'],
117
+ },
118
  // 工具调用默认关闭:上游风控严格,加工具说明有封号风险。
119
  tools: {
120
  enabled: Boolean(toolsSource?.enabled),
 
156
  name: String(form.prompt_profile?.name || 'default').trim() || 'default',
157
  prompt: String(form.prompt_profile?.prompt || '').trim(),
158
  },
159
+ prompt_style: String(form.prompt_style?.mode || 'v2').trim() || 'v2',
160
  tools: {
161
  enabled: Boolean(form.tools?.enabled),
162
  },
webui/src/locales/en.json CHANGED
@@ -477,6 +477,10 @@
477
  "thinkingInjectionPrompt": "Thinking format prompt",
478
  "thinkingInjectionRestoreDefault": "Restore built-in default",
479
  "thinkingInjectionPromptHelp": "Leave empty to use the built-in default prompt shown as the input placeholder.",
 
 
 
 
480
  "promptProfileTitle": "Prompt profile (optional)",
481
  "promptProfileDesc": "Prepends a server-side spec block to the final prompt, layered above thinking injection and the skill library. Off by default; when off, existing requests keep exactly the same prompt.",
482
  "promptProfileEnabled": "Enable prompt profile",
@@ -488,6 +492,31 @@
488
  "promptProfilePrompt": "Custom body",
489
  "promptProfileRestoreDefault": "Restore built-in preset",
490
  "promptProfilePromptHelp": "Leave empty to use the selected preset's built-in body, shown as the placeholder. Custom text is capped at 20000 characters; when it conflicts with a user or client role definition, the user's setting wins.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
  "toolsTitle": "Tool calling (experimental)",
492
  "toolsWarning": "Upstream is strict about DSML tool calls and may ban the account, so this is off by default.",
493
  "toolsEnabled": "Enable tool calling",
 
477
  "thinkingInjectionPrompt": "Thinking format prompt",
478
  "thinkingInjectionRestoreDefault": "Restore built-in default",
479
  "thinkingInjectionPromptHelp": "Leave empty to use the built-in default prompt shown as the input placeholder.",
480
+ "promptStyleTitle": "Built-in prompt style",
481
+ "promptStyleV2": "v2 (current: continuation carries requirements, contract injected)",
482
+ "promptStyleLegacy": "legacy (short continuation, no built-in contract)",
483
+ "promptStyleHelp": "The legacy feel comes from adding almost no constraints: the model answers in its own default style, so replies tend to be longer and looser. Pick v2 when you want earlier requirements honoured item by item; switch to legacy when replies feel too short and too customer-service-like. A custom thinking-injection prompt you write yourself still applies under either style.",
484
  "promptProfileTitle": "Prompt profile (optional)",
485
  "promptProfileDesc": "Prepends a server-side spec block to the final prompt, layered above thinking injection and the skill library. Off by default; when off, existing requests keep exactly the same prompt.",
486
  "promptProfileEnabled": "Enable prompt profile",
 
492
  "promptProfilePrompt": "Custom body",
493
  "promptProfileRestoreDefault": "Restore built-in preset",
494
  "promptProfilePromptHelp": "Leave empty to use the selected preset's built-in body, shown as the placeholder. Custom text is capped at 20000 characters; when it conflicts with a user or client role definition, the user's setting wins.",
495
+ "modelProfilesTitle": "Custom models",
496
+ "modelProfilesDesc": "Expose several model names on top of the same flash base model, each with its own prompt. Clients pick a name, entries do not affect each other, and rollback is simpler than flipping a global switch. When the selected model carries a prompt it wins; otherwise the prompt profile above applies.",
497
+ "modelProfilesCount": "{count} total",
498
+ "modelProfilesEmpty": "No custom models yet. Click \"Add model\" to create one, e.g. id deepseek-writer, base flash, prompt with your writing spec.",
499
+ "modelProfilesLoadFailed": "Failed to load custom models",
500
+ "modelProfilesSave": "Save model list",
501
+ "modelProfilesSaveSuccess": "Custom models saved ({count})",
502
+ "modelProfilesSaveFailed": "Failed to save custom models",
503
+ "modelProfilesId": "Model name (public id)",
504
+ "modelProfilesIdPlaceholder": "e.g. deepseek-writer",
505
+ "modelProfilesIdRequired": "Model #{index} has no name yet",
506
+ "modelProfilesIdInvalid": "Model name \"{id}\" may only use letters, digits, dot, underscore and hyphen, 1-64 characters",
507
+ "modelProfilesIdDuplicate": "Model name \"{id}\" is duplicated",
508
+ "modelProfilesBase": "Base model",
509
+ "modelProfilesBaseRequired": "Model \"{id}\" has no base model",
510
+ "modelProfilesName": "Display name (optional)",
511
+ "modelProfilesNamePlaceholder": "e.g. Writing only",
512
+ "modelProfilesRemark": "Remark (optional)",
513
+ "modelProfilesPrompt": "Prompt for this model (expand to edit)",
514
+ "modelProfilesPromptPlaceholder": "Prompt body that only applies to this model; leave empty to add no extra spec",
515
+ "modelProfilesPromptHint": "Leave empty to add no extra spec for this model; the prompt profile or default behaviour then applies.",
516
+ "modelProfilesPromptTooLong": "Prompt for \"{id}\" exceeds {count} characters",
517
+ "modelProfilesAdd": "Add model",
518
+ "modelProfilesRemove": "Remove",
519
+ "modelProfilesHint": "The model list is saved on its own; the Save settings button below does not submit it.",
520
  "toolsTitle": "Tool calling (experimental)",
521
  "toolsWarning": "Upstream is strict about DSML tool calls and may ban the account, so this is off by default.",
522
  "toolsEnabled": "Enable tool calling",
webui/src/locales/zh.json CHANGED
@@ -477,6 +477,10 @@
477
  "thinkingInjectionPrompt": "思考格式提示词",
478
  "thinkingInjectionRestoreDefault": "恢复内置默认提示词",
479
  "thinkingInjectionPromptHelp": "留空时使用内置默认提示词;默认内容会显示在输入框占位文本中。",
 
 
 
 
480
  "promptProfileTitle": "提示词特化层(可选)",
481
  "promptProfileDesc": "在最终 prompt 最前面补一段服务端规范,与思考注入、技能库分层叠加。默认关闭,关闭时现有请求的 prompt 保持不变。",
482
  "promptProfileEnabled": "启用提示词特化层",
@@ -488,6 +492,31 @@
488
  "promptProfilePrompt": "自定义正文",
489
  "promptProfileRestoreDefault": "恢复内置预设",
490
  "promptProfilePromptHelp": "留空时使用所选预设的内置正文,占位文本里就是该正文;自定义内容上限 20000 字符。与用户或客户端设定的角色冲突时以用户设定为准。",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
  "toolsTitle": "工具调用(实验性)",
492
  "toolsWarning": "上游对 DSML 工具调用风控严格,触发后可能封号,因此默认关闭。",
493
  "toolsEnabled": "启用工具调用",
 
477
  "thinkingInjectionPrompt": "思考格式提示词",
478
  "thinkingInjectionRestoreDefault": "恢复内置默认提示词",
479
  "thinkingInjectionPromptHelp": "留空时使用内置默认提示词;默认内容会显示在输入框占位文本中。",
480
+ "promptStyleTitle": "内置提示词风格",
481
+ "promptStyleV2": "v2(当前版:衔接指令带执行要求,注入执行契约)",
482
+ "promptStyleLegacy": "legacy(旧版:短衔接指令,不注入执行契约)",
483
+ "promptStyleHelp": "旧版手感来自「几乎不加约束」:模型按自己的默认风格作答,回答更长更放得开。需要「上文要求逐条满足」选 v2;觉得回答太短、太像客服就切 legacy。你自己填的思考注入提示词在任何风格下都生效。",
484
  "promptProfileTitle": "提示词特化层(可选)",
485
  "promptProfileDesc": "在最终 prompt 最前面补一段服务端规范,与思考注入、技能库分层叠加。默认关闭,关闭时现有请求的 prompt 保持不变。",
486
  "promptProfileEnabled": "启用提示词特化层",
 
492
  "promptProfilePrompt": "自定义正文",
493
  "promptProfileRestoreDefault": "恢复内置预设",
494
  "promptProfilePromptHelp": "留空时使用所选预设的内置正文,占位文本里就是该正文;自定义内容上限 20000 字符。与用户或客户端设定的角色冲突时以用户设定为准。",
495
+ "modelProfilesTitle": "自定义模型(模型列表)",
496
+ "modelProfilesDesc": "给同一个 flash 基模挂出多个对外模型名,每个名字带自己的提示词。客户端按模型名选用,互不影响,比改全局开关更好回滚。命中带提示词的模型时优先用它,没命中才回落到上面的提示词特化层。",
497
+ "modelProfilesCount": "共 {count} 个",
498
+ "modelProfilesEmpty": "还没有自定义模型。点「添加模型」建一个,例如 id 填 deepseek-writer、基模选 flash、提示词填写作规范。",
499
+ "modelProfilesLoadFailed": "加载自定义模型失败",
500
+ "modelProfilesSave": "保存模型列表",
501
+ "modelProfilesSaveSuccess": "自定义模型已保存({count} 个)",
502
+ "modelProfilesSaveFailed": "保存自定义模型失败",
503
+ "modelProfilesId": "模型名(对外 id)",
504
+ "modelProfilesIdPlaceholder": "例如:deepseek-writer",
505
+ "modelProfilesIdRequired": "第 {index} 个模型还没填模型名",
506
+ "modelProfilesIdInvalid": "模型名「{id}」只能用字母、数字、点、下划线和短横线,长度 1-64",
507
+ "modelProfilesIdDuplicate": "模型名「{id}」重复了",
508
+ "modelProfilesBase": "基模",
509
+ "modelProfilesBaseRequired": "模型「{id}」还没选基模",
510
+ "modelProfilesName": "展示名(可选)",
511
+ "modelProfilesNamePlaceholder": "例如:写作专用",
512
+ "modelProfilesRemark": "备注(可选)",
513
+ "modelProfilesPrompt": "该模型的提示词(展开编辑)",
514
+ "modelProfilesPromptPlaceholder": "该模型专属的提示词正文;留空表示不加额外规范",
515
+ "modelProfilesPromptHint": "留空则该模型不加额外规范,回落到提示词特化层或默认行为。",
516
+ "modelProfilesPromptTooLong": "模型「{id}」的提示词超过 {count} 字符",
517
+ "modelProfilesAdd": "添加模型",
518
+ "modelProfilesRemove": "删除",
519
+ "modelProfilesHint": "模型列表单独保存,不随下方「保存设置」提交。",
520
  "toolsTitle": "工具调用(实验性)",
521
  "toolsWarning": "上游对 DSML 工具调用风控严格,触发后可能封号,因此默认关闭。",
522
  "toolsEnabled": "启用工具调用",