dsh-remote-plugin 0.6.15 → 0.6.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +199 -15
- package/index.mjs +21 -3
- package/package.json +1 -1
- package/public/admin.html +53 -3
- package/public/admin.js +163 -19
- package/public/announcements.json +33 -0
- package/public/app.js +1040 -61
- package/public/desktop/desktop.css +1 -0
- package/public/desktop/desktop.html +4 -3
- package/public/desktop/desktop.js +167 -20
- package/public/index.html +95 -7
- package/public/styles.css +76 -0
- package/public/update.json +12 -12
- package/public/version.json +1 -1
package/public/admin.js
CHANGED
|
@@ -18,6 +18,13 @@ const store = {
|
|
|
18
18
|
del(k) { try { localStorage.removeItem(k) } catch {} }
|
|
19
19
|
}
|
|
20
20
|
let token = store.get('dshAdminToken') || new URLSearchParams(location.search).get('token') || ''
|
|
21
|
+
function adminHeaders(extra = {}, accessToken = token) {
|
|
22
|
+
const headers = { 'x-dsh-remote-client': 'admin', ...extra }
|
|
23
|
+
// /remote/* 可能由 Caddy Basic Auth 保护。插件路由自身已在 DSH 登录态内,
|
|
24
|
+
// 这里不能再写 Authorization: Bearer,否则会覆盖浏览器的 Basic 凭据并形成 401 循环。
|
|
25
|
+
if (!pluginMode && accessToken) headers.authorization = 'Bearer ' + accessToken
|
|
26
|
+
return headers
|
|
27
|
+
}
|
|
21
28
|
let timer = null
|
|
22
29
|
let gatewayRunning = false
|
|
23
30
|
let gatewayBusy = false
|
|
@@ -31,6 +38,132 @@ let gatewayPort = 8787
|
|
|
31
38
|
let gatewayPortLoaded = false
|
|
32
39
|
let doctorExpanded = store.get('dshAdminDoctorCollapsed') !== '1'
|
|
33
40
|
let doctorChecks = []
|
|
41
|
+
const HOST_IP_SELECTION_KEY = 'dshAdminEnabledHostIPsV1'
|
|
42
|
+
const MANUAL_HOST_IP_KEY = 'dshAdminManualHostIPsV1'
|
|
43
|
+
|
|
44
|
+
function normalizedHostIPs(st) {
|
|
45
|
+
const detected = Array.isArray(st?.lanIPs) ? st.lanIPs : []
|
|
46
|
+
return [...new Set([...detected, ...manualHostIPs(st)].map(validManualHost).filter(Boolean))]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validManualHost(value) {
|
|
50
|
+
const host = String(value || '').trim()
|
|
51
|
+
if (!host || host.length > 253 || host === '0.0.0.0' || host === '127.0.0.1') return ''
|
|
52
|
+
if (!/^[A-Za-z0-9.-]+$/.test(host) || host.startsWith('.') || host.endsWith('.') || host.includes('..')) return ''
|
|
53
|
+
if (/^\d+(?:\.\d+){3}$/.test(host) && host.split('.').some(part => Number(part) > 255)) return ''
|
|
54
|
+
const labels = host.split('.')
|
|
55
|
+
if (labels.some(label => !label || label.length > 63 || label.startsWith('-') || label.endsWith('-'))) return ''
|
|
56
|
+
return host
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hostIPScope(st) {
|
|
60
|
+
return [st?.hostname || location.hostname || 'host', st?.host || ''].join('|')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function manualHostIPScope(st) {
|
|
64
|
+
return st?.hostname || location.hostname || 'host'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hostIPPreferences() {
|
|
68
|
+
try {
|
|
69
|
+
const value = JSON.parse(store.get(HOST_IP_SELECTION_KEY) || '{}')
|
|
70
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
71
|
+
} catch {
|
|
72
|
+
return {}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function manualHostPreferences() {
|
|
77
|
+
try {
|
|
78
|
+
const value = JSON.parse(store.get(MANUAL_HOST_IP_KEY) || '{}')
|
|
79
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
80
|
+
} catch {
|
|
81
|
+
return {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function manualHostIPs(st) {
|
|
86
|
+
const values = manualHostPreferences()[manualHostIPScope(st)]
|
|
87
|
+
return Array.isArray(values) ? [...new Set(values.map(validManualHost).filter(Boolean))] : []
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function saveManualHostIPs(st, values) {
|
|
91
|
+
const prefs = manualHostPreferences()
|
|
92
|
+
prefs[manualHostIPScope(st)] = [...new Set(values.map(validManualHost).filter(Boolean))]
|
|
93
|
+
store.set(MANUAL_HOST_IP_KEY, JSON.stringify(prefs))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function enabledHostIPs(st) {
|
|
97
|
+
const all = normalizedHostIPs(st)
|
|
98
|
+
if (!all.length) return []
|
|
99
|
+
const saved = hostIPPreferences()[hostIPScope(st)]
|
|
100
|
+
if (!Array.isArray(saved)) return all
|
|
101
|
+
const selected = all.filter(ip => saved.includes(ip))
|
|
102
|
+
return selected.length ? selected : all
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function saveEnabledHostIPs(st, selected) {
|
|
106
|
+
const prefs = hostIPPreferences()
|
|
107
|
+
prefs[hostIPScope(st)] = normalizedHostIPs(st).filter(ip => selected.includes(ip))
|
|
108
|
+
store.set(HOST_IP_SELECTION_KEY, JSON.stringify(prefs))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function addManualHostIP() {
|
|
112
|
+
if (!lastState) return
|
|
113
|
+
const input = prompt(t('hostIPs.addPrompt'), '')
|
|
114
|
+
if (input === null) return
|
|
115
|
+
const host = validManualHost(input)
|
|
116
|
+
if (!host) {
|
|
117
|
+
toast(t('hostIPs.invalid'), 'err')
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if (normalizedHostIPs(lastState).includes(host)) {
|
|
121
|
+
toast(t('hostIPs.exists'), 'err')
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
saveManualHostIPs(lastState, [...manualHostIPs(lastState), host])
|
|
125
|
+
saveEnabledHostIPs(lastState, [...enabledHostIPs(lastState), host])
|
|
126
|
+
render(lastState)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function removeManualHostIP(st, host) {
|
|
130
|
+
saveManualHostIPs(st, manualHostIPs(st).filter(value => value !== host))
|
|
131
|
+
render(st)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function renderHostIPs(st) {
|
|
135
|
+
const all = normalizedHostIPs(st)
|
|
136
|
+
const selected = enabledHostIPs(st)
|
|
137
|
+
const manual = new Set(manualHostIPs(st))
|
|
138
|
+
const rows = $('host-ip-rows')
|
|
139
|
+
const empty = $('host-ip-empty')
|
|
140
|
+
const summary = $('host-ip-summary')
|
|
141
|
+
if (!rows || !empty || !summary) return
|
|
142
|
+
summary.textContent = all.length
|
|
143
|
+
? t('hostIPs.summary', { enabled: selected.length, total: all.length })
|
|
144
|
+
: t('hostIPs.empty')
|
|
145
|
+
rows.innerHTML = all.map(ip => `<tr>
|
|
146
|
+
<td class="host-ip-toggle"><label class="host-ip-switch" title="${esc(t('hostIPs.enable'))}"><input type="checkbox" data-host-ip-toggle="${esc(ip)}" ${selected.includes(ip) ? 'checked' : ''}><span aria-hidden="true"></span></label></td>
|
|
147
|
+
<td class="mono host-ip-value">${esc(ip)}</td>
|
|
148
|
+
<td class="host-ip-use">${esc(t(selected.includes(ip) ? 'hostIPs.enabled' : 'hostIPs.disabled'))}${manual.has(ip) ? ` <button class="mini-btn host-ip-remove" type="button" data-host-ip-remove="${esc(ip)}">${esc(t('hostIPs.remove'))}</button>` : ''}</td>
|
|
149
|
+
</tr>`).join('')
|
|
150
|
+
empty.classList.toggle('hidden', all.length > 0)
|
|
151
|
+
rows.querySelectorAll('[data-host-ip-toggle]').forEach(input => input.addEventListener('change', () => {
|
|
152
|
+
const ip = input.dataset.hostIpToggle
|
|
153
|
+
const next = enabledHostIPs(st).filter(value => value !== ip)
|
|
154
|
+
if (input.checked) next.push(ip)
|
|
155
|
+
if (!next.length) {
|
|
156
|
+
input.checked = true
|
|
157
|
+
toast(t('hostIPs.keepOne'), 'err')
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
saveEnabledHostIPs(st, next)
|
|
161
|
+
render(st)
|
|
162
|
+
}))
|
|
163
|
+
rows.querySelectorAll('[data-host-ip-remove]').forEach(button => button.addEventListener('click', () => {
|
|
164
|
+
removeManualHostIP(st, button.dataset.hostIpRemove)
|
|
165
|
+
}))
|
|
166
|
+
}
|
|
34
167
|
|
|
35
168
|
function onlineClientDevices(st) {
|
|
36
169
|
return (st.devices || []).filter(device => device.online && (device.kind === 'app' || device.kind === 'web'))
|
|
@@ -38,7 +171,7 @@ function onlineClientDevices(st) {
|
|
|
38
171
|
|
|
39
172
|
function firewallCommand(st) {
|
|
40
173
|
const port = Number(st.port || gatewayPort) || 8787
|
|
41
|
-
const ip = (st
|
|
174
|
+
const ip = enabledHostIPs(st).find(value => /^10\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\.|^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(value || ''))
|
|
42
175
|
let cidr = 'LocalSubnet'
|
|
43
176
|
if (/^10\./.test(ip || '')) cidr = '10.0.0.0/8'
|
|
44
177
|
else if (/^192\.168\./.test(ip || '')) cidr = '192.168.0.0/16'
|
|
@@ -52,7 +185,7 @@ function firewallCommand(st) {
|
|
|
52
185
|
function buildDoctorChecks(st) {
|
|
53
186
|
const isGateway = st.mode === 'gateway'
|
|
54
187
|
const port = Number(st.port || gatewayPort) || 8787
|
|
55
|
-
const ip = (st
|
|
188
|
+
const ip = enabledHostIPs(st).find(value => value && value !== '127.0.0.1' && value !== '0.0.0.0')
|
|
56
189
|
const base = ip ? `http://${ip}:${port}` : ''
|
|
57
190
|
const clients = onlineClientDevices(st)
|
|
58
191
|
const events = st.events || {}
|
|
@@ -118,7 +251,7 @@ async function loadStats() {
|
|
|
118
251
|
if (!token && !pluginMode) return
|
|
119
252
|
try {
|
|
120
253
|
const res = await fetch(`${STATS_API}/summary?days=7`, {
|
|
121
|
-
headers:
|
|
254
|
+
headers: adminHeaders(), credentials: 'same-origin'
|
|
122
255
|
})
|
|
123
256
|
if (!res.ok) {
|
|
124
257
|
if (res.status === 401) return
|
|
@@ -139,7 +272,7 @@ async function loadGatewayConfig() {
|
|
|
139
272
|
if (!pluginMode) return
|
|
140
273
|
try {
|
|
141
274
|
const res = await fetch(`${API}/config`, {
|
|
142
|
-
headers:
|
|
275
|
+
headers: adminHeaders(), credentials: 'same-origin'
|
|
143
276
|
})
|
|
144
277
|
const out = await res.json().catch(() => ({}))
|
|
145
278
|
if (out.ok) {
|
|
@@ -290,15 +423,19 @@ async function loadState() {
|
|
|
290
423
|
if (!token && !pluginMode) return
|
|
291
424
|
try {
|
|
292
425
|
const res = await fetch(`${API}/state`, {
|
|
293
|
-
headers:
|
|
426
|
+
headers: adminHeaders(), credentials: 'same-origin'
|
|
294
427
|
})
|
|
295
|
-
if (res.status === 401) throw new Error('AUTH')
|
|
428
|
+
if (res.status === 401) throw new Error(pluginMode ? 'AUTH_LAYER' : 'AUTH')
|
|
429
|
+
if (pluginMode && !String(res.headers.get('content-type') || '').includes('application/json')) throw new Error('AUTH_LAYER')
|
|
296
430
|
const st = await res.json()
|
|
297
431
|
render(st)
|
|
298
432
|
} catch (e) {
|
|
299
433
|
if (e.message === 'AUTH') {
|
|
300
434
|
toast(t('toast.tokenInvalid'), 'err')
|
|
301
435
|
logout()
|
|
436
|
+
} else if (e.message === 'AUTH_LAYER') {
|
|
437
|
+
$('conn-badge').textContent = t('toast.authLayer')
|
|
438
|
+
$('conn-badge').className = 'conn-badge off'
|
|
302
439
|
} else {
|
|
303
440
|
$('conn-badge').textContent = t('toast.connFailed')
|
|
304
441
|
$('conn-badge').className = 'conn-badge off'
|
|
@@ -322,6 +459,7 @@ function render(st) {
|
|
|
322
459
|
$('btn-qr').classList.toggle('hidden', isGateway !== true || !shownToken)
|
|
323
460
|
$('btn-rotate').classList.toggle('hidden', isGateway !== true || !shownToken || !!st.tokenFromEnv)
|
|
324
461
|
renderDeviceKeys(st, isGateway)
|
|
462
|
+
renderHostIPs(st)
|
|
325
463
|
renderQr(st)
|
|
326
464
|
renderDoctor(st)
|
|
327
465
|
// 网关开关: 仅插件内嵌页提供, 网关运行/停止两种状态
|
|
@@ -356,14 +494,12 @@ function render(st) {
|
|
|
356
494
|
action.dataset.heroAction = heroState === 'plugin' ? 'start' : heroState === 'offline' ? 'copy' : 'devices'
|
|
357
495
|
}
|
|
358
496
|
}
|
|
359
|
-
const hostIPs = (st.lanIPs || []).join(t('stat.ipSep')) || '127.0.0.1'
|
|
360
497
|
const latestHtml = st.latest?.newer
|
|
361
498
|
? `<div class="v">${t('stat.updateAvailable', { version: st.latest.version })}</div><div class="k">${t('stat.currentV', { version: st.version })} · <a href="${st.latest.url || '#'}" target="_blank" rel="noopener" style="color:var(--dsr-accent-strong)">${t('stat.download')}</a></div>`
|
|
362
499
|
: `<div class="v">v${st.version}</div><div class="k">${isPlugin ? t('stat.embedded') : st.latest?.error ? t('stat.updateCheck', { error: st.latest.error }) : st.latest?.version ? t('stat.latest') : t('stat.notChecked')}</div>`
|
|
363
500
|
$('stats').innerHTML = `
|
|
364
501
|
<div class="stat-card"><div class="v">v${st.version}</div><div class="k">${t(isPlugin ? 'stat.pluginVersion' : 'stat.gatewayVersion')}</div></div>
|
|
365
502
|
<div class="stat-card ${st.latest?.newer ? 'warn' : 'ok'}">${latestHtml}</div>
|
|
366
|
-
<div class="stat-card ok"><div class="v" style="font-size:13px">${hostIPs}</div><div class="k">${t('stat.hostIP', { hostname: st.hostname })}${isPlugin ? t('stat.phoneGateway', { port: gatewayPort }) : t('stat.phoneThis')}</div></div>
|
|
367
503
|
<div class="stat-card ${upOk ? 'ok' : 'warn'}"><div class="v">${t(upOk ? 'stat.reachable' : 'stat.unreachable')}</div><div class="k">${t('stat.dshUpstream', { url: st.upstream.url })}</div></div>
|
|
368
504
|
<div class="stat-card"><div class="v">${st.onlineCount}/${st.deviceCount}</div><div class="k">${t('stat.devicesOnline')}</div></div>
|
|
369
505
|
<div class="stat-card"><div class="v">${st.totalRequests}</div><div class="k">${t('stat.totalRequests')}</div></div>
|
|
@@ -424,13 +560,19 @@ function render(st) {
|
|
|
424
560
|
}
|
|
425
561
|
|
|
426
562
|
function pairTarget(st, accessToken) {
|
|
427
|
-
const ip = (st.lanIPs || []).find(x => x && x !== '127.0.0.1' && x !== '0.0.0.0') || (st.lanIPs || [])[0]
|
|
428
|
-
const host = ip || (st.host && st.host !== '0.0.0.0' ? st.host : location.hostname)
|
|
429
563
|
const port = st.port || 8787
|
|
430
|
-
const
|
|
564
|
+
const hosts = enabledHostIPs(st).slice()
|
|
565
|
+
if (!hosts.length) {
|
|
566
|
+
const fallback = st.host && st.host !== '0.0.0.0' ? String(st.host).trim() : location.hostname
|
|
567
|
+
if (fallback) hosts.push(fallback)
|
|
568
|
+
}
|
|
569
|
+
const bases = hosts.map(host => `http://${host}:${port}`)
|
|
570
|
+
const query = new URLSearchParams({ token: String(accessToken || '') })
|
|
571
|
+
for (const base of bases) query.append('server', base)
|
|
431
572
|
return {
|
|
432
|
-
url: `dshremote://pair
|
|
433
|
-
base
|
|
573
|
+
url: `dshremote://pair?${query.toString()}`,
|
|
574
|
+
base: bases[0] || '',
|
|
575
|
+
bases
|
|
434
576
|
}
|
|
435
577
|
}
|
|
436
578
|
|
|
@@ -460,7 +602,7 @@ async function setNote(ip, current) {
|
|
|
460
602
|
if (name === null) return
|
|
461
603
|
const res = await fetch(`${API}/note`, {
|
|
462
604
|
method: 'POST',
|
|
463
|
-
headers: { 'content-type': 'application/json',
|
|
605
|
+
headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
|
|
464
606
|
body: JSON.stringify({ ip, name })
|
|
465
607
|
})
|
|
466
608
|
if (res.ok) {
|
|
@@ -475,7 +617,7 @@ async function kick(ip) {
|
|
|
475
617
|
if (!confirm(t('confirm.kick'))) return
|
|
476
618
|
const res = await fetch(`${API}/kick`, {
|
|
477
619
|
method: 'POST',
|
|
478
|
-
headers: { 'content-type': 'application/json',
|
|
620
|
+
headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
|
|
479
621
|
body: JSON.stringify({ ip })
|
|
480
622
|
})
|
|
481
623
|
if (res.ok) {
|
|
@@ -493,7 +635,7 @@ async function deviceKeyMutation(action, payload = {}) {
|
|
|
493
635
|
try {
|
|
494
636
|
const res = await fetch(`${API}/device-keys/${action}`, {
|
|
495
637
|
method: 'POST',
|
|
496
|
-
headers: { 'content-type': 'application/json',
|
|
638
|
+
headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
|
|
497
639
|
body: JSON.stringify(payload),
|
|
498
640
|
})
|
|
499
641
|
const out = await res.json().catch(() => ({}))
|
|
@@ -685,6 +827,8 @@ $('btn-qr').addEventListener('click', () => {
|
|
|
685
827
|
renderQr(lastState || { mode: '', token: shownToken })
|
|
686
828
|
})
|
|
687
829
|
|
|
830
|
+
$('btn-host-ip-add').addEventListener('click', addManualHostIP)
|
|
831
|
+
|
|
688
832
|
/* 右上角「网关」徽章: 新标签页打开独立网关管理面板(带 token 免登录) */
|
|
689
833
|
$('conn-badge').addEventListener('click', () => {
|
|
690
834
|
const st = lastState
|
|
@@ -704,7 +848,7 @@ $('btn-rotate').addEventListener('click', async () => {
|
|
|
704
848
|
try {
|
|
705
849
|
const res = await fetch(`${API}/token/rotate`, {
|
|
706
850
|
method: 'POST',
|
|
707
|
-
headers: {
|
|
851
|
+
headers: adminHeaders({}, token || shownToken), credentials: 'same-origin'
|
|
708
852
|
})
|
|
709
853
|
const out = await res.json().catch(() => ({}))
|
|
710
854
|
if (out.ok && out.token) {
|
|
@@ -729,7 +873,7 @@ $('btn-gateway').addEventListener('click', async () => {
|
|
|
729
873
|
try {
|
|
730
874
|
const res = await fetch(`${API}/gateway`, {
|
|
731
875
|
method: 'POST',
|
|
732
|
-
headers: { 'content-type': 'application/json',
|
|
876
|
+
headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
|
|
733
877
|
body: JSON.stringify({ action: gatewayRunning ? 'stop' : 'start' })
|
|
734
878
|
})
|
|
735
879
|
const out = await res.json().catch(() => ({}))
|
|
@@ -759,7 +903,7 @@ $('btn-save-port').addEventListener('click', async () => {
|
|
|
759
903
|
try {
|
|
760
904
|
const res = await fetch(`${API}/config`, {
|
|
761
905
|
method: 'PUT',
|
|
762
|
-
headers: { 'content-type': 'application/json',
|
|
906
|
+
headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
|
|
763
907
|
body: JSON.stringify({ port })
|
|
764
908
|
})
|
|
765
909
|
const out = await res.json().catch(() => ({}))
|
|
@@ -75,6 +75,39 @@
|
|
|
75
75
|
"minVersion": "",
|
|
76
76
|
"maxVersion": "",
|
|
77
77
|
"publishedAt": "2026-08-25T00:24:27+08:00"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": "2026-08-26-dsh-local-android-rc1",
|
|
81
|
+
"title": "dsh-local-android v0.1.0-rc.1 发布公告",
|
|
82
|
+
"content": "大家好,dsh-local-android 现已发布 v0.1.0-rc.1 测试版本。\n\ndsh-local-android 是 DSH 的 Android 本地发行版,应用名称为 DSH for Android。它将 DSH 运行时、Local Gateway 和交互界面整合到 Android 设备本地运行,适合希望直接在手机或 Android 环境中使用 DSH 的用户。\n\n当前版本支持:\n\n- 在 Android 设备本地运行 DSH;\n- 使用本地 Gateway 和 WebView 界面进行交互;\n- 通过系统文件选择器访问本地文件;\n- 在“设置 → 模型”中配置模型提供方、API 地址、API 密钥和模型目录;\n- 分层显示安装、Engine、Gateway 和界面启动状态,便于排查问题。\n\n本次发布同时提供 arm64-v8a 和 x86_64 架构版本,请根据设备或模拟器的 CPU 架构选择对应 APK。\n\n目前版本仍处于 RC 测试阶段,不同设备、Android 版本和运行环境下可能存在兼容性或稳定性差异。默认运行时采用 minimal profile,部分扩展能力暂未包含。\n\n如果你有在 Android 环境中本地运行 DSH 的需求,欢迎下载尝试,并反馈实际使用体验、设备兼容性和遇到的问题。\n\n提交日志或截图时,请注意隐藏 API 密钥、Token 及其他敏感信息。感谢大家的支持与反馈。",
|
|
83
|
+
"minVersion": "",
|
|
84
|
+
"maxVersion": "",
|
|
85
|
+
"publishedAt": "2026-08-26T18:28:26+08:00",
|
|
86
|
+
"actionUrl": "https://github.com/Blank-not-black/dsh-local-android/releases",
|
|
87
|
+
"actionText": "下载 DSH for Android"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"id": "2026-08-27-meeting-asr-brand-poll",
|
|
91
|
+
"title": "投票:你的手机品牌是什么?(会议转写调研)",
|
|
92
|
+
"content": "大家好,我们正在研究为 dsh-Remote 增加会议模式:使用手机录音,调用语音识别生成完整文字,再由 DSH 整理会议纪要、待办事项或指定文件。\n\n第一阶段准备优先验证手机厂商提供的云端语音识别能力,因此先统计大家正在使用的手机品牌。本次投票只用于了解设备分布和安排后续适配顺序,不代表某个品牌已经确认支持,也不会立即改变现有功能。\n\n后续如果需要进一步确认识别能力,我们会在 App 内提供自愿参与的诊断测试,只记录识别服务是否可用、是否返回 partial results、连续识别时长和错误类型,不收集录音、识别文字或 API 密钥。",
|
|
93
|
+
"minVersion": "0.6.11",
|
|
94
|
+
"maxVersion": "",
|
|
95
|
+
"publishedAt": "2026-08-27T12:00:00+08:00",
|
|
96
|
+
"poll": {
|
|
97
|
+
"id": "meeting-asr-brand-2026-08",
|
|
98
|
+
"question": "你的手机品牌是什么?",
|
|
99
|
+
"options": [
|
|
100
|
+
{ "id": "xiaomi-redmi", "label": "小米 / Redmi" },
|
|
101
|
+
{ "id": "huawei", "label": "华为" },
|
|
102
|
+
{ "id": "honor", "label": "荣耀" },
|
|
103
|
+
{ "id": "oppo-oneplus", "label": "OPPO / 一加" },
|
|
104
|
+
{ "id": "vivo-iqoo", "label": "vivo / iQOO" },
|
|
105
|
+
{ "id": "samsung", "label": "三星" },
|
|
106
|
+
{ "id": "meizu", "label": "魅族" },
|
|
107
|
+
{ "id": "google-pixel", "label": "Google Pixel" },
|
|
108
|
+
{ "id": "other", "label": "其他品牌" }
|
|
109
|
+
]
|
|
110
|
+
}
|
|
78
111
|
}
|
|
79
112
|
]
|
|
80
113
|
}
|