dsh-remote-plugin 0.7.0-rc.2 → 0.7.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/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +402 -28
- package/index.mjs +29 -2
- package/package.json +1 -1
- package/public/app.js +165 -23
- package/public/index.html +36 -2
- package/public/styles.css +6 -0
- package/public/update.json +8 -8
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -23,13 +23,15 @@
|
|
|
23
23
|
* DSH_REMOTE_FS_MAX_UPLOAD 上传字节上限, 默认 2147483648 (2GB)
|
|
24
24
|
* DSH_REMOTE_WORKBENCH 工作台绑定文件, 默认 ~/.dsh-remote/workbench.json
|
|
25
25
|
* DSH_REMOTE_ADVERTISE_HOSTS 额外写入配对二维码的宿主 IP/主机名, 逗号或空白分隔
|
|
26
|
-
* DSH_REMOTE_DSH_CONTROL_MODE DSH 生命周期后端: auto/systemd/windows/disabled
|
|
26
|
+
* DSH_REMOTE_DSH_CONTROL_MODE DSH 生命周期后端: auto/systemd/windows/process/disabled
|
|
27
|
+
* DSH_REMOTE_DSH_LAUNCH_FILE 本机 DSH 启动配置, 默认 ~/.dsh-remote/dsh-launch.json
|
|
27
28
|
*/
|
|
28
29
|
'use strict'
|
|
29
30
|
|
|
30
31
|
const http = require('node:http')
|
|
31
32
|
const https = require('node:https')
|
|
32
|
-
const { execFile } = require('node:child_process')
|
|
33
|
+
const { execFile, spawn } = require('node:child_process')
|
|
34
|
+
const net = require('node:net')
|
|
33
35
|
const fs = require('node:fs')
|
|
34
36
|
const path = require('node:path')
|
|
35
37
|
const os = require('node:os')
|
|
@@ -85,12 +87,14 @@ const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remot
|
|
|
85
87
|
const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
|
|
86
88
|
const DEVICE_KEYS_FILE = process.env.DSH_REMOTE_DEVICE_KEYS || path.join(os.homedir(), '.dsh-remote', 'device-keys.json')
|
|
87
89
|
const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(), '.dsh-remote', 'workbench.json')
|
|
90
|
+
const HANDOFF_FILE = process.env.DSH_REMOTE_HANDOFF || path.join(os.homedir(), '.dsh-remote', 'handoff.json')
|
|
88
91
|
const STARTED_AT = Date.now()
|
|
89
92
|
const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
|
|
90
93
|
const SYSTEMCTL = String(process.env.DSH_REMOTE_SYSTEMCTL || 'systemctl').trim() || 'systemctl'
|
|
91
94
|
const WINDOWS_SC = String(process.env.DSH_REMOTE_WINDOWS_SC || 'sc.exe').trim() || 'sc.exe'
|
|
92
95
|
const DSH_CONTROL_MODE_RAW = String(process.env.DSH_REMOTE_DSH_CONTROL_MODE || 'auto').trim().toLowerCase()
|
|
93
|
-
const DSH_CONTROL_MODE = ['auto', 'systemd', 'windows', 'disabled'].includes(DSH_CONTROL_MODE_RAW) ? DSH_CONTROL_MODE_RAW : 'auto'
|
|
96
|
+
const DSH_CONTROL_MODE = ['auto', 'systemd', 'windows', 'process', 'disabled'].includes(DSH_CONTROL_MODE_RAW) ? DSH_CONTROL_MODE_RAW : 'auto'
|
|
97
|
+
const DSH_LAUNCH_FILE = process.env.DSH_REMOTE_DSH_LAUNCH_FILE || path.join(os.homedir(), '.dsh-remote', 'dsh-launch.json')
|
|
94
98
|
const DSH_CONTROL_TIMEOUT_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 45000, 2000, 5 * 60 * 1000)
|
|
95
99
|
const DSH_CONTROL_POLL_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_POLL_MS', 500, 50, 5000)
|
|
96
100
|
const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 15 * 60 * 1000, 0, 24 * 60 * 60 * 1000)
|
|
@@ -153,6 +157,7 @@ function containerRuntimeDetected() {
|
|
|
153
157
|
}
|
|
154
158
|
|
|
155
159
|
function dshControlSupport() {
|
|
160
|
+
if (DSH_CONTROL_MODE === 'process') return { supported: true, manager: 'process' }
|
|
156
161
|
if (DSH_CONTROL_MODE === 'disabled') {
|
|
157
162
|
return { supported: false, code: 'EXTERNAL_LIFECYCLE', message: '当前 DSH 由 Docker、面板或其他外部平台管理' }
|
|
158
163
|
}
|
|
@@ -1034,6 +1039,96 @@ async function windowsServiceStatus() {
|
|
|
1034
1039
|
}
|
|
1035
1040
|
}
|
|
1036
1041
|
|
|
1042
|
+
// Only a live ChildProcess handle grants restart authority. Never trust a saved PID:
|
|
1043
|
+
// a gateway restart or PID reuse must not turn an unrelated process into our child.
|
|
1044
|
+
let dshManagedChild = null
|
|
1045
|
+
let dshManagedExit = null
|
|
1046
|
+
function readDshLaunch() {
|
|
1047
|
+
const config = JSON.parse(fs.readFileSync(DSH_LAUNCH_FILE, 'utf8'))
|
|
1048
|
+
if (config.version !== 1 || !path.isAbsolute(config.executable || '') ||
|
|
1049
|
+
!path.isAbsolute(config.cwd || '') || !Array.isArray(config.args) ||
|
|
1050
|
+
!config.args.length || !config.args.every(arg => typeof arg === 'string' && !arg.includes('\0')) ||
|
|
1051
|
+
!path.isAbsolute(config.args[0]) || !fs.statSync(config.args[0]).isFile() ||
|
|
1052
|
+
!fs.statSync(config.executable).isFile() || !fs.statSync(config.cwd).isDirectory()) {
|
|
1053
|
+
throw new Error('DSH 启动配置无效,请从 DSH Web 插件重新生成')
|
|
1054
|
+
}
|
|
1055
|
+
if (!['127.0.0.1', 'localhost', '[::1]'].includes(UPSTREAM.hostname) || config.upstream !== UPSTREAM.origin) {
|
|
1056
|
+
throw new Error('DSH 启动配置与本机上游地址不匹配')
|
|
1057
|
+
}
|
|
1058
|
+
const env = {}
|
|
1059
|
+
for (const key of ['DSH_HOME', 'HOME', 'USERPROFILE', 'PATH', 'NODE_OPTIONS']) {
|
|
1060
|
+
if (typeof config.env?.[key] === 'string') env[key] = config.env[key]
|
|
1061
|
+
}
|
|
1062
|
+
return { ...config, env }
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function dshPortOccupied() {
|
|
1066
|
+
return new Promise(resolve => {
|
|
1067
|
+
const socket = net.connect({ host: UPSTREAM.hostname.replace(/^\[|\]$/g, ''), port: UPSTREAM.port || (UPSTREAM.protocol === 'https:' ? 443 : 80) })
|
|
1068
|
+
const finish = value => { socket.destroy(); resolve(value) }
|
|
1069
|
+
socket.once('connect', () => finish(true))
|
|
1070
|
+
socket.once('error', err => finish(err.code !== 'ECONNREFUSED'))
|
|
1071
|
+
socket.setTimeout(1500, () => finish(true)) // uncertainty must not spawn a duplicate
|
|
1072
|
+
})
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function dshProcessStatus() {
|
|
1076
|
+
const base = { ok: true, supported: true, manager: 'process', service: 'dsh-process', running: false, mainPid: 0, canRestart: false }
|
|
1077
|
+
try { readDshLaunch() } catch (err) {
|
|
1078
|
+
return { ...base, supported: false, code: 'LAUNCH_CONFIG_INVALID', message: '缺少有效 DSH 启动配置,请先从 DSH Web 启动 Remote 插件', detail: err.message }
|
|
1079
|
+
}
|
|
1080
|
+
if (dshManagedChild) {
|
|
1081
|
+
return { ...base, running: true, mainPid: dshManagedChild.pid, owned: true, canRestart: true, activeState: 'active', subState: 'running' }
|
|
1082
|
+
}
|
|
1083
|
+
const occupied = await dshPortOccupied()
|
|
1084
|
+
const probe = occupied ? await probeDshUpstream() : null
|
|
1085
|
+
return { ...base, running: !!probe?.ok, occupied, owned: false,
|
|
1086
|
+
activeState: occupied ? (probe?.ok ? 'active' : 'inactive') : dshManagedExit ? 'failed' : 'inactive',
|
|
1087
|
+
subState: occupied ? 'external' : 'stopped',
|
|
1088
|
+
...(dshManagedExit && !occupied ? { result: 'process-exited', execMainStatus: dshManagedExit.code, logFile: dshManagedExit.logFile } : {}),
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
async function executeDshProcessAction(action) {
|
|
1093
|
+
const config = readDshLaunch()
|
|
1094
|
+
if (action === 'restart' && !dshManagedChild && await dshPortOccupied()) {
|
|
1095
|
+
return { ok: false, code: 'EXTERNAL_PROCESS', error: '当前 DSH 并非本网关启动,不能安全重启;请在原启动器中停止后重试' }
|
|
1096
|
+
}
|
|
1097
|
+
if (action === 'restart' && dshManagedChild) {
|
|
1098
|
+
const child = dshManagedChild
|
|
1099
|
+
const stopped = new Promise(resolve => {
|
|
1100
|
+
const timer = setTimeout(() => { child.removeListener('exit', onExit); resolve(false) }, 5000)
|
|
1101
|
+
const onExit = () => { clearTimeout(timer); resolve(true) }
|
|
1102
|
+
child.once('exit', onExit)
|
|
1103
|
+
})
|
|
1104
|
+
child.kill()
|
|
1105
|
+
if (!await stopped) return { ok: false, code: 'PROCESS_STOP_TIMEOUT', error: 'DSH 进程未及时退出,未启动第二个实例' }
|
|
1106
|
+
}
|
|
1107
|
+
if (dshManagedChild || await dshPortOccupied()) return { ok: false, code: 'PORT_IN_USE', error: 'DSH 端口已被占用,未启动重复实例' }
|
|
1108
|
+
const logFile = path.join(path.dirname(DSH_LAUNCH_FILE), 'dsh-process.log')
|
|
1109
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true })
|
|
1110
|
+
const fd = fs.openSync(logFile, 'a', 0o600)
|
|
1111
|
+
let child
|
|
1112
|
+
try {
|
|
1113
|
+
child = spawn(config.executable, config.args, {
|
|
1114
|
+
cwd: config.cwd, env: { ...process.env, ...config.env },
|
|
1115
|
+
detached: true, windowsHide: true, shell: false, stdio: ['ignore', fd, fd],
|
|
1116
|
+
})
|
|
1117
|
+
} finally { fs.closeSync(fd) }
|
|
1118
|
+
dshManagedChild = child
|
|
1119
|
+
dshManagedExit = null
|
|
1120
|
+
child.once('exit', code => {
|
|
1121
|
+
if (dshManagedChild === child) { dshManagedChild = null; dshManagedExit = { code, logFile } }
|
|
1122
|
+
})
|
|
1123
|
+
return new Promise(resolve => {
|
|
1124
|
+
child.once('error', err => {
|
|
1125
|
+
if (dshManagedChild === child) { dshManagedChild = null; dshManagedExit = { code: err.code, logFile } }
|
|
1126
|
+
resolve({ ok: false, code: 'PROCESS_START_FAILED', error: `${err.message};日志:${logFile}` })
|
|
1127
|
+
})
|
|
1128
|
+
child.once('spawn', () => { child.unref(); resolve({ ok: true, code: 0 }) })
|
|
1129
|
+
})
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1037
1132
|
async function dshServiceStatus() {
|
|
1038
1133
|
if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
|
|
1039
1134
|
return { ok: false, supported: false, running: false, service: DSH_SERVICE, code: 'INVALID_SERVICE', message: 'DSH_REMOTE_DSH_SERVICE 服务名配置不合法' }
|
|
@@ -1041,7 +1136,12 @@ async function dshServiceStatus() {
|
|
|
1041
1136
|
if (!DSH_CONTROL_SUPPORT.supported) {
|
|
1042
1137
|
return { ok: true, supported: false, running: false, service: DSH_SERVICE, ...DSH_CONTROL_SUPPORT }
|
|
1043
1138
|
}
|
|
1044
|
-
if (
|
|
1139
|
+
if (DSH_CONTROL_MODE === 'process') return dshProcessStatus()
|
|
1140
|
+
if (process.platform === 'win32') {
|
|
1141
|
+
const service = await windowsServiceStatus()
|
|
1142
|
+
if (DSH_CONTROL_MODE === 'auto' && service.code === 'SERVICE_NOT_FOUND') return dshProcessStatus()
|
|
1143
|
+
return { ...service, manager: 'windows' }
|
|
1144
|
+
}
|
|
1045
1145
|
const r = await execFileResult(SYSTEMCTL, [
|
|
1046
1146
|
'--user', 'show', DSH_SERVICE,
|
|
1047
1147
|
'--property=Id,LoadState,ActiveState,SubState,UnitFileState,MainPID,Result,ExecMainStatus',
|
|
@@ -1080,6 +1180,7 @@ async function dshServiceStatus() {
|
|
|
1080
1180
|
}
|
|
1081
1181
|
|
|
1082
1182
|
async function executeDshServiceAction(action, initial) {
|
|
1183
|
+
if (initial?.manager === 'process') return executeDshProcessAction(action)
|
|
1083
1184
|
if (process.platform !== 'win32') {
|
|
1084
1185
|
return execFileResult(SYSTEMCTL, ['--user', '--no-block', action, DSH_SERVICE], 5000)
|
|
1085
1186
|
}
|
|
@@ -1170,27 +1271,25 @@ function reconnectDshEventCollectors() {
|
|
|
1170
1271
|
|
|
1171
1272
|
async function runDshControlOperation(operation) {
|
|
1172
1273
|
try {
|
|
1173
|
-
|
|
1174
|
-
dshOperationStep(operation, 'checking', `正在检查 ${manager} ${DSH_SERVICE}`)
|
|
1274
|
+
dshOperationStep(operation, 'checking', '正在检查 DSH 启动配置与运行状态')
|
|
1175
1275
|
const initial = await dshServiceStatus()
|
|
1176
1276
|
operation.initialStatus = initial
|
|
1177
1277
|
operation.observed = initial
|
|
1278
|
+
const manager = initial.manager === 'process' ? '本机进程' : process.platform === 'win32' ? 'Windows 服务' : 'systemd 用户服务'
|
|
1279
|
+
operation.service = initial.service
|
|
1178
1280
|
if (!initial.supported) {
|
|
1179
1281
|
failDshOperation(operation, initial.code || 'UNSUPPORTED', initial.message || '当前 DSH 服务不可控', initial.detail, initial)
|
|
1180
1282
|
return
|
|
1181
1283
|
}
|
|
1182
|
-
if (operation.action === 'start' && initial.running) {
|
|
1183
|
-
dshOperationStep(operation, 'complete', `DSH 已在运行(${initial.service},PID ${initial.mainPid || '未知'})`, {
|
|
1184
|
-
ok: true, done: true, code: 'ALREADY_RUNNING', status: initial, upstream: await probeDshUpstream(),
|
|
1185
|
-
})
|
|
1186
|
-
return
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
1284
|
dshOperationStep(operation, 'command', `正在向 ${manager} 提交 DSH ${operation.action === 'start' ? '启动' : '重启'}命令`)
|
|
1190
|
-
const command =
|
|
1285
|
+
const command = operation.action === 'start' && initial.running
|
|
1286
|
+
? { ok: true, code: 'ALREADY_RUNNING' }
|
|
1287
|
+
: await executeDshServiceAction(operation.action, initial)
|
|
1191
1288
|
operation.command = { ok: command.ok, code: command.code, signal: command.signal }
|
|
1192
1289
|
if (!command.ok) {
|
|
1193
|
-
const failure =
|
|
1290
|
+
const failure = initial.manager === 'process'
|
|
1291
|
+
? { code: command.code, message: command.error, detail: '' }
|
|
1292
|
+
: classifyDshServiceFailure(command)
|
|
1194
1293
|
failDshOperation(operation, failure.code, failure.message, failure.detail, await dshServiceStatus())
|
|
1195
1294
|
return
|
|
1196
1295
|
}
|
|
@@ -1215,7 +1314,7 @@ async function runDshControlOperation(operation) {
|
|
|
1215
1314
|
}
|
|
1216
1315
|
if (operation.action === 'restart' && (status.mainPid > 0 && status.mainPid !== initialPid || status.activeState !== 'active')) restartObserved = true
|
|
1217
1316
|
if (status.activeState === 'failed') {
|
|
1218
|
-
failDshOperation(operation, 'SERVICE_FAILED', `DSH
|
|
1317
|
+
failDshOperation(operation, 'SERVICE_FAILED', `DSH 启动进程已退出(Result=${status.result || 'unknown'},ExecMainStatus=${status.execMainStatus})`, status.logFile ? `启动日志:${status.logFile}` : '', status)
|
|
1219
1318
|
return
|
|
1220
1319
|
}
|
|
1221
1320
|
if (status.running && restartObserved) {
|
|
@@ -1479,7 +1578,10 @@ async function detectUpstreamApiFlavor(force = false) {
|
|
|
1479
1578
|
}
|
|
1480
1579
|
} catch (error) {
|
|
1481
1580
|
upstreamApiFlavorCheckedAt = Date.now()
|
|
1482
|
-
|
|
1581
|
+
// 网络级失败(上游未启动/cookie 未就绪)不改变 flavor:
|
|
1582
|
+
// 把 unknown 定格成 legacy 会让新版 DSH 被永久当旧服务器探测,
|
|
1583
|
+
// events 双流从此连不上(websocket error 循环)。保持 unknown,
|
|
1584
|
+
// 下一轮 recheck 再探;已有明确 flavor 的也不因瞬断回退。
|
|
1483
1585
|
recordCompatibility('protocol-probe-failed', { detail: error?.message || error })
|
|
1484
1586
|
} finally {
|
|
1485
1587
|
upstreamApiFlavorProbe = null
|
|
@@ -1656,7 +1758,15 @@ async function translateModernRpc(method, payload, rpcId) {
|
|
|
1656
1758
|
return legacyFallback('no generated RPC adapter')
|
|
1657
1759
|
}
|
|
1658
1760
|
|
|
1659
|
-
|
|
1761
|
+
let response = await callUpstreamRemote(endpoint, args, rpcId)
|
|
1762
|
+
if (method === 'session.prompt' && imageAdmissionRejected(response.body)) {
|
|
1763
|
+
try {
|
|
1764
|
+
const bridged = await bridgeVisionPrompt(args.request)
|
|
1765
|
+
if (bridged) response = await callUpstreamRemote(endpoint, { ...args, request: bridged }, rpcId)
|
|
1766
|
+
} catch (error) {
|
|
1767
|
+
return legacyEnvelope(rpcId, modernError(error.message, 'image-vision-failed'))
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1660
1770
|
if (modernResponseNeedsLegacyFallback(response)) return legacyFallback(`generated RPC ${endpoint} unavailable`)
|
|
1661
1771
|
if (!response.body?.result?.ok) {
|
|
1662
1772
|
recordCompatibility('generated-rpc-error', { method, status: response.status, detail: remoteFailureKind(response) })
|
|
@@ -1975,6 +2085,11 @@ function legacyPush(kind, payload, rpcId = crypto.randomUUID()) {
|
|
|
1975
2085
|
|
|
1976
2086
|
function openModernSessionStream(ws, sessionId) {
|
|
1977
2087
|
if (!sessionId || ws.readyState !== 1) return
|
|
2088
|
+
// subagent 来源的会话在 DSH 侧只接受带 durable parent address 的 follow,
|
|
2089
|
+
// 直接按 session 地址 open 会被拒(session/agent-busy),错误帧还会污染事件流;
|
|
2090
|
+
// 这些会话的投影变化由 control/workspaces 基线推送覆盖,无需单独 follow。
|
|
2091
|
+
const summary = modernState.sessions.get(sessionId)
|
|
2092
|
+
if (summary?.origin === 'subagent') return
|
|
1978
2093
|
const streamId = 'session:' + sessionId
|
|
1979
2094
|
ws.send(JSON.stringify({
|
|
1980
2095
|
type: 'open', streamId, endpoint: 'session/follow',
|
|
@@ -2752,7 +2867,7 @@ function serveStatic(req, res, url) {
|
|
|
2752
2867
|
})
|
|
2753
2868
|
return
|
|
2754
2869
|
}
|
|
2755
|
-
const apkOverride = pathname === '/dsh-remote.apk'
|
|
2870
|
+
const apkOverride = pathname === '/dsh-remote.apk' || pathname === '/dsh-remote-harmonyos-unsigned.hap' || pathname === '/dsh-remote-harmonyos.hap'
|
|
2756
2871
|
const baseDir = apkOverride ? path.join(ROOT, 'apk') : PUBLIC_DIR
|
|
2757
2872
|
const filePath = path.normalize(path.join(baseDir, pathname))
|
|
2758
2873
|
if (filePath !== baseDir && !filePath.startsWith(baseDir + path.sep)) {
|
|
@@ -2778,7 +2893,7 @@ function serveStatic(req, res, url) {
|
|
|
2778
2893
|
}
|
|
2779
2894
|
res.writeHead(200, {
|
|
2780
2895
|
'content-type': MIME[ext] || 'application/octet-stream',
|
|
2781
|
-
'cache-control': ext === '.html' || ext === '.js' || ext === '.css' ? 'no-cache' : 'public, max-age=300',
|
|
2896
|
+
'cache-control': pathname === '/harmonyos-update.json' ? 'no-store' : ext === '.html' || ext === '.js' || ext === '.css' ? 'no-cache' : 'public, max-age=300',
|
|
2782
2897
|
'content-length': st.size,
|
|
2783
2898
|
'last-modified': lastModified
|
|
2784
2899
|
})
|
|
@@ -2805,7 +2920,7 @@ function upstreamReachable(cb) {
|
|
|
2805
2920
|
req.end()
|
|
2806
2921
|
}
|
|
2807
2922
|
|
|
2808
|
-
function serveAdminApi(req, res, url) {
|
|
2923
|
+
async function serveAdminApi(req, res, url) {
|
|
2809
2924
|
const sub = url.pathname.slice('/admin/api'.length) || '/'
|
|
2810
2925
|
if (sub === '/dsh') return serveDshControl(req, res, url)
|
|
2811
2926
|
if (sub === '/state' && req.method === 'GET') {
|
|
@@ -2816,6 +2931,7 @@ function serveAdminApi(req, res, url) {
|
|
|
2816
2931
|
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
2817
2932
|
return
|
|
2818
2933
|
}
|
|
2934
|
+
const control = await dshControlDescription()
|
|
2819
2935
|
upstreamReachable((reachable) => {
|
|
2820
2936
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
2821
2937
|
res.end(JSON.stringify({
|
|
@@ -2831,8 +2947,8 @@ function serveAdminApi(req, res, url) {
|
|
|
2831
2947
|
host: HOST,
|
|
2832
2948
|
port: PORT,
|
|
2833
2949
|
protocol: { version: PROTOCOL_VERSION },
|
|
2834
|
-
capabilities: CAPABILITIES,
|
|
2835
|
-
dshControl:
|
|
2950
|
+
capabilities: { ...CAPABILITIES, dshLifecycle: control.supported ? 2 : 0 },
|
|
2951
|
+
dshControl: control,
|
|
2836
2952
|
upstream: { url: UPSTREAM.origin, reachable },
|
|
2837
2953
|
latest: {
|
|
2838
2954
|
version: latestState.version,
|
|
@@ -3971,6 +4087,127 @@ function workbenchPathInfo(rawPath) {
|
|
|
3971
4087
|
return { path: checked.abs }
|
|
3972
4088
|
}
|
|
3973
4089
|
|
|
4090
|
+
/* ---------- /handoff 跨端接续指针 ----------
|
|
4091
|
+
* 单条记录:最近一次"正在查看的会话"。双端客户端离开会话时 PUT,
|
|
4092
|
+
* 进入会话列表时 GET,发现是另一台设备写的就展示"在 xx 上打开过"接续卡片。
|
|
4093
|
+
* 只存指针(sessionId + 设备名),不存消息内容——消息仍由 DSH 实时通道下发。
|
|
4094
|
+
* 支持多服务器共用一个网关:key 是上游会话所属服务器没有区分需求,
|
|
4095
|
+
* 这里按"该 token 能看到的同一个 DSH"即单工作台语义,只存一份。
|
|
4096
|
+
*/
|
|
4097
|
+
const HANDOFF_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
4098
|
+
let handoffMemory = null
|
|
4099
|
+
|
|
4100
|
+
function handoffLoad() {
|
|
4101
|
+
if (handoffMemory) return handoffMemory
|
|
4102
|
+
try {
|
|
4103
|
+
const raw = JSON.parse(fs.readFileSync(HANDOFF_FILE, 'utf8'))
|
|
4104
|
+
if (raw && typeof raw === 'object' && typeof raw.sessionId === 'string' && raw.sessionId) {
|
|
4105
|
+
handoffMemory = {
|
|
4106
|
+
sessionId: String(raw.sessionId).slice(0, 128),
|
|
4107
|
+
title: String(raw.title || '').slice(0, 200),
|
|
4108
|
+
device: String(raw.device || '').slice(0, 80),
|
|
4109
|
+
clientId: String(raw.clientId || '').slice(0, 96),
|
|
4110
|
+
at: Number(raw.at) || 0
|
|
4111
|
+
}
|
|
4112
|
+
}
|
|
4113
|
+
} catch {}
|
|
4114
|
+
return handoffMemory
|
|
4115
|
+
}
|
|
4116
|
+
|
|
4117
|
+
function handoffSave(record) {
|
|
4118
|
+
handoffMemory = record
|
|
4119
|
+
try {
|
|
4120
|
+
fs.mkdirSync(path.dirname(HANDOFF_FILE), { recursive: true })
|
|
4121
|
+
fs.writeFileSync(HANDOFF_FILE, JSON.stringify(record, null, 2) + '\n')
|
|
4122
|
+
} catch {}
|
|
4123
|
+
}
|
|
4124
|
+
|
|
4125
|
+
function handoffFresh() {
|
|
4126
|
+
const rec = handoffLoad()
|
|
4127
|
+
if (!rec) return null
|
|
4128
|
+
if (!rec.at || Date.now() - rec.at > HANDOFF_MAX_AGE_MS) return null
|
|
4129
|
+
return rec
|
|
4130
|
+
}
|
|
4131
|
+
|
|
4132
|
+
function serveHandoff(req, res, url) {
|
|
4133
|
+
cors(res)
|
|
4134
|
+
if (req.method === 'OPTIONS') {
|
|
4135
|
+
res.writeHead(204)
|
|
4136
|
+
res.end()
|
|
4137
|
+
return
|
|
4138
|
+
}
|
|
4139
|
+
if (req.method !== 'GET' && req.method !== 'PUT' && req.method !== 'DELETE') {
|
|
4140
|
+
res.writeHead(405, { allow: 'GET, PUT, DELETE' })
|
|
4141
|
+
res.end()
|
|
4142
|
+
return
|
|
4143
|
+
}
|
|
4144
|
+
if (!authorized(req, url)) {
|
|
4145
|
+
authFailures++
|
|
4146
|
+
touchDevice(req, { failedAuth: true })
|
|
4147
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
4148
|
+
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
4149
|
+
return
|
|
4150
|
+
}
|
|
4151
|
+
touchDevice(req)
|
|
4152
|
+
|
|
4153
|
+
if (req.method === 'GET') {
|
|
4154
|
+
const rec = handoffFresh()
|
|
4155
|
+
fsJson(res, 200, { ok: true, handoff: rec })
|
|
4156
|
+
return
|
|
4157
|
+
}
|
|
4158
|
+
|
|
4159
|
+
if (req.method === 'DELETE') {
|
|
4160
|
+
handoffMemory = null
|
|
4161
|
+
try { fs.rmSync(HANDOFF_FILE, { force: true }) } catch {}
|
|
4162
|
+
fsJson(res, 200, { ok: true })
|
|
4163
|
+
return
|
|
4164
|
+
}
|
|
4165
|
+
|
|
4166
|
+
// PUT:读 JSON 体,字段白名单 + 截断,4KB 上限
|
|
4167
|
+
let body = ''
|
|
4168
|
+
let done = false
|
|
4169
|
+
req.on('data', chunk => {
|
|
4170
|
+
if (done) return
|
|
4171
|
+
body += chunk
|
|
4172
|
+
if (Buffer.byteLength(body) > 4096) {
|
|
4173
|
+
done = true
|
|
4174
|
+
fsJson(res, 413, { error: 'payload too large' })
|
|
4175
|
+
req.destroy()
|
|
4176
|
+
}
|
|
4177
|
+
})
|
|
4178
|
+
req.on('end', () => {
|
|
4179
|
+
if (done || res.headersSent) return
|
|
4180
|
+
done = true
|
|
4181
|
+
let payload
|
|
4182
|
+
try {
|
|
4183
|
+
payload = JSON.parse(body || '{}')
|
|
4184
|
+
} catch {
|
|
4185
|
+
fsJson(res, 400, { error: 'invalid json' })
|
|
4186
|
+
return
|
|
4187
|
+
}
|
|
4188
|
+
// JSON 允许 null/数组/标量:后续读 payload.sessionId 会在 end 回调里抛
|
|
4189
|
+
// TypeError(外层 try/catch 接不住,请求悬挂无响应,2026-09-20 评审复现)。
|
|
4190
|
+
// 先校验为普通对象,不合规直接 400。
|
|
4191
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
4192
|
+
fsJson(res, 400, { error: 'object body required' })
|
|
4193
|
+
return
|
|
4194
|
+
}
|
|
4195
|
+
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : ''
|
|
4196
|
+
if (!sessionId) {
|
|
4197
|
+
fsJson(res, 400, { error: 'sessionId required' })
|
|
4198
|
+
return
|
|
4199
|
+
}
|
|
4200
|
+
handoffSave({
|
|
4201
|
+
sessionId: sessionId.slice(0, 128),
|
|
4202
|
+
title: String(payload.title || '').slice(0, 200),
|
|
4203
|
+
device: String(payload.device || '').slice(0, 80),
|
|
4204
|
+
clientId: String(payload.clientId || '').slice(0, 96),
|
|
4205
|
+
at: Date.now()
|
|
4206
|
+
})
|
|
4207
|
+
fsJson(res, 200, { ok: true })
|
|
4208
|
+
})
|
|
4209
|
+
}
|
|
4210
|
+
|
|
3974
4211
|
function loadWorkbench() {
|
|
3975
4212
|
try {
|
|
3976
4213
|
const raw = JSON.parse(fs.readFileSync(WORKBENCH_FILE, 'utf8'))
|
|
@@ -4088,7 +4325,50 @@ function readApiRequest(req, maxBytes = 4 * 1024 * 1024) {
|
|
|
4088
4325
|
})
|
|
4089
4326
|
}
|
|
4090
4327
|
|
|
4091
|
-
|
|
4328
|
+
// Only the explicit pre-admission image rejection is safe to retry. Never retry
|
|
4329
|
+
// a timeout, ambiguous transport failure, or an already accepted prompt.
|
|
4330
|
+
function imageAdmissionRejected(body) {
|
|
4331
|
+
const result = body?.result
|
|
4332
|
+
return result?.ok === false && result.error?.code === 'session/attachment-invalid'
|
|
4333
|
+
&& result.error?.details?.reason === 'MODEL_DOES_NOT_SUPPORT_IMAGES'
|
|
4334
|
+
}
|
|
4335
|
+
|
|
4336
|
+
async function bridgeVisionPrompt(payload) {
|
|
4337
|
+
if (!Array.isArray(payload?.content) || !payload.content.some(part => part?.type === 'image')) return null
|
|
4338
|
+
const pluginFetch = (route, options = {}) => fetch(new URL('/api/dsh-image-vision/' + route, UPSTREAM), {
|
|
4339
|
+
...options,
|
|
4340
|
+
headers: dshUpstreamHeaders({ 'content-type': 'application/json' }),
|
|
4341
|
+
signal: AbortSignal.timeout(UPSTREAM_REQUEST_TIMEOUT_MS),
|
|
4342
|
+
redirect: 'error',
|
|
4343
|
+
})
|
|
4344
|
+
const configResponse = await pluginFetch('config')
|
|
4345
|
+
if (configResponse.status === 404) throw new Error('当前模型不支持图片,未找到兼容的 dsh-image-vision 插件;请安装并启用视觉辅助插件,或切换视觉模型。')
|
|
4346
|
+
if (!configResponse.ok) throw new Error('无法检查视觉辅助插件状态,请稍后重试。')
|
|
4347
|
+
const config = await configResponse.json()
|
|
4348
|
+
if (config?.config?.enabled !== true) throw new Error('当前模型不支持图片,视觉辅助插件尚未开启。请在 DSH 中启用插件后重试。')
|
|
4349
|
+
const content = []
|
|
4350
|
+
for (const part of payload.content) {
|
|
4351
|
+
if (part?.type !== 'image') { content.push(part); continue }
|
|
4352
|
+
if (typeof part.data !== 'string' || !part.data || typeof part.mediaType !== 'string') {
|
|
4353
|
+
throw new Error('视觉辅助转换失败:图片缺少内联数据,请重新选择图片。')
|
|
4354
|
+
}
|
|
4355
|
+
const response = await pluginFetch('attach', { method: 'POST', body: JSON.stringify({
|
|
4356
|
+
data: part.data, mediaType: part.mediaType,
|
|
4357
|
+
...(typeof part.name === 'string' ? { name: part.name } : {}),
|
|
4358
|
+
}) })
|
|
4359
|
+
const value = await response.json().catch(() => null)
|
|
4360
|
+
// Accept only the plugin's same-upstream attachment reference, not arbitrary
|
|
4361
|
+
// returned text/URLs. Preserve every other block and the original send mode.
|
|
4362
|
+
if (!response.ok || value?.ok !== true || typeof value.markdown !== 'string'
|
|
4363
|
+
|| !/^!\[图片\]\(\/api\/dsh-image-vision\/raw\/[A-Za-z0-9%._~-]+\?[^\s()<>]+\)$/.test(value.markdown)) {
|
|
4364
|
+
throw new Error('视觉辅助图片上传失败,消息尚未发送,请检查插件后重试。')
|
|
4365
|
+
}
|
|
4366
|
+
content.push({ type: 'text', text: value.markdown })
|
|
4367
|
+
}
|
|
4368
|
+
return { ...payload, content }
|
|
4369
|
+
}
|
|
4370
|
+
|
|
4371
|
+
async function forwardLegacyRpc(url, raw, allowVisionBridge = true) {
|
|
4092
4372
|
const target = new URL(url.pathname + url.search, UPSTREAM)
|
|
4093
4373
|
const response = await fetch(target, {
|
|
4094
4374
|
method: 'POST',
|
|
@@ -4096,7 +4376,20 @@ async function forwardLegacyRpc(url, raw) {
|
|
|
4096
4376
|
body: raw,
|
|
4097
4377
|
signal: AbortSignal.timeout(UPSTREAM_REQUEST_TIMEOUT_MS),
|
|
4098
4378
|
})
|
|
4099
|
-
|
|
4379
|
+
const result = { status: response.status, headers: response.headers, raw: await response.text() }
|
|
4380
|
+
if (allowVisionBridge) {
|
|
4381
|
+
let body, request
|
|
4382
|
+
try { body = JSON.parse(result.raw); request = JSON.parse(raw) } catch {}
|
|
4383
|
+
if (request?.method === 'session.prompt' && imageAdmissionRejected(body)) {
|
|
4384
|
+
try {
|
|
4385
|
+
const bridged = await bridgeVisionPrompt(request.payload)
|
|
4386
|
+
if (bridged) return forwardLegacyRpc(url, JSON.stringify({ ...request, payload: bridged }), false)
|
|
4387
|
+
} catch (error) {
|
|
4388
|
+
return { ...result, status: 200, raw: JSON.stringify(legacyEnvelope(request.rpcId, modernError(error.message, 'image-vision-failed'))) }
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
4391
|
+
}
|
|
4392
|
+
return result
|
|
4100
4393
|
}
|
|
4101
4394
|
|
|
4102
4395
|
function sendBufferedUpstreamResponse(res, response) {
|
|
@@ -4286,6 +4579,15 @@ function proxyApi(req, res, url) {
|
|
|
4286
4579
|
}
|
|
4287
4580
|
|
|
4288
4581
|
// ---------- 其它 ----------
|
|
4582
|
+
async function dshControlDescription() {
|
|
4583
|
+
if (!DSH_CONTROL_SUPPORT.supported) return DSH_CONTROL_SUPPORT
|
|
4584
|
+
if (process.platform !== 'win32' && DSH_CONTROL_MODE !== 'process') return DSH_CONTROL_SUPPORT
|
|
4585
|
+
const status = await dshServiceStatus()
|
|
4586
|
+
return { supported: status.supported, manager: status.manager || DSH_CONTROL_SUPPORT.manager,
|
|
4587
|
+
...(status.code ? { code: status.code, message: status.message } : {}),
|
|
4588
|
+
...(status.canRestart !== undefined ? { canRestart: status.canRestart } : {}) }
|
|
4589
|
+
}
|
|
4590
|
+
|
|
4289
4591
|
async function serveHealth(req, res, url) {
|
|
4290
4592
|
restoreMissingTokenFile()
|
|
4291
4593
|
const eventHealth = Object.fromEntries(Object.entries(eventCollectorState).map(([kind, state]) => [kind, {
|
|
@@ -4326,6 +4628,7 @@ async function serveHealth(req, res, url) {
|
|
|
4326
4628
|
const eventsOk = eventHealth.mux.connected && eventHealth.host.connected
|
|
4327
4629
|
const readiness = { ok: upstreamOk && eventsOk, upstreamOk, eventsOk }
|
|
4328
4630
|
const status = readiness.ok ? 'ready' : upstreamReachable ? 'degraded' : 'offline'
|
|
4631
|
+
const control = await dshControlDescription()
|
|
4329
4632
|
cors(res)
|
|
4330
4633
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
4331
4634
|
res.end(JSON.stringify({
|
|
@@ -4336,8 +4639,8 @@ async function serveHealth(req, res, url) {
|
|
|
4336
4639
|
readiness,
|
|
4337
4640
|
version: VERSION,
|
|
4338
4641
|
protocol: { version: PROTOCOL_VERSION },
|
|
4339
|
-
capabilities: CAPABILITIES,
|
|
4340
|
-
dshControl:
|
|
4642
|
+
capabilities: { ...CAPABILITIES, dshLifecycle: control.supported ? 2 : 0 },
|
|
4643
|
+
dshControl: control,
|
|
4341
4644
|
pid: process.pid,
|
|
4342
4645
|
upstream: UPSTREAM.origin,
|
|
4343
4646
|
upstreamProbe: DSH_HEALTH_PATH,
|
|
@@ -4369,7 +4672,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
4369
4672
|
if (url.pathname === '/workbench' || url.pathname.startsWith('/workbench/')) return serveWorkbench(req, res, url)
|
|
4370
4673
|
if (url.pathname === '/diagnostics') return serveDiagnostics(req, res, url)
|
|
4371
4674
|
if (url.pathname === '/feedback') return serveFeedback(req, res, url)
|
|
4372
|
-
if (url.pathname
|
|
4675
|
+
if (url.pathname === '/handoff') return serveHandoff(req, res, url)
|
|
4676
|
+
if (url.pathname.startsWith('/admin/api')) return await serveAdminApi(req, res, url)
|
|
4373
4677
|
if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
|
|
4374
4678
|
if (url.pathname === '/api/ws-ticket') return serveWsTicket(req, res, url)
|
|
4375
4679
|
if (url.pathname === '/api/events.poll') return serveEventPoll(req, res, url)
|
|
@@ -4417,6 +4721,41 @@ function wsPingFrame(masked) {
|
|
|
4417
4721
|
return Buffer.concat([Buffer.from([0x89, 0x80]), mask])
|
|
4418
4722
|
}
|
|
4419
4723
|
|
|
4724
|
+
/**
|
|
4725
|
+
* 从缓冲头部解析一个完整客户端帧(RFC6455 §5.2)。
|
|
4726
|
+
* 返回 null 表示数据不够、需要等下一个 chunk;解析成功返回
|
|
4727
|
+
* { fin, opcode, payload(已解掩码), total(帧总长) }。
|
|
4728
|
+
* 客户端帧必须带掩码位(§5.1),无掩码直接按协议错误返回 null 并由调用方清缓冲。
|
|
4729
|
+
* 只支持 ≤125 载荷的控制帧上限约束交由调用方(Ping 载荷协议上限 125);
|
|
4730
|
+
* 长度用 126/127 扩展格式完整解析,保证数据帧边界正确跳过。
|
|
4731
|
+
*/
|
|
4732
|
+
function wsParseClientFrame(buf) {
|
|
4733
|
+
if (buf.length < 2) return null
|
|
4734
|
+
const fin = (buf[0] & 0x80) !== 0
|
|
4735
|
+
const opcode = buf[0] & 0x0f
|
|
4736
|
+
const masked = (buf[1] & 0x80) !== 0
|
|
4737
|
+
let len = buf[1] & 0x7f
|
|
4738
|
+
let off = 2
|
|
4739
|
+
if (len === 126) {
|
|
4740
|
+
if (buf.length < 4) return null
|
|
4741
|
+
len = buf.readUInt16BE(2)
|
|
4742
|
+
off = 4
|
|
4743
|
+
} else if (len === 127) {
|
|
4744
|
+
if (buf.length < 10) return null
|
|
4745
|
+
const big = buf.readBigUInt64BE(2)
|
|
4746
|
+
if (big > BigInt(1 << 20)) return 'oversize'
|
|
4747
|
+
len = Number(big)
|
|
4748
|
+
off = 10
|
|
4749
|
+
}
|
|
4750
|
+
if (!masked) return 'unmasked'
|
|
4751
|
+
if (buf.length < off + 4 + len) return null
|
|
4752
|
+
const mask = buf.subarray(off, off + 4)
|
|
4753
|
+
const payload = Buffer.alloc(len)
|
|
4754
|
+
const src = buf.subarray(off + 4, off + 4 + len)
|
|
4755
|
+
for (let i = 0; i < len; i++) payload[i] = src[i] ^ mask[i & 3]
|
|
4756
|
+
return { fin, opcode, payload, total: off + 4 + len }
|
|
4757
|
+
}
|
|
4758
|
+
|
|
4420
4759
|
/**
|
|
4421
4760
|
* 原始 TCP 透传也要维护 WebSocket 控制帧活性:
|
|
4422
4761
|
* - 浏览器侧收到网关的未掩码 Ping 后会自动回 Pong;
|
|
@@ -4521,6 +4860,41 @@ function acceptCollectorClient(req, socket, head, kind, device) {
|
|
|
4521
4860
|
socket.setNoDelay(true)
|
|
4522
4861
|
collectorClients[kind].add(socket)
|
|
4523
4862
|
eventCollectorState[kind].clients = collectorClients[kind].size
|
|
4863
|
+
// 客户端(鸿蒙 lws 等严格 RFC 实现)会发掩码 Ping 并等待 Pong;
|
|
4864
|
+
// 网关必须按 RFC6455 回 Pong(0x8A),且服务端→客户端方向不带掩码位
|
|
4865
|
+
// (RFC6455 §5.1:客户端帧必须掩码,服务端帧必须不掩码,违规即断链)。
|
|
4866
|
+
// TCP 不保证帧边界与 data 事件对齐,逐字节扫描当前 chunk 会把跨包帧、
|
|
4867
|
+
// 其他帧的掩码/载荷误认成帧头(2026-09-20 评审复现:掩码后拆包回出错误
|
|
4868
|
+
// Pong 8a03010203)。这里按 RFC6455 组帧:维护 per-socket 缓冲,帧头
|
|
4869
|
+
// (含 126/127 扩展长度)与载荷凑齐才应答,整帧消费,控制帧间数据帧安全跳过。
|
|
4870
|
+
let rx = Buffer.alloc(0)
|
|
4871
|
+
socket.on('data', (chunk) => {
|
|
4872
|
+
rx = rx.length ? Buffer.concat([rx, chunk]) : chunk
|
|
4873
|
+
while (true) {
|
|
4874
|
+
let frame
|
|
4875
|
+
try {
|
|
4876
|
+
frame = wsParseClientFrame(rx)
|
|
4877
|
+
} catch {
|
|
4878
|
+
rx = Buffer.alloc(0)
|
|
4879
|
+
break
|
|
4880
|
+
}
|
|
4881
|
+
if (!frame) break
|
|
4882
|
+
if (typeof frame === 'string') { rx = Buffer.alloc(0); break }
|
|
4883
|
+
rx = rx.subarray(frame.total)
|
|
4884
|
+
if (frame.payload.length > 125) continue
|
|
4885
|
+
if (frame.fin === false) continue
|
|
4886
|
+
// 仅应答 Ping(RFC6455 §5.5 控制帧载荷上限 125);Pong(0xA)消费后丢弃,
|
|
4887
|
+
// 其余 opcode(含数据帧/分片)跳过
|
|
4888
|
+
if (frame.opcode === 0x9) {
|
|
4889
|
+
const pong = Buffer.alloc(2 + frame.payload.length)
|
|
4890
|
+
pong[0] = 0x8a
|
|
4891
|
+
pong[1] = frame.payload.length
|
|
4892
|
+
frame.payload.copy(pong, 2)
|
|
4893
|
+
try { socket.write(pong) } catch {}
|
|
4894
|
+
}
|
|
4895
|
+
}
|
|
4896
|
+
if (rx.length > 1 << 20) rx = Buffer.alloc(0)
|
|
4897
|
+
})
|
|
4524
4898
|
for (const raw of collectorReplay[kind].values()) {
|
|
4525
4899
|
if (socket.destroyed || !socket.writable) break
|
|
4526
4900
|
try { socket.write(encodeWsText(raw)) } catch { break }
|
package/index.mjs
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
* 浏览器侧入口由 client half 注册在 DSH 原生侧边栏(见 client.js)。
|
|
7
7
|
*/
|
|
8
8
|
import { execFileSync, spawn } from 'node:child_process'
|
|
9
|
-
import { appendFileSync, chmodSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { appendFileSync, chmodSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
10
10
|
import { stat } from 'node:fs/promises'
|
|
11
11
|
import net from 'node:net'
|
|
12
12
|
import { homedir, hostname, networkInterfaces } from 'node:os'
|
|
13
13
|
import { dirname, extname, normalize, resolve } from 'node:path'
|
|
14
14
|
import { fileURLToPath } from 'node:url'
|
|
15
|
-
import { createPluginCenter } from './plugin-center.mjs'
|
|
15
|
+
import { createPluginCenter, detectProfile } from './plugin-center.mjs'
|
|
16
16
|
|
|
17
17
|
export const name = 'dsh-remote'
|
|
18
18
|
export const inject = ['webServer', 'commands', 'agents', 'connection']
|
|
@@ -976,10 +976,37 @@ async function serveStatic(req, res, ctx) {
|
|
|
976
976
|
}
|
|
977
977
|
|
|
978
978
|
const pluginCenters = new WeakMap()
|
|
979
|
+
export function saveDshLaunchConfig(ctx) {
|
|
980
|
+
// Desktop owns its Electron runtime. Never record a desktop or unknown launcher.
|
|
981
|
+
if (process.platform !== 'win32' || process.versions.electron) return false
|
|
982
|
+
const profile = detectProfile(ctx)
|
|
983
|
+
if (!profile?.cli || profile.name === 'desktop' || resolve(process.argv[1] || '') !== resolve(profile.cli)) return false
|
|
984
|
+
const env = { DSH_HOME: profile.home }
|
|
985
|
+
for (const key of ['HOME', 'USERPROFILE', 'PATH', 'NODE_OPTIONS']) {
|
|
986
|
+
if (process.env[key] !== undefined) env[key] = process.env[key]
|
|
987
|
+
}
|
|
988
|
+
const args = [profile.cli, ...process.argv.slice(2)]
|
|
989
|
+
if (!args.includes('--no-open')) args.push('--no-open')
|
|
990
|
+
const config = { version: 1, executable: process.execPath, args, cwd: process.cwd(),
|
|
991
|
+
env, profile: profile.name, upstream: upstreamUrlForListener({ host: ctx.webServer.host, port: ctx.webServer.port }) }
|
|
992
|
+
const file = process.env.DSH_REMOTE_DSH_LAUNCH_FILE || `${homedir()}/.dsh-remote/dsh-launch.json`
|
|
993
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
994
|
+
try {
|
|
995
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
996
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 })
|
|
997
|
+
renameSync(tmp, file)
|
|
998
|
+
return true
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
logGateway('无法保存 DSH 启动配置: ' + err.message)
|
|
1001
|
+
return false
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
979
1005
|
export function apply(ctx) {
|
|
980
1006
|
pluginCenters.set(ctx, createPluginCenter(ctx))
|
|
981
1007
|
dshListen = { host: ctx.webServer.host, port: ctx.webServer.port }
|
|
982
1008
|
dshConnection = ctx.connection
|
|
1009
|
+
saveDshLaunchConfig(ctx)
|
|
983
1010
|
ctx.effect(() => ctx.webServer.register({
|
|
984
1011
|
kind: 'prefix',
|
|
985
1012
|
path: MOUNT,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.7.0
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
package/public/app.js
CHANGED
|
@@ -132,6 +132,7 @@ const CUSTOM_SELECT_TITLES = {
|
|
|
132
132
|
'session-sort': 'sessions.sortLabel',
|
|
133
133
|
'fs-workspace': 'workspace.select',
|
|
134
134
|
'mobile-enter-action': 'settings.mobileEnterTitle',
|
|
135
|
+
'busy-send-mode': 'settings.busySendTitle',
|
|
135
136
|
'bg-interval': 'settings.bgIntervalTitle',
|
|
136
137
|
'new-session-workspace': 'workspace.select'
|
|
137
138
|
}
|
|
@@ -606,7 +607,9 @@ async function rpc(method, payload = {}, timeoutMs = 45000) {
|
|
|
606
607
|
if (!full?.result) throw new Error(t('err.badResponse'))
|
|
607
608
|
if (!full.result.ok) {
|
|
608
609
|
const err = full.result.error || {}
|
|
609
|
-
|
|
610
|
+
const error = new Error(err.message || t('err.dshError'))
|
|
611
|
+
error.rpcRejected = true
|
|
612
|
+
throw error
|
|
610
613
|
}
|
|
611
614
|
return full.result.value
|
|
612
615
|
}
|
|
@@ -2416,6 +2419,7 @@ function renderSessionSub() {
|
|
|
2416
2419
|
|
|
2417
2420
|
/** 顶栏状态: 运行中=蓝色流动渐变, 中断/出错=橙红渐变, 空闲=原样式 */
|
|
2418
2421
|
function updateSessionStatus() {
|
|
2422
|
+
renderComposerSend()
|
|
2419
2423
|
const s = state.byId.get(state.current)
|
|
2420
2424
|
const head = $('session-head')
|
|
2421
2425
|
if (!head) return
|
|
@@ -3248,33 +3252,153 @@ async function sendSessionText(text) {
|
|
|
3248
3252
|
return sendSessionContent(text, [])
|
|
3249
3253
|
}
|
|
3250
3254
|
|
|
3251
|
-
|
|
3255
|
+
let composerSending = false
|
|
3256
|
+
const composerSendFeedback = new Map()
|
|
3257
|
+
const steeringInFlight = new Set()
|
|
3258
|
+
function composerContext() { return `${state.server}\0${state.current}` }
|
|
3259
|
+
function steerSendingEnabled() { return LS.get('steerSendingEnabled', '0') === '1' }
|
|
3260
|
+
function composerIsBusy() {
|
|
3261
|
+
return !!state.byId.get(state.current)?.running || (state.queues[state.current] || []).some(item => item.placement === 'queued')
|
|
3262
|
+
}
|
|
3263
|
+
function composerSendMode(alternate = false) {
|
|
3264
|
+
if (!steerSendingEnabled() || !composerIsBusy()) return 'queue'
|
|
3265
|
+
const preferred = LS.get('busySendMode', 'queue') === 'steer' ? 'steer' : 'queue'
|
|
3266
|
+
return alternate ? (preferred === 'queue' ? 'steer' : 'queue') : preferred
|
|
3267
|
+
}
|
|
3268
|
+
function renderComposerSend() {
|
|
3269
|
+
const enabled = steerSendingEnabled() && composerIsBusy()
|
|
3270
|
+
const mode = composerSendMode()
|
|
3271
|
+
for (const id of ['btn-send', 'btn-fs-send']) {
|
|
3272
|
+
const button = $(id)
|
|
3273
|
+
if (!button) continue
|
|
3274
|
+
if (composerSending || !button.classList.contains('send-hold-ready')) button.textContent = t(composerSending ? 'send.submitting' : enabled ? `send.${mode}Button` : 'composer.send')
|
|
3275
|
+
button.disabled = composerSending
|
|
3276
|
+
button.classList.toggle('steer-mode', enabled && mode === 'steer')
|
|
3277
|
+
button.title = t(enabled ? 'send.holdHint' : 'composer.send')
|
|
3278
|
+
button.setAttribute('aria-label', button.textContent)
|
|
3279
|
+
}
|
|
3280
|
+
const feedback = composerSendFeedback.get(composerContext())
|
|
3281
|
+
const node = $('composer-send-status')
|
|
3282
|
+
if (node) {
|
|
3283
|
+
node.textContent = feedback ? t(feedback.key, { msg: feedback.message || '' }) : enabled ? t('send.holdHint') : ''
|
|
3284
|
+
node.classList.toggle('hidden', !node.textContent)
|
|
3285
|
+
node.dataset.phase = feedback?.phase || 'hint'
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
function setComposerSendFeedback(context, key, phase, message = '') {
|
|
3289
|
+
composerSendFeedback.set(context, { key, phase, message })
|
|
3290
|
+
if (composerSendFeedback.size > 100) composerSendFeedback.delete(composerSendFeedback.keys().next().value)
|
|
3291
|
+
renderComposerSend()
|
|
3292
|
+
}
|
|
3293
|
+
// No preflight or automatic retry: an ambiguous timeout may already have delivered the message.
|
|
3294
|
+
async function submitSteer(method, payload, context) {
|
|
3295
|
+
if (steeringInFlight.has(context)) {
|
|
3296
|
+
if (composerContext() === context) toast(t('send.steerPending'), 'err')
|
|
3297
|
+
return null
|
|
3298
|
+
}
|
|
3299
|
+
steeringInFlight.add(context)
|
|
3300
|
+
setComposerSendFeedback(context, 'send.steerPending', 'pending')
|
|
3301
|
+
const slow = setTimeout(() => setComposerSendFeedback(context, 'send.steerSlow', 'pending'), 3000)
|
|
3302
|
+
try {
|
|
3303
|
+
const result = await rpc(method, payload)
|
|
3304
|
+
if (!result?.accepted) {
|
|
3305
|
+
setComposerSendFeedback(context, 'send.steerUnconfirmed', 'unknown')
|
|
3306
|
+
return result
|
|
3307
|
+
}
|
|
3308
|
+
setComposerSendFeedback(context, 'send.steerAccepted', 'accepted')
|
|
3309
|
+
if (composerContext() === context) toast(t('send.steerAccepted'), 'ok')
|
|
3310
|
+
return result
|
|
3311
|
+
} catch (error) {
|
|
3312
|
+
setComposerSendFeedback(context, error.rpcRejected || error.message === 'AUTH' ? 'send.steerRejected' : 'send.steerUnconfirmed', error.rpcRejected ? 'error' : 'unknown', error.message)
|
|
3313
|
+
if (error.message === 'AUTH' && composerContext() === context) authFailure()
|
|
3314
|
+
return null
|
|
3315
|
+
} finally { clearTimeout(slow); steeringInFlight.delete(context) }
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
function bindComposerSend(button) {
|
|
3319
|
+
let press = null
|
|
3320
|
+
let suppressClickUntil = 0
|
|
3321
|
+
const cancel = () => {
|
|
3322
|
+
if (!press) return
|
|
3323
|
+
clearTimeout(press.timer)
|
|
3324
|
+
press = null
|
|
3325
|
+
suppressClickUntil = Date.now() + 1000
|
|
3326
|
+
button.classList.remove('send-hold-ready')
|
|
3327
|
+
renderComposerSend()
|
|
3328
|
+
}
|
|
3329
|
+
button.addEventListener('pointerdown', event => {
|
|
3330
|
+
if (event.button !== 0 || event.isPrimary === false || composerSending || !steerSendingEnabled() || !composerIsBusy()) return
|
|
3331
|
+
cancel()
|
|
3332
|
+
suppressClickUntil = 0
|
|
3333
|
+
press = { id: event.pointerId, x: event.clientX, y: event.clientY, context: composerContext(), mode: composerSendMode(), alternate: composerSendMode(true), ready: false }
|
|
3334
|
+
press.timer = setTimeout(() => {
|
|
3335
|
+
if (!press || composerSending || composerContext() !== press.context) { cancel(); return }
|
|
3336
|
+
press.ready = true
|
|
3337
|
+
button.classList.add('send-hold-ready')
|
|
3338
|
+
button.textContent = t(press.alternate === 'steer' ? 'send.releaseSteer' : 'send.releaseQueue')
|
|
3339
|
+
}, 450)
|
|
3340
|
+
button.setPointerCapture?.(event.pointerId)
|
|
3341
|
+
})
|
|
3342
|
+
button.addEventListener('pointermove', event => {
|
|
3343
|
+
if (press && Math.hypot(event.clientX - press.x, event.clientY - press.y) > 12) cancel()
|
|
3344
|
+
})
|
|
3345
|
+
button.addEventListener('pointerup', event => {
|
|
3346
|
+
if (!press || event.pointerId !== press.id) return
|
|
3347
|
+
const intent = press
|
|
3348
|
+
cancel()
|
|
3349
|
+
event.preventDefault()
|
|
3350
|
+
if (intent.context === composerContext() && steerSendingEnabled()) void sendMessage(intent.ready ? intent.alternate : intent.mode)
|
|
3351
|
+
})
|
|
3352
|
+
for (const name of ['pointercancel', 'lostpointercapture', 'blur']) button.addEventListener(name, cancel)
|
|
3353
|
+
button.addEventListener('contextmenu', event => { if (press || Date.now() < suppressClickUntil) event.preventDefault() })
|
|
3354
|
+
button.addEventListener('keydown', () => { cancel(); suppressClickUntil = 0 })
|
|
3355
|
+
button.addEventListener('click', () => { if (Date.now() >= suppressClickUntil) void sendMessage() })
|
|
3356
|
+
}
|
|
3357
|
+
|
|
3358
|
+
async function sendSessionContent(text, images, requestedMode = 'queue') {
|
|
3252
3359
|
const clean = String(text || '').trim()
|
|
3253
|
-
if ((!clean && !images.length) || !state.current) return false
|
|
3360
|
+
if ((!clean && !images.length) || !state.current || composerSending) return false
|
|
3254
3361
|
const sessionId = state.current
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3362
|
+
const context = composerContext()
|
|
3363
|
+
const mode = requestedMode === 'steer' && steerSendingEnabled() && composerIsBusy() ? 'steer' : 'queue'
|
|
3364
|
+
if (mode === 'steer' && clean.startsWith('/')) {
|
|
3365
|
+
setComposerSendFeedback(context, 'send.steerSlash', 'error')
|
|
3366
|
+
return false
|
|
3258
3367
|
}
|
|
3368
|
+
composerSending = true
|
|
3259
3369
|
const buttons = [$('btn-send'), $('btn-fs-send')].filter(Boolean)
|
|
3260
3370
|
buttons.forEach(button => { button.disabled = true })
|
|
3261
3371
|
state.pendingPrompts.add(sessionId)
|
|
3262
3372
|
try {
|
|
3373
|
+
if (mode === 'steer') setComposerSendFeedback(context, 'send.steerPending', 'pending')
|
|
3374
|
+
else { composerSendFeedback.delete(context); renderComposerSend() }
|
|
3375
|
+
if (images.length === 0 && clean && await runSlashCommand(clean)) {
|
|
3376
|
+
state.sessionActivity.add(sessionId)
|
|
3377
|
+
return true
|
|
3378
|
+
}
|
|
3263
3379
|
const content = [...await encodeComposerImagesFor(images)]
|
|
3264
3380
|
if (clean) content.push({ type: 'text', text: clean })
|
|
3381
|
+
if (composerContext() !== context) {
|
|
3382
|
+
setComposerSendFeedback(context, 'send.contextChanged', 'error')
|
|
3383
|
+
return false
|
|
3384
|
+
}
|
|
3265
3385
|
setSessionRecovery('resuming')
|
|
3266
|
-
const
|
|
3386
|
+
const payload = {
|
|
3267
3387
|
sessionId,
|
|
3268
|
-
mode
|
|
3388
|
+
mode,
|
|
3269
3389
|
content
|
|
3270
|
-
}
|
|
3390
|
+
}
|
|
3391
|
+
const v = mode === 'steer'
|
|
3392
|
+
? await submitSteer('session.prompt', payload, context)
|
|
3393
|
+
: await safeRpc('session.prompt', payload, t('send.failed'))
|
|
3394
|
+
if (composerContext() !== context) return !!v?.accepted
|
|
3271
3395
|
if (v?.accepted) {
|
|
3272
3396
|
state.sessionActivity.add(sessionId)
|
|
3273
|
-
if (
|
|
3397
|
+
if (composerContext() === context) {
|
|
3274
3398
|
setSessionRecovery('ready')
|
|
3275
3399
|
noteSessionTurnTime(sessionId, Date.now())
|
|
3276
3400
|
renderSessions()
|
|
3277
|
-
toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
|
|
3401
|
+
if (mode !== 'steer') toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
|
|
3278
3402
|
}
|
|
3279
3403
|
return true
|
|
3280
3404
|
}
|
|
@@ -3286,14 +3410,17 @@ async function sendSessionContent(text, images) {
|
|
|
3286
3410
|
if (state.current === sessionId) setSessionRecovery('error')
|
|
3287
3411
|
return false
|
|
3288
3412
|
} catch (e) {
|
|
3289
|
-
if (
|
|
3413
|
+
if (mode === 'steer') setComposerSendFeedback(context, 'send.steerRejected', 'error', e?.message || String(e))
|
|
3414
|
+
if (composerContext() === context) {
|
|
3290
3415
|
setSessionRecovery('error', e?.message)
|
|
3291
3416
|
toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
|
|
3292
3417
|
}
|
|
3293
3418
|
return false
|
|
3294
3419
|
} finally {
|
|
3295
3420
|
state.pendingPrompts.delete(sessionId)
|
|
3421
|
+
composerSending = false
|
|
3296
3422
|
buttons.forEach(button => { button.disabled = false })
|
|
3423
|
+
renderComposerSend()
|
|
3297
3424
|
}
|
|
3298
3425
|
}
|
|
3299
3426
|
|
|
@@ -3304,16 +3431,18 @@ async function encodeComposerImagesFor(images) {
|
|
|
3304
3431
|
})))
|
|
3305
3432
|
}
|
|
3306
3433
|
|
|
3307
|
-
async function sendMessage() {
|
|
3434
|
+
async function sendMessage(requestedMode) {
|
|
3308
3435
|
const input = $('composer-input')
|
|
3309
|
-
const
|
|
3436
|
+
const originalText = input.value
|
|
3437
|
+
const text = originalText.trim()
|
|
3438
|
+
const context = composerContext()
|
|
3310
3439
|
const images = state.composerImages.slice()
|
|
3311
3440
|
if ((!text && !images.length) || !state.current) return
|
|
3312
3441
|
if (images.length && text.startsWith('/')) { toast(t('composer.imageSlashUnsupported'), 'err'); return }
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
autosize(input)
|
|
3316
|
-
clearComposerImages()
|
|
3442
|
+
const mode = typeof requestedMode === 'string' ? requestedMode : composerSendMode()
|
|
3443
|
+
if (await sendSessionContent(text, images, mode) && composerContext() === context) {
|
|
3444
|
+
if (input.value === originalText) { input.value = ''; autosize(input) }
|
|
3445
|
+
if (state.composerImages.length === images.length && images.every((item, i) => item === state.composerImages[i])) clearComposerImages()
|
|
3317
3446
|
}
|
|
3318
3447
|
}
|
|
3319
3448
|
|
|
@@ -3842,20 +3971,21 @@ function queuePreview(item) {
|
|
|
3842
3971
|
}
|
|
3843
3972
|
async function steerQueueItem(itemId) {
|
|
3844
3973
|
const sessionId = state.current
|
|
3974
|
+
const context = composerContext()
|
|
3845
3975
|
const key = `${sessionId}:${itemId}`
|
|
3846
3976
|
const s = state.byId.get(sessionId)
|
|
3847
3977
|
if (!sessionId || !s?.running || state.queueSteering[key]) return
|
|
3848
3978
|
state.queueSteering[key] = true
|
|
3849
3979
|
renderQueue()
|
|
3850
3980
|
try {
|
|
3851
|
-
|
|
3852
|
-
if (v?.accepted) toast(t('queue.steerSubmitted'), 'ok')
|
|
3981
|
+
await submitSteer('session.updateQueue', { sessionId, itemId, action: { kind: 'steer' } }, context)
|
|
3853
3982
|
} finally {
|
|
3854
3983
|
delete state.queueSteering[key]
|
|
3855
3984
|
renderQueue()
|
|
3856
3985
|
}
|
|
3857
3986
|
}
|
|
3858
3987
|
function renderQueue() {
|
|
3988
|
+
renderComposerSend()
|
|
3859
3989
|
const s = state.byId.get(state.current)
|
|
3860
3990
|
if (!s) return
|
|
3861
3991
|
const items = (state.queues[state.current] || []).filter(item => item?.placement === 'queued')
|
|
@@ -6960,8 +7090,8 @@ function bindUi() {
|
|
|
6960
7090
|
$('rename-confirm').addEventListener('click', confirmRenameSession)
|
|
6961
7091
|
$('rename-session-input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.isComposing) confirmRenameSession() })
|
|
6962
7092
|
$('modal-rename').addEventListener('click', e => { if (e.target === $('modal-rename')) closeRenameSession() })
|
|
6963
|
-
$('btn-send')
|
|
6964
|
-
$('btn-fs-send')
|
|
7093
|
+
bindComposerSend($('btn-send'))
|
|
7094
|
+
bindComposerSend($('btn-fs-send'))
|
|
6965
7095
|
$('btn-plus').addEventListener('click', toggleComposerMenu)
|
|
6966
7096
|
$('btn-image').addEventListener('click', toggleComposerImageMenu)
|
|
6967
7097
|
$('composer-image-menu').addEventListener('click', (e) => {
|
|
@@ -7139,11 +7269,23 @@ function bindUi() {
|
|
|
7139
7269
|
})
|
|
7140
7270
|
$('btn-reset').addEventListener('click', () => {
|
|
7141
7271
|
if (!confirm(t('settings.confirmReset'))) return
|
|
7142
|
-
LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY); LS.del(ANNOUNCEMENT_VOTES_KEY)
|
|
7272
|
+
LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del('steerSendingEnabled'); LS.del('busySendMode'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY); LS.del(ANNOUNCEMENT_VOTES_KEY)
|
|
7143
7273
|
if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
|
|
7144
7274
|
location.reload()
|
|
7145
7275
|
})
|
|
7146
7276
|
$('mobile-enter-action').value = mobileEnterAction()
|
|
7277
|
+
$('opt-steer-send').checked = steerSendingEnabled()
|
|
7278
|
+
$('busy-send-mode').value = LS.get('busySendMode', 'queue') === 'steer' ? 'steer' : 'queue'
|
|
7279
|
+
$('busy-send-mode').disabled = !steerSendingEnabled()
|
|
7280
|
+
$('opt-steer-send').addEventListener('change', event => {
|
|
7281
|
+
LS.set('steerSendingEnabled', event.target.checked ? '1' : '0')
|
|
7282
|
+
$('busy-send-mode').disabled = !event.target.checked
|
|
7283
|
+
renderComposerSend()
|
|
7284
|
+
})
|
|
7285
|
+
$('busy-send-mode').addEventListener('change', event => {
|
|
7286
|
+
LS.set('busySendMode', event.target.value === 'steer' ? 'steer' : 'queue')
|
|
7287
|
+
renderComposerSend()
|
|
7288
|
+
})
|
|
7147
7289
|
$('mobile-enter-action').addEventListener('change', (e) => {
|
|
7148
7290
|
const action = e.target.value === 'send' ? 'send' : 'newline'
|
|
7149
7291
|
LS.set('mobileEnterAction', action)
|
package/public/index.html
CHANGED
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
<button id="btn-rename-session" class="icon-btn hidden" data-i18n-title="session.rename" data-i18n-aria="session.rename">✎</button>
|
|
114
114
|
<button id="btn-archive-session" class="mini-btn hidden" data-i18n="session.archive">归档</button>
|
|
115
115
|
<button id="btn-stats" class="icon-btn" data-i18n-title="stats.title" data-i18n-aria="stats.title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 19V9M12 19V5M19 19v-7M3 19h18"/></svg></button>
|
|
116
|
-
<button id="btn-fs-send" class="session-send-btn hidden" type="button"
|
|
116
|
+
<button id="btn-fs-send" class="session-send-btn hidden" type="button">发送</button>
|
|
117
117
|
<button id="btn-cancel" class="danger-btn hidden" data-i18n="session.stop">停止</button>
|
|
118
118
|
</div>
|
|
119
119
|
|
|
@@ -173,6 +173,7 @@
|
|
|
173
173
|
<button type="button" class="composer-image-option" data-image-source="CAMERA" role="menuitem"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8.5h3l1.5-2h7L17 8.5h3v10H4z"/><circle cx="12" cy="13.5" r="3.2"/></svg><span data-i18n="composer.takePhoto">拍照</span></button>
|
|
174
174
|
<button type="button" class="composer-image-option" data-image-source="PHOTOS" role="menuitem"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="4" width="17" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.2"/><path d="m5 17 4.5-4.5 3 3 2-2L19 17"/></svg><span data-i18n="composer.choosePhoto">相册</span></button>
|
|
175
175
|
</div>
|
|
176
|
+
<div id="composer-send-status" class="composer-send-status hidden" role="status" aria-live="polite" aria-atomic="true"></div>
|
|
176
177
|
<div id="composer-attachments" class="composer-attachments hidden"></div>
|
|
177
178
|
<div class="composer">
|
|
178
179
|
<button id="btn-plus" class="plus-btn" data-i18n-aria="a11y.more" data-i18n-title="a11y.moreTitle">+</button>
|
|
@@ -183,7 +184,7 @@
|
|
|
183
184
|
</button>
|
|
184
185
|
</div>
|
|
185
186
|
<button id="btn-image" class="composer-image-btn" type="button" data-i18n-title="composer.addImage" data-i18n-aria="composer.addImage"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="4" width="17" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.2"/><path d="m5 17 4.5-4.5 3 3 2-2L19 17"/></svg></button>
|
|
186
|
-
<button id="btn-send" class="send-btn"
|
|
187
|
+
<button id="btn-send" class="send-btn" type="button">发送</button>
|
|
187
188
|
</div>
|
|
188
189
|
<input id="composer-camera-input" class="hidden" type="file" accept="image/*" capture="environment">
|
|
189
190
|
<input id="composer-gallery-input" class="hidden" type="file" accept="image/*" multiple>
|
|
@@ -375,6 +376,17 @@
|
|
|
375
376
|
<option value="send" data-i18n="settings.mobileEnterSend">回车发送</option>
|
|
376
377
|
</select>
|
|
377
378
|
</div>
|
|
379
|
+
<div class="setting-row">
|
|
380
|
+
<div><div class="setting-name" data-i18n="settings.steerSendTitle">启用长按插队发送</div><div class="setting-desc" data-i18n="settings.steerSendDesc">繁忙时长按发送切换方式,松开发送;移动手指取消。默认关闭。</div></div>
|
|
381
|
+
<label class="switch"><input type="checkbox" id="opt-steer-send" data-i18n-aria="settings.steerSendTitle"><span class="slider"></span></label>
|
|
382
|
+
</div>
|
|
383
|
+
<div class="setting-row">
|
|
384
|
+
<div><div class="setting-name" data-i18n="settings.busySendTitle">繁忙时默认发送方式</div><div class="setting-desc" data-i18n="settings.busySendDesc">单击和回车使用默认方式,长按使用另一种;空闲时正常发送。</div></div>
|
|
385
|
+
<select id="busy-send-mode" class="setting-select" data-i18n-aria="settings.busySendTitle">
|
|
386
|
+
<option value="queue" data-i18n="send.queueButton">排队发送</option>
|
|
387
|
+
<option value="steer" data-i18n="send.steerButton">插队发送</option>
|
|
388
|
+
</select>
|
|
389
|
+
</div>
|
|
378
390
|
<div class="setting-row">
|
|
379
391
|
<div><div class="setting-name" data-i18n="presets.title">预设提示词</div><div class="setting-desc" data-i18n="presets.desc">常用提示词,对话界面一键插入(最多 20 条)</div></div>
|
|
380
392
|
<button id="btn-preset-add" class="mini-btn" data-i18n="presets.add">新增</button>
|
|
@@ -907,6 +919,17 @@
|
|
|
907
919
|
<script>
|
|
908
920
|
window.APP_STR = {
|
|
909
921
|
zh: {
|
|
922
|
+
'settings.steerSendTitle': '启用长按插队发送', 'settings.steerSendDesc': '繁忙时长按发送切换方式,松开发送;移动手指取消。默认关闭。',
|
|
923
|
+
'settings.busySendTitle': '繁忙时默认发送方式', 'settings.busySendDesc': '单击和回车使用默认方式,长按使用另一种;空闲时正常发送。',
|
|
924
|
+
'send.queueButton': '排队发送', 'send.steerButton': '插队发送', 'send.submitting': '提交中…',
|
|
925
|
+
'send.holdHint': '长按切换发送方式,松开发送;移动取消', 'send.releaseSteer': '松开插队', 'send.releaseQueue': '松开排队',
|
|
926
|
+
'send.steerPending': '正在提交插队请求,尚未确认;请勿认为当前任务已打断。',
|
|
927
|
+
'send.steerSlow': '仍在等待 DSH 确认插队,请勿重复发送;当前任务可能仍在执行。',
|
|
928
|
+
'send.steerAccepted': 'DSH 已接受插队请求;不代表当前工具已中断。',
|
|
929
|
+
'send.steerUnconfirmed': '插队结果未确认,消息可能已送达。请先检查会话与队列,勿直接重复发送。',
|
|
930
|
+
'send.steerRejected': '插队未成功:{msg}。未自动改为排队。',
|
|
931
|
+
'send.steerSlash': '斜杠命令不支持插队发送,请使用排队发送执行命令。',
|
|
932
|
+
'send.contextChanged': '已切换会话或服务器,本次消息未发送。',
|
|
910
933
|
'a11y.hostAdmin': '主机管理', 'a11y.refresh': '刷新', 'a11y.more': '更多操作', 'a11y.moreTitle': '指令/权限/模型',
|
|
911
934
|
'conn.on': '已连接', 'conn.off': '未连接', 'conn.reconnecting': '连接中断,正在重连…',
|
|
912
935
|
'conn.reconnectIn': '重连中 {n}s', 'conn.offline': '离线', 'conn.failed': '连接失败',
|
|
@@ -1139,6 +1162,17 @@
|
|
|
1139
1162
|
'time.justNow': '刚刚', 'time.minAgo': ' 分钟前', 'time.hourAgo': ' 小时前'
|
|
1140
1163
|
},
|
|
1141
1164
|
en: {
|
|
1165
|
+
'settings.steerSendTitle': 'Enable hold-to-steer sending', 'settings.steerSendDesc': 'While busy, hold to switch modes and release to send. Move to cancel. Off by default.',
|
|
1166
|
+
'settings.busySendTitle': 'Default sending mode while busy', 'settings.busySendDesc': 'Click and Enter use the default; hold uses the other mode. Normal sending when idle.',
|
|
1167
|
+
'send.queueButton': 'Queue', 'send.steerButton': 'Steer', 'send.submitting': 'Submitting…',
|
|
1168
|
+
'send.holdHint': 'Hold to switch modes; release to send, move to cancel', 'send.releaseSteer': 'Release to steer', 'send.releaseQueue': 'Release to queue',
|
|
1169
|
+
'send.steerPending': 'Submitting steering request, not yet confirmed. The current task may still be running.',
|
|
1170
|
+
'send.steerSlow': 'Still waiting for DSH to confirm. Do not resend; the current task may still be running.',
|
|
1171
|
+
'send.steerAccepted': 'DSH accepted the steering request. The current tool may still be running.',
|
|
1172
|
+
'send.steerUnconfirmed': 'Steering is unconfirmed; the message may have arrived. Check the conversation and queue before resending.',
|
|
1173
|
+
'send.steerRejected': 'Steering failed: {msg}. Not automatically queued.',
|
|
1174
|
+
'send.steerSlash': 'Slash commands cannot be sent as steering. Use queue mode to execute the command.',
|
|
1175
|
+
'send.contextChanged': 'Conversation or server changed; this message was not sent.',
|
|
1142
1176
|
'a11y.hostAdmin': 'Host admin', 'a11y.refresh': 'Refresh', 'a11y.more': 'More actions', 'a11y.moreTitle': 'Commands / Permissions / Models',
|
|
1143
1177
|
'conn.on': 'Connected', 'conn.off': 'Offline', 'conn.reconnecting': 'Connection lost, reconnecting…',
|
|
1144
1178
|
'conn.reconnectIn': 'Reconnecting {n}s', 'conn.offline': 'Offline', 'conn.failed': 'Connection failed',
|
package/public/styles.css
CHANGED
|
@@ -1319,3 +1319,9 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
|
|
|
1319
1319
|
.update-download-progress { margin-top: 12px; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
|
1320
1320
|
.update-download-progress progress { display: block; width: 100%; height: 10px; margin: 7px 0; accent-color: var(--dsr-accent, #2563eb); }
|
|
1321
1321
|
#update-download-detail { color: var(--dsr-text-muted); font-variant-numeric: tabular-nums; }
|
|
1322
|
+
/* Sending intent remains visible while waiting for a steering acknowledgement. */
|
|
1323
|
+
.composer-send-status { padding: 6px 12px; font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
|
|
1324
|
+
.composer-send-status[data-phase="pending"], .composer-send-status[data-phase="unknown"], .composer-send-status[data-phase="error"] { color: var(--dsr-accent-strong); font-weight: 600; }
|
|
1325
|
+
#btn-send, #btn-fs-send { touch-action: none; user-select: none; -webkit-touch-callout: none; }
|
|
1326
|
+
#btn-send.steer-mode, #btn-fs-send.steer-mode { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
1327
|
+
.send-hold-ready { box-shadow: 0 0 0 3px var(--dsr-accent-strong); }
|
package/public/update.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.7.0
|
|
2
|
+
"version": "0.7.0",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"sha256": "
|
|
5
|
-
"releasedAt": "2026-09-
|
|
6
|
-
"notes": "0.7.0-
|
|
4
|
+
"sha256": "a2c885aecf6d677b76413d0ed92a369a7045f3227a4c8b90d3365c3dfc9979f9",
|
|
5
|
+
"releasedAt": "2026-09-24T07:05:55.044Z",
|
|
6
|
+
"notes": "0.7.0:Windows 上未注册为服务的 DSH 可由 Remote 网关按已检查的 CLI 启动配置受控启动;已有 Windows 服务仍优先使用,网关只重启自己启动并持有句柄的进程。手机发送图片遇到 DSH 明确拒绝(当前模型不支持图片)时,可通过已启用的 dsh-image-vision 插件桥接附件;桥接失败不会提交不完整消息。保留长按插队发送、跨设备接续、鸿蒙客户端源码与平板分栏、网关 WebSocket Ping 分帧修复等 0.7.0 RC 功能。本次提供 Android APK 与 Windows/Linux 网关,不提供鸿蒙 HAP。",
|
|
7
7
|
"history": [
|
|
8
|
+
{
|
|
9
|
+
"version": "0.7.0",
|
|
10
|
+
"notes": "0.7.0:Windows 上未注册为服务的 DSH 可由 Remote 网关按已检查的 CLI 启动配置受控启动;已有 Windows 服务仍优先使用,网关只重启自己启动并持有句柄的进程。手机发送图片遇到 DSH 明确拒绝(当前模型不支持图片)时,可通过已启用的 dsh-image-vision 插件桥接附件;桥接失败不会提交不完整消息。保留长按插队发送、跨设备接续、鸿蒙客户端源码与平板分栏、网关 WebSocket Ping 分帧修复等 0.7.0 RC 功能。本次提供 Android APK 与 Windows/Linux 网关,不提供鸿蒙 HAP。"
|
|
11
|
+
},
|
|
8
12
|
{
|
|
9
13
|
"version": "0.6.26",
|
|
10
14
|
"notes": "0.6.26:修复 DSH 通配监听地址导致的认证与实时连接失败,兼容 IPv6-only;上传覆盖失败保留原文件,避免并发同名覆盖和续传分片竞争;网关改用认证关闭,避免误杀其他进程;完善 dsh-ui 堆叠柱状图、标签页、表格排序与特殊单元格、趋势线、环形进度、文件树和修改前后预览。已通过 Windows 自动测试与浏览器检查,Linux/Docker 专项实测仍待完成。"
|
|
@@ -40,10 +44,6 @@
|
|
|
40
44
|
{
|
|
41
45
|
"version": "0.6.18",
|
|
42
46
|
"notes": "0.6.18:新增独立 Token 量趋势统计并保留原有费用趋势;服务器连接身份改为地址加令牌,支持同地址不同令牌并兼容旧配置;投票在网关不可达时尝试直连公网收集器托底(需收集器允许 App/Web CORS);修复 Windows 下 gateway-stats 调用 zstd 时弹出终端窗口的问题,并补充回归测试;同步完善相关模块文档。"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"version": "0.6.17",
|
|
46
|
-
"notes": "0.6.17:修复 host.describe 初次失败后总览误报 DSH 上游离线并增加恢复重试;识别 Docker、无 systemd 和插件内嵌等外部生命周期环境,隐藏不可用的 DSH 启停并给出明确提示;兼容 Caddy Basic Auth,避免管理 API 覆盖 Authorization 导致登录循环;支持通过环境变量或管理页手动补充 Docker 宿主、局域网和 Tailscale 地址并写入配对二维码;完整适配 Windows 盘符、UNC、多文件根和跨主机文件路径清理,并新增 Windows 原生 CI 安全门禁。"
|
|
47
47
|
}
|
|
48
48
|
]
|
|
49
49
|
}
|
package/public/version.json
CHANGED