@jedylicong/dsh-xinlab 0.0.0-stage → 0.2.1
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 +39 -0
- package/LICENSE +21 -0
- package/README.md +100 -2
- package/bin/dsh-providers.mjs +28 -0
- package/client.js +220 -0
- package/cordis.patch.yml +310 -0
- package/data/sources.json +7 -0
- package/data/vllm.json +63 -0
- package/data/xin-lab.json +284 -0
- package/data/zai.json +47 -0
- package/package.json +73 -4
- package/src/catalog.mjs +50 -0
- package/src/client-page.mjs +191 -0
- package/src/inspect.mjs +60 -0
- package/src/key-preview-protocol.mjs +28 -0
- package/src/key-preview.mjs +37 -0
- package/src/model-settings.mjs +37 -0
- package/src/official-adapter.mjs +52 -0
- package/src/runtime.mjs +99 -0
- package/src/state.mjs +40 -0
- package/src/vllm-mm-uuid.mjs +14 -0
- package/vendor/vllm-mm-uuid.mjs +239 -0
- package/vendor/vllm-reasoning-only-retry.mjs +41 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# 更新说明
|
|
2
|
+
|
|
3
|
+
此文件记录插件版本;npm 发布状态以 npm 注册表为准。
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.2.1] - 2026-11-08
|
|
8
|
+
|
|
9
|
+
### 首次公开发布
|
|
10
|
+
|
|
11
|
+
- 首个 npm 公开版本为 `@jedylicong/dsh-xinlab`,采用 MIT 许可证。
|
|
12
|
+
- 兼容 DSH 0.2.2-rc.1、Node.js 22 及以上;模型独立密钥与三种协议配置保持不变。
|
|
13
|
+
|
|
14
|
+
### 修复
|
|
15
|
+
|
|
16
|
+
- 发布标签原样保留 Markdown 标题,使标签附注与安装包更新说明一致,并加入带标题的回归验证。
|
|
17
|
+
|
|
18
|
+
## [0.2.0] - 2026-11-08
|
|
19
|
+
|
|
20
|
+
此版本为本地候选包,未发布到 npm。
|
|
21
|
+
|
|
22
|
+
### 新增
|
|
23
|
+
|
|
24
|
+
- 以 `@jedylicong/dsh-xinlab` 公开分发,采用 MIT 许可证。
|
|
25
|
+
- 在 DSH 设置中提供模型配置页,每个托管模型可分别保存 API Key、选择调用协议。
|
|
26
|
+
- 支持 OpenAI Chat Completions、OpenAI Responses、Anthropic Messages,并保留供应商和模型标识。
|
|
27
|
+
- 独立密钥通过 DSH 凭据服务保存,页面显示前 7 位脱敏提示,短密钥全部隐藏。
|
|
28
|
+
- 提供版本、Git 标签、更新说明、安装包校验与受保护的 npm 发布流程。
|
|
29
|
+
|
|
30
|
+
### 兼容性与升级
|
|
31
|
+
|
|
32
|
+
- 目标环境为 DSH 0.2.2-rc.1、Node.js 22 及以上。
|
|
33
|
+
- 从 0.1.x 升级时,按 README 迁移供应商覆盖;保留既有凭据、会话及 UUID 状态。
|
|
34
|
+
- 从 npm 按包名安装后,可使用 DSH 插件管理器的版本检查和确认更新功能;本地压缩包安装不提供该功能。
|
|
35
|
+
|
|
36
|
+
## [0.1.0]
|
|
37
|
+
|
|
38
|
+
- 提供 Z.ai、XinLab、vLLM 供应商快照和两个 vLLM 扩展。
|
|
39
|
+
- 提供只读迁移检查和本地安装包;该版本仅作本地预览。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jedylicong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,101 @@
|
|
|
1
|
-
#
|
|
1
|
+
# XinLab DSH 供应商配置包
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@jedylicong/dsh-xinlab` 是 DSH 原生可安装组合包,当前版本 `0.2.1`,验证目标为 DSH `0.2.2-rc.1`、Node.js 22 及以上。
|
|
4
|
+
|
|
5
|
+
采用 [MIT 许可证](LICENSE),版权署名为 `jedylicong`。
|
|
6
|
+
|
|
7
|
+
包含 `zai`、`xin-lab`、`vllm` 的供应商定义,以及 vLLM 图片 UUID、空回复分类扩展。模型调用继续使用官方 `@deepseek-ai/dsh-llm-pi-ai`。包内没有 API 密钥、节点私钥、会话数据或安装后自动执行脚本。
|
|
8
|
+
|
|
9
|
+
公开发布后,在 DSH「插件 → 添加插件」输入 `@jedylicong/dsh-xinlab` 安装。用户使用自己的供应商 Key;vLLM 默认地址属于实验室内网,需要相应网络访问权限。
|
|
10
|
+
|
|
11
|
+
## 模型配置页面
|
|
12
|
+
|
|
13
|
+
安装后,DSH 设置中出现 **XinLab 模型配置**。每个托管模型只提供两项配置,分别保存:
|
|
14
|
+
|
|
15
|
+
- **API Key**:输入并保存此模型的独立密钥,保存后显示前 7 位与遮罩,例如 `sk-abcd••••••••`。留空不修改已保存值;清除独立密钥后恢复使用供应商凭据。
|
|
16
|
+
- **调用协议**:沿用预设,或选择 OpenAI Chat Completions、OpenAI Responses、Anthropic Messages。上游接口必须支持所选协议。
|
|
17
|
+
|
|
18
|
+
页面不提供地址、模型目录、容量、默认模型或 Agent 预设的编辑入口。原供应商和模型标识保持不变;协议修改用于下次调用,已经准备好的调用保留原协议。
|
|
19
|
+
|
|
20
|
+
独立密钥只通过 DSH `credentials.set` / `unset` 保存和删除,以供应商和模型完整标识派生稳定引用。官方本地凭据提供方把值保存在当前节点 `<DSH_HOME>/.credentials.yaml`,文件为 `0600`,新建目录为 `0700`。这是受文件权限保护的本地凭据存储,**不是加密保险库**;插件不另建密钥文件、不跨节点同步密钥。
|
|
21
|
+
|
|
22
|
+
前端通过 DSH 已认证 Remote 通道读取状态和脱敏提示,只有节点所有者可读取,且仅限此插件的模型密钥引用。后端即时生成前 7 位提示;长度不超过 14 的短密钥全部隐藏。刷新页面后仍可查看,无需保存额外提示文件。
|
|
23
|
+
|
|
24
|
+
密码框永不回填已保存值;新输入只短暂留在框内,提交时清空。协议配置、供应商快照、安装包、浏览器持久存储和状态响应不包含完整用户密钥,错误提示不展示 RPC 原始错误。
|
|
25
|
+
|
|
26
|
+
## 分发范围
|
|
27
|
+
|
|
28
|
+
公开分发只包含插件代码、供应商预设和更新说明。用户在各自的 DSH 节点提供和保存自己的供应商 Key;插件不提供可共享的 API 密钥或 vLLM 内网访问权限。测试只使用临时 DSH home 和模拟接口。
|
|
29
|
+
|
|
30
|
+
## 源码与构建
|
|
31
|
+
|
|
32
|
+
供应商快照和 vLLM 扩展从管理仓库既有托管来源生成;具体相对路径与 SHA-256 见 `data/sources.json`。构建过程只读取这些明确来源,不读取任何节点的 `.dsh` 或凭据。现有 Skill 文件保持不变。
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install --ignore-scripts
|
|
36
|
+
npm run build
|
|
37
|
+
npm run check
|
|
38
|
+
npm test
|
|
39
|
+
mkdir -p dist
|
|
40
|
+
npm pack --pack-destination dist
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
更新快照的 `build` 和来源核对 `check` 需要与 `skills/` 同处管理仓库。源码已包含生成快照,独立检出后也能直接打包;`prepack` 核对版本、更新说明与生成产物。生成的 `.tgz` 包含运行所需快照和扩展,安装使用时不依赖管理仓库。正式发布通过版本发布流程完成,直接从源码目录执行 `npm publish` 会被阻止。
|
|
44
|
+
|
|
45
|
+
## 节点参数与凭据
|
|
46
|
+
|
|
47
|
+
| 参数 | 默认值 | 用途 |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `DSH_XINLAB_BASE_URL` | `https://api.xinlab-ioz.cn/v1` | 管理机后续可指定 `http://127.0.0.1:3000/v1` |
|
|
50
|
+
| `DSH_VLLM_BASE_URL` | `http://10.0.0.1/v1` | vLLM 地址;UUID 扩展同步限制到其准确 Chat Completions URL |
|
|
51
|
+
| `DSH_ZAI_BASE_URL` | `https://api.z.ai/api/coding/paas/v4` | Z.ai 地址 |
|
|
52
|
+
|
|
53
|
+
设置参数后须由 DSH 重新加载配置或重启进程才能采用新的启动环境。节点不需要复制整份模型表。密钥继续通过 DSH 凭据服务维护,名称分别为 `ZAI_API_KEY`、`VLLM_API_KEY`、`XIN_LAB_API_KEY`。
|
|
54
|
+
|
|
55
|
+
未设置模型独立密钥时沿用上述供应商凭据。切换为 Anthropic Messages 时,插件移除地址末尾的 `/v1`,由 SDK 添加 `/v1/messages`;其他路径前缀保持不变。协议切换不沿用不兼容的旧协议专属开关。
|
|
56
|
+
|
|
57
|
+
UUID 扩展只在运行时初始化 `<DSH_HOME>/plugins/vllm-mm-uuid/key` 与 `warm-uuids`。首次生成独立的 32 字节 key;已有状态保留。目录权限 `0700`,文件权限 `0600`。该扩展要求 vLLM 服务支持 nullable image UUID。
|
|
58
|
+
|
|
59
|
+
## 安装与迁移
|
|
60
|
+
|
|
61
|
+
公开发布后,在 DSH 插件管理器中按 npm 包名 `@jedylicong/dsh-xinlab` 安装;本地目录或 `.tgz` 可用于隔离验证。已有 profile 先检查并审阅迁移,不能直接覆盖用户定义。
|
|
62
|
+
|
|
63
|
+
先运行只读检查,不安装、不修改节点:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
node bin/dsh-providers.mjs inspect --home /目标用户目录/.dsh --profile web
|
|
67
|
+
node bin/dsh-providers.mjs catalog
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
检查只读取 profile 清单是否存在以及 profile/home 的 patch,既不读取凭据文件,也不执行 `!!js`。有冲突时退出码为 `2`,解析或参数错误为 `1`,检查通过为 `0`。
|
|
71
|
+
|
|
72
|
+
后续迁移须先备份并审阅:
|
|
73
|
+
|
|
74
|
+
1. 处理 profile/home 中 `llm-pi-ai` 的旧配置覆盖。保留其他自定义供应商,将完整定义并入部署侧 `xinlab-providers.providers`,或迁入标识不冲突的独立适配器;不能直接删除它们。
|
|
75
|
+
2. 移除旧 `vllm-mm-uuid`、`vllm-reasoning-only-retry` 的加载项,保留 key 和 warm-state 文件,避免重复拦截。
|
|
76
|
+
3. 将组合包放在基础组合包之后。组合包禁用旧 `llm-pi-ai` 并插入 `xinlab-providers`,避免重复注册;从 `0.1.x` 升级时将需要保留的供应商覆盖迁至新条目。
|
|
77
|
+
4. 核对实际模型列表、推理等级、上下文长度及一次真实模型请求;保留原有 Agent 预设和会话。
|
|
78
|
+
|
|
79
|
+
本包只接管供应商与两个 vLLM 扩展,不修改默认模型、Agent 预设、压缩策略、TLS、WireGuard 或 systemd。现有压缩策略和默认模型在迁移审阅中继续保留。
|
|
80
|
+
|
|
81
|
+
初版预期托管三组供应商配置;若保留额外自定义供应商,它们与托管配置的组合方式需在后续迁移时审阅。当前检查工具只报告,不自动合并。
|
|
82
|
+
|
|
83
|
+
## 更新与回滚
|
|
84
|
+
|
|
85
|
+
更新供应商来源后重新构建、测试并提高包版本,生成新 `.tgz`。各节点后续可安装统一版本;DSH 主程序的每日更新不会自动升级这个包。
|
|
86
|
+
|
|
87
|
+
从 npm 按包名安装后,目标 DSH 的插件详情页会检查新版本,也可以手动重新检查。发现新版后显示兼容性并由用户确认更新;启用 HMR 时可直接生效,否则提示重启。本地 `.tgz`、路径和 Git 安装不提供注册表版本检查,需先改为按 npm 包名安装。
|
|
88
|
+
|
|
89
|
+
维护者使用 `release:prepare`、`release:tag` 和 `release:publish` 生成经过校验的安装包、固定 Git 标签并发布。完整流程见源码仓库的 `RELEASING.md`;每个安装包随附 `CHANGELOG.md`。插件不另外添加后台自动安装任务。
|
|
90
|
+
|
|
91
|
+
## 验证
|
|
92
|
+
|
|
93
|
+
测试覆盖模型元数据、节点地址、旧配置检查、凭据隔离、UUID 状态保留及请求扩展作用范围。真实官方适配器测试还验证三种协议的实际请求格式、模型独立密钥、继承与轮换、调用快照,以及凭据落盘权限和重启恢复。接口全部模拟,不调用真实模型。
|
|
94
|
+
|
|
95
|
+
可通过 `DSH_TEST_INSTALL_ROOT` 指定用于测试的 DSH 安装目录。任何集成安装、配置展开和运行检查都应使用临时 DSH home,不使用现有节点 profile。
|
|
96
|
+
|
|
97
|
+
浏览器测试复用已有 Playwright、React 18 和 Chromium:设置 `DSH_PLAYWRIGHT_MODULE`、`DSH_REACT_ROOT`、`DSH_CHROMIUM` 后运行 `node tests/browser.test.mjs`,验证密码输入、协议保存与密钥清除;默认不下载浏览器。
|
|
98
|
+
|
|
99
|
+
## 发布维护
|
|
100
|
+
|
|
101
|
+
公开包名为 `@jedylicong/dsh-xinlab`,发布操作需要该 npm scope 的权限。构建脚本同步生成前端模块标识与包内引用;每次发布对应固定 Git 标签和更新说明,具体流程见源码仓库的 `RELEASING.md`。`admin` 仓库保持私有,npm 分发只包含插件文件。
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { parseArgs } from 'node:util'
|
|
5
|
+
import { buildProviders } from '../src/catalog.mjs'
|
|
6
|
+
import { inspectHome } from '../src/inspect.mjs'
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
10
|
+
home: { type: 'string' }, profile: { type: 'string', default: 'web' }, help: { type: 'boolean' },
|
|
11
|
+
} })
|
|
12
|
+
const command = positionals[0]
|
|
13
|
+
if (values.help || !command) {
|
|
14
|
+
console.log('只读命令:\n dsh-providers catalog\n dsh-providers inspect --home /绝对路径/.dsh --profile web\n此工具不安装、不迁移、不修改 DSH。')
|
|
15
|
+
} else if (command === 'catalog') {
|
|
16
|
+
console.log(JSON.stringify(buildProviders(process.env), null, 2))
|
|
17
|
+
} else if (command === 'inspect') {
|
|
18
|
+
const home = values.home ?? (process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'))
|
|
19
|
+
const report = inspectHome(home, values.profile)
|
|
20
|
+
console.log(JSON.stringify(report, null, 2))
|
|
21
|
+
if (!report.readyForActivation) process.exitCode = 2
|
|
22
|
+
} else {
|
|
23
|
+
throw new Error('未知命令;请使用 --help')
|
|
24
|
+
}
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error(error.message)
|
|
27
|
+
process.exitCode = 1
|
|
28
|
+
}
|
package/client.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Generated by scripts/build-client.mjs. No credentials or host state.
|
|
2
|
+
window.__ModuleLoader__.load({ id: "@jedylicong/dsh-xinlab", factory: function(require) {
|
|
3
|
+
return (function createClientPlugin(React, catalog, settingsId, keyRef, previewDescriptor, packageName) {
|
|
4
|
+
const { createElement: h, useEffect, useState, useSyncExternalStore } = React
|
|
5
|
+
const protocols = {
|
|
6
|
+
'openai-completions': 'OpenAI Chat Completions',
|
|
7
|
+
'openai-responses': 'OpenAI Responses',
|
|
8
|
+
'anthropic-messages': 'Anthropic Messages',
|
|
9
|
+
}
|
|
10
|
+
const models = catalog.flatMap((provider) => provider.models.map((model) => ({
|
|
11
|
+
...model, provider: provider.id, defaultApi: provider.api, ref: keyRef(provider.id, model.id),
|
|
12
|
+
})))
|
|
13
|
+
|
|
14
|
+
class Controller {
|
|
15
|
+
constructor(ctx) {
|
|
16
|
+
this.ctx = ctx
|
|
17
|
+
this.scope = ctx.configForms.get(settingsId)
|
|
18
|
+
this.listeners = new Set()
|
|
19
|
+
this.credentials = {}
|
|
20
|
+
this.notice = {}
|
|
21
|
+
this.busy = new Set()
|
|
22
|
+
this.sequence = 0
|
|
23
|
+
this.disposed = false
|
|
24
|
+
this.refreshState()
|
|
25
|
+
this.unsubscribe = this.scope.subscribe(() => this.refreshState())
|
|
26
|
+
}
|
|
27
|
+
subscribe = (listener) => { this.listeners.add(listener); return () => this.listeners.delete(listener) }
|
|
28
|
+
getSnapshot = () => this.state
|
|
29
|
+
refreshState() {
|
|
30
|
+
if (this.disposed) return
|
|
31
|
+
this.state = { form: this.scope.getSnapshot(), credentials: { ...this.credentials }, notice: { ...this.notice }, busy: new Set(this.busy) }
|
|
32
|
+
for (const listener of this.listeners) listener()
|
|
33
|
+
}
|
|
34
|
+
async refreshCredentials() {
|
|
35
|
+
const sequence = ++this.sequence
|
|
36
|
+
const next = {}
|
|
37
|
+
try {
|
|
38
|
+
for (let start = 0; start < models.length; start += 64) {
|
|
39
|
+
const refs = models.slice(start, start + 64).map((model) => model.ref)
|
|
40
|
+
const result = await this.ctx.remote.xinlabModelKeys.describe(refs)
|
|
41
|
+
if (!result.ok) throw new Error()
|
|
42
|
+
for (const ref of refs) next[ref] = { configured: result.value[ref]?.configured === true, writable: result.value[ref]?.writable === true, hint: result.value[ref]?.hint }
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Failed reads never pretend the key is absent or writable.
|
|
46
|
+
if (!this.disposed && sequence === this.sequence) {
|
|
47
|
+
this.credentials = {}
|
|
48
|
+
this.refreshState()
|
|
49
|
+
}
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
if (this.disposed) return false
|
|
53
|
+
if (sequence === this.sequence) { this.credentials = next; this.refreshState() }
|
|
54
|
+
// A write's readback remains valid even if a pushed credential event
|
|
55
|
+
// started a newer refresh. Only publication is latest-read-wins.
|
|
56
|
+
return next
|
|
57
|
+
}
|
|
58
|
+
async action(id, work, success) {
|
|
59
|
+
if (this.disposed || this.busy.has(id)) return false
|
|
60
|
+
this.busy.add(id)
|
|
61
|
+
delete this.notice[id]
|
|
62
|
+
this.refreshState()
|
|
63
|
+
let ok = false
|
|
64
|
+
try {
|
|
65
|
+
ok = await work()
|
|
66
|
+
this.notice[id] = ok ? success : '保存未完成,请刷新状态后重试。'
|
|
67
|
+
} catch {
|
|
68
|
+
// RPC/provider errors may contain submitted data. Never render or log them.
|
|
69
|
+
this.notice[id] = '保存失败,请检查连接和写入权限后重试。'
|
|
70
|
+
} finally {
|
|
71
|
+
this.busy.delete(id)
|
|
72
|
+
this.refreshState()
|
|
73
|
+
}
|
|
74
|
+
return ok
|
|
75
|
+
}
|
|
76
|
+
saveKey(model, value) {
|
|
77
|
+
const id = model.ref + ':key'
|
|
78
|
+
if (!value.trim() || /[^\x21-\x7E]/.test(value.trim())) {
|
|
79
|
+
this.notice[id] = '请输入有效的 API Key,不能包含空白、换行或非 ASCII 字符。'
|
|
80
|
+
this.refreshState()
|
|
81
|
+
return Promise.resolve(false)
|
|
82
|
+
}
|
|
83
|
+
if (!this.credentials[model.ref]?.writable) return Promise.resolve(false)
|
|
84
|
+
return this.action(id, async () => {
|
|
85
|
+
const result = await this.ctx.remote.credentials.set(model.ref, value.trim())
|
|
86
|
+
if (!result.ok) return false
|
|
87
|
+
const readback = await this.refreshCredentials()
|
|
88
|
+
return readback && readback[model.ref]?.configured === true
|
|
89
|
+
}, '独立密钥已安全保存。')
|
|
90
|
+
}
|
|
91
|
+
clearKey(model) {
|
|
92
|
+
if (!this.credentials[model.ref]?.writable) return Promise.resolve(false)
|
|
93
|
+
return this.action(model.ref + ':key', async () => {
|
|
94
|
+
const result = await this.ctx.remote.credentials.unset(model.ref)
|
|
95
|
+
if (!result.ok) return false
|
|
96
|
+
const readback = await this.refreshCredentials()
|
|
97
|
+
return readback && readback[model.ref]?.configured === false
|
|
98
|
+
}, '独立密钥已移除,恢复使用供应商凭据。')
|
|
99
|
+
}
|
|
100
|
+
saveProtocol(model, api, revision) {
|
|
101
|
+
if (api !== '' && !Object.hasOwn(protocols, api)) return Promise.resolve(false)
|
|
102
|
+
if (!this.scope.getSnapshot().writable || revision === undefined) return Promise.resolve(false)
|
|
103
|
+
const path = ['protocols', model.provider, model.id]
|
|
104
|
+
return this.action(model.ref + ':protocol', () => this.scope.mutate([
|
|
105
|
+
api === '' ? { op: 'unset', path } : { op: 'set', path, value: api },
|
|
106
|
+
], revision), '协议已保存,将用于下一次模型调用。')
|
|
107
|
+
}
|
|
108
|
+
dispose() {
|
|
109
|
+
this.disposed = true
|
|
110
|
+
this.sequence++
|
|
111
|
+
this.unsubscribe()
|
|
112
|
+
this.listeners.clear()
|
|
113
|
+
this.credentials = {}
|
|
114
|
+
this.notice = {}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const inputStyle = { border: '1px solid var(--border, #8886)', borderRadius: 6, padding: '7px 9px', background: 'transparent', color: 'inherit', minWidth: 0, width: '100%', boxSizing: 'border-box' }
|
|
119
|
+
const buttonStyle = { ...inputStyle, width: 'auto', cursor: 'pointer', marginTop: 8, marginRight: 8 }
|
|
120
|
+
function ModelRow({ model, controller, state }) {
|
|
121
|
+
const accepted = state.form.value?.protocols?.[model.provider]?.[model.id] ?? ''
|
|
122
|
+
const [api, setApi] = useState(accepted)
|
|
123
|
+
const [revision, setRevision] = useState(state.form.revision)
|
|
124
|
+
const [dirty, setDirty] = useState(false)
|
|
125
|
+
const [key, setKey] = useState('')
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!dirty) { setApi(accepted); setRevision(state.form.revision) }
|
|
128
|
+
}, [accepted, state.form.revision, dirty])
|
|
129
|
+
const status = state.credentials[model.ref]
|
|
130
|
+
const keyBusy = state.busy.has(model.ref + ':key')
|
|
131
|
+
const protocolBusy = state.busy.has(model.ref + ':protocol')
|
|
132
|
+
const keyId = model.ref + '-input'
|
|
133
|
+
const apiId = model.ref + '-protocol'
|
|
134
|
+
return h('section', { style: { borderTop: '1px solid var(--border, #8884)', padding: '16px 0' } },
|
|
135
|
+
h('div', { style: { fontWeight: 600, marginBottom: 12 } }, model.name || model.id),
|
|
136
|
+
model.name && model.name !== model.id ? h('div', { style: { opacity: 0.65, fontSize: 12, marginBottom: 10 } }, model.id) : null,
|
|
137
|
+
h('label', { htmlFor: keyId }, 'API Key'),
|
|
138
|
+
h('input', { id: keyId, type: 'password', autoComplete: 'new-password', spellCheck: false, value: key,
|
|
139
|
+
disabled: !status?.writable || keyBusy, style: { ...inputStyle, marginTop: 5 },
|
|
140
|
+
placeholder: status?.configured ? '已保存独立密钥;输入新值可替换' : '留空沿用供应商凭据',
|
|
141
|
+
onChange: (event) => setKey(event.target.value),
|
|
142
|
+
}),
|
|
143
|
+
h('div', { style: { fontSize: 12, opacity: 0.75, marginTop: 5 } }, status ? (status.configured ? '已配置独立密钥:' + (status.hint || '••••••••') : '未配置独立密钥,将沿用供应商凭据。') : '密钥状态尚未确认。'),
|
|
144
|
+
h('button', { type: 'button', style: buttonStyle, disabled: !status?.writable || !key.trim() || keyBusy,
|
|
145
|
+
onClick: () => { const submitted = key; setKey(''); void controller.saveKey(model, submitted) },
|
|
146
|
+
}, keyBusy ? '正在保存…' : '保存密钥'),
|
|
147
|
+
h('button', { type: 'button', style: buttonStyle, disabled: !status?.writable || !status.configured || keyBusy,
|
|
148
|
+
onClick: () => { setKey(''); void controller.clearKey(model) },
|
|
149
|
+
}, '清除独立密钥'),
|
|
150
|
+
h('div', { role: 'status', style: { fontSize: 12, marginTop: 6 } }, state.notice[model.ref + ':key'] ?? ''),
|
|
151
|
+
h('label', { htmlFor: apiId, style: { display: 'block', marginTop: 16 } }, '调用协议'),
|
|
152
|
+
h('select', { id: apiId, value: api, disabled: !state.form.writable || protocolBusy, style: { ...inputStyle, marginTop: 5 },
|
|
153
|
+
onChange: (event) => { if (!dirty) setRevision(state.form.revision); setDirty(true); setApi(event.target.value) },
|
|
154
|
+
}, h('option', { value: '' }, '沿用预设(' + (protocols[model.defaultApi] || model.defaultApi) + ')'),
|
|
155
|
+
...Object.entries(protocols).map(([id, label]) => h('option', { key: id, value: id }, label))),
|
|
156
|
+
h('button', { type: 'button', style: buttonStyle, disabled: !state.form.writable || !dirty || protocolBusy,
|
|
157
|
+
onClick: async () => { if (await controller.saveProtocol(model, api, revision)) setDirty(false); else setRevision(controller.scope.getSnapshot().revision) },
|
|
158
|
+
}, protocolBusy ? '正在保存…' : '保存协议'),
|
|
159
|
+
h('div', { role: 'status', style: { fontSize: 12, marginTop: 6 } }, state.notice[model.ref + ':protocol'] ?? ''),
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
function Page({ controller }) {
|
|
163
|
+
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot)
|
|
164
|
+
if (state.form.status !== 'ready') return h('p', null, '正在读取供应商配置…')
|
|
165
|
+
return h('div', { style: { maxWidth: 720, padding: 16, overflowWrap: 'anywhere' } },
|
|
166
|
+
h('h2', { style: { marginTop: 0 } }, 'XinLab 模型配置'),
|
|
167
|
+
h('p', { style: { opacity: 0.75 } }, '为每个模型配置独立密钥和调用协议。密钥保存在当前节点的 DSH 凭据存储中。所选协议需由上游接口支持。'),
|
|
168
|
+
!state.form.writable ? h('p', { role: 'status' }, '当前连接不允许修改协议配置。') : null,
|
|
169
|
+
h('button', { type: 'button', style: buttonStyle, onClick: () => void controller.refreshCredentials() }, '刷新密钥状态'),
|
|
170
|
+
...catalog.map((provider) => h('details', { key: provider.id, open: true, style: { marginTop: 16 } },
|
|
171
|
+
h('summary', { style: { cursor: 'pointer', fontWeight: 600 } }, provider.name),
|
|
172
|
+
...models.filter((model) => model.provider === provider.id).map((model) => h(ModelRow, { key: model.ref, model, controller, state })),
|
|
173
|
+
)),
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
async function apply(ctx) {
|
|
177
|
+
const unmount = await ctx.remote.$mount({ package: packageName, descriptors: [previewDescriptor(packageName)] })
|
|
178
|
+
ctx.effect(() => unmount)
|
|
179
|
+
const controller = new Controller(ctx)
|
|
180
|
+
ctx.effect(() => () => controller.dispose())
|
|
181
|
+
ctx.effect(() => ctx.configForms.whileServed([settingsId], () => ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
182
|
+
name: 'settings.section', id: 'xinlab-models', order: 11, label: () => 'XinLab 模型配置', inject: () => ({ controller }),
|
|
183
|
+
}, Page))))
|
|
184
|
+
ctx.effect(() => ctx.remote.$on('credentials/reference-updated', (ref) => {
|
|
185
|
+
if (models.some((model) => model.ref === ref)) void controller.refreshCredentials()
|
|
186
|
+
}))
|
|
187
|
+
ctx.on('connection/reset', () => void controller.refreshCredentials())
|
|
188
|
+
void controller.refreshCredentials()
|
|
189
|
+
}
|
|
190
|
+
return { inject: ['slots', 'remote', 'remote.credentials', 'configForms'], apply, Controller, Page, ModelRow, models }
|
|
191
|
+
})(require("react"), [{"id":"zai","name":"Z.ai","api":"openai-completions","models":[{"id":"glm-5.3","name":"GLM-5.3"},{"id":"glm-5.3-flash","name":"GLM-5.3-Flash"},{"id":"glm-5.2","name":"GLM-5.2"}]},{"id":"vllm","name":"vllm","api":"openai-completions","models":[{"id":"glm-5.3-flash","name":"GLM-5.3-Flash"},{"id":"glm-5.3-flash-uncensored","name":"GLM-5.3-FLASH-UNCENSORED"},{"id":"deepseek-v4-flash","name":"DeepSeek-V4-Flash"}]},{"id":"xin-lab","name":"XinLab","api":"openai-completions","models":[{"id":"claude-fable-5","name":"Claude Fable 5"},{"id":"claude-opus-5","name":"Claude Opus 5"},{"id":"claude-opus-4-8","name":"Claude Opus 4.8"},{"id":"claude-opus-4-7","name":"Claude Opus 4.7"},{"id":"claude-opus-4-6","name":"Claude Opus 4.6"},{"id":"claude-sonnet-5","name":"Claude Sonnet 5"},{"id":"claude-sonnet-4-6","name":"Claude Sonnet 4.6"},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5"},{"id":"gpt-5.6-sol","name":"GPT 5.6 Sol"},{"id":"gpt-5.6-terra","name":"GPT 5.6 Terra"},{"id":"glm-5.3","name":"GLM 5.3"},{"id":"glm-5.2","name":"GLM 5.2"},{"id":"gemini-3.5-flash","name":"Gemini 3.5 Flash"},{"id":"gemini-3.6-flash","name":"Gemini 3.6 Flash"},{"id":"deepseek-v4-pro","name":"DeepSeek V4 Pro"},{"id":"deepseek-flash","name":"DeepSeek V4.1 Flash"},{"id":"qwen3.7-max","name":"Qwen 3.7 Max"}]}], "xinlab-providers", function modelKeyRef(provider, model) {
|
|
192
|
+
const bytes = new TextEncoder().encode(JSON.stringify([provider, model]))
|
|
193
|
+
return 'XINLAB_MODEL_' + Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('').toUpperCase()
|
|
194
|
+
}, function keyPreviewDescriptor(packageName) {
|
|
195
|
+
const refsCodec = {
|
|
196
|
+
mode: 'strict', typeSymbol: 'XinlabModelKeyRefs',
|
|
197
|
+
create: () => ({ parse(value) {
|
|
198
|
+
if (!Array.isArray(value) || value.length > 64 || value.some((ref) => typeof ref !== 'string' || !/^XINLAB_MODEL_[0-9A-F]+$/.test(ref))) throw new Error('无效的模型密钥提示请求。')
|
|
199
|
+
return value
|
|
200
|
+
} }),
|
|
201
|
+
}
|
|
202
|
+
const resultCodec = {
|
|
203
|
+
mode: 'strict', typeSymbol: 'XinlabModelKeyPreviews',
|
|
204
|
+
create: () => ({ parse(value) {
|
|
205
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('无效的密钥提示响应。')
|
|
206
|
+
const safe = {}
|
|
207
|
+
for (const [ref, info] of Object.entries(value)) {
|
|
208
|
+
if (!info || typeof info.configured !== 'boolean' || typeof info.writable !== 'boolean') throw new Error('无效的密钥状态。')
|
|
209
|
+
if (info.hint !== undefined && (typeof info.hint !== 'string' || !/^(?:[\x21-\x7E]{7})?•{8}$/.test(info.hint))) throw new Error('无效的脱敏提示。')
|
|
210
|
+
safe[ref] = { configured: info.configured, writable: info.writable, ...(info.hint === undefined ? {} : { hint: info.hint }) }
|
|
211
|
+
}
|
|
212
|
+
return safe
|
|
213
|
+
} }),
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
id: packageName + '#xinlabModelKeys/describe', service: 'xinlabModelKeys', namespace: 'xinlabModelKeys', method: 'describe',
|
|
217
|
+
invocation: { kind: 'direct' }, parameters: [{ name: 'refs', wire: 'refs', source: 'json', codec: refsCodec }], result: resultCodec,
|
|
218
|
+
}
|
|
219
|
+
}, "@jedylicong/dsh-xinlab");
|
|
220
|
+
} });
|