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 +26 -6
- API.md +26 -6
- README.en.md +1 -1
- docs/prompt-compatibility.md +17 -4
- docs/prompt-design.md +25 -6
- internal/config/model_profile.go +78 -6
- internal/config/model_profile_test.go +48 -8
- internal/config/store_accessors.go +19 -11
- internal/httpapi/admin/modelprofiles/handler_model_profiles.go +2 -0
- internal/httpapi/admin/modelprofiles/handler_model_profiles_test.go +11 -0
- internal/httpapi/admin/settings/handler_settings_read.go +1 -0
- internal/httpapi/openai/history/current_input_file.go +70 -41
- internal/httpapi/openai/history/current_input_file_overlay_test.go +93 -66
- internal/promptcompat/prompt_texts.go +8 -1
- internal/promptcompat/prompt_texts_test.go +23 -0
- webui/src/features/models/ModelAliasesSection.jsx +101 -0
- webui/src/features/models/ModelsContainer.jsx +21 -0
- webui/src/features/settings/BehaviorSection.jsx +0 -78
- webui/src/features/settings/ModelProfilesSection.jsx +71 -25
- webui/src/features/settings/ModelSection.jsx +0 -16
- webui/src/features/settings/PromptInjectionSection.jsx +87 -0
- webui/src/features/settings/SettingsContainer.jsx +52 -48
- webui/src/features/settings/SettingsGroup.jsx +11 -0
- webui/src/layout/DashboardShell.jsx +5 -0
- webui/src/locales/en.json +40 -0
- webui/src/locales/zh.json +40 -0
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
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
| `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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", "
|
|
|
|
| 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", "
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
| `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 914 |
|
| 915 |
解析与注入语义:
|
| 916 |
|
|
@@ -1082,16 +1099,18 @@ data: {"type":"message_stop"}
|
|
| 1082 |
|
| 1083 |
### `GET` / `PUT /admin/model_profiles`
|
| 1084 |
|
| 1085 |
-
自定义模型列表(模型名 + 基模 + 该模型
|
| 1086 |
|
| 1087 |
**GET 响应**:
|
| 1088 |
|
| 1089 |
```json
|
| 1090 |
{
|
| 1091 |
"items": [
|
| 1092 |
-
{"id": "deepseek-writer", "base": "deepseek-v4-flash", "name": "写作", "
|
|
|
|
| 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", "
|
| 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
|
| 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 |
-
### 模型
|
| 260 |
|
| 261 |
-
自定义模型条目把「对外模型名 → 基模 + 提示词」绑在一起,适合把不同风格并排放给客户端选,而不是反复改全局开关。
|
| 262 |
|
| 263 |
| 项目 | 行为 |
|
| 264 |
| --- | --- |
|
| 265 |
| 配置 | 顶层 `model_profiles`,数组,最多 50 条 |
|
| 266 |
| 解析优先 | 自定义模型名优先于内置别名映射;命中后按其 `base` 调上游 |
|
| 267 |
| 列表 | `/v1/models` 一并列出,`owned_by=ds2api`;Claude / Gemini 的模型列表接口不列 |
|
|
|
|
| 268 |
| 注入点 | 与 `prompt_profile` 相同(拆分之后、技能之前) |
|
| 269 |
-
| 与特化层关系 | **互斥**:
|
| 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-
|
| 241 |
-
{"id": "deepseek-
|
|
|
|
|
|
|
| 242 |
]
|
| 243 |
}
|
| 244 |
```
|
| 245 |
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 170 |
-
|
|
|
|
|
|
|
| 171 |
}
|
| 172 |
-
// 没
|
| 173 |
-
|
| 174 |
-
|
|
|
|
| 175 |
}
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 353 |
-
|
| 354 |
-
//
|
| 355 |
-
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
if s == nil {
|
| 358 |
-
return
|
| 359 |
}
|
| 360 |
s.mu.RLock()
|
| 361 |
profile, ok := FindModelProfile(s.cfg.ModelProfiles, modelID)
|
| 362 |
s.mu.RUnlock()
|
| 363 |
if !ok {
|
| 364 |
-
return
|
| 365 |
}
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
|
|
|
| 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 :=
|
| 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 |
-
//
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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,
|
| 266 |
}
|
| 267 |
|
| 268 |
-
//
|
| 269 |
-
// 用可选接口而不是写进 CurrentInputConfigReader,是为了不打断测试里的轻量假实现。
|
| 270 |
-
type modelPromptReader interface {
|
| 271 |
-
ResolveModelPrompt(modelID string) (string, bool)
|
| 272 |
-
}
|
| 273 |
-
|
| 274 |
-
// applyOverlays 把 prompt 级叠加层(模型专属提示词 / 提示词特化层 / 技能库)合进最终 prompt。
|
| 275 |
//
|
| 276 |
// 分段规则:
|
| 277 |
-
// - 外层规范
|
| 278 |
// - 内层是技能库(更贴近当前任务);
|
| 279 |
// - 全部合成一个 System 段。
|
| 280 |
func (s Service) applyOverlays(a *auth.RequestAuth, stdReq promptcompat.StandardRequest) promptcompat.StandardRequest {
|
| 281 |
blocks := make([]string, 0, 2)
|
| 282 |
-
|
| 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 |
-
//
|
| 294 |
//
|
| 295 |
-
//
|
| 296 |
-
//
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
return ""
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 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
|
| 16 |
-
minChars
|
| 17 |
-
|
| 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)
|
| 24 |
-
for id,
|
| 25 |
if strings.EqualFold(strings.TrimSpace(id), strings.TrimSpace(modelID)) {
|
| 26 |
-
|
| 27 |
-
return "", false
|
| 28 |
-
}
|
| 29 |
-
return prompt, true
|
| 30 |
}
|
| 31 |
}
|
| 32 |
-
return
|
| 33 |
}
|
| 34 |
|
| 35 |
func overlayTestRequest(requestedModel string) promptcompat.StandardRequest {
|
|
@@ -46,83 +43,114 @@ func overlayTestRequest(requestedModel string) promptcompat.StandardRequest {
|
|
| 46 |
}
|
| 47 |
}
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
},
|
| 56 |
-
DS: &skillsTestUploader{},
|
| 57 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
a := &auth.RequestAuth{
|
| 59 |
PromptProfile: config.PromptProfileSettings{Enabled: true, Name: config.PromptProfileNameWriting},
|
| 60 |
}
|
| 61 |
-
out
|
| 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
|
| 67 |
}
|
| 68 |
globalBody := promptcompat.BuildPromptProfilePrompt(config.PromptProfileNameWriting, "")
|
| 69 |
if strings.Contains(out.FinalPrompt, globalBody) {
|
| 70 |
-
t.Fatalf("global
|
| 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
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
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 |
-
//
|
| 108 |
-
func
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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-
|
| 235 |
-
<
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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 |
-
<
|
| 91 |
-
t={t}
|
| 92 |
-
form={form}
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
/>
|
| 99 |
-
|
| 100 |
-
<
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
<
|
| 107 |
-
|
| 108 |
-
<
|
| 109 |
-
|
| 110 |
-
<
|
| 111 |
-
|
| 112 |
-
<
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
<
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 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} 个",
|