dsh-plugin-model-tuning 2.0.1
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.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/client.js +1550 -0
- package/cordis.patch.yml +24 -0
- package/core.js +218 -0
- package/index.js +366 -0
- package/package.json +53 -0
package/client.js
ADDED
|
@@ -0,0 +1,1550 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-plugin-model-tuning",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
const React = require("react");
|
|
8
|
+
|
|
9
|
+
const CSS = `
|
|
10
|
+
.mcfg{display:flex;flex-direction:column;gap:16px;padding:4px 0 24px;color:inherit}
|
|
11
|
+
.mcfg-head{display:flex;flex-direction:column;gap:4px}
|
|
12
|
+
.mcfg-title{font-size:15px;font-weight:600}
|
|
13
|
+
.mcfg-sub{font-size:12.5px;opacity:.72;line-height:1.5}
|
|
14
|
+
.mcfg-searchrow{display:flex;align-items:center;gap:8px}
|
|
15
|
+
.mcfg-search{flex:1;font-size:13px;padding:7px 10px;border-radius:8px;border:1px solid rgba(128,128,128,.4);background:transparent;color:inherit}
|
|
16
|
+
.mcfg-search:focus{outline:none;border-color:rgba(80,140,255,.7)}
|
|
17
|
+
.mcfg-count{font-size:12px;opacity:.6;white-space:nowrap}
|
|
18
|
+
.mcfg-state{padding:24px 8px;font-size:13px;opacity:.7}
|
|
19
|
+
.mcfg-error{color:#d5533d;opacity:1}
|
|
20
|
+
.mcfg-toast{position:sticky;top:8px;z-index:20;align-self:flex-start;padding:6px 12px;border-radius:8px;font-size:12.5px}
|
|
21
|
+
.mcfg-toast-ok{background:rgba(46,160,67,.18)}
|
|
22
|
+
.mcfg-toast-err{background:rgba(213,83,61,.18)}
|
|
23
|
+
.mcfg-provider{display:flex;flex-direction:column;gap:10px}
|
|
24
|
+
.mcfg-provider-name{font-size:13px;font-weight:600;display:flex;align-items:baseline;gap:8px}
|
|
25
|
+
.mcfg-provider-id{font-size:11px;font-weight:400;opacity:.55;font-family:ui-monospace,SFMono-Regular,monospace}
|
|
26
|
+
.mcfg-model{display:flex;flex-direction:column;gap:10px;padding:12px;border:1px solid rgba(128,128,128,.28);border-radius:10px}
|
|
27
|
+
.mcfg-model-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
|
28
|
+
.mcfg-model-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
|
|
29
|
+
.mcfg-model-name{font-size:13.5px;font-weight:600}
|
|
30
|
+
.mcfg-model-id{font-size:11.5px;opacity:.55;font-family:ui-monospace,SFMono-Regular,monospace}
|
|
31
|
+
.mcfg-desc{font-size:12px;opacity:.6;line-height:1.4}
|
|
32
|
+
.mcfg-reasoning{display:flex;flex-direction:column;gap:6px}
|
|
33
|
+
.mcfg-levels{display:flex;flex-wrap:wrap;gap:6px 14px}
|
|
34
|
+
.mcfg-level{display:inline-flex;align-items:center;gap:5px;font-size:12.5px;cursor:pointer}
|
|
35
|
+
.mcfg-level input{accent-color:rgb(80,140,255)}
|
|
36
|
+
.mcfg-chip{font-size:11px;padding:1px 8px;border-radius:999px;background:rgba(128,128,128,.16)}
|
|
37
|
+
.mcfg-chip-on{background:rgba(46,160,67,.22)}
|
|
38
|
+
.mcfg-note{font-size:11.5px;opacity:.55}
|
|
39
|
+
.mcfg-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px}
|
|
40
|
+
.mcfg-field{display:flex;flex-direction:column;gap:4px;min-width:0}
|
|
41
|
+
/* 选择框与自定义输入框竖排:横排时 .mcfg-select 的 width:100% 会吃掉整行
|
|
42
|
+
flex 空间,.mcfg-custom 被压到约 18px 宽(几乎看不见)。 */
|
|
43
|
+
.mcfg-fieldrow{display:flex;flex-direction:column;gap:6px;align-items:stretch}
|
|
44
|
+
.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
|
+
.mcfg-custom{width:100%}
|
|
46
|
+
.mcfg-input:disabled,.mcfg-select:disabled{opacity:.5;cursor:not-allowed}
|
|
47
|
+
/* 模型参数 / 用途 / 绘图 三个页签:v2 起 title-model 与 image-generation
|
|
48
|
+
并入本插件,标题路由与绘图模型列表都是本命名空间自己的字段。 */
|
|
49
|
+
.mcfg-tabs{display:flex;gap:6px;flex-wrap:wrap}
|
|
50
|
+
.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
|
+
.mcfg-tab:hover{border-color:rgba(128,128,128,.6)}
|
|
52
|
+
.mcfg-tab-on{border-color:rgba(80,140,255,.8);background:rgba(80,140,255,.14);font-weight:600}
|
|
53
|
+
.mcfg-purpose{display:flex;flex-direction:column;gap:12px;padding:14px;border:1px solid rgba(128,128,128,.28);border-radius:10px}
|
|
54
|
+
.mcfg-purpose-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
|
|
55
|
+
.mcfg-purpose-name{font-size:13.5px;font-weight:600}
|
|
56
|
+
.mcfg-radio{display:flex;flex-direction:column;gap:8px}
|
|
57
|
+
.mcfg-radio-row{display:flex;align-items:flex-start;gap:8px;font-size:12.5px;cursor:pointer}
|
|
58
|
+
.mcfg-radio-row input{margin-top:2px;accent-color:rgb(80,140,255)}
|
|
59
|
+
.mcfg-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
60
|
+
.mcfg-save{font-size:12.5px;padding:6px 14px;border-radius:8px;border:1px solid rgba(80,140,255,.6);background:transparent;color:inherit;cursor:pointer}
|
|
61
|
+
.mcfg-save:disabled{opacity:.5;cursor:not-allowed}
|
|
62
|
+
.mcfg-select:focus,.mcfg-input:focus{outline:none;border-color:rgba(80,140,255,.7)}
|
|
63
|
+
.mcfg-img-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px}
|
|
64
|
+
.mcfg-warn{font-size:11.5px;line-height:1.5;color:#c9722c}
|
|
65
|
+
/* ── 绘图页(ex dsh-plugin-image-generation 的面板样式,统一前缀) ── */
|
|
66
|
+
.mcfg-page fieldset{border:1px solid #8886;border-radius:10px;padding:14px;min-width:0}
|
|
67
|
+
.mcfg-page legend{font-weight:600;padding:0 6px}
|
|
68
|
+
.mcfg-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px}
|
|
69
|
+
.mcfg-field2{display:flex;flex-direction:column;gap:5px;font-size:12px;margin:6px 0}
|
|
70
|
+
.mcfg-page input,.mcfg-page select,.mcfg-page textarea{box-sizing:border-box;width:100%;padding:8px;border:1px solid #8888;border-radius:7px;background:transparent;color:inherit;font:inherit}
|
|
71
|
+
.mcfg-page textarea{min-height:100px;resize:vertical}
|
|
72
|
+
.mcfg-page button,.mcfg-card button,.mcfg-card a{padding:7px 11px;border:1px solid #8888;border-radius:7px;background:transparent;color:inherit;cursor:pointer;font:inherit}
|
|
73
|
+
.mcfg-page button:disabled{opacity:.5;cursor:default}
|
|
74
|
+
.mcfg-actions2{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
|
|
75
|
+
.mcfg-status{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px}
|
|
76
|
+
.mcfg-gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}
|
|
77
|
+
.mcfg-card{min-width:0;border:1px solid #8885;border-radius:8px;padding:10px}
|
|
78
|
+
.mcfg-card img{display:block;max-width:100%;max-height:420px;object-fit:contain;margin:auto}
|
|
79
|
+
.mcfg-card p{overflow-wrap:anywhere;font-size:12px}
|
|
80
|
+
.mcfg-card a{display:inline-block;text-decoration:none}
|
|
81
|
+
.mcfg-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 0;flex-wrap:wrap}
|
|
82
|
+
.mcfg-model-list{display:flex;flex-direction:column;gap:5px;max-height:240px;overflow:auto;margin:8px 0}
|
|
83
|
+
.mcfg-model-list button{text-align:left;overflow-wrap:anywhere}
|
|
84
|
+
.mcfg-model-list button[aria-pressed=true]{border-color:#3987d7;background:#3987d71a}
|
|
85
|
+
.mcfg-page details{margin:12px 0}
|
|
86
|
+
.mcfg-page summary{cursor:pointer;padding:6px 0}
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
/**
|
|
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".
|
|
94
|
+
*/
|
|
95
|
+
const NS = "model-tuning";
|
|
96
|
+
|
|
97
|
+
/** Provenance marker on an image entry the 模型参数 tab linked. */
|
|
98
|
+
const IMAGE_SOURCE_KEY = "source";
|
|
99
|
+
const IMAGE_API = "openai-images";
|
|
100
|
+
|
|
101
|
+
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
|
+
|
|
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": "文本 + 图像" };
|
|
144
|
+
|
|
145
|
+
function formatCapacity(n) {
|
|
146
|
+
if (n == null || typeof n !== "number" || !Number.isFinite(n)) return "";
|
|
147
|
+
if (n % 1000000 === 0) return String(n / 1000000) + "M";
|
|
148
|
+
if (n % 1000 === 0) return String(n / 1000) + "K";
|
|
149
|
+
return String(n);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseCapacity(text) {
|
|
153
|
+
if (typeof text !== "string") return null;
|
|
154
|
+
const match = text.trim().match(/^(\d+(?:\.\d+)?)\s*([kKmM]?)$/);
|
|
155
|
+
if (!match) return null;
|
|
156
|
+
const base = parseFloat(match[1]);
|
|
157
|
+
const suffix = match[2].toUpperCase();
|
|
158
|
+
const value = suffix === "K" ? base * 1000 : suffix === "M" ? base * 1000000 : base;
|
|
159
|
+
if (!Number.isInteger(value) || value <= 0) return null;
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function at(value, path) {
|
|
164
|
+
let current = value;
|
|
165
|
+
for (const part of path || []) {
|
|
166
|
+
if (current == null) return undefined;
|
|
167
|
+
current = current[part];
|
|
168
|
+
}
|
|
169
|
+
return current;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The provider's own settings section, where its endpoint and key live. */
|
|
173
|
+
function profileOf(nsView, settingsPath) {
|
|
174
|
+
const profile = at(nsView ? nsView.value : undefined, settingsPath || []);
|
|
175
|
+
return profile && typeof profile === "object" ? profile : undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function findNamespace(settingsView, ns) {
|
|
179
|
+
const namespaces = settingsView && Array.isArray(settingsView.namespaces) ? settingsView.namespaces : [];
|
|
180
|
+
return namespaces.find((item) => item && item.ns === ns);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** v2: the page's own namespace view — always present once the host row is up. */
|
|
184
|
+
function ownNsView(settingsView) {
|
|
185
|
+
return findNamespace(settingsView, NS);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Derive one image entry from the chat provider that already holds the
|
|
190
|
+
* endpoint and credential reference, so linking never asks the user to
|
|
191
|
+
* retype either one.
|
|
192
|
+
*/
|
|
193
|
+
function deriveImageEntry(profile, modelId) {
|
|
194
|
+
const base = typeof profile?.baseURL === "string" ? profile.baseURL.trim() : "";
|
|
195
|
+
return {
|
|
196
|
+
model: modelId,
|
|
197
|
+
endpoint: base ? base.replace(/\/+$/, "") + "/images/generations" : "",
|
|
198
|
+
apiKeyEnv: typeof profile?.apiKeyEnv === "string" ? profile.apiKeyEnv.trim() : "",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Whether an entry is ours, in entries stored before the provider was recorded. */
|
|
203
|
+
function isManagedEntry(entry, modelId) {
|
|
204
|
+
const source = entry && entry[IMAGE_SOURCE_KEY];
|
|
205
|
+
if (!source || typeof source !== "object" || source.model !== modelId) return false;
|
|
206
|
+
return source.provider === undefined || source.provider === "" || typeof source.provider === "string";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The per-model view of the link, shared by the checkbox and its note. */
|
|
210
|
+
function computeImageModelState(ownView, profile, modelId) {
|
|
211
|
+
if (!ownView) return { available: false, managed: false, hasEntry: false, entry: null };
|
|
212
|
+
const models = imageModelsOf(ownView);
|
|
213
|
+
const entry = models.find((item) => item && typeof item === "object" && item.model === modelId) ?? null;
|
|
214
|
+
const derived = deriveImageEntry(profile, modelId);
|
|
215
|
+
return {
|
|
216
|
+
available: true,
|
|
217
|
+
managed: !!entry && isManagedEntry(entry, modelId),
|
|
218
|
+
hasEntry: !!entry,
|
|
219
|
+
endpoint: entry ? entry.endpoint : derived.endpoint,
|
|
220
|
+
apiKeyEnv: entry ? entry.apiKeyEnv : derived.apiKeyEnv,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function imageModelsOf(ownView) {
|
|
225
|
+
const models = ownView ? ownView.value?.models : undefined;
|
|
226
|
+
return Array.isArray(models) ? models : [];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeImageEndpoint(value) {
|
|
230
|
+
return typeof value === "string" ? value.trim().replace(/\/+$/, "").replace(/\/images\/generations$/, "") + "/images/generations" : "";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** `/v1/images/generations` is the OpenAI address, not the configured one. */
|
|
234
|
+
function acceptableImageEndpoint(value) {
|
|
235
|
+
try {
|
|
236
|
+
const url = new URL(value);
|
|
237
|
+
if (!["http:", "https:"].includes(url.protocol)) return false;
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
return !/api\.openai\.com/i.test(value);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The 绘图 schema validates `^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`. */
|
|
245
|
+
function sanitizeImageId(value) {
|
|
246
|
+
return String(value).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^[-_]+|[-_]+$/g, "").slice(0, 64);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function uniqueImageId(modelId, models) {
|
|
250
|
+
const taken = new Set(models.filter((item) => item && typeof item.id === "string").map((item) => item.id));
|
|
251
|
+
const base = sanitizeImageId(modelId) || "image-model";
|
|
252
|
+
let id = base;
|
|
253
|
+
for (let i = 2; taken.has(id); i++) id = sanitizeImageId(base.slice(0, 60) + "-" + i);
|
|
254
|
+
return id;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Everything the image list needs for an incremental write: only the entry
|
|
259
|
+
* the 模型参数 tab owns plus the default-model change it implies.
|
|
260
|
+
*/
|
|
261
|
+
function planImageLink(ownView, profile, modelId, providerId) {
|
|
262
|
+
const derived = deriveImageEntry(profile, modelId);
|
|
263
|
+
const endpoint = normalizeImageEndpoint(derived.endpoint);
|
|
264
|
+
if (!acceptableImageEndpoint(endpoint)) {
|
|
265
|
+
return { models: null, reason: "该 provider 没有可用的供应商地址(api.baseURL),请先在「模型」页填写后再勾选。" };
|
|
266
|
+
}
|
|
267
|
+
const models = imageModelsOf(ownView).slice();
|
|
268
|
+
const index = models.findIndex((item) => item && typeof item === "object" && item.model === modelId);
|
|
269
|
+
const owned = index >= 0
|
|
270
|
+
? { ...models[index], model: modelId, endpoint, api: IMAGE_API, [IMAGE_SOURCE_KEY]: { provider: providerId, model: modelId } }
|
|
271
|
+
: { id: uniqueImageId(modelId, models), name: modelId, model: modelId, endpoint, api: IMAGE_API, timeoutSeconds: 300 };
|
|
272
|
+
if (index >= 0 && !owned.apiKeyEnv && derived.apiKeyEnv) owned.apiKeyEnv = derived.apiKeyEnv;
|
|
273
|
+
if (index < 0) {
|
|
274
|
+
if (derived.apiKeyEnv) owned.apiKeyEnv = derived.apiKeyEnv;
|
|
275
|
+
owned[IMAGE_SOURCE_KEY] = { provider: providerId, model: modelId };
|
|
276
|
+
}
|
|
277
|
+
const next = index >= 0 ? models.map((item, i) => (i === index ? owned : item)) : [...models, owned];
|
|
278
|
+
const currentDefault = ownView.value?.defaultModel;
|
|
279
|
+
const defaultModel = typeof currentDefault === "string" && currentDefault ? currentDefault : owned.id;
|
|
280
|
+
return { models: next, defaultModel };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Unlink removes only the entry the 模型参数 tab created (has provenance). */
|
|
284
|
+
function planImageUnlink(ownView, modelId) {
|
|
285
|
+
const models = imageModelsOf(ownView);
|
|
286
|
+
const entry = models.find((item) => item && typeof item === "object" && item.model === modelId);
|
|
287
|
+
if (!entry) return { models: null, reason: "该模型未链接到绘图配置。" };
|
|
288
|
+
// v2: a hand-authored entry is not this checkbox's to remove — deleting it
|
|
289
|
+
// here would surprise an entry the user built on the 绘图 tab. It can be
|
|
290
|
+
// deleted there directly.
|
|
291
|
+
if (!isManagedEntry(entry, modelId)) return { models: null, reason: "该配置在「绘图」页手工创建,请在那边删除。" };
|
|
292
|
+
const next = models.filter((item) => item !== entry);
|
|
293
|
+
const currentDefault = ownView.value?.defaultModel;
|
|
294
|
+
const defaultModel = currentDefault === entry.id ? (next[0]?.id ?? "") : currentDefault;
|
|
295
|
+
return { models: next, defaultModel };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function readModelField(nsView, settingsPath, modelId, field) {
|
|
299
|
+
if (!nsView) return undefined;
|
|
300
|
+
const profile = at(nsView.value, settingsPath);
|
|
301
|
+
if (!profile || typeof profile !== "object") return undefined;
|
|
302
|
+
const models = Array.isArray(profile.models) ? profile.models : [];
|
|
303
|
+
const index = models.findIndex((m) => m && typeof m === "object" && m.id === modelId);
|
|
304
|
+
if (index >= 0) return models[index][field];
|
|
305
|
+
const overrides = profile.modelOverrides;
|
|
306
|
+
if (overrides && typeof overrides === "object" && overrides[modelId] && typeof overrides[modelId] === "object") {
|
|
307
|
+
return overrides[modelId][field];
|
|
308
|
+
}
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function readCompatField(nsView, settingsPath, modelId, compatKey) {
|
|
313
|
+
if (!nsView) return undefined;
|
|
314
|
+
const profile = at(nsView.value, settingsPath);
|
|
315
|
+
if (!profile || typeof profile !== "object") return undefined;
|
|
316
|
+
const models = Array.isArray(profile.models) ? profile.models : [];
|
|
317
|
+
const index = models.findIndex((m) => m && typeof m === "object" && m.id === modelId);
|
|
318
|
+
if (index >= 0) {
|
|
319
|
+
const compat = models[index].compat;
|
|
320
|
+
return compat && typeof compat === "object" ? compat[compatKey] : undefined;
|
|
321
|
+
}
|
|
322
|
+
const overrides = profile.modelOverrides;
|
|
323
|
+
if (overrides && typeof overrides === "object" && overrides[modelId] && typeof overrides[modelId] === "object") {
|
|
324
|
+
const compat = overrides[modelId].compat;
|
|
325
|
+
return compat && typeof compat === "object" ? compat[compatKey] : undefined;
|
|
326
|
+
}
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function computeWriteOps(entry, nsView, modelId, field, value) {
|
|
331
|
+
const ns = entry.settingsNs;
|
|
332
|
+
const settingsPath = entry.settingsPath || [];
|
|
333
|
+
const profile = at(nsView ? nsView.value : undefined, settingsPath);
|
|
334
|
+
const models = (profile && Array.isArray(profile.models)) ? profile.models : [];
|
|
335
|
+
const index = models.findIndex((m) => m && typeof m === "object" && m.id === modelId);
|
|
336
|
+
const basePath = [...settingsPath];
|
|
337
|
+
const usesOverrides = ns === "llm-pi-ai";
|
|
338
|
+
|
|
339
|
+
if (value === null) {
|
|
340
|
+
if (index >= 0) {
|
|
341
|
+
const next = models.map((m, i) => {
|
|
342
|
+
if (i !== index) return m;
|
|
343
|
+
const copy = { ...m };
|
|
344
|
+
delete copy[field];
|
|
345
|
+
return copy;
|
|
346
|
+
});
|
|
347
|
+
return { ns, ops: [{ op: "set", path: [...basePath, "models"], value: next }] };
|
|
348
|
+
}
|
|
349
|
+
if (usesOverrides) {
|
|
350
|
+
return { ns, ops: [{ op: "unset", path: [...basePath, "modelOverrides", modelId, field] }] };
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (index >= 0) {
|
|
356
|
+
const next = models.map((m, i) => (i === index ? { ...m, [field]: value } : m));
|
|
357
|
+
return { ns, ops: [{ op: "set", path: [...basePath, "models"], value: next }] };
|
|
358
|
+
}
|
|
359
|
+
if (models.length > 0) {
|
|
360
|
+
return { ns, ops: [{ op: "set", path: [...basePath, "models"], value: [...models, { id: modelId, [field]: value }] }] };
|
|
361
|
+
}
|
|
362
|
+
if (usesOverrides) {
|
|
363
|
+
return { ns, ops: [{ op: "set", path: [...basePath, "modelOverrides", modelId, field], value }] };
|
|
364
|
+
}
|
|
365
|
+
return { ns, ops: [{ op: "set", path: [...basePath, "models"], value: [{ id: modelId, [field]: value }] }] };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function computeCompatWriteOps(entry, nsView, modelId, compatKey, value) {
|
|
369
|
+
const ns = entry.settingsNs;
|
|
370
|
+
const settingsPath = entry.settingsPath || [];
|
|
371
|
+
const profile = at(nsView ? nsView.value : undefined, settingsPath);
|
|
372
|
+
const models = (profile && Array.isArray(profile.models)) ? profile.models : [];
|
|
373
|
+
const index = models.findIndex((m) => m && typeof m === "object" && m.id === modelId);
|
|
374
|
+
const basePath = [...settingsPath];
|
|
375
|
+
const usesOverrides = ns === "llm-pi-ai";
|
|
376
|
+
|
|
377
|
+
if (index >= 0) {
|
|
378
|
+
const next = models.map((m, i) => {
|
|
379
|
+
if (i !== index) return m;
|
|
380
|
+
const copy = { ...m };
|
|
381
|
+
const compat = copy.compat && typeof copy.compat === "object" ? { ...copy.compat } : {};
|
|
382
|
+
if (value === null) {
|
|
383
|
+
delete compat[compatKey];
|
|
384
|
+
} else {
|
|
385
|
+
compat[compatKey] = value;
|
|
386
|
+
}
|
|
387
|
+
if (Object.keys(compat).length === 0) {
|
|
388
|
+
delete copy.compat;
|
|
389
|
+
} else {
|
|
390
|
+
copy.compat = compat;
|
|
391
|
+
}
|
|
392
|
+
return copy;
|
|
393
|
+
});
|
|
394
|
+
return { ns, ops: [{ op: "set", path: [...basePath, "models"], value: next }] };
|
|
395
|
+
}
|
|
396
|
+
if (models.length > 0) {
|
|
397
|
+
const entryObj = { id: modelId };
|
|
398
|
+
if (value !== null) entryObj.compat = { [compatKey]: value };
|
|
399
|
+
return { ns, ops: [{ op: "set", path: [...basePath, "models"], value: [...models, entryObj] }] };
|
|
400
|
+
}
|
|
401
|
+
if (usesOverrides) {
|
|
402
|
+
const compatPath = [...basePath, "modelOverrides", modelId, "compat"];
|
|
403
|
+
const exists = at(nsView ? nsView.value : undefined, compatPath);
|
|
404
|
+
const compat = exists && typeof exists === "object" ? { ...exists } : {};
|
|
405
|
+
if (value === null) {
|
|
406
|
+
delete compat[compatKey];
|
|
407
|
+
} else {
|
|
408
|
+
compat[compatKey] = value;
|
|
409
|
+
}
|
|
410
|
+
if (Object.keys(compat).length === 0) {
|
|
411
|
+
return { ns, ops: [{ op: "unset", path: compatPath }] };
|
|
412
|
+
}
|
|
413
|
+
return { ns, ops: [{ op: "set", path: compatPath, value: compat }] };
|
|
414
|
+
}
|
|
415
|
+
const entryObj = { id: modelId };
|
|
416
|
+
if (value !== null) entryObj.compat = { [compatKey]: value };
|
|
417
|
+
return { ns, ops: [{ op: "set", path: [...basePath, "models"], value: [entryObj] }] };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The modality field one provider namespace records, or undefined when it records none. */
|
|
421
|
+
function modalityFieldFor(ns) {
|
|
422
|
+
return MODALITY_FIELD_BY_NS[ns];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Map a raw modality list to the control's tri-state. Absent and empty both mean `auto`. */
|
|
426
|
+
function modalityChoice(raw) {
|
|
427
|
+
if (!Array.isArray(raw) || raw.length === 0) return "auto";
|
|
428
|
+
return raw.indexOf("image") >= 0 ? "text-image" : "text";
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Explain what the current state costs, in the terms the harness applies.
|
|
433
|
+
*/
|
|
434
|
+
function modalityNote(choice, model) {
|
|
435
|
+
if (choice === "text-image") {
|
|
436
|
+
return "图像输入已开启:贴图与 read_image 可用,下一次请求即生效(无需重启)。";
|
|
437
|
+
}
|
|
438
|
+
if (choice === "text") {
|
|
439
|
+
return "已显式声明为纯文本:附图会在发送前被拒绝,read_image 同样拒绝。";
|
|
440
|
+
}
|
|
441
|
+
if (!model.declared) {
|
|
442
|
+
return "自动:由该 provider 内置的模型目录决定,本页读不到目录内容,不做推测。";
|
|
443
|
+
}
|
|
444
|
+
const route = model.routeDefaultInput;
|
|
445
|
+
if (route && route.indexOf("image") >= 0) {
|
|
446
|
+
return "自动:本路由未逐个声明,跟随路由 defaultInput,其中含图像。";
|
|
447
|
+
}
|
|
448
|
+
return "自动:手写路由没有模型目录可查,落到路由 defaultInput(未设置即为纯文本),"
|
|
449
|
+
+ "所以附图会被拒绝。端点确实收图时请选「文本 + 图像」。";
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Ops for one modality choice. Non-DeepSeek namespaces write a single
|
|
454
|
+
* field; DeepSeek additionally drops the image request budgets its schema
|
|
455
|
+
* refuses beside a text-only declaration.
|
|
456
|
+
*/
|
|
457
|
+
function computeModalityWriteOps(entry, nsView, modelId, choice) {
|
|
458
|
+
const field = modalityFieldFor(entry.settingsNs);
|
|
459
|
+
if (!field) return null;
|
|
460
|
+
const value = choice === "text-image" ? ["text", "image"] : choice === "text" ? ["text"] : null;
|
|
461
|
+
const write = computeWriteOps(entry, nsView, modelId, field, value);
|
|
462
|
+
if (!write || entry.settingsNs !== "llm-deepseek" || choice === "text-image") return write;
|
|
463
|
+
const profile = at(nsView ? nsView.value : undefined, entry.settingsPath || []);
|
|
464
|
+
const models = (profile && Array.isArray(profile.models)) ? profile.models : [];
|
|
465
|
+
const current = models.find((m) => m && typeof m === "object" && m.id === modelId) || {};
|
|
466
|
+
const stale = DEEPSEEK_IMAGE_LIMIT_FIELDS.filter((name) => current[name] !== undefined);
|
|
467
|
+
if (stale.length === 0) return write;
|
|
468
|
+
const modelsOp = write.ops[0];
|
|
469
|
+
modelsOp.value = modelsOp.value.map((m) => {
|
|
470
|
+
if (!m || typeof m !== "object" || m.id !== modelId) return m;
|
|
471
|
+
const copy = { ...m };
|
|
472
|
+
for (const name of stale) delete copy[name];
|
|
473
|
+
return copy;
|
|
474
|
+
});
|
|
475
|
+
return write;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── 用途:标题生成(v2:本插件自己的字段,title* 键) ────────────────────
|
|
479
|
+
/** Same vocabulary as the host schema (`inherit` is the default). */
|
|
480
|
+
const TITLE_MODES = ["inherit", "custom"];
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Title-request budget. Defaults mirror the shipped dsh-base row, but every
|
|
484
|
+
* field is user-editable from this page — a reasoning model that answers
|
|
485
|
+
* only after a long think needs a larger `maxOutputTokens`, and that choice
|
|
486
|
+
* belongs to the user, not to a constant in the plugin.
|
|
487
|
+
*/
|
|
488
|
+
const TITLE_BUDGET_FIELDS = [
|
|
489
|
+
{ key: "targetWords", label: "目标词数", hint: "非中日韩语言的目标词数", fallback: 5 },
|
|
490
|
+
{ key: "targetCjkCharacters", label: "目标汉字数", hint: "中文 / 日文标题的目标字数", fallback: 10 },
|
|
491
|
+
{ key: "maxInputBytes", label: "输入上限(字节)", hint: "首条消息送进标题请求的截断上限", fallback: 4096 },
|
|
492
|
+
{ key: "maxOutputTokens", label: "输出上限(token)", hint: "推理模型会先思考再作答,需留足预算(例如 1024);直接作答的模型 64 足够", fallback: 64 },
|
|
493
|
+
{ key: "timeoutMs", label: "超时(毫秒)", hint: "整次标题请求的超时", fallback: 60000 },
|
|
494
|
+
];
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Read the title purpose view out of our own namespace view.
|
|
498
|
+
* @param {object|undefined} settingsView - `remote.settings.describe()` value.
|
|
499
|
+
* @returns the title view; `available` mirrors whether our host row is up.
|
|
500
|
+
*/
|
|
501
|
+
function titleViewOf(settingsView) {
|
|
502
|
+
const budget = {};
|
|
503
|
+
for (const field of TITLE_BUDGET_FIELDS) budget[field.key] = field.fallback;
|
|
504
|
+
const view = ownNsView(settingsView);
|
|
505
|
+
if (!view) return { available: false, mode: "inherit", provider: null, model: null, revision: null, ...budget };
|
|
506
|
+
const value = view.value && typeof view.value === "object" ? view.value : {};
|
|
507
|
+
const mode = TITLE_MODES.indexOf(value.titleMode) >= 0 ? value.titleMode : "inherit";
|
|
508
|
+
for (const field of TITLE_BUDGET_FIELDS) {
|
|
509
|
+
const raw = value[field.key];
|
|
510
|
+
if (typeof raw === "number" && Number.isInteger(raw) && raw > 0) budget[field.key] = raw;
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
available: true,
|
|
514
|
+
mode,
|
|
515
|
+
provider: typeof value.titleProvider === "string" && value.titleProvider !== "" ? value.titleProvider : null,
|
|
516
|
+
model: typeof value.titleModel === "string" && value.titleModel !== "" ? value.titleModel : null,
|
|
517
|
+
revision: typeof view.revision === "number" ? view.revision : null,
|
|
518
|
+
...budget,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Plan the write for one title purpose edit.
|
|
524
|
+
*
|
|
525
|
+
* Always returns the COMPLETE field set: switching back to `inherit` must
|
|
526
|
+
* clear a previously chosen route instead of leaving it behind in the user
|
|
527
|
+
* section, where it would silently stay authoritative for a later switch.
|
|
528
|
+
* The budget is written the same way, so a field the user clears returns to
|
|
529
|
+
* the shipped default rather than keeping a stale value.
|
|
530
|
+
* @param next - `{ mode, provider, model, ...budget }` as chosen in the UI.
|
|
531
|
+
* @returns the mutate request body.
|
|
532
|
+
*/
|
|
533
|
+
function planTitleWrite(next) {
|
|
534
|
+
const ops = [];
|
|
535
|
+
for (const field of TITLE_BUDGET_FIELDS) {
|
|
536
|
+
const raw = next ? next[field.key] : undefined;
|
|
537
|
+
const value = raw === undefined || raw === null || raw === "" ? field.fallback : Number(raw);
|
|
538
|
+
if (!Number.isInteger(value) || value < 1) throw new Error(`「${field.label}」必须是正整数`);
|
|
539
|
+
ops.push({ op: "set", path: [field.key], value });
|
|
540
|
+
}
|
|
541
|
+
const mode = next && next.mode === "custom" ? "custom" : "inherit";
|
|
542
|
+
if (mode === "inherit") {
|
|
543
|
+
ops.push(
|
|
544
|
+
{ op: "unset", path: ["titleMode"] },
|
|
545
|
+
{ op: "unset", path: ["titleProvider"] },
|
|
546
|
+
{ op: "unset", path: ["titleModel"] },
|
|
547
|
+
);
|
|
548
|
+
} else {
|
|
549
|
+
ops.push(
|
|
550
|
+
{ op: "set", path: ["titleMode"], value: "custom" },
|
|
551
|
+
{ op: "set", path: ["titleProvider"], value: String(next.provider || "") },
|
|
552
|
+
{ op: "set", path: ["titleModel"], value: String(next.model || "") },
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
return { ns: NS, ops };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function CapacityField(props) {
|
|
559
|
+
const value = props.value;
|
|
560
|
+
const options = props.options;
|
|
561
|
+
const matched = value != null ? options.find((o) => o.value === value) : undefined;
|
|
562
|
+
const [mode, setMode] = React.useState(matched ? "option" : "custom");
|
|
563
|
+
const [text, setText] = React.useState(value != null ? formatCapacity(value) : "");
|
|
564
|
+
|
|
565
|
+
React.useEffect(() => {
|
|
566
|
+
const m = value != null ? options.find((o) => o.value === value) : undefined;
|
|
567
|
+
setMode(m ? "option" : "custom");
|
|
568
|
+
setText(value != null ? formatCapacity(value) : "");
|
|
569
|
+
}, [value]);
|
|
570
|
+
|
|
571
|
+
function commit() {
|
|
572
|
+
const parsed = parseCapacity(text);
|
|
573
|
+
if (parsed == null) {
|
|
574
|
+
props.onToast("err", "请输入正整数,可用 K/M 后缀(如 256K、1M)");
|
|
575
|
+
setText(value != null ? formatCapacity(value) : "");
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (parsed === value) return;
|
|
579
|
+
props.onApply(props.provider, props.model, props.kind, parsed);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return React.createElement("label", { className: "mcfg-field" },
|
|
583
|
+
React.createElement("span", { className: "mcfg-label" }, props.label),
|
|
584
|
+
React.createElement("div", { className: "mcfg-fieldrow" },
|
|
585
|
+
React.createElement("select", {
|
|
586
|
+
className: "mcfg-select",
|
|
587
|
+
disabled: props.disabled,
|
|
588
|
+
value: mode === "option" ? String(value) : "__custom__",
|
|
589
|
+
onChange: (e) => {
|
|
590
|
+
if (e.target.value === "__custom__") {
|
|
591
|
+
setMode("custom");
|
|
592
|
+
setText(value != null ? formatCapacity(value) : "");
|
|
593
|
+
} else {
|
|
594
|
+
setMode("option");
|
|
595
|
+
props.onApply(props.provider, props.model, props.kind, Number(e.target.value));
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
options.map((o) => React.createElement("option", { key: o.value, value: String(o.value) }, o.label)),
|
|
600
|
+
React.createElement("option", { value: "__custom__" }, "自定义…")
|
|
601
|
+
),
|
|
602
|
+
mode === "custom" ? React.createElement("input", {
|
|
603
|
+
className: "mcfg-input mcfg-custom",
|
|
604
|
+
type: "text",
|
|
605
|
+
disabled: props.disabled,
|
|
606
|
+
value: text,
|
|
607
|
+
placeholder: "如 256K / 1M",
|
|
608
|
+
onChange: (e) => setText(e.target.value),
|
|
609
|
+
onBlur: commit,
|
|
610
|
+
onKeyDown: (e) => { if (e.key === "Enter") e.target.blur(); },
|
|
611
|
+
}) : null
|
|
612
|
+
)
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ── 绘图页组件(ex dsh-plugin-image-generation/client.js,样式前缀统一) ──
|
|
617
|
+
const JOB_KEY = "dsh-image-generation-job";
|
|
618
|
+
|
|
619
|
+
function imageField(label, value, change, options = {}) {
|
|
620
|
+
return React.createElement("label", { className: "mcfg-field2", key: label }, label,
|
|
621
|
+
React.createElement("input", { value: value ?? "", onChange: (event) => change(event.target.value), ...options }));
|
|
622
|
+
}
|
|
623
|
+
const freshImageEntry = () => ({ id: "", name: "", model: "", api: "openai-images", endpoint: "https://api.openai.com/v1/images/generations", apiKeyEnv: "IMAGE_API_KEY", timeoutSeconds: 300 })
|
|
624
|
+
|
|
625
|
+
/** An entry the 模型参数 tab linked carries its origin. */
|
|
626
|
+
function managedBy(item) { return item?.source?.provider ? `模型参数 · ${item.source.provider}` : "" }
|
|
627
|
+
|
|
628
|
+
function PanelImage({ call, jobId, index, image }) {
|
|
629
|
+
const [url, setUrl] = React.useState("");
|
|
630
|
+
const [error, setError] = React.useState("");
|
|
631
|
+
const [busy, setBusy] = React.useState(false);
|
|
632
|
+
React.useEffect(() => {
|
|
633
|
+
let live = true;
|
|
634
|
+
call("image", { jobId, imageIndex: index, original: false }).then((value) => {
|
|
635
|
+
if (live) setUrl(`data:${value.mediaType};base64,${value.data}`);
|
|
636
|
+
}, (error) => { if (live) setError(error.message); });
|
|
637
|
+
return () => { live = false; };
|
|
638
|
+
}, [call, jobId, index]);
|
|
639
|
+
async function download() {
|
|
640
|
+
setBusy(true); setError("");
|
|
641
|
+
try {
|
|
642
|
+
const value = await call("image", { jobId, imageIndex: index, original: true });
|
|
643
|
+
const bytes = Uint8Array.from(atob(value.data), (char) => char.charCodeAt(0));
|
|
644
|
+
const objectUrl = URL.createObjectURL(new Blob([bytes], { type: value.mediaType }));
|
|
645
|
+
const anchor = document.createElement("a");
|
|
646
|
+
anchor.href = objectUrl; anchor.download = value.name; anchor.click();
|
|
647
|
+
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
|
648
|
+
} catch (error) { setError(error.message); } finally { setBusy(false); }
|
|
649
|
+
}
|
|
650
|
+
return React.createElement("div", { className: "mcfg-card" },
|
|
651
|
+
url ? React.createElement("a", { href: url, target: "_blank", rel: "noreferrer", title: "打开预览" },
|
|
652
|
+
React.createElement("img", { src: url, alt: `生成图片 ${index + 1}` })) : React.createElement("p", null, "正在加载预览…"),
|
|
653
|
+
React.createElement("p", null, `${image.preview.width} × ${image.preview.height} 预览 · 原图 ${(image.original.bytes / 1024).toFixed(0)} KB`),
|
|
654
|
+
image.path ? React.createElement("p", null, image.path) : null,
|
|
655
|
+
React.createElement("button", { onClick: download, disabled: busy }, busy ? "正在下载…" : "下载原图"),
|
|
656
|
+
error ? React.createElement("p", { className: "mcfg-error", role: "alert" }, error) : null);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function ImagePanel({ remote, call, writable, view, refresh, showToast }) {
|
|
660
|
+
const [form, setForm] = React.useState(null);
|
|
661
|
+
const [editing, setEditing] = React.useState("");
|
|
662
|
+
const [key, setKey] = React.useState("");
|
|
663
|
+
const [catalog, setCatalog] = React.useState(null);
|
|
664
|
+
const [modelQuery, setModelQuery] = React.useState("");
|
|
665
|
+
const [notice, setNotice] = React.useState("");
|
|
666
|
+
const [busy, setBusy] = React.useState(false);
|
|
667
|
+
const [selected, setSelected] = React.useState("");
|
|
668
|
+
const [prompt, setPrompt] = React.useState("");
|
|
669
|
+
const [count, setCount] = React.useState("1");
|
|
670
|
+
const [jobId, setJobId] = React.useState(() => {
|
|
671
|
+
try { return sessionStorage.getItem(JOB_KEY) ?? ""; } catch { return ""; }
|
|
672
|
+
});
|
|
673
|
+
const [job, setJob] = React.useState(null);
|
|
674
|
+
const models = view?.value?.models ?? [];
|
|
675
|
+
|
|
676
|
+
React.useEffect(() => {
|
|
677
|
+
if (!jobId) return undefined;
|
|
678
|
+
let live = true;
|
|
679
|
+
let timer;
|
|
680
|
+
async function poll() {
|
|
681
|
+
try {
|
|
682
|
+
const next = await call("status", { jobId });
|
|
683
|
+
if (!live) return;
|
|
684
|
+
setJob(next);
|
|
685
|
+
if (next.status === "running") timer = setTimeout(poll, 1500);
|
|
686
|
+
} catch (error) { if (live) setJob({ status: "error", error: error.message }); }
|
|
687
|
+
}
|
|
688
|
+
void poll();
|
|
689
|
+
return () => { live = false; clearTimeout(timer); };
|
|
690
|
+
}, [call, jobId]);
|
|
691
|
+
|
|
692
|
+
async function action(fn) {
|
|
693
|
+
setBusy(true); setNotice("");
|
|
694
|
+
try { await fn(); } catch (error) { showToast("err", error.message); } finally { setBusy(false); }
|
|
695
|
+
}
|
|
696
|
+
/** Re-read our own namespace: the write plan and the revision it is
|
|
697
|
+
* applied against must come from the same document. */
|
|
698
|
+
async function freshOwn() {
|
|
699
|
+
const response = await remote.settings.describe();
|
|
700
|
+
if (!response.ok) throw new Error(response.error?.message ?? "读取设置失败");
|
|
701
|
+
const found = (response.value?.namespaces ?? []).find((item) => item.ns === NS);
|
|
702
|
+
if (!found) throw new Error("模型调参宿主行未激活,无法写入绘图配置。");
|
|
703
|
+
return found;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Commit one drawing-config edit.
|
|
707
|
+
*
|
|
708
|
+
* Always re-reads immediately before writing (the same discipline the
|
|
709
|
+
* 模型参数 tab uses). Using the view captured at render time made a page
|
|
710
|
+
* left open across any other write fail on a stale revision — the
|
|
711
|
+
* optimistic lock rejects it and the button silently looks dead.
|
|
712
|
+
* @param buildOps - derives the ops from the freshly read view.
|
|
713
|
+
* @param successMessage - toast shown after the write and refresh.
|
|
714
|
+
*/
|
|
715
|
+
async function commit(buildOps, successMessage) {
|
|
716
|
+
const fresh = await freshOwn();
|
|
717
|
+
const response = await remote.settings.mutate(NS, buildOps(fresh), fresh.revision);
|
|
718
|
+
if (!response.ok) throw new Error(response.error?.message ?? "保存失败");
|
|
719
|
+
const refreshed = await refresh();
|
|
720
|
+
if (refreshed === false) throw new Error("已保存,但页面刷新失败,请重新打开本页确认。");
|
|
721
|
+
if (successMessage) showToast("ok", successMessage);
|
|
722
|
+
}
|
|
723
|
+
async function save() {
|
|
724
|
+
const normalized = { ...form, id: form.id.trim(), model: form.model.trim(), endpoint: form.endpoint.trim(), apiKeyEnv: (form.apiKeyEnv ?? "").trim(), timeoutSeconds: Number(form.timeoutSeconds) };
|
|
725
|
+
if (!normalized.model) throw new Error("请从供应商列表选择模型,或手动填写模型 ID。");
|
|
726
|
+
if (key) {
|
|
727
|
+
if (!normalized.apiKeyEnv) throw new Error("保存 Key 前请填写凭据引用。");
|
|
728
|
+
const saved = await remote.credentials.set(normalized.apiKeyEnv, key);
|
|
729
|
+
if (!saved.ok) throw new Error(saved.error?.message ?? "凭据保存失败");
|
|
730
|
+
setKey("");
|
|
731
|
+
}
|
|
732
|
+
// Every field below is derived from the freshly read document, so a
|
|
733
|
+
// page left open across another write can neither fail on a stale
|
|
734
|
+
// revision nor write a stale model list back over it.
|
|
735
|
+
await commit((fresh) => {
|
|
736
|
+
const freshModels = imageModelsOf(fresh);
|
|
737
|
+
// v2: `source` is provenance display only. Editing a linked entry
|
|
738
|
+
// keeps the marker (so 模型参数 still shows where it came from), but
|
|
739
|
+
// its identity fields are now editable in place — one plugin owns
|
|
740
|
+
// both tabs.
|
|
741
|
+
const stored = editing ? freshModels.find((item) => item.id === editing) : undefined;
|
|
742
|
+
if (managedBy(stored)) normalized.source = stored.source;
|
|
743
|
+
const entry = { ...normalized };
|
|
744
|
+
if (!entry.id) {
|
|
745
|
+
const base = entry.model.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^[-_]+|[-_]+$/g, "").slice(0, 54) || "image-model";
|
|
746
|
+
entry.id = base;
|
|
747
|
+
for (let i = 2; freshModels.some((item) => item.id === entry.id); i++) entry.id = `${base}-${i}`;
|
|
748
|
+
}
|
|
749
|
+
if (freshModels.some((item) => item.id === entry.id && item.id !== editing)) throw new Error("配置 ID 已存在。");
|
|
750
|
+
const next = editing ? freshModels.map((item) => item.id === editing ? entry : item) : [...freshModels, entry];
|
|
751
|
+
const previousDefault = fresh.value?.defaultModel;
|
|
752
|
+
const defaultModel = previousDefault === editing || !previousDefault ? entry.id : previousDefault;
|
|
753
|
+
return [
|
|
754
|
+
{ op: "set", path: ["models"], value: next },
|
|
755
|
+
{ op: "set", path: ["defaultModel"], value: defaultModel || "" },
|
|
756
|
+
];
|
|
757
|
+
});
|
|
758
|
+
setForm(null); setEditing(""); setNotice("已保存,下次生成即生效。");
|
|
759
|
+
}
|
|
760
|
+
async function generate() {
|
|
761
|
+
const result = await call("start", { request: { ...(selected ? { model: selected } : {}), prompt, n: Number(count) } });
|
|
762
|
+
try { sessionStorage.setItem(JOB_KEY, result.jobId); } catch { /* Storage may be disabled. */ }
|
|
763
|
+
setJob({ status: "running" }); setJobId(result.jobId);
|
|
764
|
+
}
|
|
765
|
+
async function discover() {
|
|
766
|
+
setCatalog(null);
|
|
767
|
+
const result = await call("discover", { request: {
|
|
768
|
+
endpoint: form.endpoint.trim(), modelsEndpoint: (form.modelsEndpoint ?? "").trim(),
|
|
769
|
+
apiKeyEnv: (form.apiKeyEnv ?? "").trim(), ...(key ? { apiKey: key } : {}),
|
|
770
|
+
} });
|
|
771
|
+
setCatalog(result); setModelQuery("");
|
|
772
|
+
}
|
|
773
|
+
const update = (name) => (value) => {
|
|
774
|
+
if (["endpoint", "modelsEndpoint", "apiKeyEnv"].includes(name)) setCatalog(null);
|
|
775
|
+
setForm((previous) => ({ ...previous, [name]: value }));
|
|
776
|
+
};
|
|
777
|
+
const visibleModels = (catalog?.models ?? []).filter((item) => `${item.id} ${item.name}`.toLowerCase().includes(modelQuery.trim().toLowerCase()));
|
|
778
|
+
const running = job?.status === "running";
|
|
779
|
+
|
|
780
|
+
return React.createElement("div", { className: "mcfg-page" },
|
|
781
|
+
React.createElement("p", { className: "mcfg-note" }, "独立配置绘图模型。可在聊天中让助手调用 generate_image,也可在下方直接生成。接口使用 OpenAI Images 协议。"),
|
|
782
|
+
notice ? React.createElement("div", { role: "status" }, notice) : null,
|
|
783
|
+
!view ? React.createElement("p", null, "正在读取配置…") : React.createElement("fieldset", { disabled: busy }, React.createElement("legend", null, "绘图模型"),
|
|
784
|
+
models.length === 0 ? React.createElement("p", { className: "mcfg-note" }, "尚未配置。添加模型后即可绘图;也可在「模型参数」页勾选生图模型自动生成。") : models.map((item) => React.createElement("div", { className: "mcfg-row", key: item.id },
|
|
785
|
+
React.createElement("span", null, `${item.name || item.id} · ${item.model}${view.value.defaultModel === item.id ? " · 默认" : ""}${managedBy(item) ? " · " + managedBy(item) : ""}`),
|
|
786
|
+
React.createElement("div", { className: "mcfg-actions2" },
|
|
787
|
+
React.createElement("button", { disabled: !writable || !!form, onClick: () => { setForm({ ...item }); setEditing(item.id); setKey(""); setCatalog(null); } }, "编辑"),
|
|
788
|
+
React.createElement("button", {
|
|
789
|
+
// Disabled on the entry that already IS the default: the button
|
|
790
|
+
// then states a fact instead of pretending to act.
|
|
791
|
+
disabled: !writable || !!form || view.value?.defaultModel === item.id,
|
|
792
|
+
onClick: () => void action(() => commit(
|
|
793
|
+
() => [{ op: "set", path: ["defaultModel"], value: item.id }],
|
|
794
|
+
`已将 ${item.name || item.id} 设为默认`,
|
|
795
|
+
)),
|
|
796
|
+
}, view.value?.defaultModel === item.id ? "当前默认" : "设为默认"),
|
|
797
|
+
React.createElement("button", {
|
|
798
|
+
disabled: !writable || !!form,
|
|
799
|
+
onClick: () => void action(async () => {
|
|
800
|
+
await commit((fresh) => {
|
|
801
|
+
const next = imageModelsOf(fresh).filter((model) => model.id !== item.id);
|
|
802
|
+
const currentDefault = fresh.value?.defaultModel;
|
|
803
|
+
return [
|
|
804
|
+
{ op: "set", path: ["models"], value: next },
|
|
805
|
+
{ op: "set", path: ["defaultModel"], value: currentDefault === item.id ? (next[0]?.id ?? "") : (currentDefault ?? "") },
|
|
806
|
+
];
|
|
807
|
+
}, `已删除 ${item.name || item.id}`);
|
|
808
|
+
if (selected === item.id) setSelected("");
|
|
809
|
+
}),
|
|
810
|
+
}, "删除")))),
|
|
811
|
+
React.createElement("div", { className: "mcfg-actions2" },
|
|
812
|
+
React.createElement("button", { disabled: !writable || !!form, onClick: () => { setForm(freshImageEntry()); setEditing(""); setKey(""); setCatalog(null); } }, "添加模型"),
|
|
813
|
+
React.createElement("button", { onClick: () => void action(async () => { await refresh(); setForm(null); setKey(""); }) }, "重新加载")),
|
|
814
|
+
!writable ? React.createElement("p", { className: "mcfg-note" }, "当前配置只读。") : null),
|
|
815
|
+
form ? React.createElement("fieldset", { disabled: busy }, React.createElement("legend", null, editing ? "编辑模型" : "添加模型"),
|
|
816
|
+
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" }),
|
|
817
|
+
imageField("API Key(留空保留现有值)", key, (value) => { setKey(value); setCatalog(null); }, { type: "password", autoComplete: "new-password" }),
|
|
818
|
+
React.createElement("button", { onClick: () => void action(discover) }, "获取模型列表"),
|
|
819
|
+
catalog ? React.createElement("div", null,
|
|
820
|
+
imageField("搜索供应商模型", modelQuery, setModelQuery, { placeholder: "按名称或模型 ID 搜索" }),
|
|
821
|
+
React.createElement("p", { className: "mcfg-note" }, `供应商返回 ${catalog.models.length} 个模型。列表可能包含聊天模型,请选择供应商明确支持绘图的型号。${catalog.truncated ? "供应商还有后续分页,当前只显示首批结果;未找到的型号可手动填写。" : ""}`),
|
|
822
|
+
React.createElement("div", { className: "mcfg-model-list" }, visibleModels.slice(0, 200).map((item) => React.createElement("button", {
|
|
823
|
+
key: item.id, "aria-pressed": form.model === item.id,
|
|
824
|
+
onClick: () => setForm((previous) => ({ ...previous, model: item.id, name: previous.name || item.name })),
|
|
825
|
+
}, item.name === item.id ? item.id : `${item.name} · ${item.id}`))),
|
|
826
|
+
visibleModels.length === 0 ? React.createElement("p", { className: "mcfg-note" }, "没有匹配的模型,可以更换搜索词或手动填写。") : null,
|
|
827
|
+
visibleModels.length > 200 ? React.createElement("p", { className: "mcfg-note" }, "当前显示前 200 项,请输入关键词缩小范围。") : null) : null,
|
|
828
|
+
imageField("模型 ID", form.model, update("model"), { placeholder: "从列表选择,也可手动填写" }),
|
|
829
|
+
React.createElement("details", null, React.createElement("summary", null, "高级设置(可选)"),
|
|
830
|
+
React.createElement("div", { className: "mcfg-grid" },
|
|
831
|
+
imageField("配置 ID", form.id, update("id"), { placeholder: "留空自动生成", disabled: !!editing }),
|
|
832
|
+
imageField("显示名称", form.name, update("name")),
|
|
833
|
+
imageField("接口类型", "OpenAI Images", () => {}, { disabled: true }),
|
|
834
|
+
imageField("完整生成接口", form.endpoint, update("endpoint"), { type: "url" }),
|
|
835
|
+
imageField("模型列表接口(留空自动推导)", form.modelsEndpoint, update("modelsEndpoint"), { type: "url" }),
|
|
836
|
+
imageField("凭据引用", form.apiKeyEnv, update("apiKeyEnv"), { placeholder: "IMAGE_API_KEY" }),
|
|
837
|
+
imageField("超时(秒)", form.timeoutSeconds, update("timeoutSeconds"), { type: "number", min: 10, max: 600 }),
|
|
838
|
+
...[["尺寸", "size"], ["质量", "quality"], ["背景", "background"], ["输出格式", "output_format"], ["响应格式", "response_format"], ["风格", "style"]].map(([label, name]) => imageField(`${label}(可选)`, form[name], update(name)))),
|
|
839
|
+
React.createElement("p", { className: "mcfg-note" }, "可选参数留空则不发送。GPT Image 的响应格式请留空。Key 通过 dsh 凭据服务单独保存;同名引用会被其他模型共享。"),
|
|
840
|
+
editing && managedBy(form) ? React.createElement("p", { className: "mcfg-note" }, `该配置最初由「模型参数」页从 ${managedBy(form)} 链接生成;如需同步 provider 最新地址,可在那边取消勾选后重新勾选,或直接在此修改。`) : null),
|
|
841
|
+
React.createElement("div", { className: "mcfg-actions2" }, React.createElement("button", { onClick: () => void action(save) }, "保存"), React.createElement("button", { onClick: () => { setForm(null); setKey(""); } }, "取消"))) : null,
|
|
842
|
+
React.createElement("fieldset", { disabled: busy || running }, React.createElement("legend", null, "直接绘图"),
|
|
843
|
+
React.createElement("label", { className: "mcfg-field2" }, "绘图模型", React.createElement("select", { value: selected, onChange: (event) => setSelected(event.target.value) },
|
|
844
|
+
React.createElement("option", { value: "" }, "使用默认模型"), ...models.map((item) => React.createElement("option", { key: item.id, value: item.id }, item.name || item.id)))),
|
|
845
|
+
React.createElement("label", { className: "mcfg-field2" }, "提示词", React.createElement("textarea", { value: prompt, onChange: (event) => setPrompt(event.target.value), placeholder: "描述你想生成的画面…" })),
|
|
846
|
+
imageField("图片数量", count, setCount, { type: "number", min: 1, max: 4 }),
|
|
847
|
+
React.createElement("button", { disabled: models.length === 0 || !prompt.trim(), onClick: () => void action(generate) }, "生成图片"),
|
|
848
|
+
React.createElement("p", { className: "mcfg-note" }, "点击生成会调用所选服务,可能产生费用。此面板不发送聊天历史,结果不插入对话。任务预览保留到宿主重启、过期或被后续任务替换前,请及时下载原图。")),
|
|
849
|
+
running ? React.createElement("div", { role: "status" }, "正在生成… ", React.createElement("button", { onClick: () => void action(async () => { await call("cancel", { jobId }); }) }, "取消等待")) : null,
|
|
850
|
+
job?.status === "error" ? React.createElement("div", { className: "mcfg-error", role: "alert" }, job.error) : null,
|
|
851
|
+
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);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function ModelTuningPage(props) {
|
|
855
|
+
const api = props.api;
|
|
856
|
+
const timer = props.timer;
|
|
857
|
+
const [data, setData] = React.useState(null);
|
|
858
|
+
const [ownView, setOwnView] = React.useState(null);
|
|
859
|
+
const [loading, setLoading] = React.useState(true);
|
|
860
|
+
const [error, setError] = React.useState(null);
|
|
861
|
+
const [toast, setToast] = React.useState(null);
|
|
862
|
+
const [query, setQuery] = React.useState("");
|
|
863
|
+
const [tab, setTab] = React.useState("models");
|
|
864
|
+
const [title, setTitle] = React.useState(null);
|
|
865
|
+
const [titleDraft, setTitleDraft] = React.useState(null);
|
|
866
|
+
const [titleBusy, setTitleBusy] = React.useState(false);
|
|
867
|
+
|
|
868
|
+
async function reload() {
|
|
869
|
+
try {
|
|
870
|
+
const [providersRes, modelsRes, settingsRes] = await Promise.all([
|
|
871
|
+
api.llm.providers({}),
|
|
872
|
+
api.llm.models({}),
|
|
873
|
+
api.settings.describe({}),
|
|
874
|
+
]);
|
|
875
|
+
if (!providersRes.result.ok) throw new Error(providersRes.result.error.message);
|
|
876
|
+
if (!modelsRes.result.ok) throw new Error(modelsRes.result.error.message);
|
|
877
|
+
if (!settingsRes.result.ok) throw new Error(settingsRes.result.error.message);
|
|
878
|
+
|
|
879
|
+
const providers = providersRes.result.value.providers;
|
|
880
|
+
const groups = modelsRes.result.value.groups;
|
|
881
|
+
const namespaces = settingsRes.result.value.namespaces;
|
|
882
|
+
const writable = settingsRes.result.value.writable;
|
|
883
|
+
|
|
884
|
+
const metaByProvider = new Map(providers.map((p) => [p.provider, p]));
|
|
885
|
+
const nsByNs = new Map(namespaces.map((n) => [n.ns, n]));
|
|
886
|
+
// v2: our own view; absent only when our host row failed to activate.
|
|
887
|
+
const view = ownNsView(settingsRes.result.value);
|
|
888
|
+
setTitle(titleViewOf(settingsRes.result.value));
|
|
889
|
+
setTitleDraft(null);
|
|
890
|
+
setError(null);
|
|
891
|
+
|
|
892
|
+
const built = { writable, providers: [] };
|
|
893
|
+
for (const group of groups) {
|
|
894
|
+
const entry = metaByProvider.get(group.id);
|
|
895
|
+
const nsView = entry ? nsByNs.get(entry.settingsNs) : undefined;
|
|
896
|
+
const providerProfile = profileOf(nsView, entry ? entry.settingsPath : []);
|
|
897
|
+
const reasoningEditable = !!(entry && entry.settingsNs === "llm-pi-ai");
|
|
898
|
+
const routeDefaultInput = providerProfile && Array.isArray(providerProfile.defaultInput)
|
|
899
|
+
? providerProfile.defaultInput
|
|
900
|
+
: null;
|
|
901
|
+
const models = group.models.map((m) => {
|
|
902
|
+
const ctx = readModelField(nsView, entry ? entry.settingsPath : [], m.id, "contextWindow");
|
|
903
|
+
const mx = readModelField(nsView, entry ? entry.settingsPath : [], m.id, "maxTokens");
|
|
904
|
+
const devRole = entry && entry.settingsNs === "llm-pi-ai"
|
|
905
|
+
? readCompatField(nsView, entry.settingsPath, m.id, "supportsDeveloperRole")
|
|
906
|
+
: undefined;
|
|
907
|
+
const modalityField = entry ? modalityFieldFor(entry.settingsNs) : undefined;
|
|
908
|
+
const rawInput = modalityField
|
|
909
|
+
? readModelField(nsView, entry.settingsPath, m.id, modalityField)
|
|
910
|
+
: undefined;
|
|
911
|
+
const image = computeImageModelState(view, providerProfile, m.id);
|
|
912
|
+
return {
|
|
913
|
+
id: m.id,
|
|
914
|
+
name: m.name,
|
|
915
|
+
description: m.description != null ? m.description : null,
|
|
916
|
+
contextWindow: ctx != null ? ctx : null,
|
|
917
|
+
maxTokens: mx != null ? mx : null,
|
|
918
|
+
supportsDeveloperRole: devRole,
|
|
919
|
+
modalityField: modalityField || null,
|
|
920
|
+
modality: modalityField ? modalityChoice(rawInput) : null,
|
|
921
|
+
imageAvailable: image.available,
|
|
922
|
+
imageLinked: image.hasEntry,
|
|
923
|
+
imageManaged: image.managed,
|
|
924
|
+
imageEndpoint: image.endpoint || null,
|
|
925
|
+
imageApiKeyEnv: image.apiKeyEnv || null,
|
|
926
|
+
declared: !!(entry && entry.declared),
|
|
927
|
+
routeDefaultInput: routeDefaultInput,
|
|
928
|
+
efforts: (m.reasoning && Array.isArray(m.reasoning.efforts)) ? m.reasoning.efforts.map((e) => ({ id: e.id, name: e.name })) : [],
|
|
929
|
+
defaultEffort: (m.reasoning && m.reasoning.defaultEffort != null) ? m.reasoning.defaultEffort : null,
|
|
930
|
+
};
|
|
931
|
+
});
|
|
932
|
+
built.providers.push({
|
|
933
|
+
provider: group.id,
|
|
934
|
+
name: group.name,
|
|
935
|
+
configurable: !!entry,
|
|
936
|
+
reasoningEditable,
|
|
937
|
+
imageNsAvailable: !!view,
|
|
938
|
+
models,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
const imageModels = imageModelsOf(view);
|
|
942
|
+
const linkedIds = new Set(imageModels.filter((item) => item && item.model).map((item) => item.model));
|
|
943
|
+
for (const row of built.providers) {
|
|
944
|
+
row.imageLinkedCount = row.models.filter((m) => linkedIds.has(m.id)).length;
|
|
945
|
+
}
|
|
946
|
+
built.imageModels = imageModels.map((item) => item?.model).filter(Boolean);
|
|
947
|
+
built.imageDefault = typeof view?.value?.defaultModel === "string" ? view.value.defaultModel : "";
|
|
948
|
+
setData(built);
|
|
949
|
+
setOwnView(view ?? null);
|
|
950
|
+
setError(null);
|
|
951
|
+
return true;
|
|
952
|
+
} catch (err) {
|
|
953
|
+
setError(err && err.message ? err.message : String(err));
|
|
954
|
+
return false;
|
|
955
|
+
} finally {
|
|
956
|
+
setLoading(false);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
React.useEffect(() => {
|
|
961
|
+
reload();
|
|
962
|
+
}, []);
|
|
963
|
+
|
|
964
|
+
function showToast(kind, text) {
|
|
965
|
+
setToast({ kind, text });
|
|
966
|
+
if (timer) timer.timeout(() => setToast(null), 3000);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function applyChange(provider, model, kind, value) {
|
|
970
|
+
try {
|
|
971
|
+
const [providersRes, settingsRes] = await Promise.all([
|
|
972
|
+
api.llm.providers({}),
|
|
973
|
+
api.settings.describe({}),
|
|
974
|
+
]);
|
|
975
|
+
if (!providersRes.result.ok) throw new Error(providersRes.result.error.message);
|
|
976
|
+
if (!settingsRes.result.ok) throw new Error(settingsRes.result.error.message);
|
|
977
|
+
|
|
978
|
+
const entry = providersRes.result.value.providers.find((p) => p.provider === provider);
|
|
979
|
+
if (!entry) throw new Error("该 provider 不可配置");
|
|
980
|
+
const nsView = settingsRes.result.value.namespaces.find((n) => n.ns === entry.settingsNs);
|
|
981
|
+
|
|
982
|
+
if (kind === "imageLink") {
|
|
983
|
+
// Re-read immediately before writing, so the plan and the revision
|
|
984
|
+
// it is applied against come from the same document.
|
|
985
|
+
const freshRes = await api.settings.describe({});
|
|
986
|
+
if (!freshRes.result.ok) throw new Error(freshRes.result.error.message);
|
|
987
|
+
const freshView = ownNsView(freshRes.result.value);
|
|
988
|
+
if (!freshView) throw new Error("模型调参宿主行未激活,无法写入绘图配置。");
|
|
989
|
+
const providerProfile = profileOf(nsView, entry.settingsPath);
|
|
990
|
+
const plan = value === true
|
|
991
|
+
? planImageLink(freshView, providerProfile, model, provider)
|
|
992
|
+
: planImageUnlink(freshView, model);
|
|
993
|
+
if (!plan || !plan.models) throw new Error((plan && plan.reason) || "无法写入绘图配置");
|
|
994
|
+
const written = await api.settings.mutate({
|
|
995
|
+
ns: NS,
|
|
996
|
+
ops: [
|
|
997
|
+
{ op: "set", path: ["models"], value: plan.models },
|
|
998
|
+
{ op: "set", path: ["defaultModel"], value: plan.defaultModel || "" },
|
|
999
|
+
],
|
|
1000
|
+
expectedRevision: freshView.revision,
|
|
1001
|
+
});
|
|
1002
|
+
if (!written.result.ok) throw new Error(written.result.error.message);
|
|
1003
|
+
showToast("ok", value === true ? "已在「绘图」页生成模型配置" : "已取消生图标记");
|
|
1004
|
+
await reload();
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
let field;
|
|
1009
|
+
let payload;
|
|
1010
|
+
if (kind === "reasoningLevels") {
|
|
1011
|
+
field = "reasoningEfforts";
|
|
1012
|
+
const raw = value && Array.isArray(value.levels) ? value.levels : [];
|
|
1013
|
+
const levels = REASONING_LEVELS.filter((level) => raw.indexOf(level) >= 0);
|
|
1014
|
+
if (levels.length === 0) {
|
|
1015
|
+
payload = false;
|
|
1016
|
+
} else {
|
|
1017
|
+
const dict = { off: null };
|
|
1018
|
+
for (const level of levels) dict[level] = level;
|
|
1019
|
+
payload = dict;
|
|
1020
|
+
}
|
|
1021
|
+
} else if (kind === "contextWindow" || kind === "maxTokens") {
|
|
1022
|
+
field = kind;
|
|
1023
|
+
if (value != null && (typeof value !== "number" || !Number.isInteger(value) || value <= 0)) {
|
|
1024
|
+
throw new Error("取值必须是正整数");
|
|
1025
|
+
}
|
|
1026
|
+
payload = value;
|
|
1027
|
+
} else if (kind === "supportsDeveloperRole") {
|
|
1028
|
+
if (value !== null && typeof value !== "boolean") {
|
|
1029
|
+
throw new Error("取值必须是布尔值");
|
|
1030
|
+
}
|
|
1031
|
+
const write = computeCompatWriteOps(entry, nsView, model, "supportsDeveloperRole", value);
|
|
1032
|
+
if (write) {
|
|
1033
|
+
const res = await api.settings.mutate({ ns: write.ns, ops: write.ops });
|
|
1034
|
+
if (!res.result.ok) throw new Error(res.result.error.message);
|
|
1035
|
+
}
|
|
1036
|
+
showToast("ok", "已保存");
|
|
1037
|
+
await reload();
|
|
1038
|
+
return;
|
|
1039
|
+
} else if (kind === "modalities") {
|
|
1040
|
+
if (MODALITY_CHOICES.indexOf(value) < 0) throw new Error("未知的模态取值");
|
|
1041
|
+
const write = computeModalityWriteOps(entry, nsView, model, value);
|
|
1042
|
+
if (!write) throw new Error("该 provider 不接受模态声明");
|
|
1043
|
+
const res = await api.settings.mutate({ ns: write.ns, ops: write.ops });
|
|
1044
|
+
if (!res.result.ok) throw new Error(res.result.error.message);
|
|
1045
|
+
showToast("ok", value === "text-image" ? "已开启图像输入,下一次请求即生效" : "已保存");
|
|
1046
|
+
await reload();
|
|
1047
|
+
return;
|
|
1048
|
+
} else {
|
|
1049
|
+
throw new Error("未知的修改类型");
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const write = computeWriteOps(entry, nsView, model, field, payload);
|
|
1053
|
+
if (write) {
|
|
1054
|
+
const res = await api.settings.mutate({ ns: write.ns, ops: write.ops });
|
|
1055
|
+
if (!res.result.ok) throw new Error(res.result.error.message);
|
|
1056
|
+
}
|
|
1057
|
+
showToast("ok", "已保存");
|
|
1058
|
+
await reload();
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
showToast("err", err && err.message ? err.message : String(err));
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
if (loading) {
|
|
1065
|
+
return React.createElement("div", { className: "mcfg-state" }, "正在加载模型配置…");
|
|
1066
|
+
}
|
|
1067
|
+
if (error) {
|
|
1068
|
+
return React.createElement("div", { className: "mcfg-state mcfg-error" }, "加载失败:" + error);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** The effective title selection: an unsaved draft wins over the stored value. */
|
|
1072
|
+
const titleShown = titleDraft || title || { available: false, mode: "inherit", provider: null, model: null, revision: null };
|
|
1073
|
+
const titleProviders = data && data.providers ? data.providers : [];
|
|
1074
|
+
const titleProvider = titleProviders.find((p) => p.provider === titleShown.provider) || null;
|
|
1075
|
+
const titleModels = titleProvider ? titleProvider.models : [];
|
|
1076
|
+
const titleDirty = !!title && (
|
|
1077
|
+
titleShown.mode !== title.mode
|
|
1078
|
+
|| (titleShown.provider || null) !== (title.provider || null)
|
|
1079
|
+
|| (titleShown.model || null) !== (title.model || null)
|
|
1080
|
+
|| TITLE_BUDGET_FIELDS.some((field) => Number(titleShown[field.key]) !== Number(title[field.key]))
|
|
1081
|
+
);
|
|
1082
|
+
const titleSaveDisabled = !data || !data.writable || !titleShown.available || titleBusy
|
|
1083
|
+
|| (titleShown.mode === "custom" && (!titleShown.provider || !titleShown.model));
|
|
1084
|
+
|
|
1085
|
+
function editTitleDraft(patch) {
|
|
1086
|
+
setTitleDraft({ ...titleShown, ...patch });
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async function saveTitle() {
|
|
1090
|
+
// Budget travels with the route: both are this page's to write.
|
|
1091
|
+
const payload = {
|
|
1092
|
+
mode: titleShown.mode === "custom" ? "custom" : "inherit",
|
|
1093
|
+
provider: titleShown.provider,
|
|
1094
|
+
model: titleShown.model,
|
|
1095
|
+
};
|
|
1096
|
+
for (const field of TITLE_BUDGET_FIELDS) payload[field.key] = titleShown[field.key];
|
|
1097
|
+
setTitleBusy(true);
|
|
1098
|
+
let saved = false;
|
|
1099
|
+
try {
|
|
1100
|
+
const write = planTitleWrite(payload);
|
|
1101
|
+
const res = await api.settings.mutate({ ns: write.ns, ops: write.ops });
|
|
1102
|
+
if (!res.result.ok) throw new Error(res.result.error.message);
|
|
1103
|
+
saved = true;
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
showToast("err", err && err.message ? err.message : String(err));
|
|
1106
|
+
} finally {
|
|
1107
|
+
setTitleBusy(false);
|
|
1108
|
+
}
|
|
1109
|
+
if (!saved) return;
|
|
1110
|
+
showToast("ok", "标题生成设置已保存,对之后生成的标题生效");
|
|
1111
|
+
if (!await reload()) showToast("err", "设置已保存,但页面数据刷新失败,请重新打开本页确认。");
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const q = query.trim().toLowerCase();
|
|
1115
|
+
const filtered = !data || !data.providers || data.providers.length === 0 ? [] : q.length === 0 ? data.providers : data.providers.map((provider) => {
|
|
1116
|
+
const providerMatch = provider.name.toLowerCase().indexOf(q) >= 0 || provider.provider.toLowerCase().indexOf(q) >= 0;
|
|
1117
|
+
const models = providerMatch ? provider.models : provider.models.filter((m) =>
|
|
1118
|
+
m.id.toLowerCase().indexOf(q) >= 0 ||
|
|
1119
|
+
m.name.toLowerCase().indexOf(q) >= 0 ||
|
|
1120
|
+
(m.description || "").toLowerCase().indexOf(q) >= 0
|
|
1121
|
+
);
|
|
1122
|
+
return models.length > 0 ? { ...provider, models } : null;
|
|
1123
|
+
}).filter(Boolean);
|
|
1124
|
+
const totalMatched = filtered.reduce((n, p) => n + p.models.length, 0);
|
|
1125
|
+
|
|
1126
|
+
const tabButton = (id, label) => React.createElement("button", {
|
|
1127
|
+
key: id,
|
|
1128
|
+
type: "button",
|
|
1129
|
+
className: "mcfg-tab" + (tab === id ? " mcfg-tab-on" : ""),
|
|
1130
|
+
"aria-pressed": tab === id,
|
|
1131
|
+
onClick: () => setTab(id),
|
|
1132
|
+
}, label);
|
|
1133
|
+
const tabBar = React.createElement("div", { className: "mcfg-tabs", role: "tablist" },
|
|
1134
|
+
tabButton("models", "模型参数"),
|
|
1135
|
+
tabButton("purpose", "用途"),
|
|
1136
|
+
tabButton("image", "绘图")
|
|
1137
|
+
);
|
|
1138
|
+
|
|
1139
|
+
const modelsPanel = [
|
|
1140
|
+
React.createElement("div", { className: "mcfg-searchrow", key: "search" },
|
|
1141
|
+
React.createElement("input", {
|
|
1142
|
+
className: "mcfg-search",
|
|
1143
|
+
type: "text",
|
|
1144
|
+
value: query,
|
|
1145
|
+
placeholder: "搜索模型 / provider…",
|
|
1146
|
+
onChange: (e) => setQuery(e.target.value),
|
|
1147
|
+
}),
|
|
1148
|
+
query.trim().length > 0 ? React.createElement("span", { className: "mcfg-count" }, totalMatched + " 个模型") : null
|
|
1149
|
+
),
|
|
1150
|
+
data.imageModels.length > 0
|
|
1151
|
+
? React.createElement("div", { className: "mcfg-note", key: "img" },
|
|
1152
|
+
"绘图页当前 " + data.imageModels.length + " 个配置:" + data.imageModels.join("、")
|
|
1153
|
+
+ (data.imageDefault ? "(默认 " + data.imageDefault + ")" : "(未设默认,generate_image 需显式指定)"))
|
|
1154
|
+
: React.createElement("div", { className: "mcfg-note", key: "img" }, "绘图页尚未配置任何模型。"),
|
|
1155
|
+
!data.providers || data.providers.length === 0
|
|
1156
|
+
? React.createElement("div", { className: "mcfg-state", key: "none" }, "当前没有已注册的模型。")
|
|
1157
|
+
: filtered.length === 0
|
|
1158
|
+
? React.createElement("div", { className: "mcfg-state", key: "empty" }, "没有匹配的模型。")
|
|
1159
|
+
: filtered.map((provider) =>
|
|
1160
|
+
React.createElement("div", { key: provider.provider, className: "mcfg-provider" },
|
|
1161
|
+
React.createElement("div", { className: "mcfg-provider-name" },
|
|
1162
|
+
provider.name,
|
|
1163
|
+
React.createElement("span", { className: "mcfg-provider-id" }, provider.provider)
|
|
1164
|
+
),
|
|
1165
|
+
provider.models.map((model) => {
|
|
1166
|
+
const editable = data.writable && provider.configurable;
|
|
1167
|
+
const selectedLevels = new Set((model.efforts || []).filter((e) => e.id !== "off").map((e) => e.id));
|
|
1168
|
+
|
|
1169
|
+
return React.createElement("div", { key: model.id, className: "mcfg-model" },
|
|
1170
|
+
React.createElement("div", { className: "mcfg-model-head" },
|
|
1171
|
+
React.createElement("div", { className: "mcfg-model-title" },
|
|
1172
|
+
React.createElement("span", { className: "mcfg-model-name" }, model.name),
|
|
1173
|
+
React.createElement("span", { className: "mcfg-model-id" }, model.id),
|
|
1174
|
+
model.modality === "text-image"
|
|
1175
|
+
? React.createElement("span", { className: "mcfg-chip mcfg-chip-on" }, "图像输入")
|
|
1176
|
+
: null,
|
|
1177
|
+
model.imageLinked
|
|
1178
|
+
? React.createElement("span", { className: "mcfg-chip mcfg-chip-on" }, "生图模型")
|
|
1179
|
+
: null
|
|
1180
|
+
)
|
|
1181
|
+
),
|
|
1182
|
+
model.description ? React.createElement("div", { className: "mcfg-desc" }, model.description) : null,
|
|
1183
|
+
provider.reasoningEditable ? React.createElement("div", { className: "mcfg-reasoning" },
|
|
1184
|
+
React.createElement("span", { className: "mcfg-label" }, "可选推理档位"),
|
|
1185
|
+
React.createElement("div", { className: "mcfg-levels" },
|
|
1186
|
+
REASONING_LEVELS.map((level) =>
|
|
1187
|
+
React.createElement("label", { key: level, className: "mcfg-level" },
|
|
1188
|
+
React.createElement("input", {
|
|
1189
|
+
type: "checkbox",
|
|
1190
|
+
checked: selectedLevels.has(level),
|
|
1191
|
+
onChange: () => {
|
|
1192
|
+
const next = new Set(selectedLevels);
|
|
1193
|
+
if (next.has(level)) next.delete(level); else next.add(level);
|
|
1194
|
+
applyChange(provider.provider, model.id, "reasoningLevels", { levels: [...next] });
|
|
1195
|
+
},
|
|
1196
|
+
}),
|
|
1197
|
+
React.createElement("span", null, level)
|
|
1198
|
+
)
|
|
1199
|
+
)
|
|
1200
|
+
),
|
|
1201
|
+
selectedLevels.size === 0 ? React.createElement("div", { className: "mcfg-note" }, "未勾选任何档位 → 该模型视为无推理能力") : null
|
|
1202
|
+
) : React.createElement("div", { className: "mcfg-reasoning" },
|
|
1203
|
+
React.createElement("span", { className: "mcfg-label" }, "推理档位(由 provider 固定)"),
|
|
1204
|
+
React.createElement("div", { className: "mcfg-levels" },
|
|
1205
|
+
(model.efforts || []).map((effort) =>
|
|
1206
|
+
React.createElement("span", { key: effort.id, className: "mcfg-chip" }, effort.name)
|
|
1207
|
+
)
|
|
1208
|
+
)
|
|
1209
|
+
),
|
|
1210
|
+
provider.reasoningEditable ? React.createElement("div", { className: "mcfg-reasoning" },
|
|
1211
|
+
React.createElement("span", { className: "mcfg-label" }, "系统提示角色(兼容开关)"),
|
|
1212
|
+
React.createElement("div", { className: "mcfg-levels" },
|
|
1213
|
+
["auto", "system", "developer"].map((role) =>
|
|
1214
|
+
React.createElement("label", { key: role, className: "mcfg-level" },
|
|
1215
|
+
React.createElement("input", {
|
|
1216
|
+
type: "radio",
|
|
1217
|
+
name: "devrole-" + model.id,
|
|
1218
|
+
checked: role === "auto"
|
|
1219
|
+
? model.supportsDeveloperRole === undefined
|
|
1220
|
+
: (role === "developer" ? model.supportsDeveloperRole === true : model.supportsDeveloperRole === false),
|
|
1221
|
+
onChange: () => {
|
|
1222
|
+
if (role === "auto") {
|
|
1223
|
+
applyChange(provider.provider, model.id, "supportsDeveloperRole", null);
|
|
1224
|
+
} else {
|
|
1225
|
+
applyChange(provider.provider, model.id, "supportsDeveloperRole", role === "developer");
|
|
1226
|
+
}
|
|
1227
|
+
},
|
|
1228
|
+
}),
|
|
1229
|
+
React.createElement("span", null,
|
|
1230
|
+
role === "auto" ? "自动" : (role === "system" ? "system(推荐 Kimi/DeepSeek)" : "developer")
|
|
1231
|
+
)
|
|
1232
|
+
)
|
|
1233
|
+
)
|
|
1234
|
+
),
|
|
1235
|
+
React.createElement("div", { className: "mcfg-note" },
|
|
1236
|
+
"自动:跟随网关检测;如遇 role 'developer' is not allowed,请选 system"
|
|
1237
|
+
)
|
|
1238
|
+
) : null,
|
|
1239
|
+
model.modalityField ? React.createElement("div", { className: "mcfg-reasoning" },
|
|
1240
|
+
React.createElement("span", { className: "mcfg-label" }, "输入模态(写入 " + model.modalityField + ")"),
|
|
1241
|
+
React.createElement("div", { className: "mcfg-levels" },
|
|
1242
|
+
MODALITY_CHOICES.map((choice) =>
|
|
1243
|
+
React.createElement("label", { key: choice, className: "mcfg-level" },
|
|
1244
|
+
React.createElement("input", {
|
|
1245
|
+
type: "radio",
|
|
1246
|
+
name: "modality-" + model.id,
|
|
1247
|
+
disabled: !editable,
|
|
1248
|
+
checked: model.modality === choice,
|
|
1249
|
+
onChange: () => applyChange(provider.provider, model.id, "modalities", choice),
|
|
1250
|
+
}),
|
|
1251
|
+
React.createElement("span", null, MODALITY_LABELS[choice])
|
|
1252
|
+
)
|
|
1253
|
+
)
|
|
1254
|
+
),
|
|
1255
|
+
React.createElement("div", { className: "mcfg-note" },
|
|
1256
|
+
modalityNote(model.modality, model)
|
|
1257
|
+
)
|
|
1258
|
+
) : null,
|
|
1259
|
+
model.imageAvailable ? React.createElement("div", { className: "mcfg-reasoning" },
|
|
1260
|
+
React.createElement("span", { className: "mcfg-label" }, "生图模型"),
|
|
1261
|
+
React.createElement("div", { className: "mcfg-img-actions" },
|
|
1262
|
+
React.createElement("label", { className: "mcfg-level" },
|
|
1263
|
+
React.createElement("input", {
|
|
1264
|
+
type: "checkbox",
|
|
1265
|
+
checked: model.imageLinked,
|
|
1266
|
+
disabled: !editable,
|
|
1267
|
+
onChange: () => applyChange(provider.provider, model.id, "imageLink", !model.imageLinked),
|
|
1268
|
+
}),
|
|
1269
|
+
React.createElement("span", null, "在「绘图」页自动配置该模型")
|
|
1270
|
+
),
|
|
1271
|
+
model.imageLinked ? React.createElement("span", { className: "mcfg-chip mcfg-chip-on" }, "已在绘图页配置") : null
|
|
1272
|
+
),
|
|
1273
|
+
React.createElement("div", { className: "mcfg-note" },
|
|
1274
|
+
model.imageLinked
|
|
1275
|
+
? "端点 " + (model.imageEndpoint || "(未填写)") + (model.imageApiKeyEnv ? ",凭据 " + model.imageApiKeyEnv : "")
|
|
1276
|
+
+ "。取消勾选会移除该配置;也可以直接在「绘图」页编辑或删除它。"
|
|
1277
|
+
: "勾选后写入「绘图」页的模型列表(模型 ID 复用 " + model.id + ",端点由本 provider 的供应商地址加 /images/generations 推导)。"
|
|
1278
|
+
)
|
|
1279
|
+
) : null,
|
|
1280
|
+
React.createElement("div", { className: "mcfg-fields" },
|
|
1281
|
+
React.createElement(CapacityField, {
|
|
1282
|
+
label: "上下文窗口", options: CTX_OPTIONS, value: model.contextWindow, disabled: !editable,
|
|
1283
|
+
provider: provider.provider, model: model.id, kind: "contextWindow", onApply: applyChange, onToast: showToast,
|
|
1284
|
+
}),
|
|
1285
|
+
React.createElement(CapacityField, {
|
|
1286
|
+
label: "最大输出", options: MAX_OPTIONS, value: model.maxTokens, disabled: !editable,
|
|
1287
|
+
provider: provider.provider, model: model.id, kind: "maxTokens", onApply: applyChange, onToast: showToast,
|
|
1288
|
+
})
|
|
1289
|
+
)
|
|
1290
|
+
);
|
|
1291
|
+
})
|
|
1292
|
+
)
|
|
1293
|
+
)
|
|
1294
|
+
];
|
|
1295
|
+
|
|
1296
|
+
// ── 用途页 ────────────────────────────────────────────────────────────
|
|
1297
|
+
const purposePanel = [
|
|
1298
|
+
React.createElement("div", { className: "mcfg-purpose", key: "title" },
|
|
1299
|
+
React.createElement("div", { className: "mcfg-purpose-head" },
|
|
1300
|
+
React.createElement("span", { className: "mcfg-purpose-name" }, "标题生成"),
|
|
1301
|
+
React.createElement("span", { className: "mcfg-chip" }, "独立请求"),
|
|
1302
|
+
title && title.available
|
|
1303
|
+
? React.createElement("span", { className: "mcfg-chip mcfg-chip-on" }, "已启用")
|
|
1304
|
+
: React.createElement("span", { className: "mcfg-chip" }, "宿主行未激活")
|
|
1305
|
+
),
|
|
1306
|
+
React.createElement("div", { className: "mcfg-desc" },
|
|
1307
|
+
"会话标题由一次独立的辅助请求生成,不会改动对话模型。默认继承当前会话的 provider / model。"
|
|
1308
|
+
),
|
|
1309
|
+
!titleShown.available
|
|
1310
|
+
? React.createElement("div", { className: "mcfg-state mcfg-error" },
|
|
1311
|
+
"未检测到 model-tuning 设置命名空间:宿主半区未激活(标题 provider 未注册),请检查插件是否启用并重启 dsh。"
|
|
1312
|
+
)
|
|
1313
|
+
: [
|
|
1314
|
+
React.createElement("div", { className: "mcfg-radio", key: "mode" },
|
|
1315
|
+
React.createElement("label", { className: "mcfg-radio-row" },
|
|
1316
|
+
React.createElement("input", {
|
|
1317
|
+
type: "radio",
|
|
1318
|
+
name: "title-mode",
|
|
1319
|
+
checked: titleShown.mode === "inherit",
|
|
1320
|
+
disabled: !data.writable || titleBusy,
|
|
1321
|
+
onChange: () => editTitleDraft({ mode: "inherit", provider: null, model: null }),
|
|
1322
|
+
}),
|
|
1323
|
+
React.createElement("span", null,
|
|
1324
|
+
"继承当前会话(默认)",
|
|
1325
|
+
React.createElement("div", { className: "mcfg-note" },
|
|
1326
|
+
"标题请求沿用该会话已记录的 provider / model,行为与内置标题生成一致。")
|
|
1327
|
+
)
|
|
1328
|
+
),
|
|
1329
|
+
React.createElement("label", { className: "mcfg-radio-row" },
|
|
1330
|
+
React.createElement("input", {
|
|
1331
|
+
type: "radio",
|
|
1332
|
+
name: "title-mode",
|
|
1333
|
+
checked: titleShown.mode === "custom",
|
|
1334
|
+
disabled: !data.writable || titleBusy,
|
|
1335
|
+
onChange: () => {
|
|
1336
|
+
const fallback = titleProviders[0];
|
|
1337
|
+
editTitleDraft({
|
|
1338
|
+
mode: "custom",
|
|
1339
|
+
provider: titleShown.provider || (fallback ? fallback.provider : null),
|
|
1340
|
+
model: titleShown.model || (fallback && fallback.models[0] ? fallback.models[0].id : null),
|
|
1341
|
+
});
|
|
1342
|
+
},
|
|
1343
|
+
}),
|
|
1344
|
+
React.createElement("span", null,
|
|
1345
|
+
"使用指定模型",
|
|
1346
|
+
React.createElement("div", { className: "mcfg-note" },
|
|
1347
|
+
"标题请求改投所选 provider / model;会话的对话模型保持不变。")
|
|
1348
|
+
)
|
|
1349
|
+
)
|
|
1350
|
+
),
|
|
1351
|
+
titleShown.mode === "custom"
|
|
1352
|
+
? React.createElement("div", { className: "mcfg-fields", key: "route" },
|
|
1353
|
+
React.createElement("label", { className: "mcfg-field" },
|
|
1354
|
+
React.createElement("span", { className: "mcfg-label" }, "Provider"),
|
|
1355
|
+
React.createElement("select", {
|
|
1356
|
+
className: "mcfg-select",
|
|
1357
|
+
disabled: !data.writable || titleBusy,
|
|
1358
|
+
value: titleShown.provider || "",
|
|
1359
|
+
onChange: (e) => {
|
|
1360
|
+
const next = titleProviders.find((p) => p.provider === e.target.value);
|
|
1361
|
+
editTitleDraft({
|
|
1362
|
+
provider: e.target.value,
|
|
1363
|
+
model: next && next.models[0] ? next.models[0].id : null,
|
|
1364
|
+
});
|
|
1365
|
+
},
|
|
1366
|
+
},
|
|
1367
|
+
React.createElement("option", { value: "" }, "(请选择 provider)"),
|
|
1368
|
+
titleProviders.map((p) => React.createElement("option", { key: p.provider, value: p.provider },
|
|
1369
|
+
p.name + " · " + p.provider))
|
|
1370
|
+
)
|
|
1371
|
+
),
|
|
1372
|
+
React.createElement("label", { className: "mcfg-field" },
|
|
1373
|
+
React.createElement("span", { className: "mcfg-label" }, "Model"),
|
|
1374
|
+
React.createElement("select", {
|
|
1375
|
+
className: "mcfg-select",
|
|
1376
|
+
disabled: !data.writable || titleBusy || !titleShown.provider,
|
|
1377
|
+
value: titleShown.model || "",
|
|
1378
|
+
onChange: (e) => editTitleDraft({ model: e.target.value }),
|
|
1379
|
+
},
|
|
1380
|
+
React.createElement("option", { value: "" }, "(请选择 model)"),
|
|
1381
|
+
titleModels.map((m) => React.createElement("option", { key: m.id, value: m.id },
|
|
1382
|
+
m.name + " · " + m.id))
|
|
1383
|
+
)
|
|
1384
|
+
)
|
|
1385
|
+
)
|
|
1386
|
+
: null,
|
|
1387
|
+
React.createElement("div", { className: "mcfg-note", key: "scope" },
|
|
1388
|
+
"生效范围:保存后对本 profile 之后生成的标题生效(包含新会话与后续标题生成);不会改动已有标题、手动重命名,也不会改动会话的对话模型。"
|
|
1389
|
+
),
|
|
1390
|
+
React.createElement("div", { className: "mcfg-reasoning", key: "budget" },
|
|
1391
|
+
React.createElement("span", { className: "mcfg-label" }, "标题请求预算"),
|
|
1392
|
+
React.createElement("div", { className: "mcfg-fields" },
|
|
1393
|
+
TITLE_BUDGET_FIELDS.map((field) => React.createElement("label", { className: "mcfg-field", key: field.key },
|
|
1394
|
+
React.createElement("span", { className: "mcfg-label" }, field.label),
|
|
1395
|
+
React.createElement("input", {
|
|
1396
|
+
className: "mcfg-input",
|
|
1397
|
+
type: "number",
|
|
1398
|
+
min: 1,
|
|
1399
|
+
step: 1,
|
|
1400
|
+
disabled: !data.writable || titleBusy,
|
|
1401
|
+
value: titleShown[field.key] === undefined || titleShown[field.key] === null ? "" : String(titleShown[field.key]),
|
|
1402
|
+
onChange: (event) => editTitleDraft({
|
|
1403
|
+
[field.key]: event.target.value === "" ? "" : Number(event.target.value),
|
|
1404
|
+
}),
|
|
1405
|
+
}),
|
|
1406
|
+
React.createElement("span", { className: "mcfg-note" }, field.hint)
|
|
1407
|
+
))
|
|
1408
|
+
)
|
|
1409
|
+
),
|
|
1410
|
+
React.createElement("div", { className: "mcfg-note", key: "budgetNote" },
|
|
1411
|
+
"上面的数值由本页写入设置,保存后对之后生成的标题生效;清空某项会回到宿主默认值。标题请求不传递推理档位,由所选模型自己决定是否思考——因此推理模型需要更大的「输出上限」。"
|
|
1412
|
+
),
|
|
1413
|
+
React.createElement("div", { className: "mcfg-actions", key: "actions" },
|
|
1414
|
+
React.createElement("button", {
|
|
1415
|
+
type: "button",
|
|
1416
|
+
className: "mcfg-save",
|
|
1417
|
+
disabled: titleSaveDisabled || !titleDirty,
|
|
1418
|
+
onClick: saveTitle,
|
|
1419
|
+
}, titleBusy ? "正在保存…" : (titleDirty ? "保存标题设置" : "已保存")),
|
|
1420
|
+
titleDirty ? React.createElement("span", { className: "mcfg-note" }, "有未保存的修改") : null
|
|
1421
|
+
)
|
|
1422
|
+
]
|
|
1423
|
+
),
|
|
1424
|
+
React.createElement("div", { className: "mcfg-purpose", key: "image" },
|
|
1425
|
+
React.createElement("div", { className: "mcfg-purpose-head" },
|
|
1426
|
+
React.createElement("span", { className: "mcfg-purpose-name" }, "绘图"),
|
|
1427
|
+
React.createElement("span", { className: "mcfg-chip" }, "图像专有参数")
|
|
1428
|
+
),
|
|
1429
|
+
React.createElement("div", { className: "mcfg-desc" },
|
|
1430
|
+
"绘图模型、端点、尺寸与质量属于图像专有参数,请在「绘图」页签配置;本页的「生图模型」勾选会把所选模型写入那里的模型列表。"
|
|
1431
|
+
),
|
|
1432
|
+
data.imageModels && data.imageModels.length > 0
|
|
1433
|
+
? React.createElement("div", { className: "mcfg-note" },
|
|
1434
|
+
"绘图页当前 " + data.imageModels.length + " 个配置:" + data.imageModels.join("、")
|
|
1435
|
+
+ (data.imageDefault ? "(默认 " + data.imageDefault + ")" : "(未设默认,generate_image 需显式指定)"))
|
|
1436
|
+
: React.createElement("div", { className: "mcfg-note" }, "绘图页尚未配置任何模型。")
|
|
1437
|
+
)
|
|
1438
|
+
];
|
|
1439
|
+
|
|
1440
|
+
return React.createElement("div", { className: "mcfg" },
|
|
1441
|
+
React.createElement("div", { className: "mcfg-head" },
|
|
1442
|
+
React.createElement("div", { className: "mcfg-title" }, "模型调参"),
|
|
1443
|
+
React.createElement("div", { className: "mcfg-sub" },
|
|
1444
|
+
"集中配置每个模型的可选推理档位、上下文窗口、最大输出和输入模态;「用途」页管理标题生成路由;「绘图」页管理绘图模型与直接绘图。" +
|
|
1445
|
+
"修改即时保存;API 密钥与端点请在「模型」页配置。" +
|
|
1446
|
+
(data.writable ? "" : "(当前部署为只读,无法保存。)")
|
|
1447
|
+
)
|
|
1448
|
+
),
|
|
1449
|
+
tabBar,
|
|
1450
|
+
toast ? React.createElement("div", { className: "mcfg-toast " + (toast.kind === "ok" ? "mcfg-toast-ok" : "mcfg-toast-err") }, toast.text) : null,
|
|
1451
|
+
tab === "models" ? modelsPanel : null,
|
|
1452
|
+
tab === "purpose" ? purposePanel : null,
|
|
1453
|
+
tab === "image" && ownView ? React.createElement(ImagePanel, {
|
|
1454
|
+
remote: props.remote, call: props.call,
|
|
1455
|
+
writable: !!data.writable, view: ownView, refresh: reload, showToast,
|
|
1456
|
+
}) : null,
|
|
1457
|
+
tab === "image" && !ownView ? React.createElement("div", { className: "mcfg-state mcfg-error" }, "宿主行未激活,无法读写绘图配置。") : null
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// 2026-09-08 (DSH 0.1.2-rc.1): `ctx.connection.api` no longer exists — the
|
|
1462
|
+
// client API moved to the `remote.*` service namespace (remote.llm.*,
|
|
1463
|
+
// remote.settings.*, remote.session.*). Declaring them also defers this
|
|
1464
|
+
// plugin until the mux connection is up, so the settings page never loads
|
|
1465
|
+
// against a dead API surface.
|
|
1466
|
+
const inject = ["slots", "remote", "remote.llm", "remote.settings", "remote.session", "remote.credentials", "locale", "connection"];
|
|
1467
|
+
|
|
1468
|
+
function apply(ctx) {
|
|
1469
|
+
const style = document.createElement("style");
|
|
1470
|
+
style.dataset.plugin = "dsh-plugin-model-tuning";
|
|
1471
|
+
style.textContent = CSS;
|
|
1472
|
+
document.head.appendChild(style);
|
|
1473
|
+
ctx.effect(() => () => { style.remove(); });
|
|
1474
|
+
|
|
1475
|
+
if (ctx.locale) {
|
|
1476
|
+
// 生产 dsh-client-locale 的 LOCALE_IDS 是 ["zh", "en"];multi-locale
|
|
1477
|
+
// 形态一次写入 zh / en。ctx.effect 挂进 fiber 生命周期,HMR 再激活
|
|
1478
|
+
// 不会重复注册。
|
|
1479
|
+
ctx.effect(() => ctx.locale.register("dsh-plugin-model-tuning", {
|
|
1480
|
+
en: {
|
|
1481
|
+
"tab.models": "Model parameters",
|
|
1482
|
+
"tab.purpose": "Purposes",
|
|
1483
|
+
"tab.image": "Images",
|
|
1484
|
+
"title.name": "Session titles",
|
|
1485
|
+
"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.",
|
|
1486
|
+
"title.inherit": "Inherit from the current session (default)",
|
|
1487
|
+
"title.custom": "Use a specific model",
|
|
1488
|
+
"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.",
|
|
1489
|
+
"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.",
|
|
1490
|
+
"title.save": "Save title settings",
|
|
1491
|
+
},
|
|
1492
|
+
zh: {
|
|
1493
|
+
"tab.models": "模型参数",
|
|
1494
|
+
"tab.purpose": "用途",
|
|
1495
|
+
"tab.image": "绘图",
|
|
1496
|
+
"title.name": "标题生成",
|
|
1497
|
+
"title.desc": "会话标题由一次独立的辅助请求生成,不会改动对话模型。默认继承当前会话的 provider / model。",
|
|
1498
|
+
"title.inherit": "继承当前会话(默认)",
|
|
1499
|
+
"title.custom": "使用指定模型",
|
|
1500
|
+
"title.scope": "生效范围:保存后对本 profile 之后生成的标题生效;不会改动已有标题、手动重命名,也不会改动会话的对话模型。",
|
|
1501
|
+
"title.missing": "未检测到 model-tuning 设置命名空间:宿主半区未激活(标题 provider 未注册),请检查插件是否启用并重启 dsh。",
|
|
1502
|
+
"title.save": "保存标题设置",
|
|
1503
|
+
},
|
|
1504
|
+
}));
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const remote = ctx.remote;
|
|
1508
|
+
const wrap = (response, map) => response.ok
|
|
1509
|
+
? { result: { ok: true, value: map(response.value) } }
|
|
1510
|
+
: { result: { ok: false, error: response.error } };
|
|
1511
|
+
const api = {
|
|
1512
|
+
llm: {
|
|
1513
|
+
providers: async () => {
|
|
1514
|
+
const response = await remote.llm.listConfigurableProviders();
|
|
1515
|
+
return wrap(response, (value) => ({ providers: value }));
|
|
1516
|
+
},
|
|
1517
|
+
models: async () => {
|
|
1518
|
+
const response = await remote.session.modelCatalog();
|
|
1519
|
+
return wrap(response, (value) => ({ groups: value.groups }));
|
|
1520
|
+
},
|
|
1521
|
+
},
|
|
1522
|
+
settings: {
|
|
1523
|
+
describe: async () => {
|
|
1524
|
+
const response = await remote.settings.describe();
|
|
1525
|
+
return wrap(response, (value) => value);
|
|
1526
|
+
},
|
|
1527
|
+
mutate: async (args) => {
|
|
1528
|
+
const response = await remote.settings.mutate(args.ns, args.ops, args.expectedRevision);
|
|
1529
|
+
return wrap(response, () => ({}));
|
|
1530
|
+
},
|
|
1531
|
+
},
|
|
1532
|
+
};
|
|
1533
|
+
const timer = ctx.get("timer");
|
|
1534
|
+
|
|
1535
|
+
// v2: the Remote call path for the 直接绘图 panel (ex image-generation
|
|
1536
|
+
// client half). `connection.rpc.call('/api', 'imageGeneration/<method>')`.
|
|
1537
|
+
const unwrap = (response) => { if (!response.ok) throw new Error(response.error?.message ?? "请求失败"); return response.value; };
|
|
1538
|
+
const call = async (method, args) => unwrap(await ctx.connection.rpc.call("/api", `imageGeneration/${method}`, { args }));
|
|
1539
|
+
|
|
1540
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register(
|
|
1541
|
+
{ name: "settings.section", id: "model-tuning", order: 11, label: () => "模型调参" },
|
|
1542
|
+
() => React.createElement(ModelTuningPage, { api, timer, remote, call }),
|
|
1543
|
+
));
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
exports.apply = apply;
|
|
1547
|
+
exports.inject = inject;
|
|
1548
|
+
return module.exports;
|
|
1549
|
+
}
|
|
1550
|
+
});
|