dsh-dock 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,328 @@
1
+ // dsh-dock · 功能模块【手机接力】· 宿主半部
2
+ //
3
+ // 不复制或迁移 DSH 会话:手机与电脑都连接同一个 DSH 宿主时,会话本来就是同一份。
4
+ // 本模块只负责短时配对、在线状态、实时任务摘要和接力备注,避免把任务状态再做成一套
5
+ // 容易分叉的副本。所有配对凭据均只存内存,宿主重启后自动失效。
6
+ import { randomBytes } from 'node:crypto'
7
+ import { readBody, sendJson } from '../../src/host-core.js'
8
+ import { lanAddresses, startProtectedLanGateway } from './gateway.js'
9
+
10
+ const PAIR_TTL_MS = 10 * 60 * 1000
11
+ const PRESENCE_TTL_MS = 45 * 1000
12
+ const NOTE_MAX_LENGTH = 1200
13
+ const MAX_JOIN_ATTEMPTS = 8
14
+ const DEFAULT_GATEWAY_PORT = 3081
15
+
16
+ function now() { return Date.now() }
17
+ function cleanText(value, max) { return String(value || '').replace(/\u0000/g, '').trim().slice(0, max) }
18
+ function publicId() { return randomBytes(9).toString('base64url') }
19
+ function joinCode() { return randomBytes(5).toString('hex').toUpperCase() }
20
+ function phaseOf(event) {
21
+ if (event && event.type === 'assistant/chunk') {
22
+ const type = event.data && event.data.chunk && event.data.chunk.type
23
+ if (type === 'reasoning-delta') return 'think'
24
+ if (type === 'text-delta') return 'write'
25
+ }
26
+ if (event && event.type === 'tool/call') {
27
+ const name = String(event.data && event.data.name || '').toLowerCase()
28
+ return /web|search|fetch|grep|glob|find|read|ls$|^ls|tree|locate/.test(name) ? 'search' : 'code'
29
+ }
30
+ return ''
31
+ }
32
+ function extractText(content) {
33
+ if (!Array.isArray(content)) return ''
34
+ return content.filter((x) => x && x.type === 'text' && typeof x.text === 'string')
35
+ .map((x) => x.text).join(' ').replace(/\s+/g, ' ').trim()
36
+ }
37
+ function routeMethod(req) {
38
+ const url = new URL(req.url || '/', 'http://dsh.internal')
39
+ return url.pathname.replace(/^\/dsh-dock\/mobile-relay\/?/, '').split('/')[0] || ''
40
+ }
41
+
42
+ export const feature = {
43
+ id: 'mobile-relay',
44
+ name: '手机接力',
45
+ description: '安全反向代理与扫码接力,同步当前任务状态和备注',
46
+ defaultEnabled: true,
47
+ setup(ctx) {
48
+ const disposers = []
49
+ const pairs = new Map()
50
+ const tasks = new Map()
51
+ const joinAttempts = new Map()
52
+ let gateway = null
53
+ let gatewayStarting = null
54
+
55
+ async function stopGateway() {
56
+ const current = gateway
57
+ gateway = null
58
+ gatewayStarting = null
59
+ if (current) await current.close()
60
+ }
61
+
62
+ async function ensureGateway(webServer, requestedPort) {
63
+ const port = Number(requestedPort === undefined ? DEFAULT_GATEWAY_PORT : requestedPort)
64
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) {
65
+ const error = new Error('局域网端口需为 1024 到 65535 之间的整数')
66
+ error.statusCode = 400
67
+ throw error
68
+ }
69
+ if (port === webServer.port) {
70
+ const error = new Error(`局域网端口不能与 DSH 主服务端口 ${webServer.port} 相同`)
71
+ error.statusCode = 400
72
+ throw error
73
+ }
74
+ if (gateway && gateway.port === port) return gateway
75
+ if (gateway) {
76
+ const error = new Error(`局域网网关已在 ${gateway.port} 端口运行,请先关闭当前手机连接`)
77
+ error.statusCode = 409
78
+ throw error
79
+ }
80
+ if (!gatewayStarting) {
81
+ gatewayStarting = startProtectedLanGateway({ port, upstreamHost: '127.0.0.1', upstreamPort: webServer.port })
82
+ .then((started) => { gateway = started; gatewayStarting = null; return started })
83
+ .catch((error) => { gatewayStarting = null; throw error })
84
+ }
85
+ try { return await gatewayStarting }
86
+ catch (cause) {
87
+ const error = new Error(cause && cause.code === 'EADDRINUSE'
88
+ ? `端口 ${port} 已被占用,请换一个端口`
89
+ : '无法开启局域网网关:' + ((cause && cause.message) || String(cause)))
90
+ error.statusCode = 400
91
+ throw error
92
+ }
93
+ }
94
+
95
+ function purge() {
96
+ const stamp = now()
97
+ for (const [id, pair] of pairs) {
98
+ for (const [deviceId, device] of pair.devices) {
99
+ if (stamp - device.seenAt > PRESENCE_TTL_MS) pair.devices.delete(deviceId)
100
+ }
101
+ if (pair.joinExpiresAt <= stamp && pair.devices.size === 0) {
102
+ pairs.delete(id)
103
+ if (gateway) gateway.revokePair(id)
104
+ }
105
+ }
106
+ for (const [key, stampAt] of joinAttempts) if (stamp - stampAt.firstAt > PAIR_TTL_MS) joinAttempts.delete(key)
107
+ if (gateway) gateway.purge()
108
+ if (gateway && pairs.size === 0) void stopGateway()
109
+ }
110
+ const purgeTimer = setInterval(purge, 15 * 1000)
111
+ disposers.push(() => clearInterval(purgeTimer))
112
+
113
+ function taskSnapshot() {
114
+ const stamp = now()
115
+ return [...tasks.values()].filter((task) => task.running).map((task) => ({
116
+ sessionId: task.sessionId,
117
+ title: task.title || task.firstPrompt || '(等待任务标题)',
118
+ phase: task.phase || 'think',
119
+ startedAt: task.startedAt,
120
+ elapsed: stamp - task.startedAt,
121
+ lastActivityAt: task.lastActivityAt,
122
+ })).sort((a, b) => b.lastActivityAt - a.lastActivityAt)
123
+ }
124
+ function visiblePair(pair) {
125
+ const devices = [...pair.devices.values()].map((device) => ({
126
+ id: device.id,
127
+ role: device.role,
128
+ label: device.label,
129
+ seenAt: device.seenAt,
130
+ })).sort((a, b) => b.seenAt - a.seenAt)
131
+ return {
132
+ id: pair.id,
133
+ expiresAt: pair.joinExpiresAt,
134
+ note: pair.note,
135
+ noteAt: pair.noteAt,
136
+ noteFrom: pair.noteFrom,
137
+ devices,
138
+ tasks: taskSnapshot(),
139
+ now: now(),
140
+ }
141
+ }
142
+ function requirePair(payload) {
143
+ const pairId = cleanText(payload && payload.pairId, 48)
144
+ const deviceId = cleanText(payload && payload.deviceId, 80)
145
+ const secret = cleanText(payload && payload.secret, 160)
146
+ const pair = pairs.get(pairId)
147
+ if (!pair) {
148
+ const error = new Error('配对不存在,请在电脑端重新开启连接')
149
+ error.statusCode = 410
150
+ throw error
151
+ }
152
+ const device = pair.devices.get(deviceId)
153
+ if (!device || device.secret !== secret) {
154
+ const error = new Error('此设备未完成配对,请重新打开手机接力链接')
155
+ error.statusCode = 403
156
+ throw error
157
+ }
158
+ device.seenAt = now()
159
+ return { pair, device }
160
+ }
161
+ function joinRateKey(req) {
162
+ return (req.socket && req.socket.remoteAddress) || 'unknown'
163
+ }
164
+ function recordFailedJoin(req) {
165
+ const key = joinRateKey(req)
166
+ const item = joinAttempts.get(key) || { count: 0, firstAt: now() }
167
+ if (now() - item.firstAt > PAIR_TTL_MS) { item.count = 0; item.firstAt = now() }
168
+ item.count++
169
+ joinAttempts.set(key, item)
170
+ return item.count
171
+ }
172
+ function canJoin(req) {
173
+ const item = joinAttempts.get(joinRateKey(req))
174
+ return !item || now() - item.firstAt > PAIR_TTL_MS || item.count < MAX_JOIN_ATTEMPTS
175
+ }
176
+
177
+ // 任务状态只取最小必要字段。真正的会话内容仍由 DSH 的既有会话服务管理,
178
+ // 这样手机继续输入后不会和电脑产生两个任务副本。
179
+ disposers.push(ctx.inject(['sessionQuery'], (sqCtx) => {
180
+ const sessionQuery = sqCtx.sessionQuery
181
+ const titleFor = async (task) => {
182
+ try {
183
+ const snapshot = sessionQuery && typeof sessionQuery.readTitle === 'function'
184
+ ? await sessionQuery.readTitle(task.sessionId) : null
185
+ if (snapshot && snapshot.title && tasks.get(task.sessionId) === task) task.title = snapshot.title
186
+ } catch { /* 标题缺失时以首条输入兜底 */ }
187
+ }
188
+ disposers.push(sqCtx.on('agent/status', (payload) => {
189
+ const agent = payload && payload.agent
190
+ const sid = agent && agent.id ? String(agent.id) : ''
191
+ if (!sid) return
192
+ if (payload.status === 'running') {
193
+ let task = tasks.get(sid)
194
+ if (!task) {
195
+ task = { sessionId: sid, title: '', firstPrompt: '', phase: 'think', startedAt: now(), lastActivityAt: now(), running: true }
196
+ tasks.set(sid, task)
197
+ titleFor(task)
198
+ }
199
+ task.running = true
200
+ task.lastActivityAt = now()
201
+ } else if (payload.status === 'idle') {
202
+ const task = tasks.get(sid)
203
+ if (task) { task.running = false; task.lastActivityAt = now() }
204
+ }
205
+ }))
206
+ disposers.push(sqCtx.on('agent/disposed', (payload) => {
207
+ const sid = payload && payload.agent && payload.agent.id ? String(payload.agent.id) : ''
208
+ const task = tasks.get(sid)
209
+ if (task) { task.running = false; task.lastActivityAt = now() }
210
+ }))
211
+ disposers.push(sqCtx.on('session/event', (session, event) => {
212
+ const sid = typeof session === 'object' && session ? String(session.id || '') : String(session || '')
213
+ const task = tasks.get(sid)
214
+ if (!task) return
215
+ const phase = phaseOf(event)
216
+ if (phase) task.phase = phase
217
+ if (!task.firstPrompt && event && event.type === 'user/message') {
218
+ const text = extractText(event.data && event.data.content)
219
+ if (text) task.firstPrompt = text.slice(0, 120)
220
+ }
221
+ task.lastActivityAt = (event && event.time) || now()
222
+ }))
223
+ }))
224
+
225
+ disposers.push(ctx.inject(['webServer'], (wsCtx) => {
226
+ wsCtx.effect(() => wsCtx.webServer.register({
227
+ kind: 'prefix',
228
+ path: '/dsh-dock/mobile-relay',
229
+ async handler(req, res) {
230
+ try {
231
+ const method = routeMethod(req)
232
+ const payload = await readBody(req)
233
+ purge()
234
+ if (method === 'network') {
235
+ return sendJson(res, 200, { ok: true, data: {
236
+ addresses: lanAddresses(),
237
+ defaultPort: DEFAULT_GATEWAY_PORT,
238
+ main: { host: wsCtx.webServer.host, port: wsCtx.webServer.port },
239
+ gateway: gateway ? { active: true, port: gateway.port, addresses: gateway.addresses } : { active: false },
240
+ } })
241
+ }
242
+ if (method === 'start') {
243
+ const activeGateway = await ensureGateway(wsCtx.webServer, payload && payload.port)
244
+ const id = publicId()
245
+ const code = joinCode()
246
+ const desktopId = cleanText(payload && payload.deviceId, 80) || publicId()
247
+ const desktopSecret = publicId() + publicId()
248
+ const pair = {
249
+ id, code, joinExpiresAt: now() + PAIR_TTL_MS, note: '', noteAt: 0, noteFrom: '', devices: new Map(),
250
+ }
251
+ pair.devices.set(desktopId, { id: desktopId, secret: desktopSecret, role: 'desktop', label: '电脑端', seenAt: now() })
252
+ pairs.set(id, pair)
253
+ const gatewayToken = activeGateway.issue(id, `${id}.${code}`, pair.joinExpiresAt)
254
+ return sendJson(res, 200, { ok: true, data: {
255
+ pairId: id, code, secret: desktopSecret, expiresAt: pair.joinExpiresAt, gatewayToken,
256
+ gateway: { active: true, port: activeGateway.port, addresses: activeGateway.addresses },
257
+ pair: visiblePair(pair),
258
+ } })
259
+ }
260
+ if (method === 'join') {
261
+ if (!canJoin(req)) {
262
+ const error = new Error('尝试次数过多,请稍后重试或在电脑端重新生成链接')
263
+ error.statusCode = 429
264
+ throw error
265
+ }
266
+ const pairId = cleanText(payload && payload.pairId, 48)
267
+ const code = cleanText(payload && payload.code, 32).toUpperCase()
268
+ const pair = pairs.get(pairId)
269
+ if (!pair || pair.joinExpiresAt <= now() || code !== pair.code) {
270
+ recordFailedJoin(req)
271
+ const error = new Error('连接码无效或已过期,请回到电脑端重新开启')
272
+ error.statusCode = 403
273
+ throw error
274
+ }
275
+ const deviceId = cleanText(payload && payload.deviceId, 80) || publicId()
276
+ const secret = publicId() + publicId()
277
+ pair.devices.set(deviceId, { id: deviceId, secret, role: 'mobile', label: cleanText(payload && payload.label, 32) || '手机端', seenAt: now() })
278
+ return sendJson(res, 200, { ok: true, data: { pairId, secret, expiresAt: pair.joinExpiresAt, pair: visiblePair(pair) } })
279
+ }
280
+ if (method === 'status') {
281
+ const { pair } = requirePair(payload)
282
+ return sendJson(res, 200, { ok: true, data: { pair: visiblePair(pair) } })
283
+ }
284
+ if (method === 'note') {
285
+ const { pair, device } = requirePair(payload)
286
+ const note = cleanText(payload && payload.note, NOTE_MAX_LENGTH)
287
+ if (!note) {
288
+ const error = new Error('请先写下接力内容')
289
+ error.statusCode = 400
290
+ throw error
291
+ }
292
+ pair.note = note
293
+ pair.noteAt = now()
294
+ pair.noteFrom = device.role === 'mobile' ? '手机端' : '电脑端'
295
+ return sendJson(res, 200, { ok: true, data: { pair: visiblePair(pair) } })
296
+ }
297
+ if (method === 'end') {
298
+ const { pair } = requirePair(payload)
299
+ pairs.delete(pair.id)
300
+ if (gateway) gateway.revokePair(pair.id)
301
+ const shouldStopGateway = pairs.size === 0
302
+ sendJson(res, 200, { ok: true, data: { ended: true } })
303
+ // 手机端的关闭请求本身也经过网关。先完成响应,再异步停止监听,
304
+ // 避免 server.close() 等待当前请求、当前请求又等待 close() 的死锁。
305
+ if (shouldStopGateway) setTimeout(() => { void stopGateway() }, 50)
306
+ return
307
+ }
308
+ return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method } })
309
+ } catch (error) {
310
+ const status = error && error.statusCode ? error.statusCode : 500
311
+ if (status >= 500) console.error('[dsh-dock] mobile relay HTTP error:', error && error.message)
312
+ return sendJson(res, status, { ok: false, error: { code: status >= 500 ? 'internal' : 'bad-request', message: (error && error.message) || String(error) } })
313
+ }
314
+ },
315
+ }), 'dsh-dock mobile relay: /dsh-dock/mobile-relay HTTP route')
316
+ }))
317
+
318
+ return () => {
319
+ while (disposers.length) {
320
+ const dispose = disposers.pop()
321
+ try { if (typeof dispose === 'function') dispose() } catch { /* ignore */ }
322
+ }
323
+ pairs.clear()
324
+ tasks.clear()
325
+ void stopGateway()
326
+ }
327
+ },
328
+ }
@@ -0,0 +1,207 @@
1
+ // dsh-dock · 功能模块【手机接力】· 客户端视图
2
+ // 手机和电脑均轮询同一个短时配对记录;业务会话继续由 DSH 原生会话服务负责同步。
3
+ import { useCallback, useEffect, useMemo, useState } from 'react'
4
+ import QRCode from 'qrcode'
5
+
6
+ const DEVICE_KEY = 'dsh-dock/mobile-relay/device/v1'
7
+ const SESSION_KEY = 'dsh-dock/mobile-relay/session/v1'
8
+ const LAUNCH_KEY = 'dsh-dock/mobile-relay/launch/v1'
9
+
10
+ function rpc(method, payload) {
11
+ return fetch('/dsh-dock/mobile-relay/' + method, {
12
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload || {}),
13
+ }).then(async (res) => {
14
+ const body = await res.json().catch(() => ({}))
15
+ if (res.ok && body && body.ok) return body.data
16
+ if (res.status === 404 || res.status === 405) throw new Error('宿主进程仍是旧版本,请重启 dsh web 后再开启局域网连接')
17
+ throw new Error((body && body.error && body.error.message) || ('请求失败(' + res.status + ')'))
18
+ })
19
+ }
20
+ function deviceId() {
21
+ let id = ''
22
+ try { id = localStorage.getItem(DEVICE_KEY) || '' } catch { /* memory only */ }
23
+ if (!id) {
24
+ id = (globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2))
25
+ try { localStorage.setItem(DEVICE_KEY, id) } catch { /* memory only */ }
26
+ }
27
+ return id
28
+ }
29
+ function readSession() {
30
+ try { const raw = sessionStorage.getItem(SESSION_KEY); return raw ? JSON.parse(raw) : null } catch { return null }
31
+ }
32
+ function saveSession(value) { try { sessionStorage.setItem(SESSION_KEY, JSON.stringify(value)) } catch { /* memory state still works */ } }
33
+ function clearSession() { try { sessionStorage.removeItem(SESSION_KEY) } catch { /* ignore */ } }
34
+ function fmtDuration(ms) {
35
+ const sec = Math.max(0, Math.floor((ms || 0) / 1000)); const min = Math.floor(sec / 60); const rest = sec % 60
36
+ return min > 0 ? min + ' 分 ' + String(rest).padStart(2, '0') + ' 秒' : rest + ' 秒'
37
+ }
38
+ function fmtTime(value) { return value ? new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '' }
39
+ function phaseLabel(phase) { return ({ think: '正在思考', write: '正在输出', code: '正在开发', search: '正在查资料' })[phase] || '进行中' }
40
+ function copy(value) {
41
+ if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(value)
42
+ return Promise.reject(new Error('当前浏览器不支持复制,请长按或手动复制'))
43
+ }
44
+
45
+ function RelayIcon({ name, size = 18 }) {
46
+ const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }
47
+ if (name === 'phone') return <svg {...common}><rect x="7" y="2.5" width="10" height="19" rx="2.2"/><path d="M10 18.5h4"/></svg>
48
+ if (name === 'link') return <svg {...common}><path d="M10.5 13.5a4 4 0 0 0 5.66.01l2-2a4 4 0 0 0-5.66-5.66l-1.15 1.14"/><path d="M13.5 10.5a4 4 0 0 0-5.66-.01l-2 2a4 4 0 0 0 5.66 5.66l1.15-1.14"/></svg>
49
+ if (name === 'copy') return <svg {...common}><rect x="8" y="8" width="11" height="12" rx="1.5"/><path d="M16 8V5.5A1.5 1.5 0 0 0 14.5 4h-9A1.5 1.5 0 0 0 4 5.5v9A1.5 1.5 0 0 0 5.5 16H8"/></svg>
50
+ if (name === 'check') return <svg {...common}><path d="m5 12 4.2 4.2L19 6.5"/></svg>
51
+ if (name === 'arrow') return <svg {...common}><path d="M5 12h13"/><path d="m14 7 5 5-5 5"/></svg>
52
+ if (name === 'close') return <svg {...common}><path d="m6 6 12 12M18 6 6 18"/></svg>
53
+ return <svg {...common}><circle cx="12" cy="12" r="8"/><path d="M12 8v4l2.5 2"/></svg>
54
+ }
55
+
56
+ function useCompact() {
57
+ const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 680px)').matches)
58
+ useEffect(() => {
59
+ const media = window.matchMedia('(max-width: 680px)')
60
+ const update = () => setCompact(media.matches); update(); media.addEventListener('change', update)
61
+ return () => media.removeEventListener('change', update)
62
+ }, [])
63
+ return compact
64
+ }
65
+
66
+ export function MobileRelayView() {
67
+ const compact = useCompact()
68
+ const [session, setSession] = useState(readSession)
69
+ const [pair, setPair] = useState(null)
70
+ const [network, setNetwork] = useState(null)
71
+ const [port, setPort] = useState(3081)
72
+ const [selectedAddress, setSelectedAddress] = useState('')
73
+ const [qrDataUrl, setQrDataUrl] = useState('')
74
+ const [note, setNote] = useState('')
75
+ const [busy, setBusy] = useState('')
76
+ const [notice, setNotice] = useState('')
77
+ const [error, setError] = useState('')
78
+ const mobile = session && session.role === 'mobile'
79
+
80
+ const setCurrent = useCallback((next) => { setSession(next); next ? saveSession(next) : clearSession() }, [])
81
+ useEffect(() => {
82
+ if (session) return
83
+ rpc('network').then((data) => {
84
+ setNetwork(data)
85
+ if (data && data.defaultPort) setPort(data.defaultPort)
86
+ const first = data && data.addresses && data.addresses[0]
87
+ if (first) setSelectedAddress(first.address)
88
+ }).catch((e) => setError(e && e.message ? e.message : String(e)))
89
+ }, [session])
90
+ const refresh = useCallback(async (candidate = session) => {
91
+ if (!candidate) return
92
+ try {
93
+ const result = await rpc('status', candidate)
94
+ setPair(result.pair); setError('')
95
+ } catch (e) {
96
+ const message = e && e.message ? e.message : String(e)
97
+ setError(message)
98
+ if (/过期|未完成配对/.test(message)) setCurrent(null)
99
+ }
100
+ }, [session, setCurrent])
101
+
102
+ useEffect(() => { if (session) refresh(session) }, [session, refresh])
103
+ useEffect(() => {
104
+ if (!session) return
105
+ const timer = setInterval(() => refresh(session), 2500)
106
+ return () => clearInterval(timer)
107
+ }, [session, refresh])
108
+ useEffect(() => {
109
+ if (session) return
110
+ let launch = ''
111
+ try { launch = sessionStorage.getItem(LAUNCH_KEY) || '' } catch { /* ignore */ }
112
+ if (!launch) return
113
+ try { sessionStorage.removeItem(LAUNCH_KEY) } catch { /* ignore */ }
114
+ const [pairId, code] = launch.split('.')
115
+ if (!pairId || !code) return
116
+ setBusy('join')
117
+ rpc('join', { pairId, code, deviceId: deviceId(), label: '手机端' })
118
+ .then((data) => { setCurrent({ pairId, secret: data.secret, role: 'mobile', deviceId: deviceId() }); setPair(data.pair); setNotice('已连接到电脑端,任务状态会持续同步。') })
119
+ .catch((e) => setError(e && e.message ? e.message : String(e)))
120
+ .finally(() => setBusy(''))
121
+ }, [session, setCurrent])
122
+
123
+ const mobileLink = useMemo(() => {
124
+ if (!session || !session.gatewayToken || !session.gatewayAddress || !session.gatewayPort) return ''
125
+ return 'http://' + session.gatewayAddress + ':' + session.gatewayPort + '/__dsh_mobile/connect#' + session.gatewayToken
126
+ }, [session])
127
+ useEffect(() => {
128
+ let active = true
129
+ if (!mobileLink) { setQrDataUrl(''); return () => { active = false } }
130
+ QRCode.toDataURL(mobileLink, {
131
+ errorCorrectionLevel: 'M', margin: 2, width: 260,
132
+ color: { dark: '#111827', light: '#ffffff' },
133
+ }).then((value) => { if (active) setQrDataUrl(value) })
134
+ .catch((e) => { if (active) setError('二维码生成失败:' + (e && e.message ? e.message : String(e))) })
135
+ return () => { active = false }
136
+ }, [mobileLink])
137
+
138
+ async function start() {
139
+ setError(''); setNotice('')
140
+ const numericPort = Number(port)
141
+ if (!selectedAddress) { setError('没有检测到可用的局域网 IPv4 地址,请先连接 Wi-Fi 或有线网络。'); return }
142
+ if (!Number.isInteger(numericPort) || numericPort < 1024 || numericPort > 65535) { setError('局域网端口需为 1024 到 65535 之间的整数。'); return }
143
+ setBusy('start')
144
+ try {
145
+ const data = await rpc('start', { deviceId: deviceId(), port: numericPort })
146
+ const next = {
147
+ pairId: data.pairId, secret: data.secret, code: data.code, role: 'desktop', deviceId: deviceId(),
148
+ gatewayToken: data.gatewayToken, gatewayPort: data.gateway.port, gatewayAddress: selectedAddress,
149
+ }
150
+ setCurrent(next); setPair(data.pair)
151
+ setNotice('受保护的局域网反向代理已开启。手机扫码即可进入同一 DSH。')
152
+ } catch (e) { setError(e && e.message ? e.message : String(e)) } finally { setBusy('') }
153
+ }
154
+ async function sendNote(event) {
155
+ event.preventDefault(); if (!session || !note.trim()) return
156
+ setBusy('note'); setError('')
157
+ try { const data = await rpc('note', Object.assign({}, session, { note })); setPair(data.pair); setNote(''); setNotice('接力备注已同步。') }
158
+ catch (e) { setError(e && e.message ? e.message : String(e)) } finally { setBusy('') }
159
+ }
160
+ async function end() {
161
+ if (!session) return
162
+ setBusy('end')
163
+ try { await rpc('end', session); setCurrent(null); setPair(null); setNotice('手机连接已关闭。') }
164
+ catch (e) { setError(e && e.message ? e.message : String(e)) } finally { setBusy('') }
165
+ }
166
+ function copyLink() { copy(mobileLink).then(() => setNotice('连接链接已复制。')).catch((e) => setError(e.message)) }
167
+
168
+ if (!session) return <section className={'dmr ' + (compact ? 'dmr-compact' : '')}>
169
+ <div className="dmr-hero"><span className="dmr-hero-icon"><RelayIcon name="phone" size={22}/></span><div><h3>把开发任务接到手机</h3><p>主 DSH 继续仅限本机;开启局域网反向代理后,通过一次性二维码安全接入。</p></div></div>
170
+ <ol className="dmr-steps"><li><span>1</span><div><strong>确认 IP 与端口</strong><small>自动识别电脑局域网 IPv4,默认使用独立端口 3081。</small></div></li><li><span>2</span><div><strong>开启反向代理</strong><small>代理 HTTP 与 WebSocket,未认证设备无法访问。</small></div></li><li><span>3</span><div><strong>手机扫码接力</strong><small>扫码进入原会话,电脑端同步看到开发进度。</small></div></li></ol>
171
+ <div className="dmr-network-grid">
172
+ <label className="dmr-field"><span>局域网 IP</span><select value={selectedAddress} onChange={(e) => setSelectedAddress(e.target.value)} disabled={!network || !network.addresses || !network.addresses.length}>{network && network.addresses && network.addresses.length ? network.addresses.map((item) => <option key={item.interface + item.address} value={item.address}>{item.address} · {item.interface}</option>) : <option value="">{network ? '未检测到地址' : '正在检测网络…'}</option>}</select><small>手机需与电脑位于可互访的同一局域网。</small></label>
173
+ <label className="dmr-field"><span>监听端口</span><input value={port} onChange={(e) => setPort(e.target.value)} inputMode="numeric" type="number" min="1024" max="65535" aria-describedby="dmr-port-help"/><small id="dmr-port-help">主服务仍为 127.0.0.1:3080;手机入口建议使用 3081。</small></label>
174
+ </div>
175
+ <div className="dmr-security"><strong>安全说明</strong><p>链接相当于临时登录凭据,只发给自己的手机。验证令牌 10 分钟后失效;关闭连接会立即撤销手机会话并停止端口监听。</p></div>
176
+ <button type="button" className="dmr-primary" onClick={start} disabled={busy === 'start' || !selectedAddress}>{busy === 'start' ? '正在开启反向代理…' : <><RelayIcon name="link"/>开启反向代理并生成二维码</>}</button>
177
+ {error && <div className="dmr-message error" role="alert">{error}</div>}{notice && <div className="dmr-message success" aria-live="polite"><RelayIcon name="check"/>{notice}</div>}
178
+ </section>
179
+
180
+ const onlineMobiles = pair && pair.devices ? pair.devices.filter((device) => device.role === 'mobile') : []
181
+ return <section className={'dmr ' + (compact ? 'dmr-compact' : '')}>
182
+ <div className="dmr-status-head"><div><span className="dmr-eyebrow"><i/> {mobile ? '已连接到电脑' : '手机连接已开启'}</span><h3>{mobile ? '可以在这里继续开发' : '等待手机接入'}</h3><p>{mobile ? '任务状态和接力备注会自动同步。继续开发请回到当前 DSH 会话输入区。' : '将下面的安全链接发到手机;连接在 ' + fmtTime(pair && pair.expiresAt) + ' 前有效。'}</p></div><button type="button" className="dmr-icon-button" onClick={end} disabled={busy === 'end'} title="关闭手机连接" aria-label="关闭手机连接"><RelayIcon name="close"/></button></div>
183
+ {!mobile && <div className="dmr-share"><div className="dmr-share-layout"><div className="dmr-qr-card">{qrDataUrl ? <img src={qrDataUrl} alt="手机接力一次性连接二维码" width="260" height="260"/> : <div className="dmr-qr-loading" aria-live="polite">正在生成二维码…</div>}<strong>使用手机相机扫码</strong><small>手机与电脑需在同一局域网</small></div><div className="dmr-share-detail"><div><span className="dmr-eyebrow"><i/> 反向代理已运行</span><h4>{session.gatewayAddress}:{session.gatewayPort} → 127.0.0.1:3080</h4></div><div className="dmr-link" title={mobileLink}>{mobileLink || '局域网入口未就绪'}</div><div className="dmr-share-actions"><button type="button" className="dmr-primary" onClick={copyLink} disabled={!mobileLink}><RelayIcon name="copy"/>复制连接链接</button></div><small>二维码和链接只可成功验证一次,10 分钟后失效。如需另一台手机,请关闭后重新开启。</small></div></div></div>}
184
+ <div className="dmr-overview"><div className="dmr-overview-card"><span>设备状态</span><strong>{mobile ? '手机已接力' : onlineMobiles.length ? onlineMobiles.length + ' 台手机在线' : '等待手机'}</strong><small>{mobile ? '保持此页面打开即可同步状态' : onlineMobiles.length ? '状态每 2.5 秒刷新' : '连接码仅在当前配对窗口有效'}</small></div><div className="dmr-overview-card"><span>进行中的任务</span><strong>{pair && pair.tasks ? pair.tasks.length : '—'}</strong><small>{pair && pair.tasks && pair.tasks.length ? '与 DSH 当前会话共用同一任务' : '开始任务后会自动显示在这里'}</small></div></div>
185
+ <div className="dmr-section"><div className="dmr-section-head"><h4>实时任务</h4><button type="button" className="dmr-text-button" onClick={() => refresh()} disabled={busy}>刷新</button></div>{pair && pair.tasks && pair.tasks.length ? <div className="dmr-task-list">{pair.tasks.map((task) => <article key={task.sessionId} className="dmr-task"><span className={'dmr-phase ' + task.phase}>{phaseLabel(task.phase)}</span><div><strong>{task.title}</strong><small>已运行 {fmtDuration(task.elapsed)} · 最近活动 {fmtTime(task.lastActivityAt)}</small></div></article>)}</div> : <div className="dmr-empty">当前没有正在运行的任务。启动任务后,手机和电脑都会看到同一份状态。</div>}</div>
186
+ <form className="dmr-note" onSubmit={sendNote}><div className="dmr-section-head"><h4>接力备注</h4>{pair && pair.noteAt ? <small>{pair.noteFrom} · {fmtTime(pair.noteAt)}</small> : null}</div>{pair && pair.note ? <blockquote>{pair.note}</blockquote> : <p>把当前进度、下一步或需要注意的事项留给另一台设备。</p>}<label><span className="sr-only">接力备注</span><textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength="1200" placeholder={mobile ? '例如:我已在手机查看到构建错误,下一步请在电脑运行测试。' : '例如:已经完成接口修改,手机端继续检查页面交互。'} /></label><button className="dmr-secondary" disabled={!note.trim() || busy === 'note'}>{busy === 'note' ? '正在同步…' : <><RelayIcon name="arrow"/>同步备注</>}</button></form>
187
+ {error && <div className="dmr-message error" role="alert">{error}</div>}{notice && <div className="dmr-message success" aria-live="polite"><RelayIcon name="check"/>{notice}</div>}
188
+ </section>
189
+ }
190
+
191
+ export function MobileRelayHomeStat() {
192
+ const [summary, setSummary] = useState('可在手机继续当前任务')
193
+ useEffect(() => {
194
+ const session = readSession(); if (!session) return
195
+ rpc('status', session).then((data) => { const p = data.pair; setSummary((p.tasks || []).length ? (p.tasks.length + ' 个任务同步中') : '手机已连接,等待任务') }).catch(() => setSummary('连接已失效,请重新配对'))
196
+ }, [])
197
+ return <span>{summary}</span>
198
+ }
199
+
200
+ export const feature = {
201
+ id: 'mobile-relay', name: '手机接力', order: 80, accent: '#38bdf8',
202
+ description: '安全反向代理与扫码接力,同步当前任务状态和备注', defaultEnabled: true,
203
+ css: `
204
+ .dmr{--dmr-accent:#38bdf8;--dmr-accent-soft:color-mix(in srgb,var(--dmr-accent) 13%,transparent);display:flex;flex-direction:column;gap:16px;max-width:720px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}.dmr h3,.dmr h4,.dmr p{margin:0}.dmr h3{font-size:17px;line-height:1.25;letter-spacing:-.01em}.dmr h4{font-size:13px}.dmr-hero,.dmr-status-head{display:flex;align-items:flex-start;gap:12px}.dmr-hero>div,.dmr-status-head>div{min-width:0;display:flex;flex-direction:column;gap:4px}.dmr-hero p,.dmr-status-head p,.dmr-field small,.dmr-share small,.dmr-overview-card small,.dmr-task small,.dmr-note p{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55}.dmr-hero-icon{width:42px;height:42px;display:inline-flex;align-items:center;justify-content:center;flex:none;border-radius:14px;background:var(--dmr-accent-soft);color:var(--dmr-accent);border:1px solid color-mix(in srgb,var(--dmr-accent) 35%,var(--dsw-alias-border-l1))}.dmr-steps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0;padding:0;list-style:none}.dmr-steps li{display:flex;gap:9px;padding:11px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:12px}.dmr-steps li>span{width:21px;height:21px;display:inline-flex;align-items:center;justify-content:center;flex:none;border-radius:50%;font-size:11px;font-weight:700;color:var(--dmr-accent);background:var(--dmr-accent-soft)}.dmr-steps div{display:flex;flex-direction:column;gap:3px;min-width:0}.dmr-steps strong{font-size:12px}.dmr-steps small{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.45}.dmr-field{display:flex;flex-direction:column;gap:6px;font-weight:600}.dmr-field input,.dmr-field select,.dmr-note textarea{box-sizing:border-box;width:100%;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:10px;padding:10px 12px;min-height:44px;font:inherit;outline:none;transition:border-color .18s ease,box-shadow .18s ease}.dmr-field input:focus,.dmr-field select:focus,.dmr-note textarea:focus{border-color:var(--dmr-accent);box-shadow:0 0 0 3px var(--dmr-accent-soft)}.dmr-field small{font-weight:400}.dmr-network-grid{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(150px,.6fr);gap:12px}.dmr-security{padding:12px 13px;border:1px solid color-mix(in srgb,#f59e0b 38%,var(--dsw-alias-border-l1));border-radius:12px;background:color-mix(in srgb,#f59e0b 8%,transparent)}.dmr-security strong{display:block;margin-bottom:4px;color:#f59e0b;font-size:12px}.dmr-security p{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.55}.dmr-primary,.dmr-secondary,.dmr-text-button,.dmr-icon-button{font:inherit;touch-action:manipulation;cursor:pointer;transition:transform .15s ease,background .18s ease,border-color .18s ease,opacity .18s ease}.dmr-primary,.dmr-secondary{min-height:44px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border-radius:10px;padding:9px 13px;font-weight:600}.dmr-primary{align-self:flex-start;border:1px solid var(--dmr-accent);background:var(--dmr-accent);color:#062238}.dmr-primary:hover{filter:brightness(1.05)}.dmr-secondary{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.dmr-secondary:hover{border-color:var(--dmr-accent);background:var(--dmr-accent-soft)}.dmr-primary:active,.dmr-secondary:active,.dmr-icon-button:active{transform:scale(.98)}.dmr-primary:disabled,.dmr-secondary:disabled,.dmr-text-button:disabled,.dmr-icon-button:disabled{cursor:not-allowed;opacity:.5}.dmr-message{display:flex;align-items:flex-start;gap:7px;padding:10px 12px;border-radius:10px;font-size:12px}.dmr-message.success{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent)}.dmr-message.error{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}.dmr-status-head{justify-content:space-between}.dmr-eyebrow{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:600;color:var(--dmr-accent)}.dmr-eyebrow i{width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 0 4px var(--dmr-accent-soft)}.dmr-icon-button{display:inline-flex;align-items:center;justify-content:center;flex:none;width:44px;height:44px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:transparent;color:var(--dsw-alias-label-secondary)}.dmr-icon-button:hover{color:var(--dsw-alias-state-error-primary);border-color:currentColor}.dmr-share{display:flex;flex-direction:column;gap:9px;padding:13px;border:1px solid color-mix(in srgb,var(--dmr-accent) 32%,var(--dsw-alias-border-l1));border-radius:13px;background:var(--dmr-accent-soft)}.dmr-share-layout{display:grid;grid-template-columns:196px minmax(0,1fr);gap:16px;align-items:center}.dmr-qr-card{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;gap:5px;padding:10px;border-radius:12px;background:#fff;color:#111827;text-align:center}.dmr-qr-card img{display:block;width:176px;height:176px;max-width:100%;object-fit:contain}.dmr-qr-card strong{font-size:12px}.dmr-qr-card small{color:#4b5563;font-size:10px}.dmr-qr-loading{display:grid;place-items:center;width:176px;height:176px;color:#64748b;font-size:12px}.dmr-share-detail{min-width:0;display:flex;flex-direction:column;gap:10px}.dmr-share-detail>div:first-child{display:flex;flex-direction:column;gap:4px}.dmr-link{padding:10px 11px;border-radius:8px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all;color:var(--dsw-alias-label-secondary)}.dmr-share-actions{display:flex;gap:8px;flex-wrap:wrap}.dmr-share-actions .dmr-primary{align-self:auto}.dmr-overview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.dmr-overview-card{display:flex;flex-direction:column;gap:3px;padding:12px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1)}.dmr-overview-card>span{font-size:11px;color:var(--dsw-alias-label-tertiary)}.dmr-overview-card strong{font-size:16px;letter-spacing:-.01em}.dmr-section{display:flex;flex-direction:column;gap:9px}.dmr-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmr-section-head small{font-size:11px;color:var(--dsw-alias-label-tertiary)}.dmr-text-button{border:0;background:transparent;color:var(--dmr-accent);padding:8px;min-height:36px;font-weight:600}.dmr-task-list{display:flex;flex-direction:column;gap:8px}.dmr-task{display:flex;align-items:flex-start;gap:9px;padding:10px 11px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1)}.dmr-task>div{display:flex;min-width:0;flex:1;flex-direction:column;gap:2px}.dmr-task strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dmr-phase{flex:none;border-radius:999px;padding:3px 8px;font-size:11px;background:var(--dmr-accent-soft);color:var(--dmr-accent)}.dmr-phase.write{color:var(--dsw-alias-state-success-primary)}.dmr-phase.code{color:#f59e0b}.dmr-phase.search{color:#14b8a6}.dmr-empty{padding:18px 12px;text-align:center;border:1px dashed var(--dsw-alias-border-l2);border-radius:10px;color:var(--dsw-alias-label-secondary);font-size:12px}.dmr-note{display:flex;flex-direction:column;gap:9px;padding-top:2px}.dmr-note blockquote{margin:0;padding:10px 12px;border-left:3px solid var(--dmr-accent);border-radius:0 9px 9px 0;background:var(--dmr-accent-soft);white-space:pre-wrap;font-size:12px}.dmr-note textarea{min-height:88px;resize:vertical;line-height:1.5}.dmr-note .dmr-secondary{align-self:flex-start}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media (max-width:680px){.dmr{gap:14px;font-size:14px}.dmr h3{font-size:18px}.dmr-steps{grid-template-columns:1fr;gap:8px}.dmr-steps li{padding:10px}.dmr-steps small,.dmr-hero p,.dmr-status-head p,.dmr-field small,.dmr-share small,.dmr-overview-card small,.dmr-task small,.dmr-note p{font-size:12px}.dmr-network-grid{grid-template-columns:1fr;gap:10px}.dmr-field input,.dmr-field select{font-size:16px}.dmr-primary,.dmr-secondary{width:100%;font-size:14px}.dmr-share-layout{grid-template-columns:1fr;gap:12px}.dmr-qr-card{width:min(240px,100%);margin:0 auto}.dmr-qr-card img,.dmr-qr-loading{width:210px;height:210px}.dmr-share-actions{flex-direction:column}.dmr-share-actions .dmr-primary{width:100%}.dmr-overview{gap:8px}.dmr-overview-card{padding:11px}.dmr-status-head{gap:8px}.dmr-link{font-size:11px}.dmr-note textarea{font-size:16px;min-height:104px}.dmr-note .dmr-secondary{align-self:stretch}.dmr-compact .dmr-hero-icon{width:40px;height:40px;border-radius:13px}}@media (prefers-reduced-motion:reduce){.dmr-primary,.dmr-secondary,.dmr-text-button,.dmr-icon-button,.dmr-field input,.dmr-field select,.dmr-note textarea{transition:none}}
205
+ `,
206
+ View: MobileRelayView, HomeStat: MobileRelayHomeStat,
207
+ }
@@ -52,7 +52,7 @@ export const feature = {
52
52
 
53
53
  // ===== 定价与金额估算 =====
54
54
  // 定价表以「人民币元 / 百万 tokens」为基准(DeepSeek/智谱官方刊例即人民币), 金额先算 CNY,
55
- // 对外 cost(USD) = CNY / usdCnyRate, 客户端/CSV 再按 cost × usdCnyRate 还原人民币。
55
+ // 对外记录的 cost 保持 USD,供旧版客户端兼容;当前客户端和 CSV 统一展示人民币。
56
56
  // usdCnyRate 与 pricing 均可被 settings.yaml 覆盖:
57
57
  // dsh-dock-tokenlog:
58
58
  // usdCnyRate: 7.2 # USD↔CNY 汇率
@@ -649,7 +649,7 @@ export const feature = {
649
649
  if (method === 'export') {
650
650
  loadPricingConfig()
651
651
  const list = buildQuery(payload || {})
652
- const header = ['time', 'provider', 'model', 'apiKey', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens', 'billedInput', 'cacheHitPercent', 'totalTokens', 'cost', 'costCny', 'effort', 'status', 'statusCode', 'errorCode', 'errorMsg', 'llmMs', 'sessionId', 'turn', 'step']
652
+ const header = ['time', 'provider', 'model', 'apiKey', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens', 'billedInput', 'cacheHitPercent', 'totalTokens', 'costCny', 'effort', 'status', 'statusCode', 'errorCode', 'errorMsg', 'llmMs', 'sessionId', 'turn', 'step']
653
653
  const esc = (v) => {
654
654
  const s = String(v === undefined || v === null ? '' : v)
655
655
  return /[,"\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s