dsh-connect-qoder 0.1.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 +100 -0
- package/cordis.patch.yml +6 -0
- package/lib/adapter.js +445 -0
- package/lib/client.js +992 -0
- package/lib/credentials.js +324 -0
- package/lib/errors.js +37 -0
- package/lib/index.js +691 -0
- package/lib/shim.js +378 -0
- package/lib/upstream.js +1232 -0
- package/package.json +71 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-connect-qoder",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
|
+
|
|
10
|
+
//#region src/client/paths.ts
|
|
11
|
+
/** Plugin-owned read-only model route the card renders its rows from. */
|
|
12
|
+
const QODER_MODELS_PATH = "/plugins/dsh-connect-qoder/models";
|
|
13
|
+
/** Plugin-owned read-only usage route the panel renders its quotas from. */
|
|
14
|
+
const QODER_USAGE_PATH = "/plugins/dsh-connect-qoder/usage";
|
|
15
|
+
//#endregion
|
|
16
|
+
|
|
17
|
+
//#region src/client/styles.ts
|
|
18
|
+
/**
|
|
19
|
+
* Card styles, injected once into the page head.
|
|
20
|
+
*
|
|
21
|
+
* The card reuses the host's own card classes (`dsm-plugin-card` and its
|
|
22
|
+
* header/body parts) so it sits in the settings list exactly like the
|
|
23
|
+
* cards the desktop ships, and adds only the model rows it owns. Every
|
|
24
|
+
* colour is a theme token with a literal fallback, so the card follows
|
|
25
|
+
* the active theme instead of pinning one.
|
|
26
|
+
*/
|
|
27
|
+
const QODER_CARD_CSS = [
|
|
28
|
+
".dsm-qoder-body{padding:12px 14px;display:flex;flex-direction:column;gap:12px}",
|
|
29
|
+
".dsm-qoder-hint{margin:0;color:var(--dsw-alias-label-primary,#1a1a1a);font-size:12px;line-height:1.6}",
|
|
30
|
+
".dsm-qoder-switch{display:flex;align-items:center;gap:8px;font-size:13px}",
|
|
31
|
+
".dsm-qoder-models{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:6px;max-height:320px;overflow:auto}",
|
|
32
|
+
".dsm-qoder-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 8px;border:1px solid var(--dsw-alias-border-l2,#36373b);border-radius:8px}",
|
|
33
|
+
".dsm-qoder-row-main{display:flex;align-items:center;gap:8px;min-width:0}",
|
|
34
|
+
".dsm-qoder-name{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
35
|
+
".dsm-qoder-badge{font-size:10px;padding:1px 6px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,#36373b);color:var(--dsw-alias-label-primary,#1a1a1a);white-space:nowrap}",
|
|
36
|
+
".dsm-qoder-select{font-size:12px;padding:3px 6px;border-radius:6px;background:var(--dsw-alias-bg-layer-3,#202126);color:inherit;border:1px solid var(--dsw-alias-border-l2,#36373b)}",
|
|
37
|
+
".dsm-qoder-state{margin:0;font-size:12px;color:var(--dsw-alias-label-primary,#1a1a1a)}",
|
|
38
|
+
".dsm-qoder-error{margin:0;font-size:12px;color:var(--dsw-alias-state-error-primary,#d92d20)}",
|
|
39
|
+
".dsm-qoder-tabs{display:flex;gap:6px}",
|
|
40
|
+
".dsm-qoder-tab{font-size:12px;padding:4px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,#36373b);background:transparent;color:inherit;cursor:pointer}",
|
|
41
|
+
".dsm-qoder-tab-active{background:var(--dsw-alias-bg-layer-3,#202126)}",
|
|
42
|
+
".dsm-qoder-actions{display:flex;gap:8px}",
|
|
43
|
+
".dsm-qoder-button{font-size:12px;padding:5px 12px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,#36373b);background:transparent;color:inherit;cursor:pointer}",
|
|
44
|
+
".dsm-qoder-button:disabled{opacity:.5;cursor:default}",
|
|
45
|
+
// Usage panel. Mirrors the Qoder IDE's own "我的用量" card: a titled
|
|
46
|
+
// block per quota, each with a filled bar and a used/total line.
|
|
47
|
+
".dsm-qoder-usage{border:1px solid var(--dsw-alias-border-l2,#36373b);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:12px}",
|
|
48
|
+
".dsm-qoder-usage-head{display:flex;align-items:center;justify-content:space-between;gap:8px}",
|
|
49
|
+
".dsm-qoder-usage-title{margin:0;font-size:13px;font-weight:600}",
|
|
50
|
+
".dsm-qoder-usage-region{font-size:11px;color:var(--dsw-alias-label-primary,#1a1a1a)}",
|
|
51
|
+
".dsm-qoder-usage-block{display:flex;flex-direction:column;gap:6px}",
|
|
52
|
+
".dsm-qoder-usage-label{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:12px}",
|
|
53
|
+
".dsm-qoder-usage-when{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-primary,#1a1a1a);white-space:nowrap}",
|
|
54
|
+
".dsm-qoder-bar{height:6px;border-radius:999px;background:var(--dsw-alias-bg-layer-3,#202126);overflow:hidden}",
|
|
55
|
+
".dsm-qoder-bar-fill{height:100%;border-radius:999px;background:var(--dsw-alias-label-primary,#e8eaed);transition:width .3s ease}",
|
|
56
|
+
".dsm-qoder-bar-warn{background:var(--dsw-alias-state-warning-primary,#f79009)}",
|
|
57
|
+
".dsm-qoder-bar-full{background:var(--dsw-alias-state-error-primary,#d92d20)}",
|
|
58
|
+
".dsm-qoder-usage-figures{display:flex;align-items:baseline;justify-content:space-between;gap:8px;font-size:12px;color:var(--dsw-alias-label-primary,#1a1a1a)}",
|
|
59
|
+
".dsm-qoder-usage-figures strong{color:inherit;font-weight:500}",
|
|
60
|
+
".dsm-qoder-usage-badge{font-size:10px;padding:1px 6px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,#36373b);color:var(--dsw-alias-label-primary,#1a1a1a);white-space:nowrap}",
|
|
61
|
+
".dsm-qoder-usage-badge-offer{border-color:var(--dsw-alias-state-success-primary,#12b76a);color:var(--dsw-alias-state-success-primary,#12b76a)}",
|
|
62
|
+
".dsm-qoder-usage-promo{margin:0;font-size:11px;line-height:1.6;color:var(--dsw-alias-label-primary,#1a1a1a)}",
|
|
63
|
+
".dsm-qoder-usage-promo a{color:inherit}",
|
|
64
|
+
".dsm-qoder-usage-sep{height:1px;background:var(--dsw-alias-border-l2,#36373b);margin:0}",
|
|
65
|
+
".dsm-qoder-usage-spacer{flex:1}",
|
|
66
|
+
// Model row: a picker-visibility checkbox on the left, the name and its
|
|
67
|
+
// rate in the middle, the image choice on the right.
|
|
68
|
+
".dsm-qoder-pick{display:flex;align-items:center;gap:6px;flex:0 0 auto}",
|
|
69
|
+
".dsm-qoder-rate{font-size:11px;padding:1px 6px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,#36373b);color:var(--dsw-alias-label-primary,#1a1a1a);white-space:nowrap;font-variant-numeric:tabular-nums}",
|
|
70
|
+
".dsm-qoder-rate-free{border-color:var(--dsw-alias-state-success-primary,#12b76a);color:var(--dsw-alias-state-success-primary,#12b76a)}",
|
|
71
|
+
".dsm-qoder-row-off .dsm-qoder-name{opacity:.55}",
|
|
72
|
+
".dsm-qoder-models-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
|
|
73
|
+
".dsm-qoder-models-head .dsm-qoder-state{margin-left:auto}",
|
|
74
|
+
].join("");
|
|
75
|
+
/** Inject the card stylesheet once per page. */
|
|
76
|
+
function installStyles() {
|
|
77
|
+
const cssId = "dsh-connect-qoder/client.css";
|
|
78
|
+
if (document.querySelector(`style[data-plugin-css="${cssId}"]`) !== null) return;
|
|
79
|
+
const styleTag = document.createElement("style");
|
|
80
|
+
styleTag.dataset.plugin = "dsh-connect-qoder";
|
|
81
|
+
styleTag.dataset.pluginCss = cssId;
|
|
82
|
+
styleTag.textContent = QODER_CARD_CSS;
|
|
83
|
+
document.head.appendChild(styleTag);
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
|
|
87
|
+
//#region src/client/copy.ts
|
|
88
|
+
/** Simplified Chinese copy. */
|
|
89
|
+
const zh = {
|
|
90
|
+
"row.title": "接入使用 Qoder 积分与模型 (dsh-connect-qoder)",
|
|
91
|
+
"row.desc": "使用本机已登录的 Qoder(国内版 / 国际版)模型;图像输入可由你逐个模型决定。",
|
|
92
|
+
"row.expand": "展开",
|
|
93
|
+
"row.collapse": "收起",
|
|
94
|
+
"row.loading": "正在读取模型目录…",
|
|
95
|
+
"row.signedOut": "该区域未检测到已登录的 Qoder 应用,因此没有模型。",
|
|
96
|
+
"row.emptyModels": "该区域暂无可用的模型。",
|
|
97
|
+
"row.imageTitle": "图像输入",
|
|
98
|
+
"row.imageHint": "「跟随目录」使用 Qoder 自己声明的视觉能力;「开启」/「关闭」强制覆盖。开启后该模型可接收图片。",
|
|
99
|
+
"row.maxWindow": "使用模型声明的最大上下文窗口",
|
|
100
|
+
"row.maxWindowHint": "关闭时使用 Qoder 客户端默认的窗口。",
|
|
101
|
+
"row.maxWindowOn": "已启用:每个模型按目录声明的最大窗口使用。",
|
|
102
|
+
"row.maxWindowOff": "已关闭:使用 Qoder 客户端默认窗口(多数模型 200K)。",
|
|
103
|
+
"row.save": "保存",
|
|
104
|
+
"row.saving": "保存中…",
|
|
105
|
+
"row.saved": "已保存",
|
|
106
|
+
"row.discard": "撤销更改",
|
|
107
|
+
"row.unsaved": "有未保存的更改",
|
|
108
|
+
"row.failed": "保存失败",
|
|
109
|
+
"row.requestFailed": "读取模型目录失败",
|
|
110
|
+
"row.imageAuto": "跟随目录",
|
|
111
|
+
"row.imageOn": "开启",
|
|
112
|
+
"row.imageOff": "关闭",
|
|
113
|
+
"row.vision": "视觉",
|
|
114
|
+
"row.textOnly": "纯文本",
|
|
115
|
+
"row.region.qoder-cn": "国内版",
|
|
116
|
+
"row.region.qoder": "国际版",
|
|
117
|
+
"usage.title": "我的用量",
|
|
118
|
+
"usage.refresh": "刷新",
|
|
119
|
+
"usage.loading": "正在读取用量…",
|
|
120
|
+
"usage.empty": "当前账户暂无可展示的用量。",
|
|
121
|
+
"usage.unavailable": "该区域暂时无法读取用量。",
|
|
122
|
+
"usage.error": "读取用量失败",
|
|
123
|
+
"usage.planCredits": "套餐内 Credits",
|
|
124
|
+
"usage.resourcePackage": "个人资源包",
|
|
125
|
+
"usage.dedicatedPackage": "专属资源包",
|
|
126
|
+
"usage.renewsOn": "将于 {date} 续订",
|
|
127
|
+
"usage.expiresOn": "将于 {date} 结束",
|
|
128
|
+
"usage.remaining": "剩余",
|
|
129
|
+
"usage.used": "已使用",
|
|
130
|
+
"usage.exceeded": "额度已用尽",
|
|
131
|
+
"usage.promotion": "限时特惠",
|
|
132
|
+
"usage.viewDetails": "查看详情",
|
|
133
|
+
"usage.credits": "Credits",
|
|
134
|
+
"row.showInPicker": "在下拉框中显示",
|
|
135
|
+
"row.showHint": "取消勾选的模型不会出现在模型选择器里。",
|
|
136
|
+
"row.enableAll": "全部显示",
|
|
137
|
+
"row.enabledCount": "已选 {count} / {total}",
|
|
138
|
+
"row.rateFree": "免费",
|
|
139
|
+
"row.rateLabel": "倍率",
|
|
140
|
+
"row.refreshModels": "刷新模型与费率",
|
|
141
|
+
"row.refreshing": "正在刷新…",
|
|
142
|
+
"row.refreshed": "已更新({time})",
|
|
143
|
+
"row.offPeakOn": "错峰价",
|
|
144
|
+
"row.offPeakOff": "标准价",
|
|
145
|
+
"row.offPeakUntil": "{time} 后切换",
|
|
146
|
+
"row.offPeakHint": "错峰时段 {window}({zone})享受折扣;倍率按当前时段显示,到点会自动变化。"
|
|
147
|
+
};
|
|
148
|
+
/** English copy. */
|
|
149
|
+
const en = {
|
|
150
|
+
"row.title": "Connect Qoder credits and models (dsh-connect-qoder)",
|
|
151
|
+
"row.desc": "Use the Qoder models already signed in on this machine (Qoder CN / Qoder); image input is decided per model.",
|
|
152
|
+
"row.expand": "Expand",
|
|
153
|
+
"row.collapse": "Collapse",
|
|
154
|
+
"row.loading": "Reading the model catalog…",
|
|
155
|
+
"row.signedOut": "No signed-in Qoder app was found for this region, so it lists no models.",
|
|
156
|
+
"row.emptyModels": "This region currently offers no models.",
|
|
157
|
+
"row.imageTitle": "Image input",
|
|
158
|
+
"row.imageHint": "\"Follow catalog\" uses Qoder's own vision flag; On/Off forces it. An enabled model accepts images.",
|
|
159
|
+
"row.maxWindow": "Use each model's largest declared context window",
|
|
160
|
+
"row.maxWindowHint": "When off, the window the Qoder client itself starts on is used.",
|
|
161
|
+
"row.maxWindowOn": "On: each model uses the largest window its catalog entry declares.",
|
|
162
|
+
"row.maxWindowOff": "Off: the Qoder client's own default window is used (200K for most models).",
|
|
163
|
+
"row.save": "Save",
|
|
164
|
+
"row.saving": "Saving…",
|
|
165
|
+
"row.saved": "Saved",
|
|
166
|
+
"row.discard": "Discard changes",
|
|
167
|
+
"row.unsaved": "Unsaved changes",
|
|
168
|
+
"row.failed": "Save failed",
|
|
169
|
+
"row.requestFailed": "Could not read the model catalog",
|
|
170
|
+
"row.imageAuto": "Follow catalog",
|
|
171
|
+
"row.imageOn": "On",
|
|
172
|
+
"row.imageOff": "Off",
|
|
173
|
+
"row.vision": "Vision",
|
|
174
|
+
"row.textOnly": "Text",
|
|
175
|
+
"row.region.qoder-cn": "Qoder CN",
|
|
176
|
+
"row.region.qoder": "Qoder",
|
|
177
|
+
"usage.title": "My usage",
|
|
178
|
+
"usage.refresh": "Refresh",
|
|
179
|
+
"usage.loading": "Reading usage…",
|
|
180
|
+
"usage.empty": "This account has no usage to show right now.",
|
|
181
|
+
"usage.unavailable": "Usage is unavailable for this region right now.",
|
|
182
|
+
"usage.error": "Could not read usage",
|
|
183
|
+
"usage.planCredits": "Plan Credits",
|
|
184
|
+
"usage.resourcePackage": "Personal resource package",
|
|
185
|
+
"usage.dedicatedPackage": "Dedicated package",
|
|
186
|
+
"usage.renewsOn": "Renews on {date}",
|
|
187
|
+
"usage.expiresOn": "Ends on {date}",
|
|
188
|
+
"usage.remaining": "remaining",
|
|
189
|
+
"usage.used": "used",
|
|
190
|
+
"usage.exceeded": "Quota exhausted",
|
|
191
|
+
"usage.promotion": "Limited offer",
|
|
192
|
+
"usage.viewDetails": "View details",
|
|
193
|
+
"usage.credits": "Credits",
|
|
194
|
+
"row.showInPicker": "Show in the model picker",
|
|
195
|
+
"row.showHint": "An unchecked model is hidden from the model picker.",
|
|
196
|
+
"row.enableAll": "Show all",
|
|
197
|
+
"row.enabledCount": "{count} of {total} selected",
|
|
198
|
+
"row.rateFree": "free",
|
|
199
|
+
"row.rateLabel": "Rate",
|
|
200
|
+
"row.refreshModels": "Refresh models and rates",
|
|
201
|
+
"row.refreshing": "Refreshing…",
|
|
202
|
+
"row.refreshed": "Updated ({time})",
|
|
203
|
+
"row.offPeakOn": "off-peak",
|
|
204
|
+
"row.offPeakOff": "standard",
|
|
205
|
+
"row.offPeakUntil": "switches in {time}",
|
|
206
|
+
"row.offPeakHint": "The off-peak discount applies {window} ({zone}); the rate shown follows the current window and changes on its own at the boundary."
|
|
207
|
+
};
|
|
208
|
+
//#endregion
|
|
209
|
+
|
|
210
|
+
/** The three per-model image choices this card writes. */
|
|
211
|
+
const IMAGE_MODES = ["auto", "on", "off"];
|
|
212
|
+
|
|
213
|
+
/** Normalise whatever the saved map holds into one of {@link IMAGE_MODES}. */
|
|
214
|
+
function imageModeOf(overrides, modelId) {
|
|
215
|
+
const saved = overrides === null || typeof overrides !== "object" ? undefined : overrides[modelId];
|
|
216
|
+
return IMAGE_MODES.includes(saved) ? saved : "auto";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Fill a `{date}` placeholder in a translated string. */
|
|
220
|
+
function withDate(template, at) {
|
|
221
|
+
if (typeof at !== "number" || !Number.isFinite(at) || at <= 0) return "";
|
|
222
|
+
const date = new Date(at).toLocaleDateString(undefined, { year: "numeric", month: "numeric", day: "numeric" });
|
|
223
|
+
return template.replace("{date}", date);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The credit multiplier as a short label.
|
|
228
|
+
*
|
|
229
|
+
* A zero multiplier is a free model, which is worth naming rather than
|
|
230
|
+
* rendering as "x0.00"; a model whose catalog entry carries no multiplier
|
|
231
|
+
* shows nothing at all. This mirrors how the host decorates the picker
|
|
232
|
+
* name, so the card and the picker never disagree.
|
|
233
|
+
*/
|
|
234
|
+
function rateLabelOf(t, factor) {
|
|
235
|
+
const value = Number(factor);
|
|
236
|
+
if (!Number.isFinite(value)) return undefined;
|
|
237
|
+
return value <= 0 ? t("row.rateFree") : `x${value.toFixed(2)}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Seconds past local midnight in `timezone`, or undefined when unusable. */
|
|
241
|
+
function localSecondsOf(date, timezone) {
|
|
242
|
+
try {
|
|
243
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
244
|
+
timeZone: timezone,
|
|
245
|
+
hour12: false,
|
|
246
|
+
hour: "2-digit",
|
|
247
|
+
minute: "2-digit",
|
|
248
|
+
second: "2-digit"
|
|
249
|
+
}).formatToParts(date);
|
|
250
|
+
const read = (type) => Number(parts.find((part) => part.type === type)?.value ?? Number.NaN);
|
|
251
|
+
const hour = read("hour") % 24;
|
|
252
|
+
const minute = read("minute");
|
|
253
|
+
const second = read("second");
|
|
254
|
+
if (![hour, minute, second].every(Number.isFinite)) return undefined;
|
|
255
|
+
return hour * 3600 + minute * 60 + second;
|
|
256
|
+
} catch {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Parse `HH:MM` into seconds past midnight, or undefined. */
|
|
262
|
+
function parseClock(text) {
|
|
263
|
+
const match = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(String(text ?? "").trim());
|
|
264
|
+
if (match === null) return undefined;
|
|
265
|
+
const hour = Number(match[1]);
|
|
266
|
+
const minute = Number(match[2]);
|
|
267
|
+
const second = Number(match[3] ?? 0);
|
|
268
|
+
if (hour > 23 || minute > 59 || second > 59) return undefined;
|
|
269
|
+
return hour * 3600 + minute * 60 + second;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The off-peak window state for one model at a given instant.
|
|
274
|
+
*
|
|
275
|
+
* The window is evaluated in the browser rather than taken from the
|
|
276
|
+
* server's answer so the card can flip the rate and tick the countdown on
|
|
277
|
+
* its own — a rate that silently changed at 22:00 only after a manual
|
|
278
|
+
* refresh would look broken. `end` not after `start` means the window
|
|
279
|
+
* crosses midnight (`22:00`-`08:00` is the case Qoder uses), so the test
|
|
280
|
+
* is a disjunction.
|
|
281
|
+
*
|
|
282
|
+
* @returns `{ active, remainingSeconds }`, or undefined when the model
|
|
283
|
+
* carries no usable window.
|
|
284
|
+
*/
|
|
285
|
+
function offPeakState(model, now) {
|
|
286
|
+
const promo = model.promotion;
|
|
287
|
+
if (promo === null || typeof promo !== "object") return undefined;
|
|
288
|
+
const start = parseClock(promo.windowStart);
|
|
289
|
+
const end = parseClock(promo.windowEnd);
|
|
290
|
+
if (start === undefined || end === undefined || start === end) return undefined;
|
|
291
|
+
const seconds = localSecondsOf(now, promo.timezone ?? "Asia/Shanghai");
|
|
292
|
+
if (seconds === undefined) return undefined;
|
|
293
|
+
const active = start < end ? seconds >= start && seconds < end : seconds >= start || seconds < end;
|
|
294
|
+
const target = active ? end : start;
|
|
295
|
+
const remainingSeconds = target >= seconds ? target - seconds : 86400 - seconds + target;
|
|
296
|
+
return { active, remainingSeconds };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** `HH:MM:SS` from a second count, matching the Qoder client's countdown. */
|
|
300
|
+
function formatCountdown(seconds) {
|
|
301
|
+
const total = Math.max(0, Math.floor(Number(seconds) || 0));
|
|
302
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
303
|
+
return [Math.floor(total / 3600), Math.floor((total % 3600) / 60), total % 60].map(pad).join(":");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The multiplier that applies at `now`.
|
|
308
|
+
*
|
|
309
|
+
* Mirrors the host's resolution: `priceFactor` is Qoder's *discounted*
|
|
310
|
+
* price, and `beforePromotionPriceFactor` is what applies outside the
|
|
311
|
+
* window — the two are related by exactly `before x discount`. Reading
|
|
312
|
+
* `priceFactor` alone understates the cost for most of the day.
|
|
313
|
+
*/
|
|
314
|
+
function rateAt(model, now) {
|
|
315
|
+
const base = Number(model.priceFactor);
|
|
316
|
+
const promo = model.promotion;
|
|
317
|
+
if (promo === null || typeof promo !== "object") return Number.isFinite(base) ? base : undefined;
|
|
318
|
+
const before = Number(promo.beforePromotionPriceFactor);
|
|
319
|
+
const discount = Number(promo.discountFactor);
|
|
320
|
+
const state = offPeakState(model, now);
|
|
321
|
+
if (state?.active === true) {
|
|
322
|
+
if (Number.isFinite(before) && Number.isFinite(discount)) return before * discount;
|
|
323
|
+
return Number.isFinite(base) ? base : undefined;
|
|
324
|
+
}
|
|
325
|
+
if (Number.isFinite(before)) return before;
|
|
326
|
+
return Number.isFinite(base) ? base : undefined;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The set of model ids currently ticked, given what the host reported.
|
|
331
|
+
*
|
|
332
|
+
* The host stores an empty list as "no filter" (every model shows), so the
|
|
333
|
+
* card presents that same state as "everything ticked" — otherwise a fresh
|
|
334
|
+
* install would render every box empty while every model was visible.
|
|
335
|
+
*/
|
|
336
|
+
function enabledIdsFor(models, saved) {
|
|
337
|
+
const list = Array.isArray(saved) ? saved.filter((id) => typeof id === "string") : [];
|
|
338
|
+
if (list.length === 0) return new Set(models.map((model) => model.id));
|
|
339
|
+
return new Set(list);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* One quota row: label, optional badges, an optional date, a bar, and the
|
|
344
|
+
* used/total figures. Shared by the plan quota, the add-on package and the
|
|
345
|
+
* per-model dedicated packages, which differ only in their wording.
|
|
346
|
+
*/
|
|
347
|
+
function QuotaBlock({ t, label, quota, when, badge }) {
|
|
348
|
+
const percentage = Math.min(1, Math.max(0, Number(quota.percentage) || 0));
|
|
349
|
+
// Round before formatting: a ratio like 268/2000 is 0.14 in binary
|
|
350
|
+
// floating point, and rendering the raw product would emit
|
|
351
|
+
// "14.000000000000002%" into the style attribute.
|
|
352
|
+
const percent = Math.round(percentage * 1000) / 10;
|
|
353
|
+
// The IDE paints the bar neutral, amber past 80%, red once exhausted;
|
|
354
|
+
// matching that keeps a nearly-spent quota legible at a glance.
|
|
355
|
+
const tone = quota.remaining <= 0 ? " dsm-qoder-bar-full" : percentage >= 0.8 ? " dsm-qoder-bar-warn" : "";
|
|
356
|
+
const unit = quota.unit === "credits" ? t("usage.credits") : quota.unit ?? "";
|
|
357
|
+
return (0, react_jsx_runtime.jsxs)("div", {
|
|
358
|
+
className: "dsm-qoder-usage-block",
|
|
359
|
+
children: [
|
|
360
|
+
(0, react_jsx_runtime.jsxs)("div", {
|
|
361
|
+
className: "dsm-qoder-usage-label",
|
|
362
|
+
children: [
|
|
363
|
+
(0, react_jsx_runtime.jsx)("span", { children: label }),
|
|
364
|
+
badge !== undefined ? (0, react_jsx_runtime.jsx)("span", {
|
|
365
|
+
className: "dsm-qoder-usage-badge dsm-qoder-usage-badge-offer",
|
|
366
|
+
children: badge
|
|
367
|
+
}) : null,
|
|
368
|
+
quota.remaining <= 0 ? (0, react_jsx_runtime.jsx)("span", {
|
|
369
|
+
className: "dsm-qoder-usage-badge",
|
|
370
|
+
children: t("usage.exceeded")
|
|
371
|
+
}) : null,
|
|
372
|
+
when ? (0, react_jsx_runtime.jsx)("span", {
|
|
373
|
+
className: "dsm-qoder-usage-when",
|
|
374
|
+
children: when
|
|
375
|
+
}) : null
|
|
376
|
+
]
|
|
377
|
+
}),
|
|
378
|
+
(0, react_jsx_runtime.jsx)("div", {
|
|
379
|
+
className: "dsm-qoder-bar",
|
|
380
|
+
role: "progressbar",
|
|
381
|
+
"aria-valuemin": 0,
|
|
382
|
+
"aria-valuemax": 100,
|
|
383
|
+
"aria-valuenow": Math.round(percentage * 100),
|
|
384
|
+
"aria-label": label,
|
|
385
|
+
children: (0, react_jsx_runtime.jsx)("div", {
|
|
386
|
+
className: `dsm-qoder-bar-fill${tone}`,
|
|
387
|
+
style: { width: `${percent}%` }
|
|
388
|
+
})
|
|
389
|
+
}),
|
|
390
|
+
(0, react_jsx_runtime.jsxs)("div", {
|
|
391
|
+
className: "dsm-qoder-usage-figures",
|
|
392
|
+
children: [
|
|
393
|
+
(0, react_jsx_runtime.jsxs)("span", {
|
|
394
|
+
children: [
|
|
395
|
+
(0, react_jsx_runtime.jsx)("strong", { children: `${quota.used} / ${quota.total}` }),
|
|
396
|
+
` (${Math.round(percent)}%)`
|
|
397
|
+
]
|
|
398
|
+
}),
|
|
399
|
+
(0, react_jsx_runtime.jsx)("span", {
|
|
400
|
+
children: `${t("usage.remaining")} ${quota.remaining}${unit ? ` ${unit}` : ""}`
|
|
401
|
+
})
|
|
402
|
+
]
|
|
403
|
+
})
|
|
404
|
+
]
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** One region's usage block, as returned by the host usage route. */
|
|
409
|
+
function RegionUsage({ t, entry }) {
|
|
410
|
+
if (entry.available !== true) {
|
|
411
|
+
return (0, react_jsx_runtime.jsxs)("div", {
|
|
412
|
+
className: "dsm-qoder-usage-block",
|
|
413
|
+
children: [
|
|
414
|
+
(0, react_jsx_runtime.jsx)("p", {
|
|
415
|
+
className: "dsm-qoder-state",
|
|
416
|
+
children: `${entry.regionName}: ${t("usage.unavailable")}`
|
|
417
|
+
})
|
|
418
|
+
]
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
const packages = Array.isArray(entry.dedicatedPackages) ? entry.dedicatedPackages : [];
|
|
422
|
+
const campaigns = Array.isArray(entry.campaigns) ? entry.campaigns : [];
|
|
423
|
+
const hasAny = entry.userQuota !== undefined || entry.addOnQuota !== undefined || packages.length > 0;
|
|
424
|
+
return (0, react_jsx_runtime.jsxs)("div", {
|
|
425
|
+
className: "dsm-qoder-usage-block",
|
|
426
|
+
children: [
|
|
427
|
+
(0, react_jsx_runtime.jsxs)("div", {
|
|
428
|
+
className: "dsm-qoder-usage-head",
|
|
429
|
+
children: [
|
|
430
|
+
(0, react_jsx_runtime.jsx)("h4", {
|
|
431
|
+
className: "dsm-qoder-usage-title",
|
|
432
|
+
children: t("usage.title")
|
|
433
|
+
}),
|
|
434
|
+
(0, react_jsx_runtime.jsx)("span", {
|
|
435
|
+
className: "dsm-qoder-usage-region",
|
|
436
|
+
children: entry.regionName
|
|
437
|
+
})
|
|
438
|
+
]
|
|
439
|
+
}),
|
|
440
|
+
!hasAny ? (0, react_jsx_runtime.jsx)("p", {
|
|
441
|
+
className: "dsm-qoder-state",
|
|
442
|
+
children: t("usage.empty")
|
|
443
|
+
}) : null,
|
|
444
|
+
entry.userQuota !== undefined ? (0, react_jsx_runtime.jsx)(QuotaBlock, {
|
|
445
|
+
t,
|
|
446
|
+
label: t("usage.planCredits"),
|
|
447
|
+
quota: entry.userQuota,
|
|
448
|
+
when: withDate(t("usage.renewsOn"), entry.expiresAt)
|
|
449
|
+
}) : null,
|
|
450
|
+
entry.addOnQuota !== undefined ? (0, react_jsx_runtime.jsx)(QuotaBlock, {
|
|
451
|
+
t,
|
|
452
|
+
label: t("usage.resourcePackage"),
|
|
453
|
+
quota: entry.addOnQuota
|
|
454
|
+
}) : null,
|
|
455
|
+
// Dedicated packages are per-model allowances. They appear only
|
|
456
|
+
// when the account actually holds one, so the panel stays honest
|
|
457
|
+
// for accounts that have none.
|
|
458
|
+
packages.map((pack, index) => (0, react_jsx_runtime.jsxs)(react.Fragment, {
|
|
459
|
+
children: [
|
|
460
|
+
(0, react_jsx_runtime.jsx)("div", { className: "dsm-qoder-usage-sep" }),
|
|
461
|
+
(0, react_jsx_runtime.jsx)(QuotaBlock, {
|
|
462
|
+
t,
|
|
463
|
+
label: pack.name || t("usage.dedicatedPackage"),
|
|
464
|
+
quota: pack,
|
|
465
|
+
when: withDate(t("usage.expiresOn"), pack.expiresAt)
|
|
466
|
+
})
|
|
467
|
+
]
|
|
468
|
+
}, `pack:${pack.id}:${index}`)),
|
|
469
|
+
campaigns.length > 0 ? (0, react_jsx_runtime.jsx)("div", { className: "dsm-qoder-usage-sep" }) : null,
|
|
470
|
+
campaigns.map((camp) => (0, react_jsx_runtime.jsxs)("p", {
|
|
471
|
+
className: "dsm-qoder-usage-promo",
|
|
472
|
+
children: [
|
|
473
|
+
(0, react_jsx_runtime.jsx)("span", {
|
|
474
|
+
className: "dsm-qoder-usage-badge dsm-qoder-usage-badge-offer",
|
|
475
|
+
children: t("usage.promotion")
|
|
476
|
+
}),
|
|
477
|
+
" ",
|
|
478
|
+
camp.title,
|
|
479
|
+
camp.endsAt !== undefined ? ` · ${withDate(t("usage.expiresOn"), camp.endsAt)}` : "",
|
|
480
|
+
camp.detailUrl ? (0, react_jsx_runtime.jsxs)(react.Fragment, {
|
|
481
|
+
children: [
|
|
482
|
+
" ",
|
|
483
|
+
(0, react_jsx_runtime.jsx)("a", {
|
|
484
|
+
href: camp.detailUrl,
|
|
485
|
+
target: "_blank",
|
|
486
|
+
rel: "noreferrer",
|
|
487
|
+
children: t("usage.viewDetails")
|
|
488
|
+
})
|
|
489
|
+
]
|
|
490
|
+
}) : null
|
|
491
|
+
]
|
|
492
|
+
}, `camp:${camp.key}`))
|
|
493
|
+
]
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The usage section: a refresh control plus one block per region.
|
|
499
|
+
*
|
|
500
|
+
* The panel owns its own fetch rather than riding the model read, because
|
|
501
|
+
* quota changes with every turn and must be refreshable on demand.
|
|
502
|
+
*/
|
|
503
|
+
function QoderUsagePanel({ t }) {
|
|
504
|
+
const [regions, setRegions] = (0, react.useState)([]);
|
|
505
|
+
const [status, setStatus] = (0, react.useState)("loading");
|
|
506
|
+
const [notice, setNotice] = (0, react.useState)(undefined);
|
|
507
|
+
const [busy, setBusy] = (0, react.useState)(false);
|
|
508
|
+
const mounted = (0, react.useRef)(true);
|
|
509
|
+
(0, react.useEffect)(() => {
|
|
510
|
+
mounted.current = true;
|
|
511
|
+
return () => {
|
|
512
|
+
mounted.current = false;
|
|
513
|
+
};
|
|
514
|
+
}, []);
|
|
515
|
+
const load = (0, react.useCallback)(async (refresh) => {
|
|
516
|
+
setBusy(true);
|
|
517
|
+
try {
|
|
518
|
+
const response = await fetch(`${QODER_USAGE_PATH}${refresh ? "?refresh=1" : ""}`, {
|
|
519
|
+
headers: { accept: "application/json" },
|
|
520
|
+
credentials: "same-origin"
|
|
521
|
+
});
|
|
522
|
+
const value = await response.json().catch(() => void 0);
|
|
523
|
+
if (!response.ok || value === void 0) throw new Error(`HTTP ${response.status}`);
|
|
524
|
+
if (!mounted.current) return;
|
|
525
|
+
setRegions(Array.isArray(value.regions) ? value.regions : []);
|
|
526
|
+
setStatus("ready");
|
|
527
|
+
setNotice(undefined);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
if (!mounted.current) return;
|
|
530
|
+
setStatus("error");
|
|
531
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
532
|
+
} finally {
|
|
533
|
+
if (mounted.current) setBusy(false);
|
|
534
|
+
}
|
|
535
|
+
}, []);
|
|
536
|
+
(0, react.useEffect)(() => {
|
|
537
|
+
void load(false);
|
|
538
|
+
}, [load]);
|
|
539
|
+
return (0, react_jsx_runtime.jsxs)("div", {
|
|
540
|
+
className: "dsm-qoder-usage",
|
|
541
|
+
children: [
|
|
542
|
+
(0, react_jsx_runtime.jsxs)("div", {
|
|
543
|
+
className: "dsm-qoder-usage-head",
|
|
544
|
+
children: [
|
|
545
|
+
// The status line renders only when it has something to say; the
|
|
546
|
+
// spacer keeps the refresh button pinned right either way.
|
|
547
|
+
status === "loading" ? (0, react_jsx_runtime.jsx)("p", {
|
|
548
|
+
className: "dsm-qoder-hint",
|
|
549
|
+
children: t("usage.loading")
|
|
550
|
+
}) : status === "error" ? (0, react_jsx_runtime.jsx)("p", {
|
|
551
|
+
className: "dsm-qoder-error",
|
|
552
|
+
children: `${t("usage.error")}: ${notice ?? ""}`
|
|
553
|
+
}) : (0, react_jsx_runtime.jsx)("span", { className: "dsm-qoder-usage-spacer" }),
|
|
554
|
+
(0, react_jsx_runtime.jsx)("button", {
|
|
555
|
+
type: "button",
|
|
556
|
+
className: "dsm-qoder-button",
|
|
557
|
+
disabled: busy,
|
|
558
|
+
onClick: () => {
|
|
559
|
+
void load(true);
|
|
560
|
+
},
|
|
561
|
+
children: t("usage.refresh")
|
|
562
|
+
})
|
|
563
|
+
]
|
|
564
|
+
}),
|
|
565
|
+
regions.map((entry, index) => (0, react_jsx_runtime.jsxs)(react.Fragment, {
|
|
566
|
+
children: [
|
|
567
|
+
index > 0 ? (0, react_jsx_runtime.jsx)("div", { className: "dsm-qoder-usage-sep" }) : null,
|
|
568
|
+
(0, react_jsx_runtime.jsx)(RegionUsage, { t, entry })
|
|
569
|
+
]
|
|
570
|
+
}, `region:${entry.region ?? index}`))
|
|
571
|
+
]
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Render the Qoder model and image-input card. */
|
|
576
|
+
function QoderPluginCard({ t, settingsScope }) {
|
|
577
|
+
if (t === void 0) throw new Error("Qoder settings card requires its translation function");
|
|
578
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
579
|
+
const [models, setModels] = (0, react.useState)([]);
|
|
580
|
+
const [imageOverrides, setImageOverrides] = (0, react.useState)({});
|
|
581
|
+
const [savedOverrides, setSavedOverrides] = (0, react.useState)({});
|
|
582
|
+
const [maxWindow, setMaxWindow] = (0, react.useState)(false);
|
|
583
|
+
const [savedMaxWindow, setSavedMaxWindow] = (0, react.useState)(false);
|
|
584
|
+
// Picker visibility, per region. The host stores an empty list as "no
|
|
585
|
+
// filter", so the card keeps the same shape and renders that state as
|
|
586
|
+
// "everything ticked".
|
|
587
|
+
const [enabledIds, setEnabledIds] = (0, react.useState)({});
|
|
588
|
+
const [savedEnabledIds, setSavedEnabledIds] = (0, react.useState)({});
|
|
589
|
+
const [status, setStatus] = (0, react.useState)("loading");
|
|
590
|
+
const [saving, setSaving] = (0, react.useState)(false);
|
|
591
|
+
const [notice, setNotice] = (0, react.useState)(undefined);
|
|
592
|
+
const [refreshing, setRefreshing] = (0, react.useState)(false);
|
|
593
|
+
const [refreshedAt, setRefreshedAt] = (0, react.useState)(undefined);
|
|
594
|
+
// A ticking clock, so the off-peak rate and its countdown flip on their
|
|
595
|
+
// own at the window boundary instead of waiting for a manual refresh.
|
|
596
|
+
const [clock, setClock] = (0, react.useState)(() => new Date());
|
|
597
|
+
const mounted = (0, react.useRef)(true);
|
|
598
|
+
(0, react.useEffect)(() => {
|
|
599
|
+
mounted.current = true;
|
|
600
|
+
return () => {
|
|
601
|
+
mounted.current = false;
|
|
602
|
+
};
|
|
603
|
+
}, []);
|
|
604
|
+
(0, react.useEffect)(() => {
|
|
605
|
+
const timer = window.setInterval(() => {
|
|
606
|
+
if (mounted.current) setClock(new Date());
|
|
607
|
+
}, 1000);
|
|
608
|
+
return () => {
|
|
609
|
+
window.clearInterval(timer);
|
|
610
|
+
};
|
|
611
|
+
}, []);
|
|
612
|
+
/**
|
|
613
|
+
* Read the model roster from the host route.
|
|
614
|
+
*
|
|
615
|
+
* The card reads its rows from the host rather than the settings
|
|
616
|
+
* document: the roster and the rates are live catalog state, not
|
|
617
|
+
* configuration, so they are never persisted.
|
|
618
|
+
*
|
|
619
|
+
* `refresh` asks the host to re-read the catalog from upstream, which is
|
|
620
|
+
* how a newly published model or a changed multiplier reaches the
|
|
621
|
+
* picker without restarting DSH. The settings fields are only seeded on
|
|
622
|
+
* the first load: a refresh must not clobber edits the user has staged
|
|
623
|
+
* but not yet saved.
|
|
624
|
+
*/
|
|
625
|
+
const load = (0, react.useCallback)(async (refresh, signal) => {
|
|
626
|
+
if (refresh) setRefreshing(true);
|
|
627
|
+
try {
|
|
628
|
+
const response = await fetch(`${QODER_MODELS_PATH}${refresh ? "?refresh=1" : ""}`, {
|
|
629
|
+
headers: { accept: "application/json" },
|
|
630
|
+
credentials: "same-origin",
|
|
631
|
+
signal
|
|
632
|
+
});
|
|
633
|
+
const value = await response.json().catch(() => void 0);
|
|
634
|
+
if (!response.ok || value === void 0) throw new Error(`HTTP ${response.status}`);
|
|
635
|
+
if (!mounted.current) return;
|
|
636
|
+
setModels(Array.isArray(value.models) ? value.models : []);
|
|
637
|
+
if (!refresh) {
|
|
638
|
+
const overrides = value.imageOverrides !== null && typeof value.imageOverrides === "object" ? value.imageOverrides : {};
|
|
639
|
+
const savedEnabled = value.enabledModelIds !== null && typeof value.enabledModelIds === "object" ? value.enabledModelIds : {};
|
|
640
|
+
setImageOverrides(overrides);
|
|
641
|
+
setSavedOverrides(overrides);
|
|
642
|
+
setMaxWindow(value.useMaximumContextWindow === true);
|
|
643
|
+
setSavedMaxWindow(value.useMaximumContextWindow === true);
|
|
644
|
+
setEnabledIds(savedEnabled);
|
|
645
|
+
setSavedEnabledIds(savedEnabled);
|
|
646
|
+
}
|
|
647
|
+
setRefreshedAt(typeof value.refreshedAt === "number" ? value.refreshedAt : Date.now());
|
|
648
|
+
setStatus("ready");
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (!mounted.current || signal?.aborted === true) return;
|
|
651
|
+
setStatus("error");
|
|
652
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
653
|
+
} finally {
|
|
654
|
+
if (mounted.current) setRefreshing(false);
|
|
655
|
+
}
|
|
656
|
+
}, []);
|
|
657
|
+
(0, react.useEffect)(() => {
|
|
658
|
+
const controller = new AbortController();
|
|
659
|
+
void load(false, controller.signal);
|
|
660
|
+
return () => {
|
|
661
|
+
controller.abort();
|
|
662
|
+
};
|
|
663
|
+
}, [load]);
|
|
664
|
+
// Every editable field participates in "has unsaved changes": the
|
|
665
|
+
// per-model image modes, the picker roster, and the context-window switch.
|
|
666
|
+
const dirty = (0, react.useMemo)(() => JSON.stringify(imageOverrides) !== JSON.stringify(savedOverrides) || JSON.stringify(enabledIds) !== JSON.stringify(savedEnabledIds) || maxWindow !== savedMaxWindow, [imageOverrides, savedOverrides, enabledIds, savedEnabledIds, maxWindow, savedMaxWindow]);
|
|
667
|
+
const setMode = (0, react.useCallback)((modelId, mode) => {
|
|
668
|
+
setImageOverrides((current) => {
|
|
669
|
+
const next = { ...current };
|
|
670
|
+
// "auto" is the absence of an override, so an auto row never
|
|
671
|
+
// writes a key — the saved document stays minimal and a future
|
|
672
|
+
// catalog change is picked up again.
|
|
673
|
+
if (mode === "auto") delete next[modelId];
|
|
674
|
+
else next[modelId] = mode;
|
|
675
|
+
return next;
|
|
676
|
+
});
|
|
677
|
+
setNotice(undefined);
|
|
678
|
+
}, []);
|
|
679
|
+
/**
|
|
680
|
+
* Tick or untick one model for the picker.
|
|
681
|
+
*
|
|
682
|
+
* Ticking is recorded against the region's **full** roster, not against
|
|
683
|
+
* whatever happens to be ticked now, so the saved list is a complete
|
|
684
|
+
* allow-list rather than a diff. That is what lets a partially curated
|
|
685
|
+
* region stay curated when the catalog later grows.
|
|
686
|
+
*/
|
|
687
|
+
const toggleModel = (0, react.useCallback)((regionId, modelId) => {
|
|
688
|
+
setEnabledIds((current) => {
|
|
689
|
+
const roster = models.filter((m) => m.region === regionId).map((m) => m.id);
|
|
690
|
+
const active = enabledIdsFor(models, current[regionId]);
|
|
691
|
+
const next = new Set(active);
|
|
692
|
+
if (!next.delete(modelId)) next.add(modelId);
|
|
693
|
+
// A full selection is stored as the empty list, which is the
|
|
694
|
+
// "no filter" state — so re-ticking everything returns the region
|
|
695
|
+
// to following the catalog automatically.
|
|
696
|
+
const list = roster.filter((id) => next.has(id));
|
|
697
|
+
return { ...current, [regionId]: list.length === roster.length ? [] : list };
|
|
698
|
+
});
|
|
699
|
+
setNotice(undefined);
|
|
700
|
+
}, [models]);
|
|
701
|
+
/**
|
|
702
|
+
* Return one region to "follow the catalog" — every model shown.
|
|
703
|
+
*
|
|
704
|
+
* There is deliberately no "select none": an empty list *is* the
|
|
705
|
+
* no-filter state, so hiding every model is not expressible, and a
|
|
706
|
+
* button that silently showed everything instead would be a lie. A user
|
|
707
|
+
* who wants a narrow roster ticks the few they want and leaves the rest
|
|
708
|
+
* unticked.
|
|
709
|
+
*/
|
|
710
|
+
const resetRegion = (0, react.useCallback)((regionId) => {
|
|
711
|
+
setEnabledIds((current) => ({ ...current, [regionId]: [] }));
|
|
712
|
+
setNotice(undefined);
|
|
713
|
+
}, []);
|
|
714
|
+
const save = (0, react.useCallback)(async () => {
|
|
715
|
+
if (settingsScope === undefined) return;
|
|
716
|
+
setSaving(true);
|
|
717
|
+
try {
|
|
718
|
+
await settingsScope.set("imageOverrides", imageOverrides);
|
|
719
|
+
// The other fields are siblings of the same namespace, so they are
|
|
720
|
+
// written in the same save action.
|
|
721
|
+
await settingsScope.set("useMaximumContextWindow", maxWindow);
|
|
722
|
+
await settingsScope.set("enabledModelIds", enabledIds);
|
|
723
|
+
setSavedOverrides(imageOverrides);
|
|
724
|
+
setSavedMaxWindow(maxWindow);
|
|
725
|
+
setSavedEnabledIds(enabledIds);
|
|
726
|
+
setNotice(t("row.saved"));
|
|
727
|
+
} catch (error) {
|
|
728
|
+
setNotice(`${t("row.failed")}: ${error instanceof Error ? error.message : String(error)}`);
|
|
729
|
+
} finally {
|
|
730
|
+
if (mounted.current) setSaving(false);
|
|
731
|
+
}
|
|
732
|
+
}, [settingsScope, imageOverrides, maxWindow, enabledIds, t]);
|
|
733
|
+
const discard = (0, react.useCallback)(() => {
|
|
734
|
+
setImageOverrides(savedOverrides);
|
|
735
|
+
setMaxWindow(savedMaxWindow);
|
|
736
|
+
setEnabledIds(savedEnabledIds);
|
|
737
|
+
setNotice(undefined);
|
|
738
|
+
}, [savedOverrides, savedMaxWindow, savedEnabledIds]);
|
|
739
|
+
// `t()` echoes the key when a lookup misses, so the host-provided
|
|
740
|
+
// region name is the fallback rather than a raw "row.region.x" string.
|
|
741
|
+
const regionLabel = (model) => {
|
|
742
|
+
const key = `row.region.${model.region}`;
|
|
743
|
+
const translated = t(key);
|
|
744
|
+
return translated === key ? model.regionName ?? model.region : translated;
|
|
745
|
+
};
|
|
746
|
+
return (0, react_jsx_runtime.jsxs)("li", {
|
|
747
|
+
className: `dsm-plugin-card${open ? " dsm-plugin-card-open" : ""}`,
|
|
748
|
+
children: [(0, react_jsx_runtime.jsxs)("button", {
|
|
749
|
+
type: "button",
|
|
750
|
+
className: "dsm-plugin-card-header",
|
|
751
|
+
"aria-expanded": open,
|
|
752
|
+
"aria-label": `${t(open ? "row.collapse" : "row.expand")}: ${t("row.title")}`,
|
|
753
|
+
onClick: () => {
|
|
754
|
+
setOpen(!open);
|
|
755
|
+
},
|
|
756
|
+
children: [(0, react_jsx_runtime.jsxs)("span", {
|
|
757
|
+
className: "dsm-plugin-card-head",
|
|
758
|
+
children: [(0, react_jsx_runtime.jsx)("span", {
|
|
759
|
+
className: "dsm-plugin-card-title",
|
|
760
|
+
children: t("row.title")
|
|
761
|
+
}), (0, react_jsx_runtime.jsx)("span", {
|
|
762
|
+
className: "dsm-plugin-card-description",
|
|
763
|
+
children: t("row.desc")
|
|
764
|
+
})]
|
|
765
|
+
}), (0, react_jsx_runtime.jsx)("span", {
|
|
766
|
+
"aria-hidden": "true",
|
|
767
|
+
className: `dsm-plugin-card-chevron${open ? " dsm-plugin-card-chevron-open" : ""}`,
|
|
768
|
+
children: open ? "▾" : "▸"
|
|
769
|
+
})]
|
|
770
|
+
}), (0, react_jsx_runtime.jsx)("div", {
|
|
771
|
+
className: "dsm-plugin-card-body",
|
|
772
|
+
hidden: !open,
|
|
773
|
+
children: open ? (0, react_jsx_runtime.jsxs)("div", {
|
|
774
|
+
className: "dsm-qoder-body",
|
|
775
|
+
children: [
|
|
776
|
+
(0, react_jsx_runtime.jsx)(QoderUsagePanel, { t }),
|
|
777
|
+
(0, react_jsx_runtime.jsx)("p", {
|
|
778
|
+
className: "dsm-qoder-hint",
|
|
779
|
+
children: t("row.imageHint")
|
|
780
|
+
}),
|
|
781
|
+
status === "loading" ? (0, react_jsx_runtime.jsx)("p", {
|
|
782
|
+
className: "dsm-qoder-state",
|
|
783
|
+
children: t("row.loading")
|
|
784
|
+
}) : null,
|
|
785
|
+
status === "error" ? (0, react_jsx_runtime.jsx)("p", {
|
|
786
|
+
className: "dsm-qoder-error",
|
|
787
|
+
children: `${t("row.requestFailed")}: ${notice ?? ""}`
|
|
788
|
+
}) : null,
|
|
789
|
+
status === "ready" && models.length === 0 ? (0, react_jsx_runtime.jsx)("p", {
|
|
790
|
+
className: "dsm-qoder-state",
|
|
791
|
+
children: t("row.signedOut")
|
|
792
|
+
}) : null,
|
|
793
|
+
models.length > 0 ? (0, react_jsx_runtime.jsxs)("div", {
|
|
794
|
+
className: "dsm-qoder-models-head",
|
|
795
|
+
children: [(0, react_jsx_runtime.jsx)("span", {
|
|
796
|
+
className: "dsm-qoder-hint",
|
|
797
|
+
children: t("row.showHint")
|
|
798
|
+
}), (0, react_jsx_runtime.jsx)("button", {
|
|
799
|
+
type: "button",
|
|
800
|
+
className: "dsm-qoder-button",
|
|
801
|
+
disabled: refreshing,
|
|
802
|
+
onClick: () => {
|
|
803
|
+
void load(true);
|
|
804
|
+
},
|
|
805
|
+
children: refreshing ? t("row.refreshing") : t("row.refreshModels")
|
|
806
|
+
}), (0, react_jsx_runtime.jsx)("button", {
|
|
807
|
+
type: "button",
|
|
808
|
+
className: "dsm-qoder-button",
|
|
809
|
+
disabled: saving,
|
|
810
|
+
onClick: () => {
|
|
811
|
+
// One button per region: each region's roster is
|
|
812
|
+
// curated independently, so "show all" has to name
|
|
813
|
+
// which region it applies to.
|
|
814
|
+
const regionId = models[0]?.region;
|
|
815
|
+
if (regionId !== undefined) resetRegion(regionId);
|
|
816
|
+
},
|
|
817
|
+
children: t("row.enableAll")
|
|
818
|
+
}), refreshedAt !== undefined && !refreshing ? (0, react_jsx_runtime.jsx)("span", {
|
|
819
|
+
className: "dsm-qoder-state",
|
|
820
|
+
children: t("row.refreshed", { time: new Date(refreshedAt).toLocaleTimeString() })
|
|
821
|
+
}) : null]
|
|
822
|
+
}) : null,
|
|
823
|
+
// The off-peak rule is stated once for the roster rather than
|
|
824
|
+
// repeated on every row that carries it.
|
|
825
|
+
models.some((model) => model.promotion !== undefined) ? (0, react_jsx_runtime.jsx)("p", {
|
|
826
|
+
className: "dsm-qoder-hint",
|
|
827
|
+
children: t("row.offPeakHint", {
|
|
828
|
+
window: `${models.find((model) => model.promotion !== undefined)?.promotion?.windowStart}–${models.find((model) => model.promotion !== undefined)?.promotion?.windowEnd}`,
|
|
829
|
+
zone: models.find((model) => model.promotion !== undefined)?.promotion?.timezone ?? "Asia/Shanghai"
|
|
830
|
+
})
|
|
831
|
+
}) : null,
|
|
832
|
+
models.length > 0 ? (0, react_jsx_runtime.jsx)("ul", {
|
|
833
|
+
className: "dsm-qoder-models",
|
|
834
|
+
children: models.map((model) => {
|
|
835
|
+
const active = enabledIdsFor(models, enabledIds[model.region]).has(model.id);
|
|
836
|
+
// The rate is resolved against the ticking clock, not the
|
|
837
|
+
// server's snapshot, so it flips at the window boundary.
|
|
838
|
+
const offPeak = offPeakState(model, clock);
|
|
839
|
+
const rate = rateLabelOf(t, rateAt(model, clock));
|
|
840
|
+
const offPeakTitle = model.promotion === undefined ? t("row.rateLabel") : offPeak?.active === true ? `${t("row.offPeakOn")} · ${formatCountdown(offPeak.remainingSeconds)}` : t("row.offPeakOff");
|
|
841
|
+
return (0, react_jsx_runtime.jsxs)("li", {
|
|
842
|
+
className: `dsm-qoder-row${active ? "" : " dsm-qoder-row-off"}`,
|
|
843
|
+
children: [(0, react_jsx_runtime.jsxs)("span", {
|
|
844
|
+
className: "dsm-qoder-row-main",
|
|
845
|
+
children: [(0, react_jsx_runtime.jsx)("label", {
|
|
846
|
+
className: "dsm-qoder-pick",
|
|
847
|
+
title: t("row.showInPicker"),
|
|
848
|
+
children: (0, react_jsx_runtime.jsx)("input", {
|
|
849
|
+
type: "checkbox",
|
|
850
|
+
checked: active,
|
|
851
|
+
disabled: saving,
|
|
852
|
+
"aria-label": `${t("row.showInPicker")}: ${model.name ?? model.id}`,
|
|
853
|
+
onChange: () => {
|
|
854
|
+
toggleModel(model.region, model.id);
|
|
855
|
+
}
|
|
856
|
+
})
|
|
857
|
+
}), (0, react_jsx_runtime.jsx)("span", {
|
|
858
|
+
className: "dsm-qoder-name",
|
|
859
|
+
title: model.id,
|
|
860
|
+
children: model.name ?? model.id
|
|
861
|
+
}), rate !== undefined ? (0, react_jsx_runtime.jsx)("span", {
|
|
862
|
+
className: `dsm-qoder-rate${Number(rateAt(model, clock)) <= 0 ? " dsm-qoder-rate-free" : ""}`,
|
|
863
|
+
title: offPeakTitle,
|
|
864
|
+
children: rate
|
|
865
|
+
}) : null, offPeak !== undefined ? (0, react_jsx_runtime.jsx)("span", {
|
|
866
|
+
className: `dsm-qoder-badge${offPeak.active ? " dsm-qoder-badge-offer" : ""}`,
|
|
867
|
+
title: model.promotion?.description ?? "",
|
|
868
|
+
children: `${offPeak.active ? t("row.offPeakOn") : t("row.offPeakOff")} ${formatCountdown(offPeak.remainingSeconds)}`
|
|
869
|
+
}) : null, (0, react_jsx_runtime.jsx)("span", {
|
|
870
|
+
className: "dsm-qoder-badge",
|
|
871
|
+
children: regionLabel(model)
|
|
872
|
+
}), (0, react_jsx_runtime.jsx)("span", {
|
|
873
|
+
className: "dsm-qoder-badge",
|
|
874
|
+
children: model.isVL === true ? t("row.vision") : t("row.textOnly")
|
|
875
|
+
})]
|
|
876
|
+
}), (0, react_jsx_runtime.jsxs)("label", {
|
|
877
|
+
className: "dsm-qoder-switch",
|
|
878
|
+
children: [(0, react_jsx_runtime.jsx)("span", {
|
|
879
|
+
children: t("row.imageTitle")
|
|
880
|
+
}), (0, react_jsx_runtime.jsx)("select", {
|
|
881
|
+
className: "dsm-qoder-select",
|
|
882
|
+
value: imageModeOf(imageOverrides, model.id),
|
|
883
|
+
disabled: saving,
|
|
884
|
+
"aria-label": `${t("row.imageTitle")}: ${model.name ?? model.id}`,
|
|
885
|
+
onChange: (event) => {
|
|
886
|
+
setMode(model.id, event.target.value);
|
|
887
|
+
},
|
|
888
|
+
children: IMAGE_MODES.map((mode) => (0, react_jsx_runtime.jsx)("option", {
|
|
889
|
+
value: mode,
|
|
890
|
+
children: t(mode === "auto" ? "row.imageAuto" : mode === "on" ? "row.imageOn" : "row.imageOff")
|
|
891
|
+
}, mode))
|
|
892
|
+
})]
|
|
893
|
+
})]
|
|
894
|
+
}, `${model.region}:${model.id}`);
|
|
895
|
+
})
|
|
896
|
+
}) : null,
|
|
897
|
+
(0, react_jsx_runtime.jsxs)("label", {
|
|
898
|
+
className: "dsm-qoder-switch",
|
|
899
|
+
children: [(0, react_jsx_runtime.jsx)("input", {
|
|
900
|
+
type: "checkbox",
|
|
901
|
+
checked: maxWindow,
|
|
902
|
+
disabled: saving,
|
|
903
|
+
"aria-label": t("row.maxWindow"),
|
|
904
|
+
onChange: (event) => {
|
|
905
|
+
setMaxWindow(event.target.checked);
|
|
906
|
+
setNotice(undefined);
|
|
907
|
+
}
|
|
908
|
+
}), (0, react_jsx_runtime.jsx)("span", {
|
|
909
|
+
children: t("row.maxWindow")
|
|
910
|
+
})]
|
|
911
|
+
}),
|
|
912
|
+
(0, react_jsx_runtime.jsx)("p", {
|
|
913
|
+
className: "dsm-qoder-hint",
|
|
914
|
+
children: maxWindow ? t("row.maxWindowOn") : t("row.maxWindowOff")
|
|
915
|
+
}),
|
|
916
|
+
(0, react_jsx_runtime.jsxs)("div", {
|
|
917
|
+
className: "dsm-qoder-actions",
|
|
918
|
+
children: [(0, react_jsx_runtime.jsx)("button", {
|
|
919
|
+
type: "button",
|
|
920
|
+
className: "dsm-qoder-button",
|
|
921
|
+
disabled: saving || !dirty || settingsScope === undefined,
|
|
922
|
+
onClick: save,
|
|
923
|
+
children: saving ? t("row.saving") : t("row.save")
|
|
924
|
+
}), (0, react_jsx_runtime.jsx)("button", {
|
|
925
|
+
type: "button",
|
|
926
|
+
className: "dsm-qoder-button",
|
|
927
|
+
disabled: saving || !dirty,
|
|
928
|
+
onClick: discard,
|
|
929
|
+
children: t("row.discard")
|
|
930
|
+
}), dirty ? (0, react_jsx_runtime.jsx)("span", {
|
|
931
|
+
className: "dsm-qoder-state",
|
|
932
|
+
children: t("row.unsaved")
|
|
933
|
+
}) : notice !== undefined ? (0, react_jsx_runtime.jsx)("span", {
|
|
934
|
+
className: "dsm-qoder-state",
|
|
935
|
+
children: notice
|
|
936
|
+
}) : null]
|
|
937
|
+
})
|
|
938
|
+
]
|
|
939
|
+
}) : null
|
|
940
|
+
})]
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
//#region src/client/index.ts
|
|
945
|
+
/** Stable browser-plugin name. */
|
|
946
|
+
const name = "dsh-connect-qoder-client";
|
|
947
|
+
/** Client services this card reads. */
|
|
948
|
+
const inject = ["slots", "locale", "settingsScope"];
|
|
949
|
+
/**
|
|
950
|
+
* Register the Qoder card under Plugin configuration.
|
|
951
|
+
*
|
|
952
|
+
* The whole body is wrapped so that a future DSH slot-API change degrades
|
|
953
|
+
* to a `console.error` instead of throwing into the loader and raising the
|
|
954
|
+
* red "Failed to load plugins" banner — the host provider keeps working
|
|
955
|
+
* either way, and the model channel is unaffected.
|
|
956
|
+
*
|
|
957
|
+
* `key` names the settings namespace the HOST serves. A card whose key
|
|
958
|
+
* names no served namespace is registered into the slot but never
|
|
959
|
+
* rendered, because the card list is built from the Host's installed
|
|
960
|
+
* sections.
|
|
961
|
+
*/
|
|
962
|
+
function apply(ctx) {
|
|
963
|
+
try {
|
|
964
|
+
installStyles();
|
|
965
|
+
const namespace = "settings.qoder";
|
|
966
|
+
ctx.effect(() => ctx.locale.register(namespace, {
|
|
967
|
+
zh,
|
|
968
|
+
en
|
|
969
|
+
}), "dsh-connect-qoder: settings copy");
|
|
970
|
+
const t = ctx.locale.bind(namespace);
|
|
971
|
+
const settingsScope = ctx.settingsScope.bind({ namespace: "dsh-connect-qoder" });
|
|
972
|
+
ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
|
|
973
|
+
name: "settings.plugin.item",
|
|
974
|
+
key: "dsh-connect-qoder",
|
|
975
|
+
priority: 30,
|
|
976
|
+
inject: () => ({
|
|
977
|
+
t,
|
|
978
|
+
settingsScope
|
|
979
|
+
})
|
|
980
|
+
}, QoderPluginCard));
|
|
981
|
+
} catch (error) {
|
|
982
|
+
console.error("[dsh-connect-qoder] client card failed to load (host provider unaffected):", error);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
//#endregion
|
|
986
|
+
|
|
987
|
+
exports.apply = apply;
|
|
988
|
+
exports.inject = inject;
|
|
989
|
+
exports.name = name;
|
|
990
|
+
return module.exports;
|
|
991
|
+
}
|
|
992
|
+
});
|