dsh-xdpool 2.4.1 → 2.4.3
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/CHANGELOG.md +87 -0
- package/lib/bin.js +87 -11
- package/lib/client.js +157 -2
- package/lib/index.d.ts +192 -26
- package/lib/index.js +763 -44
- package/package.json +1 -1
- package/vendor/channel-pack/pack.js +97 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,93 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
本项目的所有重要变更都会记录在此文件。
|
|
3
3
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
|
+
## 2.4.3 — 渠道包补上 11128、粘性绑定跨重启、网关白名单、目录 TTL,外加 CodeArts 重签开关
|
|
5
|
+
|
|
6
|
+
> 2.4.1 与 2.4.2 修的是**池车道**(`src/upstream.ts`);这一版把同一套 11128 规则补进**渠道车道**自己那份 channel pack,并顺带解决三件独立的事:会话粘性绑定不该止于进程边界、网关页面的勾选该是真开关、远端模型目录该会过期。四个 PR 来自社区([@GIGIGIGIbaby](https://github.com/GIGIGIGIbaby)),复核与发布由维护者完成。另附一个默认关闭的 CodeArts 重签开关(见末节)。
|
|
7
|
+
|
|
8
|
+
### 渠道账号也吃 400 / 11128(PR [#63](https://github.com/XDTrees/dsh-xdpool/pull/63))
|
|
9
|
+
|
|
10
|
+
`workbuddy` 渠道在 `channels.ts` 的 `POOL_OWNED_CHANNELS` 列表里,但它**不走** `src/upstream.ts` —— 它由 channel pack 自己的 WorkBuddy 适配器应答。2.4.1 的两道内容指纹、2.4.2 的 role 转换与三个会话身份头都只加在了池车道,于是渠道账号继续吃到 2.4.2 已经替别人修好的那个 `400 / 11128`:
|
|
11
|
+
|
|
12
|
+
| 项 | 渠道适配器改前 | 改后 |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| 首条消息 role | `developer` | `system`(就地转换) |
|
|
15
|
+
| `powered by DeepSeek Harness` | 原样发出 | `running on DeepSeek Harness` |
|
|
16
|
+
| `DeepSeek Harness Web GUI` | 原样发出 | `DeepSeek Harness web interface` |
|
|
17
|
+
| `X-Conversation-ID` / `-Request-ID` / `-Message-ID` | 一个都不带 | 三个都带 |
|
|
18
|
+
|
|
19
|
+
三处编辑各自标了 `[xdpool-local]`,配套的 `tests/chat-identity-headers.test.ts` 直接对**已发布的 bundle** 做源断言(pack.js 是外部构建产物、无法 `import`,所以只能在字节层 pin 住)—— 下次 pack 刷新若把这三处冲掉,测试会变红,而不是静默回退。
|
|
20
|
+
|
|
21
|
+
### 会话粘性绑定跨过进程边界(PR [#64](https://github.com/XDTrees/dsh-xdpool/pull/64))
|
|
22
|
+
|
|
23
|
+
卡片承诺「一个会话一个账号」,但这个承诺原先止于进程边界:绑定存在一个 `Map` 里,每次重启都重新挑选,于是同一个会话一天里可能被三个账号各服务一遍,每次都按完整 prompt 计费。现在这张表写进插件状态目录(只存哈希到账号 id,不含任何消息内容),构造时加载,写入合并成一次原子 rename,并沿用与内存版相同的 LRU 上限。
|
|
24
|
+
|
|
25
|
+
测试特意把会话绑到一个「默认挑选不会选中」的账号 —— 新建的空池本来就会同意未绑定会话的选择,所以「两个池答案一致」在禁用加载时也能通过;实测把加载去掉会让其中三个用例变红。
|
|
26
|
+
|
|
27
|
+
### 网关页面的勾选不再是纯展示(PR [#65](https://github.com/XDTrees/dsh-xdpool/pull/65))
|
|
28
|
+
|
|
29
|
+
Gateway 页的模型勾选框与 LAN 共享列表此前只是**展示过滤器**:`/v1/models` 会隐藏没勾的模型,但请求路径照单全收。一台还留着旧模型 id 的手机、或照文档猜一个 id 的第二编辑器,就能继续拿卡片上写着「关」的模型花用户的钱。
|
|
30
|
+
|
|
31
|
+
现在强制发生在**调用方实际发出的那个 id 上**,且在任何车道剥前缀或改写 body **之前**,覆盖每条车道:pool、aggregate、free、combo,以及登录渠道。列表与闸门读同一份集合——这正是让卡片上的勾选有意义的原因。
|
|
32
|
+
|
|
33
|
+
### 远端模型目录会过期(PR [#66](https://github.com/XDTrees/dsh-xdpool/pull/66))
|
|
34
|
+
|
|
35
|
+
bundle 里每个适配器都把 `remoteModels` 缓存到进程结束,于是厂商改名或调整倍率要等重启才反映到卡片上——而**改名通常就是改价**,因为倍率是名字的一部分。现在由一道 TTL 闸门决定缓存能否应答,时长十分钟,可用 `DSH_XDPOOL_CATALOG_TTL_MS` 覆盖以便测试。
|
|
36
|
+
|
|
37
|
+
闸门刻意**不清缓存**:加载器只在成功时覆盖,所以刷新失败会继续沿用最后一份已知良好的目录,而不是退化成一堆裸 id;网关侧 30s 的冷却又限住了重试速率。同样无需重建 lib —— `channels.ts` 在运行期按路径加载 `pack.js`。
|
|
38
|
+
|
|
39
|
+
### 可选:CodeArts 请求带重签头(默认关闭,`[xdpool-local]`)
|
|
40
|
+
|
|
41
|
+
在 CodeArts 的 chat 请求上追加两个头:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
x-bili-resign: sdk-hmac-sha256
|
|
45
|
+
x-bili-resign-credential: base64({"ak":"…","sk":"…","token":"…"})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
用途是让 billion-context 用这对凭证**重签**一条它接管的 SDK-HMAC-SHA256 请求:该方案的签名覆盖请求体,压缩改写会让签名失效,而 bili 拿不到重签凭证时会按 compress-or-refuse 契约**本地拒发**(`bili_resign_unavailable`,表现为 403,再被误读成「密钥无效」)。带上这对头,bili 就能重算签名,压缩能力得以保留(billion-context 的 #1884 逃生口)。
|
|
49
|
+
|
|
50
|
+
**为什么默认关闭**:`billion-context` 只在 `optionalDependencies` 里,未安装时这两个头会**直达上游**,而它们携带的是 AK/SK;另外 bili 的进程内 native client 自己会构造这两个头(`resignExtra`),xdpool 加不加都不影响它。开关留给「xdpool 直接对接 bili 代理路径」或将来需要显式携带凭证的场合 —— 启用方式是在 pool 构造时传 `codeartsResignHeaders: true`(当前没有对应的用户级配置项)。
|
|
51
|
+
|
|
52
|
+
配套 `tests/codearts-resign-headers.test.ts` 用源断言 pin 住四件事:两个头名齐全、字段名与 bili `decodeApigCredential` 读取的 `ak`/`sk`/`token` 一致、插入位置在 `signRequestHuawei` 之后、以及默认关闭。
|
|
53
|
+
|
|
54
|
+
### 其他
|
|
55
|
+
|
|
56
|
+
- 新增测试:渠道包 11128 规则源断言、粘性绑定跨重启(含「去掉加载即变红」的对照)、网关白名单与列表一致性、目录 TTL。
|
|
57
|
+
|
|
58
|
+
## 2.4.2 — 第二道内容指纹:`Web GUI` 那句,以及 assistant 历史
|
|
59
|
+
> 2.4.1 之后仍有 11128。这一版补上第二道内容指纹,并把改写范围扩到 assistant 历史。修复来自社区 PR [#62](https://github.com/XDTrees/dsh-xdpool/pull/62)([@jmglsi](https://github.com/jmglsi)),实测复核与发布由维护者完成。
|
|
60
|
+
### 同一条提示词里还有第二道指纹
|
|
61
|
+
2.4.1 只改写了 `You are an AI agent powered by DeepSeek Harness` 那一句的开头。把这一句改掉之后,请求仍然 `400 / 11128`,因为网关还在看提示词的另一段——介绍 Web GUI 的那一对句子:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
You are interacting with the user through the DeepSeek Harness Web GUI
|
|
65
|
+
whose URL the runtime context names. When the user refers to "this page",
|
|
66
|
+
"this GUI", or "this app" without naming another target, they mean this GUI.
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
这两句是**合起来**才算的:单独发任一句都 `200`,两句凑齐就 `400`;在这对句子里做任何小改动都放行。所以这是第二道**内容指纹**,不是请求长度限制,也不是别的头。
|
|
70
|
+
|
|
71
|
+
修法是把产品名后的 `Web GUI` 改称 `web interface`——保住原意的最小改动。改写清单仍是数组,这一条只是新的一行。
|
|
72
|
+
|
|
73
|
+
### 被扫的是 system 和 assistant,user/tool 不扫
|
|
74
|
+
同一句话放进 `assistant` 消息同样 `400`,改写后 `200`;放进 `user` 或 `tool` 则从来不管。这一条很重要:**只要历史里有一条助手回复带着这段文字,之后这个会话的每一次请求都会失败**。所以改写范围从「只改 system」扩到 system + assistant。
|
|
75
|
+
|
|
76
|
+
### 实测(真实国内网关,改写前 → 改写后)
|
|
77
|
+
| 请求体 | 修复前 | 修复后 |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| 主 agent 提示词(含 Web GUI 那对句子) | `400 / 11128` | `200` |
|
|
80
|
+
| 同一对句子放在 `assistant` 历史里 | `400 / 11128` | `200` |
|
|
81
|
+
| 两句合一的提示词(两道指纹同时在) | `400 / 11128` | `200` |
|
|
82
|
+
| 埋进数百字长提示词中间 | `400 / 11128` | `200` |
|
|
83
|
+
| 只有无关填充的长提示词(基线) | `200` | `200` |
|
|
84
|
+
|
|
85
|
+
维护者复核时额外确认了一件事:DSH 当前版本真正发出的那几句(`The runtime context names the DeepSeek Harness implementation checkout…`、`… the DeepSeek Harness Web GUI at <url>.`、`Current DeepSeek Harness Web GUI URL: …`)**原样发送就是 `200`**,不触发任何检查——它们含产品名却不是模板命中的句式。也就是说命中的是句式,不是「出现产品名」;这也是为什么没有采用「把 `DeepSeek Harness` 全局替换成 `DSH`」那种更宽的改法:它会连 `D:\DeepSeek Harness\resources\app\dsh\` 这类要求模型逐字使用的路径一起改掉,而换不来任何额外的放行。
|
|
86
|
+
### 同时并入的两个社区修复
|
|
87
|
+
- **PR [#49](https://github.com/XDTrees/dsh-xdpool/pull/49)**([@GIGIGIGIbaby](https://github.com/GIGIGIGIbaby)):模型连通性探测的 `content` 改用内容块数组。宿主按 `content.some(...)` 校验每条出站消息,裸字符串没有 `.some`,探测在进程内就炸了,卡片上显示成 `content.some is not a function`——一个形状错误伪装成模型坏了。
|
|
88
|
+
- **PR [#50](https://github.com/XDTrees/dsh-xdpool/pull/50)**([@GIGIGIGIbaby](https://github.com/GIGIGIGIbaby)):禁用按钮划过时会「消失」。`.dsm-btn:disabled:hover` 里的 `background:transparent` 优先级 (0,3,0),压过了 `.dsm-btn-primary` 的填充色 (0,1,0);主按钮文字是深色、为浅底设计的,底一变透明文字就看不见了。现在这条规则只保留 `transform` 部分。
|
|
89
|
+
### 其他
|
|
90
|
+
- 测试:新增指纹改写与被扫角色 9 例 + 运行时上下文指纹 5 例 + 探测请求形状 4 例 + 禁用按钮悬停 6 例,全套 72 文件 947 用例。
|
|
4
91
|
## 2.4.1 — 11128 的真正成因:system 内容指纹
|
|
5
92
|
> 2.4.0 之后仍有用户撞上 `400 / 11128 Illegal API invocation from an unapproved channel`。定位下来,2.4.0 的归因是错的,这一版修的是真原因。
|
|
6
93
|
### 11128 不是身份头的问题,是 system 内容里的自报句
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createDecipheriv, createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { copyFile, mkdir, readFile, readdir, rename, rm, writeFile, writeFile as writeFile$1 } from "node:fs/promises";
|
|
4
4
|
import { homedir, platform } from "node:os";
|
|
5
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
@@ -300,8 +300,11 @@ const CHAT_IDENTITY_HEADERS = Object.freeze({
|
|
|
300
300
|
* it" above is the same finding from the other side).
|
|
301
301
|
* - it is case-insensitive (`you are an ai agent powered by deepseek harness`
|
|
302
302
|
* is blocked too).
|
|
303
|
-
* -
|
|
304
|
-
* `tool` message passes, and it is blocked in a second system
|
|
303
|
+
* - the scanned roles are `system` and `assistant`: the same sentence in a
|
|
304
|
+
* `user` or `tool` message passes, and it is blocked in a second system
|
|
305
|
+
* message too. 2.4.1 recorded this as system-only; re-measured
|
|
306
|
+
* 2026-10-21, an `assistant` message carrying the sentence 400s as well,
|
|
307
|
+
* so `prepareChatBody` rewrites both roles.
|
|
305
308
|
* - `deepseek harness` alone, or `powered by deepseek` alone, passes — the
|
|
306
309
|
* whole phrase is what matches.
|
|
307
310
|
* - replacing `powered by` with `running on` passes, which is the rewrite
|
|
@@ -313,14 +316,38 @@ const CHAT_IDENTITY_HEADERS = Object.freeze({
|
|
|
313
316
|
* lose its anchor tomorrow. Rewriting the phrase is what the report's repro
|
|
314
317
|
* verified, so that is what this does.
|
|
315
318
|
*
|
|
319
|
+
* A SECOND FINGERPRINT (measured against the live domestic gateway 2026-10-21)
|
|
320
|
+
*
|
|
321
|
+
* Once the sentence above was rewritten, requests still 400/11128'd, and the
|
|
322
|
+
* blocker turned out to be another region of the same DSH system prompt: the
|
|
323
|
+
* runtime-context sentence pair
|
|
324
|
+
*
|
|
325
|
+
* You are interacting with the user through the DeepSeek Harness Web GUI
|
|
326
|
+
* whose URL the runtime context names. When the user refers to "this page",
|
|
327
|
+
* "this GUI", or "this app" without naming another target, they mean this GUI.
|
|
328
|
+
*
|
|
329
|
+
* The pair is load-bearing: either sentence alone 200s, and so does the pair
|
|
330
|
+
* with any small edit inside it — so this is a second CONTENT fingerprint, not
|
|
331
|
+
* a prompt-length rule and not another header rule. Renaming the product's
|
|
332
|
+
* "Web GUI" to "web interface" is the smallest edit that keeps the meaning for
|
|
333
|
+
* the model and clears the check; it was verified at 200 against both a main
|
|
334
|
+
* agent system prompt and a subagent system prompt, on the same account that
|
|
335
|
+
* reproduced the 400.
|
|
336
|
+
*
|
|
337
|
+
* The same window in an `assistant` message also answers 400, and 200 after
|
|
338
|
+
* this rewrite; `user` and `tool` messages are not scanned. That is why the
|
|
339
|
+
* rewrite is applied to assistant history too — one past reply carrying a
|
|
340
|
+
* fingerprint would otherwise fail every later request in the conversation.
|
|
341
|
+
*
|
|
316
342
|
* The list is an ARRAY because these fingerprints are appended to over time; a
|
|
317
343
|
* future rejection should be one more row here, not a new code path.
|
|
318
344
|
*/
|
|
319
|
-
const BANNED_SYSTEM_PHRASES = Object.freeze([[/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"]]);
|
|
345
|
+
const BANNED_SYSTEM_PHRASES = Object.freeze([[/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"], [/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface"]]);
|
|
320
346
|
/**
|
|
321
|
-
* Rewrite fingerprinted system content, leaving everything else
|
|
347
|
+
* Rewrite fingerprinted system/assistant content, leaving everything else
|
|
348
|
+
* byte-identical.
|
|
322
349
|
*
|
|
323
|
-
*
|
|
350
|
+
* Message content arrives as either a plain string or an array of content blocks
|
|
324
351
|
* (an OpenAI-compatible body allows both), so both shapes are handled — fixing
|
|
325
352
|
* only the string form would miss every request that carries an image or a
|
|
326
353
|
* cache-control block.
|
|
@@ -962,9 +989,9 @@ var WorkBuddyUpstreamClient = class {
|
|
|
962
989
|
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
|
|
963
990
|
* force `stream: true` (the upstream rejects non-streaming), convert the
|
|
964
991
|
* DSH `developer` role into `system` (upstream rejects `developer` with
|
|
965
|
-
* business code 11128), rewrite system content the
|
|
966
|
-
* non-official client (also 11128), and flatten
|
|
967
|
-
* form.
|
|
992
|
+
* business code 11128), rewrite fingerprinted system/assistant content the
|
|
993
|
+
* gateway reads as a non-official client (also 11128), and flatten
|
|
994
|
+
* `tool_choice` into its string form.
|
|
968
995
|
*
|
|
969
996
|
* This is the REGION-BLIND half. Everything here is measured good on both
|
|
970
997
|
* gateways, so it runs before the region is known — the pool lane picks its
|
|
@@ -989,7 +1016,7 @@ var WorkBuddyUpstreamClient = class {
|
|
|
989
1016
|
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
990
1017
|
const message = value;
|
|
991
1018
|
if (message["role"] === "developer") message["role"] = "system";
|
|
992
|
-
if (message["role"] === "system") message["content"] = sanitizeSystemContent(message["content"]);
|
|
1019
|
+
if (message["role"] === "system" || message["role"] === "assistant") message["content"] = sanitizeSystemContent(message["content"]);
|
|
993
1020
|
}
|
|
994
1021
|
const choice = obj["tool_choice"];
|
|
995
1022
|
if (typeof choice === "string") {
|
|
@@ -3336,6 +3363,9 @@ var WorkBuddyAccountPool = class {
|
|
|
3336
3363
|
* the LRU order: re-binding deletes then re-inserts.
|
|
3337
3364
|
*/
|
|
3338
3365
|
affinity = /* @__PURE__ */ new Map();
|
|
3366
|
+
/** Where the bindings above are persisted; undefined keeps them in memory. */
|
|
3367
|
+
affinityPath;
|
|
3368
|
+
affinitySaveTimer;
|
|
3339
3369
|
lastScanAtMs = 0;
|
|
3340
3370
|
/**
|
|
3341
3371
|
* The account id the user pinned, or undefined for none.
|
|
@@ -3445,6 +3475,8 @@ var WorkBuddyAccountPool = class {
|
|
|
3445
3475
|
this.distribution = options.distribution ?? "priority";
|
|
3446
3476
|
this.persistent = options.persistent ?? false;
|
|
3447
3477
|
this.storePath = accountStorePath();
|
|
3478
|
+
this.affinityPath = options.affinityPath;
|
|
3479
|
+
if (this.affinityPath !== void 0) this.loadAffinity();
|
|
3448
3480
|
}
|
|
3449
3481
|
/**
|
|
3450
3482
|
* Re-apply configuration that only affects discovery and cooldown policy,
|
|
@@ -3720,6 +3752,49 @@ var WorkBuddyAccountPool = class {
|
|
|
3720
3752
|
if (oldest === void 0) break;
|
|
3721
3753
|
this.affinity.delete(oldest);
|
|
3722
3754
|
}
|
|
3755
|
+
this.scheduleAffinitySave();
|
|
3756
|
+
}
|
|
3757
|
+
/**
|
|
3758
|
+
* Restore bindings written by a previous process.
|
|
3759
|
+
*
|
|
3760
|
+
* Tolerant like the other state files: a malformed document or one bad entry
|
|
3761
|
+
* must not take the pool down, and an account id that no longer exists is
|
|
3762
|
+
* simply never matched (the next acquire re-picks and rebinds).
|
|
3763
|
+
*/
|
|
3764
|
+
loadAffinity() {
|
|
3765
|
+
if (this.affinityPath === void 0) return;
|
|
3766
|
+
try {
|
|
3767
|
+
const bindings = JSON.parse(readFileSync(this.affinityPath, "utf8")).bindings;
|
|
3768
|
+
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return;
|
|
3769
|
+
for (const [key, value] of Object.entries(bindings)) {
|
|
3770
|
+
if (key === "" || typeof value !== "string" || value === "") continue;
|
|
3771
|
+
this.affinity.set(key, value);
|
|
3772
|
+
}
|
|
3773
|
+
while (this.affinity.size > STICKY_AFFINITY_LIMIT) {
|
|
3774
|
+
const oldest = this.affinity.keys().next().value;
|
|
3775
|
+
if (oldest === void 0) break;
|
|
3776
|
+
this.affinity.delete(oldest);
|
|
3777
|
+
}
|
|
3778
|
+
} catch {}
|
|
3779
|
+
}
|
|
3780
|
+
/** Write the bindings back, coalescing a burst into one atomic rename. */
|
|
3781
|
+
scheduleAffinitySave() {
|
|
3782
|
+
if (this.affinityPath === void 0 || this.affinitySaveTimer !== void 0) return;
|
|
3783
|
+
this.affinitySaveTimer = setTimeout(() => {
|
|
3784
|
+
this.affinitySaveTimer = void 0;
|
|
3785
|
+
const path = this.affinityPath;
|
|
3786
|
+
if (path === void 0) return;
|
|
3787
|
+
try {
|
|
3788
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
3789
|
+
writeFileSync(`${path}.tmp`, `${JSON.stringify({
|
|
3790
|
+
version: 1,
|
|
3791
|
+
bindings: Object.fromEntries(this.affinity)
|
|
3792
|
+
}, null, 2)}
|
|
3793
|
+
`, "utf8");
|
|
3794
|
+
renameSync(`${path}.tmp`, path);
|
|
3795
|
+
} catch {}
|
|
3796
|
+
}, 500);
|
|
3797
|
+
this.affinitySaveTimer.unref?.();
|
|
3723
3798
|
}
|
|
3724
3799
|
/**
|
|
3725
3800
|
* `sticky`: the account this conversation already used, when it can still
|
|
@@ -11190,7 +11265,8 @@ function createCore(logger) {
|
|
|
11190
11265
|
const pool = new WorkBuddyAccountPool({
|
|
11191
11266
|
...logger === void 0 ? {} : { logger },
|
|
11192
11267
|
client,
|
|
11193
|
-
persistent: true
|
|
11268
|
+
persistent: true,
|
|
11269
|
+
affinityPath: join(pluginDataDir(), "sticky-affinity.json")
|
|
11194
11270
|
});
|
|
11195
11271
|
return {
|
|
11196
11272
|
pool,
|
package/lib/client.js
CHANGED
|
@@ -1255,6 +1255,21 @@ window.__ModuleLoader__.load({
|
|
|
1255
1255
|
.dsm-xdpool-gw-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
1256
1256
|
.dsm-xdpool-gw-flash{margin:0;padding:6px 10px;border-radius:9px;border:1px solid color-mix(in srgb, var(--dsw-alias-border-l1,#ffffff14) 70%, transparent);background:color-mix(in srgb, var(--dsw-alias-bg-layer-1,#191a1e) 72%, transparent);color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:11px;line-height:17px;word-break:break-word}
|
|
1257
1257
|
.dsm-xdpool-gw-btn{appearance:none;display:inline-flex;align-items:center;gap:6px;font:inherit;font-size:11.5px;font-weight:560;line-height:17px;padding:4px 12px;border-radius:8px;border:1px solid color-mix(in srgb, var(--dsw-alias-border-l2,#36373b) 85%, transparent);background:color-mix(in srgb, var(--dsw-alias-bg-layer-3,#2a2c33) 70%, transparent);color:var(--dsw-alias-label-secondary,#c6c9d0);cursor:pointer;white-space:nowrap;transition:border-color .16s ease,color .16s ease,background .16s ease,transform .18s var(--ease-instrument,cubic-bezier(.16,1,.3,1))}
|
|
1258
|
+
/* The gateway's shared-model picker. Grouped by lane, and each row carries BOTH
|
|
1259
|
+
the display name and the wire id, because for the login channels they are
|
|
1260
|
+
different words: the user knows the model as "Qwen3.8-Flash · 免费" and has to
|
|
1261
|
+
type "qfmodel" into the client. Showing only one of the two makes the list
|
|
1262
|
+
either unreadable or unusable. */
|
|
1263
|
+
.dsm-xdpool-gw-group{margin-top:10px;display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
1264
|
+
.dsm-xdpool-gw-group-title{margin:0 0 2px;color:var(--dsw-alias-label-tertiary,#8a90a0);font-size:11px;line-height:16px;font-weight:650;letter-spacing:.1px;text-transform:lowercase}
|
|
1265
|
+
.dsm-xdpool-gw-model{display:grid;grid-template-columns:16px minmax(0,1fr) auto;align-items:center;gap:8px;padding:3px 6px;border-radius:8px;cursor:pointer;min-width:0}
|
|
1266
|
+
.dsm-xdpool-gw-model:hover{background:color-mix(in srgb, var(--dsw-alias-bg-layer-1,#191a1e) 60%, transparent)}
|
|
1267
|
+
.dsm-xdpool-gw-model input{appearance:none;width:14px;height:14px;margin:0;border-radius:4px;border:1px solid var(--dsw-alias-border-l2,#36373b);background:color-mix(in srgb, var(--dsw-alias-bg-layer-1,#191a1e) 78%, transparent);cursor:pointer;display:grid;place-items:center}
|
|
1268
|
+
.dsm-xdpool-gw-model input:checked{background:var(--dsw-alias-brand-primary,#5686fe);border-color:var(--dsw-alias-brand-primary,#5686fe)}
|
|
1269
|
+
.dsm-xdpool-gw-model input:checked::after{content:"";width:6px;height:3px;border-left:1.5px solid #fff;border-bottom:1.5px solid #fff;transform:rotate(-45deg) translateY(-1px)}
|
|
1270
|
+
.dsm-xdpool-gw-model input:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
|
|
1271
|
+
.dsm-xdpool-gw-model-name{color:var(--dsw-alias-label-primary,#e8e8ea);font-size:12px;line-height:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
|
1272
|
+
.dsm-xdpool-gw-model-id{font-family:var(--dsw-alias-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#8a90a0);white-space:nowrap}
|
|
1258
1273
|
.dsm-xdpool-gw-btn:hover:not(:disabled){transform:translateY(-1px);border-color:color-mix(in srgb, var(--dsw-alias-brand-primary,#5686fe) 55%, var(--dsw-alias-border-l2,#36373b));color:var(--dsw-alias-label-primary,#e8e8ea)}
|
|
1259
1274
|
.dsm-xdpool-gw-btn:active:not(:disabled){transform:translateY(0);box-shadow:inset 0 2px 4px rgb(0 0 0 / 22%)}
|
|
1260
1275
|
.dsm-xdpool-gw-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:2px}
|
|
@@ -1340,7 +1355,14 @@ window.__ModuleLoader__.load({
|
|
|
1340
1355
|
.dsm-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}
|
|
1341
1356
|
.dsm-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
|
|
1342
1357
|
.dsm-btn:disabled{opacity:.4;cursor:default;filter:saturate(.35);transform:none}
|
|
1343
|
-
.
|
|
1358
|
+
/* Only the transform is neutralised here. Every variant's hover background is
|
|
1359
|
+
already gated with ":not(:disabled)", so a disabled button never acquires one
|
|
1360
|
+
and this rule has nothing to undo — but background:transparent used to sit
|
|
1361
|
+
beside it, and at specificity (0,3,0) it beat .dsm-btn-primary's (0,1,0)
|
|
1362
|
+
fill. The primary label is a dark colour designed for a light background, so
|
|
1363
|
+
on a dark panel the fill went transparent, the text stayed dark, and the
|
|
1364
|
+
button appeared to vanish the moment the cursor reached it. */
|
|
1365
|
+
.dsm-btn:disabled:hover{transform:none}
|
|
1344
1366
|
.dsm-btn-outline{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:transparent;font-weight:500}
|
|
1345
1367
|
.dsm-btn-outline:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed);background:rgba(255,255,255,.04)}
|
|
1346
1368
|
.dsm-btn-primary{background:var(--dsw-alias-label-primary,#e6e6e6);color:var(--dsw-alias-bg-layer-3,#202126)}
|
|
@@ -10769,6 +10791,35 @@ window.__ModuleLoader__.load({
|
|
|
10769
10791
|
const [revealed, setRevealed] = (0, react.useState)(false);
|
|
10770
10792
|
const [host, setHost] = (0, react.useState)("127.0.0.1");
|
|
10771
10793
|
const [port, setPort] = (0, react.useState)("8326");
|
|
10794
|
+
/**
|
|
10795
|
+
* The in-progress share list, or undefined while it still mirrors the server.
|
|
10796
|
+
*
|
|
10797
|
+
* Held as a draft for the same reason the model card holds one: saving is a
|
|
10798
|
+
* deliberate act here, because every unchecked row stops working on whatever
|
|
10799
|
+
* device is already pointed at the gateway.
|
|
10800
|
+
*/
|
|
10801
|
+
const [draftShared, setDraftShared] = (0, react.useState)(void 0);
|
|
10802
|
+
/**
|
|
10803
|
+
* Which ids are shared right now.
|
|
10804
|
+
*
|
|
10805
|
+
* `status.shared === undefined` means the user has never chosen, and the
|
|
10806
|
+
* gateway is serving everything — so the picker must show every row ticked
|
|
10807
|
+
* rather than an empty list, which would read as "nothing is shared" and invite
|
|
10808
|
+
* a save that closes a gateway that was open.
|
|
10809
|
+
*/
|
|
10810
|
+
const candidates = status?.candidates ?? [];
|
|
10811
|
+
const serverShared = status?.shared ?? candidates.map((row) => row.id);
|
|
10812
|
+
const shared = draftShared ?? new Set(serverShared);
|
|
10813
|
+
const sharedDirty = draftShared !== void 0 && (draftShared.size !== serverShared.length || [...draftShared].some((id) => !serverShared.includes(id)));
|
|
10814
|
+
const toggleShared = (id) => {
|
|
10815
|
+
const next = new Set(shared);
|
|
10816
|
+
if (next.has(id)) next.delete(id);
|
|
10817
|
+
else next.add(id);
|
|
10818
|
+
setDraftShared(next);
|
|
10819
|
+
};
|
|
10820
|
+
const setAllShared = (on) => {
|
|
10821
|
+
setDraftShared(on ? new Set(candidates.map((row) => row.id)) : /* @__PURE__ */ new Set());
|
|
10822
|
+
};
|
|
10772
10823
|
/** True once the server's values were adopted, so polls do not stomp edits. */
|
|
10773
10824
|
const seeded = (0, react.useRef)(false);
|
|
10774
10825
|
const mounted = (0, react.useRef)(true);
|
|
@@ -11008,6 +11059,92 @@ window.__ModuleLoader__.load({
|
|
|
11008
11059
|
})
|
|
11009
11060
|
]
|
|
11010
11061
|
}),
|
|
11062
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
11063
|
+
className: "dsm-xdpool-sec",
|
|
11064
|
+
children: [
|
|
11065
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
11066
|
+
className: "dsm-xdpool-sectitle",
|
|
11067
|
+
children: t?.("gw.share") ?? "Shared models"
|
|
11068
|
+
}),
|
|
11069
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
11070
|
+
className: "dsm-xdpool-gw-note",
|
|
11071
|
+
children: t?.("gw.shareHint") ?? ""
|
|
11072
|
+
}),
|
|
11073
|
+
candidates.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
11074
|
+
className: "dsm-xdpool-gw-note",
|
|
11075
|
+
children: t?.("gw.shareNone") ?? ""
|
|
11076
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
11077
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
11078
|
+
className: "dsm-xdpool-gw-actions",
|
|
11079
|
+
children: [
|
|
11080
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
11081
|
+
type: "button",
|
|
11082
|
+
className: "dsm-xdpool-gw-btn",
|
|
11083
|
+
"data-on": "false",
|
|
11084
|
+
disabled: busy,
|
|
11085
|
+
onClick: () => {
|
|
11086
|
+
setAllShared(true);
|
|
11087
|
+
},
|
|
11088
|
+
children: t?.("gw.shareAll") ?? "Select all"
|
|
11089
|
+
}),
|
|
11090
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
11091
|
+
type: "button",
|
|
11092
|
+
className: "dsm-xdpool-gw-btn",
|
|
11093
|
+
"data-on": "false",
|
|
11094
|
+
disabled: busy,
|
|
11095
|
+
onClick: () => {
|
|
11096
|
+
setAllShared(false);
|
|
11097
|
+
},
|
|
11098
|
+
children: t?.("gw.shareNoneBtn") ?? "Select none"
|
|
11099
|
+
}),
|
|
11100
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
11101
|
+
type: "button",
|
|
11102
|
+
className: "dsm-xdpool-gw-btn",
|
|
11103
|
+
"data-on": "true",
|
|
11104
|
+
disabled: busy || !sharedDirty,
|
|
11105
|
+
onClick: () => {
|
|
11106
|
+
write({ models: [...shared] }).then(() => {
|
|
11107
|
+
setDraftShared(void 0);
|
|
11108
|
+
});
|
|
11109
|
+
},
|
|
11110
|
+
children: t?.("gw.shareSave") ?? "Save"
|
|
11111
|
+
})
|
|
11112
|
+
]
|
|
11113
|
+
}),
|
|
11114
|
+
sharedDirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
11115
|
+
className: "dsm-xdpool-gw-warn",
|
|
11116
|
+
children: t?.("gw.shareDirty") ?? ""
|
|
11117
|
+
}) : null,
|
|
11118
|
+
[...new Set(candidates.map((row) => row.source))].map((source) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
11119
|
+
className: "dsm-xdpool-gw-group",
|
|
11120
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
11121
|
+
className: "dsm-xdpool-gw-group-title",
|
|
11122
|
+
children: source
|
|
11123
|
+
}), candidates.filter((row) => row.source === source).map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
11124
|
+
className: "dsm-xdpool-gw-model",
|
|
11125
|
+
children: [
|
|
11126
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
11127
|
+
type: "checkbox",
|
|
11128
|
+
checked: shared.has(row.id),
|
|
11129
|
+
disabled: busy,
|
|
11130
|
+
onChange: () => {
|
|
11131
|
+
toggleShared(row.id);
|
|
11132
|
+
}
|
|
11133
|
+
}),
|
|
11134
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
11135
|
+
className: "dsm-xdpool-gw-model-name",
|
|
11136
|
+
children: row.name
|
|
11137
|
+
}),
|
|
11138
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
11139
|
+
className: "dsm-xdpool-gw-model-id",
|
|
11140
|
+
children: row.id
|
|
11141
|
+
})
|
|
11142
|
+
]
|
|
11143
|
+
}, row.id))]
|
|
11144
|
+
}, source))
|
|
11145
|
+
] })
|
|
11146
|
+
]
|
|
11147
|
+
}),
|
|
11011
11148
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
11012
11149
|
className: "dsm-xdpool-sec",
|
|
11013
11150
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
@@ -12188,7 +12325,7 @@ window.__ModuleLoader__.load({
|
|
|
12188
12325
|
*
|
|
12189
12326
|
* @module dsh-xdpool/version
|
|
12190
12327
|
*/
|
|
12191
|
-
const POOL_VERSION = "2.4.
|
|
12328
|
+
const POOL_VERSION = "2.4.3";
|
|
12192
12329
|
//#endregion
|
|
12193
12330
|
//#region src/client/PoolCard.tsx
|
|
12194
12331
|
/**
|
|
@@ -14954,6 +15091,15 @@ window.__ModuleLoader__.load({
|
|
|
14954
15091
|
"gw.usageHint": "Any OpenAI-compatible app works. Set its base URL to the endpoint above, pick a model out of /v1/models, and send this key as the API key. The relay passes /v1/models and /v1/chat/completions only; everything else is refused before a byte is forwarded.",
|
|
14955
15092
|
"gw.loading": "Reading the relay status…",
|
|
14956
15093
|
"gw.saveFailed": "Could not save",
|
|
15094
|
+
"gw.share": "Shared models",
|
|
15095
|
+
"gw.shareHint": "Only the ticked models are reachable over the gateway. Unticking one does not just hide it from /v1/models — the gateway refuses it, because the relay key is a single secret shared by every device on the network, and a listing a client can ignore is not a restriction.",
|
|
15096
|
+
"gw.shareNone": "Nothing to offer yet: no model is reachable until an account pool, an aggregate key or a signed-in channel provides one.",
|
|
15097
|
+
"gw.shareAll": "Select all",
|
|
15098
|
+
"gw.shareNoneBtn": "Select none",
|
|
15099
|
+
"gw.shareSave": "Save sharing",
|
|
15100
|
+
"gw.shareSaved": "Gateway sharing updated",
|
|
15101
|
+
"gw.shareDirty": "Not saved yet. A device already using an unticked model will start getting 403.",
|
|
15102
|
+
"gw.shareCount": "{shared} of {total} shared",
|
|
14957
15103
|
"ledger.title": "Data dashboard",
|
|
14958
15104
|
"ledger.sub": "Every number here is counted from calls this pool actually made.",
|
|
14959
15105
|
"ledger.window": "last {count} call(s)",
|
|
@@ -15456,6 +15602,15 @@ window.__ModuleLoader__.load({
|
|
|
15456
15602
|
"gw.usageHint": "任何 OpenAI 兼容的客户端都行:接口地址填上面那个入口,模型从 /v1/models 里挑,密钥填到这个 API 密钥的位置。中继只放行 /v1/models 和 /v1/chat/completions,其余路径在转发任何字节之前就拒掉。",
|
|
15457
15603
|
"gw.loading": "正在读取中继状态……",
|
|
15458
15604
|
"gw.saveFailed": "没能保存",
|
|
15605
|
+
"gw.share": "共享哪些模型",
|
|
15606
|
+
"gw.shareHint": "只有勾选的模型能通过网关访问。取消勾选不只是把它从 /v1/models 里藏起来——网关会直接拒绝它。因为中继密钥是整台网络上所有设备共用的同一把,而一份客户端可以无视的列表算不上限制。",
|
|
15607
|
+
"gw.shareNone": "暂时无可提供:在账号池、聚合渠道密钥或已登录的渠道给出模型之前,这里不会有任何条目。",
|
|
15608
|
+
"gw.shareAll": "全选",
|
|
15609
|
+
"gw.shareNoneBtn": "全不选",
|
|
15610
|
+
"gw.shareSave": "保存共享设置",
|
|
15611
|
+
"gw.shareSaved": "网关共享设置已更新",
|
|
15612
|
+
"gw.shareDirty": "尚未保存。已在使用某个被取消勾选模型的设备,会开始收到 403。",
|
|
15613
|
+
"gw.shareCount": "已共享 {shared} / {total}",
|
|
15459
15614
|
"ledger.title": "数据看板",
|
|
15460
15615
|
"ledger.sub": "这里每个数字,都是这个池子真实发出去的调用数出来的。",
|
|
15461
15616
|
"ledger.window": "最近 {count} 次调用",
|