dsh-model 0.14.0 → 0.15.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.en.md +4 -0
- package/README.md +4 -0
- package/dist/bridge/server.js +5 -0
- package/dist/cli.js +9 -1
- package/dist/commands/opencode.js +9 -3
- package/dist/commands/self.js +116 -0
- package/dist/commands/setup.js +5 -2
- package/dist/commands/uninstall.js +1 -1
- package/dist/daemon/client.js +2 -2
- package/dist/daemon/core.js +60 -0
- package/dist/dsh/cli.js +19 -1
- package/dist/integrations/dshplugin.js +60 -13
- package/dist/service/index.js +2 -0
- package/dist/state.js +1 -1
- package/package.json +1 -1
- package/plugin/client.js +142 -3
- package/plugin/index.js +56 -2
package/README.en.md
CHANGED
|
@@ -55,6 +55,9 @@ On a dsh-vps server, use `sudo dsh-model setup`.
|
|
|
55
55
|
| **Models** | Pick which models of each source appear in dsh (5 suggested per source by default); the rest stay available through the endpoint |
|
|
56
56
|
| **API keys** | OpenAI-style, one per device. Requests, success rate, latency and speed over 24 hours; copy, rotate, revoke |
|
|
57
57
|
| **Model stats** | Success rate, latency and speed per model |
|
|
58
|
+
| **About** | Current and latest npm version; one-click Update and Uninstall (on a VPS these need root, so the commands are shown instead) |
|
|
59
|
+
|
|
60
|
+
If the service is not set up on this machine yet, the page says what is missing and offers a "Set up now" button, so you can also start by installing dsh-model from the dsh plugin marketplace and finish from this page.
|
|
58
61
|
|
|
59
62
|
dsh-model does every sign-in itself. No provider CLI is installed and the server needs no browser:
|
|
60
63
|
|
|
@@ -94,6 +97,7 @@ dsh-model only connects; it provides no quota of its own.
|
|
|
94
97
|
| `remote enable --via caddy\|ssh\|tailscale` / `remote disable` | Remote access (on a VPS, setup already enables caddy) |
|
|
95
98
|
| `engine version\|upgrade\|rollback` | Engine version |
|
|
96
99
|
| `logs [--bridge]` / `repair` | Logs / repair |
|
|
100
|
+
| `update [--to ver]` | Update to the latest npm version: program, dsh plugin and services together |
|
|
97
101
|
| `uninstall` | Clean uninstall |
|
|
98
102
|
|
|
99
103
|
Every command accepts `--lang zh|en` and `--json`.
|
package/README.md
CHANGED
|
@@ -55,6 +55,9 @@ dsh-model setup
|
|
|
55
55
|
| **选模型** | 每个来源选哪些模型显示在 dsh 里,默认每家推荐 5 个,其余的经统一地址照样可用 |
|
|
56
56
|
| **API key** | OpenAI 格式,每台设备一把。显示 24 小时内的请求数、成功率、延迟和速度,可复制、轮换、吊销 |
|
|
57
57
|
| **模型统计** | 按模型显示成功率、延迟和速度 |
|
|
58
|
+
| **关于** | 当前版本和 npm 上的最新版本;「更新」「卸载」一键完成(VPS 上要 root,这里给出命令) |
|
|
59
|
+
|
|
60
|
+
还没在这台机器上装好服务时,这一页会说明缺什么,并提供「一键安装」按钮。所以也可以先从 dsh 的插件市场装 dsh-model,再在这一页里点安装。
|
|
58
61
|
|
|
59
62
|
登录全由 dsh-model 自己完成,服务器上不装各家的 CLI,也不需要浏览器:
|
|
60
63
|
|
|
@@ -94,6 +97,7 @@ dsh-model 只负责转接,不提供额度。
|
|
|
94
97
|
| `remote enable --via caddy\|ssh\|tailscale` / `remote disable` | 远程访问(VPS 上 setup 已自动开通 caddy) |
|
|
95
98
|
| `engine version\|upgrade\|rollback` | 引擎版本 |
|
|
96
99
|
| `logs [--bridge]` / `repair` | 日志 / 修复 |
|
|
100
|
+
| `update [--to 版本]` | 更新到 npm 上的最新版:程序、dsh 插件和服务一起更新 |
|
|
97
101
|
| `uninstall` | 干净卸载 |
|
|
98
102
|
|
|
99
103
|
所有命令都支持 `--lang zh|en` 和 `--json`。
|
package/dist/bridge/server.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// 路由按产品分前缀:/cn/v1/...(WorkBuddy 国内版)、/ai/v1/...(WorkBuddy AI 国际版)。
|
|
5
5
|
// 协议细节(凭据解密、客户端身份、刷新、目录)来自移植的 ./workbuddy/*(MIT,见其 LICENSE)。
|
|
6
6
|
import { timingSafeEqual } from 'node:crypto';
|
|
7
|
+
import { initLang } from '../i18n.js';
|
|
7
8
|
import { createServer } from 'node:http';
|
|
8
9
|
import { Readable } from 'node:stream';
|
|
9
10
|
import { aggregateSse } from './aggregate.js';
|
|
@@ -93,6 +94,10 @@ export function createBridge(options) {
|
|
|
93
94
|
return oaiError(res, 400, 'invalid_json', 'request body is not JSON');
|
|
94
95
|
}
|
|
95
96
|
}
|
|
97
|
+
// 提示语跟着调用方的语言(dsh 页面 / CLI 的 --lang);守护进程自己由 launchd 启动时没有 LANG
|
|
98
|
+
const lang = String(req.headers['x-dsh-model-lang'] ?? '');
|
|
99
|
+
if (lang === 'zh' || lang === 'en')
|
|
100
|
+
initLang(lang);
|
|
96
101
|
const r = await options.onControl(req.method ?? 'GET', url.slice('/control'.length), body, fullUrl.searchParams);
|
|
97
102
|
return json(res, r.status, r.body);
|
|
98
103
|
}
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { repair, service } from './commands/service.js';
|
|
|
9
9
|
import { setup } from './commands/setup.js';
|
|
10
10
|
import { doctor, status } from './commands/status.js';
|
|
11
11
|
import { uninstall } from './commands/uninstall.js';
|
|
12
|
+
import { selfUpdate } from './commands/self.js';
|
|
12
13
|
import { opencode } from './commands/opencode.js';
|
|
13
14
|
import { workbuddy } from './commands/workbuddy.js';
|
|
14
15
|
import { bridge } from './commands/bridge.js';
|
|
@@ -46,6 +47,8 @@ const OPTIONS = {
|
|
|
46
47
|
'skip-dsh-plugin': { type: 'boolean' },
|
|
47
48
|
stdin: { type: 'boolean' },
|
|
48
49
|
'skip-verify': { type: 'boolean' },
|
|
50
|
+
to: { type: 'string' },
|
|
51
|
+
'remove-plugin': { type: 'boolean' },
|
|
49
52
|
};
|
|
50
53
|
function help() {
|
|
51
54
|
const ups = UPSTREAMS.map((u) => u.id).join('|');
|
|
@@ -76,6 +79,7 @@ function help() {
|
|
|
76
79
|
service status|install|start|stop|restart|uninstall
|
|
77
80
|
repair 按台账修复配置、服务与接线
|
|
78
81
|
logs [--lines N] [--bridge] 引擎 / bridge 日志
|
|
82
|
+
update [--to 版本] 更新到 npm 上的最新版(程序、dsh 插件、服务一起)
|
|
79
83
|
uninstall [--yes] [--keep-auth] 干净卸载
|
|
80
84
|
|
|
81
85
|
通用选项:--lang zh|en --json --help --version
|
|
@@ -106,6 +110,7 @@ Usage: dsh-model <command> [options]
|
|
|
106
110
|
service status|install|start|stop|restart|uninstall
|
|
107
111
|
repair Repair config, service and wiring from the ledger
|
|
108
112
|
logs [--lines N] [--bridge] Engine / bridge logs
|
|
113
|
+
update [--to ver] Update to the latest npm version (program, dsh plugin and services)
|
|
109
114
|
uninstall [--yes] [--keep-auth] Clean uninstall
|
|
110
115
|
|
|
111
116
|
Global: --lang zh|en --json --help --version
|
|
@@ -184,7 +189,10 @@ export async function main(argv) {
|
|
|
184
189
|
case 'logs':
|
|
185
190
|
return await logs(ctx, o.lines ? Number(o.lines) : undefined, o.bridge ? 'bridge' : 'engine');
|
|
186
191
|
case 'uninstall':
|
|
187
|
-
return await uninstall(ctx, { yes: o.yes, keepAuth: o['keep-auth'] });
|
|
192
|
+
return await uninstall(ctx, { yes: o.yes, keepAuth: o['keep-auth'], removePlugin: o['remove-plugin'] });
|
|
193
|
+
case 'self-update':
|
|
194
|
+
case 'update':
|
|
195
|
+
return await selfUpdate(ctx, { to: o.to });
|
|
188
196
|
default:
|
|
189
197
|
fail(L(`未知命令:${cmd}`, `Unknown command: ${cmd}`));
|
|
190
198
|
console.log(dim(L('查看帮助:dsh-model --help', 'See: dsh-model --help')));
|
|
@@ -7,6 +7,13 @@ import { loadAll, syncAll } from '../ops.js';
|
|
|
7
7
|
import { withLock } from '../state.js';
|
|
8
8
|
import { info, isJsonMode, ok, printJson, skip } from '../util/output.js';
|
|
9
9
|
import { canPrompt, promptSecret, readStdin } from '../util/prompt.js';
|
|
10
|
+
/** 交互地问 OpenCode key(回车跳过 → '';不能交互 → undefined) */
|
|
11
|
+
export async function askOpencodeKey() {
|
|
12
|
+
if (!canPrompt())
|
|
13
|
+
return undefined;
|
|
14
|
+
info(L(`OpenCode Zen 的 API key(${OPENCODE_KEY_PAGE},要先充值;没有就直接回车跳过,以后在 dsh 的管理页填)。输入不会显示。`, `Your OpenCode Zen API key (${OPENCODE_KEY_PAGE}; needs a top-up. Press Enter to skip and add it later in dsh's management page). Input is hidden.`));
|
|
15
|
+
return promptSecret('OpenCode Zen API key: ');
|
|
16
|
+
}
|
|
10
17
|
/** 拿 key → 实测 → 存进 secrets.json → 拉模型目录。返回是否有变化(调用方随后 syncAll) */
|
|
11
18
|
export async function configureOpencode(ctx, all, opts = {}) {
|
|
12
19
|
if ((await opencodeConfigured(ctx)) && !opts.replace) {
|
|
@@ -18,9 +25,8 @@ export async function configureOpencode(ctx, all, opts = {}) {
|
|
|
18
25
|
if (opts.stdin) {
|
|
19
26
|
key = await readStdin();
|
|
20
27
|
}
|
|
21
|
-
else if (canPrompt()) {
|
|
22
|
-
|
|
23
|
-
key = await promptSecret('OpenCode Zen API key: ');
|
|
28
|
+
else if (opts.key !== undefined || canPrompt()) {
|
|
29
|
+
key = opts.key ?? (await askOpencodeKey()) ?? '';
|
|
24
30
|
if (!key) {
|
|
25
31
|
skip(L('已跳过;之后可执行 dsh-model opencode key', 'Skipped; later run dsh-model opencode key'));
|
|
26
32
|
return false;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// commands/self.ts — 更新 dsh-model 自己(命令行与 dsh 管理页的「更新」共用)
|
|
2
|
+
//
|
|
3
|
+
// 1. 新版本装到原来的位置:npm 全局装的就 npm install -g;从 dsh 管理页一键安装的(程序就在插件目录里)跳过这步;
|
|
4
|
+
// 2. dsh 里的插件换成同一版本(插件页的新代码要重启一次 dsh 才生效);
|
|
5
|
+
// 3. 用新版本执行一次 repair:重写服务文件、重启引擎与守护进程、同步模型。
|
|
6
|
+
import { realpath } from 'node:fs/promises';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { requireRootInVps } from '../context.js';
|
|
9
|
+
import { DshModelError, isDshModelError } from '../errors.js';
|
|
10
|
+
import { L, lang } from '../i18n.js';
|
|
11
|
+
import { installDshPlugin, pluginRoot } from '../integrations/dshplugin.js';
|
|
12
|
+
import { loadAll, saveAll } from '../ops.js';
|
|
13
|
+
import { withLock } from '../state.js';
|
|
14
|
+
import { run, runInherit } from '../util/exec.js';
|
|
15
|
+
import { exists } from '../util/fs.js';
|
|
16
|
+
import { info, next, ok, skip, warn } from '../util/output.js';
|
|
17
|
+
import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
|
|
18
|
+
const REGISTRY = 'https://registry.npmjs.org/dsh-model';
|
|
19
|
+
/** a 比 b 新(只比 major.minor.patch) */
|
|
20
|
+
export function isNewer(a, b) {
|
|
21
|
+
const pa = a.split(/[.-]/).map((x) => parseInt(x, 10) || 0);
|
|
22
|
+
const pb = b.split(/[.-]/).map((x) => parseInt(x, 10) || 0);
|
|
23
|
+
for (let i = 0; i < 3; i++)
|
|
24
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
25
|
+
return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
export async function latestVersion() {
|
|
29
|
+
try {
|
|
30
|
+
const r = await fetch(`${REGISTRY}/latest`, { signal: AbortSignal.timeout(10_000) });
|
|
31
|
+
if (!r.ok)
|
|
32
|
+
return null;
|
|
33
|
+
return String((await r.json()).version ?? '') || null;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** 和当前 node 同目录的 npm,以及让它能找到 node 的环境 */
|
|
40
|
+
async function npmBeside() {
|
|
41
|
+
if (process.versions.electron)
|
|
42
|
+
return null;
|
|
43
|
+
const dir = dirname(process.execPath);
|
|
44
|
+
const npm = join(dir, 'npm');
|
|
45
|
+
if (!(await exists(npm)))
|
|
46
|
+
return null;
|
|
47
|
+
return { npm, env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` } };
|
|
48
|
+
}
|
|
49
|
+
/** 当前程序是不是 npm 全局装的(是就能用 npm install -g 原地更新) */
|
|
50
|
+
async function npmGlobalInstall() {
|
|
51
|
+
const n = await npmBeside();
|
|
52
|
+
if (!n)
|
|
53
|
+
return null;
|
|
54
|
+
const r = await run(n.npm, ['root', '-g'], { env: n.env, timeoutMs: 20_000 });
|
|
55
|
+
if (r.code !== 0)
|
|
56
|
+
return null;
|
|
57
|
+
try {
|
|
58
|
+
return (await realpath(join(r.stdout.trim(), 'dsh-model'))) === (await realpath(PKG_ROOT)) ? n : null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function selfUpdate(ctx, opts = {}) {
|
|
65
|
+
requireRootInVps(ctx);
|
|
66
|
+
const current = pkgVersion();
|
|
67
|
+
const target = opts.to ?? (await latestVersion());
|
|
68
|
+
if (!target)
|
|
69
|
+
throw new DshModelError('no_latest', L('查不到 npm 上的最新版本(网络?)', 'Could not get the latest version from npm (network?)'), L('稍后再试,或指定:dsh-model self-update --to <版本>', 'Try later, or pass one: dsh-model self-update --to <version>'));
|
|
70
|
+
if (!opts.to && !isNewer(target, current)) {
|
|
71
|
+
ok(L(`已是最新(${current};npm 上最新 ${target})`, `Up to date (${current}; latest on npm is ${target})`));
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
info(L(`当前 ${current},目标 ${target}`, `Current ${current}, target ${target}`));
|
|
75
|
+
// 1. 程序本身
|
|
76
|
+
let root = PKG_ROOT;
|
|
77
|
+
const npm = await npmGlobalInstall();
|
|
78
|
+
if (target === current)
|
|
79
|
+
skip(L('程序已是这个版本', 'The program is already at this version'));
|
|
80
|
+
else if (npm) {
|
|
81
|
+
info(L(`npm install -g dsh-model@${target}`, `npm install -g dsh-model@${target}`));
|
|
82
|
+
const code = await runInherit(npm.npm, ['install', '-g', `dsh-model@${target}`], { env: npm.env });
|
|
83
|
+
if (code !== 0)
|
|
84
|
+
throw new DshModelError('update_failed', L(`npm 安装失败(退出码 ${code})`, `npm install failed (exit code ${code})`));
|
|
85
|
+
ok(L(`程序已更新到 ${target}`, `Program updated to ${target}`));
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
skip(L('程序在 dsh 的插件目录里,随插件一起更新', 'The program lives in the dsh plugin directory and is updated with it'));
|
|
89
|
+
}
|
|
90
|
+
// 2. dsh 里的插件
|
|
91
|
+
// 插件没换成也继续:服务照样要用新版本重装,插件之后可以在 dsh 的插件市场更新
|
|
92
|
+
try {
|
|
93
|
+
await withLock(ctx, async () => {
|
|
94
|
+
const all = await loadAll(ctx);
|
|
95
|
+
await installDshPlugin(ctx, all, { version: target, replaceUser: true });
|
|
96
|
+
await saveAll(ctx, all);
|
|
97
|
+
if (!npm)
|
|
98
|
+
root = (await pluginRoot(ctx, all.config.dsh.profile)) ?? root;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
warn(`${error.message}${isDshModelError(error) && error.hint ? ` — ${error.hint}` : ''}`);
|
|
103
|
+
}
|
|
104
|
+
// 3. 用新版本重装服务并重启
|
|
105
|
+
info(L('用新版本重装服务…', 'Reinstalling the services with the new version…'));
|
|
106
|
+
const code = await runInherit(process.execPath, [join(root, 'bin', 'dsh-model.js'), 'repair', '--lang', lang()], {
|
|
107
|
+
env: { ...process.env, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) },
|
|
108
|
+
});
|
|
109
|
+
if (code !== 0) {
|
|
110
|
+
warn(L(`repair 没有完成(退出码 ${code}):执行 dsh-model repair 重试`, `repair did not finish (exit code ${code}); run dsh-model repair to retry`));
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
ok(L(`已更新到 ${target}`, `Updated to ${target}`));
|
|
114
|
+
next(L('重启一次 dsh,管理页的新代码才会生效', 'Restart dsh once so the management page picks up the new code'));
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
package/dist/commands/setup.js
CHANGED
|
@@ -17,13 +17,16 @@ import { ensureDir } from '../util/fs.js';
|
|
|
17
17
|
import { bold, info, next, ok, skip, warn } from '../util/output.js';
|
|
18
18
|
import { findFreePort, isPortFree } from '../util/port.js';
|
|
19
19
|
import { detectProxy, normalizeProxyUrl, redactProxy } from '../util/proxy.js';
|
|
20
|
-
import { configureOpencode } from './opencode.js';
|
|
20
|
+
import { askOpencodeKey, configureOpencode } from './opencode.js';
|
|
21
|
+
import { opencodeConfigured } from '../integrations/opencode.js';
|
|
21
22
|
import { ensureDaemon } from '../daemon/service.js';
|
|
22
23
|
import { installDshPlugin } from '../integrations/dshplugin.js';
|
|
23
24
|
import { enableCaddy } from './remote.js';
|
|
24
25
|
import { DEFAULT_PUBLIC_PORT, publicHost } from '../remote/public.js';
|
|
25
26
|
export async function setup(ctx, opts) {
|
|
26
27
|
requireRootInVps(ctx);
|
|
28
|
+
// 要问的先问完再拿锁:等你输入时不占着锁,dsh 管理页照常能用(实测:卡在问 key 时页面新增 key 报"另一个命令正在运行")
|
|
29
|
+
const opencodeKey = opts.skipOpencode || (await opencodeConfigured(ctx)) ? undefined : await askOpencodeKey();
|
|
27
30
|
return withLock(ctx, async () => {
|
|
28
31
|
const all = await loadAll(ctx);
|
|
29
32
|
await ensureDir(ctx.paths.home, { owner: ctx.owner });
|
|
@@ -72,7 +75,7 @@ export async function setup(ctx, opts) {
|
|
|
72
75
|
if (opts.skipOpencode)
|
|
73
76
|
skip(L('已跳过(--skip-opencode)', 'Skipped (--skip-opencode)'));
|
|
74
77
|
else
|
|
75
|
-
await step('opencode', () => configureOpencode(ctx, all));
|
|
78
|
+
await step('opencode', () => configureOpencode(ctx, all, opencodeKey !== undefined ? { key: opencodeKey } : {}));
|
|
76
79
|
info('');
|
|
77
80
|
info(bold('WorkBuddy'));
|
|
78
81
|
if (opts.skipWorkbuddy)
|
|
@@ -57,7 +57,7 @@ export async function uninstall(ctx, opts) {
|
|
|
57
57
|
await disableRemote(ctx, all);
|
|
58
58
|
});
|
|
59
59
|
await step('dsh-plugin', async () => {
|
|
60
|
-
if (await removeDshPlugin(ctx, all))
|
|
60
|
+
if (await removeDshPlugin(ctx, all, { force: opts.removePlugin }))
|
|
61
61
|
ok(L('已从 dsh 移除 dsh-model 插件', 'Removed the dsh-model plugin from dsh'));
|
|
62
62
|
});
|
|
63
63
|
await step('workbuddy', async () => {
|
package/dist/daemon/client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// daemon/client.ts — 调守护进程 /control/*(CLI 用;dsh 插件宿主端也照这个格式调)
|
|
2
2
|
import { loadBridgeConfig } from '../bridge/runtime.js';
|
|
3
3
|
import { DshModelError } from '../errors.js';
|
|
4
|
-
import { L } from '../i18n.js';
|
|
4
|
+
import { L, lang } from '../i18n.js';
|
|
5
5
|
export async function control(ctx, method, path, body, timeoutMs = 60_000) {
|
|
6
6
|
const cfg = await loadBridgeConfig(ctx.paths.home);
|
|
7
7
|
if (!cfg)
|
|
@@ -10,7 +10,7 @@ export async function control(ctx, method, path, body, timeoutMs = 60_000) {
|
|
|
10
10
|
try {
|
|
11
11
|
res = await fetch(`http://127.0.0.1:${cfg.port}/control${path}`, {
|
|
12
12
|
method,
|
|
13
|
-
headers: { Authorization: `Bearer ${cfg.secret}`, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
13
|
+
headers: { Authorization: `Bearer ${cfg.secret}`, 'x-dsh-model-lang': lang(), ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
14
14
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
15
15
|
signal: AbortSignal.timeout(timeoutMs),
|
|
16
16
|
});
|
package/dist/daemon/core.js
CHANGED
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
// 职责:来源状态与开关、登录会话(引擎 OAuth / WorkBuddy 自有登录 / OpenCode key)、key 管理、用量统计。
|
|
6
6
|
// CLI 与 dsh 插件都只通过 /control/* 调这里,逻辑只有一份。
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
|
+
import { closeSync, openSync } from 'node:fs';
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { latestVersion } from '../commands/self.js';
|
|
13
|
+
import { lang } from '../i18n.js';
|
|
14
|
+
import { which } from '../util/exec.js';
|
|
15
|
+
import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
|
|
8
16
|
import { randomBytes } from 'node:crypto';
|
|
9
17
|
import { currentBinary } from '../engine/install.js';
|
|
10
18
|
import { buildRuntime, availableVariants, refreshCatalog, loadCatalogs } from '../bridge/runtime.js';
|
|
@@ -37,6 +45,7 @@ const RESYNC_RETRY_MS = 60_000;
|
|
|
37
45
|
const USAGE_REFRESH_MS = 5 * 60 * 1000;
|
|
38
46
|
/** 查不了用量的来源多久重新实测一次 */
|
|
39
47
|
const PROBE_MS = 6 * 60 * 60 * 1000;
|
|
48
|
+
const log_ = (m) => log(m);
|
|
40
49
|
const log = (m) => console.error(`[${new Date().toISOString()}] ${m}`);
|
|
41
50
|
export class Daemon {
|
|
42
51
|
ctx;
|
|
@@ -622,6 +631,51 @@ export class Daemon {
|
|
|
622
631
|
.filter((k) => !k.revokedAt)
|
|
623
632
|
.map((k) => ({ name: k.name, key: redactKey(k.key), createdAt: k.createdAt, ...(snap.byKey[k.name] ? { stats: snap.byKey[k.name] } : {}) }));
|
|
624
633
|
}
|
|
634
|
+
// —— 更新 / 卸载(管理页的按钮)——
|
|
635
|
+
selfJob;
|
|
636
|
+
/** 版本与能不能在页面上自管理(VPS 上要 root,只给命令) */
|
|
637
|
+
async selfInfo() {
|
|
638
|
+
const vps = this.ctx.mode === 'vps';
|
|
639
|
+
let log = '';
|
|
640
|
+
if (this.selfJob)
|
|
641
|
+
log = await readFile(this.selfJob.log, 'utf8').catch(() => '');
|
|
642
|
+
return {
|
|
643
|
+
version: pkgVersion(),
|
|
644
|
+
latest: await latestVersion(),
|
|
645
|
+
canManage: !vps,
|
|
646
|
+
...(this.selfJob ? { job: { action: this.selfJob.action, startedAt: this.selfJob.startedAt, log: log.replace(/\x1b\[[0-9;]*m/g, '').slice(-6000) } } : {}),
|
|
647
|
+
commands: vps
|
|
648
|
+
? { update: 'sudo npm install -g dsh-model@latest && sudo dsh-model setup', uninstall: 'sudo dsh-model uninstall' }
|
|
649
|
+
: { update: 'dsh-model update', uninstall: 'dsh-model uninstall' },
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* 在独立进程里跑 update / uninstall:两者都会重启或移除守护进程本身,所以不能在守护进程里做。
|
|
654
|
+
* 用 detached 让它脱离守护进程的进程组(launchd 重启服务时不会被一起杀掉)。
|
|
655
|
+
*/
|
|
656
|
+
async startSelfJob(action, opts = {}) {
|
|
657
|
+
if (this.ctx.mode === 'vps')
|
|
658
|
+
throw new DshModelError('needs_root', L('服务器上要 root 权限,请在终端里执行命令', 'On the server this needs root; run the command in a terminal'));
|
|
659
|
+
// 卸载会删掉 home,日志放在外面
|
|
660
|
+
const log = action === 'uninstall' ? join(homedir(), 'dsh-model-uninstall.log') : join(this.ctx.paths.home, 'update.log');
|
|
661
|
+
const args = action === 'update' ? ['update'] : ['uninstall', '--yes', '--remove-plugin', ...(opts.keepAuth ? ['--keep-auth'] : [])];
|
|
662
|
+
const out = openSync(log, 'w', 0o600);
|
|
663
|
+
const cmd = [process.execPath, join(PKG_ROOT, 'bin', 'dsh-model.js'), ...args, '--lang', lang()];
|
|
664
|
+
// Linux 的 systemd --user 重启服务会杀掉整个 cgroup:交给 systemd-run 另起一个
|
|
665
|
+
const useSystemdRun = this.ctx.platform === 'linux' && (await which('systemd-run'));
|
|
666
|
+
const keepEnv = ['PATH', 'HOME', 'LANG', 'DSH_HOME', 'DSH_MODEL_HOME', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY'].filter((k) => process.env[k]).map((k) => `--setenv=${k}=${process.env[k]}`);
|
|
667
|
+
const viaSystemd = ['--user', '--collect', '--quiet', ...keepEnv, '--setenv=NO_COLOR=1', '/bin/sh', '-c', 'log="$1"; shift; exec "$@" >"$log" 2>&1', 'sh', log, ...cmd];
|
|
668
|
+
const child = spawn(useSystemdRun ? 'systemd-run' : cmd[0], useSystemdRun ? viaSystemd : cmd.slice(1), {
|
|
669
|
+
detached: true,
|
|
670
|
+
stdio: ['ignore', out, out],
|
|
671
|
+
env: { ...process.env, NO_COLOR: '1', NODE_NO_WARNINGS: '1', ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) },
|
|
672
|
+
});
|
|
673
|
+
child.unref();
|
|
674
|
+
closeSync(out);
|
|
675
|
+
this.selfJob = { action, startedAt: new Date().toISOString(), log };
|
|
676
|
+
log_(`daemon: started ${action} (pid ${child.pid})`);
|
|
677
|
+
return { started: true, log };
|
|
678
|
+
}
|
|
625
679
|
/** 这个来源的全部模型,以及哪些显示在 dsh 里 */
|
|
626
680
|
async sourceModels(id) {
|
|
627
681
|
const def = mustSource(id);
|
|
@@ -758,6 +812,12 @@ export class Daemon {
|
|
|
758
812
|
return ok(this.statsSnapshot());
|
|
759
813
|
if (method === 'GET' && path === '/endpoints')
|
|
760
814
|
return ok(await this.endpoints());
|
|
815
|
+
if (method === 'GET' && path === '/self')
|
|
816
|
+
return ok(await this.selfInfo());
|
|
817
|
+
if (method === 'POST' && path === '/self/update')
|
|
818
|
+
return ok(await this.startSelfJob('update'));
|
|
819
|
+
if (method === 'POST' && path === '/self/uninstall')
|
|
820
|
+
return ok(await this.startSelfJob('uninstall', { keepAuth: b.keepAuth === true }));
|
|
761
821
|
if (method === 'GET' && path === '/usage')
|
|
762
822
|
return ok(await this.usage(query.get('refresh') === '1'));
|
|
763
823
|
return fail(404, 'not_found', `${method} ${path}`);
|
package/dist/dsh/cli.js
CHANGED
|
@@ -45,10 +45,28 @@ function wrap(ctx, bin, args, proxy) {
|
|
|
45
45
|
}
|
|
46
46
|
return { cmd: bin, args };
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* 这个 profile 的 node_modules 当初链接的 pnpm 仓库:沿用它。
|
|
50
|
+
* 从终端调 dsh 和 dsh 桌面版自己用的仓库位置可能不同,不沿用就报 ERR_PNPM_UNEXPECTED_STORE(Mac 上实测)
|
|
51
|
+
*/
|
|
52
|
+
async function profileStoreDir(ctx, profile) {
|
|
53
|
+
try {
|
|
54
|
+
const text = await readFile(join(ctx.dshHome, 'profiles', profile, 'node_modules', '.modules.yaml'), 'utf8');
|
|
55
|
+
const dir = /"?storeDir"?\s*:\s*"?([^"\n,]+)"?/.exec(text)?.[1]?.trim();
|
|
56
|
+
if (!dir)
|
|
57
|
+
return null;
|
|
58
|
+
// 记录的是带版本的子目录(…/store/v11),store-dir 要它的上一级
|
|
59
|
+
return dir.replace(/\/v\d+$/, '');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
48
65
|
/** dsh plugin --profile <p> <pnpm-args...>,输出直接给用户看(pnpm 进度) */
|
|
49
66
|
export async function dshPlugin(ctx, profile, pnpmArgs, proxy) {
|
|
50
67
|
const bin = await locateDshCli(ctx);
|
|
51
|
-
const
|
|
68
|
+
const store = await profileStoreDir(ctx, profile);
|
|
69
|
+
const w = wrap(ctx, bin, ['plugin', '--profile', profile, ...pnpmArgs, ...(store ? [`--config.store-dir=${store}`] : [])], proxy);
|
|
52
70
|
return runInherit(w.cmd, w.args, { env: dshEnv(ctx, proxy) });
|
|
53
71
|
}
|
|
54
72
|
/** profile 的 package.json 里有没有这个依赖(有就是用户或 dsh 已经装了) */
|
|
@@ -1,35 +1,82 @@
|
|
|
1
1
|
// integrations/dshplugin.ts — 把 dsh-model 自己装进 dsh 当插件(设置 → dsh-model 页面,设计 §14.5)
|
|
2
2
|
//
|
|
3
|
-
// 用 dsh 自己的插件管理:dsh plugin --profile <p> add
|
|
4
|
-
//
|
|
3
|
+
// 用 dsh 自己的插件管理:dsh plugin --profile <p> add dsh-model@<本版本>,和插件市场装的是同一个 npm 包。
|
|
4
|
+
// 早先用 link: 指到 npm 全局目录:VPS 上那个目录归 root,之后从插件市场装就会 EACCES(实测),所以改掉。
|
|
5
|
+
// 用户自己从市场 / GitHub 装的(依赖写法不是我们的)就不动它。卸载时只移除 dsh-model 装的那份。
|
|
6
|
+
import { lstat, realpath, unlink } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
5
8
|
import { dshPlugin, profileHasDependency } from '../dsh/cli.js';
|
|
6
9
|
import { resolveProfile } from '../dsh/locate.js';
|
|
7
10
|
import { DshModelError } from '../errors.js';
|
|
8
11
|
import { L } from '../i18n.js';
|
|
9
|
-
import {
|
|
12
|
+
import { pkgVersion } from '../util/pkg.js';
|
|
10
13
|
import { ok, skip } from '../util/output.js';
|
|
11
14
|
export const PLUGIN_NAME = 'dsh-model';
|
|
12
|
-
|
|
15
|
+
/** 插件在 profile 里的实际目录(pnpm 的真实路径) */
|
|
16
|
+
export async function pluginRoot(ctx, profile) {
|
|
17
|
+
try {
|
|
18
|
+
return await realpath(join(ctx.dshHome, 'profiles', await resolveProfile(ctx, profile), 'node_modules', PLUGIN_NAME));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const NPM_PACKAGE_URL = `https://registry.npmjs.org/${PLUGIN_NAME}`;
|
|
25
|
+
const GITHUB_SPEC = 'https://github.com/AIcivilization/dsh-model/archive/refs/heads/main.tar.gz';
|
|
26
|
+
/** 我们自己写进 profile 的依赖:link:(旧版)、精确版本号、GitHub 主干包 */
|
|
27
|
+
function isOurSpec(spec) {
|
|
28
|
+
return spec.startsWith('link:') || /^\d+\.\d+\.\d+(-[\w.]+)?$/.test(spec) || spec === GITHUB_SPEC;
|
|
29
|
+
}
|
|
30
|
+
/** 本版本发到 npm 了就用 npm(和插件市场同一个包),否则(开发版)用 GitHub 主干 */
|
|
31
|
+
async function desiredSpec() {
|
|
32
|
+
const v = pkgVersion();
|
|
33
|
+
try {
|
|
34
|
+
const r = await fetch(`${NPM_PACKAGE_URL}/${v}`, { signal: AbortSignal.timeout(10_000) });
|
|
35
|
+
if (r.ok)
|
|
36
|
+
return { spec: v, install: `${PLUGIN_NAME}@${v}` };
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// 网络不通:退到 GitHub
|
|
40
|
+
}
|
|
41
|
+
return { spec: GITHUB_SPEC, install: GITHUB_SPEC };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* opts.version:装这个 npm 版本(更新时用);opts.replaceUser:用户自己从市场装的也换成它(管理页点「更新」时)
|
|
45
|
+
*/
|
|
46
|
+
export async function installDshPlugin(ctx, all, opts = {}) {
|
|
13
47
|
const profile = await resolveProfile(ctx, all.config.dsh.profile);
|
|
14
48
|
const existing = await profileHasDependency(ctx, profile, PLUGIN_NAME);
|
|
15
|
-
if (existing) {
|
|
49
|
+
if (existing && !isOurSpec(existing) && !opts.replaceUser) {
|
|
50
|
+
skip(L(`dsh 插件已由你自己安装(${existing}),不改动`, `dsh plugin was installed by you (${existing}); left as is`));
|
|
51
|
+
all.state.plugins = [...(all.state.plugins ?? []).filter((p) => p.name !== PLUGIN_NAME), { name: PLUGIN_NAME, version: existing, installedByUs: false, installedAt: new Date().toISOString() }];
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const want = opts.version ? { spec: opts.version, install: `${PLUGIN_NAME}@${opts.version}` } : await desiredSpec();
|
|
55
|
+
if (existing === want.spec) {
|
|
16
56
|
skip(L(`dsh 插件已安装(${existing})`, `dsh plugin already installed (${existing})`));
|
|
17
|
-
if (!all.state.plugins?.some((p) => p.name === PLUGIN_NAME)) {
|
|
18
|
-
all.state.plugins = [...(all.state.plugins ?? []), { name: PLUGIN_NAME, version: existing, installedByUs: existing.startsWith('link:'), installedAt: new Date().toISOString() }];
|
|
19
|
-
}
|
|
20
57
|
return;
|
|
21
58
|
}
|
|
22
|
-
|
|
59
|
+
// 旧版留下的符号链接(profile 里已没有这个依赖,或者是 link:):先去掉,pnpm 才不会往链接的目标里写
|
|
60
|
+
const linkPath = join(ctx.dshHome, 'profiles', profile, 'node_modules', PLUGIN_NAME);
|
|
61
|
+
try {
|
|
62
|
+
if ((await lstat(linkPath)).isSymbolicLink())
|
|
63
|
+
await unlink(linkPath);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// 不存在
|
|
67
|
+
}
|
|
68
|
+
const code = await dshPlugin(ctx, profile, ['add', want.install, '--config.strict-dep-builds=false'], all.config.proxy);
|
|
23
69
|
const installed = await profileHasDependency(ctx, profile, PLUGIN_NAME);
|
|
24
70
|
if (code !== 0 || !installed) {
|
|
25
|
-
throw new DshModelError('plugin_install_failed', L(`把 dsh-model 装进 dsh 失败(退出码 ${code})`, `Failed to install dsh-model into dsh (exit code ${code})`), L('可以在 dsh
|
|
71
|
+
throw new DshModelError('plugin_install_failed', L(`把 dsh-model 装进 dsh 失败(退出码 ${code})`, `Failed to install dsh-model into dsh (exit code ${code})`), L('可以在 dsh 的插件市场搜索 dsh-model 安装', 'You can install it from the dsh plugin marketplace (search dsh-model)'));
|
|
26
72
|
}
|
|
27
73
|
all.state.plugins = [...(all.state.plugins ?? []).filter((p) => p.name !== PLUGIN_NAME), { name: PLUGIN_NAME, version: installed, installedByUs: true, installedAt: new Date().toISOString() }];
|
|
28
|
-
ok(L(
|
|
74
|
+
ok(L(`dsh 插件已安装(${installed}):重启一次 dsh,在「设置 → dsh-model」里管理来源、用量和 key`, `dsh plugin installed (${installed}): restart dsh once, then manage sources, usage and keys in Settings → dsh-model`));
|
|
29
75
|
}
|
|
30
|
-
|
|
76
|
+
/** 只移除我们装的那份;force(管理页点「卸载」)时用户自己从市场装的也移除 */
|
|
77
|
+
export async function removeDshPlugin(ctx, all, opts = {}) {
|
|
31
78
|
const rec = all.state.plugins?.find((p) => p.name === PLUGIN_NAME);
|
|
32
|
-
if (!rec?.installedByUs)
|
|
79
|
+
if (!rec?.installedByUs && !opts.force)
|
|
33
80
|
return false;
|
|
34
81
|
const profile = await resolveProfile(ctx, all.config.dsh.profile);
|
|
35
82
|
if (await profileHasDependency(ctx, profile, PLUGIN_NAME)) {
|
package/dist/service/index.js
CHANGED
|
@@ -25,6 +25,8 @@ function bridgeProgram() {
|
|
|
25
25
|
export function bridgeEnv(ctx, proxy) {
|
|
26
26
|
return {
|
|
27
27
|
DSH_MODEL_HOME: ctx.paths.home,
|
|
28
|
+
// 从 dsh 桌面版的管理页一键安装时,process.execPath 是 DeepSeek Harness(Electron):服务里也要让它当 Node 跑
|
|
29
|
+
...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
|
|
28
30
|
...(proxy ? { NODE_USE_ENV_PROXY: '1', HTTPS_PROXY: proxy, HTTP_PROXY: proxy, NO_PROXY: '127.0.0.1,localhost,::1,.tencent.com,.codebuddy.cn,.workbuddy.cn,.qq.com' } : {}),
|
|
29
31
|
};
|
|
30
32
|
}
|
package/dist/state.js
CHANGED
|
@@ -67,7 +67,7 @@ export async function withLock(ctx, fn) {
|
|
|
67
67
|
throw error;
|
|
68
68
|
const pid = Number((await readFile(ctx.paths.lock, 'utf8').catch(() => '')).trim());
|
|
69
69
|
if (pid && pid !== process.pid && isAlive(pid)) {
|
|
70
|
-
throw new DshModelError('locked', L(`另一个 dsh-model 命令正在运行(pid ${pid}
|
|
70
|
+
throw new DshModelError('locked', L(`另一个 dsh-model 命令正在运行(pid ${pid}),等它结束再试`, `Another dsh-model command is running (pid ${pid}); try again when it finishes`), L('如果是终端里的命令停在等你输入,回到终端完成它', 'If a command in a terminal is waiting for your input, finish it there'));
|
|
71
71
|
}
|
|
72
72
|
await rm(ctx.paths.lock, { force: true });
|
|
73
73
|
await acquire();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-model",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Many model sources behind one authenticated OpenAI-compatible endpoint for DeepSeek Harness (dsh) and your tools: WorkBuddy, OpenCode Zen and your CLI subscriptions (Codex, Kimi…), with a management page inside dsh. 多家模型汇成一个 OpenAI 兼容地址,dsh 和其他软件都能直接用。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
package/plugin/client.js
CHANGED
|
@@ -33,7 +33,7 @@ window.__ModuleLoader__.load({
|
|
|
33
33
|
method: 'POST',
|
|
34
34
|
credentials: 'same-origin',
|
|
35
35
|
headers: { 'content-type': 'application/json', 'x-dsh-model-token': token },
|
|
36
|
-
body: JSON.stringify({ method, path, ...(body !== undefined ? { body } : {}) }),
|
|
36
|
+
body: JSON.stringify({ method, path, lang: isZh() ? 'zh' : 'en', ...(body !== undefined ? { body } : {}) }),
|
|
37
37
|
})
|
|
38
38
|
const j = await r.json().catch(() => ({}))
|
|
39
39
|
if (r.status === 403 && !retried && j?.error?.code === 'refused') {
|
|
@@ -327,6 +327,8 @@ window.__ModuleLoader__.load({
|
|
|
327
327
|
}, (e) => alive && setMsg(e.message))
|
|
328
328
|
return () => { alive = false }
|
|
329
329
|
}, [source.id])
|
|
330
|
+
if (selfJob) return h(SelfProgress, { job: selfJob, onDone: () => { setSelfJob(null); void load(true) } })
|
|
331
|
+
if (errCode === 'daemon_not_configured' || errCode === 'daemon_unreachable') return h(Onboarding, { code: errCode, onDone: () => void load(true) })
|
|
330
332
|
if (!data) return h('div', { style: { ...S.meta, paddingLeft: 46, marginTop: 8 } }, msg || L('加载中…', 'Loading…'))
|
|
331
333
|
const flip = (id) => setSel((x) => { const n = new Set(x); n.has(id) ? n.delete(id) : n.add(id); return n })
|
|
332
334
|
const save = async (models) => {
|
|
@@ -499,19 +501,155 @@ window.__ModuleLoader__.load({
|
|
|
499
501
|
h('td', { style: S.td }, v.d1.tokensPerSec ?? '-')))))))
|
|
500
502
|
}
|
|
501
503
|
|
|
504
|
+
/** 守护进程还没装 / 没在运行:说明 + 一键安装(本机)或命令(VPS 上要 root) */
|
|
505
|
+
function Onboarding({ code, onDone }) {
|
|
506
|
+
const [busy, setBusy] = useState(false)
|
|
507
|
+
const [res, setRes] = useState(null)
|
|
508
|
+
const notInstalled = code === 'daemon_not_configured'
|
|
509
|
+
const action = notInstalled ? 'setup' : 'repair'
|
|
510
|
+
const run = async () => {
|
|
511
|
+
setBusy(true)
|
|
512
|
+
setRes(null)
|
|
513
|
+
try {
|
|
514
|
+
const r = await fetch('/api-dsh-model/setup', {
|
|
515
|
+
method: 'POST',
|
|
516
|
+
credentials: 'same-origin',
|
|
517
|
+
headers: { 'content-type': 'application/json', 'x-dsh-model-token': token },
|
|
518
|
+
body: JSON.stringify({ action, lang: isZh() ? 'zh' : 'en' }),
|
|
519
|
+
})
|
|
520
|
+
const j = await r.json().catch(() => ({}))
|
|
521
|
+
if (r.status === 403 && j?.error?.code === 'refused') { await refreshToken(); setBusy(false); return run() }
|
|
522
|
+
setRes(j)
|
|
523
|
+
if (j.code === 0) setTimeout(onDone, 1500)
|
|
524
|
+
} catch (e) {
|
|
525
|
+
setRes({ code: -1, output: e.message })
|
|
526
|
+
} finally {
|
|
527
|
+
setBusy(false)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const cmds = notInstalled ? ['npm install -g dsh-model', 'dsh-model setup'] : ['dsh-model repair']
|
|
531
|
+
const vpsCmds = notInstalled ? ['sudo npm install -g dsh-model', 'sudo dsh-model setup'] : ['sudo dsh-model repair']
|
|
532
|
+
const cmdBlock = (list) => h('div', { style: { ...S.card, padding: '8px 12px', marginTop: 8 } }, list.map((c) => h('div', { key: c, style: { ...S.line, minHeight: 30 } }, h('code', { style: { ...S.mono, ...S.grow } }, c), h(CopyButton, { get: c }))))
|
|
533
|
+
return h('div', { style: { color: C.text, maxWidth: 820 } },
|
|
534
|
+
h('div', { style: S.section },
|
|
535
|
+
h('div', { style: S.h }, notInstalled ? L('还差一步:在这台机器上装好 dsh-model', 'One more step: set up dsh-model on this machine') : L('dsh-model 的服务没在运行', 'The dsh-model service is not running')),
|
|
536
|
+
h('p', { style: S.note }, notInstalled
|
|
537
|
+
? L('这个页面只是管理界面;模型由 dsh-model 在本机运行的服务提供(统一端点、WorkBuddy bridge、登录与用量)。装好后,来源、key 和模型都在这里管理。', 'This page is only the control panel; models are served by dsh-model\'s local service (the unified endpoint, WorkBuddy bridge, sign-ins and usage). Once it is set up, manage sources, keys and models here.')
|
|
538
|
+
: L('可能是电脑重启后服务没起来,或者刚升级。修复会按记录重新注册并启动服务。', 'The service may not have started after a reboot, or was just upgraded. Repair re-registers and starts it from the saved records.')),
|
|
539
|
+
res?.vps ? h('div', null,
|
|
540
|
+
h('p', { style: { ...S.note, color: C.text } }, L('这是 dsh-vps 服务器:安装要 root 权限,请在服务器终端里执行:', 'This is a dsh-vps server: setup needs root, so run this in a server terminal:')),
|
|
541
|
+
cmdBlock(vpsCmds),
|
|
542
|
+
h('p', { style: { ...S.note, marginTop: 8 } }, L('装好后刷新本页。', 'Reload this page when done.'))) : h('div', null,
|
|
543
|
+
h('div', { style: S.line },
|
|
544
|
+
h('button', { type: 'button', style: S.btnPrimary, disabled: busy, onClick: run }, busy ? (notInstalled ? L('安装中…(约 1 分钟)', 'Setting up… (about a minute)') : L('修复中…', 'Repairing…')) : (notInstalled ? L('一键安装', 'Set up now') : L('修复', 'Repair'))),
|
|
545
|
+
h('button', { type: 'button', style: S.btn, onClick: onDone }, L('重新检测', 'Check again'))),
|
|
546
|
+
h('p', { style: { ...S.note, marginTop: 10 } }, L('也可以在终端里执行:', 'Or run in a terminal:')),
|
|
547
|
+
cmdBlock(cmds)),
|
|
548
|
+
res && !res.vps ? h('div', { style: { marginTop: 12 } },
|
|
549
|
+
h('div', { style: { fontSize: 12, color: res.code === 0 ? C.ok : C.err, marginBottom: 6 } }, res.code === 0 ? L('完成,正在加载…', 'Done, loading…') : L(`没有完成(退出码 ${res.code}),输出如下:`, `Did not finish (exit code ${res.code}); output:`)),
|
|
550
|
+
h('pre', { style: { ...S.mono, ...S.card, padding: 10, maxHeight: 260, overflow: 'auto', whiteSpace: 'pre-wrap', margin: 0 } }, res.output || '')) : null))
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const newer = (a, b) => {
|
|
554
|
+
const pa = String(a || '').split(/[.-]/).map((x) => parseInt(x, 10) || 0)
|
|
555
|
+
const pb = String(b || '').split(/[.-]/).map((x) => parseInt(x, 10) || 0)
|
|
556
|
+
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0)
|
|
557
|
+
return false
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** 版本、更新、卸载 */
|
|
561
|
+
function AboutCard({ onJob }) {
|
|
562
|
+
const [info, setInfo] = useState(null)
|
|
563
|
+
const [confirming, setConfirming] = useState(false)
|
|
564
|
+
const [keepAuth, setKeepAuth] = useState(false)
|
|
565
|
+
const [err, setErr] = useState('')
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
let alive = true
|
|
568
|
+
api('GET', '/self').then((r) => alive && setInfo(r), () => {})
|
|
569
|
+
return () => { alive = false }
|
|
570
|
+
}, [])
|
|
571
|
+
if (!info) return null
|
|
572
|
+
const hasUpdate = info.latest && newer(info.latest, info.version)
|
|
573
|
+
const start = async (action) => {
|
|
574
|
+
setErr('')
|
|
575
|
+
try {
|
|
576
|
+
await api('POST', `/self/${action}`, action === 'uninstall' ? { keepAuth } : {})
|
|
577
|
+
onJob({ action, from: info.version, target: info.latest })
|
|
578
|
+
} catch (e) {
|
|
579
|
+
setErr(e.message)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return h('div', { style: S.section },
|
|
583
|
+
h('div', { style: S.h }, L('关于', 'About')),
|
|
584
|
+
h('div', { style: { ...S.card, padding: '12px 14px' } },
|
|
585
|
+
h('div', { style: S.line },
|
|
586
|
+
h('span', { style: { ...S.label, ...S.grow } }, `dsh-model ${info.version}`,
|
|
587
|
+
h('span', { style: { ...S.meta, marginLeft: 8 } }, info.latest ? (hasUpdate ? L(`有新版本 ${info.latest}`, `${info.latest} available`) : L('已是最新', 'Up to date')) : L('查不到最新版本', 'Latest version unknown'))),
|
|
588
|
+
info.canManage && hasUpdate ? h('button', { type: 'button', style: S.btnPrimary, onClick: () => void start('update') }, L(`更新到 ${info.latest}`, `Update to ${info.latest}`)) : null,
|
|
589
|
+
info.canManage ? h('button', { type: 'button', style: { ...S.btn, color: C.err }, onClick: () => setConfirming(true) }, L('卸载', 'Uninstall')) : null),
|
|
590
|
+
!info.canManage ? h('div', { style: { marginTop: 8 } },
|
|
591
|
+
h('div', { style: S.meta }, L('服务器上更新和卸载要 root 权限,请在服务器终端里执行:', 'On the server, update and uninstall need root; run in a server terminal:')),
|
|
592
|
+
[[L('更新', 'Update'), info.commands.update], [L('卸载', 'Uninstall'), info.commands.uninstall]].map(([t, c]) => h('div', { key: c, style: { ...S.line, minHeight: 30 } },
|
|
593
|
+
h('span', { style: { ...S.meta, width: 40 } }, t), h('code', { style: { ...S.mono, ...S.grow } }, c), h(CopyButton, { get: c })))) : null,
|
|
594
|
+
confirming ? h('div', { style: { marginTop: 10, padding: 10, border: `1px solid ${C.err}`, borderRadius: 8 } },
|
|
595
|
+
h('div', { style: { fontSize: 13, color: C.text, lineHeight: 1.7 } }, L('卸载会:关闭对外访问 → 移除这个管理页 → 把 dsh 里的模型配置还原 → 停止并删除引擎与守护进程 → 删除 ~/.dsh-model(含各家登录、OpenCode key、WorkBuddy 令牌副本)。', 'Uninstall will: turn off remote access → remove this page → restore dsh\'s model config → stop and delete the engine and daemon → delete ~/.dsh-model (including sign-ins, the OpenCode key and WorkBuddy token copies).')),
|
|
596
|
+
h('label', { style: { ...S.line, fontSize: 13, marginTop: 6, cursor: 'pointer' } },
|
|
597
|
+
h('input', { type: 'checkbox', checked: keepAuth, onChange: (e) => setKeepAuth(e.target.checked) }),
|
|
598
|
+
L('先把各家登录凭据备份到主目录(以后重装可以拷回来)', 'Back up provider sign-ins to the home folder first (to reuse after reinstalling)')),
|
|
599
|
+
h('div', { style: { ...S.line, marginTop: 8 } },
|
|
600
|
+
h('button', { type: 'button', style: { ...S.btnPrimary, background: C.err }, onClick: () => void start('uninstall') }, L('确认卸载', 'Uninstall')),
|
|
601
|
+
h('button', { type: 'button', style: S.btn, onClick: () => setConfirming(false) }, L('取消', 'Cancel')))) : null,
|
|
602
|
+
err ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, err) : null))
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** 更新 / 卸载进行中:守护进程会重启或消失,期间不显示别的 */
|
|
606
|
+
function SelfProgress({ job, onDone }) {
|
|
607
|
+
const [state, setState] = useState({ phase: 'running', log: '' })
|
|
608
|
+
useEffect(() => {
|
|
609
|
+
let stop = false
|
|
610
|
+
const tick = async () => {
|
|
611
|
+
if (stop) return
|
|
612
|
+
try {
|
|
613
|
+
const r = await api('GET', '/self')
|
|
614
|
+
if (r.job?.log) setState((s) => ({ ...s, log: r.job.log }))
|
|
615
|
+
if (job.action === 'update' && r.version !== job.from) { setState({ phase: 'done', log: r.job?.log || '' }); return }
|
|
616
|
+
} catch (e) {
|
|
617
|
+
if (job.action === 'uninstall' && e.code === 'daemon_not_configured') { setState((s) => ({ ...s, phase: 'done' })); return }
|
|
618
|
+
}
|
|
619
|
+
setTimeout(tick, 2000)
|
|
620
|
+
}
|
|
621
|
+
const t = setTimeout(tick, 2000)
|
|
622
|
+
return () => { stop = true; clearTimeout(t) }
|
|
623
|
+
}, [])
|
|
624
|
+
const upd = job.action === 'update'
|
|
625
|
+
const title = state.phase === 'done'
|
|
626
|
+
? (upd ? L('更新完成', 'Update complete') : L('已卸载', 'Uninstalled'))
|
|
627
|
+
: (upd ? L(`正在更新到 ${job.target}…(服务会重启一下)`, `Updating to ${job.target}… (the service restarts briefly)`) : L('正在卸载…', 'Uninstalling…'))
|
|
628
|
+
return h('div', { style: { color: C.text, maxWidth: 820 } },
|
|
629
|
+
h('div', { style: S.section },
|
|
630
|
+
h('div', { style: S.h }, title),
|
|
631
|
+
state.phase === 'done' ? h('p', { style: { ...S.note, color: C.text } }, upd
|
|
632
|
+
? L('完全退出 dsh 再打开一次,管理页的新代码才会生效。', 'Quit dsh completely and open it again so the management page loads the new code.')
|
|
633
|
+
: L('dsh 里的 dsh-model 模型已移除。完全退出 dsh 再打开,这一页就会消失。日志在主目录的 dsh-model-uninstall.log。', 'dsh-model\'s models are gone from dsh. Quit dsh completely and reopen it and this page disappears. The log is ~/dsh-model-uninstall.log.')) : null,
|
|
634
|
+
state.log ? h('pre', { style: { ...S.mono, ...S.card, padding: 10, maxHeight: 300, overflow: 'auto', whiteSpace: 'pre-wrap', margin: 0 } }, state.log) : null,
|
|
635
|
+
state.phase === 'done' && upd ? h('button', { type: 'button', style: { ...S.btn, marginTop: 10 }, onClick: onDone }, L('返回', 'Back')) : null))
|
|
636
|
+
}
|
|
637
|
+
|
|
502
638
|
// —— 整页 ——
|
|
503
639
|
function SettingsSection() {
|
|
504
640
|
const [data, setData] = useState(null)
|
|
505
641
|
const [err, setErr] = useState('')
|
|
642
|
+
const [errCode, setErrCode] = useState('')
|
|
643
|
+
const [selfJob, setSelfJob] = useState(null)
|
|
506
644
|
const [login, setLogin] = useState(null)
|
|
507
645
|
const [keyDialog, setKeyDialog] = useState(false)
|
|
508
646
|
const alive = useRef(true)
|
|
509
647
|
const load = useCallback(async (refresh) => {
|
|
510
648
|
try {
|
|
511
649
|
const [sources, keys, stats, endpoints] = await Promise.all([api('GET', refresh ? '/sources?refresh=1' : '/sources'), api('GET', '/keys'), api('GET', '/stats'), api('GET', '/endpoints').catch(() => null)])
|
|
512
|
-
if (alive.current) { setData({ sources, keys, stats, endpoints }); setErr('') }
|
|
650
|
+
if (alive.current) { setData({ sources, keys, stats, endpoints }); setErr(''); setErrCode('') }
|
|
513
651
|
} catch (e) {
|
|
514
|
-
if (alive.current) setErr(e.hint ? `${e.message}(${e.hint})` : e.message)
|
|
652
|
+
if (alive.current) { setErr(e.hint ? `${e.message}(${e.hint})` : e.message); setErrCode(e.code || '') }
|
|
515
653
|
}
|
|
516
654
|
}, [])
|
|
517
655
|
useEffect(() => {
|
|
@@ -527,6 +665,7 @@ window.__ModuleLoader__.load({
|
|
|
527
665
|
h(SourcesCard, { sources: data.sources, reload: () => load(false), onLogin: (s, session) => setLogin({ source: s, session }), onKey: () => setKeyDialog(true) }),
|
|
528
666
|
h(KeysCard, { keys: data.keys, reload: () => load(false) }),
|
|
529
667
|
h(ModelsCard, { stats: data.stats }),
|
|
668
|
+
h(AboutCard, { onJob: setSelfJob }),
|
|
530
669
|
login ? h(LoginDialog, { source: login.source, session: login.session, onDone: () => { setLogin(null); void load(true) }, onClose: () => { setLogin(null); void load(false) } }) : null,
|
|
531
670
|
keyDialog ? h(KeyDialog, { onDone: () => { setKeyDialog(false); void load(true) }, onClose: () => setKeyDialog(false) }) : null)
|
|
532
671
|
}
|
package/plugin/index.js
CHANGED
|
@@ -7,10 +7,16 @@
|
|
|
7
7
|
// - 只转发白名单里的控制接口。
|
|
8
8
|
// 写法照 dsh-vps-manager(同作者)的 routes.js。
|
|
9
9
|
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
10
11
|
import { randomBytes } from 'node:crypto'
|
|
11
|
-
import { readFile } from 'node:fs/promises'
|
|
12
|
+
import { access, readFile } from 'node:fs/promises'
|
|
12
13
|
import { homedir } from 'node:os'
|
|
13
14
|
import { join } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
/** 本包根目录:插件就是完整的 dsh-model 包,自带命令行 */
|
|
18
|
+
const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
19
|
+
const VPS_ROOT = '/opt/dsh-vps'
|
|
14
20
|
|
|
15
21
|
export const name = 'dsh-model'
|
|
16
22
|
export const inject = []
|
|
@@ -29,6 +35,8 @@ const ALLOWED = [
|
|
|
29
35
|
['POST', /^\/keys\/[A-Za-z0-9._-]{1,40}\/rotate$/],
|
|
30
36
|
['POST', /^\/keys\/[A-Za-z0-9._-]{1,40}\/reveal$/],
|
|
31
37
|
['GET', /^\/endpoints$/],
|
|
38
|
+
['GET', /^\/self$/],
|
|
39
|
+
['POST', /^\/self\/(update|uninstall)$/],
|
|
32
40
|
['GET', /^\/sources\/[a-z-]{2,20}\/models$/],
|
|
33
41
|
['POST', /^\/sources\/[a-z-]{2,20}\/models$/],
|
|
34
42
|
['POST', /^\/sources\/[a-z-]{2,20}\/(enable|disable|logout)$/],
|
|
@@ -87,6 +95,29 @@ function check(req, token, needToken = true) {
|
|
|
87
95
|
return null
|
|
88
96
|
}
|
|
89
97
|
|
|
98
|
+
const exists = (p) => access(p).then(() => true, () => false)
|
|
99
|
+
|
|
100
|
+
/** 从页面一键执行 setup / repair(只在本机模式:VPS 上要 root,页面只给命令)。同一时间只跑一个 */
|
|
101
|
+
let running = null
|
|
102
|
+
function runCli(args) {
|
|
103
|
+
if (running) return running
|
|
104
|
+
running = new Promise((resolve) => {
|
|
105
|
+
let out = ''
|
|
106
|
+
const child = spawn(process.execPath, [join(PKG_ROOT, 'bin/dsh-model.js'), ...args], {
|
|
107
|
+
// dsh 桌面版可能跑在 Electron 里:让它当普通 Node 用
|
|
108
|
+
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', NO_COLOR: '1', FORCE_COLOR: '0' },
|
|
109
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
110
|
+
})
|
|
111
|
+
const add = (b) => { out = (out + b.toString()).slice(-20000) }
|
|
112
|
+
child.stdout.on('data', add)
|
|
113
|
+
child.stderr.on('data', add)
|
|
114
|
+
const timer = setTimeout(() => child.kill('SIGTERM'), 10 * 60 * 1000)
|
|
115
|
+
child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, output: `${out}\n${e.message}` }) })
|
|
116
|
+
child.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, output: out.replace(/\x1b\[[0-9;]*m/g, '') }) })
|
|
117
|
+
}).finally(() => { running = null })
|
|
118
|
+
return running
|
|
119
|
+
}
|
|
120
|
+
|
|
90
121
|
export function apply(ctx) {
|
|
91
122
|
const token = randomBytes(24).toString('hex')
|
|
92
123
|
ctx.inject(['webServer'], (webCtx) => {
|
|
@@ -122,7 +153,7 @@ export function apply(ctx) {
|
|
|
122
153
|
try {
|
|
123
154
|
const r = await fetch(`http://127.0.0.1:${cfg.port}/control${path}`, {
|
|
124
155
|
method,
|
|
125
|
-
headers: { Authorization: `Bearer ${cfg.secret}`, ...(call.body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
156
|
+
headers: { Authorization: `Bearer ${cfg.secret}`, 'x-dsh-model-lang': call.lang === 'en' ? 'en' : 'zh', ...(call.body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
126
157
|
body: call.body !== undefined && method !== 'GET' ? JSON.stringify(call.body) : undefined,
|
|
127
158
|
signal: AbortSignal.timeout(90_000),
|
|
128
159
|
})
|
|
@@ -136,6 +167,29 @@ export function apply(ctx) {
|
|
|
136
167
|
}),
|
|
137
168
|
)
|
|
138
169
|
|
|
170
|
+
// 页面上的「一键安装 / 修复」
|
|
171
|
+
track(
|
|
172
|
+
ws.register({
|
|
173
|
+
kind: 'exact',
|
|
174
|
+
path: '/api-dsh-model/setup',
|
|
175
|
+
handler: async (req, res) => {
|
|
176
|
+
const bad = check(req, token)
|
|
177
|
+
if (bad) return json(res, bad.code, { error: { code: 'refused', message: bad.error } })
|
|
178
|
+
let body
|
|
179
|
+
try {
|
|
180
|
+
body = JSON.parse((await readBody(req)) || '{}')
|
|
181
|
+
} catch {
|
|
182
|
+
return json(res, 400, { error: { code: 'invalid_json', message: 'invalid JSON' } })
|
|
183
|
+
}
|
|
184
|
+
if (await exists(VPS_ROOT)) return json(res, 200, { vps: true })
|
|
185
|
+
const action = body.action === 'repair' ? 'repair' : 'setup'
|
|
186
|
+
const lang = body.lang === 'en' ? 'en' : 'zh'
|
|
187
|
+
const r = await runCli(action === 'repair' ? ['repair', '--lang', lang] : ['setup', '--skip-dsh-plugin', '--lang', lang])
|
|
188
|
+
return json(res, 200, r)
|
|
189
|
+
},
|
|
190
|
+
}),
|
|
191
|
+
)
|
|
192
|
+
|
|
139
193
|
// 令牌对不上时(dsh 重启、插件热更新)页面悄悄来换:只认同源 JSON,并且先过 dsh 自己的登录
|
|
140
194
|
track(
|
|
141
195
|
ws.register({
|