dsh-plugin-model-tuning 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/README.md +83 -234
  2. package/client.js +425 -1013
  3. package/cordis.patch.yml +10 -19
  4. package/index.js +20 -263
  5. package/package.json +5 -11
package/client.js CHANGED
@@ -5,7 +5,6 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  const React = require("react");
8
-
9
8
  const CSS = `
10
9
  .mcfg{display:flex;flex-direction:column;gap:16px;padding:4px 0 24px;color:inherit}
11
10
  .mcfg-head{display:flex;flex-direction:column;gap:4px}
@@ -44,8 +43,8 @@ window.__ModuleLoader__.load({
44
43
  .mcfg-select,.mcfg-input{font-size:12.5px;padding:6px 8px;border-radius:7px;border:1px solid rgba(128,128,128,.4);background:transparent;color:inherit;width:100%;box-sizing:border-box}
45
44
  .mcfg-custom{width:100%}
46
45
  .mcfg-input:disabled,.mcfg-select:disabled{opacity:.5;cursor:not-allowed}
47
- /* 模型参数 / 用途 / 绘图 三个页签:v2 起 title-model 与 image-generation
48
- 并入本插件,标题路由与绘图模型列表都是本命名空间自己的字段。 */
46
+ /* 页签样式:v3 起设置页只剩「绘图」一页,页签控件不再使用,样式保留给
47
+ 未来多页布局,不影响当前渲染。 */
49
48
  .mcfg-tabs{display:flex;gap:6px;flex-wrap:wrap}
50
49
  .mcfg-tab{font-size:12.5px;padding:6px 14px;border-radius:8px;border:1px solid rgba(128,128,128,.35);background:transparent;color:inherit;cursor:pointer}
51
50
  .mcfg-tab:hover{border-color:rgba(128,128,128,.6)}
@@ -79,6 +78,17 @@ window.__ModuleLoader__.load({
79
78
  .mcfg-card p{overflow-wrap:anywhere;font-size:12px}
80
79
  .mcfg-card a{display:inline-block;text-decoration:none}
81
80
  .mcfg-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 0;flex-wrap:wrap}
81
+ .mcfg-draw-item{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1 1 220px}
82
+ .mcfg-draw-name{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;overflow-wrap:anywhere}
83
+ .mcfg-draw-meta{font-size:11.5px;opacity:.55;overflow-wrap:anywhere}
84
+ .mcfg-draw-row{align-items:center;gap:10px}
85
+ .mcfg-draw-radio{display:flex;align-items:center;padding:0 2px}
86
+ .mcfg-draw-radio input{accent-color:rgb(80,140,255);width:14px;height:14px;cursor:pointer}
87
+ .mcfg-primary{border-color:rgba(80,140,255,.65)!important;background:rgba(80,140,255,.10)!important}
88
+ .mcfg-advanced{margin-top:12px;border:1px solid #8884;border-radius:10px;padding:10px 14px}
89
+ .mcfg-advanced>summary{cursor:pointer;font-size:12.5px;opacity:.85}
90
+ .mcfg-advanced[open]>summary{margin-bottom:8px}
91
+ .mcfg-advanced fieldset{border:0;padding:0;margin:0;min-width:0}
82
92
  .mcfg-model-list{display:flex;flex-direction:column;gap:5px;max-height:240px;overflow:auto;margin:8px 0}
83
93
  .mcfg-model-list button{text-align:left;overflow-wrap:anywhere}
84
94
  .mcfg-model-list button[aria-pressed=true]{border-color:#3987d7;background:#3987d71a}
@@ -87,60 +97,17 @@ window.__ModuleLoader__.load({
87
97
  `;
88
98
 
89
99
  /**
90
- * v2: ONE namespace. Before the merge, three plugins shared this page's
91
- * writes through two foreign namespaces (`title-model`,
92
- * `image-generation`). Both are now fields of `model-tuning` itself, so
93
- * every write below targets ns "model-tuning".
100
+ * v3: one namespace. Drawing models (the former `dsh-plugin-image-generation`
101
+ * Config keys) still live under `model-tuning` so existing profiles keep
102
+ * working; the removed title fields are ignored.
94
103
  */
95
104
  const NS = "model-tuning";
96
105
 
97
- /** Provenance marker on an image entry the 模型参数 tab linked. */
98
- const IMAGE_SOURCE_KEY = "source";
99
- const IMAGE_API = "openai-images";
100
-
101
106
  const REASONING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"];
102
- const CTX_OPTIONS = [
103
- { label: "64K", value: 64000 },
104
- { label: "128K", value: 128000 },
105
- { label: "256K", value: 256000 },
106
- { label: "512K", value: 512000 },
107
- { label: "1M", value: 1000000 },
108
- { label: "2M", value: 2000000 },
109
- ];
110
- const MAX_OPTIONS = [
111
- { label: "4K", value: 4000 },
112
- { label: "8K", value: 8000 },
113
- { label: "16K", value: 16000 },
114
- { label: "32K", value: 32000 },
115
- { label: "64K", value: 64000 },
116
- { label: "128K", value: 128000 },
117
- { label: "256K", value: 256000 },
118
- { label: "384K", value: 384000 },
119
- { label: "512K", value: 512000 },
120
- ];
121
107
 
122
- /**
123
- * Per-namespace settings field where a provider records the input
124
- * modalities one model accepts. The harness vocabulary is closed
125
- * (`text` | `image`) at both the adapter and the core content block, so a
126
- * provider that names no such field is shown without the control.
127
- */
128
- const MODALITY_FIELD_BY_NS = {
129
- "llm-pi-ai": "input",
130
- "llm-deepseek": "inputModalities",
131
- };
132
- /**
133
- * DeepSeek-side image request budgets. Its schema refuses these on a
134
- * text-only model, so clearing image capability must clear them too.
135
- */
136
- const DEEPSEEK_IMAGE_LIMIT_FIELDS = ["imagePixelBudget", "imageMaxBytes", "imageDetail"];
137
- /**
138
- * Tri-state of the control. `auto` states nothing and leaves resolution to
139
- * the installed catalog entry, then the route's `defaultInput` (default
140
- * `[text]`); an empty list means the same thing there, never "no modality".
141
- */
142
- const MODALITY_CHOICES = ["auto", "text", "text-image"];
143
- const MODALITY_LABELS = { auto: "自动", text: "纯文本", "text-image": "文本 + 图像" };
108
+ /** Provenance marker on a drawing entry linked from a provider model. */
109
+ const IMAGE_SOURCE_KEY = "source";
110
+ const IMAGE_API = "openai-images";
144
111
 
145
112
  /**
146
113
  * The compat switch that selects Anthropic's adaptive thinking request.
@@ -163,24 +130,6 @@ window.__ModuleLoader__.load({
163
130
  */
164
131
  const ADAPTIVE_THINKING_API = "anthropic-messages";
165
132
 
166
- function formatCapacity(n) {
167
- if (n == null || typeof n !== "number" || !Number.isFinite(n)) return "";
168
- if (n % 1000000 === 0) return String(n / 1000000) + "M";
169
- if (n % 1000 === 0) return String(n / 1000) + "K";
170
- return String(n);
171
- }
172
-
173
- function parseCapacity(text) {
174
- if (typeof text !== "string") return null;
175
- const match = text.trim().match(/^(\d+(?:\.\d+)?)\s*([kKmM]?)$/);
176
- if (!match) return null;
177
- const base = parseFloat(match[1]);
178
- const suffix = match[2].toUpperCase();
179
- const value = suffix === "K" ? base * 1000 : suffix === "M" ? base * 1000000 : base;
180
- if (!Number.isInteger(value) || value <= 0) return null;
181
- return value;
182
- }
183
-
184
133
  function at(value, path) {
185
134
  let current = value;
186
135
  for (const part of path || []) {
@@ -196,18 +145,13 @@ window.__ModuleLoader__.load({
196
145
  return profile && typeof profile === "object" ? profile : undefined;
197
146
  }
198
147
 
199
- function findNamespace(settingsView, ns) {
200
- const namespaces = settingsView && Array.isArray(settingsView.namespaces) ? settingsView.namespaces : [];
201
- return namespaces.find((item) => item && item.ns === ns);
202
- }
203
-
204
- /** v2: the page's own namespace view — always present once the host row is up. */
205
- function ownNsView(settingsView) {
206
- return findNamespace(settingsView, NS);
148
+ function imageModelsOf(ownView) {
149
+ const models = ownView ? ownView.value?.models : undefined;
150
+ return Array.isArray(models) ? models : [];
207
151
  }
208
152
 
209
153
  /**
210
- * Derive one image entry from the chat provider that already holds the
154
+ * Derive one drawing entry from the chat provider that already holds the
211
155
  * endpoint and credential reference, so linking never asks the user to
212
156
  * retype either one.
213
157
  */
@@ -220,14 +164,14 @@ window.__ModuleLoader__.load({
220
164
  };
221
165
  }
222
166
 
223
- /** Whether an entry is ours, in entries stored before the provider was recorded. */
167
+ /** Whether an entry came from linking this provider model (has provenance). */
224
168
  function isManagedEntry(entry, modelId) {
225
169
  const source = entry && entry[IMAGE_SOURCE_KEY];
226
170
  if (!source || typeof source !== "object" || source.model !== modelId) return false;
227
171
  return source.provider === undefined || source.provider === "" || typeof source.provider === "string";
228
172
  }
229
173
 
230
- /** The per-model view of the link, shared by the checkbox and its note. */
174
+ /** The per-model view of the link: can it link, is it already linked, and where. */
231
175
  function computeImageModelState(ownView, profile, modelId) {
232
176
  if (!ownView) return { available: false, managed: false, hasEntry: false, entry: null };
233
177
  const models = imageModelsOf(ownView);
@@ -242,11 +186,6 @@ window.__ModuleLoader__.load({
242
186
  };
243
187
  }
244
188
 
245
- function imageModelsOf(ownView) {
246
- const models = ownView ? ownView.value?.models : undefined;
247
- return Array.isArray(models) ? models : [];
248
- }
249
-
250
189
  function normalizeImageEndpoint(value) {
251
190
  return typeof value === "string" ? value.trim().replace(/\/+$/, "").replace(/\/images\/generations$/, "") + "/images/generations" : "";
252
191
  }
@@ -276,14 +215,14 @@ window.__ModuleLoader__.load({
276
215
  }
277
216
 
278
217
  /**
279
- * Everything the image list needs for an incremental write: only the entry
280
- * the 模型参数 tab owns plus the default-model change it implies.
218
+ * Everything the drawing list needs for an incremental write: only the entry
219
+ * the provider checkbox owns plus the default-model change it implies.
281
220
  */
282
221
  function planImageLink(ownView, profile, modelId, providerId) {
283
222
  const derived = deriveImageEntry(profile, modelId);
284
223
  const endpoint = normalizeImageEndpoint(derived.endpoint);
285
224
  if (!acceptableImageEndpoint(endpoint)) {
286
- return { models: null, reason: "该 provider 没有可用的供应商地址(api.baseURL),请先在「模型」页填写后再勾选。" };
225
+ return { models: null, reason: "该 provider 没有可用的供应商地址(baseURL),请先在「设置 → 模型」里填写后再勾选。" };
287
226
  }
288
227
  const models = imageModelsOf(ownView).slice();
289
228
  const index = models.findIndex((item) => item && typeof item === "object" && item.model === modelId);
@@ -301,35 +240,19 @@ window.__ModuleLoader__.load({
301
240
  return { models: next, defaultModel };
302
241
  }
303
242
 
304
- /** Unlink removes only the entry the 模型参数 tab created (has provenance). */
243
+ /** Unlink removes only entries the provider checkbox created (has provenance). */
305
244
  function planImageUnlink(ownView, modelId) {
306
245
  const models = imageModelsOf(ownView);
307
246
  const entry = models.find((item) => item && typeof item === "object" && item.model === modelId);
308
247
  if (!entry) return { models: null, reason: "该模型未链接到绘图配置。" };
309
- // v2: a hand-authored entry is not this checkbox's to remove — deleting it
310
- // here would surprise an entry the user built on the 绘图 tab. It can be
311
- // deleted there directly.
312
- if (!isManagedEntry(entry, modelId)) return { models: null, reason: "该配置在「绘图」页手工创建,请在那边删除。" };
248
+ // A hand-authored entry is not this checkbox's to remove.
249
+ if (!isManagedEntry(entry, modelId)) return { models: null, reason: "该配置是在「绘图」页手工创建的,请到那边删除。" };
313
250
  const next = models.filter((item) => item !== entry);
314
251
  const currentDefault = ownView.value?.defaultModel;
315
252
  const defaultModel = currentDefault === entry.id ? (next[0]?.id ?? "") : currentDefault;
316
253
  return { models: next, defaultModel };
317
254
  }
318
255
 
319
- function readModelField(nsView, settingsPath, modelId, field) {
320
- if (!nsView) return undefined;
321
- const profile = at(nsView.value, settingsPath);
322
- if (!profile || typeof profile !== "object") return undefined;
323
- const models = Array.isArray(profile.models) ? profile.models : [];
324
- const index = models.findIndex((m) => m && typeof m === "object" && m.id === modelId);
325
- if (index >= 0) return models[index][field];
326
- const overrides = profile.modelOverrides;
327
- if (overrides && typeof overrides === "object" && overrides[modelId] && typeof overrides[modelId] === "object") {
328
- return overrides[modelId][field];
329
- }
330
- return undefined;
331
- }
332
-
333
256
  function readCompatField(nsView, settingsPath, modelId, compatKey) {
334
257
  if (!nsView) return undefined;
335
258
  const profile = at(nsView.value, settingsPath);
@@ -495,202 +418,6 @@ window.__ModuleLoader__.load({
495
418
  return { ns, ops: [{ op: "set", path: [...basePath, "models"], value: [entryObj] }] };
496
419
  }
497
420
 
498
- /** The modality field one provider namespace records, or undefined when it records none. */
499
- function modalityFieldFor(ns) {
500
- return MODALITY_FIELD_BY_NS[ns];
501
- }
502
-
503
- /** Map a raw modality list to the control's tri-state. Absent and empty both mean `auto`. */
504
- function modalityChoice(raw) {
505
- if (!Array.isArray(raw) || raw.length === 0) return "auto";
506
- return raw.indexOf("image") >= 0 ? "text-image" : "text";
507
- }
508
-
509
- /**
510
- * Explain what the current state costs, in the terms the harness applies.
511
- */
512
- function modalityNote(choice, model) {
513
- if (choice === "text-image") {
514
- return "图像输入已开启:贴图与 read_image 可用,下一次请求即生效(无需重启)。";
515
- }
516
- if (choice === "text") {
517
- return "已显式声明为纯文本:附图会在发送前被拒绝,read_image 同样拒绝。";
518
- }
519
- if (!model.declared) {
520
- return "自动:由该 provider 内置的模型目录决定,本页读不到目录内容,不做推测。";
521
- }
522
- const route = model.routeDefaultInput;
523
- if (route && route.indexOf("image") >= 0) {
524
- return "自动:本路由未逐个声明,跟随路由 defaultInput,其中含图像。";
525
- }
526
- return "自动:手写路由没有模型目录可查,落到路由 defaultInput(未设置即为纯文本),"
527
- + "所以附图会被拒绝。端点确实收图时请选「文本 + 图像」。";
528
- }
529
-
530
- /**
531
- * Ops for one modality choice. Non-DeepSeek namespaces write a single
532
- * field; DeepSeek additionally drops the image request budgets its schema
533
- * refuses beside a text-only declaration.
534
- */
535
- function computeModalityWriteOps(entry, nsView, modelId, choice) {
536
- const field = modalityFieldFor(entry.settingsNs);
537
- if (!field) return null;
538
- const value = choice === "text-image" ? ["text", "image"] : choice === "text" ? ["text"] : null;
539
- const write = computeWriteOps(entry, nsView, modelId, field, value);
540
- if (!write || entry.settingsNs !== "llm-deepseek" || choice === "text-image") return write;
541
- const profile = at(nsView ? nsView.value : undefined, entry.settingsPath || []);
542
- const models = (profile && Array.isArray(profile.models)) ? profile.models : [];
543
- const current = models.find((m) => m && typeof m === "object" && m.id === modelId) || {};
544
- const stale = DEEPSEEK_IMAGE_LIMIT_FIELDS.filter((name) => current[name] !== undefined);
545
- if (stale.length === 0) return write;
546
- const modelsOp = write.ops[0];
547
- modelsOp.value = modelsOp.value.map((m) => {
548
- if (!m || typeof m !== "object" || m.id !== modelId) return m;
549
- const copy = { ...m };
550
- for (const name of stale) delete copy[name];
551
- return copy;
552
- });
553
- return write;
554
- }
555
-
556
- // ── 用途:标题生成(v2:本插件自己的字段,title* 键) ────────────────────
557
- /** Same vocabulary as the host schema (`inherit` is the default). */
558
- const TITLE_MODES = ["inherit", "custom"];
559
-
560
- /**
561
- * Title-request budget. Defaults mirror the shipped dsh-base row, but every
562
- * field is user-editable from this page — a reasoning model that answers
563
- * only after a long think needs a larger `maxOutputTokens`, and that choice
564
- * belongs to the user, not to a constant in the plugin.
565
- */
566
- const TITLE_BUDGET_FIELDS = [
567
- { key: "targetWords", label: "目标词数", hint: "非中日韩语言的目标词数", fallback: 5 },
568
- { key: "targetCjkCharacters", label: "目标汉字数", hint: "中文 / 日文标题的目标字数", fallback: 10 },
569
- { key: "maxInputBytes", label: "输入上限(字节)", hint: "首条消息送进标题请求的截断上限", fallback: 4096 },
570
- { key: "maxOutputTokens", label: "输出上限(token)", hint: "推理模型会先思考再作答,需留足预算(例如 1024);直接作答的模型 64 足够", fallback: 64 },
571
- { key: "timeoutMs", label: "超时(毫秒)", hint: "整次标题请求的超时", fallback: 60000 },
572
- ];
573
-
574
- /**
575
- * Read the title purpose view out of our own namespace view.
576
- * @param {object|undefined} settingsView - `remote.settings.describe()` value.
577
- * @returns the title view; `available` mirrors whether our host row is up.
578
- */
579
- function titleViewOf(settingsView) {
580
- const budget = {};
581
- for (const field of TITLE_BUDGET_FIELDS) budget[field.key] = field.fallback;
582
- const view = ownNsView(settingsView);
583
- if (!view) return { available: false, mode: "inherit", provider: null, model: null, revision: null, ...budget };
584
- const value = view.value && typeof view.value === "object" ? view.value : {};
585
- const mode = TITLE_MODES.indexOf(value.titleMode) >= 0 ? value.titleMode : "inherit";
586
- for (const field of TITLE_BUDGET_FIELDS) {
587
- const raw = value[field.key];
588
- if (typeof raw === "number" && Number.isInteger(raw) && raw > 0) budget[field.key] = raw;
589
- }
590
- return {
591
- available: true,
592
- mode,
593
- provider: typeof value.titleProvider === "string" && value.titleProvider !== "" ? value.titleProvider : null,
594
- model: typeof value.titleModel === "string" && value.titleModel !== "" ? value.titleModel : null,
595
- revision: typeof view.revision === "number" ? view.revision : null,
596
- ...budget,
597
- };
598
- }
599
-
600
- /**
601
- * Plan the write for one title purpose edit.
602
- *
603
- * Always returns the COMPLETE field set: switching back to `inherit` must
604
- * clear a previously chosen route instead of leaving it behind in the user
605
- * section, where it would silently stay authoritative for a later switch.
606
- * The budget is written the same way, so a field the user clears returns to
607
- * the shipped default rather than keeping a stale value.
608
- * @param next - `{ mode, provider, model, ...budget }` as chosen in the UI.
609
- * @returns the mutate request body.
610
- */
611
- function planTitleWrite(next) {
612
- const ops = [];
613
- for (const field of TITLE_BUDGET_FIELDS) {
614
- const raw = next ? next[field.key] : undefined;
615
- const value = raw === undefined || raw === null || raw === "" ? field.fallback : Number(raw);
616
- if (!Number.isInteger(value) || value < 1) throw new Error(`「${field.label}」必须是正整数`);
617
- ops.push({ op: "set", path: [field.key], value });
618
- }
619
- const mode = next && next.mode === "custom" ? "custom" : "inherit";
620
- if (mode === "inherit") {
621
- ops.push(
622
- { op: "unset", path: ["titleMode"] },
623
- { op: "unset", path: ["titleProvider"] },
624
- { op: "unset", path: ["titleModel"] },
625
- );
626
- } else {
627
- ops.push(
628
- { op: "set", path: ["titleMode"], value: "custom" },
629
- { op: "set", path: ["titleProvider"], value: String(next.provider || "") },
630
- { op: "set", path: ["titleModel"], value: String(next.model || "") },
631
- );
632
- }
633
- return { ns: NS, ops };
634
- }
635
-
636
- function CapacityField(props) {
637
- const value = props.value;
638
- const options = props.options;
639
- const matched = value != null ? options.find((o) => o.value === value) : undefined;
640
- const [mode, setMode] = React.useState(matched ? "option" : "custom");
641
- const [text, setText] = React.useState(value != null ? formatCapacity(value) : "");
642
-
643
- React.useEffect(() => {
644
- const m = value != null ? options.find((o) => o.value === value) : undefined;
645
- setMode(m ? "option" : "custom");
646
- setText(value != null ? formatCapacity(value) : "");
647
- }, [value]);
648
-
649
- function commit() {
650
- const parsed = parseCapacity(text);
651
- if (parsed == null) {
652
- props.onToast("err", "请输入正整数,可用 K/M 后缀(如 256K、1M)");
653
- setText(value != null ? formatCapacity(value) : "");
654
- return;
655
- }
656
- if (parsed === value) return;
657
- props.onApply(props.provider, props.model, props.kind, parsed);
658
- }
659
-
660
- return React.createElement("label", { className: "mcfg-field" },
661
- React.createElement("span", { className: "mcfg-label" }, props.label),
662
- React.createElement("div", { className: "mcfg-fieldrow" },
663
- React.createElement("select", {
664
- className: "mcfg-select",
665
- disabled: props.disabled,
666
- value: mode === "option" ? String(value) : "__custom__",
667
- onChange: (e) => {
668
- if (e.target.value === "__custom__") {
669
- setMode("custom");
670
- setText(value != null ? formatCapacity(value) : "");
671
- } else {
672
- setMode("option");
673
- props.onApply(props.provider, props.model, props.kind, Number(e.target.value));
674
- }
675
- },
676
- },
677
- options.map((o) => React.createElement("option", { key: o.value, value: String(o.value) }, o.label)),
678
- React.createElement("option", { value: "__custom__" }, "自定义…")
679
- ),
680
- mode === "custom" ? React.createElement("input", {
681
- className: "mcfg-input mcfg-custom",
682
- type: "text",
683
- disabled: props.disabled,
684
- value: text,
685
- placeholder: "如 256K / 1M",
686
- onChange: (e) => setText(e.target.value),
687
- onBlur: commit,
688
- onKeyDown: (e) => { if (e.key === "Enter") e.target.blur(); },
689
- }) : null
690
- )
691
- );
692
- }
693
-
694
421
  // ── 绘图页组件(ex dsh-plugin-image-generation/client.js,样式前缀统一) ──
695
422
  const JOB_KEY = "dsh-image-generation-job";
696
423
 
@@ -700,8 +427,8 @@ window.__ModuleLoader__.load({
700
427
  }
701
428
  const freshImageEntry = () => ({ id: "", name: "", model: "", api: "openai-images", endpoint: "https://api.openai.com/v1/images/generations", apiKeyEnv: "IMAGE_API_KEY", timeoutSeconds: 300 })
702
429
 
703
- /** An entry the 模型参数 tab linked carries its origin. */
704
- function managedBy(item) { return item?.source?.provider ? `模型参数 · ${item.source.provider}` : "" }
430
+ /** An entry an earlier version's 模型参数 checkbox linked carries its origin. */
431
+ function managedBy(item) { return item?.source?.provider ? `旧版链接 · ${item.source.provider}` : "" }
705
432
 
706
433
  function PanelImage({ call, jobId, index, image }) {
707
434
  const [url, setUrl] = React.useState("");
@@ -783,10 +510,10 @@ window.__ModuleLoader__.load({
783
510
  /**
784
511
  * Commit one drawing-config edit.
785
512
  *
786
- * Always re-reads immediately before writing (the same discipline the
787
- * 模型参数 tab uses). Using the view captured at render time made a page
788
- * left open across any other write fail on a stale revision — the
789
- * optimistic lock rejects it and the button silently looks dead.
513
+ * Always re-reads immediately before writing. Using the view captured at
514
+ * render time made a page left open across any other write fail on a
515
+ * stale revision — the optimistic lock rejects it and the button
516
+ * silently looks dead.
790
517
  * @param buildOps - derives the ops from the freshly read view.
791
518
  * @param successMessage - toast shown after the write and refresh.
792
519
  */
@@ -812,10 +539,9 @@ window.__ModuleLoader__.load({
812
539
  // revision nor write a stale model list back over it.
813
540
  await commit((fresh) => {
814
541
  const freshModels = imageModelsOf(fresh);
815
- // v2: `source` is provenance display only. Editing a linked entry
816
- // keeps the marker (so 模型参数 still shows where it came from), but
817
- // its identity fields are now editable in place — one plugin owns
818
- // both tabs.
542
+ // `source` is provenance display only. Editing a linked entry keeps
543
+ // the marker so the row still tells where it came from; the identity
544
+ // fields are editable in place.
819
545
  const stored = editing ? freshModels.find((item) => item.id === editing) : undefined;
820
546
  if (managedBy(stored)) normalized.source = stored.source;
821
547
  const entry = { ...normalized };
@@ -854,43 +580,61 @@ window.__ModuleLoader__.load({
854
580
  };
855
581
  const visibleModels = (catalog?.models ?? []).filter((item) => `${item.id} ${item.name}`.toLowerCase().includes(modelQuery.trim().toLowerCase()));
856
582
  const running = job?.status === "running";
583
+ const defaultEntry = models.find((item) => item.id === view?.value?.defaultModel);
857
584
 
858
585
  return React.createElement("div", { className: "mcfg-page" },
859
- React.createElement("p", { className: "mcfg-note" }, "独立配置绘图模型。可在聊天中让助手调用 generate_image,也可在下方直接生成。接口使用 OpenAI Images 协议。"),
860
586
  notice ? React.createElement("div", { role: "status" }, notice) : null,
861
587
  !view ? React.createElement("p", null, "正在读取配置…") : React.createElement("fieldset", { disabled: busy }, React.createElement("legend", null, "绘图模型"),
862
- models.length === 0 ? React.createElement("p", { className: "mcfg-note" }, "尚未配置。添加模型后即可绘图;也可在「模型参数」页勾选生图模型自动生成。") : models.map((item) => React.createElement("div", { className: "mcfg-row", key: item.id },
863
- React.createElement("span", null, `${item.name || item.id} · ${item.model}${view.value.defaultModel === item.id ? " · 默认" : ""}${managedBy(item) ? " · " + managedBy(item) : ""}`),
864
- React.createElement("div", { className: "mcfg-actions2" },
865
- React.createElement("button", { disabled: !writable || !!form, onClick: () => { setForm({ ...item }); setEditing(item.id); setKey(""); setCatalog(null); } }, "编辑"),
866
- React.createElement("button", {
867
- // Disabled on the entry that already IS the default: the button
868
- // then states a fact instead of pretending to act.
869
- disabled: !writable || !!form || view.value?.defaultModel === item.id,
870
- onClick: () => void action(() => commit(
871
- () => [{ op: "set", path: ["defaultModel"], value: item.id }],
872
- `已将 ${item.name || item.id} 设为默认`,
873
- )),
874
- }, view.value?.defaultModel === item.id ? "当前默认" : "设为默认"),
875
- React.createElement("button", {
876
- disabled: !writable || !!form,
877
- onClick: () => void action(async () => {
878
- await commit((fresh) => {
879
- const next = imageModelsOf(fresh).filter((model) => model.id !== item.id);
880
- const currentDefault = fresh.value?.defaultModel;
881
- return [
882
- { op: "set", path: ["models"], value: next },
883
- { op: "set", path: ["defaultModel"], value: currentDefault === item.id ? (next[0]?.id ?? "") : (currentDefault ?? "") },
884
- ];
885
- }, `已删除 ${item.name || item.id}`);
886
- if (selected === item.id) setSelected("");
887
- }),
888
- }, "删除")))),
588
+ React.createElement("p", { className: "mcfg-note" }, "每条配置是一个独立的 OpenAI Images 端点(地址 + Key + 上游模型),与「设置 → 模型」里的聊天模型无关。在模型页勾选「绘图」会自动生成配置;generate_image 默认使用标记为「默认」的那条。"),
589
+ models.length === 0
590
+ ? React.createElement("p", { className: "mcfg-note" }, "尚未配置。点「添加绘图模型」填供应商地址与 Key,再从模型列表里选一个即可。")
591
+ : models.map((item) => {
592
+ const isDefault = view.value?.defaultModel === item.id;
593
+ return React.createElement("div", { className: "mcfg-row mcfg-draw-row", key: item.id },
594
+ React.createElement("label", { className: "mcfg-draw-radio", title: isDefault ? "当前默认绘图模型" : "设为默认绘图模型" },
595
+ React.createElement("input", {
596
+ type: "radio",
597
+ name: "drawing-default",
598
+ checked: isDefault,
599
+ disabled: !writable || !!form || isDefault,
600
+ onChange: () => void action(() => commit(
601
+ () => [{ op: "set", path: ["defaultModel"], value: item.id }],
602
+ `已将 ${item.name || item.id} 设为默认`,
603
+ )),
604
+ })
605
+ ),
606
+ React.createElement("span", { className: "mcfg-draw-item" },
607
+ React.createElement("span", { className: "mcfg-draw-name" },
608
+ item.name || item.id,
609
+ isDefault ? React.createElement("span", { className: "mcfg-chip mcfg-chip-on" }, "默认") : null
610
+ ),
611
+ React.createElement("span", { className: "mcfg-draw-meta" },
612
+ "配置 " + item.id + " · 上游模型 " + item.model + (managedBy(item) ? " · " + managedBy(item) : "")
613
+ )
614
+ ),
615
+ React.createElement("div", { className: "mcfg-actions2" },
616
+ React.createElement("button", { disabled: !writable || !!form, onClick: () => { setForm({ ...item }); setEditing(item.id); setKey(""); setCatalog(null); } }, "编辑"),
617
+ React.createElement("button", {
618
+ disabled: !writable || !!form,
619
+ onClick: () => void action(async () => {
620
+ await commit((fresh) => {
621
+ const next = imageModelsOf(fresh).filter((model) => model.id !== item.id);
622
+ const currentDefault = fresh.value?.defaultModel;
623
+ return [
624
+ { op: "set", path: ["models"], value: next },
625
+ { op: "set", path: ["defaultModel"], value: currentDefault === item.id ? (next[0]?.id ?? "") : (currentDefault ?? "") },
626
+ ];
627
+ }, `已删除 ${item.name || item.id}`);
628
+ if (selected === item.id) setSelected("");
629
+ }),
630
+ }, "删除")));
631
+ }),
889
632
  React.createElement("div", { className: "mcfg-actions2" },
890
- React.createElement("button", { disabled: !writable || !!form, onClick: () => { setForm(freshImageEntry()); setEditing(""); setKey(""); setCatalog(null); } }, "添加模型"),
633
+ React.createElement("button", { className: "mcfg-primary", disabled: !writable || !!form, onClick: () => { setForm(freshImageEntry()); setEditing(""); setKey(""); setCatalog(null); } }, "手动添加端点"),
891
634
  React.createElement("button", { onClick: () => void action(async () => { await refresh(); setForm(null); setKey(""); }) }, "重新加载")),
892
635
  !writable ? React.createElement("p", { className: "mcfg-note" }, "当前配置只读。") : null),
893
- form ? React.createElement("fieldset", { disabled: busy }, React.createElement("legend", null, editing ? "编辑模型" : "添加模型"),
636
+ form ? React.createElement("fieldset", { disabled: busy }, React.createElement("legend", null, editing ? "编辑绘图模型" : "手动添加端点"),
637
+ React.createElement("p", { className: "mcfg-note" }, "通常不需要手动添加:在「设置 → 模型」里勾选模型行的「绘图」即可自动生成。这里用于没有对应聊天模型的独立端点。填写顺序:供应商 API 地址 → API Key →「获取模型列表」选择模型 → 保存。"),
894
638
  imageField("供应商 API 地址", form.endpoint.replace(/\/images\/generations\/?$/, ""), (value) => update("endpoint")(value.replace(/\/+$/, "").replace(/\/images\/generations$/, "") + "/images/generations"), { type: "url", placeholder: "https://api.example.com/v1" }),
895
639
  imageField("API Key(留空保留现有值)", key, (value) => { setKey(value); setCatalog(null); }, { type: "password", autoComplete: "new-password" }),
896
640
  React.createElement("button", { onClick: () => void action(discover) }, "获取模型列表"),
@@ -915,739 +659,407 @@ window.__ModuleLoader__.load({
915
659
  imageField("超时(秒)", form.timeoutSeconds, update("timeoutSeconds"), { type: "number", min: 10, max: 600 }),
916
660
  ...[["尺寸", "size"], ["质量", "quality"], ["背景", "background"], ["输出格式", "output_format"], ["响应格式", "response_format"], ["风格", "style"]].map(([label, name]) => imageField(`${label}(可选)`, form[name], update(name)))),
917
661
  React.createElement("p", { className: "mcfg-note" }, "可选参数留空则不发送。GPT Image 的响应格式请留空。Key 通过 dsh 凭据服务单独保存;同名引用会被其他模型共享。"),
918
- editing && managedBy(form) ? React.createElement("p", { className: "mcfg-note" }, `该配置最初由「模型参数」页从 ${managedBy(form)} 链接生成;如需同步 provider 最新地址,可在那边取消勾选后重新勾选,或直接在此修改。`) : null),
662
+ editing && managedBy(form) ? React.createElement("p", { className: "mcfg-note" }, `该配置来自「设置 → 模型」里勾选的绘图模型(${managedBy(form)})。推荐回到那边勾选/取消,端点与凭据会自动跟随 provider。`) : null),
919
663
  React.createElement("div", { className: "mcfg-actions2" }, React.createElement("button", { onClick: () => void action(save) }, "保存"), React.createElement("button", { onClick: () => { setForm(null); setKey(""); } }, "取消"))) : null,
920
- React.createElement("fieldset", { disabled: busy || running }, React.createElement("legend", null, "直接绘图"),
921
- React.createElement("label", { className: "mcfg-field2" }, "绘图模型", React.createElement("select", { value: selected, onChange: (event) => setSelected(event.target.value) },
922
- React.createElement("option", { value: "" }, "使用默认模型"), ...models.map((item) => React.createElement("option", { key: item.id, value: item.id }, item.name || item.id)))),
923
- React.createElement("label", { className: "mcfg-field2" }, "提示词", React.createElement("textarea", { value: prompt, onChange: (event) => setPrompt(event.target.value), placeholder: "描述你想生成的画面…" })),
924
- imageField("图片数量", count, setCount, { type: "number", min: 1, max: 4 }),
925
- React.createElement("button", { disabled: models.length === 0 || !prompt.trim(), onClick: () => void action(generate) }, "生成图片"),
926
- React.createElement("p", { className: "mcfg-note" }, "点击生成会调用所选服务,可能产生费用。此面板不发送聊天历史,结果不插入对话。任务预览保留到宿主重启、过期或被后续任务替换前,请及时下载原图。")),
664
+ React.createElement("details", { className: "mcfg-advanced" },
665
+ React.createElement("summary", null, "直接绘图(可选,不经过聊天)"),
666
+ React.createElement("fieldset", { disabled: busy || running },
667
+ React.createElement("label", { className: "mcfg-field2" }, "绘图模型", React.createElement("select", { value: selected, onChange: (event) => setSelected(event.target.value) },
668
+ React.createElement("option", { value: "" }, "使用默认模型" + (defaultEntry ? `(${defaultEntry.name || defaultEntry.id})` : "")),
669
+ ...models.map((item) => React.createElement("option", { key: item.id, value: item.id },
670
+ (item.name || item.id) + (view.value?.defaultModel === item.id ? "(默认)" : "") + " · " + item.model)))),
671
+ React.createElement("label", { className: "mcfg-field2" }, "提示词", React.createElement("textarea", { value: prompt, onChange: (event) => setPrompt(event.target.value), placeholder: "描述你想生成的画面…" })),
672
+ imageField("图片数量", count, setCount, { type: "number", min: 1, max: 4 }),
673
+ React.createElement("button", { className: "mcfg-primary", disabled: models.length === 0 || !prompt.trim(), onClick: () => void action(generate) }, "生成图片"),
674
+ React.createElement("p", { className: "mcfg-note" }, "点击生成会调用所选服务,可能产生费用。此面板不发送聊天历史,结果不插入对话。任务预览保留到宿主重启、过期或被后续任务替换前,请及时下载原图。"))),
927
675
  running ? React.createElement("div", { role: "status" }, "正在生成… ", React.createElement("button", { onClick: () => void action(async () => { await call("cancel", { jobId }); }) }, "取消等待")) : null,
928
676
  job?.status === "error" ? React.createElement("div", { className: "mcfg-error", role: "alert" }, job.error) : null,
929
677
  job?.result ? React.createElement("div", { className: "mcfg-gallery" }, job.result.images.map((image, index) => React.createElement(PanelImage, { key: `${jobId}-${index}`, call, jobId, image, index }))) : null);
930
678
  }
931
679
 
932
- function ModelTuningPage(props) {
933
- const api = props.api;
680
+ // ── 设置页:绘图(v3 后本插件唯一的独立页面) ─────────────────────────
681
+ function DrawingPage(props) {
682
+ const remote = props.remote;
934
683
  const timer = props.timer;
935
- const [data, setData] = React.useState(null);
936
- const [ownView, setOwnView] = React.useState(null);
937
- const [loading, setLoading] = React.useState(true);
684
+ const [view, setView] = React.useState(null);
685
+ const [writable, setWritable] = React.useState(false);
938
686
  const [error, setError] = React.useState(null);
939
687
  const [toast, setToast] = React.useState(null);
940
- const [query, setQuery] = React.useState("");
941
- const [tab, setTab] = React.useState("models");
942
- const [title, setTitle] = React.useState(null);
943
- const [titleDraft, setTitleDraft] = React.useState(null);
944
- const [titleBusy, setTitleBusy] = React.useState(false);
945
688
 
946
- async function reload() {
689
+ async function refresh() {
947
690
  try {
948
- const [providersRes, modelsRes, settingsRes] = await Promise.all([
949
- api.llm.providers({}),
950
- api.llm.models({}),
951
- api.settings.describe({}),
952
- ]);
953
- if (!providersRes.result.ok) throw new Error(providersRes.result.error.message);
954
- if (!modelsRes.result.ok) throw new Error(modelsRes.result.error.message);
955
- if (!settingsRes.result.ok) throw new Error(settingsRes.result.error.message);
956
-
957
- const providers = providersRes.result.value.providers;
958
- const groups = modelsRes.result.value.groups;
959
- const namespaces = settingsRes.result.value.namespaces;
960
- const writable = settingsRes.result.value.writable;
961
-
962
- const metaByProvider = new Map(providers.map((p) => [p.provider, p]));
963
- const nsByNs = new Map(namespaces.map((n) => [n.ns, n]));
964
- // v2: our own view; absent only when our host row failed to activate.
965
- const view = ownNsView(settingsRes.result.value);
966
- setTitle(titleViewOf(settingsRes.result.value));
967
- setTitleDraft(null);
968
- setError(null);
969
-
970
- const built = { writable, providers: [] };
971
- for (const group of groups) {
972
- const entry = metaByProvider.get(group.id);
973
- const nsView = entry ? nsByNs.get(entry.settingsNs) : undefined;
974
- const providerProfile = profileOf(nsView, entry ? entry.settingsPath : []);
975
- const reasoningEditable = !!(entry && entry.settingsNs === "llm-pi-ai");
976
- const adaptiveKey = entry ? adaptiveThinkingCompatKey(entry.settingsNs, providerProfile) : null;
977
- const routeDefaultInput = providerProfile && Array.isArray(providerProfile.defaultInput)
978
- ? providerProfile.defaultInput
979
- : null;
980
- const models = group.models.map((m) => {
981
- const ctx = readModelField(nsView, entry ? entry.settingsPath : [], m.id, "contextWindow");
982
- const mx = readModelField(nsView, entry ? entry.settingsPath : [], m.id, "maxTokens");
983
- const devRole = entry && entry.settingsNs === "llm-pi-ai"
984
- ? readCompatField(nsView, entry.settingsPath, m.id, "supportsDeveloperRole")
985
- : undefined;
986
- const adaptiveThinking = adaptiveKey
987
- ? readCompatField(nsView, entry.settingsPath, m.id, adaptiveKey)
988
- : undefined;
989
- const modalityField = entry ? modalityFieldFor(entry.settingsNs) : undefined;
990
- const rawInput = modalityField
991
- ? readModelField(nsView, entry.settingsPath, m.id, modalityField)
992
- : undefined;
993
- const image = computeImageModelState(view, providerProfile, m.id);
994
- const efforts = (m.reasoning && Array.isArray(m.reasoning.efforts)) ? m.reasoning.efforts.map((e) => ({ id: e.id, name: e.name })) : [];
995
- return {
996
- id: m.id,
997
- name: m.name,
998
- description: m.description != null ? m.description : null,
999
- contextWindow: ctx != null ? ctx : null,
1000
- maxTokens: mx != null ? mx : null,
1001
- supportsDeveloperRole: devRole,
1002
- adaptiveThinking: adaptiveThinking === true,
1003
- // The flag only changes a request that carries thinking at all,
1004
- // so it is offered where a level can be selected.
1005
- adaptiveThinkingEditable: adaptiveKey !== null && efforts.length > 0,
1006
- modalityField: modalityField || null,
1007
- modality: modalityField ? modalityChoice(rawInput) : null,
1008
- imageAvailable: image.available,
1009
- imageLinked: image.hasEntry,
1010
- imageManaged: image.managed,
1011
- imageEndpoint: image.endpoint || null,
1012
- imageApiKeyEnv: image.apiKeyEnv || null,
1013
- declared: !!(entry && entry.declared),
1014
- routeDefaultInput: routeDefaultInput,
1015
- efforts,
1016
- defaultEffort: (m.reasoning && m.reasoning.defaultEffort != null) ? m.reasoning.defaultEffort : null,
1017
- };
1018
- });
1019
- built.providers.push({
1020
- provider: group.id,
1021
- name: group.name,
1022
- configurable: !!entry,
1023
- reasoningEditable,
1024
- imageNsAvailable: !!view,
1025
- models,
1026
- });
1027
- }
1028
- const imageModels = imageModelsOf(view);
1029
- const linkedIds = new Set(imageModels.filter((item) => item && item.model).map((item) => item.model));
1030
- for (const row of built.providers) {
1031
- row.imageLinkedCount = row.models.filter((m) => linkedIds.has(m.id)).length;
1032
- }
1033
- built.imageModels = imageModels.map((item) => item?.model).filter(Boolean);
1034
- built.imageDefault = typeof view?.value?.defaultModel === "string" ? view.value.defaultModel : "";
1035
- setData(built);
1036
- setOwnView(view ?? null);
691
+ const response = await remote.settings.describe();
692
+ if (!response.ok) throw new Error(response.error && response.error.message ? response.error.message : "读取设置失败");
693
+ const namespaces = Array.isArray(response.value?.namespaces) ? response.value.namespaces : [];
694
+ const found = namespaces.find((item) => item && item.ns === NS);
695
+ setView(found ?? null);
696
+ setWritable(response.value?.writable !== false);
1037
697
  setError(null);
1038
698
  return true;
1039
699
  } catch (err) {
1040
700
  setError(err && err.message ? err.message : String(err));
1041
701
  return false;
1042
- } finally {
1043
- setLoading(false);
1044
702
  }
1045
703
  }
1046
704
 
1047
- React.useEffect(() => {
1048
- reload();
1049
- }, []);
705
+ React.useEffect(() => { void refresh(); }, []);
1050
706
 
1051
707
  function showToast(kind, text) {
1052
708
  setToast({ kind, text });
1053
709
  if (timer) timer.timeout(() => setToast(null), 3000);
1054
710
  }
1055
711
 
1056
- async function applyChange(provider, model, kind, value) {
1057
- try {
1058
- const [providersRes, settingsRes] = await Promise.all([
1059
- api.llm.providers({}),
1060
- api.settings.describe({}),
1061
- ]);
1062
- if (!providersRes.result.ok) throw new Error(providersRes.result.error.message);
1063
- if (!settingsRes.result.ok) throw new Error(settingsRes.result.error.message);
1064
-
1065
- const entry = providersRes.result.value.providers.find((p) => p.provider === provider);
1066
- if (!entry) throw new Error("该 provider 不可配置");
1067
- const nsView = settingsRes.result.value.namespaces.find((n) => n.ns === entry.settingsNs);
712
+ if (error) return React.createElement("div", { className: "mcfg-state mcfg-error" }, "加载失败:" + error);
713
+ if (!view) return React.createElement("div", { className: "mcfg-state" }, "正在读取绘图配置…");
1068
714
 
1069
- if (kind === "imageLink") {
1070
- // Re-read immediately before writing, so the plan and the revision
1071
- // it is applied against come from the same document.
1072
- const freshRes = await api.settings.describe({});
1073
- if (!freshRes.result.ok) throw new Error(freshRes.result.error.message);
1074
- const freshView = ownNsView(freshRes.result.value);
1075
- if (!freshView) throw new Error("模型调参宿主行未激活,无法写入绘图配置。");
1076
- const providerProfile = profileOf(nsView, entry.settingsPath);
1077
- const plan = value === true
1078
- ? planImageLink(freshView, providerProfile, model, provider)
1079
- : planImageUnlink(freshView, model);
1080
- if (!plan || !plan.models) throw new Error((plan && plan.reason) || "无法写入绘图配置");
1081
- const written = await api.settings.mutate({
1082
- ns: NS,
1083
- ops: [
1084
- { op: "set", path: ["models"], value: plan.models },
1085
- { op: "set", path: ["defaultModel"], value: plan.defaultModel || "" },
1086
- ],
1087
- expectedRevision: freshView.revision,
1088
- });
1089
- if (!written.result.ok) throw new Error(written.result.error.message);
1090
- showToast("ok", value === true ? "已在「绘图」页生成模型配置" : "已取消生图标记");
1091
- await reload();
1092
- return;
1093
- }
715
+ return React.createElement("div", { className: "mcfg" },
716
+ React.createElement("div", { className: "mcfg-head" },
717
+ React.createElement("div", { className: "mcfg-title" }, "绘图"),
718
+ React.createElement("div", { className: "mcfg-sub" },
719
+ "这里管理 generate_image 使用的绘图模型。通常不用手动配置:去「设置 → 模型」给某个模型勾选「绘图」即可自动生成一条;标为默认的那条就是 generate_image 的默认模型。"
720
+ + (writable ? "" : "(当前部署为只读,无法保存。)")
721
+ )
722
+ ),
723
+ toast ? React.createElement("div", { className: "mcfg-toast " + (toast.kind === "ok" ? "mcfg-toast-ok" : "mcfg-toast-err") }, toast.text) : null,
724
+ React.createElement(ImagePanel, {
725
+ remote, call: props.call, writable, view, refresh, showToast,
726
+ })
727
+ );
728
+ }
1094
729
 
1095
- let field;
1096
- let payload;
1097
- if (kind === "reasoningLevels") {
1098
- field = "reasoningEfforts";
1099
- const raw = value && Array.isArray(value.levels) ? value.levels : [];
1100
- payload = reasoningEffortsPayload(raw, storedReasoningEfforts(entry, nsView, model));
1101
- } else if (kind === "contextWindow" || kind === "maxTokens") {
1102
- field = kind;
1103
- if (value != null && (typeof value !== "number" || !Number.isInteger(value) || value <= 0)) {
1104
- throw new Error("取值必须是正整数");
1105
- }
1106
- payload = value;
1107
- } else if (kind === "supportsDeveloperRole" || kind === "adaptiveThinking") {
1108
- if (value !== null && typeof value !== "boolean") {
1109
- throw new Error("取值必须是布尔值");
1110
- }
1111
- const compatKey = kind === "supportsDeveloperRole" ? "supportsDeveloperRole" : ADAPTIVE_THINKING_COMPAT_KEY;
1112
- const write = computeCompatWriteOps(entry, nsView, model, compatKey, value);
1113
- if (write) {
1114
- const res = await api.settings.mutate({ ns: write.ns, ops: write.ops });
1115
- if (!res.result.ok) throw new Error(res.result.error.message);
1116
- }
1117
- showToast("ok", "已保存");
1118
- await reload();
1119
- return;
1120
- } else if (kind === "modalities") {
1121
- if (MODALITY_CHOICES.indexOf(value) < 0) throw new Error("未知的模态取值");
1122
- const write = computeModalityWriteOps(entry, nsView, model, value);
1123
- if (!write) throw new Error("该 provider 不接受模态声明");
1124
- const res = await api.settings.mutate({ ns: write.ns, ops: write.ops });
1125
- if (!res.result.ok) throw new Error(res.result.error.message);
1126
- showToast("ok", value === "text-image" ? "已开启图像输入,下一次请求即生效" : "已保存");
1127
- await reload();
1128
- return;
1129
- } else {
1130
- throw new Error("未知的修改类型");
1131
- }
730
+ // ── 官方「设置 → 模型」卡片的扩展:官方没有的兼容开关与目录推理覆盖 ──
731
+ // 页面长度敏感:整段默认折叠(<details>),每个模型一行紧凑控件;
732
+ // 推理档位是复选组,只在点开该模型时才渲染。
733
+ function CompatCard(props) {
734
+ const load = props.load;
735
+ const save = props.save;
736
+ const provider = props.provider;
737
+ const [data, setData] = React.useState(null);
738
+ const [error, setError] = React.useState(null);
739
+ const [toast, setToast] = React.useState(null);
740
+ const [busy, setBusy] = React.useState(false);
741
+ const [openLevels, setOpenLevels] = React.useState("");
1132
742
 
1133
- const write = computeWriteOps(entry, nsView, model, field, payload);
1134
- if (write) {
1135
- const res = await api.settings.mutate({ ns: write.ns, ops: write.ops });
1136
- if (!res.result.ok) throw new Error(res.result.error.message);
1137
- }
1138
- showToast("ok", "已保存");
1139
- await reload();
743
+ const reload = React.useCallback(async () => {
744
+ try {
745
+ const value = await load(provider.provider);
746
+ setData(value);
747
+ setError(null);
1140
748
  } catch (err) {
1141
- showToast("err", err && err.message ? err.message : String(err));
749
+ setError(err && err.message ? err.message : String(err));
1142
750
  }
1143
- }
1144
-
1145
- if (loading) {
1146
- return React.createElement("div", { className: "mcfg-state" }, "正在加载模型配置…");
1147
- }
1148
- if (error) {
1149
- return React.createElement("div", { className: "mcfg-state mcfg-error" }, "加载失败:" + error);
1150
- }
751
+ }, [load, provider.provider]);
1151
752
 
1152
- /** The effective title selection: an unsaved draft wins over the stored value. */
1153
- const titleShown = titleDraft || title || { available: false, mode: "inherit", provider: null, model: null, revision: null };
1154
- const titleProviders = data && data.providers ? data.providers : [];
1155
- const titleProvider = titleProviders.find((p) => p.provider === titleShown.provider) || null;
1156
- const titleModels = titleProvider ? titleProvider.models : [];
1157
- const titleDirty = !!title && (
1158
- titleShown.mode !== title.mode
1159
- || (titleShown.provider || null) !== (title.provider || null)
1160
- || (titleShown.model || null) !== (title.model || null)
1161
- || TITLE_BUDGET_FIELDS.some((field) => Number(titleShown[field.key]) !== Number(title[field.key]))
1162
- );
1163
- const titleSaveDisabled = !data || !data.writable || !titleShown.available || titleBusy
1164
- || (titleShown.mode === "custom" && (!titleShown.provider || !titleShown.model));
1165
-
1166
- function editTitleDraft(patch) {
1167
- setTitleDraft({ ...titleShown, ...patch });
1168
- }
753
+ React.useEffect(() => { void reload(); }, [reload]);
1169
754
 
1170
- async function saveTitle() {
1171
- // Budget travels with the route: both are this page's to write.
1172
- const payload = {
1173
- mode: titleShown.mode === "custom" ? "custom" : "inherit",
1174
- provider: titleShown.provider,
1175
- model: titleShown.model,
1176
- };
1177
- for (const field of TITLE_BUDGET_FIELDS) payload[field.key] = titleShown[field.key];
1178
- setTitleBusy(true);
1179
- let saved = false;
755
+ async function commit(kind, modelId, value, message) {
756
+ setBusy(true);
757
+ setToast(null);
1180
758
  try {
1181
- const write = planTitleWrite(payload);
1182
- const res = await api.settings.mutate({ ns: write.ns, ops: write.ops });
1183
- if (!res.result.ok) throw new Error(res.result.error.message);
1184
- saved = true;
759
+ await save(provider.provider, modelId, kind, value);
760
+ await reload();
761
+ setToast({ kind: "ok", text: message || "已保存,下一次请求生效" });
1185
762
  } catch (err) {
1186
- showToast("err", err && err.message ? err.message : String(err));
763
+ setToast({ kind: "err", text: err && err.message ? err.message : String(err) });
1187
764
  } finally {
1188
- setTitleBusy(false);
765
+ setBusy(false);
1189
766
  }
1190
- if (!saved) return;
1191
- showToast("ok", "标题生成设置已保存,对之后生成的标题生效");
1192
- if (!await reload()) showToast("err", "设置已保存,但页面数据刷新失败,请重新打开本页确认。");
1193
767
  }
1194
768
 
1195
- const q = query.trim().toLowerCase();
1196
- const filtered = !data || !data.providers || data.providers.length === 0 ? [] : q.length === 0 ? data.providers : data.providers.map((provider) => {
1197
- const providerMatch = provider.name.toLowerCase().indexOf(q) >= 0 || provider.provider.toLowerCase().indexOf(q) >= 0;
1198
- const models = providerMatch ? provider.models : provider.models.filter((m) =>
1199
- m.id.toLowerCase().indexOf(q) >= 0 ||
1200
- m.name.toLowerCase().indexOf(q) >= 0 ||
1201
- (m.description || "").toLowerCase().indexOf(q) >= 0
1202
- );
1203
- return models.length > 0 ? { ...provider, models } : null;
1204
- }).filter(Boolean);
1205
- const totalMatched = filtered.reduce((n, p) => n + p.models.length, 0);
1206
-
1207
- const tabButton = (id, label) => React.createElement("button", {
1208
- key: id,
1209
- type: "button",
1210
- className: "mcfg-tab" + (tab === id ? " mcfg-tab-on" : ""),
1211
- "aria-pressed": tab === id,
1212
- onClick: () => setTab(id),
1213
- }, label);
1214
- const tabBar = React.createElement("div", { className: "mcfg-tabs", role: "tablist" },
1215
- tabButton("models", "模型参数"),
1216
- tabButton("purpose", "用途"),
1217
- tabButton("image", "绘图")
769
+ if (error) {
770
+ return React.createElement("div", { className: "mcfg-compat" },
771
+ React.createElement("div", { className: "mcfg-error" }, "兼容设置读取失败:" + error));
772
+ }
773
+ if (!data) return null;
774
+ if (!data.models || data.models.length === 0) return null;
775
+ const editable = !busy && data.writable;
776
+ const overridden = data.models.filter((model) => model.hasOverride).length;
777
+
778
+ const imageModels = data.models.filter((model) => model.imageAvailable);
779
+ const linkedCount = imageModels.filter((model) => model.imageLinked).length;
780
+ const toastLine = toast
781
+ ? React.createElement("div", { className: "mcfg-toast " + (toast.kind === "ok" ? "mcfg-toast-ok" : "mcfg-toast-err"), role: "status" }, toast.text)
782
+ : null;
783
+
784
+ // Drawing link lives next to the compat switches because both are
785
+ // per-model add-ons to the official Models page; this is also where the
786
+ // old 模型调参 page offered its 「生图模型」 checkbox.
787
+ const drawingSection = imageModels.length === 0 ? null : React.createElement("details", { className: "mcfg-compat" },
788
+ React.createElement("summary", { className: "mcfg-compat-head" },
789
+ React.createElement("span", null, "绘图模型(generate_image)"),
790
+ React.createElement("span", { className: linkedCount > 0 ? "mcfg-chip mcfg-chip-on" : "mcfg-chip" },
791
+ linkedCount > 0 ? linkedCount + " 个已勾选" : "未勾选"),
792
+ React.createElement("span", { className: "mcfg-compat-hint" }, "勾选后自动加入「设置 → 绘图」")
793
+ ),
794
+ React.createElement("div", { className: "mcfg-compat-list" },
795
+ imageModels.map((model) => React.createElement("label", {
796
+ key: model.id, className: "mcfg-draw-check",
797
+ title: "复用本 provider 的地址与凭据引用,自动生成一条绘图配置(generate_image 可直接使用)",
798
+ },
799
+ React.createElement("input", {
800
+ type: "checkbox",
801
+ checked: model.imageLinked,
802
+ disabled: !editable,
803
+ onChange: () => void commit(
804
+ "imageLink",
805
+ model.id,
806
+ !model.imageLinked,
807
+ model.imageLinked ? "已从「设置 → 绘图」移除" : "已加入「设置 → 绘图」",
808
+ ),
809
+ }),
810
+ React.createElement("span", { className: "mcfg-draw-check-name" }, model.name || model.id),
811
+ React.createElement("span", { className: "mcfg-draw-check-meta" },
812
+ model.imageLinked ? "已加入绘图配置" : "未加入")
813
+ ))
814
+ )
1218
815
  );
1219
816
 
1220
- const modelsPanel = [
1221
- React.createElement("div", { className: "mcfg-searchrow", key: "search" },
1222
- React.createElement("input", {
1223
- className: "mcfg-search",
1224
- type: "text",
1225
- value: query,
1226
- placeholder: "搜索模型 / provider…",
1227
- onChange: (e) => setQuery(e.target.value),
1228
- }),
1229
- query.trim().length > 0 ? React.createElement("span", { className: "mcfg-count" }, totalMatched + " 个模型") : null
817
+ const compatSection = React.createElement("details", { className: "mcfg-compat" },
818
+ React.createElement("summary", { className: "mcfg-compat-head" },
819
+ React.createElement("span", null, "兼容与推理(插件补充)"),
820
+ React.createElement("span", { className: overridden > 0 ? "mcfg-chip mcfg-chip-on" : "mcfg-chip" },
821
+ overridden > 0 ? overridden + " 个模型有覆盖" : data.models.length + " 个模型"),
822
+ React.createElement("span", { className: "mcfg-compat-hint" }, "官方 Models 页没有这些控件")
1230
823
  ),
1231
- data.imageModels.length > 0
1232
- ? React.createElement("div", { className: "mcfg-note", key: "img" },
1233
- "绘图页当前 " + data.imageModels.length + " 个配置:" + data.imageModels.join("、")
1234
- + (data.imageDefault ? "(默认 " + data.imageDefault + ")" : "(未设默认,generate_image 需显式指定)"))
1235
- : React.createElement("div", { className: "mcfg-note", key: "img" }, "绘图页尚未配置任何模型。"),
1236
- !data.providers || data.providers.length === 0
1237
- ? React.createElement("div", { className: "mcfg-state", key: "none" }, "当前没有已注册的模型。")
1238
- : filtered.length === 0
1239
- ? React.createElement("div", { className: "mcfg-state", key: "empty" }, "没有匹配的模型。")
1240
- : filtered.map((provider) =>
1241
- React.createElement("div", { key: provider.provider, className: "mcfg-provider" },
1242
- React.createElement("div", { className: "mcfg-provider-name" },
1243
- provider.name,
1244
- React.createElement("span", { className: "mcfg-provider-id" }, provider.provider)
824
+ React.createElement("div", { className: "mcfg-compat-list" },
825
+ data.models.map((model) => {
826
+ const levelsOpen = openLevels === model.id;
827
+ return React.createElement("div", { key: model.id, className: "mcfg-compat-row" + (levelsOpen ? " mcfg-compat-row-open" : "") },
828
+ React.createElement("div", { className: "mcfg-compat-line" },
829
+ React.createElement("span", { className: "mcfg-compat-name", title: model.id }, model.name || model.id),
830
+ React.createElement("select", {
831
+ className: "mcfg-mini-select",
832
+ title: "系统提示角色:网关报 role 'developer' is not allowed 时选 system(Kimi/DeepSeek 网关常见)",
833
+ disabled: !editable,
834
+ value: model.supportsDeveloperRole === undefined ? "auto" : (model.supportsDeveloperRole ? "developer" : "system"),
835
+ onChange: (event) => {
836
+ const picked = event.target.value;
837
+ void commit("supportsDeveloperRole", model.id, picked === "auto" ? null : picked === "developer");
838
+ },
839
+ },
840
+ React.createElement("option", { value: "auto" }, "角色:自动"),
841
+ React.createElement("option", { value: "system" }, "角色:system"),
842
+ React.createElement("option", { value: "developer" }, "角色:developer")
1245
843
  ),
1246
- provider.models.map((model) => {
1247
- const editable = data.writable && provider.configurable;
1248
- const selectedLevels = new Set((model.efforts || []).filter((e) => e.id !== "off").map((e) => e.id));
1249
-
1250
- return React.createElement("div", { key: model.id, className: "mcfg-model" },
1251
- React.createElement("div", { className: "mcfg-model-head" },
1252
- React.createElement("div", { className: "mcfg-model-title" },
1253
- React.createElement("span", { className: "mcfg-model-name" }, model.name),
1254
- React.createElement("span", { className: "mcfg-model-id" }, model.id),
1255
- model.modality === "text-image"
1256
- ? React.createElement("span", { className: "mcfg-chip mcfg-chip-on" }, "图像输入")
1257
- : null,
1258
- model.imageLinked
1259
- ? React.createElement("span", { className: "mcfg-chip mcfg-chip-on" }, "生图模型")
1260
- : null,
1261
- model.adaptiveThinking
1262
- ? React.createElement("span", { className: "mcfg-chip mcfg-chip-on" }, "自适应思考")
1263
- : null
1264
- )
1265
- ),
1266
- model.description ? React.createElement("div", { className: "mcfg-desc" }, model.description) : null,
1267
- provider.reasoningEditable ? React.createElement("div", { className: "mcfg-reasoning" },
1268
- React.createElement("span", { className: "mcfg-label" }, "可选推理档位"),
1269
- React.createElement("div", { className: "mcfg-levels" },
1270
- REASONING_LEVELS.map((level) =>
1271
- React.createElement("label", { key: level, className: "mcfg-level" },
1272
- React.createElement("input", {
1273
- type: "checkbox",
1274
- checked: selectedLevels.has(level),
1275
- onChange: () => {
1276
- const next = new Set(selectedLevels);
1277
- if (next.has(level)) next.delete(level); else next.add(level);
1278
- applyChange(provider.provider, model.id, "reasoningLevels", { levels: [...next] });
1279
- },
1280
- }),
1281
- React.createElement("span", null, level)
1282
- )
1283
- )
1284
- ),
1285
- selectedLevels.size === 0 ? React.createElement("div", { className: "mcfg-note" }, "未勾选任何档位 → 该模型视为无推理能力") : null
1286
- ) : React.createElement("div", { className: "mcfg-reasoning" },
1287
- React.createElement("span", { className: "mcfg-label" }, "推理档位(由 provider 固定)"),
1288
- React.createElement("div", { className: "mcfg-levels" },
1289
- (model.efforts || []).map((effort) =>
1290
- React.createElement("span", { key: effort.id, className: "mcfg-chip" }, effort.name)
1291
- )
1292
- )
1293
- ),
1294
- provider.reasoningEditable ? React.createElement("div", { className: "mcfg-reasoning" },
1295
- React.createElement("span", { className: "mcfg-label" }, "系统提示角色(兼容开关)"),
1296
- React.createElement("div", { className: "mcfg-levels" },
1297
- ["auto", "system", "developer"].map((role) =>
1298
- React.createElement("label", { key: role, className: "mcfg-level" },
1299
- React.createElement("input", {
1300
- type: "radio",
1301
- name: "devrole-" + model.id,
1302
- disabled: !editable,
1303
- checked: role === "auto"
1304
- ? model.supportsDeveloperRole === undefined
1305
- : (role === "developer" ? model.supportsDeveloperRole === true : model.supportsDeveloperRole === false),
1306
- onChange: () => {
1307
- if (role === "auto") {
1308
- applyChange(provider.provider, model.id, "supportsDeveloperRole", null);
1309
- } else {
1310
- applyChange(provider.provider, model.id, "supportsDeveloperRole", role === "developer");
1311
- }
1312
- },
1313
- }),
1314
- React.createElement("span", null,
1315
- role === "auto" ? "自动" : (role === "system" ? "system(推荐 Kimi/DeepSeek)" : "developer")
1316
- )
1317
- )
1318
- )
1319
- ),
1320
- React.createElement("div", { className: "mcfg-note" },
1321
- "自动:跟随网关检测;如遇 role 'developer' is not allowed,请选 system"
1322
- )
1323
- ) : null,
1324
- model.adaptiveThinkingEditable ? React.createElement("div", { className: "mcfg-reasoning" },
1325
- React.createElement("span", { className: "mcfg-label" }, "思考格式(Anthropic 兼容开关)"),
1326
- React.createElement("div", { className: "mcfg-levels" },
1327
- ["auto", "adaptive"].map((choice) =>
1328
- React.createElement("label", { key: choice, className: "mcfg-level" },
1329
- React.createElement("input", {
1330
- type: "radio",
1331
- name: "adaptive-" + model.id,
1332
- disabled: !editable,
1333
- checked: choice === "auto" ? !model.adaptiveThinking : model.adaptiveThinking,
1334
- onChange: () => applyChange(provider.provider, model.id, "adaptiveThinking", choice === "adaptive" ? true : null),
1335
- }),
1336
- React.createElement("span", null, choice === "auto" ? "自动(预算式 thinking)" : "自适应 thinking")
1337
- )
1338
- )
1339
- ),
1340
- React.createElement("div", { className: "mcfg-note" },
1341
- "上面选中档位后,请求体由这里决定:自适应写 compat." + ADAPTIVE_THINKING_COMPAT_KEY
1342
- + ",发出 thinking.type=adaptive 与 output_config.effort;Claude Opus 4.6 及更新、Sonnet 5、"
1343
- + "Fable 5 等只接受这种,报 400 requires adaptive thinking 就切到它。"
1344
- + "Claude 4.5 及更早只认预算式,保持「自动」。"
1345
- )
1346
- ) : null,
1347
- model.modalityField ? React.createElement("div", { className: "mcfg-reasoning" },
1348
- React.createElement("span", { className: "mcfg-label" }, "输入模态(写入 " + model.modalityField + ")"),
1349
- React.createElement("div", { className: "mcfg-levels" },
1350
- MODALITY_CHOICES.map((choice) =>
1351
- React.createElement("label", { key: choice, className: "mcfg-level" },
1352
- React.createElement("input", {
1353
- type: "radio",
1354
- name: "modality-" + model.id,
1355
- disabled: !editable,
1356
- checked: model.modality === choice,
1357
- onChange: () => applyChange(provider.provider, model.id, "modalities", choice),
1358
- }),
1359
- React.createElement("span", null, MODALITY_LABELS[choice])
1360
- )
1361
- )
1362
- ),
1363
- React.createElement("div", { className: "mcfg-note" },
1364
- modalityNote(model.modality, model)
1365
- )
1366
- ) : null,
1367
- model.imageAvailable ? React.createElement("div", { className: "mcfg-reasoning" },
1368
- React.createElement("span", { className: "mcfg-label" }, "生图模型"),
1369
- React.createElement("div", { className: "mcfg-img-actions" },
1370
- React.createElement("label", { className: "mcfg-level" },
1371
- React.createElement("input", {
1372
- type: "checkbox",
1373
- checked: model.imageLinked,
1374
- disabled: !editable,
1375
- onChange: () => applyChange(provider.provider, model.id, "imageLink", !model.imageLinked),
1376
- }),
1377
- React.createElement("span", null, "在「绘图」页自动配置该模型")
1378
- ),
1379
- model.imageLinked ? React.createElement("span", { className: "mcfg-chip mcfg-chip-on" }, "已在绘图页配置") : null
1380
- ),
1381
- React.createElement("div", { className: "mcfg-note" },
1382
- model.imageLinked
1383
- ? "端点 " + (model.imageEndpoint || "(未填写)") + (model.imageApiKeyEnv ? ",凭据 " + model.imageApiKeyEnv : "")
1384
- + "。取消勾选会移除该配置;也可以直接在「绘图」页编辑或删除它。"
1385
- : "勾选后写入「绘图」页的模型列表(模型 ID 复用 " + model.id + ",端点由本 provider 的供应商地址加 /images/generations 推导)。"
1386
- )
1387
- ) : null,
1388
- React.createElement("div", { className: "mcfg-fields" },
1389
- React.createElement(CapacityField, {
1390
- label: "上下文窗口", options: CTX_OPTIONS, value: model.contextWindow, disabled: !editable,
1391
- provider: provider.provider, model: model.id, kind: "contextWindow", onApply: applyChange, onToast: showToast,
1392
- }),
1393
- React.createElement(CapacityField, {
1394
- label: "最大输出", options: MAX_OPTIONS, value: model.maxTokens, disabled: !editable,
1395
- provider: provider.provider, model: model.id, kind: "maxTokens", onApply: applyChange, onToast: showToast,
1396
- })
1397
- )
1398
- );
1399
- })
1400
- )
1401
- )
1402
- ];
1403
-
1404
- // ── 用途页 ────────────────────────────────────────────────────────────
1405
- const purposePanel = [
1406
- React.createElement("div", { className: "mcfg-purpose", key: "title" },
1407
- React.createElement("div", { className: "mcfg-purpose-head" },
1408
- React.createElement("span", { className: "mcfg-purpose-name" }, "标题生成"),
1409
- React.createElement("span", { className: "mcfg-chip" }, "独立请求"),
1410
- title && title.available
1411
- ? React.createElement("span", { className: "mcfg-chip mcfg-chip-on" }, "已启用")
1412
- : React.createElement("span", { className: "mcfg-chip" }, "宿主行未激活")
1413
- ),
1414
- React.createElement("div", { className: "mcfg-desc" },
1415
- "会话标题由一次独立的辅助请求生成,不会改动对话模型。默认继承当前会话的 provider / model。"
1416
- ),
1417
- !titleShown.available
1418
- ? React.createElement("div", { className: "mcfg-state mcfg-error" },
1419
- "未检测到 model-tuning 设置命名空间:宿主半区未激活(标题 provider 未注册),请检查插件是否启用并重启 dsh。"
1420
- )
1421
- : [
1422
- React.createElement("div", { className: "mcfg-radio", key: "mode" },
1423
- React.createElement("label", { className: "mcfg-radio-row" },
844
+ model.adaptiveThinkingEditable ? React.createElement("select", {
845
+ className: "mcfg-mini-select",
846
+ title: "思考格式:只接受 adaptive thinking 的模型(Claude Opus 4.6+ / Sonnet 5 等)切到自适应,其余保持自动",
847
+ disabled: !editable,
848
+ value: model.adaptiveThinking ? "adaptive" : "auto",
849
+ onChange: (event) => void commit("adaptiveThinking", model.id, event.target.value === "adaptive" ? true : null),
850
+ },
851
+ React.createElement("option", { value: "auto" }, "思考:自动"),
852
+ React.createElement("option", { value: "adaptive" }, "思考:自适应")
853
+ ) : null,
854
+ model.reasoningEditable ? React.createElement("button", {
855
+ type: "button",
856
+ className: "mcfg-mini-button" + (levelsOpen ? " mcfg-mini-button-on" : ""),
857
+ title: "内置目录路由的推理档位(写入 modelOverrides.<id>.reasoningEfforts):当前 "
858
+ + (model.levels.length === 0 ? "无档位" : model.levels.join("/"))
859
+ + ";官方 Models 页只覆盖手写路由",
860
+ disabled: !editable,
861
+ onClick: () => setOpenLevels(levelsOpen ? "" : model.id),
862
+ }, (model.levels.length === 0 ? "推理:无" : "推理:" + model.levels.length + " 档")
863
+ + (model.hasReasoningOverride ? " ·已改" : "")) : null
864
+ ),
865
+ levelsOpen ? React.createElement("div", { className: "mcfg-compat-levels" },
866
+ React.createElement("div", { className: "mcfg-levels" },
867
+ REASONING_LEVELS.map((level) => React.createElement("label", { key: level, className: "mcfg-level" },
1424
868
  React.createElement("input", {
1425
- type: "radio",
1426
- name: "title-mode",
1427
- checked: titleShown.mode === "inherit",
1428
- disabled: !data.writable || titleBusy,
1429
- onChange: () => editTitleDraft({ mode: "inherit", provider: null, model: null }),
1430
- }),
1431
- React.createElement("span", null,
1432
- "继承当前会话(默认)",
1433
- React.createElement("div", { className: "mcfg-note" },
1434
- "标题请求沿用该会话已记录的 provider / model,行为与内置标题生成一致。")
1435
- )
1436
- ),
1437
- React.createElement("label", { className: "mcfg-radio-row" },
1438
- React.createElement("input", {
1439
- type: "radio",
1440
- name: "title-mode",
1441
- checked: titleShown.mode === "custom",
1442
- disabled: !data.writable || titleBusy,
869
+ type: "checkbox",
870
+ checked: model.levels.indexOf(level) >= 0,
871
+ disabled: !editable,
1443
872
  onChange: () => {
1444
- const fallback = titleProviders[0];
1445
- editTitleDraft({
1446
- mode: "custom",
1447
- provider: titleShown.provider || (fallback ? fallback.provider : null),
1448
- model: titleShown.model || (fallback && fallback.models[0] ? fallback.models[0].id : null),
1449
- });
873
+ const next = model.levels.slice();
874
+ const index = next.indexOf(level);
875
+ if (index >= 0) next.splice(index, 1); else next.push(level);
876
+ void commit("reasoningLevels", model.id, next);
1450
877
  },
1451
878
  }),
1452
- React.createElement("span", null,
1453
- "使用指定模型",
1454
- React.createElement("div", { className: "mcfg-note" },
1455
- "标题请求改投所选 provider / model;会话的对话模型保持不变。")
1456
- )
1457
- )
1458
- ),
1459
- titleShown.mode === "custom"
1460
- ? React.createElement("div", { className: "mcfg-fields", key: "route" },
1461
- React.createElement("label", { className: "mcfg-field" },
1462
- React.createElement("span", { className: "mcfg-label" }, "Provider"),
1463
- React.createElement("select", {
1464
- className: "mcfg-select",
1465
- disabled: !data.writable || titleBusy,
1466
- value: titleShown.provider || "",
1467
- onChange: (e) => {
1468
- const next = titleProviders.find((p) => p.provider === e.target.value);
1469
- editTitleDraft({
1470
- provider: e.target.value,
1471
- model: next && next.models[0] ? next.models[0].id : null,
1472
- });
1473
- },
1474
- },
1475
- React.createElement("option", { value: "" }, "(请选择 provider)"),
1476
- titleProviders.map((p) => React.createElement("option", { key: p.provider, value: p.provider },
1477
- p.name + " · " + p.provider))
1478
- )
1479
- ),
1480
- React.createElement("label", { className: "mcfg-field" },
1481
- React.createElement("span", { className: "mcfg-label" }, "Model"),
1482
- React.createElement("select", {
1483
- className: "mcfg-select",
1484
- disabled: !data.writable || titleBusy || !titleShown.provider,
1485
- value: titleShown.model || "",
1486
- onChange: (e) => editTitleDraft({ model: e.target.value }),
1487
- },
1488
- React.createElement("option", { value: "" }, "(请选择 model)"),
1489
- titleModels.map((m) => React.createElement("option", { key: m.id, value: m.id },
1490
- m.name + " · " + m.id))
1491
- )
1492
- )
1493
- )
1494
- : null,
1495
- React.createElement("div", { className: "mcfg-note", key: "scope" },
1496
- "生效范围:保存后对本 profile 之后生成的标题生效(包含新会话与后续标题生成);不会改动已有标题、手动重命名,也不会改动会话的对话模型。"
1497
- ),
1498
- React.createElement("div", { className: "mcfg-reasoning", key: "budget" },
1499
- React.createElement("span", { className: "mcfg-label" }, "标题请求预算"),
1500
- React.createElement("div", { className: "mcfg-fields" },
1501
- TITLE_BUDGET_FIELDS.map((field) => React.createElement("label", { className: "mcfg-field", key: field.key },
1502
- React.createElement("span", { className: "mcfg-label" }, field.label),
1503
- React.createElement("input", {
1504
- className: "mcfg-input",
1505
- type: "number",
1506
- min: 1,
1507
- step: 1,
1508
- disabled: !data.writable || titleBusy,
1509
- value: titleShown[field.key] === undefined || titleShown[field.key] === null ? "" : String(titleShown[field.key]),
1510
- onChange: (event) => editTitleDraft({
1511
- [field.key]: event.target.value === "" ? "" : Number(event.target.value),
1512
- }),
1513
- }),
1514
- React.createElement("span", { className: "mcfg-note" }, field.hint)
1515
- ))
1516
- )
1517
- ),
1518
- React.createElement("div", { className: "mcfg-note", key: "budgetNote" },
1519
- "上面的数值由本页写入设置,保存后对之后生成的标题生效;清空某项会回到宿主默认值。标题请求不传递推理档位,由所选模型自己决定是否思考——因此推理模型需要更大的「输出上限」。"
879
+ React.createElement("span", null, level)
880
+ ))
1520
881
  ),
1521
- React.createElement("div", { className: "mcfg-actions", key: "actions" },
1522
- React.createElement("button", {
1523
- type: "button",
1524
- className: "mcfg-save",
1525
- disabled: titleSaveDisabled || !titleDirty,
1526
- onClick: saveTitle,
1527
- }, titleBusy ? "正在保存…" : (titleDirty ? "保存标题设置" : "已保存")),
1528
- titleDirty ? React.createElement("span", { className: "mcfg-note" }, "有未保存的修改") : null
1529
- )
1530
- ]
1531
- ),
1532
- React.createElement("div", { className: "mcfg-purpose", key: "image" },
1533
- React.createElement("div", { className: "mcfg-purpose-head" },
1534
- React.createElement("span", { className: "mcfg-purpose-name" }, "绘图"),
1535
- React.createElement("span", { className: "mcfg-chip" }, "图像专有参数")
1536
- ),
1537
- React.createElement("div", { className: "mcfg-desc" },
1538
- "绘图模型、端点、尺寸与质量属于图像专有参数,请在「绘图」页签配置;本页的「生图模型」勾选会把所选模型写入那里的模型列表。"
1539
- ),
1540
- data.imageModels && data.imageModels.length > 0
1541
- ? React.createElement("div", { className: "mcfg-note" },
1542
- "绘图页当前 " + data.imageModels.length + " 个配置:" + data.imageModels.join("、")
1543
- + (data.imageDefault ? "(默认 " + data.imageDefault + ")" : "(未设默认,generate_image 需显式指定)"))
1544
- : React.createElement("div", { className: "mcfg-note" }, "绘图页尚未配置任何模型。")
882
+ React.createElement("div", { className: "mcfg-note" },
883
+ "不勾选任何档位 = 该模型视为无推理能力;已勾选档位写入该路由的 modelOverrides,官方已存的每档 API 值保持不变。")
884
+ ) : null
885
+ );
886
+ })
1545
887
  )
1546
- ];
1547
-
1548
- return React.createElement("div", { className: "mcfg" },
1549
- React.createElement("div", { className: "mcfg-head" },
1550
- React.createElement("div", { className: "mcfg-title" }, "模型调参"),
1551
- React.createElement("div", { className: "mcfg-sub" },
1552
- "集中配置每个模型的可选推理档位、上下文窗口、最大输出和输入模态;「用途」页管理标题生成路由;「绘图」页管理绘图模型与直接绘图。" +
1553
- "修改即时保存;API 密钥与端点请在「模型」页配置。" +
1554
- (data.writable ? "" : "(当前部署为只读,无法保存。)")
1555
- )
1556
- ),
1557
- tabBar,
1558
- toast ? React.createElement("div", { className: "mcfg-toast " + (toast.kind === "ok" ? "mcfg-toast-ok" : "mcfg-toast-err") }, toast.text) : null,
1559
- tab === "models" ? modelsPanel : null,
1560
- tab === "purpose" ? purposePanel : null,
1561
- tab === "image" && ownView ? React.createElement(ImagePanel, {
1562
- remote: props.remote, call: props.call,
1563
- writable: !!data.writable, view: ownView, refresh: reload, showToast,
1564
- }) : null,
1565
- tab === "image" && !ownView ? React.createElement("div", { className: "mcfg-state mcfg-error" }, "宿主行未激活,无法读写绘图配置。") : null
1566
888
  );
889
+
890
+ return React.createElement(React.Fragment, null, toastLine, drawingSection, compatSection);
1567
891
  }
1568
892
 
893
+ /** Styles the official Models card extension (collapsed summary + one compact row per model). */
894
+ const COMPAT_CSS = `
895
+ .mcfg-compat{display:block;padding:10px 0 2px;border-top:1px dashed rgba(128,128,128,.35);margin-top:12px}
896
+ .mcfg-compat>summary{cursor:pointer;display:flex;align-items:center;gap:8px;list-style:none;font-size:12.5px;font-weight:600}
897
+ .mcfg-compat>summary::-webkit-details-marker{display:none}
898
+ .mcfg-compat>summary::before{content:"▸";font-size:10px;opacity:.6;width:10px}
899
+ .mcfg-compat[open]>summary::before{content:"▾"}
900
+ .mcfg-compat-hint{font-size:11.5px;font-weight:400;opacity:.55}
901
+ .mcfg-compat-list{display:flex;flex-direction:column;gap:2px;padding-top:8px}
902
+ .mcfg-compat-row{display:flex;flex-direction:column;gap:6px;padding:5px 8px;border-radius:8px}
903
+ .mcfg-compat-row-open{background:rgba(128,128,128,.08)}
904
+ .mcfg-compat-line{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-height:24px}
905
+ .mcfg-compat-name{font-size:12.5px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1 1 130px}
906
+ .mcfg-mini-select{font-size:12px;padding:3px 6px;border-radius:6px;border:1px solid rgba(128,128,128,.4);background:transparent;color:inherit}
907
+ .mcfg-mini-select:disabled{opacity:.5;cursor:not-allowed}
908
+ .mcfg-mini-button{font-size:12px;padding:3px 8px;border-radius:6px;border:1px solid rgba(128,128,128,.4);background:transparent;color:inherit;cursor:pointer;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
909
+ .mcfg-mini-button:hover{border-color:rgba(128,128,128,.65)}
910
+ .mcfg-mini-button:disabled{opacity:.5;cursor:not-allowed}
911
+ .mcfg-mini-button-on{border-color:rgba(80,140,255,.75);background:rgba(80,140,255,.12)}
912
+ .mcfg-draw-check{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:8px;font-size:12.5px;cursor:pointer}
913
+ .mcfg-draw-check:hover{background:rgba(128,128,128,.08)}
914
+ .mcfg-draw-check input{accent-color:rgb(80,140,255)}
915
+ .mcfg-draw-check-name{flex:1 1 130px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
916
+ .mcfg-draw-check-meta{font-size:11.5px;opacity:.55}
917
+ .mcfg-compat-levels{display:flex;flex-direction:column;gap:6px;padding:2px 0 4px 2px}
918
+ `;
919
+
1569
920
  // 2026-09-08 (DSH 0.1.2-rc.1): `ctx.connection.api` no longer exists — the
1570
- // client API moved to the `remote.*` service namespace (remote.llm.*,
1571
- // remote.settings.*, remote.session.*). Declaring them also defers this
1572
- // plugin until the mux connection is up, so the settings page never loads
1573
- // against a dead API surface.
1574
- const inject = ["slots", "remote", "remote.llm", "remote.settings", "remote.session", "remote.credentials", "locale", "connection"];
921
+ // client API lives on the `remote.*` service namespace. Declaring them also
922
+ // defers this plugin until the mux connection is up.
923
+ const inject = ["slots", "remote", "remote.llm", "remote.settings", "remote.session", "remote.credentials", "connection"];
1575
924
 
1576
925
  function apply(ctx) {
1577
926
  const style = document.createElement("style");
1578
927
  style.dataset.plugin = "dsh-plugin-model-tuning";
1579
- style.textContent = CSS;
928
+ style.textContent = CSS + COMPAT_CSS;
1580
929
  document.head.appendChild(style);
1581
930
  ctx.effect(() => () => { style.remove(); });
1582
931
 
1583
- if (ctx.locale) {
1584
- // 生产 dsh-client-locale 的 LOCALE_IDS 是 ["zh", "en"];multi-locale
1585
- // 形态一次写入 zh / en。ctx.effect 挂进 fiber 生命周期,HMR 再激活
1586
- // 不会重复注册。
1587
- ctx.effect(() => ctx.locale.register("dsh-plugin-model-tuning", {
1588
- en: {
1589
- "tab.models": "Model parameters",
1590
- "tab.purpose": "Purposes",
1591
- "tab.image": "Images",
1592
- "title.name": "Session titles",
1593
- "title.desc": "A session title comes from a separate auxiliary request and never changes the chat model. By default it inherits the session's provider / model.",
1594
- "title.inherit": "Inherit from the current session (default)",
1595
- "title.custom": "Use a specific model",
1596
- "title.scope": "Scope: applying this affects titles generated afterwards in this profile. Existing titles, manual renames, and the session's chat model are left untouched.",
1597
- "title.missing": "The model-tuning settings namespace is missing: the host half did not activate (no title provider registered). Check that the plugin is enabled and restart dsh.",
1598
- "title.save": "Save title settings",
1599
- },
1600
- zh: {
1601
- "tab.models": "模型参数",
1602
- "tab.purpose": "用途",
1603
- "tab.image": "绘图",
1604
- "title.name": "标题生成",
1605
- "title.desc": "会话标题由一次独立的辅助请求生成,不会改动对话模型。默认继承当前会话的 provider / model。",
1606
- "title.inherit": "继承当前会话(默认)",
1607
- "title.custom": "使用指定模型",
1608
- "title.scope": "生效范围:保存后对本 profile 之后生成的标题生效;不会改动已有标题、手动重命名,也不会改动会话的对话模型。",
1609
- "title.missing": "未检测到 model-tuning 设置命名空间:宿主半区未激活(标题 provider 未注册),请检查插件是否启用并重启 dsh。",
1610
- "title.save": "保存标题设置",
1611
- },
1612
- }));
1613
- }
1614
-
1615
932
  const remote = ctx.remote;
1616
- const wrap = (response, map) => response.ok
1617
- ? { result: { ok: true, value: map(response.value) } }
1618
- : { result: { ok: false, error: response.error } };
1619
- const api = {
1620
- llm: {
1621
- providers: async () => {
1622
- const response = await remote.llm.listConfigurableProviders();
1623
- return wrap(response, (value) => ({ providers: value }));
1624
- },
1625
- models: async () => {
1626
- const response = await remote.session.modelCatalog();
1627
- return wrap(response, (value) => ({ groups: value.groups }));
1628
- },
1629
- },
1630
- settings: {
1631
- describe: async () => {
1632
- const response = await remote.settings.describe();
1633
- return wrap(response, (value) => value);
1634
- },
1635
- mutate: async (args) => {
1636
- const response = await remote.settings.mutate(args.ns, args.ops, args.expectedRevision);
1637
- return wrap(response, () => ({}));
1638
- },
1639
- },
1640
- };
1641
933
  const timer = ctx.get("timer");
1642
-
1643
- // v2: the Remote call path for the 直接绘图 panel (ex image-generation
1644
- // client half). `connection.rpc.call('/api', 'imageGeneration/<method>')`.
1645
- const unwrap = (response) => { if (!response.ok) throw new Error(response.error?.message ?? "请求失败"); return response.value; };
934
+ const unwrap = (response) => {
935
+ if (!response.ok) throw new Error(response.error && response.error.message ? response.error.message : "请求失败");
936
+ return response.value;
937
+ };
1646
938
  const call = async (method, args) => unwrap(await ctx.connection.rpc.call("/api", `imageGeneration/${method}`, { args }));
1647
939
 
940
+ /** Provider metadata, its settings section and the model catalog, read together. */
941
+ async function compatSnapshot(providerId) {
942
+ const [providers, catalog, settings] = await Promise.all([
943
+ unwrap(await remote.llm.listConfigurableProviders()),
944
+ unwrap(await remote.session.modelCatalog()),
945
+ unwrap(await remote.settings.describe()),
946
+ ]);
947
+ const entry = providers.find((item) => item && item.provider === providerId);
948
+ if (!entry) throw new Error("该 provider 不可配置");
949
+ const namespaces = Array.isArray(settings.namespaces) ? settings.namespaces : [];
950
+ const nsView = namespaces.find((item) => item && item.ns === entry.settingsNs);
951
+ const ownView = namespaces.find((item) => item && item.ns === NS);
952
+ const groups = Array.isArray(catalog.groups) ? catalog.groups : [];
953
+ const group = groups.find((item) => item && item.id === providerId);
954
+ const profile = profileOf(nsView, entry.settingsPath);
955
+ return {
956
+ entry, nsView, ownView, group, profile,
957
+ adaptiveKey: adaptiveThinkingCompatKey(entry.settingsNs, profile),
958
+ writable: settings.writable !== false,
959
+ };
960
+ }
961
+
962
+ /** One provider card's compat rows: the switches and the catalog-route levels. */
963
+ async function loadCompat(providerId) {
964
+ const snapshot = await compatSnapshot(providerId);
965
+ const entry = snapshot.entry;
966
+ const nsView = snapshot.nsView;
967
+ const models = ((snapshot.group && snapshot.group.models) || []).map((model) => {
968
+ const efforts = model.reasoning && Array.isArray(model.reasoning.efforts) ? model.reasoning.efforts : [];
969
+ const levels = efforts.map((effort) => effort.id).filter((id) => id !== "off");
970
+ const stored = storedReasoningEfforts(entry, nsView, model.id);
971
+ const devRole = entry.settingsNs === "llm-pi-ai"
972
+ ? readCompatField(nsView, entry.settingsPath, model.id, "supportsDeveloperRole")
973
+ : undefined;
974
+ const adaptive = snapshot.adaptiveKey !== null
975
+ ? readCompatField(nsView, entry.settingsPath, model.id, snapshot.adaptiveKey)
976
+ : undefined;
977
+ const image = computeImageModelState(snapshot.ownView, snapshot.profile, model.id);
978
+ const hasBaseURL = typeof snapshot.profile?.baseURL === "string" && snapshot.profile.baseURL.trim() !== "";
979
+ return {
980
+ id: model.id,
981
+ name: model.name,
982
+ supportsDeveloperRole: devRole,
983
+ adaptiveThinking: adaptive === true,
984
+ adaptiveThinkingEditable: snapshot.adaptiveKey !== null && levels.length > 0,
985
+ // The official Models page owns reasoning for hand-declared routes;
986
+ // only installed-catalog routes need this override editor.
987
+ reasoningEditable: entry.settingsNs === "llm-pi-ai" && entry.declared !== true,
988
+ levels: stored === false ? [] : levels,
989
+ hasReasoningOverride: stored !== undefined,
990
+ // Drawing link: only a model whose provider carries a base URL can
991
+ // derive an OpenAI-Images endpoint (and reuse its credential ref).
992
+ imageAvailable: image.available && hasBaseURL,
993
+ imageLinked: image.hasEntry,
994
+ imageManaged: image.managed,
995
+ // Count only fields this plugin actually wrote: catalog-declared
996
+ // reasoning levels are the model's own capability, not an override.
997
+ hasOverride: devRole !== undefined || adaptive !== undefined || stored !== undefined,
998
+ };
999
+ });
1000
+ return { writable: snapshot.writable, models };
1001
+ }
1002
+
1003
+ /** Apply one compat edit through the provider's own settings namespace. */
1004
+ async function saveCompat(providerId, modelId, kind, value) {
1005
+ const snapshot = await compatSnapshot(providerId);
1006
+ const entry = snapshot.entry;
1007
+ const nsView = snapshot.nsView;
1008
+ if (kind === "imageLink") {
1009
+ // The drawing list lives in this plugin's own namespace; re-read it
1010
+ // right before writing so the plan and its revision come from one
1011
+ // document (the same discipline the drawing page uses).
1012
+ const freshSettings = unwrap(await remote.settings.describe());
1013
+ const namespaces = Array.isArray(freshSettings.namespaces) ? freshSettings.namespaces : [];
1014
+ const freshOwn = namespaces.find((item) => item && item.ns === NS);
1015
+ if (!freshOwn) throw new Error("model-tuning 宿主行未激活,无法写入绘图配置");
1016
+ const plan = value === true
1017
+ ? planImageLink(freshOwn, snapshot.profile, modelId, providerId)
1018
+ : planImageUnlink(freshOwn, modelId);
1019
+ if (!plan || !plan.models) throw new Error((plan && plan.reason) || "无法写入绘图配置");
1020
+ const response = await remote.settings.mutate(NS, [
1021
+ { op: "set", path: ["models"], value: plan.models },
1022
+ { op: "set", path: ["defaultModel"], value: plan.defaultModel || "" },
1023
+ ], freshOwn.revision);
1024
+ if (!response.ok) throw new Error(response.error && response.error.message ? response.error.message : "保存失败");
1025
+ return;
1026
+ }
1027
+ let write;
1028
+ if (kind === "supportsDeveloperRole" || kind === "adaptiveThinking") {
1029
+ if (value !== null && typeof value !== "boolean") throw new Error("取值必须是布尔值");
1030
+ const compatKey = kind === "supportsDeveloperRole" ? "supportsDeveloperRole" : snapshot.adaptiveKey;
1031
+ if (compatKey === null || compatKey === undefined) throw new Error("该路由不是 anthropic-messages,不能设置思考格式");
1032
+ write = computeCompatWriteOps(entry, nsView, modelId, compatKey, value);
1033
+ } else if (kind === "reasoningLevels") {
1034
+ const levels = Array.isArray(value) ? value : [];
1035
+ for (const level of levels) {
1036
+ if (REASONING_LEVELS.indexOf(level) < 0) throw new Error("未知的推理档位:" + level);
1037
+ }
1038
+ const payload = reasoningEffortsPayload(levels, storedReasoningEfforts(entry, nsView, modelId));
1039
+ write = computeWriteOps(entry, nsView, modelId, "reasoningEfforts", payload);
1040
+ } else {
1041
+ throw new Error("未知的修改类型");
1042
+ }
1043
+ if (!write) throw new Error("该 provider 不接受这个字段");
1044
+ const response = await remote.settings.mutate(write.ns, write.ops);
1045
+ if (!response.ok) throw new Error(response.error && response.error.message ? response.error.message : "保存失败");
1046
+ }
1047
+
1648
1048
  ctx.slots.inject("settings.section", () => ctx.slots.register(
1649
- { name: "settings.section", id: "model-tuning", order: 11, label: () => "模型调参" },
1650
- () => React.createElement(ModelTuningPage, { api, timer, remote, call }),
1049
+ { name: "settings.section", id: "model-tuning", order: 11, label: () => "绘图" },
1050
+ () => React.createElement(DrawingPage, { remote, timer, call }),
1051
+ ));
1052
+
1053
+ // Official seat for plugins distributed outside the repository: one
1054
+ // registration under the pi-ai settings namespace receives every pi-ai
1055
+ // provider card (shipped, added and hand-declared alike).
1056
+ ctx.slots.inject("settings.models.provider-card", () => ctx.slots.register(
1057
+ {
1058
+ name: "settings.models.provider-card",
1059
+ key: "llm-pi-ai",
1060
+ inject: () => ({ load: loadCompat, save: saveCompat }),
1061
+ },
1062
+ CompatCard,
1651
1063
  ));
1652
1064
  }
1653
1065