dsh-remote-plugin 0.6.19 → 0.6.20
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/client.js +9 -2
- package/gateway.cjs +610 -33
- package/index.mjs +50 -3
- package/package.json +1 -1
- package/public/app.js +42 -2
- package/public/desktop/desktop.css +9 -4
- package/public/desktop/desktop.html +1 -0
- package/public/desktop/desktop.js +38 -2
- package/public/index.html +2 -0
- package/public/styles.css +11 -3
- package/public/update.json +8 -8
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/client.js
CHANGED
|
@@ -10,7 +10,14 @@ window.__ModuleLoader__.load({
|
|
|
10
10
|
var exports = module.exports
|
|
11
11
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
12
12
|
var React = require('react')
|
|
13
|
-
|
|
13
|
+
// DSH 0.1.2-alpha.1 将 defineStore 从已删除的 client-runtime 迁入
|
|
14
|
+
// client-store 平台种子;0.1.1-rc.2 及更早版本仍只提供旧模块。
|
|
15
|
+
var storeRuntime
|
|
16
|
+
try {
|
|
17
|
+
storeRuntime = require('@deepseek-ai/dsh-client-store')
|
|
18
|
+
} catch (_) {
|
|
19
|
+
storeRuntime = require('@deepseek-ai/dsh-client-runtime/client')
|
|
20
|
+
}
|
|
14
21
|
|
|
15
22
|
var DRAWER_STYLE = {
|
|
16
23
|
position: 'fixed', top: 12, right: 12, bottom: 12, zIndex: 2147483000,
|
|
@@ -146,7 +153,7 @@ window.__ModuleLoader__.load({
|
|
|
146
153
|
|
|
147
154
|
var inject = ['slots']
|
|
148
155
|
function apply(ctx) {
|
|
149
|
-
var store =
|
|
156
|
+
var store = storeRuntime.defineStore({
|
|
150
157
|
init: function () { return { open: false } },
|
|
151
158
|
actions: {
|
|
152
159
|
toggle: function (d) { d.open = !d.open },
|
package/gateway.cjs
CHANGED
|
@@ -78,6 +78,7 @@ const UPSTREAM_AUTHORITY = `${UPSTREAM.hostname}${UPSTREAM.port ? ':' + UPSTREAM
|
|
|
78
78
|
const DSH_HEALTH_PATH = String(process.env.DSH_HEALTH_PATH || '/').startsWith('/')
|
|
79
79
|
? String(process.env.DSH_HEALTH_PATH || '/')
|
|
80
80
|
: '/' + String(process.env.DSH_HEALTH_PATH)
|
|
81
|
+
const DSH_UPSTREAM_COOKIE_FILE = process.env.DSH_REMOTE_DSH_COOKIE_FILE || path.join(os.homedir(), '.dsh-remote', 'dsh-upstream.cookie')
|
|
81
82
|
const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remote', 'token')
|
|
82
83
|
const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
|
|
83
84
|
const DEVICE_KEYS_FILE = process.env.DSH_REMOTE_DEVICE_KEYS || path.join(os.homedir(), '.dsh-remote', 'device-keys.json')
|
|
@@ -94,6 +95,24 @@ const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 1
|
|
|
94
95
|
const HTTP_HEADERS_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_HEADERS_TIMEOUT_MS', 120000, 1000, 10 * 60 * 1000)
|
|
95
96
|
const HTTP_KEEPALIVE_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_KEEPALIVE_TIMEOUT_MS', 65000, 1000, 10 * 60 * 1000)
|
|
96
97
|
|
|
98
|
+
/** 读取插件兑换的新版 DSH 会话 Cookie;动态读取允许 DSH 重启后原地刷新。 */
|
|
99
|
+
function dshUpstreamCookie() {
|
|
100
|
+
try {
|
|
101
|
+
const value = fs.readFileSync(DSH_UPSTREAM_COOKIE_FILE, 'utf8').trim()
|
|
102
|
+
if (!value.includes('=') || value.length > 4096 || /[\0\r\n]/.test(value)) return ''
|
|
103
|
+
return value
|
|
104
|
+
} catch {
|
|
105
|
+
return ''
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function dshUpstreamHeaders(base = {}) {
|
|
110
|
+
const headers = { ...base }
|
|
111
|
+
const cookie = dshUpstreamCookie()
|
|
112
|
+
if (cookie) headers.cookie = cookie
|
|
113
|
+
return headers
|
|
114
|
+
}
|
|
115
|
+
|
|
97
116
|
// 更新检查: GitHub 为默认源, 可用环境变量覆盖(国内镜像 / 代理)
|
|
98
117
|
const UPDATE_CHECK_URL = process.env.UPDATE_CHECK_URL ||
|
|
99
118
|
'https://api.github.com/repos/Blank-not-black/dsh-Remote/releases/latest'
|
|
@@ -1055,6 +1074,7 @@ async function probeDshUpstream() {
|
|
|
1055
1074
|
const startedAt = Date.now()
|
|
1056
1075
|
try {
|
|
1057
1076
|
const probe = await fetch(new URL(DSH_HEALTH_PATH, UPSTREAM), {
|
|
1077
|
+
headers: dshUpstreamHeaders(),
|
|
1058
1078
|
signal: AbortSignal.timeout(Math.min(2500, UPSTREAM_REQUEST_TIMEOUT_MS)),
|
|
1059
1079
|
cache: 'no-store',
|
|
1060
1080
|
})
|
|
@@ -1314,6 +1334,211 @@ const eventCollectorState = {
|
|
|
1314
1334
|
}
|
|
1315
1335
|
const eventCollectors = { mux: null, host: null }
|
|
1316
1336
|
|
|
1337
|
+
// DSH 0.1.2-alpha.1 replaced dotted RPC names and the two downlink sockets with
|
|
1338
|
+
// generated slash RPCs over one logical-stream mux. Keep the public Remote
|
|
1339
|
+
// contract stable here so older DSH releases and newer generated Remotes can
|
|
1340
|
+
// both serve the same zero-build clients.
|
|
1341
|
+
let upstreamApiFlavor = 'unknown'
|
|
1342
|
+
let upstreamApiFlavorProbe = null
|
|
1343
|
+
const modernState = {
|
|
1344
|
+
home: '',
|
|
1345
|
+
eventClientId: '',
|
|
1346
|
+
sessions: new Map(),
|
|
1347
|
+
sessionCursors: new Map(),
|
|
1348
|
+
workspaces: { items: [], archivedSessionIds: [] },
|
|
1349
|
+
pendingEvents: new Map(),
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
async function callUpstreamRemote(endpoint, args, rpcId = crypto.randomUUID()) {
|
|
1353
|
+
const target = new URL('/api/' + endpoint, UPSTREAM)
|
|
1354
|
+
const response = await fetch(target, {
|
|
1355
|
+
method: 'POST',
|
|
1356
|
+
headers: dshUpstreamHeaders({ 'content-type': 'application/json' }),
|
|
1357
|
+
body: JSON.stringify({ type: 'client-request', rpcId, method: endpoint, payload: { args } }),
|
|
1358
|
+
signal: AbortSignal.timeout(UPSTREAM_REQUEST_TIMEOUT_MS),
|
|
1359
|
+
})
|
|
1360
|
+
const body = await response.json().catch(() => null)
|
|
1361
|
+
return { status: response.status, body }
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
async function detectUpstreamApiFlavor(force = false) {
|
|
1365
|
+
if (!force && upstreamApiFlavor !== 'unknown') return upstreamApiFlavor
|
|
1366
|
+
if (!force && upstreamApiFlavorProbe) return upstreamApiFlavorProbe
|
|
1367
|
+
upstreamApiFlavorProbe = (async () => {
|
|
1368
|
+
try {
|
|
1369
|
+
const probe = await callUpstreamRemote('session/list', { _request: {} })
|
|
1370
|
+
upstreamApiFlavor = probe.status === 200
|
|
1371
|
+
&& probe.body?.result?.ok === true
|
|
1372
|
+
&& Array.isArray(probe.body?.result?.value?.items)
|
|
1373
|
+
? 'modern'
|
|
1374
|
+
: 'legacy'
|
|
1375
|
+
if (upstreamApiFlavor === 'modern' && probe.body?.result?.ok) {
|
|
1376
|
+
updateModernSessions(probe.body.result.value?.items)
|
|
1377
|
+
}
|
|
1378
|
+
} catch {
|
|
1379
|
+
upstreamApiFlavor = 'legacy'
|
|
1380
|
+
} finally {
|
|
1381
|
+
upstreamApiFlavorProbe = null
|
|
1382
|
+
}
|
|
1383
|
+
return upstreamApiFlavor
|
|
1384
|
+
})()
|
|
1385
|
+
return upstreamApiFlavorProbe
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function updateModernSessions(items) {
|
|
1389
|
+
if (!Array.isArray(items)) return
|
|
1390
|
+
modernState.sessions.clear()
|
|
1391
|
+
for (const item of items) {
|
|
1392
|
+
if (!item?.sessionId) continue
|
|
1393
|
+
modernState.sessions.set(item.sessionId, item)
|
|
1394
|
+
const cursor = Number(item.projections?.asOfSeq)
|
|
1395
|
+
if (Number.isSafeInteger(cursor) && cursor >= -1) modernState.sessionCursors.set(item.sessionId, cursor)
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function legacyEnvelope(rpcId, result) {
|
|
1400
|
+
return { rpcId, result }
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function modernError(message, code = 'upstream-incompatible', details = {}) {
|
|
1404
|
+
return { ok: false, error: { code, message, details } }
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function legacyHistoryValue(value, summary) {
|
|
1408
|
+
const records = Array.isArray(value?.records) ? value.records : []
|
|
1409
|
+
return {
|
|
1410
|
+
events: records.map(record => ({ event: record?.event })).filter(entry => entry.event),
|
|
1411
|
+
hasMore: !!value?.hasMore,
|
|
1412
|
+
...(summary?.projections ? { projections: summary.projections } : {}),
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
async function refreshModernSessions() {
|
|
1417
|
+
const response = await callUpstreamRemote('session/list', { _request: {} })
|
|
1418
|
+
if (response.status === 200 && response.body?.result?.ok) updateModernSessions(response.body.result.value?.items)
|
|
1419
|
+
return response
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
async function translateModernRpc(method, payload, rpcId) {
|
|
1423
|
+
let endpoint = ''
|
|
1424
|
+
let args = {}
|
|
1425
|
+
let transform = value => value
|
|
1426
|
+
|
|
1427
|
+
if (method === 'host.describe') {
|
|
1428
|
+
return legacyEnvelope(rpcId, { ok: true, value: { home: modernState.home || os.homedir(), canOpenPath: false } })
|
|
1429
|
+
}
|
|
1430
|
+
if (method === 'workspace.list') {
|
|
1431
|
+
return legacyEnvelope(rpcId, { ok: true, value: modernState.workspaces })
|
|
1432
|
+
}
|
|
1433
|
+
if (method === 'session.list') {
|
|
1434
|
+
const response = await refreshModernSessions()
|
|
1435
|
+
return response.body || legacyEnvelope(rpcId, modernError('DSH session/list returned no JSON response'))
|
|
1436
|
+
}
|
|
1437
|
+
if (method === 'session.history') {
|
|
1438
|
+
let summary = modernState.sessions.get(payload.sessionId)
|
|
1439
|
+
if (!summary) {
|
|
1440
|
+
await refreshModernSessions()
|
|
1441
|
+
summary = modernState.sessions.get(payload.sessionId)
|
|
1442
|
+
}
|
|
1443
|
+
const throughSeq = modernState.sessionCursors.get(payload.sessionId) ?? Number(summary?.projections?.asOfSeq)
|
|
1444
|
+
if (!Number.isSafeInteger(throughSeq) || throughSeq < -1) {
|
|
1445
|
+
return legacyEnvelope(rpcId, modernError('DSH did not expose a history cursor for this session', 'session-not-found'))
|
|
1446
|
+
}
|
|
1447
|
+
endpoint = 'session/page'
|
|
1448
|
+
args = { request: {
|
|
1449
|
+
address: { kind: 'session', sessionId: payload.sessionId },
|
|
1450
|
+
throughSeq,
|
|
1451
|
+
...(payload.beforeSeq === undefined ? {} : { beforeSeq: payload.beforeSeq }),
|
|
1452
|
+
...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
|
|
1453
|
+
} }
|
|
1454
|
+
transform = value => legacyHistoryValue(value, summary)
|
|
1455
|
+
} else if (method === 'session.models') {
|
|
1456
|
+
endpoint = 'session/modelCatalog'
|
|
1457
|
+
args = {}
|
|
1458
|
+
transform = catalog => {
|
|
1459
|
+
const summary = modernState.sessions.get(payload.sessionId)
|
|
1460
|
+
const projected = summary?.projections?.values?.modelSelection
|
|
1461
|
+
const current = projected?.next || projected?.lastUsed || catalog?.default
|
|
1462
|
+
return {
|
|
1463
|
+
current,
|
|
1464
|
+
routable: !!current && (!Array.isArray(catalog?.routableProviders) || catalog.routableProviders.includes(current.provider)),
|
|
1465
|
+
groups: catalog?.groups || [],
|
|
1466
|
+
failures: catalog?.failures || [],
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
} else if (method.startsWith('session.')) {
|
|
1470
|
+
const verb = method.slice('session.'.length)
|
|
1471
|
+
if (!['search', 'create', 'selectModel', 'rename', 'fork', 'prompt', 'attachment', 'updateQueue', 'cancel'].includes(verb)) return null
|
|
1472
|
+
endpoint = 'session/' + verb
|
|
1473
|
+
const request = verb === 'prompt' && !payload.requestId
|
|
1474
|
+
? { ...payload, requestId: crypto.randomUUID() }
|
|
1475
|
+
: payload
|
|
1476
|
+
args = verb === 'search' ? { request } : { request }
|
|
1477
|
+
} else if (method.startsWith('workspace.')) {
|
|
1478
|
+
const verb = method.slice('workspace.'.length)
|
|
1479
|
+
if (!['create', 'rename', 'delete', 'insertBefore', 'insertSessionBefore', 'archiveSession'].includes(verb)) return null
|
|
1480
|
+
endpoint = 'workspace/' + verb
|
|
1481
|
+
args = { request: payload }
|
|
1482
|
+
} else if (method.startsWith('goal.')) {
|
|
1483
|
+
const verb = method.slice('goal.'.length)
|
|
1484
|
+
if (!['create', 'edit', 'pause', 'resume', 'complete', 'clear'].includes(verb)) return null
|
|
1485
|
+
endpoint = 'goals/' + verb
|
|
1486
|
+
args = verb === 'create'
|
|
1487
|
+
? { agentId: payload.sessionId, request: { objective: payload.objective, ...(payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: payload.maxGoalRounds }) } }
|
|
1488
|
+
: { agentId: payload.sessionId, ref: payload.ref, ...(verb === 'edit' ? { request: { ...(payload.objective === undefined ? {} : { objective: payload.objective }), ...(payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: payload.maxGoalRounds }) } } : {}) }
|
|
1489
|
+
transform = value => verb === 'create'
|
|
1490
|
+
? value
|
|
1491
|
+
: verb === 'clear'
|
|
1492
|
+
? { cleared: true }
|
|
1493
|
+
: { ref: { id: value?.id, revision: value?.revision } }
|
|
1494
|
+
} else if (method === 'subagent.list') {
|
|
1495
|
+
endpoint = 'subagents/list'
|
|
1496
|
+
args = { parentSessionId: payload.parentSessionId }
|
|
1497
|
+
} else if (method === 'subagent.interrupt') {
|
|
1498
|
+
endpoint = 'subagents/interruptByParent'
|
|
1499
|
+
args = { childSessionId: payload.childSessionId, parentSessionId: payload.parentSessionId, mode: payload.mode }
|
|
1500
|
+
} else if (method === 'settings.describe') {
|
|
1501
|
+
endpoint = 'settings/describe'
|
|
1502
|
+
args = {}
|
|
1503
|
+
} else if (method === 'settings.mutate') {
|
|
1504
|
+
endpoint = 'settings/mutate'
|
|
1505
|
+
args = { ns: payload.ns, ops: payload.ops, expectedRevision: payload.expectedRevision }
|
|
1506
|
+
} else if (method === 'settings.openDocument') {
|
|
1507
|
+
endpoint = 'settings/openSettingsDocument'
|
|
1508
|
+
args = {}
|
|
1509
|
+
} else if (method.startsWith('credentials.')) {
|
|
1510
|
+
const verb = method.slice('credentials.'.length)
|
|
1511
|
+
if (!['describe', 'set', 'unset'].includes(verb)) return null
|
|
1512
|
+
endpoint = 'credentials/' + verb
|
|
1513
|
+
args = verb === 'describe' ? { refs: payload.refs } : verb === 'set' ? { ref: payload.ref, value: payload.value } : { ref: payload.ref }
|
|
1514
|
+
if (verb !== 'describe') transform = () => ({})
|
|
1515
|
+
} else if (method === 'llm.providers') {
|
|
1516
|
+
endpoint = 'llm/listConfigurableProviders'
|
|
1517
|
+
args = {}
|
|
1518
|
+
transform = values => ({ providers: (Array.isArray(values) ? values : []).map(entry => ({
|
|
1519
|
+
provider: entry.provider,
|
|
1520
|
+
displayName: entry.displayName,
|
|
1521
|
+
settingsNs: entry.settingsNs,
|
|
1522
|
+
settingsPath: entry.settingsPath || [],
|
|
1523
|
+
active: true,
|
|
1524
|
+
...(entry.declared === undefined ? {} : { declared: entry.declared }),
|
|
1525
|
+
})) })
|
|
1526
|
+
} else if (method === 'llm.discoverModels') {
|
|
1527
|
+
endpoint = 'llm/discoverModels'
|
|
1528
|
+
args = {
|
|
1529
|
+
settingsNs: payload.settingsNs,
|
|
1530
|
+
request: Object.fromEntries(Object.entries(payload).filter(([key, value]) => key !== 'settingsNs' && value !== undefined)),
|
|
1531
|
+
}
|
|
1532
|
+
transform = models => ({ models: Array.isArray(models) ? models : [] })
|
|
1533
|
+
} else {
|
|
1534
|
+
return null
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
const response = await callUpstreamRemote(endpoint, args, rpcId)
|
|
1538
|
+
if (!response.body?.result?.ok) return response.body || legacyEnvelope(rpcId, modernError(`DSH ${endpoint} returned no JSON response`))
|
|
1539
|
+
return legacyEnvelope(rpcId, { ok: true, value: transform(response.body.result.value) })
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1317
1542
|
/** 递归截断超大字段,避免单条超大事件撑爆环形缓冲。 */
|
|
1318
1543
|
function truncateEventValue(v, depth = 0) {
|
|
1319
1544
|
if (typeof v === 'string') return v.length > EVENT_MAX_STRING ? v.slice(0, EVENT_MAX_STRING) + '…[truncated]' : v
|
|
@@ -1530,7 +1755,8 @@ function startEventCollector(kind) {
|
|
|
1530
1755
|
if (stopped) return
|
|
1531
1756
|
let current
|
|
1532
1757
|
try {
|
|
1533
|
-
|
|
1758
|
+
const headers = dshUpstreamHeaders()
|
|
1759
|
+
current = Object.keys(headers).length ? new WebSocket(url, { headers }) : new WebSocket(url)
|
|
1534
1760
|
ws = current
|
|
1535
1761
|
} catch (err) {
|
|
1536
1762
|
state.lastError = String(err?.message || err)
|
|
@@ -1616,6 +1842,280 @@ function startEventCollector(kind) {
|
|
|
1616
1842
|
}
|
|
1617
1843
|
}
|
|
1618
1844
|
|
|
1845
|
+
function legacyPush(kind, payload, rpcId = crypto.randomUUID()) {
|
|
1846
|
+
pushEvent(kind, { rpcId, payload })
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function openModernSessionStream(ws, sessionId) {
|
|
1850
|
+
if (!sessionId || ws.readyState !== 1) return
|
|
1851
|
+
const streamId = 'session:' + sessionId
|
|
1852
|
+
ws.send(JSON.stringify({
|
|
1853
|
+
type: 'open', streamId, endpoint: 'session/follow',
|
|
1854
|
+
payload: { args: { request: { address: { kind: 'session', sessionId } } } },
|
|
1855
|
+
}))
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function applyModernControlFrame(value) {
|
|
1859
|
+
if (value?.type === 'baseline') {
|
|
1860
|
+
for (const [sessionId, items] of Object.entries(value.value?.queues || {})) {
|
|
1861
|
+
legacyPush('mux', { type: 'session/queue', sessionId, items })
|
|
1862
|
+
}
|
|
1863
|
+
for (const [sessionId, jobs] of Object.entries(value.value?.jobs || {})) {
|
|
1864
|
+
legacyPush('mux', { type: 'session/jobs', sessionId, jobs })
|
|
1865
|
+
}
|
|
1866
|
+
for (const [sessionId, block] of Object.entries(value.value?.projections || {})) {
|
|
1867
|
+
for (const [key, projection] of Object.entries(block?.values || {})) {
|
|
1868
|
+
legacyPush('mux', { type: 'session/projection', sessionId, key, value: projection, seq: block?.asOfSeq ?? 0 })
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
return
|
|
1872
|
+
}
|
|
1873
|
+
if (value?.type === 'queue') legacyPush('mux', { type: 'session/queue', sessionId: value.sessionId, items: value.items || [] })
|
|
1874
|
+
else if (value?.type === 'jobs') legacyPush('mux', { type: 'session/jobs', sessionId: value.sessionId, jobs: value.jobs || [] })
|
|
1875
|
+
else if (value?.type === 'projection') legacyPush('mux', {
|
|
1876
|
+
type: 'session/projection', sessionId: value.sessionId, key: value.key, value: value.value, seq: value.seq,
|
|
1877
|
+
})
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
function applyModernWorkspaceFrame(value) {
|
|
1881
|
+
if (value?.type === 'baseline') {
|
|
1882
|
+
modernState.workspaces = {
|
|
1883
|
+
items: Array.isArray(value.value?.items) ? value.value.items : [],
|
|
1884
|
+
archivedSessionIds: Array.isArray(value.value?.archivedSessionIds) ? value.value.archivedSessionIds : [],
|
|
1885
|
+
}
|
|
1886
|
+
return
|
|
1887
|
+
}
|
|
1888
|
+
if (value?.type === 'upsert' && value.workspace) {
|
|
1889
|
+
const items = modernState.workspaces.items.filter(item => item.workspaceId !== value.workspace.workspaceId)
|
|
1890
|
+
items.push(value.workspace)
|
|
1891
|
+
modernState.workspaces = { ...modernState.workspaces, items }
|
|
1892
|
+
legacyPush('host', { type: 'host/workspace-changed', workspace: value.workspace })
|
|
1893
|
+
} else if (value?.type === 'remove') {
|
|
1894
|
+
modernState.workspaces = {
|
|
1895
|
+
...modernState.workspaces,
|
|
1896
|
+
items: modernState.workspaces.items.filter(item => item.workspaceId !== value.workspaceId),
|
|
1897
|
+
}
|
|
1898
|
+
legacyPush('host', { type: 'host/workspace-removed', workspaceId: value.workspaceId })
|
|
1899
|
+
} else if (value?.type === 'order') {
|
|
1900
|
+
const byId = new Map(modernState.workspaces.items.map(item => [item.workspaceId, item]))
|
|
1901
|
+
const ordered = (value.workspaceIds || []).map(id => byId.get(id)).filter(Boolean)
|
|
1902
|
+
for (const item of modernState.workspaces.items) if (!value.workspaceIds?.includes(item.workspaceId)) ordered.push(item)
|
|
1903
|
+
modernState.workspaces = { ...modernState.workspaces, items: ordered }
|
|
1904
|
+
legacyPush('host', { type: 'host/workspace-order-changed', workspaceIds: value.workspaceIds || [] })
|
|
1905
|
+
} else if (value?.type === 'archived') {
|
|
1906
|
+
modernState.workspaces = { ...modernState.workspaces, archivedSessionIds: value.archivedSessionIds || [] }
|
|
1907
|
+
legacyPush('host', { type: 'host/archived-sessions-changed', archivedSessionIds: value.archivedSessionIds || [] })
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function applyModernSessionFrame(sessionId, value) {
|
|
1912
|
+
if (value?.type === 'snapshot') {
|
|
1913
|
+
modernState.sessionCursors.set(sessionId, value.cursor)
|
|
1914
|
+
legacyPush('mux', { type: 'session/subscribed', sessionId, lastSeq: value.cursor })
|
|
1915
|
+
for (const record of value.records || []) {
|
|
1916
|
+
if (record?.event) legacyPush('mux', { type: 'session/event', sessionId, event: record.event })
|
|
1917
|
+
}
|
|
1918
|
+
for (const [key, projection] of Object.entries(value.projections?.values || {})) {
|
|
1919
|
+
legacyPush('mux', { type: 'session/projection', sessionId, key, value: projection, seq: value.projections?.asOfSeq ?? value.cursor })
|
|
1920
|
+
}
|
|
1921
|
+
return
|
|
1922
|
+
}
|
|
1923
|
+
if (value?.type === 'event' && value.event) {
|
|
1924
|
+
modernState.sessionCursors.set(sessionId, value.event.seq)
|
|
1925
|
+
legacyPush('mux', { type: 'session/event', sessionId, event: value.event })
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
function resolveModernPendingEvent(eventId, cancelled = false) {
|
|
1930
|
+
const pending = modernState.pendingEvents.get(eventId)
|
|
1931
|
+
if (!pending) return
|
|
1932
|
+
modernState.pendingEvents.delete(eventId)
|
|
1933
|
+
if (pending.event === 'approval/request') {
|
|
1934
|
+
legacyPush('mux', {
|
|
1935
|
+
type: 'approval/resolved', sessionId: pending.sessionId, approvalId: eventId,
|
|
1936
|
+
outcome: cancelled ? 'cancelled' : pending.outcome,
|
|
1937
|
+
})
|
|
1938
|
+
} else if (pending.event === 'user-questions/request') {
|
|
1939
|
+
legacyPush('mux', {
|
|
1940
|
+
type: 'question/resolved', sessionId: pending.sessionId, questionRpcId: eventId,
|
|
1941
|
+
outcome: cancelled ? 'cancelled' : 'answered',
|
|
1942
|
+
})
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function applyModernRemoteEvent(ws, value) {
|
|
1947
|
+
if (value?.type === 'ready') {
|
|
1948
|
+
modernState.eventClientId = value.clientId || ''
|
|
1949
|
+
modernState.home = value.host?.home || modernState.home
|
|
1950
|
+
return
|
|
1951
|
+
}
|
|
1952
|
+
if (value?.type === 'cancel') {
|
|
1953
|
+
resolveModernPendingEvent(value.eventId, true)
|
|
1954
|
+
return
|
|
1955
|
+
}
|
|
1956
|
+
if (value?.type === 'waterfall') {
|
|
1957
|
+
const sessionId = value.agentId
|
|
1958
|
+
modernState.pendingEvents.set(value.eventId, { event: value.event, sessionId, outcome: '' })
|
|
1959
|
+
if (value.event === 'approval/request') {
|
|
1960
|
+
legacyPush('mux', {
|
|
1961
|
+
type: 'approval/requested', sessionId, approvalId: value.eventId,
|
|
1962
|
+
toolName: value.request?.toolName || '',
|
|
1963
|
+
...(value.request?.callId === undefined ? {} : { callId: value.request.callId }),
|
|
1964
|
+
...(value.request?.reason === undefined ? {} : { reason: value.request.reason }),
|
|
1965
|
+
}, value.eventId)
|
|
1966
|
+
} else if (value.event === 'user-questions/request') {
|
|
1967
|
+
legacyPush('mux', { type: 'question/requested', sessionId, questions: value.request?.questions || [] }, value.eventId)
|
|
1968
|
+
}
|
|
1969
|
+
return
|
|
1970
|
+
}
|
|
1971
|
+
if (value?.type !== 'emit') return
|
|
1972
|
+
const args = value.args || []
|
|
1973
|
+
if (value.event === 'api-session/added') {
|
|
1974
|
+
const summary = args[0]
|
|
1975
|
+
if (summary?.sessionId) {
|
|
1976
|
+
modernState.sessions.set(summary.sessionId, summary)
|
|
1977
|
+
openModernSessionStream(ws, summary.sessionId)
|
|
1978
|
+
legacyPush('host', {
|
|
1979
|
+
type: 'host/session-added', sessionId: summary.sessionId, blank: !!summary.blank,
|
|
1980
|
+
...(summary.parentSessionId === undefined ? {} : { parentSessionId: summary.parentSessionId }),
|
|
1981
|
+
...(summary.origin === undefined ? {} : { origin: summary.origin }),
|
|
1982
|
+
...(summary.cwd === undefined ? {} : { cwd: summary.cwd }),
|
|
1983
|
+
})
|
|
1984
|
+
}
|
|
1985
|
+
} else if (value.event === 'api-session/removed') {
|
|
1986
|
+
modernState.sessions.delete(args[0])
|
|
1987
|
+
modernState.sessionCursors.delete(args[0])
|
|
1988
|
+
try { ws.send(JSON.stringify({ type: 'cancel', streamId: 'session:' + args[0] })) } catch {}
|
|
1989
|
+
legacyPush('host', { type: 'host/session-removed', sessionId: args[0] })
|
|
1990
|
+
} else if (value.event === 'api-session/status') {
|
|
1991
|
+
legacyPush('host', { type: 'host/session-status', sessionId: args[0], running: !!args[1] })
|
|
1992
|
+
} else if (value.event === 'api-session/error') {
|
|
1993
|
+
legacyPush('host', { type: 'host/agent-error', sessionId: args[0], message: String(args[1] || '') })
|
|
1994
|
+
} else {
|
|
1995
|
+
legacyPush('host', { type: 'host/remote-event', event: value.event, args })
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
function startModernEventCollector() {
|
|
2000
|
+
if (typeof WebSocket !== 'function') return null
|
|
2001
|
+
let ws = null
|
|
2002
|
+
let stopped = false
|
|
2003
|
+
let retryTimer = null
|
|
2004
|
+
let connectTimer = null
|
|
2005
|
+
let attempt = 0
|
|
2006
|
+
const scheme = UPSTREAM.protocol === 'https:' ? 'wss' : 'ws'
|
|
2007
|
+
const url = `${scheme}://${UPSTREAM_AUTHORITY}/api/remote.mux`
|
|
2008
|
+
const setConnected = (connected, error = '') => {
|
|
2009
|
+
for (const state of Object.values(eventCollectorState)) {
|
|
2010
|
+
state.connected = connected
|
|
2011
|
+
if (connected) {
|
|
2012
|
+
state.lastConnectAt = Date.now()
|
|
2013
|
+
state.lastError = ''
|
|
2014
|
+
state.attempt = 0
|
|
2015
|
+
} else if (error) state.lastError = error
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
const schedule = () => {
|
|
2019
|
+
if (stopped || retryTimer) return
|
|
2020
|
+
const delay = Math.round(Math.min(1500 * Math.pow(2, attempt++), 60000) * (0.8 + Math.random() * 0.4))
|
|
2021
|
+
retryTimer = setTimeout(() => { retryTimer = null; connect() }, delay)
|
|
2022
|
+
retryTimer.unref?.()
|
|
2023
|
+
}
|
|
2024
|
+
const connect = () => {
|
|
2025
|
+
if (stopped) return
|
|
2026
|
+
let current
|
|
2027
|
+
try {
|
|
2028
|
+
const headers = dshUpstreamHeaders()
|
|
2029
|
+
current = Object.keys(headers).length ? new WebSocket(url, { headers }) : new WebSocket(url)
|
|
2030
|
+
ws = current
|
|
2031
|
+
} catch (error) {
|
|
2032
|
+
setConnected(false, String(error?.message || error))
|
|
2033
|
+
schedule()
|
|
2034
|
+
return
|
|
2035
|
+
}
|
|
2036
|
+
let finished = false
|
|
2037
|
+
const finish = (code = 0, reason = '', error = '') => {
|
|
2038
|
+
if (finished) return
|
|
2039
|
+
finished = true
|
|
2040
|
+
clearTimeout(connectTimer)
|
|
2041
|
+
modernState.eventClientId = ''
|
|
2042
|
+
setConnected(false, error)
|
|
2043
|
+
for (const state of Object.values(eventCollectorState)) {
|
|
2044
|
+
state.lastCloseCode = Number(code) || 0
|
|
2045
|
+
state.lastCloseReason = String(reason || '')
|
|
2046
|
+
state.reconnects++
|
|
2047
|
+
}
|
|
2048
|
+
if (ws === current) ws = null
|
|
2049
|
+
if (!stopped) schedule()
|
|
2050
|
+
}
|
|
2051
|
+
connectTimer = setTimeout(() => {
|
|
2052
|
+
finish(0, '', 'websocket connect timeout')
|
|
2053
|
+
try { current.close() } catch {}
|
|
2054
|
+
}, WS_UPGRADE_TIMEOUT_MS)
|
|
2055
|
+
connectTimer.unref?.()
|
|
2056
|
+
current.onopen = () => {
|
|
2057
|
+
if (finished || stopped || ws !== current) return
|
|
2058
|
+
clearTimeout(connectTimer)
|
|
2059
|
+
attempt = 0
|
|
2060
|
+
setConnected(true)
|
|
2061
|
+
current.send(JSON.stringify({ type: 'open', streamId: 'events', endpoint: '$events', payload: { args: {} } }))
|
|
2062
|
+
current.send(JSON.stringify({ type: 'open', streamId: 'control', endpoint: 'session/control', payload: { args: {} } }))
|
|
2063
|
+
current.send(JSON.stringify({ type: 'open', streamId: 'workspaces', endpoint: 'workspace/follow', payload: { args: {} } }))
|
|
2064
|
+
for (const sessionId of modernState.sessions.keys()) openModernSessionStream(current, sessionId)
|
|
2065
|
+
}
|
|
2066
|
+
current.onmessage = ev => {
|
|
2067
|
+
try {
|
|
2068
|
+
const data = typeof ev.data === 'string' ? ev.data : Buffer.isBuffer(ev.data) ? ev.data.toString() : String(ev.data)
|
|
2069
|
+
const frame = JSON.parse(data)
|
|
2070
|
+
if (frame.type === 'error') {
|
|
2071
|
+
const message = frame.error?.message || 'modern stream error'
|
|
2072
|
+
for (const state of Object.values(eventCollectorState)) state.lastError = message
|
|
2073
|
+
legacyPush('mux', { type: 'stream/error', error: frame.error || { message } })
|
|
2074
|
+
return
|
|
2075
|
+
}
|
|
2076
|
+
if (frame.type !== 'item') return
|
|
2077
|
+
if (frame.streamId === 'events') applyModernRemoteEvent(current, frame.value)
|
|
2078
|
+
else if (frame.streamId === 'control') applyModernControlFrame(frame.value)
|
|
2079
|
+
else if (frame.streamId === 'workspaces') applyModernWorkspaceFrame(frame.value)
|
|
2080
|
+
else if (frame.streamId.startsWith('session:')) applyModernSessionFrame(frame.streamId.slice(8), frame.value)
|
|
2081
|
+
} catch {}
|
|
2082
|
+
}
|
|
2083
|
+
current.onclose = ev => finish(ev?.code, ev?.reason)
|
|
2084
|
+
current.onerror = err => {
|
|
2085
|
+
finish(0, '', String(err?.error?.message || err?.message || 'websocket error'))
|
|
2086
|
+
try { current.close() } catch {}
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
connect()
|
|
2090
|
+
return {
|
|
2091
|
+
kind: 'modern',
|
|
2092
|
+
reconnectNow() {
|
|
2093
|
+
if (stopped || ws?.readyState === 0 || ws?.readyState === 1) return
|
|
2094
|
+
clearTimeout(retryTimer)
|
|
2095
|
+
retryTimer = null
|
|
2096
|
+
attempt = 0
|
|
2097
|
+
connect()
|
|
2098
|
+
},
|
|
2099
|
+
close() {
|
|
2100
|
+
stopped = true
|
|
2101
|
+
clearTimeout(retryTimer)
|
|
2102
|
+
clearTimeout(connectTimer)
|
|
2103
|
+
try { ws?.close() } catch {}
|
|
2104
|
+
},
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
async function startCompatibleEventCollectors() {
|
|
2109
|
+
if (await detectUpstreamApiFlavor() === 'modern') {
|
|
2110
|
+
const collector = startModernEventCollector()
|
|
2111
|
+
eventCollectors.mux = collector
|
|
2112
|
+
eventCollectors.host = collector
|
|
2113
|
+
return
|
|
2114
|
+
}
|
|
2115
|
+
eventCollectors.mux = startEventCollector('mux')
|
|
2116
|
+
eventCollectors.host = startEventCollector('host')
|
|
2117
|
+
}
|
|
2118
|
+
|
|
1619
2119
|
// ---------- 统计 API ----------
|
|
1620
2120
|
let statsScanning = false
|
|
1621
2121
|
async function scanStatsOnce(delay) {
|
|
@@ -2048,6 +2548,7 @@ function upstreamReachable(cb) {
|
|
|
2048
2548
|
port: UPSTREAM_PORT,
|
|
2049
2549
|
method: 'GET',
|
|
2050
2550
|
path: '/health',
|
|
2551
|
+
headers: dshUpstreamHeaders(),
|
|
2051
2552
|
timeout: 1500
|
|
2052
2553
|
}, (res) => {
|
|
2053
2554
|
res.resume()
|
|
@@ -2330,16 +2831,21 @@ async function loadFsWorkspaceRoots(force = false) {
|
|
|
2330
2831
|
if (fsWorkspaceRootsFetch) return fsWorkspaceRootsFetch
|
|
2331
2832
|
fsWorkspaceRootsFetch = (async () => {
|
|
2332
2833
|
try {
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2834
|
+
let value
|
|
2835
|
+
if (await detectUpstreamApiFlavor() === 'modern') {
|
|
2836
|
+
value = modernState.workspaces
|
|
2837
|
+
} else {
|
|
2838
|
+
const target = new URL('/api/workspace.list', UPSTREAM)
|
|
2839
|
+
const res = await fetch(target, {
|
|
2840
|
+
method: 'POST',
|
|
2841
|
+
headers: dshUpstreamHeaders({ 'content-type': 'application/json' }),
|
|
2842
|
+
body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method: 'workspace.list', payload: {} }),
|
|
2843
|
+
signal: AbortSignal.timeout(Math.min(8000, UPSTREAM_REQUEST_TIMEOUT_MS)),
|
|
2844
|
+
})
|
|
2845
|
+
if (!res.ok) throw new Error(`workspace.list HTTP ${res.status}`)
|
|
2846
|
+
const body = await res.json()
|
|
2847
|
+
value = body?.result?.ok ? body.result.value : null
|
|
2848
|
+
}
|
|
2343
2849
|
const items = Array.isArray(value?.items) ? value.items : []
|
|
2344
2850
|
const roots = [...new Set(items.map(fsWorkspacePath).filter(Boolean))]
|
|
2345
2851
|
const reals = roots.map(root => { try { return fs.realpathSync(root) } catch { return null } }).filter(Boolean)
|
|
@@ -3263,38 +3769,84 @@ function serveWorkbench(req, res, url) {
|
|
|
3263
3769
|
}
|
|
3264
3770
|
|
|
3265
3771
|
// ---------- /api 代理 ----------
|
|
3266
|
-
function
|
|
3267
|
-
if (req.method
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3772
|
+
async function proxyModernApi(req, res, url) {
|
|
3773
|
+
if (req.method !== 'POST' || url.pathname.startsWith('/remote/')) return false
|
|
3774
|
+
if (await detectUpstreamApiFlavor() !== 'modern') return false
|
|
3775
|
+
let raw = ''
|
|
3776
|
+
try {
|
|
3777
|
+
raw = await new Promise((resolve, reject) => {
|
|
3778
|
+
req.setEncoding('utf8')
|
|
3779
|
+
req.on('data', chunk => {
|
|
3780
|
+
raw += chunk
|
|
3781
|
+
if (raw.length > 4 * 1024 * 1024) reject(new Error('request body too large'))
|
|
3782
|
+
})
|
|
3783
|
+
req.once('end', () => resolve(raw))
|
|
3784
|
+
req.once('error', reject)
|
|
3785
|
+
req.once('aborted', () => reject(new Error('request aborted')))
|
|
3786
|
+
})
|
|
3787
|
+
const body = JSON.parse(raw || '{}')
|
|
3277
3788
|
cors(res)
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3789
|
+
if (url.pathname === '/api/respond') {
|
|
3790
|
+
const eventId = body.rpcId
|
|
3791
|
+
const pending = modernState.pendingEvents.get(eventId)
|
|
3792
|
+
if (!pending || !modernState.eventClientId) {
|
|
3793
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
3794
|
+
res.end(JSON.stringify({ accepted: false }))
|
|
3795
|
+
return true
|
|
3796
|
+
}
|
|
3797
|
+
const value = body.result?.value
|
|
3798
|
+
const answer = pending.event === 'approval/request' ? value?.outcome : value?.answer
|
|
3799
|
+
pending.outcome = answer
|
|
3800
|
+
const response = await callUpstreamRemote('$events/result', {
|
|
3801
|
+
clientId: modernState.eventClientId,
|
|
3802
|
+
eventId,
|
|
3803
|
+
outcome: { kind: 'result', value: answer },
|
|
3804
|
+
})
|
|
3805
|
+
const accepted = response.status === 200 && response.body?.result?.ok === true
|
|
3806
|
+
if (accepted) resolveModernPendingEvent(eventId, false)
|
|
3807
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
3808
|
+
res.end(JSON.stringify({ accepted }))
|
|
3809
|
+
return true
|
|
3810
|
+
}
|
|
3811
|
+
if (body.type !== 'client-request' || typeof body.rpcId !== 'string' || typeof body.method !== 'string') {
|
|
3812
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
3813
|
+
res.end(JSON.stringify({ error: 'bad-request' }))
|
|
3814
|
+
return true
|
|
3815
|
+
}
|
|
3816
|
+
const translated = await translateModernRpc(body.method, body.payload || {}, body.rpcId)
|
|
3817
|
+
if (translated === null) {
|
|
3818
|
+
res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' })
|
|
3819
|
+
res.end(JSON.stringify(legacyEnvelope(body.rpcId, modernError(`Remote method ${body.method} is unavailable on this DSH version`, 'method-unavailable'))))
|
|
3820
|
+
return true
|
|
3821
|
+
}
|
|
3822
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
3823
|
+
res.end(JSON.stringify(translated))
|
|
3824
|
+
return true
|
|
3825
|
+
} catch (error) {
|
|
3826
|
+
if (!res.headersSent) {
|
|
3827
|
+
cors(res)
|
|
3828
|
+
res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
|
|
3829
|
+
}
|
|
3830
|
+
if (!res.writableEnded) res.end(JSON.stringify({ error: 'upstream-incompatible', detail: String(error?.message || error) }))
|
|
3831
|
+
return true
|
|
3281
3832
|
}
|
|
3833
|
+
}
|
|
3282
3834
|
|
|
3835
|
+
function proxyLegacyApi(req, res, url) {
|
|
3283
3836
|
const headers = {}
|
|
3284
3837
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
3285
3838
|
if (v === undefined) continue
|
|
3286
3839
|
const key = k.toLowerCase()
|
|
3287
|
-
if (['host', 'authorization', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
|
|
3840
|
+
if (['host', 'authorization', 'cookie', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
|
|
3288
3841
|
'proxy-connection', 'accept-encoding', 'origin', 'referer',
|
|
3289
3842
|
'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user',
|
|
3290
3843
|
'x-dsh-remote-client'].includes(key)) continue
|
|
3291
3844
|
headers[k] = v
|
|
3292
3845
|
}
|
|
3293
3846
|
headers.host = UPSTREAM.host
|
|
3847
|
+
Object.assign(headers, dshUpstreamHeaders())
|
|
3294
3848
|
// /remote/* 由 DSH 插件端点处理;插件侧用网关自身 token 鉴权。
|
|
3295
|
-
if (url.pathname.startsWith('/remote/'))
|
|
3296
|
-
headers.authorization = 'Bearer ' + TOKEN
|
|
3297
|
-
}
|
|
3849
|
+
if (url.pathname.startsWith('/remote/')) headers.authorization = 'Bearer ' + TOKEN
|
|
3298
3850
|
|
|
3299
3851
|
let responseDone = false
|
|
3300
3852
|
const upstreamReq = UPSTREAM_TRANSPORT.request({
|
|
@@ -3336,6 +3888,31 @@ function proxyApi(req, res, url) {
|
|
|
3336
3888
|
req.pipe(upstreamReq)
|
|
3337
3889
|
}
|
|
3338
3890
|
|
|
3891
|
+
function proxyApi(req, res, url) {
|
|
3892
|
+
if (req.method === 'OPTIONS') {
|
|
3893
|
+
cors(res)
|
|
3894
|
+
res.writeHead(204)
|
|
3895
|
+
res.end()
|
|
3896
|
+
return
|
|
3897
|
+
}
|
|
3898
|
+
const ok = authorized(req, url)
|
|
3899
|
+
touchDevice(req, ok ? {} : { failedAuth: true })
|
|
3900
|
+
if (!ok) {
|
|
3901
|
+
authFailures++
|
|
3902
|
+
cors(res)
|
|
3903
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
3904
|
+
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
3905
|
+
return
|
|
3906
|
+
}
|
|
3907
|
+
void proxyModernApi(req, res, url).then(handled => {
|
|
3908
|
+
if (!handled) proxyLegacyApi(req, res, url)
|
|
3909
|
+
}).catch(error => {
|
|
3910
|
+
cors(res)
|
|
3911
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
|
|
3912
|
+
if (!res.writableEnded) res.end(JSON.stringify({ error: 'upstream-unreachable', detail: String(error?.message || error) }))
|
|
3913
|
+
})
|
|
3914
|
+
}
|
|
3915
|
+
|
|
3339
3916
|
// ---------- 其它 ----------
|
|
3340
3917
|
async function serveHealth(req, res, url) {
|
|
3341
3918
|
const eventHealth = Object.fromEntries(Object.entries(eventCollectorState).map(([kind, state]) => [kind, {
|
|
@@ -3364,7 +3941,7 @@ async function serveHealth(req, res, url) {
|
|
|
3364
3941
|
const ctrl = new AbortController()
|
|
3365
3942
|
timer = setTimeout(() => ctrl.abort(), 5000)
|
|
3366
3943
|
const probeUrl = new URL(DSH_HEALTH_PATH, UPSTREAM).toString()
|
|
3367
|
-
const probe = await fetch(probeUrl, { signal: ctrl.signal, cache: 'no-store' })
|
|
3944
|
+
const probe = await fetch(probeUrl, { headers: dshUpstreamHeaders(), signal: ctrl.signal, cache: 'no-store' })
|
|
3368
3945
|
upstreamReachable = true
|
|
3369
3946
|
upstreamStatus = probe.status
|
|
3370
3947
|
upstreamOk = probe.ok
|
|
@@ -3637,7 +4214,7 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
3637
4214
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
3638
4215
|
if (v === undefined) continue
|
|
3639
4216
|
const key = k.toLowerCase()
|
|
3640
|
-
if (['host', 'authorization', 'connection', 'upgrade', 'sec-websocket-key',
|
|
4217
|
+
if (['host', 'authorization', 'cookie', 'connection', 'upgrade', 'sec-websocket-key',
|
|
3641
4218
|
'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol',
|
|
3642
4219
|
'proxy-connection', 'accept-encoding', 'origin', 'referer',
|
|
3643
4220
|
'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user',
|
|
@@ -3645,6 +4222,7 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
3645
4222
|
headers[k] = v
|
|
3646
4223
|
}
|
|
3647
4224
|
headers.host = UPSTREAM.host
|
|
4225
|
+
Object.assign(headers, dshUpstreamHeaders())
|
|
3648
4226
|
headers.connection = 'Upgrade'
|
|
3649
4227
|
headers.upgrade = 'websocket'
|
|
3650
4228
|
if (req.headers['sec-websocket-key']) headers['sec-websocket-key'] = req.headers['sec-websocket-key']
|
|
@@ -3747,8 +4325,7 @@ server.listen(PORT, HOST, () => {
|
|
|
3747
4325
|
}
|
|
3748
4326
|
console.log(' 上游: ' + UPSTREAM.origin + ' (Ctrl+C 退出)')
|
|
3749
4327
|
// 事件轮询缓冲:网关自身上游 WS 采集,断线自动重连
|
|
3750
|
-
|
|
3751
|
-
eventCollectors.host = startEventCollector('host')
|
|
4328
|
+
void startCompatibleEventCollectors()
|
|
3752
4329
|
// 启动 8 秒后首查, 之后每 6 小时查一次 GitHub/镜像最新版
|
|
3753
4330
|
setTimeout(() => checkForUpdates(false), 8000)
|
|
3754
4331
|
setInterval(() => checkForUpdates(false), UPDATE_INTERVAL_MS)
|
package/index.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 浏览器侧入口由 client half 注册在 DSH 原生侧边栏(见 client.js)。
|
|
7
7
|
*/
|
|
8
8
|
import { execFileSync, spawn } from 'node:child_process'
|
|
9
|
-
import { appendFileSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { appendFileSync, chmodSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } 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'
|
|
@@ -14,7 +14,7 @@ import { dirname, extname, normalize, resolve } from 'node:path'
|
|
|
14
14
|
import { fileURLToPath } from 'node:url'
|
|
15
15
|
|
|
16
16
|
export const name = 'dsh-remote'
|
|
17
|
-
export const inject = ['webServer', 'commands', 'agents']
|
|
17
|
+
export const inject = ['webServer', 'commands', 'agents', 'connection']
|
|
18
18
|
|
|
19
19
|
const MOUNT = '/remote'
|
|
20
20
|
const PUBLIC_DIR = fileURLToPath(new URL('./public/', import.meta.url))
|
|
@@ -68,6 +68,42 @@ try {
|
|
|
68
68
|
|
|
69
69
|
// DSH 实际监听地址由 apply 时从 webServer 服务读取
|
|
70
70
|
let dshListen = { host: '127.0.0.1', port: 3080 }
|
|
71
|
+
let dshConnection = null
|
|
72
|
+
|
|
73
|
+
function dshUpstreamCookieFile() {
|
|
74
|
+
return process.env.DSH_REMOTE_DSH_COOKIE_FILE || `${homedir()}/.dsh-remote/dsh-upstream.cookie`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* DSH 0.1.2-alpha.1 起,Host RPC 与 WebSocket 都要求浏览器会话 Cookie。
|
|
79
|
+
* 新版 connection 服务可把仅进程内可见的启动令牌兑换为 Cookie;旧版没有
|
|
80
|
+
* authenticatedUrl,直接跳过即可。文件只让同一用户的独立网关读取。
|
|
81
|
+
*/
|
|
82
|
+
async function refreshDshUpstreamCookie() {
|
|
83
|
+
if (typeof dshConnection?.authenticatedUrl !== 'function') return false
|
|
84
|
+
const upstream = `http://${dshListen.host}:${dshListen.port}`
|
|
85
|
+
try {
|
|
86
|
+
const loginUrl = dshConnection.authenticatedUrl(upstream)
|
|
87
|
+
const response = await fetch(loginUrl, {
|
|
88
|
+
redirect: 'manual',
|
|
89
|
+
signal: AbortSignal.timeout(2500),
|
|
90
|
+
})
|
|
91
|
+
const setCookie = response.headers.get('set-cookie') || ''
|
|
92
|
+
const cookie = setCookie.split(';', 1)[0].trim()
|
|
93
|
+
if (response.status !== 303 || !cookie.includes('=') || cookie.length > 4096 || /[\0\r\n]/.test(cookie)) {
|
|
94
|
+
throw new Error(`认证交换返回 HTTP ${response.status}`)
|
|
95
|
+
}
|
|
96
|
+
const file = dshUpstreamCookieFile()
|
|
97
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
98
|
+
writeFileSync(file, cookie + '\n', { mode: 0o600 })
|
|
99
|
+
chmodSync(file, 0o600)
|
|
100
|
+
return true
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// 禁止记录带启动令牌的 URL 或 Cookie,只保留不含凭据的错误摘要。
|
|
103
|
+
logGateway('刷新 DSH 上游认证失败: ' + (e?.message || String(e)))
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
71
107
|
|
|
72
108
|
function lanIPs() {
|
|
73
109
|
const out = [...configuredAdvertisedHosts()]
|
|
@@ -169,6 +205,7 @@ function runExit(cmd, args) {
|
|
|
169
205
|
|
|
170
206
|
const GATEWAY_ENV_KEYS = [
|
|
171
207
|
'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_DEVICE_KEYS', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 'DSH_REMOTE_FS_MAX_UPLOAD',
|
|
208
|
+
'DSH_REMOTE_DSH_COOKIE_FILE',
|
|
172
209
|
'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_ADVERTISE_HOSTS', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_SYSTEMCTL', 'DSH_REMOTE_DSH_CONTROL_MODE',
|
|
173
210
|
'DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 'DSH_REMOTE_DSH_CONTROL_POLL_MS', 'DSH_REMOTE_FEEDBACK_URL',
|
|
174
211
|
'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', 'DSH_HEALTH_PATH',
|
|
@@ -363,6 +400,7 @@ function ensureGateway() {
|
|
|
363
400
|
if (ensurePromise) return ensurePromise
|
|
364
401
|
ensurePromise = (async () => {
|
|
365
402
|
try {
|
|
403
|
+
await refreshDshUpstreamCookie()
|
|
366
404
|
const health = await gatewayRunning()
|
|
367
405
|
if (!health.running) {
|
|
368
406
|
const out = await startGateway()
|
|
@@ -740,7 +778,9 @@ async function serveStatic(req, res, ctx) {
|
|
|
740
778
|
commandNames = ['list-error: ' + (e?.message || String(e))]
|
|
741
779
|
}
|
|
742
780
|
const signal = AbortSignal.timeout(30000)
|
|
743
|
-
const result =
|
|
781
|
+
const result = ctx.commands.execute.length === 3
|
|
782
|
+
? await ctx.commands.execute(agent, line, signal)
|
|
783
|
+
: await ctx.commands.execute(agent, line, [], signal)
|
|
744
784
|
sendJson(res, 200, { ok: true, executed: result !== undefined, debug: { resolvePath, commandNames } })
|
|
745
785
|
} catch (e) {
|
|
746
786
|
sendJson(res, 200, { ok: false, message: e?.message || String(e) })
|
|
@@ -783,6 +823,7 @@ async function serveStatic(req, res, ctx) {
|
|
|
783
823
|
|
|
784
824
|
export function apply(ctx) {
|
|
785
825
|
dshListen = { host: ctx.webServer.host, port: ctx.webServer.port }
|
|
826
|
+
dshConnection = ctx.connection
|
|
786
827
|
ctx.effect(() => ctx.webServer.register({
|
|
787
828
|
kind: 'prefix',
|
|
788
829
|
path: MOUNT,
|
|
@@ -794,4 +835,10 @@ export function apply(ctx) {
|
|
|
794
835
|
})
|
|
795
836
|
// DSH 启动/重启后自愈: 用户没关过网关就自动拉起(默认开, DSH_REMOTE_AUTOSTART=0 关闭)
|
|
796
837
|
void ensureGateway()
|
|
838
|
+
// apply 可能早于 Web 监听完成;延迟再交换一次新版 DSH 的会话 Cookie。
|
|
839
|
+
ctx.effect(() => {
|
|
840
|
+
const timer = setTimeout(() => { void ensureGateway() }, 5000)
|
|
841
|
+
timer.unref?.()
|
|
842
|
+
return () => clearTimeout(timer)
|
|
843
|
+
}, 'dsh-remote: refresh upstream authentication')
|
|
797
844
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.20",
|
|
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
|
@@ -2108,6 +2108,7 @@ async function openSession(id) {
|
|
|
2108
2108
|
renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
|
|
2109
2109
|
$('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
|
|
2110
2110
|
renderQueue()
|
|
2111
|
+
renderSessionPending()
|
|
2111
2112
|
restoreCachedHistory()
|
|
2112
2113
|
await loadHistory(true)
|
|
2113
2114
|
renderSessionCards()
|
|
@@ -2140,6 +2141,7 @@ async function closeSession() {
|
|
|
2140
2141
|
const discard = await shouldDiscardEmptySession(sessionId)
|
|
2141
2142
|
if (state.current !== sessionId) return
|
|
2142
2143
|
state.current = null
|
|
2144
|
+
renderSessionPending()
|
|
2143
2145
|
if (discard) removeLocalSessionRecord(sessionId)
|
|
2144
2146
|
setComposerFullscreen(false)
|
|
2145
2147
|
clearComposerImages()
|
|
@@ -3329,7 +3331,7 @@ function renderOverview() {
|
|
|
3329
3331
|
$('overview-attention-list').innerHTML = pending.length ? pending.slice(0, 3).map(({ kind, item }) => {
|
|
3330
3332
|
const title = titleOf(state.byId.get(item.sessionId))
|
|
3331
3333
|
if (kind === 'approval') return `<div class="overview-attention-item" data-overview-approval="${esc(item.approvalId)}">
|
|
3332
|
-
<span class="overview-item-mark">⌁</span><span class="overview-item-copy"><span class="overview-item-title">${esc(item.toolName || t('tool.default'))}</span><span class="overview-item-desc">${esc(item
|
|
3334
|
+
<span class="overview-item-mark">⌁</span><span class="overview-item-copy"><span class="overview-item-title">${esc(item.toolName || t('tool.default'))}</span><span class="overview-item-desc">${esc(approvalDetail(item))} · ${esc(title)}</span></span>
|
|
3333
3335
|
<span class="overview-item-actions"><button type="button" class="mini-btn" data-overview-approve="1">${t('pending.allow')}</button><button type="button" class="mini-btn" data-overview-approve="0">${t('pending.reject')}</button></span>
|
|
3334
3336
|
</div>`
|
|
3335
3337
|
return `<button type="button" class="overview-attention-item question" data-overview-question="${esc(item.rpcId)}">
|
|
@@ -3394,7 +3396,7 @@ function renderPending() {
|
|
|
3394
3396
|
const title = titleOf(state.byId.get(a.sessionId))
|
|
3395
3397
|
return `<div class="pending-card approval" data-approval="${esc(a.approvalId)}">
|
|
3396
3398
|
<div class="pc-title">${esc(t('pending.approvalTitle', { tool: a.toolName || t('tool.default') }))}</div>
|
|
3397
|
-
<div class="pc-desc">${esc(a
|
|
3399
|
+
<div class="pc-desc">${esc(approvalDetail(a))}</div>
|
|
3398
3400
|
<div class="pc-session">${esc(title)}</div>
|
|
3399
3401
|
<div class="goal-actions"><button class="mini-btn" data-approve="1">${t('pending.allow')}</button><button class="mini-btn" data-approve="0">${t('pending.reject')}</button></div>
|
|
3400
3402
|
</div>`
|
|
@@ -3414,10 +3416,48 @@ function renderPending() {
|
|
|
3414
3416
|
})
|
|
3415
3417
|
list.querySelectorAll('[data-question]').forEach(btn =>
|
|
3416
3418
|
btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.question))))
|
|
3419
|
+
renderSessionPending()
|
|
3417
3420
|
updatePendingBadge()
|
|
3418
3421
|
renderOverview()
|
|
3419
3422
|
}
|
|
3420
3423
|
|
|
3424
|
+
function approvalDetail(a) {
|
|
3425
|
+
const lines = []
|
|
3426
|
+
if (a?.reason) lines.push(String(a.reason))
|
|
3427
|
+
if (a?.arguments !== undefined) lines.push(safeJson(a.arguments))
|
|
3428
|
+
if (a?.callId) lines.push(`callId: ${a.callId}`)
|
|
3429
|
+
return lines.join('\n') || t('pending.noReason')
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
function renderSessionPending() {
|
|
3433
|
+
const box = $('session-pending')
|
|
3434
|
+
if (!box) return
|
|
3435
|
+
const sessionId = state.current
|
|
3436
|
+
const items = [
|
|
3437
|
+
...state.approvals.filter(a => a.sessionId === sessionId).map(a => ({ kind: 'approval', item: a })),
|
|
3438
|
+
...state.questions.filter(q => q.sessionId === sessionId).map(q => ({ kind: 'question', item: q }))
|
|
3439
|
+
]
|
|
3440
|
+
box.classList.toggle('hidden', !sessionId || !items.length)
|
|
3441
|
+
if (!sessionId || !items.length) { box.innerHTML = ''; return }
|
|
3442
|
+
box.innerHTML = `<div class="session-pending-head"><span>${esc(t('overview.attention'))}</span><span>${esc(t('pending.count', { n: items.length }))}</span></div><div class="session-pending-list">${items.map(({ kind, item }) => {
|
|
3443
|
+
if (kind === 'approval') return `<article class="pending-card approval" data-session-approval="${esc(item.approvalId)}">
|
|
3444
|
+
<div class="pc-title">${esc(item.toolName || t('tool.default'))}</div>
|
|
3445
|
+
<div class="pc-desc">${esc(approvalDetail(item))}</div>
|
|
3446
|
+
<div class="goal-actions"><button type="button" class="mini-btn" data-session-approve="1">${t('pending.allow')}</button><button type="button" class="mini-btn" data-session-approve="0">${t('pending.reject')}</button></div>
|
|
3447
|
+
</article>`
|
|
3448
|
+
return `<article class="pending-card question" data-session-question="${esc(item.rpcId)}">
|
|
3449
|
+
<div class="pc-title">❓ ${esc((item.questions || []).map(question => question.question).filter(Boolean).join(' / ') || t('notify.questionTitle'))}</div>
|
|
3450
|
+
<div class="goal-actions"><button type="button" class="mini-btn" data-session-answer>${t('pending.answer')}</button></div>
|
|
3451
|
+
</article>`
|
|
3452
|
+
}).join('')}</div>`
|
|
3453
|
+
box.querySelectorAll('[data-session-approve]').forEach(button => {
|
|
3454
|
+
button.addEventListener('click', () => approveApproval(button.closest('[data-session-approval]')?.dataset.sessionApproval || '', button.dataset.sessionApprove === '1'))
|
|
3455
|
+
})
|
|
3456
|
+
box.querySelectorAll('[data-session-answer]').forEach(button => {
|
|
3457
|
+
button.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === button.closest('[data-session-question]')?.dataset.sessionQuestion)))
|
|
3458
|
+
})
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3421
3461
|
async function approveApproval(id, allow) {
|
|
3422
3462
|
const a = state.approvals.find(x => x.approvalId === id)
|
|
3423
3463
|
if (!a) return
|
|
@@ -225,15 +225,15 @@ html.reorder-scroll-lock body { overscroll-behavior: none; }
|
|
|
225
225
|
.ds-overview-attention-list, .ds-overview-session-list { display:flex; flex-direction:column; gap:8px; }
|
|
226
226
|
.ds-overview-attention-item, .ds-overview-session-item { width:100%; min-width:0; display:flex; align-items:center; gap:10px; padding:11px 12px; border:1px solid var(--dsr-line); border-radius:12px; background:var(--dsr-bg-2); color:var(--dsr-text); text-align:left; }
|
|
227
227
|
button.ds-overview-attention-item, button.ds-overview-session-item { cursor:pointer; font:inherit; }
|
|
228
|
-
.ds-overview-attention-item { border-left:3px solid var(--dsr-warning); }
|
|
228
|
+
.ds-overview-attention-item { align-items:flex-start; border-left:3px solid var(--dsr-warning); }
|
|
229
229
|
.ds-overview-attention-item.question { border-left-color:var(--dsr-accent-2); }
|
|
230
230
|
.ds-overview-mark { flex:0 0 auto; width:26px; height:26px; display:grid; place-items:center; border-radius:8px; background:var(--dsr-warning-soft); color:var(--dsr-warning); font-size:12px; }
|
|
231
231
|
.ds-overview-attention-item.question .ds-overview-mark { background:var(--dsr-accent-2-soft); color:var(--dsr-accent-2); }
|
|
232
232
|
.ds-overview-copy { flex:1; min-width:0; }
|
|
233
|
-
.ds-overview-item-title, .ds-overview-item-desc { display:block;
|
|
233
|
+
.ds-overview-item-title, .ds-overview-item-desc { display:block; line-height:1.45; overflow-wrap:anywhere; word-break:break-word; }
|
|
234
234
|
.ds-overview-item-title { font-size:12px; font-weight:650; }
|
|
235
|
-
.ds-overview-item-desc { margin-top:3px; color:var(--dsr-muted); font-size:11px; }
|
|
236
|
-
.ds-overview-actions { flex:0 0 auto; display:flex; gap:4px; }
|
|
235
|
+
.ds-overview-item-desc { margin-top:3px; color:var(--dsr-muted); font-size:11px; white-space:normal; }
|
|
236
|
+
.ds-overview-actions { flex:0 0 auto; display:flex; flex-wrap:wrap; justify-content:flex-end; gap:4px; }
|
|
237
237
|
.ds-overview-actions .ds-btn { min-height:27px; padding:2px 8px; font-size:11px; }
|
|
238
238
|
.ds-overview-actions .allow { background:var(--dsr-success-soft); border-color:var(--dsr-success-line); color:var(--dsr-success); }
|
|
239
239
|
.ds-overview-actions .reject { background:var(--dsr-warning-soft); border-color:var(--dsr-warning-line); color:var(--dsr-warning); }
|
|
@@ -287,6 +287,11 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
|
|
|
287
287
|
.ds-presets-guide { padding: 2px 8px 10px; font-size: 11px; line-height: 1.5; }
|
|
288
288
|
.ds-session-cards { flex: none; max-height: 220px; overflow-y: auto; padding: 10px 22px 0; display: flex; flex-direction: column; gap: 8px; }
|
|
289
289
|
.ds-session-cards:empty { display: none; }
|
|
290
|
+
.ds-session-pending { flex:none; margin:10px 22px 0; padding:10px; border:1px solid var(--dsr-warning-line); border-radius:13px; background:var(--dsr-warning-soft); }
|
|
291
|
+
.ds-session-pending-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; color:var(--dsr-text); font-size:12px; font-weight:750; }
|
|
292
|
+
.ds-session-pending-list { display:flex; flex-direction:column; gap:8px; }
|
|
293
|
+
.ds-session-pending .ds-notif-card { min-width:0; box-shadow:none; animation:none; }
|
|
294
|
+
.ds-session-pending .ds-notif-title, .ds-session-pending .ds-notif-body { overflow-wrap:anywhere; word-break:break-word; }
|
|
290
295
|
.ds-card { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 12px; padding: 10px 12px; }
|
|
291
296
|
.ds-card-title { font-size: 11px; font-weight: 700; color: var(--dsr-muted); letter-spacing: .6px; margin-bottom: 6px; text-transform: uppercase; }
|
|
292
297
|
.ds-goal-disclosure { width: 100%; min-width: 0; display: flex; justify-content: flex-end; }
|
|
@@ -151,6 +151,7 @@
|
|
|
151
151
|
<section id="view-chat" class="ds-view">
|
|
152
152
|
<div id="session-cards" class="ds-session-cards"></div>
|
|
153
153
|
<div id="queue-dock" class="ds-queue-dock hidden" aria-live="polite"></div>
|
|
154
|
+
<section id="session-pending" class="ds-session-pending hidden" aria-live="polite"></section>
|
|
154
155
|
<div id="history" class="ds-history" aria-live="polite"></div>
|
|
155
156
|
<div class="ds-composer">
|
|
156
157
|
<div id="composer-status" class="ds-composer-status hidden" role="status" aria-live="polite"><span class="ds-composer-status-dot" aria-hidden="true"></span><span data-i18n="ds.composerRunning">运行中…</span></div>
|
|
@@ -1566,6 +1566,7 @@ async function openSession(id) {
|
|
|
1566
1566
|
renderSessions()
|
|
1567
1567
|
renderSessionCards()
|
|
1568
1568
|
renderQueue()
|
|
1569
|
+
renderSessionPendingDesktop()
|
|
1569
1570
|
updateComposerStatus()
|
|
1570
1571
|
await loadHistory()
|
|
1571
1572
|
}
|
|
@@ -1576,6 +1577,7 @@ function closeSession() {
|
|
|
1576
1577
|
const cards = $('session-cards')
|
|
1577
1578
|
if (cards) cards.innerHTML = ''
|
|
1578
1579
|
renderQueue()
|
|
1580
|
+
renderSessionPendingDesktop()
|
|
1579
1581
|
updateComposerStatus()
|
|
1580
1582
|
updateSessionActions()
|
|
1581
1583
|
showView('view-sessions')
|
|
@@ -2010,6 +2012,39 @@ function serverLabel() {
|
|
|
2010
2012
|
const cur = currentServerEntry()
|
|
2011
2013
|
return cur ? (cur.note || cur.url) : (state.server || location.host)
|
|
2012
2014
|
}
|
|
2015
|
+
function desktopApprovalDetail(a) {
|
|
2016
|
+
const lines = []
|
|
2017
|
+
if (a?.reason) lines.push(String(a.reason))
|
|
2018
|
+
if (a?.arguments !== undefined) lines.push(safeJson(a.arguments))
|
|
2019
|
+
if (a?.callId) lines.push(`callId: ${a.callId}`)
|
|
2020
|
+
return lines.join('\n') || t('ds.approvalReason', { reason: '' })
|
|
2021
|
+
}
|
|
2022
|
+
function renderSessionPendingDesktop() {
|
|
2023
|
+
const box = $('session-pending')
|
|
2024
|
+
if (!box) return
|
|
2025
|
+
const sessionId = state.current
|
|
2026
|
+
const items = [
|
|
2027
|
+
...state.approvals.filter(a => a.sessionId === sessionId).map(a => ({ kind: 'approval', item: a })),
|
|
2028
|
+
...state.questions.filter(q => q.sessionId === sessionId).map(q => ({ kind: 'question', item: q }))
|
|
2029
|
+
]
|
|
2030
|
+
box.classList.toggle('hidden', !sessionId || !items.length)
|
|
2031
|
+
if (!sessionId || !items.length) { box.innerHTML = ''; return }
|
|
2032
|
+
box.innerHTML = `<div class="ds-session-pending-head"><span>${esc(t('ds.attention'))}</span><span>${items.length}</span></div><div class="ds-session-pending-list">${items.map(({ kind, item }) => {
|
|
2033
|
+
if (kind === 'approval') return `<article class="ds-notif-card" data-session-approval="${esc(item.approvalId)}">
|
|
2034
|
+
<div class="ds-notif-head">🔐 ${t('ds.approvalTitle')}</div>
|
|
2035
|
+
<div class="ds-notif-title">${esc(item.toolName || t('ds.toolDefault'))}</div>
|
|
2036
|
+
<div class="ds-notif-body">${esc(desktopApprovalDetail(item))}</div>
|
|
2037
|
+
<div class="ds-notif-actions"><button type="button" class="ds-btn allow" data-session-approve="1">${t('ds.allow')}</button><button type="button" class="ds-btn reject" data-session-approve="0">${t('ds.reject')}</button></div>
|
|
2038
|
+
</article>`
|
|
2039
|
+
return `<article class="ds-notif-card question" data-session-question="${esc(item.rpcId)}">
|
|
2040
|
+
<div class="ds-notif-head">❓ ${t('ds.questionNotify')}</div>
|
|
2041
|
+
<div class="ds-notif-title">${esc((item.questions || []).map(question => question.question).filter(Boolean).join(' / '))}</div>
|
|
2042
|
+
<div class="ds-notif-actions"><button type="button" class="ds-btn" data-session-answer>${t('ds.submit')}</button></div>
|
|
2043
|
+
</article>`
|
|
2044
|
+
}).join('')}</div>`
|
|
2045
|
+
box.querySelectorAll('[data-session-approve]').forEach(button => button.addEventListener('click', () => approveApproval(button.closest('[data-session-approval]')?.dataset.sessionApproval || '', button.dataset.sessionApprove === '1')))
|
|
2046
|
+
box.querySelectorAll('[data-session-answer]').forEach(button => button.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === button.closest('[data-session-question]')?.dataset.sessionQuestion))))
|
|
2047
|
+
}
|
|
2013
2048
|
function renderNotifStack() {
|
|
2014
2049
|
const stack = $('notif-stack')
|
|
2015
2050
|
const items = [
|
|
@@ -2019,7 +2054,7 @@ function renderNotifStack() {
|
|
|
2019
2054
|
stack.innerHTML = items.map(it => {
|
|
2020
2055
|
if (it.kind === 'approval') {
|
|
2021
2056
|
const a = it.a
|
|
2022
|
-
const reason =
|
|
2057
|
+
const reason = desktopApprovalDetail(a)
|
|
2023
2058
|
return `<div class="ds-notif-card" data-approval="${esc(a.approvalId)}" tabindex="0">
|
|
2024
2059
|
<div class="ds-notif-head">🔐 ${t('ds.approvalTitle')} · ${esc(serverLabel())} · ${fmtTime(a.time || Date.now())}</div>
|
|
2025
2060
|
<div class="ds-notif-title">${esc(a.toolName || t('ds.toolDefault'))}</div>
|
|
@@ -2050,6 +2085,7 @@ function renderNotifStack() {
|
|
|
2050
2085
|
stack.querySelectorAll('.ds-notif-card').forEach(card => card.addEventListener('keydown', (e) => {
|
|
2051
2086
|
if (e.key === 'Escape') toast(t('ds.ignored'), 'ok')
|
|
2052
2087
|
}))
|
|
2088
|
+
renderSessionPendingDesktop()
|
|
2053
2089
|
renderOverviewDesktop()
|
|
2054
2090
|
}
|
|
2055
2091
|
async function approveApproval(id, allow) {
|
|
@@ -2708,7 +2744,7 @@ function renderOverviewDesktop() {
|
|
|
2708
2744
|
$('ds-overview-attention-list').innerHTML = pending.length ? pending.slice(0, 4).map(({ kind, item }) => {
|
|
2709
2745
|
const title = titleOf(state.byId.get(item.sessionId))
|
|
2710
2746
|
if (kind === 'approval') return `<div class="ds-overview-attention-item" data-ds-overview-approval="${esc(item.approvalId)}">
|
|
2711
|
-
<span class="ds-overview-mark">⌁</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(item.toolName || t('ds.toolDefault'))}</span><span class="ds-overview-item-desc">${esc(item
|
|
2747
|
+
<span class="ds-overview-mark">⌁</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(item.toolName || t('ds.toolDefault'))}</span><span class="ds-overview-item-desc">${esc(desktopApprovalDetail(item))} · ${esc(title)}</span></span>
|
|
2712
2748
|
<span class="ds-overview-actions"><button class="ds-btn allow" data-ds-overview-approve="1">${t('ds.allow')}</button><button class="ds-btn reject" data-ds-overview-approve="0">${t('ds.reject')}</button></span>
|
|
2713
2749
|
</div>`
|
|
2714
2750
|
return `<button type="button" class="ds-overview-attention-item question" data-ds-overview-question="${esc(item.rpcId)}">
|
package/public/index.html
CHANGED
|
@@ -104,6 +104,8 @@
|
|
|
104
104
|
|
|
105
105
|
<div id="queue-dock" class="queue-dock hidden" aria-live="polite"></div>
|
|
106
106
|
|
|
107
|
+
<section id="session-pending" class="session-pending hidden" aria-live="polite"></section>
|
|
108
|
+
|
|
107
109
|
<div class="section-head small">
|
|
108
110
|
<span data-i18n="session.history">对话</span>
|
|
109
111
|
<span id="history-hint" class="muted"></span>
|
package/public/styles.css
CHANGED
|
@@ -424,6 +424,13 @@ body.in-session .view {
|
|
|
424
424
|
.queue-dock-item:last-child { border-bottom: 0; }
|
|
425
425
|
.queue-dock-preview { flex: 1; min-width: 0; color: var(--dsr-text); font-size: 13px; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
426
426
|
.queue-dock-action { flex: 0 0 auto; }
|
|
427
|
+
.session-pending { flex:0 0 auto; margin:0 0 10px; padding:10px; border:1px solid var(--dsr-warning-line); border-radius:13px; background:var(--dsr-warning-soft); }
|
|
428
|
+
.session-pending-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; color:var(--dsr-text); font-size:12px; font-weight:750; }
|
|
429
|
+
.session-pending-list { display:flex; flex-direction:column; gap:8px; }
|
|
430
|
+
.session-pending .pending-card { min-width:0; background:var(--dsr-panel); }
|
|
431
|
+
.session-pending .pc-title { line-height:1.45; overflow-wrap:anywhere; word-break:break-word; }
|
|
432
|
+
.session-pending .pc-desc { line-height:1.5; white-space:pre-wrap; overflow-wrap:anywhere; }
|
|
433
|
+
.session-pending .goal-actions { justify-content:flex-end; }
|
|
427
434
|
.md-table-wrap { max-width: 100%; overflow-x: auto; margin: 8px 0; }
|
|
428
435
|
.md-table-wrap table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: .94em; }
|
|
429
436
|
.md-table-wrap th, .md-table-wrap td { padding: 6px 9px; border: 1px solid var(--dsr-line); text-align: left; white-space: nowrap; }
|
|
@@ -711,16 +718,17 @@ body.in-session .main { padding-bottom: 0; }
|
|
|
711
718
|
.overview-section-meta { color:var(--dsr-muted); font-size:11px; font-weight:500; }
|
|
712
719
|
.overview-attention-list, .overview-session-list { display:flex; flex-direction:column; gap:7px; }
|
|
713
720
|
.overview-attention-item, .overview-session-item { width:100%; display:flex; align-items:center; gap:10px; min-width:0; padding:11px 12px; border:1px solid var(--dsr-line); border-radius:13px; background:var(--dsr-panel); color:var(--dsr-text); text-align:left; }
|
|
721
|
+
.overview-attention-item { align-items:flex-start; }
|
|
714
722
|
button.overview-attention-item, button.overview-session-item { cursor:pointer; }
|
|
715
723
|
.overview-attention-item { border-left:3px solid var(--dsr-warning); }
|
|
716
724
|
.overview-attention-item.question { border-left-color:var(--dsr-accent-2); }
|
|
717
725
|
.overview-item-mark { flex:0 0 auto; width:25px; height:25px; display:grid; place-items:center; border-radius:8px; background:var(--dsr-warning-soft); color:var(--dsr-warning); font-size:12px; }
|
|
718
726
|
.overview-attention-item.question .overview-item-mark { background:var(--dsr-accent-2-soft); color:var(--dsr-accent-2); }
|
|
719
727
|
.overview-item-copy { min-width:0; flex:1; }
|
|
720
|
-
.overview-item-title {
|
|
721
|
-
.overview-item-desc {
|
|
728
|
+
.overview-item-title { font-size:12.5px; font-weight:650; line-height:1.45; overflow-wrap:anywhere; word-break:break-word; }
|
|
729
|
+
.overview-item-desc { margin-top:3px; color:var(--dsr-muted); font-size:11px; line-height:1.45; white-space:normal; overflow-wrap:anywhere; }
|
|
722
730
|
.overview-item-arrow { color:var(--dsr-muted); font-size:18px; }
|
|
723
|
-
.overview-item-actions { flex:0 0 auto; display:flex; gap:4px; }
|
|
731
|
+
.overview-item-actions { flex:0 0 auto; display:flex; flex-wrap:wrap; justify-content:flex-end; gap:4px; }
|
|
724
732
|
.overview-item-actions .mini-btn { padding:4px 7px; font-size:10px; }
|
|
725
733
|
.overview-metric-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; }
|
|
726
734
|
.overview-metric { min-width:0; padding:11px 12px; border:1px solid var(--dsr-line); border-radius:13px; background:var(--dsr-panel); }
|
package/public/update.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.6.
|
|
2
|
+
"version": "0.6.20",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"sha256": "
|
|
5
|
-
"releasedAt": "2026-08-
|
|
6
|
-
"notes": "0.6.
|
|
4
|
+
"sha256": "a5793085029414356a0a4faf411eb3c99bc934cdbf3ae387cc9c4c06738cf0c1",
|
|
5
|
+
"releasedAt": "2026-08-30T14:51:29.621Z",
|
|
6
|
+
"notes": "0.6.20:适配 DSH 0.1.2-alpha.1 的新版客户端模块、认证、斜杠 RPC 与 remote.mux 实时协议,同时保留旧版 DSH 的自动回退;修复斜杠命令因 commands.execute 参数变化触发 aborted 报错,以及新版历史、工作区、模型、目标和子代理等接口不兼容问题;审批与用户提问现在会在所属对话内完整显示并可直接处理,主页仍保留全局待办入口,长工具参数、原因和调用标识不再被截断。",
|
|
7
7
|
"history": [
|
|
8
|
+
{
|
|
9
|
+
"version": "0.6.20",
|
|
10
|
+
"notes": "0.6.20:适配 DSH 0.1.2-alpha.1 的新版客户端模块、认证、斜杠 RPC 与 remote.mux 实时协议,同时保留旧版 DSH 的自动回退;修复斜杠命令因 commands.execute 参数变化触发 aborted 报错,以及新版历史、工作区、模型、目标和子代理等接口不兼容问题;审批与用户提问现在会在所属对话内完整显示并可直接处理,主页仍保留全局待办入口,长工具参数、原因和调用标识不再被截断。"
|
|
11
|
+
},
|
|
8
12
|
{
|
|
9
13
|
"version": "0.6.19",
|
|
10
14
|
"notes": "0.6.19:全面优化手机端、桌面端、管理页和插件页的布局、动效与响应式体验,并支持减少动态效果偏好;重做蓝色、草原、音乐厅等主题的明暗层级和文字图标对比度,新增利落的黑白配色;统一主页图标配色并修正设置齿轮中心孔偏移;目标面板支持收起为始终可见的侧边入口,保留运行状态、可随时恢复,并按会话与目标在本地记忆。"
|
|
@@ -40,10 +44,6 @@
|
|
|
40
44
|
{
|
|
41
45
|
"version": "0.6.11",
|
|
42
46
|
"notes": "0.6.11 正式版:新增按 DSH 工作区筛选、切换和创建会话,文件页支持切换工作区并直接预览常见文本与 Markdown;修复工作区筛选未正确收敛会话、工作区位于默认文件根外时无法查看,以及新会话工作区名称与完整路径显示异常;新增投票公告及隐私化汇总;统一手机端应用内选择抽屉;周末全天按谷时并修复重复峰谷提醒;插件侧栏图标采用亮色背景并随插件自身四套皮肤切换配色;加强网关安装、连接与故障排查引导。"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"version": "0.6.10",
|
|
46
|
-
"notes": "0.6.10 正式版:修复 DSH/网关重启后事件通道不自动恢复、启动阶段网卡读取异常、同源状态误报、双通道总览卡在 3/4、图片后实时回复不可见及子代理重复;新增完整用户链路与重启生命周期测试;公网反馈切换到安全的 HTTPS 临时入口并加强来源 IP 防伪造、节流与隐私保护。"
|
|
47
47
|
}
|
|
48
48
|
]
|
|
49
49
|
}
|
package/public/version.json
CHANGED