dsh-provider-proxy 0.2.1 → 0.4.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 +135 -116
- package/index.js +5 -5
- package/lib/client.js +217 -11
- package/lib/client.js.map +3 -3
- package/lib/index.js +278 -23
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,116 +1,135 @@
|
|
|
1
|
-
# dsh-provider-proxy
|
|
2
|
-
|
|
3
|
-
给 **DeepSeek Harness**(`0.2.0-rc.1` 及以上)用的插件:在 **设置** 里为**每个 provider 单独配置**「这个 provider 要不要走代理」,填写代理类型 + IP/端口。只有被你打开开关的 provider 走代理,其余 provider 和 DSH 的其它流量保持原来的直连路径。
|
|
4
|
-
|
|
5
|
-
支持官方桌面版(DSH Desktop)与 `dsh web`。
|
|
6
|
-
|
|
7
|
-
## 安装
|
|
8
|
-
|
|
9
|
-
先确保已构建过产物(`lib/index.js`、`lib/client.js`):
|
|
10
|
-
|
|
11
|
-
```sh
|
|
12
|
-
npm install
|
|
13
|
-
npm run build
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
桌面版(**必须先完整打开一次桌面版以初始化 profile,然后完全退出**,否则安装命令会拒绝执行):
|
|
17
|
-
|
|
18
|
-
```sh
|
|
19
|
-
dsh plugin --profile desktop add
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Web:
|
|
23
|
-
|
|
24
|
-
```sh
|
|
25
|
-
dsh plugin --profile web add
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
安装后重启对应应用。设置项写在 `$DSH_HOME/profiles/<profile>/cordis.patch.yml`。
|
|
29
|
-
|
|
30
|
-
## 使用
|
|
31
|
-
|
|
32
|
-
打开 **设置**,左侧导航里会出现一项 **Provider 代理**(英文界面为 *Provider Proxy*)。
|
|
33
|
-
页面里每个 provider 一张卡片,各自独立配置:
|
|
34
|
-
|
|
35
|
-
| 字段 | 说明 |
|
|
36
|
-
| --- | --- |
|
|
37
|
-
| 开关 | 该 provider 是否走代理 |
|
|
38
|
-
| 类型 | `HTTP` / `HTTPS
|
|
39
|
-
| IP / 主机 | 只填主机名或 IP,不要带 `http://` 或路径 |
|
|
40
|
-
| 端口 | 1–65535 的整数 |
|
|
41
|
-
| 用户名 | 可选 |
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
2.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
-
|
|
78
|
-
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
1
|
+
# dsh-provider-proxy [](https://dsh.market/)
|
|
2
|
+
|
|
3
|
+
给 **DeepSeek Harness**(`0.2.0-rc.1` 及以上)用的插件:在 **设置** 里为**每个 provider 单独配置**「这个 provider 要不要走代理」,填写代理类型 + IP/端口。只有被你打开开关的 provider 走代理,其余 provider 和 DSH 的其它流量保持原来的直连路径。
|
|
4
|
+
|
|
5
|
+
支持官方桌面版(DSH Desktop)与 `dsh web`。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
先确保已构建过产物(`lib/index.js`、`lib/client.js`):
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install
|
|
13
|
+
npm run build
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
桌面版(**必须先完整打开一次桌面版以初始化 profile,然后完全退出**,否则安装命令会拒绝执行):
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
dsh plugin --profile desktop add dsh-provider-proxy
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Web:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
dsh plugin --profile web add dsh-provider-proxy
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
安装后重启对应应用。设置项写在 `$DSH_HOME/profiles/<profile>/cordis.patch.yml`。
|
|
29
|
+
|
|
30
|
+
## 使用
|
|
31
|
+
|
|
32
|
+
打开 **设置**,左侧导航里会出现一项 **Provider 代理**(英文界面为 *Provider Proxy*)。
|
|
33
|
+
页面里每个 provider 一张卡片,各自独立配置:
|
|
34
|
+
|
|
35
|
+
| 字段 | 说明 |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| 开关 | 该 provider 是否走代理 |
|
|
38
|
+
| 类型 | `HTTP` / `HTTPS` / `SOCKS5` |
|
|
39
|
+
| IP / 主机 | 只填主机名或 IP,不要带 `http://` 或路径 |
|
|
40
|
+
| 端口 | 1–65535 的整数 |
|
|
41
|
+
| 用户名 | 可选 |
|
|
42
|
+
| 密码 | 可选。作为代理认证口令发给代理(`HTTP` / `HTTPS` / `SOCKS5` 均支持) |
|
|
43
|
+
|
|
44
|
+
列表只包含**已激活**的 provider(`llm.listProviders()` 能列出的那些)。仅在配置里声明、
|
|
45
|
+
但没有实际注册的可配置 provider 不显示——它的请求根本不会被发出去。
|
|
46
|
+
已经在 `providers` 里配过代理的条目即使暂时不激活也仍然保留,避免已保存的设置悄悄消失。
|
|
47
|
+
|
|
48
|
+
保存立即对新请求生效,不需要重启;正在进行中的流会继续用它启动时的那条连接。
|
|
49
|
+
|
|
50
|
+
每张卡片在**该 provider 已启用代理**时还有一个 **测试** 按钮:点它会让 Host 通过**该 provider 自己的代理**发一次请求,回报延迟与(探测目标返回时)出口 IP。探测目标默认是 `https://api.ipify.org?format=json`,可用顶层设置 `testUrl` 换成自建的回显服务。代理不可达时它会明确失败,而不是假通过。延迟数值按快慢着色——≤500 ms 绿、≤1500 ms 黄、更慢红——但数值始终照常显示,颜色只是辅助。
|
|
51
|
+
|
|
52
|
+
卡片里还有一个折叠的 **高级:目标域名规则**,按**目标主机**决定是否走代理:
|
|
53
|
+
|
|
54
|
+
| 字段 | 说明 |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| `仅走代理`(`onlyHosts`) | 逗号或空格分隔。非空时,只有匹配这些主机的请求走代理,其余直连。 |
|
|
57
|
+
| `直连`(`bypassHosts`) | 逗号或空格分隔。匹配这些主机的请求直连,优先于 `onlyHosts`。 |
|
|
58
|
+
|
|
59
|
+
两者都按后缀匹配(`example.com` 覆盖 `api.example.com`),`*` 表示全部,大小写不敏感。**默认是 fail-closed**:启用了代理的路由默认走代理,只有「`bypassHosts` 命中」与「`onlyHosts` 非空且未命中」两种显式例外才直连;解析不出目标主机时**仍走代理**,避免畸形 URL 把带凭据的请求悄悄直连出去。
|
|
60
|
+
|
|
61
|
+
当组成里存在 web 服务时,列表还会多出一张 **Web 搜索** 卡片(保留 id `web-search`,字段与其它卡片完全相同,也支持自检、计数与域名规则)。它承载 `web_search` 工具的流量;不启用它,搜索保持直连。
|
|
62
|
+
|
|
63
|
+
> **为什么是独立设置页,而不是内嵌在 Models 的 provider 卡片里?**
|
|
64
|
+
> 本插件最早按卡片内嵌(`settings.models.provider-card`)实现,但那个槽位是 keyed 的、
|
|
65
|
+
> 按 provider 命名空间派发,第三方注册在 0.2.0-rc.1 上始终匹配不上——不报错、不发请求、
|
|
66
|
+
> 界面上什么都不出现。独立分区(`settings.section`)是同一 profile 下
|
|
67
|
+
> `dsh-llm-retry-settings` 已被验证可用的机制。
|
|
68
|
+
|
|
69
|
+
## 它是怎么工作的
|
|
70
|
+
|
|
71
|
+
0.2.0 没有「给单个 provider 注入 transport」的官方口子,这是核对过源码的结论:
|
|
72
|
+
|
|
73
|
+
- `llm-deepseek` 直接调用全局 `fetch`,请求参数里没有 `dispatcher`;
|
|
74
|
+
- `llm-pi-ai` 走 pi-ai SDK 自己的 HTTP,DSH 无法配置;
|
|
75
|
+
- `LlmCallControls` 只有 `reasoningEffort` / `temperature` / `maxTokens` / `stop`;
|
|
76
|
+
- `llm/stream` 拦截的是**已解码的** `StreamChunk`,发生在 HTTP 之后;
|
|
77
|
+
- `ctx.web` 只服务 `web_search` / `web_fetch`,与 provider 流量无关;
|
|
78
|
+
- 官方的 `@deepseek-ai/dsh-http-proxy` 是**库**不是插件:`installGlobalProxy` 是模块私有函数,没有 service、没有 Config、只有从环境变量解析出的一份进程级策略。
|
|
79
|
+
|
|
80
|
+
所以请求在唯一还能拿到线索的地方被路由:`llm/stream` waterfall 的 `options.provider`。用一个 `AsyncLocalStorage` 把选中的 dispatcher 带到流上,再由一层很薄的 `globalThis.fetch` 包装把它交给 fetch。
|
|
81
|
+
|
|
82
|
+
两个关键点:
|
|
83
|
+
|
|
84
|
+
1. **每个 provider 各自持有一个 dispatcher**(而不是共用一个),所以即使两个 provider 指向同一个上游主机,也不可能互相串用对方的代理。
|
|
85
|
+
2. **流的惰性消费必须被处理**:adapter 的 HTTP 调用通常发生在后续的 `next()` 里,那时 `als.run()` 早已返回。所以每次迭代器的 `next/return/throw` 都会被重新包进作用域里 —— 这正是旧版实现在这里踩过的坑,仓库里有对应的回归测试。
|
|
86
|
+
|
|
87
|
+
代价是这一个全局 `fetch` 包装:作用域之外的行为与原生完全一致,作用域之内若调用方已经自带 `dispatcher` 也不会被覆盖。
|
|
88
|
+
|
|
89
|
+
## 已知限制
|
|
90
|
+
|
|
91
|
+
- **SOCKS5 由 `undici` 内置的 `Socks5ProxyAgent` 实现**(RFC 1928 / RFC 1929),API 稳定性标记为 *Experimental*;`undici` 若在未来移除,需要同步调整。
|
|
92
|
+
- **代理认证字段的成对规则。** SOCKS5 下填了用户名就必须填密码(`Socks5ProxyAgent` 只在两者齐备时才发起认证;只填用户名会被拒绝并给出提示,而不是静默降级为无认证);HTTP/HTTPS 下填了密码就必须填用户名(基本认证无法只发密码)。不合规则的配置会被拒绝并在「连接诊断」里说明,而不是被静默忽略——否则在强制认证的代理端,报错会指向代理而真正原因是设置缺项。HTTP/HTTPS 下只填用户名仍照常工作。
|
|
93
|
+
- **SOCKS5 不支持 `socks://` 别名与 SOCKS5 over TLS**(`proxyTls`)。
|
|
94
|
+
- **`bypassHosts` 命中时由本机解析并连接目标**(DNS 不经过代理)。SOCKS5 下这一点后果最明显:命中的域名不再由代理端解析,本机 DNS 会暴露该次查询。
|
|
95
|
+
- **`web_search` 可以走代理,`web_fetch` 不行。** 搜索 provider(`dsh-web-search-deepseek`)用全局 `fetch`,插件通过包裹 `ctx.web.search` 把它送进代理作用域;这条成立的前提是:工具每次调用都重新取 `ctx.web.search`,且 provider 的 `search()` 是 await-to-completion(不是惰性消费的流)。`web_fetch` 则用 `undici` 自带的 `dispatcher`(来自官方的进程级 env 策略),没有 per-provider 注入点,本插件覆盖不到——需要覆盖它请改用 `HTTP_PROXY` 之类的环境变量。
|
|
96
|
+
- **保留 id `web-search`。** 它的路由存在 `providers` 字典里;真实 provider 若恰好撞名,会与该路由共用同一份配置,无害。
|
|
97
|
+
- **上游若绕过 `globalThis.fetch` 自行建连,则不受影响。** `llm-deepseek` 确认是调用时解析全局 `fetch`;`llm-pi-ai` 走外部 SDK,抓取 `fetch` 的时机未能在源码层面确证。
|
|
98
|
+
- **不支持 PAC**,也不读取系统代理设置。
|
|
99
|
+
- **不改写 `baseURL`** —— 反向代理/中转请直接在 provider 上配置。
|
|
100
|
+
- **代理不可达时**请求照常失败,DSH 既有的重试与错误码语义不变。
|
|
101
|
+
- **连接自检依赖 `testUrl` 可达。** 默认的回显主机在部分网络下可能不可达,会让自检误报失败;请通过 `testUrl` 换成自建回显服务。自检只接受 `POST`,同一 provider 的多次点击会共享同一次在飞请求,不会被当成外呼放大器。
|
|
102
|
+
- **代理密码以明文随设置写入 `cordis.patch.yml`。** HTTP/HTTPS 的密码会拼进交给 `undici` 的代理 URL(`ProxyAgent` 由此生成 `Proxy-Authorization`),SOCKS5 的密码只作为选项传给 `Socks5ProxyAgent`;两者都不会出现在日志或诊断里的代理 URL——那里只显示不含凭据的 origin。
|
|
103
|
+
|
|
104
|
+
## 如何验证代理确实生效
|
|
105
|
+
|
|
106
|
+
插件本身不显示出口 IP。可靠的判断方式是在代理端看日志,或临时把端口改成
|
|
107
|
+
一个没人监听的端口——如果请求失败并报 `ECONNREFUSED 127.0.0.1:<该端口>`,
|
|
108
|
+
就说明请求确实是被代理承载的(直连时报错会指向目标站点)。
|
|
109
|
+
|
|
110
|
+
仓库里的 `tests/proxy-routing-proof.test.ts` 就是用这个思路做的:起一个会记录
|
|
111
|
+
连接的假代理,断言「启用的路由收到 1 次连接、未配置的 provider 收到 0 次」。
|
|
112
|
+
|
|
113
|
+
## 开发
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
npm run build # 构建 lib/index.js 与 lib/client.js
|
|
117
|
+
npm test # 82 个用例,跑的是构建产物
|
|
118
|
+
npm run typecheck # 类型检查(仅覆盖 src/)
|
|
119
|
+
npm run verify # 上面三件事,按顺序
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`lib/` 是构建产物,不入库;包上的 `prepare` 脚本会在 `npm publish` 以及从 git
|
|
123
|
+
地址安装时自动重建。直接 `dsh plugin add <克隆目录>` 前请先执行
|
|
124
|
+
`npm install && npm run build`。
|
|
125
|
+
|
|
126
|
+
浏览器半必须输出 client module system 的 lazy-CJS factory 格式(脚本本身只注册工厂,所有副作用都在工厂闭包里执行),`scripts/build.mjs` 负责这件事,格式与 DSH 自带插件一致。
|
|
127
|
+
|
|
128
|
+
## 发布
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
npm run verify # 先确认全绿
|
|
132
|
+
npm version patch # 0.2.0 -> 0.2.1
|
|
133
|
+
npm publish
|
|
134
|
+
git push --follow-tags
|
|
135
|
+
```
|
package/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Loader compatibility shim.
|
|
2
|
-
//
|
|
3
|
-
// cordis-plugin-loader resolves an entry as "<package>/index.js" by convention
|
|
4
|
-
// and does NOT read package.json `main`/`exports`, so a package installed into
|
|
5
|
-
// a profile must expose this file. The implementation lives in ./lib/index.js.
|
|
1
|
+
// Loader compatibility shim.
|
|
2
|
+
//
|
|
3
|
+
// cordis-plugin-loader resolves an entry as "<package>/index.js" by convention
|
|
4
|
+
// and does NOT read package.json `main`/`exports`, so a package installed into
|
|
5
|
+
// a profile must expose this file. The implementation lives in ./lib/index.js.
|
|
6
6
|
export * from './lib/index.js'
|
package/lib/client.js
CHANGED
|
@@ -159,18 +159,45 @@ function knownProviders(configured, published) {
|
|
|
159
159
|
return [...rows.values()].sort(byName);
|
|
160
160
|
}
|
|
161
161
|
var CATALOG_PATH = "/dsh-provider-proxy/providers";
|
|
162
|
+
var TEST_PATH = "/dsh-provider-proxy/test";
|
|
163
|
+
var LATENCY_FAST_MS = 500;
|
|
164
|
+
var LATENCY_SLOW_MS = 1500;
|
|
165
|
+
function latencyColor(ms) {
|
|
166
|
+
if (typeof ms !== "number" || !Number.isFinite(ms)) return "var(--dsw-alias-label-secondary)";
|
|
167
|
+
if (ms <= LATENCY_FAST_MS) return "var(--dsw-alias-state-success-primary, #2ea043)";
|
|
168
|
+
if (ms <= LATENCY_SLOW_MS) return "var(--dsw-alias-state-warn-primary, #d29922)";
|
|
169
|
+
return "var(--dsw-alias-state-error-primary, #f85149)";
|
|
170
|
+
}
|
|
171
|
+
function testResultFrom(body, strings) {
|
|
172
|
+
const ok = body?.ok === true;
|
|
173
|
+
return {
|
|
174
|
+
pending: false,
|
|
175
|
+
ok,
|
|
176
|
+
ms: typeof body?.ms === "number" && Number.isFinite(body.ms) ? body.ms : void 0,
|
|
177
|
+
ip: typeof body?.exitIp === "string" && body.exitIp.length > 0 ? body.exitIp : void 0,
|
|
178
|
+
error: ok ? void 0 : typeof body?.error === "string" && body.error.length > 0 ? body.error : strings.testFailed
|
|
179
|
+
};
|
|
180
|
+
}
|
|
162
181
|
var catalogueSetter = () => {
|
|
163
182
|
};
|
|
164
|
-
async function
|
|
183
|
+
async function fetchCatalog() {
|
|
165
184
|
const response = await globalThis.fetch(CATALOG_PATH, { headers: { accept: "application/json" } });
|
|
166
185
|
if (!response.ok) throw new Error(`Host answered ${response.status}`);
|
|
167
186
|
const body = await response.json();
|
|
168
187
|
const list = Array.isArray(body?.providers) ? body.providers : [];
|
|
169
|
-
|
|
188
|
+
const rows = list.filter((row) => typeof row?.id === "string" && row.id.length > 0).map((row) => ({
|
|
170
189
|
id: row.id,
|
|
171
190
|
name: typeof row.name === "string" && row.name.length > 0 ? row.name : row.id,
|
|
172
191
|
live: row.live === true
|
|
173
192
|
}));
|
|
193
|
+
const hits = {};
|
|
194
|
+
const published = body?.hits;
|
|
195
|
+
if (published !== null && typeof published === "object") {
|
|
196
|
+
for (const [id, count] of Object.entries(published)) {
|
|
197
|
+
if (typeof count === "number" && Number.isFinite(count)) hits[id] = count;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { rows, hits };
|
|
174
201
|
}
|
|
175
202
|
var byName = (a, b) => a.name.localeCompare(b.name);
|
|
176
203
|
function view(route) {
|
|
@@ -180,7 +207,9 @@ function view(route) {
|
|
|
180
207
|
host: route?.host ?? "",
|
|
181
208
|
port: route?.port ?? 0,
|
|
182
209
|
username: route?.username ?? "",
|
|
183
|
-
|
|
210
|
+
password: route?.password ?? "",
|
|
211
|
+
onlyHosts: route?.onlyHosts ?? "",
|
|
212
|
+
bypassHosts: route?.bypassHosts ?? ""
|
|
184
213
|
};
|
|
185
214
|
}
|
|
186
215
|
var diagnostics = [];
|
|
@@ -232,6 +261,42 @@ var S = {
|
|
|
232
261
|
fontSize: "11px",
|
|
233
262
|
color: "var(--dsw-alias-label-secondary)"
|
|
234
263
|
},
|
|
264
|
+
hits: {
|
|
265
|
+
flex: "none",
|
|
266
|
+
fontSize: "11px",
|
|
267
|
+
color: "var(--dsw-alias-label-secondary)",
|
|
268
|
+
whiteSpace: "nowrap"
|
|
269
|
+
},
|
|
270
|
+
testRow: {
|
|
271
|
+
display: "flex",
|
|
272
|
+
alignItems: "center",
|
|
273
|
+
gap: "10px",
|
|
274
|
+
flexWrap: "wrap"
|
|
275
|
+
},
|
|
276
|
+
testButton: {
|
|
277
|
+
padding: "5px 12px",
|
|
278
|
+
border: "1px solid var(--dsw-alias-border-l1)",
|
|
279
|
+
borderRadius: "6px",
|
|
280
|
+
background: "var(--dsw-alias-bg-layer-1)",
|
|
281
|
+
color: "var(--dsw-alias-label-primary)",
|
|
282
|
+
font: "inherit",
|
|
283
|
+
fontSize: "12px",
|
|
284
|
+
cursor: "pointer"
|
|
285
|
+
},
|
|
286
|
+
testResult: {
|
|
287
|
+
fontSize: "11px",
|
|
288
|
+
color: "var(--dsw-alias-label-secondary)",
|
|
289
|
+
wordBreak: "break-all"
|
|
290
|
+
},
|
|
291
|
+
advanced: {
|
|
292
|
+
marginTop: "4px",
|
|
293
|
+
fontSize: "12px"
|
|
294
|
+
},
|
|
295
|
+
advancedSummary: {
|
|
296
|
+
cursor: "pointer",
|
|
297
|
+
fontSize: "11px",
|
|
298
|
+
color: "var(--dsw-alias-label-secondary)"
|
|
299
|
+
},
|
|
235
300
|
pill: {
|
|
236
301
|
flex: "none",
|
|
237
302
|
padding: "2px 10px",
|
|
@@ -296,20 +361,46 @@ function renderPage(ctx) {
|
|
|
296
361
|
);
|
|
297
362
|
const [state, setState] = (React.useState ?? ((fn) => [fn(), () => {
|
|
298
363
|
}]))(() => {
|
|
299
|
-
void
|
|
364
|
+
void fetchCatalog().then((catalog) => catalogueSetter?.(catalog)).catch((error) => {
|
|
300
365
|
note(`provider \u5217\u8868\u83B7\u53D6\u5931\u8D25\uFF1A${error?.message ?? error}`);
|
|
301
|
-
catalogueSetter?.([]);
|
|
366
|
+
catalogueSetter?.({ rows: [], hits: {} });
|
|
302
367
|
});
|
|
303
|
-
return { rows: void 0, pending: true };
|
|
368
|
+
return { rows: void 0, hits: {}, pending: true };
|
|
304
369
|
});
|
|
305
|
-
catalogueSetter = (
|
|
370
|
+
catalogueSetter = (catalog) => setState({ rows: catalog.rows, hits: catalog.hits, pending: false });
|
|
306
371
|
const published = state.rows ?? [];
|
|
372
|
+
const hits = state.hits ?? {};
|
|
307
373
|
note(
|
|
308
374
|
`provider \u5217\u8868\uFF1A\u6765\u81EA Host \u5171 ${published.length} \u4E2A\uFF08${published.map((r) => r.id).join("\u3001") || "\u65E0"}\uFF09`
|
|
309
375
|
);
|
|
310
376
|
const [draft, setDraft] = (React.useState ?? ((v) => [v, () => {
|
|
311
377
|
}]))({});
|
|
312
378
|
const edit = (id, field2, value) => setDraft((current) => ({ ...current, [id]: { ...current[id], [field2]: value } }));
|
|
379
|
+
const [tests, setTests] = (React.useState ?? ((v) => [v, () => {
|
|
380
|
+
}]))({});
|
|
381
|
+
const runTest = (id) => {
|
|
382
|
+
setTests((current) => ({ ...current, [id]: { pending: true } }));
|
|
383
|
+
void (async () => {
|
|
384
|
+
try {
|
|
385
|
+
const response = await globalThis.fetch(TEST_PATH, {
|
|
386
|
+
method: "POST",
|
|
387
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
388
|
+
body: JSON.stringify({ provider: id })
|
|
389
|
+
});
|
|
390
|
+
const body2 = await response.json();
|
|
391
|
+
setTests((current) => ({ ...current, [id]: testResultFrom(body2, t) }));
|
|
392
|
+
if (body2?.ok === true) {
|
|
393
|
+
void fetchCatalog().then((catalog) => catalogueSetter?.(catalog)).catch(() => {
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
} catch (error) {
|
|
397
|
+
setTests((current) => ({
|
|
398
|
+
...current,
|
|
399
|
+
[id]: { pending: false, ok: false, error: String(error?.message ?? error) }
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
})();
|
|
403
|
+
};
|
|
313
404
|
const rows = knownProviders({ ...configured, ...draft }, published);
|
|
314
405
|
const save = (id, field2, value) => {
|
|
315
406
|
void writeConfig(ctx, [{ op: "set", path: ["providers", id, field2], value }]).then(() => {
|
|
@@ -359,6 +450,7 @@ function renderPage(ctx) {
|
|
|
359
450
|
] : rows.map((row) => {
|
|
360
451
|
const route = view({ ...configured[row.id], ...draft[row.id] });
|
|
361
452
|
const disabled = !route.enabled;
|
|
453
|
+
const result = tests[row.id];
|
|
362
454
|
return h(
|
|
363
455
|
"section",
|
|
364
456
|
{
|
|
@@ -373,7 +465,14 @@ function renderPage(ctx) {
|
|
|
373
465
|
"div",
|
|
374
466
|
{ className: import_client.default.cardTitles, style: S.titles },
|
|
375
467
|
h("span", { className: import_client.default.name, style: S.name }, row.name),
|
|
376
|
-
h("span", { className: import_client.default.id, style: S.id }, row.id)
|
|
468
|
+
h("span", { className: import_client.default.id, style: S.id }, row.id),
|
|
469
|
+
// A disabled route can never have hits, so "0" there would only add
|
|
470
|
+
// noise; the count shows once the route is on (or has counted).
|
|
471
|
+
route.enabled || (hits[row.id] ?? 0) > 0 ? h(
|
|
472
|
+
"span",
|
|
473
|
+
{ className: import_client.default.hits, style: S.hits },
|
|
474
|
+
`${t.hitCountPrefix}${hits[row.id] ?? 0}${t.hitCountSuffix}`
|
|
475
|
+
) : null
|
|
377
476
|
),
|
|
378
477
|
h(
|
|
379
478
|
"span",
|
|
@@ -412,7 +511,7 @@ function renderPage(ctx) {
|
|
|
412
511
|
(protocol) => h("option", { key: protocol, value: protocol }, protocol.toUpperCase())
|
|
413
512
|
)
|
|
414
513
|
),
|
|
415
|
-
route.protocol === "socks5" ? t.
|
|
514
|
+
route.protocol === "socks5" ? t.socks5Hint : void 0
|
|
416
515
|
),
|
|
417
516
|
field(
|
|
418
517
|
t.host,
|
|
@@ -453,6 +552,85 @@ function renderPage(ctx) {
|
|
|
453
552
|
value: route.username,
|
|
454
553
|
onChange: (event) => bind(row.id, "username")(event.target.value)
|
|
455
554
|
})
|
|
555
|
+
),
|
|
556
|
+
// Every transport accepts a password now: SOCKS5 hands it to
|
|
557
|
+
// Socks5ProxyAgent as an option, HTTP/HTTPS embed it in the proxy URL
|
|
558
|
+
// userinfo (undici turns that into Proxy-Authorization). It is stored on
|
|
559
|
+
// the route, so it lands in the settings file with the rest of the
|
|
560
|
+
// config; the diagnostics only ever surface a credential-free origin.
|
|
561
|
+
field(
|
|
562
|
+
t.password,
|
|
563
|
+
h("input", {
|
|
564
|
+
className: import_client.default.input,
|
|
565
|
+
style: S.input,
|
|
566
|
+
disabled,
|
|
567
|
+
type: "password",
|
|
568
|
+
autocomplete: "off",
|
|
569
|
+
value: route.password,
|
|
570
|
+
onChange: (event) => bind(row.id, "password")(event.target.value)
|
|
571
|
+
}),
|
|
572
|
+
route.protocol === "socks5" ? t.passwordHint : t.passwordHintProxy
|
|
573
|
+
),
|
|
574
|
+
// The self-check only makes sense for a route that is ON; a disabled
|
|
575
|
+
// route is not in the Host's table, so there is nothing to test.
|
|
576
|
+
route.enabled ? h(
|
|
577
|
+
"div",
|
|
578
|
+
{ className: import_client.default.testRow, style: S.testRow, key: `${row.id}:test` },
|
|
579
|
+
h(
|
|
580
|
+
"button",
|
|
581
|
+
{
|
|
582
|
+
className: import_client.default.testButton,
|
|
583
|
+
style: S.testButton,
|
|
584
|
+
type: "button",
|
|
585
|
+
disabled: result?.pending === true,
|
|
586
|
+
onClick: () => runTest(row.id)
|
|
587
|
+
},
|
|
588
|
+
result?.pending === true ? t.testRunning : t.testButton
|
|
589
|
+
),
|
|
590
|
+
result?.pending === false ? h(
|
|
591
|
+
"span",
|
|
592
|
+
{ className: import_client.default.testResult, style: S.testResult },
|
|
593
|
+
result.ok === true ? [
|
|
594
|
+
// Only the latency carries the band colour; the exit IP
|
|
595
|
+
// stays in the secondary label colour.
|
|
596
|
+
h(
|
|
597
|
+
"span",
|
|
598
|
+
{ key: "lat", style: { color: latencyColor(result.ms) } },
|
|
599
|
+
`\u2713 ${typeof result.ms === "number" ? result.ms : "\u2014"} ms`
|
|
600
|
+
),
|
|
601
|
+
result.ip === void 0 ? null : ` \xB7 ${t.exitIpLabel}${result.ip}`
|
|
602
|
+
] : `\u2717 ${result.error}`
|
|
603
|
+
) : null
|
|
604
|
+
) : null,
|
|
605
|
+
// Target-host rules, collapsed so the common case stays one short card.
|
|
606
|
+
h(
|
|
607
|
+
"details",
|
|
608
|
+
{ className: import_client.default.advanced, style: S.advanced, key: `${row.id}:rules` },
|
|
609
|
+
h("summary", { className: import_client.default.advancedSummary, style: S.advancedSummary }, t.advanced),
|
|
610
|
+
field(
|
|
611
|
+
t.onlyHosts,
|
|
612
|
+
h("input", {
|
|
613
|
+
className: import_client.default.input,
|
|
614
|
+
style: S.input,
|
|
615
|
+
disabled,
|
|
616
|
+
placeholder: "api.example.com",
|
|
617
|
+
value: route.onlyHosts,
|
|
618
|
+
onChange: (event) => bind(row.id, "onlyHosts")(event.target.value)
|
|
619
|
+
}),
|
|
620
|
+
t.onlyHostsHint
|
|
621
|
+
),
|
|
622
|
+
field(
|
|
623
|
+
t.bypassHosts,
|
|
624
|
+
h("input", {
|
|
625
|
+
className: import_client.default.input,
|
|
626
|
+
style: S.input,
|
|
627
|
+
disabled,
|
|
628
|
+
placeholder: "localhost",
|
|
629
|
+
value: route.bypassHosts,
|
|
630
|
+
onChange: (event) => bind(row.id, "bypassHosts")(event.target.value)
|
|
631
|
+
}),
|
|
632
|
+
t.bypassHostsHint
|
|
633
|
+
)
|
|
456
634
|
)
|
|
457
635
|
);
|
|
458
636
|
});
|
|
@@ -492,8 +670,22 @@ var EN = {
|
|
|
492
670
|
host: "IP / host",
|
|
493
671
|
port: "Port",
|
|
494
672
|
username: "Username",
|
|
673
|
+
password: "Password",
|
|
495
674
|
hostHint: "Host or IP only, without scheme or path.",
|
|
496
|
-
|
|
675
|
+
passwordHint: "Sent to the SOCKS5 proxy for authentication; stored with this setting.",
|
|
676
|
+
passwordHintProxy: "Sent to the proxy as an authentication password; stored with this setting.",
|
|
677
|
+
hitCountPrefix: "",
|
|
678
|
+
hitCountSuffix: " proxied",
|
|
679
|
+
testButton: "Test",
|
|
680
|
+
testRunning: "Testing\u2026",
|
|
681
|
+
testFailed: "Test failed",
|
|
682
|
+
exitIpLabel: "exit IP ",
|
|
683
|
+
advanced: "Advanced: target host rules",
|
|
684
|
+
onlyHosts: "Proxy only",
|
|
685
|
+
onlyHostsHint: "Comma/space separated; when set, only requests to these hosts are proxied (suffix match, * for all).",
|
|
686
|
+
bypassHosts: "Bypass",
|
|
687
|
+
bypassHostsHint: "Comma/space separated; requests to these hosts go direct, taking priority.",
|
|
688
|
+
socks5Hint: "SOCKS5 tunnels the connection; the proxy resolves the target host name.",
|
|
497
689
|
on: "proxied",
|
|
498
690
|
live: "active",
|
|
499
691
|
off: "direct",
|
|
@@ -508,8 +700,22 @@ var ZH = {
|
|
|
508
700
|
host: "IP / \u4E3B\u673A",
|
|
509
701
|
port: "\u7AEF\u53E3",
|
|
510
702
|
username: "\u7528\u6237\u540D",
|
|
703
|
+
password: "\u5BC6\u7801",
|
|
511
704
|
hostHint: "\u53EA\u586B\u4E3B\u673A\u540D\u6216 IP\uFF0C\u4E0D\u8981\u5E26\u534F\u8BAE\u524D\u7F00\u6216\u8DEF\u5F84\u3002",
|
|
512
|
-
|
|
705
|
+
passwordHint: "\u4EC5\u4F5C\u4E3A SOCKS5 \u8BA4\u8BC1\u53E3\u4EE4\u53D1\u9001\u7ED9\u4EE3\u7406\uFF1B\u968F\u8BE5\u8BBE\u7F6E\u4E00\u8D77\u4FDD\u5B58\u3002",
|
|
706
|
+
passwordHintProxy: "\u4F5C\u4E3A\u4EE3\u7406\u8BA4\u8BC1\u53E3\u4EE4\u53D1\u9001\u7ED9\u4EE3\u7406\uFF1B\u968F\u8BE5\u8BBE\u7F6E\u4E00\u8D77\u4FDD\u5B58\u3002",
|
|
707
|
+
hitCountPrefix: "\u5DF2\u4EE3\u7406 ",
|
|
708
|
+
hitCountSuffix: " \u6B21",
|
|
709
|
+
testButton: "\u6D4B\u8BD5",
|
|
710
|
+
testRunning: "\u6D4B\u8BD5\u4E2D\u2026",
|
|
711
|
+
testFailed: "\u6D4B\u8BD5\u5931\u8D25",
|
|
712
|
+
exitIpLabel: "\u51FA\u53E3 IP ",
|
|
713
|
+
advanced: "\u9AD8\u7EA7\uFF1A\u76EE\u6807\u57DF\u540D\u89C4\u5219",
|
|
714
|
+
onlyHosts: "\u4EC5\u8D70\u4EE3\u7406",
|
|
715
|
+
onlyHostsHint: "\u9017\u53F7\u6216\u7A7A\u683C\u5206\u9694\uFF1B\u975E\u7A7A\u65F6\u53EA\u6709\u5339\u914D\u8FD9\u4E9B\u4E3B\u673A\u7684\u8BF7\u6C42\u8D70\u4EE3\u7406\uFF08\u540E\u7F00\u5339\u914D\uFF0C* \u8868\u793A\u5168\u90E8\uFF09\u3002",
|
|
716
|
+
bypassHosts: "\u76F4\u8FDE",
|
|
717
|
+
bypassHostsHint: "\u9017\u53F7\u6216\u7A7A\u683C\u5206\u9694\uFF1B\u5339\u914D\u8FD9\u4E9B\u4E3B\u673A\u7684\u8BF7\u6C42\u76F4\u8FDE\uFF0C\u4F18\u5148\u4E8E\u4E0A\u4E00\u9879\u3002",
|
|
718
|
+
socks5Hint: "SOCKS5 \u4F1A\u5EFA\u7ACB\u96A7\u9053\uFF0C\u76EE\u6807\u57DF\u540D\u7531\u4EE3\u7406\u7AEF\u89E3\u6790\u3002",
|
|
513
719
|
on: "\u8D70\u4EE3\u7406",
|
|
514
720
|
live: "\u542F\u7528\u4E2D",
|
|
515
721
|
off: "\u76F4\u8FDE",
|