@dshfly/remote-connector 0.2.1 → 0.3.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/core/connector-core.js +70 -2
- package/core/deps-check-cli.js +33 -0
- package/core/deps-check.js +103 -0
- package/core/dsh-web.js +15 -1
- package/core/history-slim.js +59 -0
- package/core/tunnel.js +42 -13
- package/core/version.js +63 -0
- package/dist/client.js +31 -2
- package/dist/client.js.map +2 -2
- package/http-api.js +75 -3
- package/index.js +5 -1
- package/mobile-bridge/core/bridge-core.js +7 -5
- package/mobile-bridge/core/files.js +22 -1
- package/mobile-bridge/core/git-status.js +121 -0
- package/mobile-bridge/index.js +1 -0
- package/package.json +5 -4
package/core/connector-core.js
CHANGED
|
@@ -21,6 +21,22 @@ import { deriveSessionKey, pairFingerprint, pairShortCode, signPairingConfirm, s
|
|
|
21
21
|
// 解析信封 → bridge.handleRpc → 组 server-response 回包。bridge 未接入时返回 null(回落反代)。
|
|
22
22
|
const MOBILE_PREFIX = '/api/mobile.';
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* 本 connector 插件向手机端声明的能力清单(三端兼容 / 能力协商)。
|
|
26
|
+
*
|
|
27
|
+
* 背景(docs/compatibility.md G5、docs/session-archive-plan.md §12):App 与插件**独立升级**,
|
|
28
|
+
* "新 App × 旧插件"是常态。新功能若不可探测,App 只能靠"调一次看报不报错"来猜——用户会看到
|
|
29
|
+
* 一个永远不会成功的按钮。因此:**新增手机端能力时,在这里加一个字符串**,App 侧用
|
|
30
|
+
* `features.includes(...)` 决定入口显隐(fail-closed:字段缺失/不含即视为不支持)。
|
|
31
|
+
*
|
|
32
|
+
* 现有能力:
|
|
33
|
+
* · `session-archive` —— `workspace.archiveSession`(会话归档,2026-09)
|
|
34
|
+
* · `history-slim` —— 历史响应已剥 chunk 家族 + 大响应已 deflate(2026-09,0.3.0)。
|
|
35
|
+
* **注意它门控的是"页大小"而非入口**:App 有该能力 → 上翻 50 / 跳转 200;
|
|
36
|
+
* 无(旧插件)→ 自动降到 20 / 60,避免一页 ~10.9MB 过中继(docs/turn-navigation-plan.md §8.2)。
|
|
37
|
+
*/
|
|
38
|
+
const CONNECTOR_FEATURES = ['session-archive', 'history-slim'];
|
|
39
|
+
|
|
24
40
|
/** PC 计算机名(去 .local 后缀;2026-08:手机端"我"页显示可读 PC 名)。 */
|
|
25
41
|
function hostnameClean() {
|
|
26
42
|
return String(os.hostname() || '').replace(/\.local$/, '') || null;
|
|
@@ -126,7 +142,13 @@ export async function handleMobileLocal(bridge, inner, { fullDisk = true, resolv
|
|
|
126
142
|
}
|
|
127
143
|
|
|
128
144
|
export class ConnectorCore {
|
|
129
|
-
constructor({ relayUrl, deviceName, keysFile, target, controlPath = '/control', dshAdapter = null }) {
|
|
145
|
+
constructor({ relayUrl, deviceName, keysFile, target, controlPath = '/control', dshAdapter = null, featureProbe = true }) {
|
|
146
|
+
/**
|
|
147
|
+
* 是否暴露能力协商路由(`mobile.getFeatures`)。**仅用于测试**:置 false 可精确模拟
|
|
148
|
+
* "旧版插件"(能力协商引入之前的 connector)——因为路由由本插件核心代码直接处理,
|
|
149
|
+
* 不受 dshAdapter/gateway 影响,没有这个开关就无法回归"旧插件 × 新 App"路径。
|
|
150
|
+
*/
|
|
151
|
+
this.featureProbe = featureProbe !== false;
|
|
130
152
|
this.keys = loadOrCreateKeys(keysFile);
|
|
131
153
|
// 2026-09:DSH web 0.1.2 起 /api 需要 browser-session cookie 且协议结构性改动(斜杠端点、
|
|
132
154
|
// {args} 包装、事件走 remote.mux、/api/respond 移除)。connector 同进程 —— 改为进程内
|
|
@@ -232,6 +254,11 @@ export class ConnectorCore {
|
|
|
232
254
|
devices,
|
|
233
255
|
tokenState: tok.state, // device token 健康(2026-08 自愈)
|
|
234
256
|
deviceTokenExpiringAt: tok.expiringAt,
|
|
257
|
+
// 三端兼容(docs/compatibility.md / session-archive-plan §12):本插件支持的手机端能力。
|
|
258
|
+
// ⚠️ 手机 App 与 connector 插件是**独立升级**的(App 走 Expo/商店,插件走 dsh plugin add),
|
|
259
|
+
// 所以"新 App × 旧插件"是随时会发生的常态——新功能必须能被探测,否则 App 只能靠猜。
|
|
260
|
+
// App 侧契约:**只有明确读到 true 才显示入口**(fail-closed);字段缺失 = 旧插件 = 不支持。
|
|
261
|
+
features: CONNECTOR_FEATURES.slice(),
|
|
235
262
|
};
|
|
236
263
|
}
|
|
237
264
|
|
|
@@ -525,7 +552,48 @@ export class ConnectorCore {
|
|
|
525
552
|
result: { ok: true, value: { name: this.keys.deviceNames?.[phonePk] || null } },
|
|
526
553
|
});
|
|
527
554
|
}
|
|
528
|
-
|
|
555
|
+
// 能力协商(2026-09,docs/session-archive-plan.md §12):手机 App 探测本插件支持哪些
|
|
556
|
+
// 手机端能力。**旧插件没有这条路由** → 手机侧请求会落到通用分支(返 unknown-method)或
|
|
557
|
+
// 被 adapter 判为未知方法 → App 侧据此把新功能入口隐藏(fail-closed),而不是给用户一个
|
|
558
|
+
// 永远点不成功的按钮。响应形状与 /dshfly/status 的 features 一致。
|
|
559
|
+
if (this.featureProbe && inner?.url === '/api/mobile.getFeatures') {
|
|
560
|
+
let req = null;
|
|
561
|
+
try {
|
|
562
|
+
req = JSON.parse(Buffer.from(inner.body || '', 'base64').toString('utf8'));
|
|
563
|
+
} catch {
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
return httpJson({
|
|
567
|
+
type: 'server-response',
|
|
568
|
+
rpcId: req?.rpcId ?? null,
|
|
569
|
+
result: { ok: true, value: { features: CONNECTOR_FEATURES.slice() } },
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
// 未知 `mobile.*` 方法一律在此明确失败(**不回落 HTTP 反代**)。
|
|
573
|
+
// 反代会把 /api/mobile.xxx 当普通 DSH 端点去 fetch target —— 而 mobile.* 是本插件
|
|
574
|
+
// 自己扩展的手机端方法,DSH 从来没有这些端点,结果只是把"方法不存在"变成
|
|
575
|
+
// "fetch failed" 这种无法诊断的传输错误(三端兼容排查时实测踩到)。
|
|
576
|
+
// 落在这里的典型场景:**旧插件 × 新 App**(App 调了新插件才有的 mobile.getFeatures)。
|
|
577
|
+
if (!this.mobileBridge) {
|
|
578
|
+
let req = null;
|
|
579
|
+
try {
|
|
580
|
+
req = JSON.parse(Buffer.from(inner.body || '', 'base64').toString('utf8'));
|
|
581
|
+
} catch {
|
|
582
|
+
req = null;
|
|
583
|
+
}
|
|
584
|
+
return httpJson({
|
|
585
|
+
type: 'server-response',
|
|
586
|
+
rpcId: req?.rpcId ?? null,
|
|
587
|
+
result: {
|
|
588
|
+
ok: false,
|
|
589
|
+
error: {
|
|
590
|
+
code: 'unhandled',
|
|
591
|
+
message: `dshfly: unsupported mobile method ${String(inner?.url || '').replace('/api/', '')}`,
|
|
592
|
+
details: {},
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
}
|
|
529
597
|
return handleMobileLocal(this.mobileBridge, inner, {
|
|
530
598
|
fullDisk: this.getPermission(phonePk).fullDisk,
|
|
531
599
|
resolveRoots: this._roots,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// packages/remote-connector/core/deps-check-cli.js
|
|
2
|
+
// 依赖预检 CLI 入口(发布面把关)。
|
|
3
|
+
//
|
|
4
|
+
// 为什么 CLI 放在 core/ 而不是 scripts/:**`scripts/` 不进 npm 包**(package.json `files` 只有
|
|
5
|
+
// index/config/http-api/core/dist/mobile-bridge),而 npm 的 `prepack` 是在**打包出的 tarball
|
|
6
|
+
// 目录里**执行的——若 prepack 指向 scripts/,tarball 里找不到脚本,发布/安装即失败。
|
|
7
|
+
// core/ 会随包发布,所以 CLI 放这里,`scripts/check-deps.mjs` 只做薄转发(仓库内直跑用)。
|
|
8
|
+
//
|
|
9
|
+
// 用法:
|
|
10
|
+
// node core/deps-check-cli.js # 检查(失败 exit 1 + 修复指引)
|
|
11
|
+
// node core/deps-check-cli.js --json # 机器可读(CI)
|
|
12
|
+
|
|
13
|
+
import { checkDependencies, dependencyFixHint } from './deps-check.js';
|
|
14
|
+
|
|
15
|
+
const asJson = process.argv.includes('--json');
|
|
16
|
+
const result = checkDependencies();
|
|
17
|
+
|
|
18
|
+
if (asJson) {
|
|
19
|
+
console.log(JSON.stringify(result, null, 2));
|
|
20
|
+
} else if (result.ok) {
|
|
21
|
+
console.log(`✅ 插件依赖可解析(${result.checked.length} 个裸导入)`);
|
|
22
|
+
if (result.hostOptional.length) {
|
|
23
|
+
console.log(` ℹ 宿主提供(本机未安装,运行时由 DSH 注入):${result.hostOptional.join(', ')}`);
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
console.error(`❌ ${dependencyFixHint(result.missing)}`);
|
|
27
|
+
if (result.error) console.error(` (解析起点初始化失败:${result.error})`);
|
|
28
|
+
if (result.hostOptional.length) {
|
|
29
|
+
console.error(` ℹ 宿主提供(跳过):${result.hostOptional.join(', ')}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
process.exit(result.ok ? 0 : 1);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// packages/remote-connector/core/deps-check.js
|
|
2
|
+
// 插件依赖可解析性预检(2026-09)。
|
|
3
|
+
//
|
|
4
|
+
// 为什么需要(真机事故):`dsh plugin --profile web add .` 把插件目录以 **link:** 方式装进
|
|
5
|
+
// profile(符号链接到仓库源码),pnpm **不会**为 link: 目标安装它自己的依赖。于是仓库工作区
|
|
6
|
+
// node_modules 不完整时(`pnpm install` 没跑过 / 跑过被过滤安装),插件 apply 阶段
|
|
7
|
+
// `import '@dshfly/crypto'` 抛 ERR_MODULE_NOT_FOUND → DSH fail-loud → **整个 dsh web 起不来**,
|
|
8
|
+
// 且错误只有一行 ERR_MODULE_NOT_FOUND,看不出"去仓库跑 pnpm install"这一步。
|
|
9
|
+
//
|
|
10
|
+
// 这里把扫描/解析抽成纯 Node 函数(无 cordis 依赖),供三处共用:
|
|
11
|
+
// ① `scripts/check-deps.mjs`(CLI / CI);
|
|
12
|
+
// ② `/dshfly/doctor` 端点(设置页把"缺依赖 + 怎么修"直接显示出来);
|
|
13
|
+
// ③ 单测。
|
|
14
|
+
//
|
|
15
|
+
// 注意:本模块必须在**任何可能失败的第三方 import 之前**可用(它自己只用 node: 内置 + 相对路径)。
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
/** 插件包根(本文件位于 <pkg>/core/)。 */
|
|
23
|
+
export const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
|
|
25
|
+
/** 扫描根(插件运行时代码;dist/ 是前端 bundle,test/ 是测试,均不参与)。 */
|
|
26
|
+
const SCAN_ROOTS = ['index.js', 'config.js', 'http-api.js', 'core', 'mobile-bridge'];
|
|
27
|
+
const SCAN_EXT = new Set(['.js', '.mjs', '.cjs']);
|
|
28
|
+
const SKIP_DIRS = new Set(['node_modules', 'test', 'dist']);
|
|
29
|
+
|
|
30
|
+
/** node: 内置模块无需解析。 */
|
|
31
|
+
const BUILTIN_RE = /^node:/;
|
|
32
|
+
/** 宿主(DSH)注入的包:本机没装也不算故障,只在报告里标注。 */
|
|
33
|
+
const HOST_PROVIDED_RE = /^@deepseek-ai\/(cordis|dsh-|schemastery)/;
|
|
34
|
+
|
|
35
|
+
const IMPORT_RE =
|
|
36
|
+
/(?:^|[\s;{}()])(?:import|export)\s[^'"]*?from\s*['"]([^'"]+)['"]|(?:^|[\s;{}()])import\s*\(\s*['"]([^'"]+)['"]\s*\)|require\(\s*['"]([^'"]+)['"]\s*\)/gm;
|
|
37
|
+
|
|
38
|
+
function walk(rel, out = []) {
|
|
39
|
+
const abs = path.join(PLUGIN_ROOT, rel);
|
|
40
|
+
if (!fs.existsSync(abs)) return out;
|
|
41
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
42
|
+
for (const name of fs.readdirSync(abs)) {
|
|
43
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
44
|
+
walk(path.join(rel, name), out);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
if (SCAN_EXT.has(path.extname(abs))) out.push(abs);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 收集运行时代码里的裸导入(去重、排除相对路径与 node: 内置)。 */
|
|
53
|
+
export function collectBareImports() {
|
|
54
|
+
const specs = new Set();
|
|
55
|
+
for (const file of SCAN_ROOTS.flatMap((e) => walk(e))) {
|
|
56
|
+
let src = '';
|
|
57
|
+
try {
|
|
58
|
+
src = fs.readFileSync(file, 'utf8');
|
|
59
|
+
} catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const m of src.matchAll(IMPORT_RE)) {
|
|
63
|
+
const spec = m[1] || m[2] || m[3];
|
|
64
|
+
if (!spec || spec.startsWith('.') || spec.startsWith('/') || BUILTIN_RE.test(spec)) continue;
|
|
65
|
+
specs.add(spec);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [...specs].sort();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 从插件目录解析每个依赖(与运行时 import 同一条解析链:以 core/keys.js 为起点)。
|
|
73
|
+
* @returns {{ ok: boolean, missing: string[], hostOptional: string[], checked: string[], error?: string }}
|
|
74
|
+
*/
|
|
75
|
+
export function checkDependencies() {
|
|
76
|
+
let req;
|
|
77
|
+
try {
|
|
78
|
+
req = createRequire(path.join(PLUGIN_ROOT, 'core', 'keys.js'));
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return { ok: false, missing: [], hostOptional: [], checked: [], error: e?.message || String(e) };
|
|
81
|
+
}
|
|
82
|
+
const checked = collectBareImports();
|
|
83
|
+
const missing = [];
|
|
84
|
+
const hostOptional = [];
|
|
85
|
+
for (const spec of checked) {
|
|
86
|
+
try {
|
|
87
|
+
req.resolve(spec);
|
|
88
|
+
} catch {
|
|
89
|
+
if (HOST_PROVIDED_RE.test(spec)) hostOptional.push(spec);
|
|
90
|
+
else missing.push(spec);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { ok: missing.length === 0, missing, hostOptional, checked };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 缺依赖时的可操作提示(CLI 与设置页共用同一文案,禁止两处漂移)。 */
|
|
97
|
+
export function dependencyFixHint(missing) {
|
|
98
|
+
return (
|
|
99
|
+
`插件依赖未安装(缺少:${missing.join(', ')})。` +
|
|
100
|
+
'本地开发安装(dsh plugin add .)走 link:,pnpm 不会为链接目录安装依赖——' +
|
|
101
|
+
'请在仓库根目录执行 `pnpm install` 后重启 dsh web。'
|
|
102
|
+
);
|
|
103
|
+
}
|
package/core/dsh-web.js
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
// 纯翻译函数(mapInvoke / buildEventFrames / buildApprovalResult)可单测;gateway 与 ctx
|
|
21
21
|
// 依赖注入(测试注入 mock,生产传 Cordis 真服务)。
|
|
22
22
|
|
|
23
|
+
import { stripHistoryEvents } from './history-slim.js';
|
|
24
|
+
|
|
23
25
|
const LEGACY_OK_FIELD = Symbol('ok');
|
|
24
26
|
|
|
25
27
|
/** 生成 requestId(DSH session/prompt 必填字段;手机端未发时由 connector 补一个)。 */
|
|
@@ -93,6 +95,15 @@ export function mapInvoke(legacyMethod, payload) {
|
|
|
93
95
|
|
|
94
96
|
case 'workspace.create': return { endpoint: 'workspace/create', args: req({ path: p.path }) };
|
|
95
97
|
case 'workspace.list': return { synthesizeWorkspaceList: true };
|
|
98
|
+
// 会话归档(docs/session-archive-plan.md):DSH 原生 workspace/archiveSession,参数为单
|
|
99
|
+
// request 对象(与 workspace/create 同族)。**幂等**(已归档再调直接 return 不写盘)、
|
|
100
|
+
// 返回**完整归档集** { archivedSessionIds:[…] }——手机端据此直接覆盖本地归档集,无需等刷新。
|
|
101
|
+
// 归档是显示层过滤(不动工作区账本/不删日志/不搬目录),因此**不纳入 fullDisk 门禁**。
|
|
102
|
+
// 0.1.5-rc.1 起 workspace 命名空间**无 unarchive 方法**(归档单向,见方案 §2.4)。
|
|
103
|
+
case 'workspace.archiveSession': return {
|
|
104
|
+
endpoint: 'workspace/archiveSession',
|
|
105
|
+
args: req({ sessionId: p.sessionId }),
|
|
106
|
+
};
|
|
96
107
|
|
|
97
108
|
case 'agentPreset.list': return { endpoint: 'agentPresets/list', args: {} };
|
|
98
109
|
// agentPresets/select 实测:实参**直接** {agentId, agentPreset}(不包 request;包了报
|
|
@@ -315,7 +326,10 @@ export function createDshAdapter({ gateway, ctx, dshVersion = null, connectorVer
|
|
|
315
326
|
const args = { request: { ...req, throughSeq } };
|
|
316
327
|
const r = await g.invokeRpc('session/page', { args });
|
|
317
328
|
if (!r?.ok) return r;
|
|
318
|
-
|
|
329
|
+
// 历史瘦身(**生产路径**):剥 chunk 家族事件(按消息对齐窗口口径省 62% 字节)。
|
|
330
|
+
// 2026-09 修复:此前只写在 tunnel.js 的 replayInner(旧 HTTP 反代)里,而进程内迁移
|
|
331
|
+
// (9b9e13d)后本函数才是默认路径 → 瘦身成了死代码(见 history-slim.js 头注释)。
|
|
332
|
+
return { ok: true, value: stripHistoryEvents(translateResult('session.history', r.value)) };
|
|
319
333
|
}
|
|
320
334
|
// session.prompt:requestId 必填(实测契约),手机端未发 → 补生成一个(mode:'queue' 已实测有效)
|
|
321
335
|
if (method === 'session.prompt' && mapped.endpoint === 'session/prompt' && !mapped.args.request?.requestId) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// packages/remote-connector/core/history-slim.js
|
|
2
|
+
// 会话历史响应瘦身:剥除手机端**零消费**的流式 chunk 家族事件。
|
|
3
|
+
//
|
|
4
|
+
// 为什么单独成模块:现在有**两条**产生 session.history 响应的路径,都必须瘦身——
|
|
5
|
+
// ① 进程内适配层(`dsh-web.js` 的 `invoke()` → typertGateway)——**生产路径**;
|
|
6
|
+
// ② 旧 HTTP 反代(`tunnel.js` 的 `replayInner`)——仅在适配层缺失时兜底。
|
|
7
|
+
// 历史上瘦身只写在 ② 里,而 2026-09-04 的进程内迁移(9b9e13d)把 ① 变成了默认路径,
|
|
8
|
+
// 于是瘦身与压缩双双变成死代码(详见 docs/turn-navigation-plan.md §3.2.2)。
|
|
9
|
+
//
|
|
10
|
+
// 背景(2026-09 实测,本机 3 个真实大会话逐事件类型统计):`session/page` 返回窗口内
|
|
11
|
+
// **全部事件**,而手机端只消费 `user/message` / `assistant/message` / `assistant/chunk`(实时)/
|
|
12
|
+
// `turn/start` / `turn/end`。chunk 家族按条数占 91.6%;**按"消息对齐窗口"口径占 62% 字节**:
|
|
13
|
+
// · `assistant/chunk` 旧形态(逐条流式帧)
|
|
14
|
+
// · `reasoning-chunks` ┐
|
|
15
|
+
// · `tool-call-chunks` ├ 0.1.5 的 `AssistantStreamRecord` 批量形态
|
|
16
|
+
// · `text-chunks` ┘
|
|
17
|
+
// · `chunk` 该联合类型里的单条形态
|
|
18
|
+
//
|
|
19
|
+
// **必须保留** `turn/start`(轮次锚点:目录面板与跳转定位依赖)、消息级事件与 `hasMore`
|
|
20
|
+
// ——只丢流式预览,权威内容零损失。
|
|
21
|
+
// (注:宿主 `session/page` 只返回 `{records,hasMore}`,历史页响应本就**不携带 projections**;
|
|
22
|
+
// 导航大纲走 `session/follow` 开流快照那条路。)
|
|
23
|
+
|
|
24
|
+
/** 手机端零消费、可安全剥除的事件类型集合。 */
|
|
25
|
+
export const HISTORY_CHUNK_EVENT_TYPES = new Set([
|
|
26
|
+
'assistant/chunk',
|
|
27
|
+
'reasoning-chunks',
|
|
28
|
+
'text-chunks',
|
|
29
|
+
'tool-call-chunks',
|
|
30
|
+
'chunk',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 值级瘦身(**生产路径用**):`{ events, hasMore, projections? }` → 去掉 chunk 家族事件。
|
|
35
|
+
* 没有可剥项时返回原对象(保持引用,调用方无需额外判等)。非目标结构原样返回。
|
|
36
|
+
*/
|
|
37
|
+
export function stripHistoryEvents(value, chunkTypes = HISTORY_CHUNK_EVENT_TYPES) {
|
|
38
|
+
if (!value || !Array.isArray(value.events)) return value;
|
|
39
|
+
const kept = value.events.filter((e) => !chunkTypes.has(e?.event?.type));
|
|
40
|
+
if (kept.length === value.events.length) return value;
|
|
41
|
+
return { ...value, events: kept };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* JSON 级瘦身(旧 HTTP 反代 `replayInner` 用:那里拿到的只有响应体字节)。
|
|
46
|
+
* 非目标结构 / 解析失败 → 原样返回(**绝不破坏响应**)。
|
|
47
|
+
*/
|
|
48
|
+
export function stripHistoryChunks(jsonStr, chunkTypes = HISTORY_CHUNK_EVENT_TYPES) {
|
|
49
|
+
try {
|
|
50
|
+
const full = JSON.parse(jsonStr);
|
|
51
|
+
if (full?.result?.ok) {
|
|
52
|
+
const value = stripHistoryEvents(full.result.value, chunkTypes);
|
|
53
|
+
if (value === full.result.value) return jsonStr;
|
|
54
|
+
full.result.value = value;
|
|
55
|
+
return JSON.stringify(full);
|
|
56
|
+
}
|
|
57
|
+
} catch { /* 解析失败原样返回 */ }
|
|
58
|
+
return jsonStr;
|
|
59
|
+
}
|
package/core/tunnel.js
CHANGED
|
@@ -11,6 +11,7 @@ import { promisify } from 'node:util';
|
|
|
11
11
|
import { TYPE, encode, decodeFrame, toJson, jsonFrame } from '@dshfly/tunnel-protocol';
|
|
12
12
|
import { sealEnvelope, openEnvelope, deriveDirectionalKeys, ReplayGuard, utf8Decode, utf8, verifyUnpair } from '@dshfly/crypto';
|
|
13
13
|
import { claimOutSeq, saveKeys } from './keys.js';
|
|
14
|
+
import { stripHistoryChunks } from './history-slim.js';
|
|
14
15
|
|
|
15
16
|
// C2(P2-8):异步 deflate(大响应压缩不阻塞宿主主线程)
|
|
16
17
|
const zlibDeflate = promisify(zlib.deflate);
|
|
@@ -73,6 +74,30 @@ export function stripSessionListJson(jsonStr) {
|
|
|
73
74
|
} catch { /* 解析失败原样返回 */ }
|
|
74
75
|
return jsonStr;
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
// 会话历史瘦身(chunk 家族剥离)已抽到独立模块:进程内适配层与旧 HTTP 反代**两条路径**都要用
|
|
79
|
+
// (历史上只写在 replayInner 里,2026-09-04 进程内迁移后变成死代码——见 history-slim.js 头注释)。
|
|
80
|
+
export { HISTORY_CHUNK_EVENT_TYPES, stripHistoryChunks } from './history-slim.js';
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* RES 统一压缩(大响应 deflate,App 侧 pako inflate)。
|
|
84
|
+
* - 已有 `enc`(如 replayInner 已压缩)→ 原样返回;
|
|
85
|
+
* - body(base64)解出 < COMPRESS_THRESHOLD → 原样返回(小响应不值得压缩);
|
|
86
|
+
* - 否则 deflate(**异步**,不阻塞宿主主线程)并打 `enc:'deflate'`。
|
|
87
|
+
* 非目标结构(无 body / 非 base64)原样返回,绝不破坏响应。纯函数(除解压外无副作用),可单测。
|
|
88
|
+
*/
|
|
89
|
+
export async function maybeDeflateRes(innerRes) {
|
|
90
|
+
if (!innerRes || innerRes.enc || typeof innerRes.body !== 'string' || innerRes.body.length === 0) return innerRes;
|
|
91
|
+
let buf;
|
|
92
|
+
try {
|
|
93
|
+
buf = Buffer.from(innerRes.body, 'base64');
|
|
94
|
+
} catch {
|
|
95
|
+
return innerRes;
|
|
96
|
+
}
|
|
97
|
+
if (buf.length < COMPRESS_THRESHOLD) return innerRes;
|
|
98
|
+
return { ...innerRes, body: (await zlibDeflate(buf)).toString('base64'), enc: 'deflate' };
|
|
99
|
+
}
|
|
100
|
+
|
|
76
101
|
async function replayInner(target, inner, sessionTitles = null) {
|
|
77
102
|
// A5(P0-4,SSRF 修复):反代目标必须与 target 同源。
|
|
78
103
|
// inner.url 是手机 E2EE 明文,可被恶意手机/被劫持会话设置为任意绝对 URL——
|
|
@@ -89,20 +114,16 @@ async function replayInner(target, inner, sessionTitles = null) {
|
|
|
89
114
|
body: inner.body ? Buffer.from(inner.body, 'base64') : undefined,
|
|
90
115
|
});
|
|
91
116
|
let buf = Buffer.from(await res.arrayBuffer());
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
117
|
+
// history 响应瘦身(本函数 = **旧 HTTP 反代兜底路径**;生产路径的瘦身在 dsh-web.js 的
|
|
118
|
+
// `invoke()` 里,两条路都走同一个 history-slim.js 单一事实源——见该文件头注释)。
|
|
119
|
+
// session.history 响应含"该区间全部事件",其中流式 chunk 家族对手机端历史重建零消费
|
|
120
|
+
// (消息级事件才是权威内容)却占"消息对齐窗口"62% 字节与 JSON.parse 时间——进入会话变慢 /
|
|
121
|
+
// 上翻翻不动的主因。剥掉 chunk 家族(保留 turn/start 轮次锚点、消息级事件与 hasMore)。
|
|
97
122
|
if (inner.body) {
|
|
98
123
|
try {
|
|
99
124
|
const req = JSON.parse(Buffer.from(inner.body, 'base64').toString('utf8'));
|
|
100
125
|
if (req?.method === 'session.history') {
|
|
101
|
-
|
|
102
|
-
if (full?.result?.ok && Array.isArray(full.result.value?.events)) {
|
|
103
|
-
full.result.value.events = full.result.value.events.filter((e) => e?.event?.type !== 'assistant/chunk');
|
|
104
|
-
buf = Buffer.from(JSON.stringify(full));
|
|
105
|
-
}
|
|
126
|
+
buf = Buffer.from(stripHistoryChunks(buf.toString('utf8')));
|
|
106
127
|
} else if (req?.method === 'session.list') {
|
|
107
128
|
// 2026-08 性能:剥除 App 无消费的投影字段(goal/subagentTiming/subagent/plan/
|
|
108
129
|
// todos,实测合计约 21.5% 字节中的大头)——列表刷新体量与解析随会话数线性增长。
|
|
@@ -323,8 +344,12 @@ export function connectConnector({ relayWsUrl, deviceToken, keys, keysFile, sess
|
|
|
323
344
|
} catch {}
|
|
324
345
|
}
|
|
325
346
|
dshStreams = [];
|
|
326
|
-
// 2026-09
|
|
327
|
-
//
|
|
347
|
+
// 2026-09 协议迁移(已核实 DSH 0.1.5-rc.1 源码,修正旧注释):`/api` 的**全部**端点(含
|
|
348
|
+
// events.mux/.host)现在都要求 browser-session 签名 cookie,裸请求 401;且事件改走
|
|
349
|
+
// `$events` 远程流(remote.mux 是 Web 客户端专用入口)。connector 是 dsh web 的**同进程**
|
|
350
|
+
// cordis 插件 → 改为进程内订阅 + typertGateway 直调(见 core/dsh-web.js 与 connector-core
|
|
351
|
+
// 的 startEventPump),**不再连任何 /api HTTP/WS 端点**(下面两条只在 eventsDisabled=false
|
|
352
|
+
// 的降级/测试路径保留)。
|
|
328
353
|
if (eventsDisabled) return;
|
|
329
354
|
const wsBase = target.replace(/^http/, 'ws');
|
|
330
355
|
for (const p of ['/api/events.mux', '/api/events.host']) openDshStream(controlWs, wsBase + p);
|
|
@@ -509,7 +534,11 @@ export function connectConnector({ relayWsUrl, deviceToken, keys, keysFile, sess
|
|
|
509
534
|
safeSend(ws, encode(TYPE.RES, f.streamId, out));
|
|
510
535
|
return;
|
|
511
536
|
}
|
|
512
|
-
|
|
537
|
+
// 进程内响应统一压缩(2026-09 修复):进程内迁移后 `local` 响应不经过 replayInner,
|
|
538
|
+
// 于是 RES 的 ≥4KB deflate 也随之失效——手机端拿到的是未压缩 JSON(历史/列表响应尤其大)。
|
|
539
|
+
// 这里在两条路径的汇合点统一补上(replayInner 已压缩则原样返回),并用**异步** deflate
|
|
540
|
+
// 避免阻塞 dsh web 主线程。App 侧契约不变:innerRes.enc === 'deflate' → pako.inflate(body)。
|
|
541
|
+
const innerRes = await maybeDeflateRes(local ?? (await replayInner(target, inner, sessionTitles)));
|
|
513
542
|
const resEnv = sealEnvelope(
|
|
514
543
|
dirOf(sessionKey).down,
|
|
515
544
|
{
|
package/core/version.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// packages/remote-connector/core/version.js
|
|
2
|
+
// 版本比较(纯函数,零依赖;Node + 单测 + 前端 bundle 通用)。
|
|
3
|
+
//
|
|
4
|
+
// 为什么需要它(2026-09 缺陷修复):
|
|
5
|
+
// 设置页「检查更新」原先用 `latest !== version` 判"有新版本"——**只比相等、不比大小**。
|
|
6
|
+
// 开发环境用 `dsh plugin --profile web add .` 装的是仓库里的 0.3.0,而 npm 上最新是 0.2.1,
|
|
7
|
+
// 于是页面显示"发现新版本 v0.2.1",点「一键升级」反而把本地开发版**降级**成 npm 旧版。
|
|
8
|
+
// 修复:一律按 semver 顺序比较(数字三元组 + 预发布后缀),只有 registry 确实更新才提示/允许升级。
|
|
9
|
+
//
|
|
10
|
+
// 口径(与 docs/compatibility.md 公约 7 一致,并作为其 API 层扩写):
|
|
11
|
+
// - 只解析**数字三元组**;`-rc/-beta/-alpha` 等预发布后缀**不参与**主序比较
|
|
12
|
+
// (0.1.0-rc.6 == 0.1.0),避免把 dev/RC 装机包判成"低于"目标版本。
|
|
13
|
+
// - 数字位允许不足三位(`0.2` 视作 `0.2.0`,`1` 视作 `1.0.0`)。
|
|
14
|
+
// - 比较失败(空值/非法格式)返回 `null`,调用方 fail-closed(不提示、不自动升级)。
|
|
15
|
+
|
|
16
|
+
const SEMVER_RE = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/i;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 解析版本号为主序三元组。
|
|
20
|
+
* @returns {[number, number, number] | null} 解析失败返回 null。
|
|
21
|
+
*/
|
|
22
|
+
export function parseVersion(raw) {
|
|
23
|
+
if (typeof raw !== 'string') return null;
|
|
24
|
+
const m = SEMVER_RE.exec(raw.trim());
|
|
25
|
+
if (!m) return null;
|
|
26
|
+
const major = Number(m[1]);
|
|
27
|
+
const minor = m[2] === undefined ? 0 : Number(m[2]);
|
|
28
|
+
const patch = m[3] === undefined ? 0 : Number(m[3]);
|
|
29
|
+
if (![major, minor, patch].every((n) => Number.isSafeInteger(n) && n >= 0)) return null;
|
|
30
|
+
return [major, minor, patch];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 主序三元组比较。任一侧不可解析 → null。 */
|
|
34
|
+
export function compareVersion(a, b) {
|
|
35
|
+
const pa = parseVersion(a);
|
|
36
|
+
const pb = parseVersion(b);
|
|
37
|
+
if (!pa || !pb) return null;
|
|
38
|
+
for (let i = 0; i < 3; i += 1) {
|
|
39
|
+
if (pa[i] !== pb[i]) return pa[i] > pb[i] ? 1 : -1;
|
|
40
|
+
}
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* candidate 是否比 current **更新**(严格大于)。
|
|
46
|
+
* 不可解析(任一侧)→ false(fail-closed:宁可少提示,绝不误导升级/降级)。
|
|
47
|
+
*/
|
|
48
|
+
export function isNewerVersion(candidate, current) {
|
|
49
|
+
return compareVersion(candidate, current) === 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 更新检查的统一判定(/dshfly/plugins/checkUpdate 与前端共用口径)。
|
|
54
|
+
* @returns {{ updateAvailable: boolean, localAhead: boolean, comparable: boolean }}
|
|
55
|
+
* - comparable=false:版本号无法解析 → 一律不提示更新(fail-closed)
|
|
56
|
+
* - localAhead=true:本地(开发/预发布)版本比 registry 更新 → 不提示更新,
|
|
57
|
+
* 且 UI 需明示"本地版本更新(开发版)",避免用户以为检查坏了
|
|
58
|
+
*/
|
|
59
|
+
export function summarizeUpdate({ current, latest }) {
|
|
60
|
+
const cmp = compareVersion(latest, current);
|
|
61
|
+
if (cmp === null) return { updateAvailable: false, localAhead: false, comparable: false };
|
|
62
|
+
return { updateAvailable: cmp === 1, localAhead: cmp === -1, comparable: true };
|
|
63
|
+
}
|
package/dist/client.js
CHANGED
|
@@ -51,6 +51,11 @@ var zh = {
|
|
|
51
51
|
upgradeFailed: "\u5347\u7EA7\u5931\u8D25",
|
|
52
52
|
upToDate: "\u5DF2\u662F\u6700\u65B0\u7248\u672C",
|
|
53
53
|
checkFailed: "\u68C0\u67E5\u66F4\u65B0\u5931\u8D25",
|
|
54
|
+
localAhead: "\u5F53\u524D\u4E3A\u672C\u5730\u5F00\u53D1\u7248\uFF0C\u6BD4 npm \u4E0A\u7684\u7248\u672C\u66F4\u65B0\uFF08\u4E0D\u4F1A\u964D\u7EA7\uFF09",
|
|
55
|
+
localAheadShort: "\u672C\u5730\u5F00\u53D1\u7248\uFF08\u65E0\u66F4\u65B0\uFF09",
|
|
56
|
+
depsBroken: "\u63D2\u4EF6\u4F9D\u8D56\u7F3A\u5931\uFF0Cdsh web \u53EF\u80FD\u65E0\u6CD5\u542F\u52A8",
|
|
57
|
+
depsMissing: "\u7F3A\u5C11\uFF1A",
|
|
58
|
+
depsFix: "\u8BF7\u5728\u63D2\u4EF6\u4ED3\u5E93\u6839\u76EE\u5F55\u6267\u884C pnpm install\uFF0C\u7136\u540E\u91CD\u542F dsh web\u3002",
|
|
54
59
|
accountName: "\u8D26\u53F7",
|
|
55
60
|
deviceId: "\u8BBE\u5907 ID",
|
|
56
61
|
hostname: "\u8BA1\u7B97\u673A\u540D",
|
|
@@ -118,6 +123,11 @@ var en = {
|
|
|
118
123
|
upgradeFailed: "Upgrade failed",
|
|
119
124
|
upToDate: "Up to date",
|
|
120
125
|
checkFailed: "Update check failed",
|
|
126
|
+
localAhead: "Local dev build is newer than the npm release (downgrade blocked)",
|
|
127
|
+
localAheadShort: "Local dev build (no update)",
|
|
128
|
+
depsBroken: "Plugin dependencies missing \u2014 dsh web may fail to start",
|
|
129
|
+
depsMissing: "Missing: ",
|
|
130
|
+
depsFix: "Run pnpm install at the plugin repo root, then restart dsh web.",
|
|
121
131
|
accountName: "Account",
|
|
122
132
|
deviceId: "Device ID",
|
|
123
133
|
hostname: "Hostname",
|
|
@@ -202,6 +212,7 @@ var getRecommendedPlugins = () => request("/dshfly/plugins/recommended");
|
|
|
202
212
|
var installPlugin = (id) => request("/dshfly/plugins/install", { method: "POST", body: JSON.stringify({ id }) });
|
|
203
213
|
var checkUpdate = () => request("/dshfly/plugins/checkUpdate");
|
|
204
214
|
var upgrade = () => request("/dshfly/plugins/upgrade", { method: "POST" });
|
|
215
|
+
var doctor = () => request("/dshfly/doctor");
|
|
205
216
|
|
|
206
217
|
// src/client/SettingsSection.jsx
|
|
207
218
|
var sectionStyle = {
|
|
@@ -307,6 +318,7 @@ function SettingsSection({ t }) {
|
|
|
307
318
|
const [installNotice, setInstallNotice] = (0, import_react.useState)(null);
|
|
308
319
|
const [updateInfo, setUpdateInfo] = (0, import_react.useState)(null);
|
|
309
320
|
const [updateBusy, setUpdateBusy] = (0, import_react.useState)(false);
|
|
321
|
+
const [doctorInfo, setDoctorInfo] = (0, import_react.useState)(null);
|
|
310
322
|
const [toast, setToast] = (0, import_react.useState)(null);
|
|
311
323
|
const toastTimer = (0, import_react.useRef)(null);
|
|
312
324
|
const showToast = (0, import_react.useCallback)((text, kind = "ok") => {
|
|
@@ -321,6 +333,7 @@ function SettingsSection({ t }) {
|
|
|
321
333
|
if (manual) {
|
|
322
334
|
if (r.updateAvailable) showToast(t("updateAvailable") + ` v${r.latest}`, "ok");
|
|
323
335
|
else if (r.error) showToast(`${t("checkFailed")}\uFF1A${r.error}`, "err");
|
|
336
|
+
else if (r.localAhead) showToast(t("localAhead"), "ok");
|
|
324
337
|
else showToast(t("upToDate"), "ok");
|
|
325
338
|
}
|
|
326
339
|
} catch (e) {
|
|
@@ -331,17 +344,33 @@ function SettingsSection({ t }) {
|
|
|
331
344
|
(0, import_react.useEffect)(() => {
|
|
332
345
|
void loadUpdate(false);
|
|
333
346
|
}, [loadUpdate]);
|
|
347
|
+
(0, import_react.useEffect)(() => {
|
|
348
|
+
let alive = true;
|
|
349
|
+
void (async () => {
|
|
350
|
+
try {
|
|
351
|
+
const r = await doctor();
|
|
352
|
+
if (alive) setDoctorInfo(r);
|
|
353
|
+
} catch {
|
|
354
|
+
if (alive) setDoctorInfo(null);
|
|
355
|
+
}
|
|
356
|
+
})();
|
|
357
|
+
return () => {
|
|
358
|
+
alive = false;
|
|
359
|
+
};
|
|
360
|
+
}, []);
|
|
334
361
|
const onUpgrade = (0, import_react.useCallback)(async () => {
|
|
335
362
|
setUpdateBusy(true);
|
|
336
363
|
try {
|
|
337
364
|
await upgrade();
|
|
338
365
|
showToast(t("upgradeDone"), "ok");
|
|
366
|
+
void loadUpdate(false);
|
|
339
367
|
} catch (e) {
|
|
340
368
|
showToast(`${t("upgradeFailed")}\uFF1A${e.message}`, "err");
|
|
369
|
+
void loadUpdate(false);
|
|
341
370
|
} finally {
|
|
342
371
|
setUpdateBusy(false);
|
|
343
372
|
}
|
|
344
|
-
}, [t, showToast]);
|
|
373
|
+
}, [t, showToast, loadUpdate]);
|
|
345
374
|
const loadRecommended = (0, import_react.useCallback)(async () => {
|
|
346
375
|
try {
|
|
347
376
|
const r = await getRecommendedPlugins();
|
|
@@ -487,7 +516,7 @@ function SettingsSection({ t }) {
|
|
|
487
516
|
}
|
|
488
517
|
},
|
|
489
518
|
/* @__PURE__ */ import_react.default.createElement("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.2", strokeLinecap: "round", strokeLinejoin: "round" }, /* @__PURE__ */ import_react.default.createElement("path", { d: "M21 12a9 9 0 1 1-2.64-6.36" }), /* @__PURE__ */ import_react.default.createElement("polyline", { points: "21 3 21 9 15 9" }))
|
|
490
|
-
))), /* @__PURE__ */ import_react.default.createElement("p", { style: { margin: 0, fontSize: 13, color: "var(--dsw-alias-label-tertiary, inherit)", lineHeight: 1.6, maxWidth: 560 } }, t("intro")), updateInfo?.updateAvailable && /* @__PURE__ */ import_react.default.createElement("div", { style: { ...cardStyle, borderColor: "#e8890c", padding: "10px 16px" } }, /* @__PURE__ */ import_react.default.createElement("div", { style: { ...rowStyle, gap: 10 } }, /* @__PURE__ */ import_react.default.createElement("span", { style: { fontSize: 13, color: "#e8890c" } }, t("updateAvailable"), " v", updateInfo.latest), /* @__PURE__ */ import_react.default.createElement("span", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ import_react.default.createElement("button", { style: btnStyle, onClick: onUpgrade, disabled: updateBusy }, updateBusy ? t("upgrading") : t("upgrade"))))), /* @__PURE__ */ import_react.default.createElement("div", { style: cardStyle }, /* @__PURE__ */ import_react.default.createElement("div", { style: rowStyle }, /* @__PURE__ */ import_react.default.createElement("span", { style: labelStyle }, t("relayServer")), /* @__PURE__ */ import_react.default.createElement("span", { style: { display: "flex", alignItems: "center", gap: 8 } }, statusBadge(status?.connection, t, status?.relayUrl), tokenBadge(status?.tokenState, t))), /* @__PURE__ */ import_react.default.createElement("div", { style: rowStyle }, /* @__PURE__ */ import_react.default.createElement("span", { style: labelStyle }, t("accountName")), /* @__PURE__ */ import_react.default.createElement("code", null, status?.accountName || "\u2026")), /* @__PURE__ */ import_react.default.createElement("div", { style: rowStyle }, /* @__PURE__ */ import_react.default.createElement("span", { style: labelStyle }, t("deviceId")), /* @__PURE__ */ import_react.default.createElement("code", null, status?.deviceId ? status.deviceId.slice(0, 12) + "\u2026" : "\u2026")), /* @__PURE__ */ import_react.default.createElement("div", { style: rowStyle }, /* @__PURE__ */ import_react.default.createElement("span", { style: labelStyle }, t("hostname")), /* @__PURE__ */ import_react.default.createElement("code", null, status?.hostname || "\u2026"))), recommendedPending.length > 0 && /* @__PURE__ */ import_react.default.createElement("div", { style: cardStyle }, /* @__PURE__ */ import_react.default.createElement("div", { style: { fontSize: 15, fontWeight: "600" } }, t("recommendedTitle")), /* @__PURE__ */ import_react.default.createElement("div", { style: { fontSize: 13, opacity: 0.7, lineHeight: 1.5 } }, t("recommendedHint")), recommendedPending.map((p) => /* @__PURE__ */ import_react.default.createElement("div", { key: p.id, style: rowStyle }, /* @__PURE__ */ import_react.default.createElement("span", { style: { display: "flex", alignItems: "center", gap: 8, fontSize: 13 } }, /* @__PURE__ */ import_react.default.createElement("span", null, p.icon ? /* @__PURE__ */ import_react.default.createElement("span", { style: { fontSize: 16 } }, "\u{1F4E6}") : null), /* @__PURE__ */ import_react.default.createElement("span", null, /* @__PURE__ */ import_react.default.createElement("strong", null, p.title), " \u2014 ", p.description)), /* @__PURE__ */ import_react.default.createElement(
|
|
519
|
+
))), /* @__PURE__ */ import_react.default.createElement("p", { style: { margin: 0, fontSize: 13, color: "var(--dsw-alias-label-tertiary, inherit)", lineHeight: 1.6, maxWidth: 560 } }, t("intro")), doctorInfo && doctorInfo.dependencies && !doctorInfo.dependencies.ok && /* @__PURE__ */ import_react.default.createElement("div", { style: { ...cardStyle, borderColor: "#d93025", padding: "10px 16px" } }, /* @__PURE__ */ import_react.default.createElement("div", { style: { fontSize: 13, color: "#d93025", fontWeight: 600 } }, t("depsBroken")), /* @__PURE__ */ import_react.default.createElement("div", { style: { fontSize: 12, lineHeight: 1.6, opacity: 0.85 } }, doctorInfo.dependencies.missing.length ? `${t("depsMissing")}${doctorInfo.dependencies.missing.join(", ")}` : t("depsBroken")), /* @__PURE__ */ import_react.default.createElement("div", { style: { fontSize: 12, opacity: 0.7 } }, t("depsFix"))), updateInfo?.updateAvailable && /* @__PURE__ */ import_react.default.createElement("div", { style: { ...cardStyle, borderColor: "#e8890c", padding: "10px 16px" } }, /* @__PURE__ */ import_react.default.createElement("div", { style: { ...rowStyle, gap: 10 } }, /* @__PURE__ */ import_react.default.createElement("span", { style: { fontSize: 13, color: "#e8890c" } }, t("updateAvailable"), " v", updateInfo.latest), /* @__PURE__ */ import_react.default.createElement("span", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ import_react.default.createElement("button", { style: btnStyle, onClick: onUpgrade, disabled: updateBusy }, updateBusy ? t("upgrading") : t("upgrade"))))), updateInfo?.localAhead && /* @__PURE__ */ import_react.default.createElement("div", { style: { fontSize: 12, color: "var(--dsw-alias-label-tertiary, inherit)", display: "flex", alignItems: "center", gap: 6 } }, /* @__PURE__ */ import_react.default.createElement("span", { "aria-hidden": true }, "\u2022"), /* @__PURE__ */ import_react.default.createElement("span", null, t("localAhead"))), /* @__PURE__ */ import_react.default.createElement("div", { style: cardStyle }, /* @__PURE__ */ import_react.default.createElement("div", { style: rowStyle }, /* @__PURE__ */ import_react.default.createElement("span", { style: labelStyle }, t("relayServer")), /* @__PURE__ */ import_react.default.createElement("span", { style: { display: "flex", alignItems: "center", gap: 8 } }, statusBadge(status?.connection, t, status?.relayUrl), tokenBadge(status?.tokenState, t))), /* @__PURE__ */ import_react.default.createElement("div", { style: rowStyle }, /* @__PURE__ */ import_react.default.createElement("span", { style: labelStyle }, t("accountName")), /* @__PURE__ */ import_react.default.createElement("code", null, status?.accountName || "\u2026")), /* @__PURE__ */ import_react.default.createElement("div", { style: rowStyle }, /* @__PURE__ */ import_react.default.createElement("span", { style: labelStyle }, t("deviceId")), /* @__PURE__ */ import_react.default.createElement("code", null, status?.deviceId ? status.deviceId.slice(0, 12) + "\u2026" : "\u2026")), /* @__PURE__ */ import_react.default.createElement("div", { style: rowStyle }, /* @__PURE__ */ import_react.default.createElement("span", { style: labelStyle }, t("hostname")), /* @__PURE__ */ import_react.default.createElement("code", null, status?.hostname || "\u2026"))), recommendedPending.length > 0 && /* @__PURE__ */ import_react.default.createElement("div", { style: cardStyle }, /* @__PURE__ */ import_react.default.createElement("div", { style: { fontSize: 15, fontWeight: "600" } }, t("recommendedTitle")), /* @__PURE__ */ import_react.default.createElement("div", { style: { fontSize: 13, opacity: 0.7, lineHeight: 1.5 } }, t("recommendedHint")), recommendedPending.map((p) => /* @__PURE__ */ import_react.default.createElement("div", { key: p.id, style: rowStyle }, /* @__PURE__ */ import_react.default.createElement("span", { style: { display: "flex", alignItems: "center", gap: 8, fontSize: 13 } }, /* @__PURE__ */ import_react.default.createElement("span", null, p.icon ? /* @__PURE__ */ import_react.default.createElement("span", { style: { fontSize: 16 } }, "\u{1F4E6}") : null), /* @__PURE__ */ import_react.default.createElement("span", null, /* @__PURE__ */ import_react.default.createElement("strong", null, p.title), " \u2014 ", p.description)), /* @__PURE__ */ import_react.default.createElement(
|
|
491
520
|
"button",
|
|
492
521
|
{
|
|
493
522
|
style: btnStyle,
|