dsh-model-extension 1.1.0 → 1.2.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.
package/lib/client.js CHANGED
@@ -217,8 +217,10 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
217
217
  /** Same-origin routes served by the server half. */
218
218
  const INDEX_URL = "/plugins/dsh-model-extension/models-index";
219
219
  const DOWNLOAD_URL = "/plugins/dsh-model-extension/models-download";
220
- /** Module-level cache: one page load fetches the index at most once. */
221
- let cached;
220
+ const PI_AI_URL = "/plugins/dsh-model-extension/pi-ai-catalog";
221
+ /** Module-level cache: one page load fetches each source at most once. */
222
+ let cachedIndex;
223
+ /** Normalize the models.dev route payload. */
222
224
  function normalize(raw) {
223
225
  if (!Array.isArray(raw)) return [];
224
226
  const entries = [];
@@ -237,23 +239,137 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
237
239
  }
238
240
  return entries;
239
241
  }
242
+ /** Narrow a modality list to what this UI offers. */
243
+ function supportedInput(value) {
244
+ if (!Array.isArray(value)) return [];
245
+ return value.filter((m) => typeof m === "string" && (m === "text" || m === "image"));
246
+ }
247
+ /** Read one object-valued field off a raw record. */
248
+ function objectField(source, key) {
249
+ const value = source[key];
250
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
251
+ }
252
+ /**
253
+ * The provider a models.dev id belongs to.
254
+ *
255
+ * models.dev ids are directory paths (`zhipuai/glm-5.3-flash`), so the leading
256
+ * segment names the provider. Reading it here gives the picker the same
257
+ * provider-first navigation that pi-ai's per-provider catalog has for free —
258
+ * and it is what lets a user narrow by vendor instead of guessing between
259
+ * same-named ids.
260
+ * @param id - the models.dev directory id.
261
+ * @returns the provider segment, or undefined when the id carries none.
262
+ */
263
+ function modelsDevProvider(id) {
264
+ const slash = id.indexOf("/");
265
+ return slash > 0 ? id.slice(0, slash) : void 0;
266
+ }
267
+ /** Turn models.dev rows into prefill candidates. */
268
+ function toModelsDevEntries(rows) {
269
+ return rows.map((row) => ({
270
+ source: "models.dev",
271
+ key: `models.dev\u0000${row.id}`,
272
+ id: row.id,
273
+ provider: modelsDevProvider(row.id),
274
+ context: row.context,
275
+ output: row.output,
276
+ input: row.input,
277
+ reasoning: row.reasoning
278
+ }));
279
+ }
280
+ /** Read the diagnostics warnings the server attaches to a catalog payload. */
281
+ function diagnosticsWarnings(payload) {
282
+ if (typeof payload !== "object" || payload === null) return [];
283
+ const diagnostics = payload["diagnostics"];
284
+ if (typeof diagnostics !== "object" || diagnostics === null) return [];
285
+ const warnings = diagnostics["warnings"];
286
+ if (!Array.isArray(warnings)) return [];
287
+ return warnings.filter((warning) => typeof warning === "string");
288
+ }
289
+ /** Turn a pi-ai route payload into prefill candidates, keeping both ids' provenance. */
290
+ function toPiAiEntries(payload) {
291
+ const warnings = diagnosticsWarnings(payload);
292
+ if (typeof payload !== "object" || payload === null) return {
293
+ entries: [],
294
+ warnings
295
+ };
296
+ const body = payload;
297
+ if (!Array.isArray(body["models"])) return {
298
+ entries: [],
299
+ warnings
300
+ };
301
+ const entries = [];
302
+ for (const item of body["models"]) {
303
+ if (typeof item !== "object" || item === null) continue;
304
+ const row = item;
305
+ const id = row["id"];
306
+ const provider = row["provider"];
307
+ if (typeof id !== "string" || id.length === 0) continue;
308
+ if (typeof provider !== "string" || provider.length === 0) continue;
309
+ entries.push({
310
+ source: "pi-ai",
311
+ key: `pi-ai\u0000${provider}\u0000${id}`,
312
+ id,
313
+ provider,
314
+ name: typeof row["name"] === "string" && row["name"].length > 0 ? row["name"] : void 0,
315
+ context: typeof row["contextWindow"] === "number" ? row["contextWindow"] : void 0,
316
+ output: typeof row["maxTokens"] === "number" ? row["maxTokens"] : void 0,
317
+ input: supportedInput(row["input"]),
318
+ reasoning: row["reasoning"] === true,
319
+ api: typeof row["api"] === "string" ? row["api"] : void 0,
320
+ thinkingLevelMap: objectField(row, "thinkingLevelMap"),
321
+ compat: objectField(row, "compat")
322
+ });
323
+ }
324
+ return {
325
+ entries,
326
+ warnings,
327
+ version: typeof body["piAiVersion"] === "string" ? body["piAiVersion"] : void 0,
328
+ generatedAt: typeof body["generatedAt"] === "string" ? body["generatedAt"] : void 0
329
+ };
330
+ }
240
331
  /**
241
- * The metadata index, fetched once per page load; `undefined` while the
242
- * server has no metadata file (the caller shows the not-ready hint).
332
+ * Fetch one JSON route, keeping a refusal distinguishable from an empty source.
333
+ *
334
+ * The distinction matters for the pi-ai half: "the host ships no catalog" and
335
+ * "the host's catalog could not be called" both leave the list empty, but only
336
+ * the second one has a fix, and the server names it in the refusal body.
337
+ * @param url - the same-origin route.
338
+ * @returns the parsed payload, or the reason it is missing.
243
339
  */
244
- async function getMetadataIndex() {
245
- if (cached !== void 0) return cached;
340
+ async function fetchJson(url) {
246
341
  try {
247
- const response = await fetch(INDEX_URL);
248
- if (!response.ok) return void 0;
249
- cached = normalize(await response.json());
250
- return cached;
251
- } catch {
252
- return;
342
+ const response = await fetch(url);
343
+ const payload = await response.json().catch(() => void 0);
344
+ if (response.ok) return { payload };
345
+ const message = typeof payload === "object" && payload !== null ? payload["message"] : void 0;
346
+ return { failure: typeof message === "string" ? message : `HTTP ${String(response.status)}` };
347
+ } catch (error) {
348
+ return { failure: error instanceof Error ? error.message : String(error) };
253
349
  }
254
350
  }
255
351
  /**
256
- * Download/refresh the metadata file through the server half (user-triggered
352
+ * The prefill index, fetched once per page load. Both sources are requested
353
+ * together and either may come back empty; an entirely empty index is a
354
+ * legitimate answer the caller renders as "nothing to load yet".
355
+ * @returns the merged index.
356
+ */
357
+ async function getPrefillIndex() {
358
+ if (cachedIndex !== void 0) return cachedIndex;
359
+ const [piAiResult, modelsDevResult] = await Promise.all([fetchJson(PI_AI_URL), fetchJson(INDEX_URL)]);
360
+ const piAi = toPiAiEntries(piAiResult.payload);
361
+ cachedIndex = {
362
+ piAi: piAi.entries,
363
+ modelsDev: toModelsDevEntries(normalize(modelsDevResult.payload)),
364
+ piAiVersion: piAi.version,
365
+ piAiGeneratedAt: piAi.generatedAt,
366
+ piAiWarnings: piAi.warnings,
367
+ ...piAiResult.failure !== void 0 ? { piAiFailure: piAiResult.failure } : {}
368
+ };
369
+ return cachedIndex;
370
+ }
371
+ /**
372
+ * Download/refresh the models.dev file through the server half (user-triggered
257
373
  * from the title-row button). A success also refreshes the local cache.
258
374
  */
259
375
  async function downloadMetadata() {
@@ -264,7 +380,7 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
264
380
  ok: false,
265
381
  message: body?.message ?? `HTTP ${String(response.status)}`
266
382
  };
267
- cached = normalize(body["index"]);
383
+ cachedIndex = void 0;
268
384
  return { ok: true };
269
385
  } catch (error) {
270
386
  return {
@@ -274,24 +390,61 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
274
390
  }
275
391
  }
276
392
  /**
277
- * Case-insensitive substring match over model ids, at most {@link limit}
278
- * candidates (plan: show ids only, never the whole list).
279
- * @param index - the metadata index, or undefined while unavailable.
280
- * @param query - the user's typed text.
281
- * @param limit - maximum candidates to return.
282
- * @returns the matching ids; empty when the index is unavailable.
393
+ * How many candidates the picker renders before it stops and reports the rest.
394
+ *
395
+ * Ten was too few once one id could arrive from a dozen providers; fifty shows
396
+ * a vendor's catalog without pouring a 1354-row index into the DOM.
397
+ */
398
+ const DEFAULT_PREFILL_LIMIT = 50;
399
+ /**
400
+ * The providers a source offers, alphabetically.
401
+ *
402
+ * This is what turns "which `glm-5.3-flash` did you mean?" into a choice the
403
+ * user can actually make: the same id ships under several providers, and each
404
+ * provider's copy can carry different capacities and compat switches.
405
+ * @param index - the fetched index, or undefined while the page still loads.
406
+ * @param source - the source to enumerate.
407
+ * @returns the provider names; empty when the source has none.
283
408
  */
284
- function searchModelIds(index, query, limit = 10) {
409
+ function providersOf(index, source) {
285
410
  if (index === void 0) return [];
286
- const needle = query.trim().toLowerCase();
287
- if (needle.length === 0) return [];
411
+ const entries = source === "pi-ai" ? index.piAi : index.modelsDev;
412
+ const providers = /* @__PURE__ */ new Set();
413
+ for (const entry of entries) if (entry.provider !== void 0 && entry.provider.length > 0) providers.add(entry.provider);
414
+ return [...providers].sort((a, b) => a.localeCompare(b));
415
+ }
416
+ /**
417
+ * Candidates matching one source, one provider and one id substring.
418
+ *
419
+ * Either filter may be blank, and a blank one *widens* rather than matching
420
+ * nothing: a provider with no id typed lists that provider's whole catalog,
421
+ * which is how a user browses a vendor whose model names they have not
422
+ * memorized. Both blank lists the source's first page.
423
+ * @param index - the fetched index, or undefined while the page still loads.
424
+ * @param query - the source, provider, id substring and cap.
425
+ * @returns the capped candidates and the total match count.
426
+ */
427
+ function searchPrefill(index, query) {
428
+ if (index === void 0) return {
429
+ hits: [],
430
+ total: 0
431
+ };
432
+ const entries = query.source === "pi-ai" ? index.piAi : index.modelsDev;
433
+ const provider = query.provider?.trim() ?? "";
434
+ const needle = query.text?.trim().toLowerCase() ?? "";
435
+ const limit = query.limit ?? DEFAULT_PREFILL_LIMIT;
288
436
  const hits = [];
289
- for (const entry of index) {
290
- if (!entry.id.toLowerCase().includes(needle)) continue;
291
- hits.push(entry.id);
292
- if (hits.length >= limit) break;
437
+ let total = 0;
438
+ for (const entry of entries) {
439
+ if (provider.length > 0 && entry.provider !== provider) continue;
440
+ if (needle.length > 0 && !entry.id.toLowerCase().includes(needle)) continue;
441
+ total += 1;
442
+ if (hits.length < limit) hits.push(entry);
293
443
  }
294
- return hits;
444
+ return {
445
+ hits,
446
+ total
447
+ };
295
448
  }
296
449
  //#endregion
297
450
  //#region src/client/models-plus/compat.ts
@@ -494,6 +647,85 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
494
647
  "string-thinking",
495
648
  "ant-ling"
496
649
  ];
650
+ /**
651
+ * The compatibility switches llm-pi-ai *offers*, by wire protocol (the `offer`
652
+ * set of its COMPAT_GATES @ 0.1.5-rc.2). Only these may reach a model row: the
653
+ * adapter refuses a switch no protocol offers rather than ignoring it, so
654
+ * copying pi-ai's catalog `compat` across verbatim would fail resolution on
655
+ * the withheld fields it also records (routing preferences, session affinity,
656
+ * deferred tools, grammar tools). Re-check against the host's COMPAT_GATES on
657
+ * every adapter-anchor bump — the same drift gate the host keeps for itself.
658
+ */
659
+ const COMPAT_FIELDS = {
660
+ "openai-completions": [
661
+ "supportsStore",
662
+ "supportsDeveloperRole",
663
+ "supportsReasoningEffort",
664
+ "supportsUsageInStreaming",
665
+ "supportsFinishReason",
666
+ "maxTokensField",
667
+ "requiresToolResultName",
668
+ "requiresAssistantAfterToolResult",
669
+ "requiresThinkingAsText",
670
+ "requiresReasoningContentOnAssistantMessages",
671
+ "thinkingFormat",
672
+ "chatTemplateKwargs",
673
+ "chatTemplateArgs",
674
+ "supportsThinkingTokenBudget",
675
+ "thinkingTokenBudgetField",
676
+ "vllmPriority",
677
+ "supportsStrictMode",
678
+ "cacheControlFormat",
679
+ "supportsLongCacheRetention"
680
+ ],
681
+ "openai-responses": [
682
+ "supportsDeveloperRole",
683
+ "supportsMaxOutputTokens",
684
+ "supportsStrictMode",
685
+ "supportsLongCacheRetention"
686
+ ],
687
+ "azure-openai-responses": [
688
+ "supportsDeveloperRole",
689
+ "supportsMaxOutputTokens",
690
+ "supportsStrictMode",
691
+ "supportsLongCacheRetention"
692
+ ],
693
+ "openai-codex-responses": [
694
+ "supportsDeveloperRole",
695
+ "supportsMaxOutputTokens",
696
+ "supportsStrictMode",
697
+ "supportsLongCacheRetention"
698
+ ],
699
+ "anthropic-messages": [
700
+ "supportsEagerToolInputStreaming",
701
+ "supportsLongCacheRetention",
702
+ "supportsCacheControlOnTools",
703
+ "supportsTemperature",
704
+ "forceAdaptiveThinking",
705
+ "allowEmptySignature",
706
+ "supportsStrictTools"
707
+ ],
708
+ "bedrock-converse-stream": ["supportsStrictMode"]
709
+ };
710
+ /**
711
+ * Switches the base panel already renders a dedicated control for; the
712
+ * "other compatibility switches" block lists everything else.
713
+ */
714
+ const COMPAT_BASE_FIELDS = [
715
+ "supportsDeveloperRole",
716
+ "supportsReasoningEffort",
717
+ "thinkingFormat"
718
+ ];
719
+ /**
720
+ * The offered compat field names for one wire protocol.
721
+ * @param api - the model's protocol, when known.
722
+ * @returns the field names; empty for an unknown or absent protocol.
723
+ */
724
+ function offeredCompatFields(api) {
725
+ if (api === void 0) return [];
726
+ const fields = COMPAT_FIELDS[api];
727
+ return fields === void 0 ? [] : fields;
728
+ }
497
729
  /** Extra copy for the extension, keyed like the official dictionaries (en is authoritative). */
498
730
  const en = {
499
731
  nav: "Models+",
@@ -719,6 +951,104 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
719
951
  thinkingFormat: "思考格式"
720
952
  };
721
953
  //#endregion
954
+ //#region src/client/models-plus/prefill.ts
955
+ /**
956
+ * Mapping pi-ai catalog values onto a Models+ row.
957
+ *
958
+ * The two sides describe the same thing, but two conventions differ in a way
959
+ * that makes a straight copy wrong:
960
+ *
961
+ * 1. **`null` means the opposite in each.** pi-ai's `thinkingLevelMap[level]`
962
+ * is *unsupported* when null; our `reasoningEfforts[level]` is *supported,
963
+ * send nothing* when null (the adapter then drops the key entirely, and
964
+ * pi-ai reads an absent `off` key as "supported but send no parameter").
965
+ * Copying one across would invert the meaning for 338 of the catalog's
966
+ * models, so values are mapped rather than copied: a pi-ai `null` becomes
967
+ * "omit our key", and an absent pi-ai key becomes an explicit entry.
968
+ * 2. **An absent base level has a protocol-specific default.** pi-ai resolves
969
+ * an absent `off`/`minimal`/`low`/`medium`/`high` by falling back to the
970
+ * level name — except on the Anthropic protocols, where `minimal` collapses
971
+ * onto `low`, and on Mistral, where every level resolves to `high`. Writing
972
+ * the level name there would send a spelling those providers never see.
973
+ *
974
+ * `xhigh` and `max` are the one pair pi-ai treats as unsupported when absent,
975
+ * so they are only written when the catalog declares them.
976
+ *
977
+ * @module dsh-model-extension/prefill
978
+ */
979
+ /** The seven levels, in pi-ai's escalation order. */
980
+ const LEVELS = [
981
+ "off",
982
+ "minimal",
983
+ "low",
984
+ "medium",
985
+ "high",
986
+ "xhigh",
987
+ "max"
988
+ ];
989
+ /**
990
+ * The wire spelling pi-ai falls back to for one absent base level, per
991
+ * protocol. This mirrors the fallbacks inside pi-ai's own dispatch branches.
992
+ * @param api - the model's wire protocol.
993
+ * @param level - the level whose key was absent.
994
+ * @returns the spelling pi-ai would send for that level.
995
+ */
996
+ function defaultWireSpelling(api, level) {
997
+ if (api === "anthropic-messages" || api === "bedrock-converse-stream") return level === "minimal" ? "low" : level;
998
+ if (api === "mistral-conversations") return "high";
999
+ return level;
1000
+ }
1001
+ /**
1002
+ * Convert a catalog `thinkingLevelMap` into our `reasoningEfforts` dict.
1003
+ *
1004
+ * A model that does not reason gets `false`. An outcome that offers nothing
1005
+ * beyond `off` also becomes `false`, because the adapter rejects a dict whose
1006
+ * only level is `off` — and the two describe the same request anyway.
1007
+ * @param levelMap - the catalog's map, when it carries one.
1008
+ * @param api - the model's wire protocol, which picks the fallback spellings.
1009
+ * @param reasoning - whether the model reasons at all.
1010
+ * @returns the dict to write into the row, or `false` for a non-reasoning model.
1011
+ */
1012
+ function reasoningEffortsFrom(levelMap, api, reasoning) {
1013
+ if (!reasoning) return false;
1014
+ const map = levelMap ?? {};
1015
+ const efforts = {};
1016
+ for (const level of LEVELS) {
1017
+ if (!(level in map)) {
1018
+ if (level === "off") {
1019
+ efforts[level] = null;
1020
+ continue;
1021
+ }
1022
+ if (level === "xhigh" || level === "max") continue;
1023
+ efforts[level] = defaultWireSpelling(api, level);
1024
+ continue;
1025
+ }
1026
+ const wire = map[level];
1027
+ if (wire === null) continue;
1028
+ efforts[level] = wire;
1029
+ }
1030
+ return Object.keys(efforts).some((level) => level !== "off") ? efforts : false;
1031
+ }
1032
+ /**
1033
+ * Project a catalog `compat` block onto the switches the host offers for one
1034
+ * protocol.
1035
+ *
1036
+ * The filter is load-bearing: pi-ai's catalog also records withheld switches
1037
+ * (routing preferences, session affinity, deferred tools, grammar tools), and
1038
+ * the adapter *refuses* a switch no protocol offers instead of ignoring it — a
1039
+ * verbatim copy would make the route fail to resolve.
1040
+ * @param compat - the catalog's compat record, when it carries one.
1041
+ * @param api - the model's wire protocol.
1042
+ * @returns the writable subset; empty when nothing survives.
1043
+ */
1044
+ function compatFrom(compat, api) {
1045
+ const offered = offeredCompatFields(api);
1046
+ if (offered.length === 0 || compat === void 0) return {};
1047
+ const next = {};
1048
+ for (const field of offered) if (field in compat) next[field] = compat[field];
1049
+ return next;
1050
+ }
1051
+ //#endregion
722
1052
  //#region src/client/models-plus/models-plus.module.css
723
1053
  var models_plus_module_default = {
724
1054
  "addActions": "J84-9a_addActions",
@@ -812,33 +1142,164 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
812
1142
  //#endregion
813
1143
  //#region src/client/models-plus/QuickLoad.tsx
814
1144
  /**
815
- * Quick-load: a searchable dropdown over the models.dev metadata index.
816
- * Plan constraints: ids only, case-insensitive substring match, at most 10
817
- * candidates rendered — the index is filtered on every keystroke and the
818
- * candidate list is only mounted while open, so a 370-row index never lands
819
- * in the DOM wholesale.
1145
+ * Quick-load: source → provider → model id, three narrowing steps.
1146
+ *
1147
+ * The single search box could not answer the question it raised. One model id
1148
+ * ships under a dozen providers (`glm-5.3-flash` exists in zai, opencode,
1149
+ * opencode-go, baseten, together, huggingface and two gateways), and each
1150
+ * provider's copy can carry different capacities and compat switches — so a
1151
+ * flat list of ten same-looking ids left the user to pick blind, or to decode
1152
+ * a suffix the plugin had appended for want of a better idea.
1153
+ *
1154
+ * Splitting the axis fixes it at the source: the user states *which catalog*
1155
+ * and *whose copy* first, and the id search then runs inside that slice.
1156
+ *
1157
+ * Both drop-downs are plugin-rendered rather than native. A `<datalist>` was
1158
+ * tried for the provider field and abandoned: the browser positions its popup
1159
+ * itself (it opened across unrelated fields) and none of it is themeable, so
1160
+ * it could not look like the list beside it. One `quickList` now serves both,
1161
+ * which also keeps only one popup on screen at a time.
1162
+ */
1163
+ /** The two sources, in the order the picker offers them; pi-ai leads by default. */
1164
+ const SOURCES = [{
1165
+ id: "pi-ai",
1166
+ label: "llm-pi-ai"
1167
+ }, {
1168
+ id: "models.dev",
1169
+ label: "models.dev"
1170
+ }];
1171
+ /**
1172
+ * One line describing what the selected source currently offers, shown while
1173
+ * nothing is typed. The pi-ai version and snapshot date matter here: a DSH home
1174
+ * can hold more than one pi-ai copy, and knowing which one answered is the
1175
+ * difference between a diagnosis and a mystery.
1176
+ * @param index - the fetched index.
1177
+ * @param source - the selected source.
1178
+ * @returns the summary line.
820
1179
  */
1180
+ function sourceSummary(index, source) {
1181
+ if (source === "pi-ai") {
1182
+ if (index.piAi.length === 0) return index.piAiFailure === void 0 ? "pi-ai 目录不可用" : `pi-ai 目录不可用:${index.piAiFailure}`;
1183
+ const version = index.piAiVersion === void 0 ? "" : ` v${index.piAiVersion}`;
1184
+ const when = index.piAiGeneratedAt === void 0 ? "" : ` · ${index.piAiGeneratedAt.slice(0, 10)}`;
1185
+ return `pi-ai 目录${version},${String(index.piAi.length)} 个模型${when};选择提供方或输入 model id`;
1186
+ }
1187
+ if (index.modelsDev.length === 0) return "models.dev 元数据未下载——点击页首「下载/更新元数据」";
1188
+ return `models.dev,${String(index.modelsDev.length)} 条;选择提供方或输入 model id`;
1189
+ }
821
1190
  /**
822
- * Render the quick-load input with its filtered candidate dropdown.
1191
+ * Render the three-step quick-load picker and its candidate list.
823
1192
  * @param props - the pick handler and disabled flag.
824
- * @returns the quick-load field.
1193
+ * @returns the quick-load block.
825
1194
  */
826
1195
  function QuickLoad(props) {
827
- const [query, setQuery] = (0, react.useState)("");
828
- const [open, setOpen] = (0, react.useState)(false);
1196
+ const [source, setSource] = (0, react.useState)("pi-ai");
1197
+ const [provider, setProvider] = (0, react.useState)("");
1198
+ const [text, setText] = (0, react.useState)("");
1199
+ const [panel, setPanel] = (0, react.useState)(null);
829
1200
  const [index, setIndex] = (0, react.useState)(void 0);
1201
+ const textField = (0, react.useRef)(null);
830
1202
  const ensureIndex = () => {
831
- if (index !== void 0) return;
832
- getMetadataIndex().then((loaded) => {
1203
+ getPrefillIndex().then((loaded) => {
833
1204
  setIndex(loaded);
834
1205
  });
835
1206
  };
836
- const hits = searchModelIds(index, query, 10);
837
- const pick = (id) => {
838
- const entry = index?.find((candidate) => candidate.id === id);
839
- setOpen(false);
840
- setQuery("");
841
- if (entry !== void 0) props.onPick(entry);
1207
+ const providers = providersOf(index, source);
1208
+ const chosenProvider = providers.includes(provider) ? provider : "";
1209
+ const { hits, total } = searchPrefill(index, {
1210
+ source,
1211
+ provider: chosenProvider,
1212
+ text
1213
+ });
1214
+ const pick = (entry) => {
1215
+ setPanel(null);
1216
+ setText(entry.id);
1217
+ props.onPick(entry);
1218
+ };
1219
+ const switchSource = (next) => {
1220
+ setSource(next);
1221
+ setProvider("");
1222
+ setText("");
1223
+ setPanel(null);
1224
+ };
1225
+ /**
1226
+ * Commit one provider choice and move straight on to the id field, so the
1227
+ * flow is pick → type rather than pick → click → type.
1228
+ * @param name - the provider to select; blank means "every provider".
1229
+ */
1230
+ const chooseProvider = (name) => {
1231
+ setProvider(name);
1232
+ setText("");
1233
+ textField.current?.focus();
1234
+ };
1235
+ const providerBody = () => {
1236
+ if (index === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1237
+ className: models_plus_module_default["quickEmpty"],
1238
+ children: "正在载入预填数据…"
1239
+ });
1240
+ const needle = provider.trim().toLowerCase();
1241
+ const matches = needle.length === 0 ? providers : providers.filter((name) => name.toLowerCase().includes(needle));
1242
+ if (providers.length === 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1243
+ className: models_plus_module_default["quickEmpty"],
1244
+ children: "该数据源没有提供方信息"
1245
+ });
1246
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1247
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1248
+ type: "button",
1249
+ className: models_plus_module_default["quickItem"],
1250
+ onMouseDown: (event) => {
1251
+ event.preventDefault();
1252
+ chooseProvider("");
1253
+ },
1254
+ children: "全部(不限提供方)"
1255
+ }),
1256
+ matches.map((name) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1257
+ type: "button",
1258
+ className: models_plus_module_default["quickItem"],
1259
+ onMouseDown: (event) => {
1260
+ event.preventDefault();
1261
+ chooseProvider(name);
1262
+ },
1263
+ children: name
1264
+ }, name)),
1265
+ matches.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1266
+ className: models_plus_module_default["quickEmpty"],
1267
+ children: "没有匹配的提供方"
1268
+ }) : null
1269
+ ] });
1270
+ };
1271
+ const modelBody = () => {
1272
+ if (index === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1273
+ className: models_plus_module_default["quickEmpty"],
1274
+ children: "正在载入预填数据…"
1275
+ });
1276
+ if (chosenProvider.length === 0 && text.trim().length === 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1277
+ className: models_plus_module_default["quickEmpty"],
1278
+ children: sourceSummary(index, source)
1279
+ }), source === "pi-ai" && index.piAiWarnings.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1280
+ className: models_plus_module_default["error"],
1281
+ children: index.piAiWarnings.join(";")
1282
+ }) : null] });
1283
+ if (hits.length === 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1284
+ className: models_plus_module_default["quickEmpty"],
1285
+ children: "没有匹配的模型"
1286
+ });
1287
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [hits.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1288
+ type: "button",
1289
+ className: models_plus_module_default["quickItem"],
1290
+ onMouseDown: (event) => {
1291
+ event.preventDefault();
1292
+ pick(entry);
1293
+ },
1294
+ children: entry.id
1295
+ }, entry.key)), total > hits.length ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1296
+ className: models_plus_module_default["quickEmpty"],
1297
+ children: [
1298
+ "另有 ",
1299
+ total - hits.length,
1300
+ " 项匹配——继续输入或选定提供方以缩小范围"
1301
+ ]
1302
+ }) : null] });
842
1303
  };
843
1304
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
844
1305
  className: models_plus_module_default["quickWrap"],
@@ -847,42 +1308,88 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
847
1308
  className: models_plus_module_default["extLabel"],
848
1309
  children: "快速装入"
849
1310
  }),
850
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
851
- className: models_plus_module_default["input"],
852
- type: "text",
853
- value: query,
854
- placeholder: "搜索模型 id(如 aper)…",
855
- "aria-label": "快速装入",
856
- disabled: props.disabled,
857
- onFocus: () => {
858
- ensureIndex();
859
- setOpen(true);
860
- },
861
- onBlur: () => {
862
- setOpen(false);
1311
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1312
+ style: {
1313
+ display: "flex",
1314
+ gap: 6,
1315
+ alignItems: "center"
863
1316
  },
864
- onChange: (event) => {
865
- setQuery(event.target.value);
866
- setOpen(true);
867
- }
1317
+ children: [
1318
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1319
+ className: `${models_plus_module_default["input"]} ${models_plus_module_default["selectInput"]}`,
1320
+ style: {
1321
+ flex: "0 0 108px",
1322
+ minWidth: 0
1323
+ },
1324
+ value: source,
1325
+ disabled: props.disabled,
1326
+ "aria-label": "数据源",
1327
+ onChange: (event) => {
1328
+ switchSource(event.target.value);
1329
+ },
1330
+ children: SOURCES.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1331
+ value: option.id,
1332
+ children: option.label
1333
+ }, option.id))
1334
+ }),
1335
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1336
+ className: models_plus_module_default["input"],
1337
+ style: {
1338
+ flex: "0 0 132px",
1339
+ minWidth: 0
1340
+ },
1341
+ type: "text",
1342
+ value: provider,
1343
+ placeholder: "提供方:全部",
1344
+ "aria-label": "提供方",
1345
+ autoComplete: "off",
1346
+ disabled: props.disabled,
1347
+ onFocus: () => {
1348
+ ensureIndex();
1349
+ setPanel("provider");
1350
+ },
1351
+ onBlur: () => {
1352
+ setPanel(null);
1353
+ },
1354
+ onChange: (event) => {
1355
+ setProvider(event.target.value);
1356
+ setPanel("provider");
1357
+ }
1358
+ }),
1359
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1360
+ className: models_plus_module_default["input"],
1361
+ style: {
1362
+ flex: "1 1 auto",
1363
+ minWidth: 0
1364
+ },
1365
+ type: "text",
1366
+ ref: textField,
1367
+ value: text,
1368
+ placeholder: "model id(如 glm)…",
1369
+ "aria-label": "model id",
1370
+ autoComplete: "off",
1371
+ disabled: props.disabled,
1372
+ onFocus: () => {
1373
+ ensureIndex();
1374
+ setPanel("model");
1375
+ },
1376
+ onBlur: () => {
1377
+ setPanel(null);
1378
+ },
1379
+ onChange: (event) => {
1380
+ setText(event.target.value);
1381
+ setPanel("model");
1382
+ }
1383
+ })
1384
+ ]
868
1385
  }),
869
- open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1386
+ panel === "provider" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
870
1387
  className: models_plus_module_default["quickList"],
871
- children: index === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
872
- className: models_plus_module_default["quickEmpty"],
873
- children: "元数据预填不可用——点击页首「下载/更新元数据」"
874
- }) : hits.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
875
- className: models_plus_module_default["quickEmpty"],
876
- children: query.trim().length === 0 ? "输入关键字搜索模型 id" : "没有匹配的模型"
877
- }) : hits.map((id) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
878
- type: "button",
879
- className: models_plus_module_default["quickItem"],
880
- onMouseDown: (event) => {
881
- event.preventDefault();
882
- pick(id);
883
- },
884
- children: id
885
- }, id))
1388
+ children: providerBody()
1389
+ }) : null,
1390
+ panel === "model" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1391
+ className: models_plus_module_default["quickList"],
1392
+ children: modelBody()
886
1393
  }) : null
887
1394
  ]
888
1395
  });
@@ -900,6 +1407,15 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
900
1407
  * the effort gate drops `reasoningEfforts` AND `compat.thinkingFormat` in ONE
901
1408
  * commit so partial updates never overwrite each other (no orphan settings).
902
1409
  */
1410
+ /**
1411
+ * Whether a draft field is absent or whitespace-only.
1412
+ * @param value - the field's current value.
1413
+ * @returns true when the field carries nothing worth keeping.
1414
+ */
1415
+ function isBlank(value) {
1416
+ if (value === void 0 || value === null) return true;
1417
+ return typeof value === "string" && value.trim().length === 0;
1418
+ }
903
1419
  /** The row's compat sub-object as a plain record (absent → empty). */
904
1420
  function compatOf(model) {
905
1421
  const value = model["compat"];
@@ -944,21 +1460,44 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
944
1460
  const setInput = (modalities) => {
945
1461
  setKey("input", modalities.length === 0 ? void 0 : [...modalities]);
946
1462
  };
1463
+ /**
1464
+ * Apply one prefill candidate.
1465
+ *
1466
+ * Every value is *restated* from the picked source rather than merged into
1467
+ * whatever was already there: capacities, modalities, reasoning levels and
1468
+ * compat are replaced wholesale. A prefill answers "what does this catalog
1469
+ * say about the model", so an absent field clears — leaving another source's
1470
+ * value behind would describe a model neither catalog claims, and switching
1471
+ * sources then re-picking is meant to be a clean rewrite.
1472
+ *
1473
+ * Two things do not follow that rule. `id` and `name` are filled only into a
1474
+ * blank row, because a catalog id is the request id for the vendor's *own*
1475
+ * endpoint while behind a gateway the string on the wire is often different.
1476
+ * And pi-ai's level map is *mapped* rather than copied, because `null` means
1477
+ * the opposite on each side.
1478
+ * @param entry - the picked candidate.
1479
+ */
947
1480
  const applyMetadata = (entry) => {
948
1481
  const next = { ...model };
949
1482
  if (entry.context === void 0) Reflect.deleteProperty(next, "contextWindow");
950
1483
  else next["contextWindow"] = entry.context;
951
1484
  if (entry.output === void 0) Reflect.deleteProperty(next, "maxTokens");
952
1485
  else next["maxTokens"] = entry.output;
953
- const compat = { ...compatOf(model) };
954
- compat["supportsReasoningEffort"] = entry.reasoning;
955
- if (!entry.reasoning) {
956
- Reflect.deleteProperty(compat, "thinkingFormat");
1486
+ const modality = entry.input.filter((m) => m === "text" || m === "image");
1487
+ if (modality.length > 0) next["input"] = modality;
1488
+ else Reflect.deleteProperty(next, "input");
1489
+ if (entry.source === "pi-ai") {
1490
+ if (isBlank(next["id"])) next["id"] = entry.id;
1491
+ if (isBlank(next["name"]) && entry.name !== void 0) next["name"] = entry.name;
1492
+ next["reasoningEfforts"] = reasoningEffortsFrom(entry.thinkingLevelMap, entry.api, entry.reasoning);
1493
+ const compat = compatFrom(entry.compat, entry.api);
1494
+ if (Object.keys(compat).length === 0) Reflect.deleteProperty(next, "compat");
1495
+ else next["compat"] = compat;
1496
+ } else {
957
1497
  Reflect.deleteProperty(next, "reasoningEfforts");
1498
+ if (entry.reasoning) next["compat"] = { supportsReasoningEffort: true };
1499
+ else Reflect.deleteProperty(next, "compat");
958
1500
  }
959
- setKey("compat", Object.keys(compat).length === 0 ? void 0 : compat);
960
- const supported = entry.input.filter((m) => m === "text" || m === "image");
961
- if (supported.length > 0) next["input"] = supported;
962
1501
  onChange(next);
963
1502
  setCapacityText(/* @__PURE__ */ new Map());
964
1503
  };
@@ -998,8 +1537,48 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
998
1537
  };
999
1538
  const compat = compatOf(model);
1000
1539
  const developerRole = compat["supportsDeveloperRole"] !== false;
1001
- const supportsReasoningEffort = compat["supportsReasoningEffort"] === true;
1540
+ /**
1541
+ * Switches a prefill wrote that the block above has no control for. Listing
1542
+ * them keeps a prefilled value from becoming invisible state: the pi-ai
1543
+ * catalog records far more of the compat surface than this panel models
1544
+ * explicitly, and that API refuses an unknown switch rather than ignoring
1545
+ * it — so a value the user cannot see is a value the user cannot fix.
1546
+ */
1547
+ const extraCompat = Object.entries(compat).filter(([key]) => !COMPAT_BASE_FIELDS.includes(key)).sort(([a], [b]) => a.localeCompare(b));
1548
+ /**
1549
+ * Read an edited switch back as the type it already had.
1550
+ * @param previous - the value the switch carried when rendered.
1551
+ * @param raw - the field text.
1552
+ * @returns the coerced value, or the previous one when the text is unreadable.
1553
+ */
1554
+ const readCompatValue = (previous, raw) => {
1555
+ if (typeof previous === "boolean") return raw === "true";
1556
+ if (typeof previous === "number") {
1557
+ const parsed = Number(raw);
1558
+ return Number.isNaN(parsed) ? previous : parsed;
1559
+ }
1560
+ if (typeof previous === "string") return raw;
1561
+ try {
1562
+ return JSON.parse(raw);
1563
+ } catch {
1564
+ return previous;
1565
+ }
1566
+ };
1567
+ /** The switch as a wire field: whether the endpoint takes `reasoning_effort`. */
1568
+ const gateOn = compat["supportsReasoningEffort"] === true;
1002
1569
  const efforts = effortsOf(model);
1570
+ /**
1571
+ * Whether the reasoning area is worth showing.
1572
+ *
1573
+ * The switch and the area answer different questions: the switch tells the
1574
+ * adapter the endpoint accepts `reasoning_effort`, while the area edits what
1575
+ * *this model* offers. A pi-ai prefill can leave the switch unset on purpose
1576
+ * — an absent key lets the adapter auto-detect from the endpoint — while
1577
+ * still carrying levels worth editing, and those levels reach the provider
1578
+ * through the protocol's own thinking parameter either way. So the area keys
1579
+ * off either signal, not the switch alone.
1580
+ */
1581
+ const reasoningAreaShown = gateOn || efforts !== void 0 || compat["thinkingFormat"] !== void 0;
1003
1582
  const toggleGate = (checked) => {
1004
1583
  if (checked) {
1005
1584
  setCompatKey("supportsReasoningEffort", true);
@@ -1107,24 +1686,28 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
1107
1686
  })]
1108
1687
  })
1109
1688
  }),
1110
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1689
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1111
1690
  className: models_plus_module_default["extGroup"],
1112
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1691
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1113
1692
  className: models_plus_module_default["switchRow"],
1114
1693
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1115
1694
  type: "checkbox",
1116
1695
  className: models_plus_module_default["extCheckbox"],
1117
- checked: supportsReasoningEffort,
1696
+ checked: gateOn,
1118
1697
  disabled,
1119
1698
  onChange: (event) => {
1120
1699
  toggleGate(event.target.checked);
1121
1700
  }
1122
1701
  }), "端点接受推理挡位参数(reasoning_effort)"]
1123
- })
1702
+ }), !gateOn && reasoningAreaShown ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1703
+ className: models_plus_module_default["extHint"],
1704
+ style: { marginTop: 6 },
1705
+ children: "此项未设置:由 llm-pi-ai 按其 baseURL 检测结果决定是否发送 reasoning_effort; 下面的挡位不受影响,仍通过各协议自己的 thinking 参数生效。"
1706
+ }) : null]
1124
1707
  }),
1125
1708
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1126
1709
  className: models_plus_module_default["extGated"],
1127
- hidden: !supportsReasoningEffort,
1710
+ hidden: !reasoningAreaShown,
1128
1711
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1129
1712
  className: models_plus_module_default["extField"],
1130
1713
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -1218,7 +1801,50 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
1218
1801
  }) : null
1219
1802
  ]
1220
1803
  })]
1221
- })
1804
+ }),
1805
+ extraCompat.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1806
+ className: models_plus_module_default["extGroup"],
1807
+ children: [
1808
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1809
+ className: models_plus_module_default["extLabel"],
1810
+ style: { marginBottom: 6 },
1811
+ children: "其他兼容开关"
1812
+ }),
1813
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1814
+ style: {
1815
+ display: "flex",
1816
+ flexDirection: "column",
1817
+ gap: 6
1818
+ },
1819
+ children: extraCompat.map(([key, value]) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1820
+ style: {
1821
+ display: "flex",
1822
+ flexDirection: "column",
1823
+ gap: 2
1824
+ },
1825
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1826
+ className: models_plus_module_default["extHint"],
1827
+ children: key
1828
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1829
+ className: models_plus_module_default["input"],
1830
+ type: "text",
1831
+ defaultValue: typeof value === "string" ? value : JSON.stringify(value) ?? "",
1832
+ "aria-label": key,
1833
+ disabled,
1834
+ onBlur: (event) => {
1835
+ const raw = event.target.value.trim();
1836
+ setCompatKey(key, raw.length === 0 ? void 0 : readCompatValue(value, raw));
1837
+ }
1838
+ })]
1839
+ }, key))
1840
+ }),
1841
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1842
+ className: models_plus_module_default["extHint"],
1843
+ style: { marginTop: 6 },
1844
+ children: "这些开关由 pi-ai 目录预填;清空输入框即移除该开关,回到目录默认。"
1845
+ })
1846
+ ]
1847
+ }) : null
1222
1848
  ]
1223
1849
  });
1224
1850
  }
@@ -1468,7 +2094,7 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
1468
2094
  onChange(models.map((row, at) => at === index ? next : row));
1469
2095
  }
1470
2096
  }) : null]
1471
- }, `${String(index)}:${textOf(model, "id")}`))
2097
+ }, String(index)))
1472
2098
  }),
1473
2099
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1474
2100
  type: "button",
@@ -1778,7 +2404,7 @@ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin
1778
2404
  role: "tabpanel",
1779
2405
  hidden: tab !== "provider",
1780
2406
  children: [
1781
- layout === "pi-ai" && props.declared === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2407
+ layout === "pi-ai" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1782
2408
  className: models_plus_module_default["field"],
1783
2409
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
1784
2410
  className: models_plus_module_default["fieldLabel"],