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

feat(models): 模型列表独立成页面,每个模型可独立选提示词风格

Browse files

- 侧边栏新增「模型列表」页(/admin/models):自定义模型 + 模型别名,
从设置页移出;删掉随之变成死代码的 ModelSection.jsx
- 自定义模型新增 style 档位:inherit / v2 / writing / legacy / custom,
一档同时决定「内置文案用哪版」与「注不注入特化层」:
v2/writing 固定 v2 文案,legacy 固定旧版短文,custom 用条目正文
且文案跟随全局;与全局 prompt_profile 仍然互斥,不叠加
- 兼容旧配置:只有 prompt、没有 style 的条目按 custom 解释,行为不变
- /admin/model_profiles 与 /admin/settings 返回 style_names
- 设置页按用途分 6 组(提示词 / 上下文与技能 / 运行与行为 / 工具调用 /
安全 / 数据与备份);提示词风格与思考注入合并成一张卡,
Responses TTL 与 Embeddings 归入运行行为
- 写作规范新增「不要照搬指令原文」一节,并写明两个例外:用户点名要原样
出现的句子/术语/数据/关键词必须一字不改地保留,避免模型连带删掉
- 同步 API(.*).md / README.en.md / prompt-compatibility.md / prompt-design.md

API.en.md CHANGED
@@ -884,7 +884,7 @@ An unknown value never breaks config loading: it falls back to `v2` with a warni
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
  {
@@ -894,7 +894,13 @@ Expose several public model names on top of the same flash base model, each carr
894
  "base": "deepseek-v4-flash",
895
  "name": "Writing",
896
  "remark": "Long-form and rewrites",
897
- "prompt": "## Writing rules\n…"
 
 
 
 
 
 
898
  }
899
  ]
900
  }
@@ -905,7 +911,18 @@ Expose several public model names on top of the same flash base model, each carr
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
 
@@ -1077,16 +1094,18 @@ Validation runs on write and is independent of `enabled`, so you can author and
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
@@ -1096,7 +1115,7 @@ The custom model list (public name + base model + per-model prompt). Like the sk
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": "..."}`):
@@ -1106,6 +1125,7 @@ The custom model list (public name + base model + per-model prompt). Like the sk
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.
 
884
 
885
  ### `model_profiles` (custom model list, global)
886
 
887
+ Expose several public model names on top of the same flash base model, each with its own prompt style. Whichever name the client sends decides the style; entries never affect each other.
888
 
889
  ```json
890
  {
 
894
  "base": "deepseek-v4-flash",
895
  "name": "Writing",
896
  "remark": "Long-form and rewrites",
897
+ "style": "writing"
898
+ },
899
+ {
900
+ "id": "deepseek-chat",
901
+ "base": "deepseek-v4-flash",
902
+ "name": "Casual chat",
903
+ "style": "legacy"
904
  }
905
  ]
906
  }
 
911
  | `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) |
912
  | `base` | Required base model: `deepseek-v4-flash` / `-search` / `-nothinking` / `-search-nothinking` |
913
  | `name` / `remark` | Optional, only for readability in the panel and in exports |
914
+ | `style` | Prompt style for this model (see below); empty means `custom` when `prompt` is set, otherwise `inherit` |
915
+ | `prompt` | Optional custom body, **only used when `style=custom`**, max 20000 characters |
916
+
917
+ `style` values:
918
+
919
+ | Value | Built-in wording (continuation / contract) | Injected prompt-profile body |
920
+ | --- | --- | --- |
921
+ | `inherit` (default) | Follows global `prompt_style` | Follows global `prompt_profile` |
922
+ | `v2` | Pinned to v2 | None |
923
+ | `writing` | Pinned to v2 | Built-in writing spec |
924
+ | `legacy` | Legacy short wording, no contract | None |
925
+ | `custom` | Follows global `prompt_style` | Uses this entry's `prompt` |
926
 
927
  Resolution and injection:
928
 
 
1094
 
1095
  ### `GET` / `PUT /admin/model_profiles`
1096
 
1097
+ The custom model list (public name + base model + per-model prompt style). 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.
1098
 
1099
  **GET response**:
1100
 
1101
  ```json
1102
  {
1103
  "items": [
1104
+ {"id": "deepseek-writer", "base": "deepseek-v4-flash", "name": "Writing", "style": "writing"},
1105
+ {"id": "deepseek-mine", "base": "deepseek-v4-flash", "style": "custom", "prompt": "…"}
1106
  ],
1107
  "base_models": ["deepseek-v4-flash", "deepseek-v4-flash-search", "deepseek-v4-flash-nothinking", "deepseek-v4-flash-search-nothinking"],
1108
+ "style_names": ["inherit", "v2", "writing", "legacy", "custom"],
1109
  "max_count": 50,
1110
  "max_id_chars": 64,
1111
  "max_prompt_chars": 20000
 
1115
  **PUT request**:
1116
 
1117
  ```json
1118
+ {"items": [{"id": "deepseek-writer", "base": "deepseek-v4-flash", "style": "writing"}]}
1119
  ```
1120
 
1121
  **Validation** (any failure returns `400` with `{"detail": "..."}`):
 
1125
  | `items` | At most 50 entries |
1126
  | `id` | Required, unique (case-insensitive), charset `[A-Za-z0-9._-]`, 1-64 characters, must not shadow a built-in model name |
1127
  | `base` | Required, one of the four `deepseek-v4-flash` names |
1128
+ | `style` | Optional; when present must be `inherit` / `v2` / `writing` / `legacy` / `custom` |
1129
  | `prompt` | Optional, ≤ 20000 characters |
1130
 
1131
  Saving takes effect immediately: `/v1/models` and model-name resolution pick up the new entries without a restart.
API.md CHANGED
@@ -889,7 +889,7 @@ data: {"type":"message_stop"}
889
 
890
  ### `model_profiles`(自定义模型列表,全局)
891
 
892
- 给同一个 flash 基模挂出多个对外模型名,每个名字带自己的提示词。客户端请求里写哪个模型名,就用哪一份提示词,互不影响。
893
 
894
  ```json
895
  {
@@ -899,7 +899,13 @@ data: {"type":"message_stop"}
899
  "base": "deepseek-v4-flash",
900
  "name": "写作",
901
  "remark": "长文与改稿",
902
- "prompt": "## 写作要求\n…"
 
 
 
 
 
 
903
  }
904
  ]
905
  }
@@ -910,7 +916,18 @@ data: {"type":"message_stop"}
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
 
@@ -1082,16 +1099,18 @@ data: {"type":"message_stop"}
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
@@ -1101,7 +1120,7 @@ data: {"type":"message_stop"}
1101
  **PUT 请求**:
1102
 
1103
  ```json
1104
- {"items": [{"id": "deepseek-writer", "base": "deepseek-v4-flash", "prompt": "…"}]}
1105
  ```
1106
 
1107
  **校验规则**(任一条不满足返回 `400`,响应体为 `{"detail": "..."}`):
@@ -1111,6 +1130,7 @@ data: {"type":"message_stop"}
1111
  | `items` | 最多 50 条 |
1112
  | `id` | 必填、唯一(大小写不敏感),字符集 `[A-Za-z0-9._-]`,长度 1-64,不可与内置模型名冲突 |
1113
  | `base` | 必填,只能是 `deepseek-v4-flash` 系列的四个名字之一 |
 
1114
  | `prompt` | 可空;≤ 20000 字符 |
1115
 
1116
  保存后立即生效:`/v1/models` 列表与模型名解析都会包含新条目,无需重启。
 
889
 
890
  ### `model_profiles`(自定义模型列表,全局)
891
 
892
+ 给同一个 flash 基模挂出多个对外模型名,每个名字可以带自己的提示词风格。客户端请求里写哪个模型名,就用哪一套风格,互不影响。
893
 
894
  ```json
895
  {
 
899
  "base": "deepseek-v4-flash",
900
  "name": "写作",
901
  "remark": "长文与改稿",
902
+ "style": "writing"
903
+ },
904
+ {
905
+ "id": "deepseek-chat",
906
+ "base": "deepseek-v4-flash",
907
+ "name": "日常对话",
908
+ "style": "legacy"
909
  }
910
  ]
911
  }
 
916
  | `id` | 必填,对外模型名。字符集 `[A-Za-z0-9._-]`,长度 1-64,不可与内置模型名(如 `deepseek-v4-flash`)冲突,条目间不可重复(大小写不敏感) |
917
  | `base` | 必填,基模,只能是 `deepseek-v4-flash` / `-search` / `-nothinking` / `-search-nothinking` |
918
  | `name` / `remark` | 可选,仅用于后台与导出可读性 |
919
+ | `style` | 该模型的提示词风格档位,见下表;留空时:有 `prompt` 按 `custom`,否则按 `inherit` |
920
+ | `prompt` | 可选,自定义正文,**只在 `style=custom` 时生效**,上限 20000 字符 |
921
+
922
+ `style` 取值:
923
+
924
+ | 值 | 内置文案(衔接指令 / 执行契约) | 注入的特化层正文 |
925
+ | --- | --- | --- |
926
+ | `inherit`(留空默认) | 跟随全局 `prompt_style` | 跟随全局 `prompt_profile` |
927
+ | `v2` | 固定 v2 | 不注入 |
928
+ | `writing` | 固定 v2 | 内置写作规范正文 |
929
+ | `legacy` | 固定旧版短文案、不注入执行契约 | 不注入 |
930
+ | `custom` | 跟随全局 `prompt_style` | 用本条目 `prompt` |
931
 
932
  解析与注入语义:
933
 
 
1099
 
1100
  ### `GET` / `PUT /admin/model_profiles`
1101
 
1102
+ 自定义模型列表(模型名 + 基模 + 该模型的提示词风格)。与技能库同样是独立接口、整体替换语义:PUT 请求里的 `items` 就是最终列表,传空数组表示清空。
1103
 
1104
  **GET 响应**:
1105
 
1106
  ```json
1107
  {
1108
  "items": [
1109
+ {"id": "deepseek-writer", "base": "deepseek-v4-flash", "name": "写作", "style": "writing"},
1110
+ {"id": "deepseek-mine", "base": "deepseek-v4-flash", "style": "custom", "prompt": "…"}
1111
  ],
1112
  "base_models": ["deepseek-v4-flash", "deepseek-v4-flash-search", "deepseek-v4-flash-nothinking", "deepseek-v4-flash-search-nothinking"],
1113
+ "style_names": ["inherit", "v2", "writing", "legacy", "custom"],
1114
  "max_count": 50,
1115
  "max_id_chars": 64,
1116
  "max_prompt_chars": 20000
 
1120
  **PUT 请求**:
1121
 
1122
  ```json
1123
+ {"items": [{"id": "deepseek-writer", "base": "deepseek-v4-flash", "style": "writing"}]}
1124
  ```
1125
 
1126
  **校验规则**(任一条不满足返回 `400`,响应体为 `{"detail": "..."}`):
 
1130
  | `items` | 最多 50 条 |
1131
  | `id` | 必填、唯一(大小写不敏感),字符集 `[A-Za-z0-9._-]`,长度 1-64,不可与内置模型名冲突 |
1132
  | `base` | 必填,只能是 `deepseek-v4-flash` 系列的四个名字之一 |
1133
+ | `style` | 可空;非空时只能是 `inherit` / `v2` / `writing` / `legacy` / `custom` |
1134
  | `prompt` | 可空;≤ 20000 字符 |
1135
 
1136
  保存后立即生效:`/v1/models` 列表与模型名解析都会包含新条目,无需重启。
README.en.md CHANGED
@@ -340,7 +340,7 @@ Common fields:
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
 
 
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 a `style` (`inherit` / `v2` / `writing` / `legacy` / `custom`) and, for `custom`, its own `prompt` (max 20000 characters). Custom names take precedence over built-in aliases, show up in `/v1/models`, and their resolved body replaces — never stacks with — the global `prompt_profile`. Managed from the **Models** page in the sidebar or via `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
 
docs/prompt-compatibility.md CHANGED
@@ -232,7 +232,7 @@ DeepSeek web-chat 只接受一段 prompt 字符串和一组文件引用,没有
232
  | 预设 | 要点 |
233
  | --- | --- |
234
  | `default` | 直接回应再按需展开、语气与长度跟着对方走(闲聊就正常聊、不硬上清单小标题)、语言跟随用户、分清确定与推测不编造、不假装能读文件/联网/调工具、用户明确要求优先于本段 |
235
- | `writing` | 写前确认体裁与读者 → 前几轮要求当硬约束(篇幅/人称/语气/禁用写法逐条满足)→ 具体优先、不写空洞表述 → 分清虚构与非虚构(只对非虚构禁编造)→ 篇幅按用户要求不注水 → 交付前逐条自检(自检不外泄) |
236
 
237
  `default` 与 `writing` 的分工:前者对**所有**请求生效,因此刻意不含「结论先行」这类交付物文风;后者由用户显式开启、面向写作,才可以更具体。这也是「默认关、按需开」的原因。
238
 
@@ -256,20 +256,33 @@ DeepSeek web-chat 只接受一段 prompt 字符串和一组文件引用,没有
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 风控。
 
232
  | 预设 | 要点 |
233
  | --- | --- |
234
  | `default` | 直接回应再按需展开、语气与长度跟着对方走(闲聊就正常聊、不硬上清单小标题)、语言跟随用户、分清确定与推测不编造、不假装能读文件/联网/调工具、用户明确要求优先于本段 |
235
+ | `writing` | 写前确认体裁与读者 → 前几轮要求当硬约束(篇幅/人称/语气/禁用写法逐条满足)→ 具体优先、不写空洞表述 → 分清虚构与非虚构(只对非虚构禁编造)→ 篇幅按用户要求不注水 → **不照搬指令原文**(但用户点名要原样出现的句子、术语、数据与关键词一字不改地保留)→ 交付前逐条自检(自检不外泄) |
236
 
237
  `default` 与 `writing` 的分工:前者对**所有**请求生效,因此刻意不含「结论先行」这类交付物文风;后者由用户显式开启、面向写作,才可以更具体。这也是「默认关、按需开」的原因。
238
 
 
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
+ | 风格档位 | `inherit`(跟随全局)/ `v2` / `writing` / `legacy` / `custom`,见下表 |
269
  | 注入点 | 与 `prompt_profile` 相同(拆分之后、技能之前) |
270
+ | 与特化层关系 | **互斥**:模型档位给出的正文优先,全局 `prompt_profile` 不叠加 |
271
  | 生效范围 | 只对该模型名生效;客户端换回 `deepseek-v4-flash` 就等于关掉它 |
272
  | 管理接口 | `GET/PUT /admin/model_profiles`,整体替换语义 |
273
 
274
+ 一个条目同时决定「内置文案用哪版」与「注不注入特化层」:
275
+
276
+ | `style` | 衔接指令 / 执行契约 | 特化层正文 |
277
+ | --- | --- | --- |
278
+ | `inherit` | 全局 `prompt_style` | 全局 `prompt_profile` |
279
+ | `v2` | 固定 v2 | 不注入 |
280
+ | `writing` | 固定 v2 | 内置写作规范正文 |
281
+ | `legacy` | 固定旧版短文案、不注入执行契约 | 不注入 |
282
+ | `custom` | 全局 `prompt_style` | 条目自己的 `prompt` |
283
+
284
+ 兼容说明:早期条目只有 `prompt` 字段、没有 `style`,这种配置按 `custom` 解释(`EffectiveStyle`),升级后行为不变。
285
+
286
  ### 技能注入(skills)
287
 
288
  技能是一段服务端配置的纯文本 markdown,直接拼进 prompt。模型全程不需要输出工具调用,看到的只是普通上下文,因此不会碰到 DSML 风控。
docs/prompt-design.md CHANGED
@@ -148,13 +148,22 @@ L4 技能层 前置 System 段:<skill> 正文块 BuildSki
148
  5. 保持上下文连续:人名、术语、编号、称呼与上文一致;改稿时说明改动了哪些地方。
149
  6. 语言跟随用户;中文写作不用「作为AI」「总之」这类套话收尾。
150
 
 
 
 
 
 
 
 
151
  ## 交付前自检
152
- 逐条核对:篇幅是否符合、硬约束是否满足、有没有漏掉用户点名要写的点、有没有出现用户明确禁止的写法。
153
- 发现缺项先补齐再交付;这份自检只在内部完成,不要输出核对过程。
154
  ```
155
 
156
  写作规范可以更具体,因为它是用户**显式开启**的。但「不要编造」必须限定在非虚构:把这条笼统地压在小说、剧本、广告创意上,会直接削弱创作能力。「改稿时说明改动了哪些地方」也是同理——只在改稿场景要求,不要求每轮自我解释。
157
 
 
 
158
  ---
159
 
160
  ## 4. 拼接与顺序规则
@@ -232,18 +241,28 @@ L4 技能层 前置 System 段:<skill> 正文块 BuildSki
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
 
 
148
  5. 保持上下文连续:人名、术语、编号、称呼与上文一致;改稿时说明改动了哪些地方。
149
  6. 语言跟随用户;中文写作不用「作为AI」「总之」这类套话收尾。
150
 
151
+ ## 不要照搬指令原文
152
+ 1. 把用户的指令当成要求,不要当成正文素材:要求句、约束说明、括号里的备注,都不要原样搬进正文,
153
+ 也不要复述「请写一篇…」「要求:…」这类指令框架。
154
+ 2. 这一条不适用于用户明确要求原样出现的内容:点名要照抄的句子、口号、标题、术语、数据、引文,
155
+ 以及必须出现的关键词,都要一字不改地写进去,位置按用户要求安排。
156
+ 3. 拿不准时分辨这句是「让我怎么写」还是「要我写什么」:前者转成正文表达,后者原样保留。
157
+
158
  ## 交付前自检
159
+ 逐条核对:篇幅是否符合、硬约束是否满足、有没有漏掉用户点名要写的点、有没有出现用户明确禁止的写法、
160
+ 有没有把指令原文抄成正文。发现缺项先补齐再交付;这份自检只在内部完成,不要输出核对过程。
161
  ```
162
 
163
  写作规范可以更具体,因为它是用户**显式开启**的。但「不要编造」必须限定在非虚构:把这条笼统地压在小说、剧本、广告创意上,会直接削弱创作能力。「改稿时说明改动了哪些地方」也是同理——只在改稿场景要求,不要求每轮自我解释。
164
 
165
+ 「不要照搬指令原文」这一条同样必须自带例外:只针对**指令性文字**(「让我怎么写」),不针对**用户点名要原样出现的素材**(「要我写什么」)。写成笼统的「不准引用用户原话」会把「把这句话原样写进标题」「必须出现 XXX 这个词」一起禁掉,那是反效果。第 3 条给模型一个可执行的判据,避免它在边界上左右为难。
166
+
167
  ---
168
 
169
  ## 4. 拼接与顺序规则
 
241
 
242
  ### 用法 C:自定义模型(把多套风格并排放给客户端)
243
 
244
+ 给同一个 flash 基模挂多个对外模型名,每个名字选一个风格档位。客户端在模型列表里直接选风格,不用来回改全局配置,回滚也只是换回原模型名。
245
 
246
  ```json
247
  {
248
  "model_profiles": [
249
+ {"id": "deepseek-chat", "base": "deepseek-v4-flash", "name": "日常对话", "style": "legacy"},
250
+ {"id": "deepseek-writer", "base": "deepseek-v4-flash", "name": "写作", "style": "writing"},
251
+ {"id": "deepseek-plain", "base": "deepseek-v4-flash", "name": "跟随全局", "style": "inherit"},
252
+ {"id": "deepseek-mine", "base": "deepseek-v4-flash", "name": "我的规范", "style": "custom", "prompt": "…"}
253
  ]
254
  }
255
  ```
256
 
257
+ | `style` | 衔接指令 / 执行契约 | 特化层正文 |
258
+ | --- | --- | --- |
259
+ | `inherit`(缺省) | 跟随全局 `prompt_style` | 跟随全局 `prompt_profile` |
260
+ | `v2` | 固定 v2 | 不注入 |
261
+ | `writing` | 固定 v2 | 内置写作规范正文 |
262
+ | `legacy` | 固定旧版短文案、不注入执行契约 | 不注入 |
263
+ | `custom` | 跟随全局 `prompt_style` | 条目自己的 `prompt` |
264
+
265
+ 规则:模型档位给出的正文与全局 `prompt_profile` **互斥**(不会叠加);`prompt` 只在 `custom` 档位下生效;基模带 `-nothinking` / `-search` 后缀时,思考与联网默认值跟着基模走。后台在左侧「模型列表」页维护,接口是 `GET/PUT /admin/model_profiles`。
266
 
267
  三种用法怎么选:
268
 
internal/config/model_profile.go CHANGED
@@ -46,6 +46,54 @@ func IsKnownPromptStyle(style string) bool {
46
  }
47
  }
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  // 自定义模型条目(model_profiles)的限制。
50
  const (
51
  // MaxModelProfiles 是自定义模型条目的数量上限。
@@ -54,10 +102,7 @@ const (
54
  MaxModelProfileIDChars = 64
55
  )
56
 
57
- // ModelProfileConfig 是一条自定义模型:对外名称 + 基模 + 该模型专属提示词。
58
- //
59
- // 用途:同一个 flash 基模可以挂出多个「模型名」,每个名字带自己的提示词,
60
- // 客户端按模型名选用,互不影响,比改全局开关更好回滚。
61
  type ModelProfileConfig struct {
62
  // ID 是对外模型名,客户端在请求里写的就是它。
63
  ID string `json:"id"`
@@ -67,10 +112,28 @@ type ModelProfileConfig struct {
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
@@ -88,13 +151,18 @@ func SupportedBaseModels() []string {
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
  }
@@ -202,6 +270,10 @@ func ValidateModelProfiles(items []ModelProfileConfig) error {
202
  return fmt.Errorf("model_profiles[%d].base must be one of %s",
203
  i, strings.Join(SupportedBaseModels(), ", "))
204
  }
 
 
 
 
205
  }
206
  return nil
207
  }
 
46
  }
47
  }
48
 
49
+ // 自定义模型条目的风格档位。
50
+ //
51
+ // 一个条目既能自带提示词(custom),也能直接选一个内置风格,或跟随全局配置。
52
+ // 这样同一个 flash 基模可以挂出「日常 v2」「写作」「旧版」等多个模型名并排给客户端选。
53
+ const (
54
+ // ModelProfileStyleInherit 跟随全局:prompt_style 与 prompt_profile 都按全局设置。
55
+ ModelProfileStyleInherit = "inherit"
56
+ // ModelProfileStyleV2 强制 v2 内置文案,不注入特化层。
57
+ ModelProfileStyleV2 = "v2"
58
+ // ModelProfileStyleWriting 强制 v2 内置文案 + 写作规范正文。
59
+ ModelProfileStyleWriting = "writing"
60
+ // ModelProfileStyleLegacy 强制旧版内置文案,不注入特化层。
61
+ ModelProfileStyleLegacy = "legacy"
62
+ // ModelProfileStyleCustom 用本条目 prompt 作为特化层正文,文案风格跟随全局。
63
+ ModelProfileStyleCustom = "custom"
64
+ )
65
+
66
+ // ModelProfileStyleNames 返回风格档位名,顺序固定,供后台下拉与文档对照。
67
+ func ModelProfileStyleNames() []string {
68
+ return []string{
69
+ ModelProfileStyleInherit,
70
+ ModelProfileStyleV2,
71
+ ModelProfileStyleWriting,
72
+ ModelProfileStyleLegacy,
73
+ ModelProfileStyleCustom,
74
+ }
75
+ }
76
+
77
+ // IsKnownModelProfileStyle 判断是否为可选风格档位。
78
+ func IsKnownModelProfileStyle(style string) bool {
79
+ switch strings.ToLower(strings.TrimSpace(style)) {
80
+ case ModelProfileStyleInherit, ModelProfileStyleV2, ModelProfileStyleWriting,
81
+ ModelProfileStyleLegacy, ModelProfileStyleCustom:
82
+ return true
83
+ default:
84
+ return false
85
+ }
86
+ }
87
+
88
+ // NormalizeModelProfileStyle 归一化风格档位:空值按 inherit 处理,未知值同样回落 inherit。
89
+ func NormalizeModelProfileStyle(style string) string {
90
+ normalized := strings.ToLower(strings.TrimSpace(style))
91
+ if IsKnownModelProfileStyle(normalized) {
92
+ return normalized
93
+ }
94
+ return ModelProfileStyleInherit
95
+ }
96
+
97
  // 自定义模型条目(model_profiles)的限制。
98
  const (
99
  // MaxModelProfiles 是自定义模型条目的数量上限。
 
102
  MaxModelProfileIDChars = 64
103
  )
104
 
105
+ // ModelProfileConfig 是一条自定义模型:对外名称 + 基模 + 该模型专属的提示词风格/正文。
 
 
 
106
  type ModelProfileConfig struct {
107
  // ID 是对外模型名,客户端在请求里写的就是它。
108
  ID string `json:"id"`
 
112
  Name string `json:"name,omitempty"`
113
  // Remark 是备注(可选)。
114
  Remark string `json:"remark,omitempty"`
115
+ // Style 是风格档位:inherit / v2 / writing / legacy / custom。
116
+ // 留空时:有 Prompt 视为 custom,否则视为 inherit(兼容旧配置)。
117
+ Style string `json:"style,omitempty"`
118
+ // Prompt 是该模型专属的提示词正文,只在 Style=custom 时使用。
119
  Prompt string `json:"prompt,omitempty"`
120
  }
121
 
122
+ // EffectiveStyle 返回生效的风格档位。
123
+ //
124
+ // 兼容旧配置:早期版本只有 prompt 字段,没有 style;那时填写 prompt 的语义就是
125
+ // 「用这段正文当特化层」,所以这里把它解释成 custom。
126
+ func (m ModelProfileConfig) EffectiveStyle() string {
127
+ style := strings.ToLower(strings.TrimSpace(m.Style))
128
+ if style == "" {
129
+ if strings.TrimSpace(m.Prompt) != "" {
130
+ return ModelProfileStyleCustom
131
+ }
132
+ return ModelProfileStyleInherit
133
+ }
134
+ return NormalizeModelProfileStyle(style)
135
+ }
136
+
137
  // ModelProfileReader 由配置中心实现,供模型解析与列表接口读取自定义模型。
138
  type ModelProfileReader interface {
139
  ModelProfiles() []ModelProfileConfig
 
151
  }
152
  }
153
 
154
+ // Normalize 归一化单条自定义模型:裁剪空白、基模转小写、风格档位转小写。
155
  func (m ModelProfileConfig) Normalize() ModelProfileConfig {
156
+ style := strings.ToLower(strings.TrimSpace(m.Style))
157
+ if style != "" {
158
+ style = NormalizeModelProfileStyle(style)
159
+ }
160
  return ModelProfileConfig{
161
  ID: strings.TrimSpace(m.ID),
162
  Base: strings.ToLower(strings.TrimSpace(m.Base)),
163
  Name: strings.TrimSpace(m.Name),
164
  Remark: strings.TrimSpace(m.Remark),
165
+ Style: style,
166
  Prompt: TruncatePromptProfileText(m.Prompt),
167
  }
168
  }
 
270
  return fmt.Errorf("model_profiles[%d].base must be one of %s",
271
  i, strings.Join(SupportedBaseModels(), ", "))
272
  }
273
+ if rawStyle := strings.TrimSpace(raw.Style); rawStyle != "" && !IsKnownModelProfileStyle(rawStyle) {
274
+ return fmt.Errorf("model_profiles[%d].style must be one of %s",
275
+ i, strings.Join(ModelProfileStyleNames(), ", "))
276
+ }
277
  }
278
  return nil
279
  }
internal/config/model_profile_test.go CHANGED
@@ -161,20 +161,60 @@ func TestResolveModelUsesModelProfiles(t *testing.T) {
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
 
 
161
  }
162
  }
163
 
164
+ func TestStoreResolveModelProfile(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
+ {ID: "deepseek-legacy", Base: "deepseek-v4-flash", Style: "legacy"},
169
  }}}
170
+ // 老配置只写了 prompt:按 custom 处理,正文生效。
171
+ got := store.ResolveModelProfile("deepseek-writer")
172
+ if !got.Found || got.Style != ModelProfileStyleCustom || got.Prompt != "writing spec" {
173
+ t.Fatalf("unexpected settings: %#v", got)
174
  }
175
+ // 什么都没配:inherit。
176
+ got = store.ResolveModelProfile("deepseek-plain")
177
+ if !got.Found || got.Style != ModelProfileStyleInherit || got.Prompt != "" {
178
+ t.Fatalf("unexpected settings: %#v", got)
179
  }
180
+ got = store.ResolveModelProfile("deepseek-legacy")
181
+ if !got.Found || got.Style != ModelProfileStyleLegacy {
182
+ t.Fatalf("unexpected settings: %#v", got)
183
+ }
184
+ // 未命中内置模型时完全跟随全局。
185
+ if got := store.ResolveModelProfile("deepseek-v4-flash"); got.Found {
186
+ t.Fatalf("expected no profile for builtin model, got %#v", got)
187
+ }
188
+ }
189
+
190
+ func TestModelProfileStylesNormalize(t *testing.T) {
191
+ cases := map[string]string{
192
+ "": ModelProfileStyleInherit,
193
+ "inherit": ModelProfileStyleInherit,
194
+ "V2": ModelProfileStyleV2,
195
+ "writing": ModelProfileStyleWriting,
196
+ " legacy": ModelProfileStyleLegacy,
197
+ "custom": ModelProfileStyleCustom,
198
+ "nope": ModelProfileStyleInherit,
199
+ }
200
+ for input, want := range cases {
201
+ if got := NormalizeModelProfileStyle(input); got != want {
202
+ t.Fatalf("style %q -> %q, want %q", input, got, want)
203
+ }
204
+ }
205
+ // 老配置兼容:无 style + 有 prompt ⇒ custom;无 style + 无 prompt ⇒ inherit。
206
+ if got := (ModelProfileConfig{Prompt: "x"}).EffectiveStyle(); got != ModelProfileStyleCustom {
207
+ t.Fatalf("expected custom for legacy prompt-only entry, got %q", got)
208
+ }
209
+ if got := (ModelProfileConfig{}).EffectiveStyle(); got != ModelProfileStyleInherit {
210
+ t.Fatalf("expected inherit for empty entry, got %q", got)
211
+ }
212
+ }
213
+
214
+ func TestValidateModelProfilesRejectsUnknownStyle(t *testing.T) {
215
+ err := ValidateModelProfiles([]ModelProfileConfig{{ID: "a", Base: "deepseek-v4-flash", Style: "nope"}})
216
+ if err == nil || !strings.Contains(err.Error(), "style must be one of") {
217
+ t.Fatalf("error=%v", err)
218
  }
219
  }
220
 
internal/config/store_accessors.go CHANGED
@@ -349,25 +349,33 @@ func (s *Store) ModelProfiles() []ModelProfileConfig {
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 是「提示词特化层」对单次请求生效的设置。
 
349
  return cloneModelProfiles(s.cfg.ModelProfiles)
350
  }
351
 
352
+ // ModelProfileSettings 是某个自定义模型条目对单次请求的提示词覆盖。
353
+ type ModelProfileSettings struct {
354
+ // Found 表示请求里的模型名命中了自定义模型条目。
355
+ Found bool
356
+ // Style 是生效的风格档位:inherit / v2 / writing / legacy / custom。
357
+ Style string
358
+ // Prompt 是自定义正文,只在 Style=custom 时有意义。
359
+ Prompt string
360
+ }
361
+
362
+ // ResolveModelProfile 按客户端请求里的模型名(大小写不敏感)解析自定义模型条目。
363
+ // 未命中时返回零值(Found=false),调用方应完全跟随全局设置。
364
+ func (s *Store) ResolveModelProfile(modelID string) ModelProfileSettings {
365
  if s == nil {
366
+ return ModelProfileSettings{}
367
  }
368
  s.mu.RLock()
369
  profile, ok := FindModelProfile(s.cfg.ModelProfiles, modelID)
370
  s.mu.RUnlock()
371
  if !ok {
372
+ return ModelProfileSettings{}
373
  }
374
+ return ModelProfileSettings{
375
+ Found: true,
376
+ Style: profile.EffectiveStyle(),
377
+ Prompt: strings.TrimSpace(profile.Prompt),
378
  }
 
379
  }
380
 
381
  // PromptProfileSettings 是「提示词特化层」对单次请求生效的设置。
internal/httpapi/admin/modelprofiles/handler_model_profiles.go CHANGED
@@ -12,6 +12,7 @@ import (
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"`
@@ -22,6 +23,7 @@ func (h *Handler) getModelProfiles(w http.ResponseWriter, _ *http.Request) {
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,
 
12
  type modelProfilesPayload struct {
13
  Items []config.ModelProfileConfig `json:"items"`
14
  BaseModels []string `json:"base_models"`
15
+ StyleNames []string `json:"style_names"`
16
  MaxCount int `json:"max_count"`
17
  MaxIDChars int `json:"max_id_chars"`
18
  MaxPromptChars int `json:"max_prompt_chars"`
 
23
  writeJSON(w, http.StatusOK, modelProfilesPayload{
24
  Items: items,
25
  BaseModels: config.SupportedBaseModels(),
26
+ StyleNames: config.ModelProfileStyleNames(),
27
  MaxCount: config.MaxModelProfiles,
28
  MaxIDChars: config.MaxModelProfileIDChars,
29
  MaxPromptChars: config.MaxPromptProfileChars,
internal/httpapi/admin/modelprofiles/handler_model_profiles_test.go CHANGED
@@ -38,6 +38,16 @@ func TestGetModelProfilesReturnsItemsAndLimits(t *testing.T) {
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
  }
@@ -98,6 +108,7 @@ func TestUpdateModelProfilesValidatesInput(t *testing.T) {
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"},
 
38
  if len(body.BaseModels) == 0 {
39
  t.Fatal("expected base model options")
40
  }
41
+ if len(body.StyleNames) != len(config.ModelProfileStyleNames()) {
42
+ t.Fatalf("style_names=%#v want %#v", body.StyleNames, config.ModelProfileStyleNames())
43
+ }
44
+ // 老配置只有 prompt、没有 style:接口层按 custom 解释,前端要能看到这一点。
45
+ if body.Items[0].Style != "" {
46
+ t.Fatalf("stored style should stay empty for legacy entries, got %q", body.Items[0].Style)
47
+ }
48
+ if got := body.Items[0].EffectiveStyle(); got != config.ModelProfileStyleCustom {
49
+ t.Fatalf("effective style=%q want custom", got)
50
+ }
51
  if body.MaxCount != config.MaxModelProfiles {
52
  t.Fatalf("max_count=%d want %d", body.MaxCount, config.MaxModelProfiles)
53
  }
 
108
  {name: "empty id", items: []map[string]any{{"base": "deepseek-v4-flash"}}},
109
  {name: "bad base", items: []map[string]any{{"id": "a", "base": "gpt-4o"}}},
110
  {name: "builtin shadow", items: []map[string]any{{"id": "deepseek-v4-flash", "base": "deepseek-v4-flash"}}},
111
+ {name: "unknown style", items: []map[string]any{{"id": "a", "base": "deepseek-v4-flash", "style": "nope"}}},
112
  {name: "duplicate", items: []map[string]any{
113
  {"id": "a", "base": "deepseek-v4-flash"},
114
  {"id": "a", "base": "deepseek-v4-flash"},
internal/httpapi/admin/settings/handler_settings_read.go CHANGED
@@ -97,6 +97,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
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
  },
 
97
  "count": len(h.Store.ModelProfiles()),
98
  "max_count": config.MaxModelProfiles,
99
  "base_models": config.SupportedBaseModels(),
100
+ "style_names": config.ModelProfileStyleNames(),
101
  "max_id_chars": config.MaxModelProfileIDChars,
102
  "max_prompt_chars": config.MaxPromptProfileChars,
103
  },
internal/httpapi/openai/history/current_input_file.go CHANGED
@@ -39,7 +39,7 @@ type Service struct {
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 {
@@ -49,7 +49,7 @@ func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth,
49
  // 在附件里。这里把同一段文本贴回可见 prompt,否则它在长对话中实际上
50
  // 等于没注入。只在本轮新发生拆分时贴一次,避免切号重传重复叠加。
51
  if !appliedBefore && stdReq.CurrentInputFileApplied {
52
- stdReq = applyThinkingInjectionToVisiblePrompt(a, stdReq)
53
  }
54
  // 纯文本增强(模型提示词 / 提示词特化层 / 技能库)都是 prompt 级叠加,必须
55
  // 放在历史拆分之后:拆分会把 prompt 整段替换成上下文文件说明,提前写入会被覆盖。
@@ -142,7 +142,7 @@ 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
- style := promptStyleOf(a)
146
  continuation := currentInputFilePrompt(style, filename, hasToolsFile)
147
  promptMessages = append(promptMessages, map[string]any{
148
  "role": "user",
@@ -237,80 +237,109 @@ func (s Service) ReuploadAppliedCurrentInputFile(ctx context.Context, a *auth.Re
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) {
 
39
  func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
40
  // 执行契约会写进最新一条用户消息,必须发生在历史拆分之前,
41
  // 否则拆分后上传的上下文文件里就缺了这段要求(旧版风格不注入内置契约)。
42
+ stdReq = s.applyThinkingInjection(a, stdReq)
43
  appliedBefore := stdReq.CurrentInputFileApplied
44
  stdReq, err := s.applyCurrentInputFile(ctx, a, stdReq)
45
  if err != nil {
 
49
  // 在附件里。这里把同一段文本贴回可见 prompt,否则它在长对话中实际上
50
  // 等于没注入。只在本轮新发生拆分时贴一次,避免切号重传重复叠加。
51
  if !appliedBefore && stdReq.CurrentInputFileApplied {
52
+ stdReq = s.applyThinkingInjectionToVisiblePrompt(a, stdReq)
53
  }
54
  // 纯文本增强(模型提示词 / 提示词特化层 / 技能库)都是 prompt 级叠加,必须
55
  // 放在历史拆分之后:拆分会把 prompt 整段替换成上下文文件说明,提前写入会被覆盖。
 
142
  } else if len(stdReq.ToolNames) == 0 && len(toolNames) > 0 {
143
  stdReq.ToolNames = toolNames
144
  }
145
+ style := s.resolvePromptStyle(a, stdReq)
146
  continuation := currentInputFilePrompt(style, filename, hasToolsFile)
147
  promptMessages = append(promptMessages, map[string]any{
148
  "role": "user",
 
237
  return stdReq, nil
238
  }
239
 
240
+ // modelProfileReader 由配置中心实现,用于读取自定义模型条目的风格与正文。
241
+ // 用可选接口而不是写进 CurrentInputConfigReader,是为了不打断测试里的轻量假实现。
242
+ type modelProfileReader interface {
243
+ ResolveModelProfile(modelID string) config.ModelProfileSettings
244
+ }
245
+
246
+ // modelProfile 返回客户端所选自定义模型条目的设置;未命中时返回零值。
247
+ //
248
+ // 用 RequestedModel 而不是 ResolvedModel:解析后的模型名已经是 flash 基模,
249
+ // 拿不回「客户端到底选了哪个自定义模型」。
250
+ func (s Service) modelProfile(stdReq promptcompat.StandardRequest) config.ModelProfileSettings {
251
+ if s.Store == nil {
252
+ return config.ModelProfileSettings{}
253
+ }
254
+ reader, ok := s.Store.(modelProfileReader)
255
+ if !ok {
256
+ return config.ModelProfileSettings{}
257
+ }
258
+ modelID := strings.TrimSpace(stdReq.RequestedModel)
259
+ if modelID == "" {
260
+ return config.ModelProfileSettings{}
261
+ }
262
+ return reader.ResolveModelProfile(modelID)
263
+ }
264
+
265
+ // resolvePromptStyle 返回本次请求生效的内置提示词风格。
266
+ //
267
+ // 自定义模型条目可以覆盖全局风格:v2 / writing 强制 v2 文案,legacy 强制旧版文案,
268
+ // custom 与 inherit 则跟随全局(custom 只换特化层正文,不改内置文案)。
269
+ func (s Service) resolvePromptStyle(a *auth.RequestAuth, stdReq promptcompat.StandardRequest) string {
270
+ global := config.PromptStyleV2
271
+ if a != nil {
272
+ global = config.NormalizePromptStyle(a.PromptStyle)
273
+ }
274
+ switch s.modelProfile(stdReq).Style {
275
+ case config.ModelProfileStyleV2, config.ModelProfileStyleWriting:
276
  return config.PromptStyleV2
277
+ case config.ModelProfileStyleLegacy:
278
+ return config.PromptStyleLegacy
279
+ default:
280
+ return global
281
  }
 
282
  }
283
 
284
  // applyThinkingInjection 应用本次请求生效的「思考格式注入」设置。
285
  // 设置已在鉴权阶段完成全局 + 单 Key 自定义的合并,这里只负责落盘到请求里。
286
+ func (s Service) applyThinkingInjection(a *auth.RequestAuth, stdReq promptcompat.StandardRequest) promptcompat.StandardRequest {
287
  if a == nil {
288
  return stdReq
289
  }
290
  return promptcompat.ApplyThinkingInjectionToRequest(
291
+ stdReq, s.resolvePromptStyle(a, stdReq), a.Injection.Enabled, a.Injection.Prompt)
292
  }
293
 
294
  // applyThinkingInjectionToVisiblePrompt 在历史拆分替换掉 prompt 之后,
295
  // 把执行契约重新贴到可见 prompt 上(只改 FinalPrompt)。
296
+ func (s Service) applyThinkingInjectionToVisiblePrompt(a *auth.RequestAuth, stdReq promptcompat.StandardRequest) promptcompat.StandardRequest {
297
  if a == nil {
298
  return stdReq
299
  }
300
  return promptcompat.ReapplyThinkingInjectionToPrompt(
301
+ stdReq, s.resolvePromptStyle(a, stdReq), a.Injection.Enabled, a.Injection.Prompt)
302
  }
303
 
304
+ // applyOverlays 把 prompt 级叠加层(模型风格/正文 / 全局特化层 / 技能库)合进最终 prompt。
 
 
 
 
 
 
305
  //
306
  // 分段规则:
307
+ // - 外层规范按「模型条目 > 全局 prompt_profile」解析(见 modelOverlayBody);
308
  // - 内层是技能库(更贴近当前任务);
309
  // - 全部合成一个 System 段。
310
  func (s Service) applyOverlays(a *auth.RequestAuth, stdReq promptcompat.StandardRequest) promptcompat.StandardRequest {
311
  blocks := make([]string, 0, 2)
312
+ blocks = append(blocks, s.modelOverlayBody(a, stdReq))
 
 
 
 
313
  if a != nil && a.Skills.Enabled && len(a.Skills.Items) > 0 {
314
  blocks = append(blocks, promptcompat.BuildSkillsPrompt(a.Skills.Items, a.Skills.MaxChars))
315
  }
316
  return promptcompat.ApplySystemOverlays(stdReq, blocks...)
317
  }
318
 
319
+ // modelOverlayBody 决定外层规范正文。
320
  //
321
+ // inherit — 全局 prompt_profile
322
+ // v2 / legacy — 不注入(这两档只改内置文案)
323
+ // writing — 写作规范正文
324
+ // custom — 条目自己的正文;为空时回落到全局
325
+ func (s Service) modelOverlayBody(a *auth.RequestAuth, stdReq promptcompat.StandardRequest) string {
326
+ profile := s.modelProfile(stdReq)
327
+ switch profile.Style {
328
+ case config.ModelProfileStyleV2, config.ModelProfileStyleLegacy:
329
  return ""
330
+ case config.ModelProfileStyleWriting:
331
+ return promptcompat.BuildPromptProfilePrompt(config.PromptProfileNameWriting, "")
332
+ case config.ModelProfileStyleCustom:
333
+ if body := strings.TrimSpace(profile.Prompt); body != "" {
334
+ return promptcompat.BuildPromptProfilePrompt("", body)
335
+ }
 
336
  return ""
337
+ default:
338
+ if a == nil {
339
+ return ""
340
+ }
341
+ return promptcompat.PromptProfileBlock(a.PromptProfile)
342
  }
 
343
  }
344
 
345
  func latestUserInputForFile(messages []any) (int, string) {
internal/httpapi/openai/history/current_input_file_overlay_test.go CHANGED
@@ -10,26 +10,23 @@ import (
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 {
@@ -46,83 +43,114 @@ func overlayTestRequest(requestedModel string) promptcompat.StandardRequest {
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
  }
@@ -133,7 +161,7 @@ func TestModelProfilePromptCombinesWithSkills(t *testing.T) {
133
  }
134
  }
135
 
136
- // 旧版风格:历史拆分后既不带「执行要求」,也不注入内置执行契约。
137
  func TestLegacyStyleKeepsSplitPromptMinimal(t *testing.T) {
138
  svc := Service{Store: overlayTestStore{enabled: true}, DS: &skillsTestUploader{}}
139
  a := &auth.RequestAuth{
@@ -156,7 +184,6 @@ func TestLegacyStyleKeepsSplitPromptMinimal(t *testing.T) {
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
  }
 
10
  "ds2api/internal/promptcompat"
11
  )
12
 
13
+ // overlayTestStore 让历史拆分保持开启,并可按模型名返回该条目的风格档位。
14
  type overlayTestStore struct {
15
+ enabled bool
16
+ minChars int
17
+ profiles map[string]config.ModelProfileSettings
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) ResolveModelProfile(modelID string) config.ModelProfileSettings {
24
+ for id, settings := range s.profiles {
25
  if strings.EqualFold(strings.TrimSpace(id), strings.TrimSpace(modelID)) {
26
+ return settings
 
 
 
27
  }
28
  }
29
+ return config.ModelProfileSettings{}
30
  }
31
 
32
  func overlayTestRequest(requestedModel string) promptcompat.StandardRequest {
 
43
  }
44
  }
45
 
46
+ func applyWithProfile(t *testing.T, a *auth.RequestAuth, store overlayTestStore, model string) promptcompat.StandardRequest {
47
+ t.Helper()
48
+ out, err := (Service{Store: store, DS: &skillsTestUploader{}}).
49
+ ApplyCurrentInputFile(context.Background(), a, overlayTestRequest(model))
50
+ if err != nil {
51
+ t.Fatalf("apply current input file failed: %v", err)
 
 
52
  }
53
+ return out
54
+ }
55
+
56
+ // custom 档位:用条目自己的正文,不再叠加全局特化层。
57
+ func TestModelProfileCustomOverridesGlobalProfile(t *testing.T) {
58
+ store := overlayTestStore{enabled: true, profiles: map[string]config.ModelProfileSettings{
59
+ "deepseek-writer": {Found: true, Style: config.ModelProfileStyleCustom, Prompt: "model spec"},
60
+ }}
61
  a := &auth.RequestAuth{
62
  PromptProfile: config.PromptProfileSettings{Enabled: true, Name: config.PromptProfileNameWriting},
63
  }
64
+ out := applyWithProfile(t, a, store, "deepseek-writer")
 
 
 
65
  if !strings.Contains(out.FinalPrompt, "model spec") {
66
+ t.Fatalf("expected model prompt, got %q", out.FinalPrompt)
67
  }
68
  globalBody := promptcompat.BuildPromptProfilePrompt(config.PromptProfileNameWriting, "")
69
  if strings.Contains(out.FinalPrompt, globalBody) {
70
+ t.Fatalf("global 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
+ // writing 档位:即使全局特化层关闭,也会注入写作规范。
78
+ func TestModelProfileWritingInjectsPreset(t *testing.T) {
79
+ store := overlayTestStore{enabled: true, profiles: map[string]config.ModelProfileSettings{
80
+ "deepseek-writer": {Found: true, Style: config.ModelProfileStyleWriting},
81
+ }}
82
+ out := applyWithProfile(t, &auth.RequestAuth{}, store, "deepseek-writer")
83
+ want := promptcompat.BuildPromptProfilePrompt(config.PromptProfileNameWriting, "")
84
+ if !strings.Contains(out.FinalPrompt, want) {
85
+ t.Fatalf("expected writing preset body, got %q", out.FinalPrompt)
86
+ }
87
+ if !strings.Contains(out.FinalPrompt, "## 执行要求") {
88
+ t.Fatalf("writing style keeps the v2 continuation, got %q", out.FinalPrompt)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  }
90
  }
91
 
92
+ // v2 / legacy 档位:只切内置文案,不注入特化层。
93
+ func TestModelProfileV2AndLegacySwapBuiltinText(t *testing.T) {
94
+ store := overlayTestStore{enabled: true, profiles: map[string]config.ModelProfileSettings{
95
+ "deepseek-v2": {Found: true, Style: config.ModelProfileStyleV2},
96
+ "deepseek-legacy": {Found: true, Style: config.ModelProfileStyleLegacy},
97
+ }}
98
+ // 全局是 legacy,但该模型强制 v2。
99
+ a := &auth.RequestAuth{
100
+ PromptStyle: config.PromptStyleLegacy,
101
+ PromptProfile: config.PromptProfileSettings{Enabled: true, Name: config.PromptProfileNameDefault},
102
+ }
103
+ v2Out := applyWithProfile(t, a, store, "deepseek-v2")
104
+ if !strings.Contains(v2Out.FinalPrompt, "## 执行要求") {
105
+ t.Fatalf("v2 profile should use the v2 continuation, got %q", v2Out.FinalPrompt)
106
+ }
107
+ if strings.Contains(v2Out.FinalPrompt, "System: ") {
108
+ t.Fatalf("v2 profile must not inject an overlay, got %q", v2Out.FinalPrompt)
109
+ }
110
+
111
+ // 反过来:全局 v2,该模型强制 legacy。
112
+ b := &auth.RequestAuth{PromptStyle: config.PromptStyleV2}
113
+ legacyOut := applyWithProfile(t, b, store, "deepseek-legacy")
114
+ if strings.Contains(legacyOut.FinalPrompt, "## 执行要求") {
115
+ t.Fatalf("legacy profile should use the short continuation, got %q", legacyOut.FinalPrompt)
116
  }
117
+ if strings.Contains(legacyOut.FinalPrompt, promptcompat.ThinkingInjectionMarker) {
118
+ t.Fatalf("legacy profile must not inject the builtin contract, got %q", legacyOut.FinalPrompt)
119
+ }
120
+ }
121
+
122
+ // inherit 档位(以及未命中):完全跟随全局。
123
+ func TestModelProfileInheritFollowsGlobal(t *testing.T) {
124
+ store := overlayTestStore{enabled: true, profiles: map[string]config.ModelProfileSettings{
125
+ "deepseek-plain": {Found: true, Style: config.ModelProfileStyleInherit},
126
+ }}
127
+ a := &auth.RequestAuth{
128
+ PromptStyle: config.PromptStyleLegacy,
129
+ PromptProfile: config.PromptProfileSettings{Enabled: true, Name: config.PromptProfileNameDefault},
130
+ }
131
+ for _, model := range []string{"deepseek-plain", "deepseek-v4-flash"} {
132
+ out := applyWithProfile(t, a, store, model)
133
+ if !strings.Contains(out.FinalPrompt, promptcompat.BuildPromptProfilePrompt(config.PromptProfileNameDefault, "")) {
134
+ t.Fatalf("model %q should follow the global profile, got %q", model, out.FinalPrompt)
135
+ }
136
+ if strings.Contains(out.FinalPrompt, "## 执行要求") {
137
+ t.Fatalf("model %q should follow the global legacy style, got %q", model, out.FinalPrompt)
138
+ }
139
+ }
140
+ }
141
+
142
+ // 模型正文与技能层合并成一个 System 段,顺序:模型 → 技能。
143
+ func TestModelProfileCustomCombinesWithSkills(t *testing.T) {
144
+ store := overlayTestStore{enabled: true, profiles: map[string]config.ModelProfileSettings{
145
+ "deepseek-writer": {Found: true, Style: config.ModelProfileStyleCustom, Prompt: "model spec"},
146
+ }}
147
  a := &auth.RequestAuth{
148
  Skills: config.SkillSettings{
149
  Enabled: true,
150
  Items: []config.SkillConfig{{ID: "sql-report", Body: "sql body"}},
151
  },
152
  }
153
+ out := applyWithProfile(t, a, store, "deepseek-writer")
 
 
 
154
  if got := strings.Count(out.FinalPrompt, "System: "); got != 1 {
155
  t.Fatalf("expected one System marker, got %d in %q", got, out.FinalPrompt)
156
  }
 
161
  }
162
  }
163
 
164
+ // 旧版风格(全局):拆分后既不带「执行要求」,也不注入内置执行契约。
165
  func TestLegacyStyleKeepsSplitPromptMinimal(t *testing.T) {
166
  svc := Service{Store: overlayTestStore{enabled: true}, DS: &skillsTestUploader{}}
167
  a := &auth.RequestAuth{
 
184
  if !strings.Contains(out.FinalPrompt, out.CurrentInputFilename) {
185
  t.Fatalf("expected continuation to reference the context file, got %q", out.FinalPrompt)
186
  }
 
187
  if strings.Contains(out.HistoryText, promptcompat.ThinkingInjectionMarker) {
188
  t.Fatalf("legacy transcript must not carry the contract, got %q", out.HistoryText)
189
  }
internal/promptcompat/prompt_texts.go CHANGED
@@ -159,8 +159,15 @@ const promptProfileWritingBody = "以下内容是服务端补充的写作规范
159
  "4. 篇幅按用户要求执行;没要求时按内容需要来,不注水、不堆砌同义句。\n" +
160
  "5. 保持上下文连续:人名、术语、编号、称呼与上文一致;改稿时说明改动了哪些地方。\n" +
161
  "6. 语言跟随用户;中文写作不用「作为AI」「总之」这类套话收尾。\n\n" +
 
 
 
 
 
 
162
  "## 交付前自检\n" +
163
- "逐条核对:篇幅是否符合、硬约束是否满足、有没有漏掉用户点名要写的点、有没有出现用户明确禁止的写法。" +
 
164
  "发现缺项先补齐再交付;这份自检只在内部完成,不要输出核对过程。"
165
 
166
  // BuildPromptProfilePrompt 返回特化层正文。
 
159
  "4. 篇幅按用户要求执行;没要求时按内容需要来,不注水、不堆砌同义句。\n" +
160
  "5. 保持上下文连续:人名、术语、编号、称呼与上文一致;改稿时说明改动了哪些地方。\n" +
161
  "6. 语言跟随用户;中文写作不用「作为AI」「总之」这类套话收尾。\n\n" +
162
+ "## 不要照搬指令原文\n" +
163
+ "1. 把用户的指令当成要求,不要当成正文素材:要求句、约束说明、括号里的备注,都不要原样搬进正文," +
164
+ "也不要复述「请写一篇…」「要求:…」这类指令框架。\n" +
165
+ "2. 这一条不适用于用户明确要求原样出现的内容:点名要照抄的句子、口号、标题、术语、数据、引文," +
166
+ "以及必须出现的关键词,都要一字不改地写进去,位置按用户要求安排。\n" +
167
+ "3. 拿不准时分辨这句是「让我怎么写」还是「要我写什么」:前者转成正文表达,后者原样保留。\n\n" +
168
  "## 交付前自检\n" +
169
+ "逐条核对:篇幅是否符合、硬约束是否满足、有没有漏掉用户点名要写的点、有没有出现用户明确禁止的写法、" +
170
+ "有没有把指令原文抄成正文。" +
171
  "发现缺项先补齐再交付;这份自检只在内部完成,不要输出核对过程。"
172
 
173
  // BuildPromptProfilePrompt 返回特化层正文。
internal/promptcompat/prompt_texts_test.go CHANGED
@@ -166,6 +166,7 @@ func TestWritingPresetCoversTheTaskComplaints(t *testing.T) {
166
  "不要输出核对过程", // 自检不外泄
167
  "分清虚构与非虚构", // 不编造只针对非虚构,别把创作能力压死
168
  "虚构创作可以按设定发挥",
 
169
  } {
170
  if !strings.Contains(body, want) {
171
  t.Fatalf("writing preset missing %q: %q", want, body)
@@ -173,6 +174,28 @@ func TestWritingPresetCoversTheTaskComplaints(t *testing.T) {
173
  }
174
  }
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  // 通用预设对每一次请求生效(含闲聊),所以不允许写死交付物文风;语气交给「跟着对方走」。
177
  func TestDefaultPresetStaysChatFriendly(t *testing.T) {
178
  body := BuildPromptProfilePrompt(config.PromptProfileNameDefault, "")
 
166
  "不要输出核对过程", // 自检不外泄
167
  "分清虚构与非虚构", // 不编造只针对非虚构,别把创作能力压死
168
  "虚构创作可以按设定发挥",
169
+ "不要照搬指令原文", // 不把指令原样抄成正文
170
  } {
171
  if !strings.Contains(body, want) {
172
  t.Fatalf("writing preset missing %q: %q", want, body)
 
174
  }
175
  }
176
 
177
+ // 「不要照搬指令原文」必须自带两个例外,否则模型会把「必须原样出现的句子/关键词」也一起删掉。
178
+ func TestWritingPresetAntiCopyKeepsUserMandatedText(t *testing.T) {
179
+ body := BuildPromptProfilePrompt(config.PromptProfileNameWriting, "")
180
+ for _, want := range []string{
181
+ "不适用于用户明确要求原样出现的内容",
182
+ "点名要照抄的句子",
183
+ "必须出现的关键词",
184
+ "一字不改地写进去",
185
+ "「让我怎么写」还是「要我写什么」",
186
+ } {
187
+ if !strings.Contains(body, want) {
188
+ t.Fatalf("writing preset anti-copy rule missing %q: %q", want, body)
189
+ }
190
+ }
191
+ // 不允许写成笼统的「禁止引用用户原话」:那会连用户点名要写的内容一起禁掉。
192
+ for _, banned := range []string{"禁止引用", "不得使用用户的措辞", "不准出现用户的词"} {
193
+ if strings.Contains(body, banned) {
194
+ t.Fatalf("anti-copy rule must not be blanket (%q): %q", banned, body)
195
+ }
196
+ }
197
+ }
198
+
199
  // 通用预设对每一次请求生效(含闲聊),所以不允许写死交付物文风;语气交给「跟着对方走」。
200
  func TestDefaultPresetStaysChatFriendly(t *testing.T) {
201
  body := BuildPromptProfilePrompt(config.PromptProfileNameDefault, "")
webui/src/features/models/ModelAliasesSection.jsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import { Save } from 'lucide-react'
3
+
4
+ import { fetchSettings, putSettings } from '../settings/settingsApi'
5
+
6
+ // 模型别名在 /admin/settings 里是一个 JSON 对象;这里单独读写,避免与设置页表单耦合。
7
+ export default function ModelAliasesSection({ t, apiFetch, onMessage }) {
8
+ const [text, setText] = useState('{}')
9
+ const [loading, setLoading] = useState(false)
10
+ const [saving, setSaving] = useState(false)
11
+ const [error, setError] = useState('')
12
+
13
+ const load = useCallback(async () => {
14
+ setLoading(true)
15
+ try {
16
+ const { res, data } = await fetchSettings(apiFetch, t)
17
+ if (!res.ok) {
18
+ setError(data?.detail || t('settings.loadFailed'))
19
+ return
20
+ }
21
+ const aliases = data?.config_snapshot?.model_aliases || data?.model_aliases || {}
22
+ setText(JSON.stringify(aliases || {}, null, 2))
23
+ setError('')
24
+ } catch (_e) {
25
+ setError(t('settings.loadFailed'))
26
+ } finally {
27
+ setLoading(false)
28
+ }
29
+ }, [apiFetch, t])
30
+
31
+ useEffect(() => {
32
+ load()
33
+ }, [load])
34
+
35
+ const save = useCallback(async () => {
36
+ let parsed
37
+ try {
38
+ parsed = JSON.parse(text || '{}')
39
+ } catch (_e) {
40
+ const detail = t('settings.invalidJsonField', { field: 'model_aliases' })
41
+ setError(detail)
42
+ onMessage?.('error', detail)
43
+ return
44
+ }
45
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
46
+ const detail = t('settings.invalidJsonField', { field: 'model_aliases' })
47
+ setError(detail)
48
+ onMessage?.('error', detail)
49
+ return
50
+ }
51
+ setSaving(true)
52
+ try {
53
+ const { res, data } = await putSettings(apiFetch, { model_aliases: parsed })
54
+ if (!res.ok) {
55
+ const detail = data?.detail || t('settings.saveFailed')
56
+ setError(detail)
57
+ onMessage?.('error', detail)
58
+ return
59
+ }
60
+ setError('')
61
+ onMessage?.('success', t('settings.modelAliasesSaved'))
62
+ } catch (_e) {
63
+ setError(t('settings.saveFailed'))
64
+ onMessage?.('error', t('settings.saveFailed'))
65
+ } finally {
66
+ setSaving(false)
67
+ }
68
+ }, [apiFetch, onMessage, text, t])
69
+
70
+ return (
71
+ <div className="bg-card border border-border rounded-xl p-5 space-y-4">
72
+ <div className="space-y-1">
73
+ <h3 className="font-semibold">{t('settings.modelTitle')}</h3>
74
+ <p className="text-xs text-muted-foreground">{t('settings.modelAliasesDesc')}</p>
75
+ </div>
76
+ {error && <p className="text-xs text-destructive">{error}</p>}
77
+ <label className="text-sm space-y-2 block">
78
+ <span className="text-muted-foreground">{t('settings.modelAliases')}</span>
79
+ <textarea
80
+ value={text}
81
+ onChange={(e) => setText(e.target.value)}
82
+ rows={12}
83
+ spellCheck={false}
84
+ className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs"
85
+ />
86
+ </label>
87
+ <div className="flex items-center gap-2">
88
+ <button
89
+ type="button"
90
+ onClick={save}
91
+ disabled={saving || loading}
92
+ 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"
93
+ >
94
+ <Save className="w-4 h-4" />
95
+ {saving ? t('settings.saving') : t('settings.modelAliasesSave')}
96
+ </button>
97
+ <span className="text-xs text-muted-foreground">{t('settings.modelAliasesHint')}</span>
98
+ </div>
99
+ </div>
100
+ )
101
+ }
webui/src/features/models/ModelsContainer.jsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useI18n } from '../../i18n'
2
+ import ModelProfilesSection from '../settings/ModelProfilesSection'
3
+ import ModelAliasesSection from './ModelAliasesSection'
4
+
5
+ export default function ModelsContainer({ onMessage, authFetch }) {
6
+ const { t } = useI18n()
7
+ const apiFetch = authFetch || fetch
8
+
9
+ return (
10
+ <div className="space-y-6">
11
+ <div className="space-y-1">
12
+ <h1 className="text-lg font-semibold">{t('modelsPage.title')}</h1>
13
+ <p className="text-sm text-muted-foreground">{t('modelsPage.desc')}</p>
14
+ </div>
15
+
16
+ <ModelProfilesSection t={t} apiFetch={apiFetch} onMessage={onMessage} />
17
+
18
+ <ModelAliasesSection t={t} apiFetch={apiFetch} onMessage={onMessage} />
19
+ </div>
20
+ )
21
+ }
webui/src/features/settings/BehaviorSection.jsx CHANGED
@@ -28,84 +28,6 @@ export default function BehaviorSection({ t, form, setForm }) {
28
  className="w-full bg-background border border-border rounded-lg px-3 py-2"
29
  />
30
  </label>
31
- <label className="flex items-start gap-3 rounded-lg border border-border bg-background/60 p-4">
32
- <input
33
- type="checkbox"
34
- checked={Boolean(form.thinking_injection?.enabled ?? true)}
35
- onChange={(e) => setForm((prev) => ({
36
- ...prev,
37
- thinking_injection: {
38
- ...prev.thinking_injection,
39
- enabled: e.target.checked,
40
- },
41
- }))}
42
- className="mt-1 h-4 w-4 rounded border-border"
43
- />
44
- <div className="space-y-1">
45
- <span className="text-sm font-medium block">{t('settings.thinkingInjectionEnabled')}</span>
46
- <span className="text-xs text-muted-foreground block">{t('settings.thinkingInjectionDesc')}</span>
47
- </div>
48
- </label>
49
- <div className="text-sm space-y-2 md:col-span-2">
50
- <div className="flex items-center justify-between gap-3">
51
- <label htmlFor="thinking-injection-prompt" className="text-muted-foreground">
52
- {t('settings.thinkingInjectionPrompt')}
53
- </label>
54
- <button
55
- type="button"
56
- disabled={!form.thinking_injection?.prompt}
57
- onClick={() => setForm((prev) => ({
58
- ...prev,
59
- thinking_injection: {
60
- ...prev.thinking_injection,
61
- // 清空即回到内置默认提示词,开关状态保持不变。
62
- prompt: '',
63
- },
64
- }))}
65
- className="h-8 px-2.5 rounded-lg border border-border bg-background text-xs text-muted-foreground hover:text-foreground hover:bg-secondary/70 disabled:opacity-50 disabled:hover:text-muted-foreground disabled:hover:bg-background"
66
- >
67
- {t('settings.thinkingInjectionRestoreDefault')}
68
- </button>
69
- </div>
70
- <textarea
71
- id="thinking-injection-prompt"
72
- rows={8}
73
- value={form.thinking_injection?.prompt || ''}
74
- placeholder={form.thinking_injection?.default_prompt || ''}
75
- onChange={(e) => setForm((prev) => ({
76
- ...prev,
77
- thinking_injection: {
78
- ...prev.thinking_injection,
79
- prompt: e.target.value,
80
- },
81
- }))}
82
- className="w-full bg-background border border-border rounded-lg px-3 py-2 resize-y min-h-48 text-sm leading-6"
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
  )
 
28
  className="w-full bg-background border border-border rounded-lg px-3 py-2"
29
  />
30
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  </div>
32
  </div>
33
  )
webui/src/features/settings/ModelProfilesSection.jsx CHANGED
@@ -10,8 +10,23 @@ const FALLBACK_BASE_MODELS = [
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) {
@@ -21,6 +36,8 @@ function normalizeItems(data) {
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
  }
@@ -30,6 +47,7 @@ export default function ModelProfilesSection({ t, apiFetch, onMessage }) {
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({})
@@ -47,6 +65,9 @@ export default function ModelProfilesSection({ t, apiFetch, onMessage }) {
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
  }
@@ -75,8 +96,8 @@ export default function ModelProfilesSection({ t, apiFetch, onMessage }) {
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] }))
@@ -123,6 +144,7 @@ export default function ModelProfilesSection({ t, apiFetch, onMessage }) {
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
  }
@@ -192,6 +214,20 @@ export default function ModelProfilesSection({ t, apiFetch, onMessage }) {
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
@@ -202,16 +238,12 @@ export default function ModelProfilesSection({ t, apiFetch, onMessage }) {
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"
@@ -219,7 +251,7 @@ export default function ModelProfilesSection({ t, apiFetch, onMessage }) {
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"
@@ -231,17 +263,31 @@ export default function ModelProfilesSection({ t, apiFetch, onMessage }) {
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>
 
10
  'deepseek-v4-flash-search-nothinking',
11
  ]
12
 
13
+ const STYLE_LABEL_KEYS = {
14
+ inherit: 'settings.modelProfilesStyleInherit',
15
+ v2: 'settings.modelProfilesStyleV2',
16
+ writing: 'settings.modelProfilesStyleWriting',
17
+ legacy: 'settings.modelProfilesStyleLegacy',
18
+ custom: 'settings.modelProfilesStyleCustom',
19
+ }
20
+
21
+ function emptyProfile(styleNames) {
22
+ return {
23
+ id: '',
24
+ base: FALLBACK_BASE_MODELS[0],
25
+ name: '',
26
+ remark: '',
27
+ style: styleNames.includes('inherit') ? 'inherit' : styleNames[0] || 'inherit',
28
+ prompt: '',
29
+ }
30
  }
31
 
32
  function normalizeItems(data) {
 
36
  base: String(item?.base || FALLBACK_BASE_MODELS[0]),
37
  name: String(item?.name || ''),
38
  remark: String(item?.remark || ''),
39
+ // 旧配置只有 prompt:展示成 custom,与后端 EffectiveStyle 的解释一致。
40
+ style: String(item?.style || (item?.prompt ? 'custom' : 'inherit')),
41
  prompt: String(item?.prompt || ''),
42
  }))
43
  }
 
47
  const [saving, setSaving] = useState(false)
48
  const [items, setItems] = useState([])
49
  const [baseModels, setBaseModels] = useState(FALLBACK_BASE_MODELS)
50
+ const [styleNames, setStyleNames] = useState(Object.keys(STYLE_LABEL_KEYS))
51
  const [maxCount, setMaxCount] = useState(50)
52
  const [maxPromptChars, setMaxPromptChars] = useState(20000)
53
  const [expandedRows, setExpandedRows] = useState({})
 
65
  if (Array.isArray(data?.base_models) && data.base_models.length > 0) {
66
  setBaseModels(data.base_models)
67
  }
68
+ if (Array.isArray(data?.style_names) && data.style_names.length > 0) {
69
+ setStyleNames(data.style_names)
70
+ }
71
  if (Number(data?.max_count) > 0) {
72
  setMaxCount(Number(data.max_count))
73
  }
 
96
  }, [])
97
 
98
  const addItem = useCallback(() => {
99
+ setItems((prev) => (prev.length >= maxCount ? prev : [...prev, emptyProfile(styleNames)]))
100
+ }, [maxCount, styleNames])
101
 
102
  const toggleRow = useCallback((index) => {
103
  setExpandedRows((prev) => ({ ...prev, [index]: !prev[index] }))
 
144
  base: String(item.base || '').trim(),
145
  name: String(item.name || '').trim(),
146
  remark: String(item.remark || '').trim(),
147
+ style: String(item.style || 'inherit'),
148
  prompt: String(item.prompt || ''),
149
  })),
150
  }
 
214
  ))}
215
  </select>
216
  </label>
217
+ <label className="text-xs space-y-1">
218
+ <span className="text-muted-foreground block">{t('settings.modelProfilesStyle')}</span>
219
+ <select
220
+ value={item.style}
221
+ onChange={(e) => updateItem(index, { style: e.target.value })}
222
+ className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm"
223
+ >
224
+ {styleNames.map((style) => (
225
+ <option key={style} value={style}>
226
+ {STYLE_LABEL_KEYS[style] ? t(STYLE_LABEL_KEYS[style]) : style}
227
+ </option>
228
+ ))}
229
+ </select>
230
+ </label>
231
  <label className="text-xs space-y-1">
232
  <span className="text-muted-foreground block">{t('settings.modelProfilesName')}</span>
233
  <input
 
238
  className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm"
239
  />
240
  </label>
 
 
 
 
 
 
 
 
 
241
  </div>
242
+ <p className="text-xs text-muted-foreground">
243
+ {STYLE_LABEL_KEYS[item.style]
244
+ ? t(`${STYLE_LABEL_KEYS[item.style]}Hint`)
245
+ : t('settings.modelProfilesStyleInheritHint')}
246
+ </p>
247
  <div className="flex items-center justify-between gap-2">
248
  <button
249
  type="button"
 
251
  className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
252
  >
253
  {expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
254
+ {expanded ? t('settings.skillsCollapse') : t('settings.modelProfilesExtra')}
255
  </button>
256
  <button
257
  type="button"
 
263
  </button>
264
  </div>
265
  {expanded && (
266
+ <div className="space-y-3">
267
+ <label className="text-xs space-y-1 block">
268
+ <span className="text-muted-foreground block">{t('settings.modelProfilesRemark')}</span>
269
+ <input
270
+ type="text"
271
+ value={item.remark}
272
+ onChange={(e) => updateItem(index, { remark: e.target.value })}
273
+ className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm"
274
+ />
275
+ </label>
276
+ <div className="space-y-1">
277
+ <span className="text-xs text-muted-foreground block">{t('settings.modelProfilesPrompt')}</span>
278
+ <textarea
279
+ rows={8}
280
+ value={item.prompt}
281
+ onChange={(e) => updateItem(index, { prompt: e.target.value })}
282
+ placeholder={t('settings.modelProfilesPromptPlaceholder')}
283
+ className="w-full bg-background border border-border rounded-lg px-3 py-2 resize-y min-h-32 text-sm leading-6"
284
+ />
285
+ <p className="text-xs text-muted-foreground">
286
+ {item.style === 'custom'
287
+ ? t('settings.modelProfilesPromptHintCustom')
288
+ : t('settings.modelProfilesPromptHintPreset')}
289
+ </p>
290
+ </div>
291
  </div>
292
  )}
293
  </div>
webui/src/features/settings/ModelSection.jsx DELETED
@@ -1,16 +0,0 @@
1
- export default function ModelSection({ t, form, setForm }) {
2
- return (
3
- <div className="bg-card border border-border rounded-xl p-5 space-y-4">
4
- <h3 className="font-semibold">{t('settings.modelTitle')}</h3>
5
- <label className="text-sm space-y-2 block">
6
- <span className="text-muted-foreground">{t('settings.modelAliases')}</span>
7
- <textarea
8
- value={form.model_aliases_text}
9
- onChange={(e) => setForm((prev) => ({ ...prev, model_aliases_text: e.target.value }))}
10
- rows={12}
11
- className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs"
12
- />
13
- </label>
14
- </div>
15
- )
16
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
webui/src/features/settings/PromptInjectionSection.jsx ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function PromptInjectionSection({ t, form, setForm }) {
2
+ return (
3
+ <div className="bg-card border border-border rounded-xl p-5 space-y-4">
4
+ <div className="space-y-1">
5
+ <h3 className="font-semibold">{t('settings.promptInjectionTitle')}</h3>
6
+ <p className="text-xs text-muted-foreground">{t('settings.promptInjectionDesc')}</p>
7
+ </div>
8
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
9
+ <label className="text-sm space-y-2">
10
+ <span className="text-muted-foreground">{t('settings.promptStyleTitle')}</span>
11
+ <select
12
+ value={form.prompt_style?.mode || 'v2'}
13
+ onChange={(e) => setForm((prev) => ({
14
+ ...prev,
15
+ prompt_style: { ...prev.prompt_style, mode: e.target.value },
16
+ }))}
17
+ className="w-full bg-background border border-border rounded-lg px-3 py-2"
18
+ >
19
+ {(form.prompt_style?.modes || ['v2', 'legacy']).map((mode) => (
20
+ <option key={mode} value={mode}>
21
+ {mode === 'legacy'
22
+ ? t('settings.promptStyleLegacy')
23
+ : t('settings.promptStyleV2')}
24
+ </option>
25
+ ))}
26
+ </select>
27
+ <span className="text-xs text-muted-foreground block">{t('settings.promptStyleHelp')}</span>
28
+ </label>
29
+ <label className="flex items-start gap-3 rounded-lg border border-border bg-background/60 p-4">
30
+ <input
31
+ type="checkbox"
32
+ checked={Boolean(form.thinking_injection?.enabled ?? true)}
33
+ onChange={(e) => setForm((prev) => ({
34
+ ...prev,
35
+ thinking_injection: {
36
+ ...prev.thinking_injection,
37
+ enabled: e.target.checked,
38
+ },
39
+ }))}
40
+ className="mt-1 h-4 w-4 rounded border-border"
41
+ />
42
+ <div className="space-y-1">
43
+ <span className="text-sm font-medium block">{t('settings.thinkingInjectionEnabled')}</span>
44
+ <span className="text-xs text-muted-foreground block">{t('settings.thinkingInjectionDesc')}</span>
45
+ </div>
46
+ </label>
47
+ <div className="text-sm space-y-2 md:col-span-2">
48
+ <div className="flex items-center justify-between gap-3">
49
+ <label htmlFor="thinking-injection-prompt" className="text-muted-foreground">
50
+ {t('settings.thinkingInjectionPrompt')}
51
+ </label>
52
+ <button
53
+ type="button"
54
+ disabled={!form.thinking_injection?.prompt}
55
+ onClick={() => setForm((prev) => ({
56
+ ...prev,
57
+ thinking_injection: {
58
+ ...prev.thinking_injection,
59
+ // 清空即回到内置默认提示词,开关状态保持不变。
60
+ prompt: '',
61
+ },
62
+ }))}
63
+ className="h-8 px-2.5 rounded-lg border border-border bg-background text-xs text-muted-foreground hover:text-foreground hover:bg-secondary/70 disabled:opacity-50 disabled:hover:text-muted-foreground disabled:hover:bg-background"
64
+ >
65
+ {t('settings.thinkingInjectionRestoreDefault')}
66
+ </button>
67
+ </div>
68
+ <textarea
69
+ id="thinking-injection-prompt"
70
+ rows={8}
71
+ value={form.thinking_injection?.prompt || ''}
72
+ placeholder={form.thinking_injection?.default_prompt || ''}
73
+ onChange={(e) => setForm((prev) => ({
74
+ ...prev,
75
+ thinking_injection: {
76
+ ...prev.thinking_injection,
77
+ prompt: e.target.value,
78
+ },
79
+ }))}
80
+ className="w-full bg-background border border-border rounded-lg px-3 py-2 resize-y min-h-48 text-sm leading-6"
81
+ />
82
+ <p className="text-xs text-muted-foreground">{t('settings.thinkingInjectionPromptHelp')}</p>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ )
87
+ }
webui/src/features/settings/SettingsContainer.jsx CHANGED
@@ -2,18 +2,18 @@ import { AlertTriangle, Save } from 'lucide-react'
2
 
3
  import { useI18n } from '../../i18n'
4
  import { useSettingsForm } from './useSettingsForm'
 
5
  import SecuritySection from './SecuritySection'
6
  import PlatformSection from './PlatformSection'
7
  import RuntimeSection from './RuntimeSection'
8
  import BehaviorSection from './BehaviorSection'
 
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'
16
- import ModelSection from './ModelSection'
17
  import BackupSection from './BackupSection'
18
 
19
  export default function SettingsContainer({ onRefresh, onMessage, authFetch, onForceLogout, isVercel = false }) {
@@ -56,7 +56,7 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
56
  })
57
 
58
  return (
59
- <div className="space-y-6">
60
  {autoFetchPaused && (
61
  <div className="p-4 rounded-lg border border-destructive/30 bg-destructive/10 text-destructive flex items-center justify-between gap-4">
62
  <div className="flex items-center gap-2">
@@ -87,51 +87,55 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
87
  </div>
88
  )}
89
 
90
- <SecuritySection
91
- t={t}
92
- form={form}
93
- setForm={setForm}
94
- newPassword={newPassword}
95
- setNewPassword={setNewPassword}
96
- changingPassword={changingPassword}
97
- onUpdatePassword={updatePassword}
98
- />
99
-
100
- <PlatformSection t={t} form={form} setForm={setForm} />
101
-
102
- <RuntimeSection t={t} form={form} setForm={setForm} />
103
-
104
- <BehaviorSection t={t} form={form} setForm={setForm} />
105
-
106
- <PromptProfileSection t={t} form={form} setForm={setForm} />
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} />
115
-
116
- <FileAuditSection t={t} form={form} setForm={setForm} />
117
-
118
- <AutoDeleteSection t={t} form={form} setForm={setForm} />
119
-
120
- <ModelSection t={t} form={form} setForm={setForm} />
121
-
122
- <BackupSection
123
- t={t}
124
- importMode={importMode}
125
- setImportMode={setImportMode}
126
- importing={importing}
127
- onLoadExportData={loadExportData}
128
- onDownloadExportFile={downloadExportFile}
129
- onImport={doImport}
130
- onImportFileChange={loadImportFile}
131
- importText={importText}
132
- setImportText={setImportText}
133
- exportData={exportData}
134
- />
 
 
 
 
135
 
136
  <div className="flex justify-end">
137
  <button
 
2
 
3
  import { useI18n } from '../../i18n'
4
  import { useSettingsForm } from './useSettingsForm'
5
+ import SettingsGroup from './SettingsGroup'
6
  import SecuritySection from './SecuritySection'
7
  import PlatformSection from './PlatformSection'
8
  import RuntimeSection from './RuntimeSection'
9
  import BehaviorSection from './BehaviorSection'
10
+ import PromptInjectionSection from './PromptInjectionSection'
11
  import PromptProfileSection from './PromptProfileSection'
12
  import ToolCallsSection from './ToolCallsSection'
13
  import SkillsSection from './SkillsSection'
 
14
  import CurrentInputFileSection from './CurrentInputFileSection'
15
  import FileAuditSection from './FileAuditSection'
16
  import AutoDeleteSection from './AutoDeleteSection'
 
17
  import BackupSection from './BackupSection'
18
 
19
  export default function SettingsContainer({ onRefresh, onMessage, authFetch, onForceLogout, isVercel = false }) {
 
56
  })
57
 
58
  return (
59
+ <div className="space-y-8">
60
  {autoFetchPaused && (
61
  <div className="p-4 rounded-lg border border-destructive/30 bg-destructive/10 text-destructive flex items-center justify-between gap-4">
62
  <div className="flex items-center gap-2">
 
87
  </div>
88
  )}
89
 
90
+ <SettingsGroup title={t('settings.groupPrompt')} description={t('settings.groupPromptDesc')}>
91
+ <PromptInjectionSection t={t} form={form} setForm={setForm} />
92
+ <PromptProfileSection t={t} form={form} setForm={setForm} />
93
+ </SettingsGroup>
94
+
95
+ <SettingsGroup title={t('settings.groupContext')} description={t('settings.groupContextDesc')}>
96
+ <CurrentInputFileSection t={t} form={form} setForm={setForm} />
97
+ <SkillsSection t={t} apiFetch={apiFetch} onMessage={onMessage} />
98
+ </SettingsGroup>
99
+
100
+ <SettingsGroup title={t('settings.groupRuntime')} description={t('settings.groupRuntimeDesc')}>
101
+ <RuntimeSection t={t} form={form} setForm={setForm} />
102
+ <BehaviorSection t={t} form={form} setForm={setForm} />
103
+ <PlatformSection t={t} form={form} setForm={setForm} />
104
+ <AutoDeleteSection t={t} form={form} setForm={setForm} />
105
+ <FileAuditSection t={t} form={form} setForm={setForm} />
106
+ </SettingsGroup>
107
+
108
+ <SettingsGroup title={t('settings.groupTools')} description={t('settings.groupToolsDesc')}>
109
+ <ToolCallsSection t={t} form={form} setForm={setForm} />
110
+ </SettingsGroup>
111
+
112
+ <SettingsGroup title={t('settings.groupSecurity')} description={t('settings.groupSecurityDesc')}>
113
+ <SecuritySection
114
+ t={t}
115
+ form={form}
116
+ setForm={setForm}
117
+ newPassword={newPassword}
118
+ setNewPassword={setNewPassword}
119
+ changingPassword={changingPassword}
120
+ onUpdatePassword={updatePassword}
121
+ />
122
+ </SettingsGroup>
123
+
124
+ <SettingsGroup title={t('settings.groupData')} description={t('settings.groupDataDesc')}>
125
+ <BackupSection
126
+ t={t}
127
+ importMode={importMode}
128
+ setImportMode={setImportMode}
129
+ importing={importing}
130
+ onLoadExportData={loadExportData}
131
+ onDownloadExportFile={downloadExportFile}
132
+ onImport={doImport}
133
+ onImportFileChange={loadImportFile}
134
+ importText={importText}
135
+ setImportText={setImportText}
136
+ exportData={exportData}
137
+ />
138
+ </SettingsGroup>
139
 
140
  <div className="flex justify-end">
141
  <button
webui/src/features/settings/SettingsGroup.jsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function SettingsGroup({ title, description, children }) {
2
+ return (
3
+ <section className="space-y-4">
4
+ <div className="space-y-1 border-l-2 border-primary/40 pl-3">
5
+ <h2 className="text-sm font-semibold tracking-wide">{title}</h2>
6
+ {description && <p className="text-xs text-muted-foreground">{description}</p>}
7
+ </div>
8
+ <div className="space-y-4">{children}</div>
9
+ </section>
10
+ )
11
+ }
webui/src/layout/DashboardShell.jsx CHANGED
@@ -11,6 +11,7 @@ import {
11
  Server,
12
  Users,
13
  Globe,
 
14
  History,
15
  Loader2
16
  } from 'lucide-react'
@@ -25,6 +26,7 @@ const ChatHistoryContainer = lazy(() => import('../features/chatHistory/ChatHist
25
  const BatchImport = lazy(() => import('../components/BatchImport'))
26
  const VercelSyncContainer = lazy(() => import('../features/vercel/VercelSyncContainer'))
27
  const SettingsContainer = lazy(() => import('../features/settings/SettingsContainer'))
 
28
  const ProxyManagerContainer = lazy(() => import('../features/proxy/ProxyManagerContainer'))
29
 
30
  function TabLoadingFallback({ label }) {
@@ -46,6 +48,7 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s
46
 
47
  const navItems = [
48
  { id: 'accounts', label: t('nav.accounts.label'), icon: Users, description: t('nav.accounts.desc') },
 
49
  { id: 'proxies', label: t('nav.proxies.label'), icon: Globe, description: t('nav.proxies.desc') },
50
  { id: 'test', label: t('nav.test.label'), icon: Server, description: t('nav.test.desc') },
51
  { id: 'history', label: t('nav.history.label'), icon: History, description: t('nav.history.desc') },
@@ -111,6 +114,8 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s
111
  switch (activeTab) {
112
  case 'accounts':
113
  return <AccountManagerContainer config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
 
 
114
  case 'proxies':
115
  return <ProxyManagerContainer config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
116
  case 'test':
 
11
  Server,
12
  Users,
13
  Globe,
14
+ Layers,
15
  History,
16
  Loader2
17
  } from 'lucide-react'
 
26
  const BatchImport = lazy(() => import('../components/BatchImport'))
27
  const VercelSyncContainer = lazy(() => import('../features/vercel/VercelSyncContainer'))
28
  const SettingsContainer = lazy(() => import('../features/settings/SettingsContainer'))
29
+ const ModelsContainer = lazy(() => import('../features/models/ModelsContainer'))
30
  const ProxyManagerContainer = lazy(() => import('../features/proxy/ProxyManagerContainer'))
31
 
32
  function TabLoadingFallback({ label }) {
 
48
 
49
  const navItems = [
50
  { id: 'accounts', label: t('nav.accounts.label'), icon: Users, description: t('nav.accounts.desc') },
51
+ { id: 'models', label: t('nav.models.label'), icon: Layers, description: t('nav.models.desc') },
52
  { id: 'proxies', label: t('nav.proxies.label'), icon: Globe, description: t('nav.proxies.desc') },
53
  { id: 'test', label: t('nav.test.label'), icon: Server, description: t('nav.test.desc') },
54
  { id: 'history', label: t('nav.history.label'), icon: History, description: t('nav.history.desc') },
 
114
  switch (activeTab) {
115
  case 'accounts':
116
  return <AccountManagerContainer config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
117
+ case 'models':
118
+ return <ModelsContainer onMessage={showMessage} authFetch={authFetch} />
119
  case 'proxies':
120
  return <ProxyManagerContainer config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
121
  case 'test':
webui/src/locales/en.json CHANGED
@@ -4,11 +4,19 @@
4
  "english": "English",
5
  "chinese": "中文"
6
  },
 
 
 
 
7
  "nav": {
8
  "accounts": {
9
  "label": "Account Management",
10
  "desc": "Manage the DeepSeek account pool"
11
  },
 
 
 
 
12
  "proxies": {
13
  "label": "Proxy IPs",
14
  "desc": "Manage outbound proxy nodes for accounts"
@@ -477,6 +485,20 @@
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)",
@@ -492,6 +514,24 @@
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",
 
4
  "english": "English",
5
  "chinese": "中文"
6
  },
7
+ "modelsPage": {
8
+ "title": "Models",
9
+ "desc": "Expose several public model names on top of the same flash base model, each with its own style (follow global / v2 / writing / legacy / custom body). Clients pick a name; entries never affect each other."
10
+ },
11
  "nav": {
12
  "accounts": {
13
  "label": "Account Management",
14
  "desc": "Manage the DeepSeek account pool"
15
  },
16
+ "models": {
17
+ "label": "Models",
18
+ "desc": "Custom models and model aliases"
19
+ },
20
  "proxies": {
21
  "label": "Proxy IPs",
22
  "desc": "Manage outbound proxy nodes for accounts"
 
485
  "thinkingInjectionPrompt": "Thinking format prompt",
486
  "thinkingInjectionRestoreDefault": "Restore built-in default",
487
  "thinkingInjectionPromptHelp": "Leave empty to use the built-in default prompt shown as the input placeholder.",
488
+ "promptInjectionTitle": "Prompt style and thinking injection",
489
+ "promptInjectionDesc": "These two decide the built-in wording the model sees in long conversations. They only affect built-in prompts, never text you wrote yourself.",
490
+ "groupPrompt": "Prompt",
491
+ "groupPromptDesc": "Built-in style, thinking injection and the prompt profile live in this group.",
492
+ "groupContext": "Context and skills",
493
+ "groupContextDesc": "History-splitting policy and the plain-text skill library.",
494
+ "groupRuntime": "Runtime and behaviour",
495
+ "groupRuntimeDesc": "Concurrency, caching, platform fingerprint, session cleanup and file audit.",
496
+ "groupTools": "Tool calling",
497
+ "groupToolsDesc": "Master switch for DSML tool calling, off by default.",
498
+ "groupSecurity": "Security",
499
+ "groupSecurityDesc": "Admin panel password and JWT lifetime.",
500
+ "groupData": "Data and backup",
501
+ "groupDataDesc": "Export, restore and bulk import.",
502
  "promptStyleTitle": "Built-in prompt style",
503
  "promptStyleV2": "v2 (current: continuation carries requirements, contract injected)",
504
  "promptStyleLegacy": "legacy (short continuation, no built-in contract)",
 
514
  "promptProfilePrompt": "Custom body",
515
  "promptProfileRestoreDefault": "Restore built-in preset",
516
  "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.",
517
+ "modelProfilesStyle": "Style",
518
+ "modelProfilesStyleInherit": "Follow global",
519
+ "modelProfilesStyleV2": "v2 wording",
520
+ "modelProfilesStyleWriting": "Writing",
521
+ "modelProfilesStyleLegacy": "Legacy",
522
+ "modelProfilesStyleCustom": "Custom body",
523
+ "modelProfilesStyleInheritHint": "Follows the built-in prompt style and prompt profile from Settings; changing the global setting changes this model too.",
524
+ "modelProfilesStyleV2Hint": "Pins the v2 built-in wording (requirement-carrying continuation + execution contract) and injects no prompt profile.",
525
+ "modelProfilesStyleWritingHint": "v2 wording plus the built-in writing spec, good for long-form and rewrites.",
526
+ "modelProfilesStyleLegacyHint": "Legacy short wording, no execution contract — replies come out looser.",
527
+ "modelProfilesStyleCustomHint": "Uses the custom body below as the prompt profile; built-in wording still follows the global style.",
528
+ "modelProfilesExtra": "Remark and model body (expand to edit)",
529
+ "modelProfilesPromptHintCustom": "This body only applies when the style is \"Custom body\"; max 20000 characters.",
530
+ "modelProfilesPromptHintPreset": "The current style uses built-in wording or follows the global setting, so this body is ignored. Switch the style to \"Custom body\" to use it.",
531
+ "modelAliasesDesc": "Maps model names sent by clients onto models this project supports, e.g. point gpt-4o at deepseek-v4-flash. Custom models take precedence over these aliases.",
532
+ "modelAliasesSave": "Save aliases",
533
+ "modelAliasesSaved": "Model aliases saved",
534
+ "modelAliasesHint": "A JSON object: keys are the model names clients send, values are the target models.",
535
  "modelProfilesTitle": "Custom models",
536
  "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.",
537
  "modelProfilesCount": "{count} total",
webui/src/locales/zh.json CHANGED
@@ -4,11 +4,19 @@
4
  "english": "English",
5
  "chinese": "中文"
6
  },
 
 
 
 
7
  "nav": {
8
  "accounts": {
9
  "label": "账号管理",
10
  "desc": "管理 DeepSeek 账号池"
11
  },
 
 
 
 
12
  "proxies": {
13
  "label": "代理 IP",
14
  "desc": "管理账号可用的代理出口"
@@ -477,6 +485,20 @@
477
  "thinkingInjectionPrompt": "思考格式提示词",
478
  "thinkingInjectionRestoreDefault": "恢复内置默认提示词",
479
  "thinkingInjectionPromptHelp": "留空时使用内置默认提示词;默认内容会显示在输入框占位文本中。",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  "promptStyleTitle": "内置提示词风格",
481
  "promptStyleV2": "v2(当前版:衔接指令带执行要求,注入执行契约)",
482
  "promptStyleLegacy": "legacy(旧版:短衔接指令,不注入执行契约)",
@@ -492,6 +514,24 @@
492
  "promptProfilePrompt": "自定义正文",
493
  "promptProfileRestoreDefault": "恢复内置预设",
494
  "promptProfilePromptHelp": "留空时使用所选预设的内置正文,占位文本里就是该正文;自定义内容上限 20000 字符。与用户或客户端设定的角色冲突时以用户设定为准。",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
  "modelProfilesTitle": "自定义模型(模型列表)",
496
  "modelProfilesDesc": "给同一个 flash 基模挂出多个对外模型名,每个名字带自己的提示词。客户端按模型名选用,互不影响,比改全局开关更好回滚。命中带提示词的模型时优先用它,没命中才回落到上面的提示词特化层。",
497
  "modelProfilesCount": "共 {count} 个",
 
4
  "english": "English",
5
  "chinese": "中文"
6
  },
7
+ "modelsPage": {
8
+ "title": "模型列表",
9
+ "desc": "给同一个 flash 基模挂出多个对外模型名,每个名字可以独立选风格(应用全局 / v2 / 写作 / 旧版 / 自定义正文)。客户端按模型名选用,互不影响。"
10
+ },
11
  "nav": {
12
  "accounts": {
13
  "label": "账号管理",
14
  "desc": "管理 DeepSeek 账号池"
15
  },
16
+ "models": {
17
+ "label": "模型列表",
18
+ "desc": "自定义模型与模型别名"
19
+ },
20
  "proxies": {
21
  "label": "代理 IP",
22
  "desc": "管理账号可用的代理出口"
 
485
  "thinkingInjectionPrompt": "思考格式提示词",
486
  "thinkingInjectionRestoreDefault": "恢复内置默认提示词",
487
  "thinkingInjectionPromptHelp": "留空时使用内置默认提示词;默认内容会显示在输入框占位文本中。",
488
+ "promptInjectionTitle": "提示词风格与思考注入",
489
+ "promptInjectionDesc": "这两项决定模型在长对话里拿到的内置文案;只影响内置提示词,不会改动你自己填的内容。",
490
+ "groupPrompt": "提示词",
491
+ "groupPromptDesc": "内置风格、思考注入与提示词特化层,都在这一组里。",
492
+ "groupContext": "上下文与技能",
493
+ "groupContextDesc": "历史拆分策略与纯文本技能库。",
494
+ "groupRuntime": "运行与行为",
495
+ "groupRuntimeDesc": "并发、缓存、平台指纹、会话清理与文件审核。",
496
+ "groupTools": "工具调用",
497
+ "groupToolsDesc": "DSML 工具调用的总开关,默认关闭。",
498
+ "groupSecurity": "安全",
499
+ "groupSecurityDesc": "管理面板密码与 JWT 有效期。",
500
+ "groupData": "数据与备份",
501
+ "groupDataDesc": "配置导出、恢复与批量导入。",
502
  "promptStyleTitle": "内置提示词风格",
503
  "promptStyleV2": "v2(当前版:衔接指令带执行要求,注入执行契约)",
504
  "promptStyleLegacy": "legacy(旧版:短衔接指令,不注入执行契约)",
 
514
  "promptProfilePrompt": "自定义正文",
515
  "promptProfileRestoreDefault": "恢复内置预设",
516
  "promptProfilePromptHelp": "留空时使用所选预设的内置正文,占位文本里就是该正文;自定义内容上限 20000 字符。与用户或客户端设定的角色冲突时以用户设定为准。",
517
+ "modelProfilesStyle": "风格",
518
+ "modelProfilesStyleInherit": "应用全局",
519
+ "modelProfilesStyleV2": "v2 文案",
520
+ "modelProfilesStyleWriting": "写作",
521
+ "modelProfilesStyleLegacy": "旧版",
522
+ "modelProfilesStyleCustom": "自定义正文",
523
+ "modelProfilesStyleInheritHint": "跟随设置里的内置提示词风格与提示词特化层,全局改它就跟着改。",
524
+ "modelProfilesStyleV2Hint": "固定用 v2 内置文案(衔接指令带执行要求 + 执行契约),不注入特化层。",
525
+ "modelProfilesStyleWritingHint": "v2 文案 + 内置写作规范正文,适合长文与改稿。",
526
+ "modelProfilesStyleLegacyHint": "旧版短文案,不注入执行契约,回答更放得开。",
527
+ "modelProfilesStyleCustomHint": "用下面的自定义正文当特化层;内置文案风格仍跟随全局。",
528
+ "modelProfilesExtra": "备注与该模型正文(展开编辑)",
529
+ "modelProfilesPromptHintCustom": "这段正文只在风格为「自定义正文」时生效,上限 20000 字符。",
530
+ "modelProfilesPromptHintPreset": "当前风格使用内置文案或跟随全局,这里填的正文不会生效;要自定义请把风格改成「自定义正文」。",
531
+ "modelAliasesDesc": "把客户端发来的模型名映射到本项目支持的模型,例如把 gpt-4o 指向 deepseek-v4-flash。自定义模型列表优先于这里的映射。",
532
+ "modelAliasesSave": "保存别名",
533
+ "modelAliasesSaved": "模型别名已保存",
534
+ "modelAliasesHint": "JSON 对象,键是客户端请求的模型名,值是目标模型。",
535
  "modelProfilesTitle": "自定义模型(模型列表)",
536
  "modelProfilesDesc": "给同一个 flash 基模挂出多个对外模型名,每个名字带自己的提示词。客户端按模型名选用,互不影响,比改全局开关更好回滚。命中带提示词的模型时优先用它,没命中才回落到上面的提示词特化层。",
537
  "modelProfilesCount": "共 {count} 个",