@lynn123411/dsh-llm-agentrouter 0.2.2 → 0.3.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/README.md +4 -2
- package/cordis.patch.yml +15 -5
- package/lib/client.js +1010 -10
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# @lynn123411/dsh-llm-agentrouter
|
|
2
2
|
|
|
3
|
-
AgentRouter 中转聚合插件:把同一家中转的多个模型收拢为一条 pi-ai 路由(模型选择器只出现一个 AgentRouter 分组),国内 / 国际端点在「设置 -
|
|
3
|
+
AgentRouter 中转聚合插件:把同一家中转的多个模型收拢为一条 pi-ai 路由(模型选择器只出现一个 AgentRouter 分组),国内 / 国际端点在「设置 - API中转 - AgentRouter 中转站」一键切换、下一请求即生效,外加出站请求的 User-Agent 改写与 402 配额耗尽提示围栏。分叉自 `aqiu817/dsh-llm-agentrouter`,适配 DSH `0.1.2-alpha.5` 起的新 settings/slots API,已在 `0.1.5-rc.1` 逐项实证兼容。
|
|
4
4
|
|
|
5
5
|
## 特性
|
|
6
6
|
|
|
7
7
|
- **单路由多模型**:Claude Opus 5、GPT-5.6-sol 等走同一 `agentrouter` 路由,`reasoningEffort` 档位与兼容开关按实测探针手写声明,模型列表不翻倍。
|
|
8
|
+
- **模型列表可编辑**:设置卡片里直接列出这条路由的模型,`更新` 向中转站询问它现有的模型 ID(只补缺失的、不动已有条目),每个模型的 ID / 显示名 / 上下文窗口 / 最大输出 / 输入模态 / 各推理档位与线上取值都可改,保存后下一个请求即生效;新补入的模型默认 1048576 上下文、131072 输出、text+image、off..max 七档。改的就是路由自己的 `models`(与内核「设置 - 模型」页同一份数据),patch 里手写的那几条是「重置」的基线。
|
|
8
9
|
- **端点一键切换**:设置卡片两个大选项(国内端点 / 国际端点,附带真实 host 展示),点击即写即生效,无需重启;API Key 只存 `$DSH_HOME/.credentials.yaml` 的 `AGENTROUTER_API_KEY` 引用。
|
|
10
|
+
- **共享「API中转」设置页**:本插件的端点卡片与 `dsh-a6api` 的面板挂在同一个设置页里(`settings.section` id `relay` 的页内两个 tab,参与者可增删)。内核不允许一页被多个插件共同声明,因此每个参与者各持一份运行时逻辑一致的页壳,**先加载者当选页面宿主**、其余只注册卡片;当选者被卸载后下次启动自动改选。做法与约束见仓库 `docs/rules/shared-settings-page.md`。
|
|
9
11
|
- **请求围栏**:重写 relay 要求的 `User-Agent`(适配器强制署名下沉到 `fetch` 层替换),哨兵 host(`.internal` 不可解析)兜底防裸奔,402 配额耗尽的 JSON 错误体改写为可读提示。
|
|
10
|
-
- **alpha.5
|
|
12
|
+
- **alpha.5 兼容**:宿主沿用 `ctx.settings.installSection` + `ctx.inject(['settings'])`,客户端卡片改挂共享设置页的子 slot;沿用 `llm-agentrouter` 设置命名空间,老配置无缝继承。
|
|
11
13
|
|
|
12
14
|
## 安装
|
|
13
15
|
|
package/cordis.patch.yml
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# row below overrides the dormant zero-route entry dsh-base mounts.
|
|
6
6
|
#
|
|
7
7
|
# WHY ONE ROUTE AND NOT TWO
|
|
8
|
-
# The relay serves the same
|
|
8
|
+
# The relay serves the same models at a domestic and an international
|
|
9
9
|
# endpoint; only the origin differs. Declaring a route per endpoint put every
|
|
10
10
|
# model in the picker twice and made "which endpoint" a choice the user had to
|
|
11
11
|
# re-make on every model switch. It is not a model property — it is one
|
|
@@ -30,16 +30,26 @@
|
|
|
30
30
|
# both endpoints because it is one relay account.
|
|
31
31
|
#
|
|
32
32
|
# MODEL CATALOG
|
|
33
|
-
#
|
|
33
|
+
# This list is the COMPOSITION layer: what the route serves until the user layer
|
|
34
|
+
# overrides it. The plugin's settings card (设置 → API中转 → AgentRouter 中转站)
|
|
35
|
+
# edits the route's own `models` — the same document the kernel's Models page
|
|
36
|
+
# writes — and its 重置 action removes that override, which is when the list
|
|
37
|
+
# below serves again. Treat these entries as the reset baseline and the probe
|
|
38
|
+
# record, not as the only way to add a model.
|
|
39
|
+
#
|
|
40
|
+
# pi-ai's installed catalog describes these models, but under their vendor
|
|
34
41
|
# providers (`anthropic`, `openai`) and other protocols — Opus via
|
|
35
42
|
# anthropic-messages, gpt-5.6-sol via openai-responses. A hand-declared route
|
|
36
43
|
# inherits nothing, so capacities, reasoning levels, and compat switches are
|
|
37
44
|
# restated here from that catalog, adjusted for Chat Completions.
|
|
38
45
|
#
|
|
39
46
|
# `reasoningEfforts` keys are the levels the picker offers; each value is the
|
|
40
|
-
# wire spelling sent as `reasoning_effort`.
|
|
41
|
-
# the live relay: every level
|
|
42
|
-
# offers Off and sends nothing, which is what an OpenAI-shaped endpoint
|
|
47
|
+
# wire spelling sent as `reasoning_effort`. Every model below was probed against
|
|
48
|
+
# the live relay: every level it declares answered 200. `off:` (declared, no
|
|
49
|
+
# value) offers Off and sends nothing, which is what an OpenAI-shaped endpoint
|
|
50
|
+
# wants. A model the card discovers starts instead from generic defaults — see
|
|
51
|
+
# `NEW_MODEL` in lib/client.js — so these hand-probed declarations stay the more
|
|
52
|
+
# accurate ones for a model that is already listed.
|
|
43
53
|
# WHY THE PICKER SAYS NOTHING ABOUT THE ENDPOINT
|
|
44
54
|
# The model picker renders no child slots, so a plugin cannot inject anything
|
|
45
55
|
# into that menu; per group it shows only `displayName`, and per model only the
|
package/lib/client.js
CHANGED
|
@@ -29,6 +29,31 @@ window.__ModuleLoader__.load({
|
|
|
29
29
|
".dshAr_choiceHint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:17px}",
|
|
30
30
|
".dshAr_status{margin:0;min-height:18px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
|
|
31
31
|
".dshAr_status[data-kind=error]{color:var(--dsw-alias-state-error-primary)}",
|
|
32
|
+
".dshAr_models{display:flex;flex-direction:column;gap:8px;margin-top:4px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2)}",
|
|
33
|
+
".dshAr_modelsHead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}",
|
|
34
|
+
".dshAr_modelsHead h4{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:600;line-height:20px}",
|
|
35
|
+
".dshAr_actions{display:flex;gap:8px;flex-wrap:wrap}",
|
|
36
|
+
".dshAr_modelsHead .dshAr_actions{margin-left:auto}",
|
|
37
|
+
".dshAr_button{appearance:none;padding:5px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer}",
|
|
38
|
+
".dshAr_button:hover:enabled{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
39
|
+
".dshAr_button:disabled{cursor:default;opacity:.5}",
|
|
40
|
+
".dshAr_modelsList{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none}",
|
|
41
|
+
".dshAr_model{margin:0;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-3)}",
|
|
42
|
+
".dshAr_modelHead{display:flex;gap:8px;align-items:baseline;cursor:pointer;color:var(--dsw-alias-label-primary);font-size:13px;line-height:19px}",
|
|
43
|
+
".dshAr_modelName{font-weight:600}",
|
|
44
|
+
".dshAr_modelId{overflow-wrap:anywhere;color:var(--dsw-alias-label-tertiary);font-family:var(--ds-font-family-code);font-size:11px;line-height:17px}",
|
|
45
|
+
".dshAr_fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin-top:10px}",
|
|
46
|
+
".dshAr_field{display:flex;flex-direction:column;gap:3px;min-width:0}",
|
|
47
|
+
".dshAr_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:15px}",
|
|
48
|
+
".dshAr_input{min-width:0;padding:4px 7px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px}",
|
|
49
|
+
".dshAr_input:disabled{opacity:.55}",
|
|
50
|
+
".dshAr_inputs{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:10px}",
|
|
51
|
+
".dshAr_toggle{display:flex;gap:5px;align-items:center;color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px}",
|
|
52
|
+
".dshAr_efforts{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:6px;margin-top:10px}",
|
|
53
|
+
".dshAr_effort{display:flex;gap:6px;align-items:center;color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px}",
|
|
54
|
+
".dshAr_effortLevel{min-width:38px;color:var(--dsw-alias-label-secondary)}",
|
|
55
|
+
".dshAr_effortWire{flex:1 1 60px}",
|
|
56
|
+
".dshAr_modelActions{display:flex;justify-content:flex-end;margin-top:10px}",
|
|
32
57
|
].join("");
|
|
33
58
|
const CSS_TAG_ID = "dsh-llm-agentrouter/EndpointCard.css";
|
|
34
59
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(CSS_TAG_ID) + "]") === null) {
|
|
@@ -42,6 +67,7 @@ window.__ModuleLoader__.load({
|
|
|
42
67
|
//#region locales
|
|
43
68
|
/** Simplified Chinese dictionary and key source of truth. */
|
|
44
69
|
const zh = {
|
|
70
|
+
pageNav: "API中转",
|
|
45
71
|
title: "AgentRouter 中转站",
|
|
46
72
|
description: "选择请求发往的端点。切换后立即生效,无需重启;模型列表不受影响。",
|
|
47
73
|
cn: "国内端点",
|
|
@@ -54,9 +80,50 @@ window.__ModuleLoader__.load({
|
|
|
54
80
|
saving: "正在保存…",
|
|
55
81
|
saved: "已切换到%s。",
|
|
56
82
|
failed: "保存失败,设置未更改。",
|
|
83
|
+
modelsTitle: "模型列表",
|
|
84
|
+
modelsLead: "这条路由当前提供的模型。「更新」向中转站询问它现有的模型 ID,只补入缺失的、不动已有的;参数改完点「保存」才写入,下一个请求即生效。",
|
|
85
|
+
refresh: "更新",
|
|
86
|
+
refreshing: "正在拉取…",
|
|
87
|
+
refreshAdded: "中转站共 %s 个模型,已补入 %s 个新模型。",
|
|
88
|
+
refreshNone: "中转站共 %s 个模型,没有新的。",
|
|
89
|
+
refreshFailed: "更新失败:%s",
|
|
90
|
+
refreshUnavailable: "当前浏览器读不到中转站能力,无法更新。",
|
|
91
|
+
save: "保存",
|
|
92
|
+
saveDone: "已保存,下一个请求即生效。",
|
|
93
|
+
saveConflict: "设置已被其他地方改动,请重新打开本页再改(%s)。",
|
|
94
|
+
saveFailed: "保存失败:%s",
|
|
95
|
+
reset: "重置为内置默认",
|
|
96
|
+
resetDone: "已恢复内置默认列表。",
|
|
97
|
+
addModel: "添加模型",
|
|
98
|
+
removeModel: "删除",
|
|
99
|
+
newModel: "新模型",
|
|
100
|
+
unsaved: "有未保存的改动。",
|
|
101
|
+
readOnlyModels: "当前部署不允许写入设置,模型列表只读。",
|
|
102
|
+
fieldId: "模型 ID",
|
|
103
|
+
fieldName: "显示名",
|
|
104
|
+
fieldContext: "上下文窗口",
|
|
105
|
+
fieldMaxTokens: "最大输出",
|
|
106
|
+
fieldInput: "输入模态",
|
|
107
|
+
fieldEfforts: "推理档位",
|
|
108
|
+
modalityText: "文本",
|
|
109
|
+
modalityImage: "图片",
|
|
110
|
+
wireNone: "留空 = 不发送",
|
|
111
|
+
levelOff: "off",
|
|
112
|
+
levelMinimal: "minimal",
|
|
113
|
+
levelLow: "low",
|
|
114
|
+
levelMedium: "medium",
|
|
115
|
+
levelHigh: "high",
|
|
116
|
+
levelXhigh: "xhigh",
|
|
117
|
+
levelMax: "max",
|
|
118
|
+
invalidEmpty: "至少保留一个模型:列表为空时这条路由没有任何可用模型。",
|
|
119
|
+
invalidId: "第 %s 行的模型 ID 不能为空。",
|
|
120
|
+
invalidDuplicate: "模型 ID 重复:%s",
|
|
121
|
+
invalidNumber: "第 %s 行的「%s」必须是正整数。",
|
|
122
|
+
invalidEffort: "第 %s 行的 %s 档位缺少发给中转站的取值。",
|
|
57
123
|
};
|
|
58
124
|
/** English dictionary checked against the Chinese key set. */
|
|
59
125
|
const en = {
|
|
126
|
+
pageNav: "API relay",
|
|
60
127
|
title: "AgentRouter relay",
|
|
61
128
|
description: "Which endpoint requests are sent to. A switch applies to the next request; the model list is unaffected.",
|
|
62
129
|
cn: "Domestic endpoint",
|
|
@@ -69,6 +136,46 @@ window.__ModuleLoader__.load({
|
|
|
69
136
|
saving: "Saving…",
|
|
70
137
|
saved: "Switched to %s.",
|
|
71
138
|
failed: "The save failed; the setting is unchanged.",
|
|
139
|
+
modelsTitle: "Model list",
|
|
140
|
+
modelsLead: "The models this route serves. Update asks the relay which model ids it currently offers and adds only the missing ones; an edit is written when you press Save and applies to the next request.",
|
|
141
|
+
refresh: "Update",
|
|
142
|
+
refreshing: "Fetching…",
|
|
143
|
+
refreshAdded: "The relay lists %s models; %s new one(s) added.",
|
|
144
|
+
refreshNone: "The relay lists %s models; nothing new.",
|
|
145
|
+
refreshFailed: "Update failed: %s",
|
|
146
|
+
refreshUnavailable: "This browser cannot interrogate the relay, so Update is unavailable.",
|
|
147
|
+
save: "Save",
|
|
148
|
+
saveDone: "Saved; the next request uses it.",
|
|
149
|
+
saveConflict: "These settings changed elsewhere; reopen this tab and edit again (%s).",
|
|
150
|
+
saveFailed: "Save failed: %s",
|
|
151
|
+
reset: "Reset to built-in",
|
|
152
|
+
resetDone: "Restored the built-in default list.",
|
|
153
|
+
addModel: "Add model",
|
|
154
|
+
removeModel: "Remove",
|
|
155
|
+
newModel: "New model",
|
|
156
|
+
unsaved: "Unsaved changes.",
|
|
157
|
+
readOnlyModels: "This deployment does not accept settings writes; the model list is read-only.",
|
|
158
|
+
fieldId: "Model id",
|
|
159
|
+
fieldName: "Display name",
|
|
160
|
+
fieldContext: "Context window",
|
|
161
|
+
fieldMaxTokens: "Max output",
|
|
162
|
+
fieldInput: "Input modalities",
|
|
163
|
+
fieldEfforts: "Reasoning levels",
|
|
164
|
+
modalityText: "Text",
|
|
165
|
+
modalityImage: "Image",
|
|
166
|
+
wireNone: "empty = send nothing",
|
|
167
|
+
levelOff: "off",
|
|
168
|
+
levelMinimal: "minimal",
|
|
169
|
+
levelLow: "low",
|
|
170
|
+
levelMedium: "medium",
|
|
171
|
+
levelHigh: "high",
|
|
172
|
+
levelXhigh: "xhigh",
|
|
173
|
+
levelMax: "max",
|
|
174
|
+
invalidEmpty: "Keep at least one model: an empty list leaves this route with nothing to serve.",
|
|
175
|
+
invalidId: "Row %s has no model id.",
|
|
176
|
+
invalidDuplicate: "Duplicate model id: %s",
|
|
177
|
+
invalidNumber: "Row %s: %s must be a positive integer.",
|
|
178
|
+
invalidEffort: "Row %s: the %s level names no wire value.",
|
|
72
179
|
};
|
|
73
180
|
//#endregion
|
|
74
181
|
|
|
@@ -101,18 +208,20 @@ window.__ModuleLoader__.load({
|
|
|
101
208
|
}
|
|
102
209
|
|
|
103
210
|
/**
|
|
104
|
-
* The relay
|
|
105
|
-
* namespace.
|
|
211
|
+
* The relay card: the endpoint radio group over this plugin's own settings
|
|
212
|
+
* namespace, above the model list the route in `llm-pi-ai` actually serves.
|
|
106
213
|
*
|
|
107
214
|
* A choice writes immediately rather than staging behind a Save button. The
|
|
108
215
|
* namespace has exactly one user-facing field and the write is reversible in
|
|
109
216
|
* one click, so a staged form would add a step without protecting anything —
|
|
110
|
-
* and `scope.set` already fences the write with the revision it read.
|
|
217
|
+
* and `scope.set` already fences the write with the revision it read. The
|
|
218
|
+
* two halves are independent: each reads and writes its own namespace, so a
|
|
219
|
+
* deployment that exposes only one of them still renders the other.
|
|
111
220
|
*
|
|
112
|
-
* @param {object} props - the injected scope
|
|
221
|
+
* @param {object} props - the injected scope faces plus the bound translator.
|
|
113
222
|
* @returns {JSX.Element} the card.
|
|
114
223
|
*/
|
|
115
|
-
function EndpointCard({ scope, t }) {
|
|
224
|
+
function EndpointCard({ scope, t, routeScope, operations }) {
|
|
116
225
|
const snapshot = react.useSyncExternalStore(
|
|
117
226
|
react.useCallback((listener) => scope.subscribe(listener), [scope]),
|
|
118
227
|
() => scope.getSnapshot(),
|
|
@@ -205,11 +314,832 @@ window.__ModuleLoader__.load({
|
|
|
205
314
|
role: shown.kind === "error" ? "alert" : "status",
|
|
206
315
|
children: shown.text,
|
|
207
316
|
}),
|
|
317
|
+
routeScope === undefined ? null : jsx.jsx(ModelList, { routeScope, operations, t }),
|
|
208
318
|
],
|
|
209
319
|
});
|
|
210
320
|
}
|
|
211
321
|
//#endregion
|
|
212
322
|
|
|
323
|
+
//#region model list
|
|
324
|
+
/**
|
|
325
|
+
* The pi-ai settings namespace the relay route is declared in. This plugin's
|
|
326
|
+
* model list is NOT its own preference: it is the `agentrouter` route's
|
|
327
|
+
* `models` array, the same data the kernel's Models page edits, which is why
|
|
328
|
+
* editing it is what changes the model picker. Spelled rather than imported,
|
|
329
|
+
* for the same reason as SETTINGS_NS in the plugin region below: a browser
|
|
330
|
+
* bundle must not depend on a Host package.
|
|
331
|
+
*/
|
|
332
|
+
const PI_AI_SETTINGS_NS = "llm-pi-ai";
|
|
333
|
+
/** The route key inside that namespace this plugin owns. */
|
|
334
|
+
const RELAY_ROUTE = "agentrouter";
|
|
335
|
+
/** Reasoning levels a model may offer, in pi-ai's escalation order. */
|
|
336
|
+
const EFFORT_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
337
|
+
/** Request modalities a model profile may declare. */
|
|
338
|
+
const MODALITIES = ["text", "image"];
|
|
339
|
+
/** Locale key of each modality's label. */
|
|
340
|
+
const MODALITY_LABELS = { text: "modalityText", image: "modalityImage" };
|
|
341
|
+
/**
|
|
342
|
+
* The parameter set a model discovered by 更新 starts from.
|
|
343
|
+
*
|
|
344
|
+
* The relay's listing carries ids only — no capacities — so a discovered row
|
|
345
|
+
* has nothing to inherit them from and starts from this declaration. A model
|
|
346
|
+
* already in the list is never re-seeded: its capacities and reasoning
|
|
347
|
+
* levels stay exactly as they are, because those are the values probed
|
|
348
|
+
* against the relay rather than generic guesses.
|
|
349
|
+
*/
|
|
350
|
+
const NEW_MODEL = Object.freeze({
|
|
351
|
+
contextWindow: "1048576",
|
|
352
|
+
maxTokens: "131072",
|
|
353
|
+
input: MODALITIES,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Fill one `%s`-style template with values, in order. Missing values become
|
|
358
|
+
* empty strings rather than `undefined`, so a diagnostic never renders as
|
|
359
|
+
* the word.
|
|
360
|
+
* @param {string} template - the localized template.
|
|
361
|
+
* @param {string[]} values - replacements for its placeholders.
|
|
362
|
+
* @returns {string} the filled text.
|
|
363
|
+
*/
|
|
364
|
+
function fill(template, values) {
|
|
365
|
+
let index = 0;
|
|
366
|
+
return template.replace(/%s/g, () => (index < values.length ? values[index++] : ""));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The `agentrouter` profile out of one resolved (or stored) `llm-pi-ai`
|
|
371
|
+
* section.
|
|
372
|
+
* @param {unknown} value - a section's resolved value or raw user layer.
|
|
373
|
+
* @returns {object|undefined} the profile, when the shape is one.
|
|
374
|
+
*/
|
|
375
|
+
function routeProfile(value) {
|
|
376
|
+
const providers = typeof value === "object" && value !== null ? value.providers : undefined;
|
|
377
|
+
const route = typeof providers === "object" && providers !== null ? providers[RELAY_ROUTE] : undefined;
|
|
378
|
+
return typeof route === "object" && route !== null ? route : undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The models array inside one resolved `llm-pi-ai` section. The composition
|
|
383
|
+
* layer is the bundle patch's hand-declared list, so this is what the route
|
|
384
|
+
* serves whenever the user layer owns nothing.
|
|
385
|
+
* @param {unknown} value - the resolved section.
|
|
386
|
+
* @returns {object[]} the declared models, or an empty list.
|
|
387
|
+
*/
|
|
388
|
+
function modelsOf(value) {
|
|
389
|
+
const models = routeProfile(value)?.models;
|
|
390
|
+
return Array.isArray(models) ? models : [];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Whether the stored user layer owns the route's model list. Presence in the
|
|
395
|
+
* raw layer is what marks an override — a stored list equal to the
|
|
396
|
+
* composition's is still an override, so comparing values could not see it.
|
|
397
|
+
* @param {unknown} user - the raw user layer.
|
|
398
|
+
* @returns {boolean} whether 重置 has something to remove.
|
|
399
|
+
*/
|
|
400
|
+
function overridesModels(user) {
|
|
401
|
+
return Array.isArray(routeProfile(user)?.models);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Whether a settings node is a nested dict — the shape a path op leaves
|
|
406
|
+
* behind when it removes the last key inside one.
|
|
407
|
+
* @param {unknown} value - a section node.
|
|
408
|
+
* @returns {boolean} whether it is a record.
|
|
409
|
+
*/
|
|
410
|
+
function isRecord(value) {
|
|
411
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The ops 重置 sends: the route's model list, plus every ancestor dict that
|
|
416
|
+
* removal empties. An empty dict is inert — the route resolves exactly as it
|
|
417
|
+
* did before a list was ever stored — but leaving one behind keeps the route
|
|
418
|
+
* looking user-owned to the kernel's Models page, and a reset that leaves
|
|
419
|
+
* litter is not a reset.
|
|
420
|
+
* @param {unknown} user - the raw user layer the card read.
|
|
421
|
+
* @returns {object[]} the ordered path ops.
|
|
422
|
+
*/
|
|
423
|
+
function resetOps(user) {
|
|
424
|
+
const ops = [{ op: "unset", path: ["providers", RELAY_ROUTE, "models"] }];
|
|
425
|
+
const providers = isRecord(user) ? user.providers : undefined;
|
|
426
|
+
const route = isRecord(providers) ? providers[RELAY_ROUTE] : undefined;
|
|
427
|
+
// Only an ancestor holding nothing else is removed: a sibling the user
|
|
428
|
+
// set through another surface is never touched.
|
|
429
|
+
if (!isRecord(route) || Object.keys(route).length !== 1) return ops;
|
|
430
|
+
ops.push({ op: "unset", path: ["providers", RELAY_ROUTE] });
|
|
431
|
+
if (isRecord(providers) && Object.keys(providers).length === 1) ops.push({ op: "unset", path: ["providers"] });
|
|
432
|
+
return ops;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* One editable draft row from a resolved model entry. Every field is a
|
|
437
|
+
* string while it is being edited, so a half-typed number is a legal value
|
|
438
|
+
* and only the save judges it.
|
|
439
|
+
* @param {object} model - one resolved model entry.
|
|
440
|
+
* @returns {object} the draft row.
|
|
441
|
+
*/
|
|
442
|
+
function rowOf(model) {
|
|
443
|
+
const declared = typeof model.reasoningEfforts === "object" && model.reasoningEfforts !== null
|
|
444
|
+
? model.reasoningEfforts
|
|
445
|
+
: {};
|
|
446
|
+
return {
|
|
447
|
+
id: typeof model.id === "string" ? model.id : "",
|
|
448
|
+
name: typeof model.name === "string" ? model.name : "",
|
|
449
|
+
contextWindow: model.contextWindow === undefined ? "" : String(model.contextWindow),
|
|
450
|
+
maxTokens: model.maxTokens === undefined ? "" : String(model.maxTokens),
|
|
451
|
+
input: Array.isArray(model.input) ? MODALITIES.filter((modality) => model.input.includes(modality)) : [],
|
|
452
|
+
efforts: EFFORT_LEVELS.map((level) => {
|
|
453
|
+
const on = Object.prototype.hasOwnProperty.call(declared, level);
|
|
454
|
+
const wire = declared[level];
|
|
455
|
+
return { level, on, wire: typeof wire === "string" ? wire : "" };
|
|
456
|
+
}),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Every draft row for one section's model list. */
|
|
461
|
+
function rowsOf(models) {
|
|
462
|
+
return models.map(rowOf);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* A draft row for a model the relay disclosed: the id it named, over
|
|
467
|
+
* {@link NEW_MODEL}'s defaults, with every level offered.
|
|
468
|
+
* @param {string} id - the model id the relay listed.
|
|
469
|
+
* @returns {object} the draft row.
|
|
470
|
+
*/
|
|
471
|
+
function freshRow(id) {
|
|
472
|
+
return {
|
|
473
|
+
id,
|
|
474
|
+
name: id,
|
|
475
|
+
contextWindow: NEW_MODEL.contextWindow,
|
|
476
|
+
maxTokens: NEW_MODEL.maxTokens,
|
|
477
|
+
input: [...NEW_MODEL.input],
|
|
478
|
+
efforts: EFFORT_LEVELS.map((level) => ({ level, on: true, wire: level === "off" ? "" : level })),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Serialize one draft row back into a pi-ai model entry. Unset optional
|
|
484
|
+
* fields are omitted rather than written blank: the Host validates the
|
|
485
|
+
* payload against the adapter's schema, and an empty string is not a
|
|
486
|
+
* capacity.
|
|
487
|
+
*
|
|
488
|
+
* `off` is the one level allowed to carry no wire value — "supported, send
|
|
489
|
+
* nothing" — which the schema spells `null`; every other offered level must
|
|
490
|
+
* name its wire spelling, so an empty one is dropped here and refused by
|
|
491
|
+
* {@link validateRows} before the write.
|
|
492
|
+
* @param {object} row - the draft row.
|
|
493
|
+
* @returns {object} the model entry.
|
|
494
|
+
*/
|
|
495
|
+
function modelOf(row) {
|
|
496
|
+
const model = { id: row.id.trim() };
|
|
497
|
+
const name = row.name.trim();
|
|
498
|
+
if (name !== "") model.name = name;
|
|
499
|
+
if (row.contextWindow.trim() !== "") model.contextWindow = Number(row.contextWindow.trim());
|
|
500
|
+
if (row.maxTokens.trim() !== "") model.maxTokens = Number(row.maxTokens.trim());
|
|
501
|
+
model.input = MODALITIES.filter((modality) => row.input.includes(modality));
|
|
502
|
+
const efforts = {};
|
|
503
|
+
for (const entry of row.efforts) {
|
|
504
|
+
if (!entry.on) continue;
|
|
505
|
+
const wire = entry.wire.trim();
|
|
506
|
+
if (wire === "") {
|
|
507
|
+
if (entry.level === "off") efforts.off = null;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
efforts[entry.level] = wire;
|
|
511
|
+
}
|
|
512
|
+
if (Object.keys(efforts).length > 0) model.reasoningEfforts = efforts;
|
|
513
|
+
return model;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Judge a draft before it reaches the Host, so a refusal names the row the
|
|
518
|
+
* user must fix instead of surfacing a schema path.
|
|
519
|
+
* @param {object[]} rows - the draft rows.
|
|
520
|
+
* @param {Function} t - bound translator, for the field names in a message.
|
|
521
|
+
* @returns {{key: string, values: string[]}|undefined} the failure, if any.
|
|
522
|
+
*/
|
|
523
|
+
function validateRows(rows, t) {
|
|
524
|
+
if (rows.length === 0) return { key: "invalidEmpty", values: [] };
|
|
525
|
+
const seen = new Set();
|
|
526
|
+
const capacities = [["contextWindow", "fieldContext"], ["maxTokens", "fieldMaxTokens"]];
|
|
527
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
528
|
+
const row = rows[index];
|
|
529
|
+
const where = String(index + 1);
|
|
530
|
+
const id = row.id.trim();
|
|
531
|
+
if (id === "") return { key: "invalidId", values: [where] };
|
|
532
|
+
if (seen.has(id)) return { key: "invalidDuplicate", values: [id] };
|
|
533
|
+
seen.add(id);
|
|
534
|
+
for (const [field, label] of capacities) {
|
|
535
|
+
const value = row[field].trim();
|
|
536
|
+
if (value !== "" && !/^\d+$/.test(value)) {
|
|
537
|
+
return { key: "invalidNumber", values: [where, t(label)] };
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
for (const entry of row.efforts) {
|
|
541
|
+
if (!entry.on || entry.level === "off") continue;
|
|
542
|
+
if (entry.wire.trim() === "") return { key: "invalidEffort", values: [where, entry.level] };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* One draft row: a collapsible entry carrying the id and display name, the
|
|
550
|
+
* two capacities, the modalities the model accepts, and one line per
|
|
551
|
+
* reasoning level — offered or not, and with which wire spelling.
|
|
552
|
+
*
|
|
553
|
+
* @param {object} props - the row, its index, and the handlers that change it.
|
|
554
|
+
* @returns {JSX.Element} the row.
|
|
555
|
+
*/
|
|
556
|
+
function ModelRow({ row, index, readOnly, onPatch, onEffort, onRemove, t }) {
|
|
557
|
+
const field = (name, label) => jsx.jsxs(
|
|
558
|
+
"label",
|
|
559
|
+
{
|
|
560
|
+
className: "dshAr_field",
|
|
561
|
+
children: [
|
|
562
|
+
jsx.jsx("span", { className: "dshAr_fieldLabel", children: t(label) }),
|
|
563
|
+
jsx.jsx("input", {
|
|
564
|
+
className: "dshAr_input",
|
|
565
|
+
type: "text",
|
|
566
|
+
value: row[name],
|
|
567
|
+
disabled: readOnly,
|
|
568
|
+
"data-model": String(index),
|
|
569
|
+
"data-field": name,
|
|
570
|
+
onChange: (event) => onPatch(index, { [name]: event.target.value }),
|
|
571
|
+
}),
|
|
572
|
+
],
|
|
573
|
+
},
|
|
574
|
+
name,
|
|
575
|
+
);
|
|
576
|
+
return jsx.jsxs(
|
|
577
|
+
"li",
|
|
578
|
+
{
|
|
579
|
+
className: "dshAr_model",
|
|
580
|
+
"data-model-id": row.id,
|
|
581
|
+
"data-model-index": String(index),
|
|
582
|
+
children: [
|
|
583
|
+
jsx.jsxs("details", {
|
|
584
|
+
children: [
|
|
585
|
+
jsx.jsxs("summary", {
|
|
586
|
+
className: "dshAr_modelHead",
|
|
587
|
+
children: [
|
|
588
|
+
jsx.jsx("span", {
|
|
589
|
+
className: "dshAr_modelName",
|
|
590
|
+
children: row.name.trim() !== "" ? row.name : (row.id.trim() !== "" ? row.id : t("newModel")),
|
|
591
|
+
}),
|
|
592
|
+
jsx.jsx("span", { className: "dshAr_modelId", children: row.id }),
|
|
593
|
+
],
|
|
594
|
+
}),
|
|
595
|
+
jsx.jsxs("div", {
|
|
596
|
+
className: "dshAr_fields",
|
|
597
|
+
children: [
|
|
598
|
+
field("id", "fieldId"),
|
|
599
|
+
field("name", "fieldName"),
|
|
600
|
+
field("contextWindow", "fieldContext"),
|
|
601
|
+
field("maxTokens", "fieldMaxTokens"),
|
|
602
|
+
],
|
|
603
|
+
}),
|
|
604
|
+
jsx.jsxs("div", {
|
|
605
|
+
className: "dshAr_inputs",
|
|
606
|
+
children: [
|
|
607
|
+
jsx.jsx("span", { className: "dshAr_fieldLabel", children: t("fieldInput") }),
|
|
608
|
+
...MODALITIES.map((modality) => jsx.jsxs(
|
|
609
|
+
"label",
|
|
610
|
+
{
|
|
611
|
+
className: "dshAr_toggle",
|
|
612
|
+
children: [
|
|
613
|
+
jsx.jsx("input", {
|
|
614
|
+
type: "checkbox",
|
|
615
|
+
checked: row.input.includes(modality),
|
|
616
|
+
disabled: readOnly,
|
|
617
|
+
"data-model": String(index),
|
|
618
|
+
"data-modality": modality,
|
|
619
|
+
onChange: () => onPatch(index, {
|
|
620
|
+
input: row.input.includes(modality)
|
|
621
|
+
? row.input.filter((entry) => entry !== modality)
|
|
622
|
+
: [...row.input, modality],
|
|
623
|
+
}),
|
|
624
|
+
}),
|
|
625
|
+
jsx.jsx("span", { children: t(MODALITY_LABELS[modality]) }),
|
|
626
|
+
],
|
|
627
|
+
},
|
|
628
|
+
modality,
|
|
629
|
+
)),
|
|
630
|
+
],
|
|
631
|
+
}),
|
|
632
|
+
jsx.jsxs("div", {
|
|
633
|
+
className: "dshAr_efforts",
|
|
634
|
+
"data-field": "reasoningEfforts",
|
|
635
|
+
children: EFFORT_LEVELS.map((level) => {
|
|
636
|
+
const entry = row.efforts.find((candidate) => candidate.level === level)
|
|
637
|
+
?? { level, on: false, wire: "" };
|
|
638
|
+
return jsx.jsxs(
|
|
639
|
+
"label",
|
|
640
|
+
{
|
|
641
|
+
className: "dshAr_effort",
|
|
642
|
+
"data-level": level,
|
|
643
|
+
children: [
|
|
644
|
+
jsx.jsx("input", {
|
|
645
|
+
type: "checkbox",
|
|
646
|
+
checked: entry.on,
|
|
647
|
+
disabled: readOnly,
|
|
648
|
+
"data-model": String(index),
|
|
649
|
+
"data-effort": level,
|
|
650
|
+
onChange: () => onEffort(index, level, { on: !entry.on }),
|
|
651
|
+
}),
|
|
652
|
+
jsx.jsx("span", {
|
|
653
|
+
className: "dshAr_effortLevel",
|
|
654
|
+
children: t("level" + level.charAt(0).toUpperCase() + level.slice(1)),
|
|
655
|
+
}),
|
|
656
|
+
jsx.jsx("input", {
|
|
657
|
+
className: "dshAr_input dshAr_effortWire",
|
|
658
|
+
type: "text",
|
|
659
|
+
value: entry.wire,
|
|
660
|
+
placeholder: level === "off" ? t("wireNone") : level,
|
|
661
|
+
disabled: readOnly || !entry.on,
|
|
662
|
+
"data-model": String(index),
|
|
663
|
+
"data-wire": level,
|
|
664
|
+
onChange: (event) => onEffort(index, level, { wire: event.target.value }),
|
|
665
|
+
}),
|
|
666
|
+
],
|
|
667
|
+
},
|
|
668
|
+
level,
|
|
669
|
+
);
|
|
670
|
+
}),
|
|
671
|
+
}),
|
|
672
|
+
jsx.jsx("div", {
|
|
673
|
+
className: "dshAr_modelActions",
|
|
674
|
+
children: jsx.jsx("button", {
|
|
675
|
+
type: "button",
|
|
676
|
+
className: "dshAr_button",
|
|
677
|
+
"data-action": "remove",
|
|
678
|
+
disabled: readOnly,
|
|
679
|
+
onClick: () => onRemove(index),
|
|
680
|
+
children: t("removeModel"),
|
|
681
|
+
}),
|
|
682
|
+
}),
|
|
683
|
+
],
|
|
684
|
+
}),
|
|
685
|
+
],
|
|
686
|
+
},
|
|
687
|
+
index,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Store faces for a caller that injects no Host operations — an older Host,
|
|
693
|
+
* or a test rendering this component directly.
|
|
694
|
+
*/
|
|
695
|
+
const NO_OPERATIONS_STORE = { subscribe: () => () => {}, snapshot: () => 0 };
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* The relay's model list: one editable row per model the `agentrouter` route
|
|
699
|
+
* serves, the 更新 action that asks the relay what it currently offers, and
|
|
700
|
+
* the write that puts the result back on the route.
|
|
701
|
+
*
|
|
702
|
+
* Reads come from the shared settings mirror through the scope bound to
|
|
703
|
+
* `llm-pi-ai`, so the card and the kernel's Models page can never disagree
|
|
704
|
+
* about the route. Writes go through the Remote call the operations were
|
|
705
|
+
* built with, NOT through `scope.set`: the mirror's write path reports a
|
|
706
|
+
* refusal by silently reloading, while this card has to show the Host's own
|
|
707
|
+
* diagnostic — a stale revision, or a payload the adapter's schema refused.
|
|
708
|
+
*
|
|
709
|
+
* An edit lives in this component until 保存; 重置 drops the draft and, when
|
|
710
|
+
* the user layer owns the list, removes that override so the composition's
|
|
711
|
+
* hand-declared list serves again.
|
|
712
|
+
*
|
|
713
|
+
* @param {object} props - the route scope, the Host operations, and `t`.
|
|
714
|
+
* @returns {JSX.Element} the model list.
|
|
715
|
+
*/
|
|
716
|
+
function ModelList({ routeScope, operations, t }) {
|
|
717
|
+
const snapshot = react.useSyncExternalStore(
|
|
718
|
+
react.useCallback((listener) => routeScope.subscribe(listener), [routeScope]),
|
|
719
|
+
() => routeScope.getSnapshot(),
|
|
720
|
+
);
|
|
721
|
+
const ops = operations ?? {};
|
|
722
|
+
// The optional Remote faces mount after this plugin activates, so the
|
|
723
|
+
// capability is re-read on every one of their announcements instead of
|
|
724
|
+
// being frozen at the first render.
|
|
725
|
+
const store = typeof ops.subscribe === "function" && typeof ops.snapshot === "function"
|
|
726
|
+
? ops
|
|
727
|
+
: NO_OPERATIONS_STORE;
|
|
728
|
+
react.useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
|
|
729
|
+
const [draft, setDraft] = react.useState(null);
|
|
730
|
+
const [busy, setBusy] = react.useState(null);
|
|
731
|
+
const [status, setStatus] = react.useState(null);
|
|
732
|
+
|
|
733
|
+
const writable = snapshot.status === "ready" && snapshot.writable === true;
|
|
734
|
+
const canWrite = typeof ops.write === "function";
|
|
735
|
+
const canProbe = typeof ops.available === "function"
|
|
736
|
+
? ops.available() === true
|
|
737
|
+
: typeof ops.discover === "function";
|
|
738
|
+
const rows = draft !== null ? draft.rows : rowsOf(modelsOf(snapshot.value));
|
|
739
|
+
const overridden = overridesModels(snapshot.user);
|
|
740
|
+
const readOnly = !writable || !canWrite || busy !== null;
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Stage one change as a draft. The first edit captures the namespace
|
|
744
|
+
* revision it was opened at, which is the fence the save carries, so a
|
|
745
|
+
* concurrent write elsewhere is refused instead of overwritten.
|
|
746
|
+
* @param {Function} change - rows in, next rows out.
|
|
747
|
+
*/
|
|
748
|
+
const edit = (change) => {
|
|
749
|
+
setDraft((current) => {
|
|
750
|
+
const base = current ?? { rows: rowsOf(modelsOf(snapshot.value)), revision: snapshot.revision };
|
|
751
|
+
return { rows: change(base.rows), revision: base.revision };
|
|
752
|
+
});
|
|
753
|
+
setStatus({ kind: "info", key: "unsaved", values: [] });
|
|
754
|
+
};
|
|
755
|
+
const patchRow = (index, fields) => edit((list) => list.map(
|
|
756
|
+
(row, at) => (at === index ? { ...row, ...fields } : row),
|
|
757
|
+
));
|
|
758
|
+
const patchEffort = (index, level, fields) => edit((list) => list.map((row, at) => {
|
|
759
|
+
if (at !== index) return row;
|
|
760
|
+
return {
|
|
761
|
+
...row,
|
|
762
|
+
efforts: row.efforts.map((entry) => (entry.level === level ? { ...entry, ...fields } : entry)),
|
|
763
|
+
};
|
|
764
|
+
}));
|
|
765
|
+
const addRow = () => edit((list) => [...list, freshRow("")]);
|
|
766
|
+
const removeRow = (index) => edit((list) => list.filter((row, at) => at !== index));
|
|
767
|
+
const fail = (key, values) => setStatus({ kind: "error", key, values });
|
|
768
|
+
const reasonOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
769
|
+
|
|
770
|
+
/** Ask the relay what it serves, and stage what is not in the list yet. */
|
|
771
|
+
const refresh = () => {
|
|
772
|
+
if (!canProbe || typeof ops.discover !== "function" || readOnly) return;
|
|
773
|
+
const profile = routeProfile(snapshot.value);
|
|
774
|
+
const baseURL = typeof profile?.baseURL === "string" ? profile.baseURL : "";
|
|
775
|
+
if (baseURL === "") {
|
|
776
|
+
fail("refreshFailed", [t("refreshUnavailable")]);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const request = { provider: RELAY_ROUTE, baseURL };
|
|
780
|
+
if (typeof profile.api === "string") request.api = profile.api;
|
|
781
|
+
setBusy("refresh");
|
|
782
|
+
setStatus({ kind: "info", key: "refreshing", values: [] });
|
|
783
|
+
Promise.resolve()
|
|
784
|
+
.then(() => ops.discover(request))
|
|
785
|
+
.then((outcome) => {
|
|
786
|
+
if (!outcome.ok) {
|
|
787
|
+
fail("refreshFailed", [outcome.message]);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
// Merge, never replace: a discovered id that is already listed keeps
|
|
791
|
+
// every hand-tuned parameter, and a local model the relay no longer
|
|
792
|
+
// lists is left alone rather than silently dropped.
|
|
793
|
+
const current = draft !== null ? draft.rows : rowsOf(modelsOf(snapshot.value));
|
|
794
|
+
const known = new Set(current.map((row) => row.id.trim()));
|
|
795
|
+
const added = [];
|
|
796
|
+
for (const model of outcome.models) {
|
|
797
|
+
const id = typeof model?.id === "string" ? model.id.trim() : "";
|
|
798
|
+
if (id === "" || known.has(id)) continue;
|
|
799
|
+
known.add(id);
|
|
800
|
+
added.push(freshRow(id));
|
|
801
|
+
}
|
|
802
|
+
if (added.length > 0) {
|
|
803
|
+
setDraft({
|
|
804
|
+
rows: [...current, ...added],
|
|
805
|
+
revision: draft !== null ? draft.revision : snapshot.revision,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
setStatus({
|
|
809
|
+
kind: "info",
|
|
810
|
+
key: added.length > 0 ? "refreshAdded" : "refreshNone",
|
|
811
|
+
values: added.length > 0
|
|
812
|
+
? [String(outcome.models.length), String(added.length)]
|
|
813
|
+
: [String(outcome.models.length)],
|
|
814
|
+
});
|
|
815
|
+
}, (error) => fail("refreshFailed", [reasonOf(error)]))
|
|
816
|
+
.then(() => setBusy(null));
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
/** Write the drafted list onto the route's own `models` key. */
|
|
820
|
+
const save = () => {
|
|
821
|
+
if (draft === null || !canWrite || readOnly) return;
|
|
822
|
+
const failure = validateRows(draft.rows, t);
|
|
823
|
+
if (failure !== undefined) {
|
|
824
|
+
setStatus({ kind: "error", key: failure.key, values: failure.values });
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const ops_ = [{
|
|
828
|
+
op: "set",
|
|
829
|
+
path: ["providers", RELAY_ROUTE, "models"],
|
|
830
|
+
value: draft.rows.map(modelOf),
|
|
831
|
+
}];
|
|
832
|
+
setBusy("save");
|
|
833
|
+
Promise.resolve()
|
|
834
|
+
.then(() => ops.write(ops_, draft.revision))
|
|
835
|
+
.then((outcome) => {
|
|
836
|
+
if (!outcome.ok) {
|
|
837
|
+
fail(outcome.code === "settings/conflict" ? "saveConflict" : "saveFailed", [outcome.message]);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
setDraft(null);
|
|
841
|
+
setStatus({ kind: "info", key: "saveDone", values: [] });
|
|
842
|
+
}, (error) => fail("saveFailed", [reasonOf(error)]))
|
|
843
|
+
.then(() => setBusy(null));
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
/** Drop the draft; with a user-owned list, remove that override too. */
|
|
847
|
+
const reset = () => {
|
|
848
|
+
if (readOnly) return;
|
|
849
|
+
if (!overridden) {
|
|
850
|
+
setDraft(null);
|
|
851
|
+
setStatus({ kind: "info", key: "resetDone", values: [] });
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (!canWrite) return;
|
|
855
|
+
setBusy("reset");
|
|
856
|
+
Promise.resolve()
|
|
857
|
+
.then(() => ops.write(resetOps(snapshot.user), snapshot.revision))
|
|
858
|
+
.then((outcome) => {
|
|
859
|
+
if (!outcome.ok) {
|
|
860
|
+
fail(outcome.code === "settings/conflict" ? "saveConflict" : "saveFailed", [outcome.message]);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
setDraft(null);
|
|
864
|
+
setStatus({ kind: "info", key: "resetDone", values: [] });
|
|
865
|
+
}, (error) => fail("saveFailed", [reasonOf(error)]))
|
|
866
|
+
.then(() => setBusy(null));
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const shown = status === null
|
|
870
|
+
? { kind: "info", text: "" }
|
|
871
|
+
: { kind: status.kind, text: fill(t(status.key), status.values) };
|
|
872
|
+
const action = (name, label, disabled, onClick) => jsx.jsx("button", {
|
|
873
|
+
type: "button",
|
|
874
|
+
className: "dshAr_button",
|
|
875
|
+
"data-action": name,
|
|
876
|
+
disabled,
|
|
877
|
+
onClick,
|
|
878
|
+
children: label,
|
|
879
|
+
});
|
|
880
|
+
const hint = !writable
|
|
881
|
+
? t("readOnlyModels")
|
|
882
|
+
: (canProbe ? "" : t("refreshUnavailable"));
|
|
883
|
+
|
|
884
|
+
return jsx.jsxs("section", {
|
|
885
|
+
className: "dshAr_models",
|
|
886
|
+
"data-model-list": RELAY_ROUTE,
|
|
887
|
+
"aria-busy": busy !== null,
|
|
888
|
+
children: [
|
|
889
|
+
jsx.jsxs("div", {
|
|
890
|
+
className: "dshAr_modelsHead",
|
|
891
|
+
children: [
|
|
892
|
+
jsx.jsx("h4", { children: t("modelsTitle") }),
|
|
893
|
+
jsx.jsxs("div", {
|
|
894
|
+
className: "dshAr_actions",
|
|
895
|
+
children: [
|
|
896
|
+
action("refresh", busy === "refresh" ? t("refreshing") : t("refresh"), readOnly || !canProbe, refresh),
|
|
897
|
+
action("reset", t("reset"), readOnly || (draft === null && !overridden), reset),
|
|
898
|
+
action("save", busy === "save" ? t("saving") : t("save"), readOnly || draft === null, save),
|
|
899
|
+
],
|
|
900
|
+
}),
|
|
901
|
+
],
|
|
902
|
+
}),
|
|
903
|
+
jsx.jsx("p", { className: "dshAr_lead", children: t("modelsLead") }),
|
|
904
|
+
jsx.jsxs("ul", {
|
|
905
|
+
className: "dshAr_modelsList",
|
|
906
|
+
children: rows.map((row, index) => jsx.jsx(ModelRow, {
|
|
907
|
+
row,
|
|
908
|
+
index,
|
|
909
|
+
readOnly,
|
|
910
|
+
onPatch: patchRow,
|
|
911
|
+
onEffort: patchEffort,
|
|
912
|
+
onRemove: removeRow,
|
|
913
|
+
t,
|
|
914
|
+
}, index)),
|
|
915
|
+
}),
|
|
916
|
+
jsx.jsx("div", {
|
|
917
|
+
className: "dshAr_actions",
|
|
918
|
+
children: action("add", t("addModel"), readOnly, addRow),
|
|
919
|
+
}),
|
|
920
|
+
jsx.jsx("p", {
|
|
921
|
+
className: "dshAr_status",
|
|
922
|
+
"data-kind": shown.kind,
|
|
923
|
+
role: shown.kind === "error" ? "alert" : "status",
|
|
924
|
+
children: shown.text,
|
|
925
|
+
}),
|
|
926
|
+
hint === "" ? null : jsx.jsx("p", { className: "dshAr_lead", children: hint }),
|
|
927
|
+
],
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
//#endregion
|
|
931
|
+
|
|
932
|
+
//#region shared relay settings page shell
|
|
933
|
+
/**
|
|
934
|
+
* The shared 「API中转」 settings page.
|
|
935
|
+
*
|
|
936
|
+
* Several plugins contribute their configuration to ONE settings page, but the
|
|
937
|
+
* kernel cannot declare that page jointly: `settings.section` is a list slot
|
|
938
|
+
* that rejects a duplicate `id` at the same priority ("already has an entry
|
|
939
|
+
* with id"), and a child slot may be declared exactly once ("slot … is already
|
|
940
|
+
* declared"). Composing several cards into one page therefore takes one
|
|
941
|
+
* declarer, so every participating plugin carries this same shell and the
|
|
942
|
+
* FIRST one to activate claims the page; the others register their card into
|
|
943
|
+
* RELAY_ITEM_SLOT and wait for the winner's declaration through
|
|
944
|
+
* `slots.inject`. Uninstalling the winner promotes another participant on the
|
|
945
|
+
* next boot, so no participant is a fixed owner.
|
|
946
|
+
*
|
|
947
|
+
* The page renders ONE TAB PER REGISTERED CARD: the tab roster comes from the
|
|
948
|
+
* child slot's own registrations (id + `label` + `order`, the same shape the
|
|
949
|
+
* kernel's own Plugins page uses for its tabs), and each panel dispatches
|
|
950
|
+
* through `renderSlot(RELAY_ITEM_SLOT, {}, { only: id })`. Every panel stays
|
|
951
|
+
* mounted but hidden, so a card's local state survives a tab switch.
|
|
952
|
+
*
|
|
953
|
+
* Keep the runtime body of this region identical to the one in
|
|
954
|
+
* `dsh-a6api`'s `src/client/relay-settings-page.js`. Participants own their own
|
|
955
|
+
* card component, locale dictionaries, settings namespace and Host half — only
|
|
956
|
+
* the page shell is shared, because cross-plugin value imports are forbidden by
|
|
957
|
+
* the client bundle purity gate. It needs nothing but `react` on purpose, so
|
|
958
|
+
* every participant's build configuration compiles it unchanged.
|
|
959
|
+
*/
|
|
960
|
+
/** Page id claimed by the first participating plugin to activate. */
|
|
961
|
+
const RELAY_PAGE_ID = "relay";
|
|
962
|
+
/** Sidebar position of the shared page; own plugins start at 110. */
|
|
963
|
+
const RELAY_PAGE_ORDER = 120;
|
|
964
|
+
/** The page's one child slot: every participant's card registers here. */
|
|
965
|
+
const RELAY_ITEM_SLOT = "relay.settings.item";
|
|
966
|
+
/**
|
|
967
|
+
* Tab chrome mirrors the kernel's own settings tabs (`.tabs` / `.tab` in
|
|
968
|
+
* `ui-settings-plugins`): tertiary label, 13px, 22px gutter. The marker is the
|
|
969
|
+
* ACTIVE TAB'S OWN bottom border and the bar draws no rail of its own — a
|
|
970
|
+
* shared underline reads as "every tab is selected".
|
|
971
|
+
*/
|
|
972
|
+
const TABLIST_STYLE = {
|
|
973
|
+
display: "flex",
|
|
974
|
+
alignItems: "flex-end",
|
|
975
|
+
gap: "22px",
|
|
976
|
+
marginTop: "2px",
|
|
977
|
+
marginBottom: "16px",
|
|
978
|
+
};
|
|
979
|
+
const TAB_STYLE = {
|
|
980
|
+
appearance: "none",
|
|
981
|
+
background: "transparent",
|
|
982
|
+
// No border on ANY tab: the marker below is the active tab's own element, so
|
|
983
|
+
// an inactive tab has nothing that could render a line.
|
|
984
|
+
border: "none",
|
|
985
|
+
position: "relative",
|
|
986
|
+
padding: "7px 1px 11px",
|
|
987
|
+
cursor: "pointer",
|
|
988
|
+
font: "inherit",
|
|
989
|
+
fontSize: "13px",
|
|
990
|
+
lineHeight: "20px",
|
|
991
|
+
color: "var(--dsw-alias-label-tertiary, inherit)",
|
|
992
|
+
};
|
|
993
|
+
const TAB_ACTIVE_STYLE = Object.assign({}, TAB_STYLE, {
|
|
994
|
+
color: "var(--dsw-alias-label-primary, inherit)",
|
|
995
|
+
});
|
|
996
|
+
/** The kernel's own tab marker: a 2px rounded bar under the active label. */
|
|
997
|
+
const TAB_MARKER_STYLE = {
|
|
998
|
+
position: "absolute",
|
|
999
|
+
left: 0,
|
|
1000
|
+
right: 0,
|
|
1001
|
+
bottom: 0,
|
|
1002
|
+
height: "2px",
|
|
1003
|
+
borderRadius: "2px 2px 0 0",
|
|
1004
|
+
background: "var(--dsw-alias-label-primary, currentColor)",
|
|
1005
|
+
};
|
|
1006
|
+
/**
|
|
1007
|
+
* Panels stay mounted (hidden) so each card keeps its local state. `display:
|
|
1008
|
+
* none` is written explicitly because a card's own styles commonly set
|
|
1009
|
+
* `display: flex` while the section's stylesheet loads after this one.
|
|
1010
|
+
*/
|
|
1011
|
+
const PANEL_STYLE = { margin: 0 };
|
|
1012
|
+
const PANEL_HIDDEN_STYLE = { margin: 0, display: "none" };
|
|
1013
|
+
/** A registration label is a plain string or a thunk re-read per projection. */
|
|
1014
|
+
function readLabel(label) {
|
|
1015
|
+
if (typeof label === "function") return label();
|
|
1016
|
+
return typeof label === "string" ? label : "";
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Build the live tab roster over the child slot's registrations. `locale` is
|
|
1020
|
+
* read through `ctx.get`: a participant needs it only to re-read localized
|
|
1021
|
+
* labels on a language switch, and reaching an undeclared service as
|
|
1022
|
+
* `ctx.locale` would trip the kernel's inject guard. Every participant passes a
|
|
1023
|
+
* bound translator as its `label` thunk, so the label re-reads the active
|
|
1024
|
+
* language on each projection.
|
|
1025
|
+
*
|
|
1026
|
+
* @param {object} ctx - browser context carrying the slot registry.
|
|
1027
|
+
* @returns {object} the tab store consumed by the page component.
|
|
1028
|
+
*/
|
|
1029
|
+
function createRelayTabs(ctx) {
|
|
1030
|
+
const locale = ctx.get("locale");
|
|
1031
|
+
let version = -1;
|
|
1032
|
+
let revision = -1;
|
|
1033
|
+
let tabs = [];
|
|
1034
|
+
return {
|
|
1035
|
+
getSnapshot: () => {
|
|
1036
|
+
const nextVersion = ctx.slots.getVersion(RELAY_ITEM_SLOT);
|
|
1037
|
+
const nextRevision = locale === undefined ? 0 : locale.getSnapshot().revision;
|
|
1038
|
+
if (nextVersion === version && nextRevision === revision) return tabs;
|
|
1039
|
+
version = nextVersion;
|
|
1040
|
+
revision = nextRevision;
|
|
1041
|
+
tabs = ctx.slots.entries(RELAY_ITEM_SLOT)
|
|
1042
|
+
.map((entry) => ({
|
|
1043
|
+
id: entry.options.id ?? "",
|
|
1044
|
+
order: entry.options.order ?? 0,
|
|
1045
|
+
label: readLabel(entry.options.label),
|
|
1046
|
+
}))
|
|
1047
|
+
.sort((left, right) => left.order - right.order);
|
|
1048
|
+
return tabs;
|
|
1049
|
+
},
|
|
1050
|
+
subscribe: (listener) => {
|
|
1051
|
+
const offSlots = ctx.slots.subscribe(RELAY_ITEM_SLOT, listener);
|
|
1052
|
+
const offLocale = locale === undefined ? undefined : locale.subscribe(listener);
|
|
1053
|
+
return () => {
|
|
1054
|
+
offSlots();
|
|
1055
|
+
if (offLocale !== undefined) offLocale();
|
|
1056
|
+
};
|
|
1057
|
+
},
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Page body: one tab per registered card, plus the selected card's panel.
|
|
1062
|
+
* The shell supplies the section's own seats and `renderSlot` bound to the
|
|
1063
|
+
* child slot declared at registration time.
|
|
1064
|
+
*
|
|
1065
|
+
* @param {object} props - the injected render seat plus the tab roster.
|
|
1066
|
+
* @returns {JSX.Element|null} the page body.
|
|
1067
|
+
*/
|
|
1068
|
+
function RelaySettingsSection({ renderSlot, relayTabs }) {
|
|
1069
|
+
const tabs = react.useSyncExternalStore(
|
|
1070
|
+
relayTabs.subscribe,
|
|
1071
|
+
relayTabs.getSnapshot,
|
|
1072
|
+
relayTabs.getSnapshot,
|
|
1073
|
+
);
|
|
1074
|
+
const [requested, setRequested] = react.useState(null);
|
|
1075
|
+
const selected = requested !== null && tabs.some((tab) => tab.id === requested)
|
|
1076
|
+
? requested
|
|
1077
|
+
: (tabs.length > 0 ? tabs[0].id : null);
|
|
1078
|
+
if (selected === null) return null;
|
|
1079
|
+
return react.createElement(
|
|
1080
|
+
"div",
|
|
1081
|
+
null,
|
|
1082
|
+
react.createElement(
|
|
1083
|
+
"div",
|
|
1084
|
+
{ role: "tablist", style: TABLIST_STYLE },
|
|
1085
|
+
tabs.map((tab) => react.createElement(
|
|
1086
|
+
"button",
|
|
1087
|
+
{
|
|
1088
|
+
key: tab.id,
|
|
1089
|
+
type: "button",
|
|
1090
|
+
role: "tab",
|
|
1091
|
+
"aria-selected": tab.id === selected,
|
|
1092
|
+
style: tab.id === selected ? TAB_ACTIVE_STYLE : TAB_STYLE,
|
|
1093
|
+
onClick: () => { setRequested(tab.id); },
|
|
1094
|
+
},
|
|
1095
|
+
tab.label,
|
|
1096
|
+
tab.id === selected ? react.createElement("span", { style: TAB_MARKER_STYLE, "aria-hidden": true }) : null,
|
|
1097
|
+
)),
|
|
1098
|
+
),
|
|
1099
|
+
tabs.map((tab) => react.createElement(
|
|
1100
|
+
"div",
|
|
1101
|
+
{
|
|
1102
|
+
key: tab.id,
|
|
1103
|
+
role: "tabpanel",
|
|
1104
|
+
hidden: tab.id !== selected,
|
|
1105
|
+
style: tab.id === selected ? PANEL_STYLE : PANEL_HIDDEN_STYLE,
|
|
1106
|
+
},
|
|
1107
|
+
renderSlot(RELAY_ITEM_SLOT, {}, { only: tab.id }),
|
|
1108
|
+
)),
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
/** Whether a participant already holds the shared page. */
|
|
1112
|
+
function relayPageClaimed(ctx) {
|
|
1113
|
+
return ctx.slots.entries("settings.section").some((entry) => entry.options.id === RELAY_PAGE_ID);
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Claim the shared page when no participant holds it yet. Call inside
|
|
1117
|
+
* `ctx.slots.inject('settings.section', …)`: injection order decides the
|
|
1118
|
+
* winner, and the losers stay silent instead of colliding with the kernel's
|
|
1119
|
+
* duplicate-id and duplicate-declaration guards.
|
|
1120
|
+
*
|
|
1121
|
+
* @param {object} ctx - browser context carrying the slot registry.
|
|
1122
|
+
* @param {Function} label - page label of the claiming participant, re-read by
|
|
1123
|
+
* the shell on every projection so a language switch reaches the sidebar too.
|
|
1124
|
+
* @returns {Function} the page registration's disposer, or a no-op when
|
|
1125
|
+
* another participant already holds the page.
|
|
1126
|
+
*/
|
|
1127
|
+
function claimRelaySettingsPage(ctx, label) {
|
|
1128
|
+
if (relayPageClaimed(ctx)) return () => {};
|
|
1129
|
+
const relayTabs = createRelayTabs(ctx);
|
|
1130
|
+
const children = {};
|
|
1131
|
+
children[RELAY_ITEM_SLOT] = { kind: "list", scope: "root" };
|
|
1132
|
+
return ctx.slots.register({
|
|
1133
|
+
name: "settings.section",
|
|
1134
|
+
id: RELAY_PAGE_ID,
|
|
1135
|
+
order: RELAY_PAGE_ORDER,
|
|
1136
|
+
label,
|
|
1137
|
+
inject: () => ({ relayTabs }),
|
|
1138
|
+
children,
|
|
1139
|
+
}, RelaySettingsSection);
|
|
1140
|
+
}
|
|
1141
|
+
//#endregion
|
|
1142
|
+
|
|
213
1143
|
//#region plugin
|
|
214
1144
|
/** Dictionary namespace owned by this plugin. */
|
|
215
1145
|
const NS = "settings.agentrouter";
|
|
@@ -223,22 +1153,91 @@ window.__ModuleLoader__.load({
|
|
|
223
1153
|
const inject = ["slots", "locale", "settingsScope"];
|
|
224
1154
|
|
|
225
1155
|
/**
|
|
226
|
-
* Register the endpoint card
|
|
1156
|
+
* Register the endpoint card inside the shared 「API中转」 page, which this
|
|
1157
|
+
* plugin carries the shell for and claims when it activates first.
|
|
227
1158
|
* @param {object} ctx - the browser plugin context.
|
|
228
1159
|
*/
|
|
229
1160
|
function apply(ctx) {
|
|
230
1161
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "llm-agentrouter: dictionaries");
|
|
231
1162
|
const t = ctx.locale.bind(NS);
|
|
232
1163
|
const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NS });
|
|
233
|
-
|
|
1164
|
+
// The model list is not this plugin's own section: it is the `agentrouter`
|
|
1165
|
+
// route inside the adapter's namespace, the same document the kernel's
|
|
1166
|
+
// Models page edits, so the card derives from that namespace's mirror.
|
|
1167
|
+
const routeScope = ctx.settingsScope.bind({ namespace: PI_AI_SETTINGS_NS });
|
|
1168
|
+
// Both Host faces are OPTIONAL reads: a deployment whose Client assembly
|
|
1169
|
+
// mounts neither still shows the endpoint switch, and the model list
|
|
1170
|
+
// degrades to read-only with the reason rendered instead of going blank.
|
|
1171
|
+
//
|
|
1172
|
+
// They are resolved per call rather than captured: the Remote namespaces
|
|
1173
|
+
// install asynchronously after this plugin activates, so a face captured
|
|
1174
|
+
// here could read as absent for the whole session. `ctx.inject`
|
|
1175
|
+
// re-announces every mount, and the store below turns that into the
|
|
1176
|
+
// re-render that re-reads the capability.
|
|
1177
|
+
let version = 0;
|
|
1178
|
+
const waiting = new Set();
|
|
1179
|
+
const announce = () => {
|
|
1180
|
+
version += 1;
|
|
1181
|
+
for (const listener of [...waiting]) listener();
|
|
1182
|
+
};
|
|
1183
|
+
const remoteOf = (name) => ctx.get(name);
|
|
1184
|
+
const operations = {
|
|
1185
|
+
available: () => remoteOf("remote.llm") !== undefined,
|
|
1186
|
+
subscribe: (listener) => {
|
|
1187
|
+
waiting.add(listener);
|
|
1188
|
+
return () => {
|
|
1189
|
+
waiting.delete(listener);
|
|
1190
|
+
};
|
|
1191
|
+
},
|
|
1192
|
+
snapshot: () => version,
|
|
1193
|
+
discover: (request) => {
|
|
1194
|
+
const llm = remoteOf("remote.llm");
|
|
1195
|
+
if (llm === undefined) return Promise.resolve({ ok: false, message: t("refreshUnavailable") });
|
|
1196
|
+
return llm.discoverModels(PI_AI_SETTINGS_NS, request).then(
|
|
1197
|
+
(response) => (response.ok
|
|
1198
|
+
? { ok: true, models: response.value }
|
|
1199
|
+
: { ok: false, message: response.error.message }),
|
|
1200
|
+
(error) => ({ ok: false, message: error instanceof Error ? error.message : String(error) }),
|
|
1201
|
+
);
|
|
1202
|
+
},
|
|
1203
|
+
write: (ops, revision) => {
|
|
1204
|
+
const settings = remoteOf("remote.settings");
|
|
1205
|
+
if (settings === undefined) {
|
|
1206
|
+
return Promise.resolve({ ok: false, code: "settings/unavailable", message: t("readOnlyModels") });
|
|
1207
|
+
}
|
|
1208
|
+
return settings.mutate(PI_AI_SETTINGS_NS, ops, revision).then(
|
|
1209
|
+
(response) => (response.ok
|
|
1210
|
+
? { ok: true, revision: response.value.revision }
|
|
1211
|
+
: { ok: false, code: response.error.code, message: response.error.message }),
|
|
1212
|
+
(error) => ({ ok: false, code: "settings/failed", message: error instanceof Error ? error.message : String(error) }),
|
|
1213
|
+
);
|
|
1214
|
+
},
|
|
1215
|
+
};
|
|
1216
|
+
// Announced per face rather than as one dependency set: a deployment that
|
|
1217
|
+
// mounts only one of them must still let the card observe it.
|
|
1218
|
+
ctx.inject(["remote.llm"], () => {
|
|
1219
|
+
announce();
|
|
1220
|
+
});
|
|
1221
|
+
ctx.inject(["remote.settings"], () => {
|
|
1222
|
+
announce();
|
|
1223
|
+
});
|
|
1224
|
+
// The page is shared with dsh-a6api and the kernel cannot declare one page
|
|
1225
|
+
// twice, so whichever participant activates first claims it and the other
|
|
1226
|
+
// only contributes a card (see the shell region above).
|
|
1227
|
+
ctx.slots.inject("settings.section", () =>
|
|
1228
|
+
claimRelaySettingsPage(ctx, () => t("pageNav")));
|
|
1229
|
+
ctx.slots.inject(RELAY_ITEM_SLOT, () =>
|
|
234
1230
|
ctx.slots.register(
|
|
235
1231
|
{
|
|
236
|
-
name:
|
|
1232
|
+
name: RELAY_ITEM_SLOT,
|
|
1233
|
+
// id = the Host settings namespace; the shared page filters its
|
|
1234
|
+
// panels by this id, so it must be the plugin's own key.
|
|
237
1235
|
id: SETTINGS_NS,
|
|
238
|
-
order:
|
|
1236
|
+
order: 20,
|
|
1237
|
+
// Rendered as the card's tab title inside the shared page.
|
|
239
1238
|
label: () => t("title"),
|
|
240
1239
|
locale: NS,
|
|
241
|
-
inject: () => ({ scope, t }),
|
|
1240
|
+
inject: () => ({ scope, t, routeScope, operations }),
|
|
242
1241
|
},
|
|
243
1242
|
EndpointCard,
|
|
244
1243
|
),
|
|
@@ -249,6 +1248,7 @@ window.__ModuleLoader__.load({
|
|
|
249
1248
|
exports.NS = NS;
|
|
250
1249
|
exports.SETTINGS_NS = SETTINGS_NS;
|
|
251
1250
|
exports.EndpointCard = EndpointCard;
|
|
1251
|
+
exports.ModelList = ModelList;
|
|
252
1252
|
exports.apply = apply;
|
|
253
1253
|
exports.inject = inject;
|
|
254
1254
|
return module.exports;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lynn123411/dsh-llm-agentrouter",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AgentRouter 中转聚合:一条 pi-ai
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "AgentRouter 中转聚合:一条 pi-ai 路由承载多模型(设置卡片内可从中转站拉取、逐个编辑模型与参数)+ 国内/国际端点一键切换 + 出站请求 User-Agent/402-配额改写围栏。aqiu817/dsh-llm-agentrouter 分叉,适配 0.1.2-alpha.5 起新 settings/slots API,已在 0.1.5-rc.1 逐项实证兼容。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
7
7
|
"dsh-plugin",
|