Spaces:
Running
Running
Settings: "Use recommended for this device" button (GPU/device-tier heuristic)
Browse filesInside the "Your device" panel, a button picks a preset from the hardware:
- Mobile → Low, but Medium if a Qualcomm Adreno 700-series (or higher) is found.
- Desktop → Medium, but High if an NVIDIA RTX 3090 (or higher) is found
(newer generation, or 30-gen ≥90; so 3090/3090 Ti and all 40xx/50xx qualify).
GPU is read from the WebGL ANGLE renderer string + WebGPU adapter info. Applies
and persists the chosen preset, and shows what it picked and why. Heuristic
unit-tested across 13 GPU strings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/deviceInfo.js +40 -0
- web/qualityBar.js +20 -4
- web/shell/persona.css +3 -0
web/deviceInfo.js
CHANGED
|
@@ -25,6 +25,46 @@ async function webgpuInfo() {
|
|
| 25 |
} catch { return 'error' }
|
| 26 |
}
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
// Returns an ordered array of [label, value] rows.
|
| 29 |
export async function gatherDeviceInfo() {
|
| 30 |
const rows = []
|
|
|
|
| 25 |
} catch { return 'error' }
|
| 26 |
}
|
| 27 |
|
| 28 |
+
export function isMobile() {
|
| 29 |
+
try { if (navigator.userAgentData && typeof navigator.userAgentData.mobile === 'boolean') return navigator.userAgentData.mobile } catch { /* ignore */ }
|
| 30 |
+
return /Mobi|Android|iPhone|iPad|iPod|IEMobile|Opera Mini/i.test(navigator.userAgent || '')
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// A combined, lowercased GPU descriptor (WebGL renderer + WebGPU adapter info) for
|
| 34 |
+
// heuristic matching. WebGL's ANGLE string usually carries the real chip name, e.g.
|
| 35 |
+
// "…nvidia geforce rtx 3090…" on desktop or "adreno (tm) 730" on Android.
|
| 36 |
+
async function gpuDescriptor() {
|
| 37 |
+
let s = webglRenderer()
|
| 38 |
+
try {
|
| 39 |
+
if (navigator.gpu) {
|
| 40 |
+
const ad = await navigator.gpu.requestAdapter()
|
| 41 |
+
const info = ad && (ad.info || (ad.requestAdapterInfo ? await ad.requestAdapterInfo() : null))
|
| 42 |
+
if (info) s += ' ' + [info.vendor, info.architecture, info.description].filter(Boolean).join(' ')
|
| 43 |
+
}
|
| 44 |
+
} catch { /* ignore */ }
|
| 45 |
+
return s.toLowerCase()
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Recommend a quality preset for this device:
|
| 49 |
+
// • Mobile → Low, but Medium if a Qualcomm Adreno 700-series (or higher) is detected.
|
| 50 |
+
// • Desktop → Medium, but High if an NVIDIA RTX 3090 (or higher) is detected.
|
| 51 |
+
// "or higher" for RTX = a newer generation, or the same 30-gen at ≥90 (so 3090/3090 Ti
|
| 52 |
+
// and all 40xx/50xx qualify; 3080 and below don't).
|
| 53 |
+
export async function recommendPreset() {
|
| 54 |
+
const gpu = await gpuDescriptor()
|
| 55 |
+
if (isMobile()) {
|
| 56 |
+
const a = gpu.match(/adreno[^\d]*(\d{3,4})/)
|
| 57 |
+
if (a && parseInt(a[1], 10) >= 700) return { id: 'medium', reason: `mobile · Adreno ${a[1]} (≥700)` }
|
| 58 |
+
return { id: 'low', reason: 'mobile device' }
|
| 59 |
+
}
|
| 60 |
+
const r = gpu.match(/rtx\s*0*(\d{3,4})/)
|
| 61 |
+
if (r) {
|
| 62 |
+
const n = parseInt(r[1], 10), gen = Math.floor(n / 100), model = n % 100
|
| 63 |
+
if (gen > 30 || (gen === 30 && model >= 90)) return { id: 'high', reason: `desktop · RTX ${r[1]} (≥3090)` }
|
| 64 |
+
}
|
| 65 |
+
return { id: 'medium', reason: 'desktop' }
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
// Returns an ordered array of [label, value] rows.
|
| 69 |
export async function gatherDeviceInfo() {
|
| 70 |
const rows = []
|
web/qualityBar.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
| 2 |
// voice provider together) plus an expandable "Your device" readout for debugging and
|
| 3 |
// benchmarking. Mounted at the TOP of the Settings page, above Local AI Model.
|
| 4 |
import { PRESETS, applyPreset, getActivePreset, onPresetChange } from '/web/qualityPreset.js'
|
| 5 |
-
import { gatherDeviceInfo } from '/web/deviceInfo.js'
|
|
|
|
|
|
|
| 6 |
|
| 7 |
function el(tag, props = {}, kids = []) {
|
| 8 |
const n = document.createElement(tag)
|
|
@@ -34,10 +36,24 @@ export function mountQualityBar(host) {
|
|
| 34 |
highlight(getActivePreset())
|
| 35 |
onPresetChange((id) => highlight(id))
|
| 36 |
|
| 37 |
-
// Expandable device readout (debug / benchmark).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
const devBody = el('div', { class: 'tac-device-body' }, 'reading…')
|
| 39 |
-
const dev = el('details', { class: 'tac-device' },
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
| 41 |
dev.addEventListener('toggle', async () => {
|
| 42 |
if (!dev.open || dev.dataset.loaded) return
|
| 43 |
dev.dataset.loaded = '1'
|
|
|
|
| 2 |
// voice provider together) plus an expandable "Your device" readout for debugging and
|
| 3 |
// benchmarking. Mounted at the TOP of the Settings page, above Local AI Model.
|
| 4 |
import { PRESETS, applyPreset, getActivePreset, onPresetChange } from '/web/qualityPreset.js'
|
| 5 |
+
import { gatherDeviceInfo, recommendPreset } from '/web/deviceInfo.js'
|
| 6 |
+
|
| 7 |
+
const presetLabel = (id) => (PRESETS.find((p) => p.id === id) || {}).label || id
|
| 8 |
|
| 9 |
function el(tag, props = {}, kids = []) {
|
| 10 |
const n = document.createElement(tag)
|
|
|
|
| 36 |
highlight(getActivePreset())
|
| 37 |
onPresetChange((id) => highlight(id))
|
| 38 |
|
| 39 |
+
// Expandable device readout (debug / benchmark) + a one-click "recommend for me".
|
| 40 |
+
const recBtn = el('button', { class: 'persona-go tac-device-rec', type: 'button' }, '✦ Use recommended for this device')
|
| 41 |
+
const recNote = el('div', { class: 'persona-status tac-device-rec-note' })
|
| 42 |
+
recBtn.addEventListener('click', async () => {
|
| 43 |
+
recBtn.disabled = true; recNote.textContent = 'detecting…'
|
| 44 |
+
try {
|
| 45 |
+
const { id, reason } = await recommendPreset()
|
| 46 |
+
applyPreset(id)
|
| 47 |
+
recNote.textContent = `Chose ${presetLabel(id)} (${reason}).`
|
| 48 |
+
} catch { recNote.textContent = 'Could not detect — pick a preset above.' }
|
| 49 |
+
recBtn.disabled = false
|
| 50 |
+
})
|
| 51 |
const devBody = el('div', { class: 'tac-device-body' }, 'reading…')
|
| 52 |
+
const dev = el('details', { class: 'tac-device' }, [
|
| 53 |
+
el('summary', {}, 'Your device (GPU / RAM / storage)'),
|
| 54 |
+
el('div', { class: 'tac-device-actions' }, [recBtn, recNote]),
|
| 55 |
+
devBody,
|
| 56 |
+
])
|
| 57 |
dev.addEventListener('toggle', async () => {
|
| 58 |
if (!dev.open || dev.dataset.loaded) return
|
| 59 |
dev.dataset.loaded = '1'
|
web/shell/persona.css
CHANGED
|
@@ -280,6 +280,9 @@
|
|
| 280 |
cursor: pointer; font-family: var(--p-mono); font-size: 11px; letter-spacing: .08em;
|
| 281 |
text-transform: uppercase; color: var(--p-transmit);
|
| 282 |
}
|
|
|
|
|
|
|
|
|
|
| 283 |
.tac-device-body { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
| 284 |
.tac-device-row { display: flex; gap: 10px; font-family: var(--p-mono); font-size: 11px; line-height: 1.4; }
|
| 285 |
.tac-device-k { flex: 0 0 130px; color: var(--p-muted); }
|
|
|
|
| 280 |
cursor: pointer; font-family: var(--p-mono); font-size: 11px; letter-spacing: .08em;
|
| 281 |
text-transform: uppercase; color: var(--p-transmit);
|
| 282 |
}
|
| 283 |
+
.tac-device-actions { display: flex; flex-direction: column; gap: 6px; margin: 10px 0 4px; }
|
| 284 |
+
.tac-device-rec { align-self: flex-start; margin-top: 0 !important; }
|
| 285 |
+
.tac-device-rec-note { min-height: 0; }
|
| 286 |
.tac-device-body { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
| 287 |
.tac-device-row { display: flex; gap: 10px; font-family: var(--p-mono); font-size: 11px; line-height: 1.4; }
|
| 288 |
.tac-device-k { flex: 0 0 130px; color: var(--p-muted); }
|