@glassly/engine 0.3.8-dev.998 → 0.4.0-dev.1017
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/build/engine.d.ts +11 -0
- package/build/engine.d.ts.map +1 -1
- package/build/facades/ai.d.ts +6 -0
- package/build/facades/ai.d.ts.map +1 -1
- package/build/facades/ai.js +6 -0
- package/build/facades/ai.js.map +1 -1
- package/build/facades/glasses.d.ts +9 -1
- package/build/facades/glasses.d.ts.map +1 -1
- package/build/facades/glasses.js +13 -0
- package/build/facades/glasses.js.map +1 -1
- package/build/generated/releaseMetadata.js +4 -4
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js.map +1 -1
- package/build/services/CloudClientService.d.ts +2 -1
- package/build/services/CloudClientService.d.ts.map +1 -1
- package/build/services/CloudClientService.js +8 -0
- package/build/services/CloudClientService.js.map +1 -1
- package/build/services/DeviceEventRouter.d.ts.map +1 -1
- package/build/services/DeviceEventRouter.js +1 -0
- package/build/services/DeviceEventRouter.js.map +1 -1
- package/build/services/GlassesSensorService.js +1 -1
- package/build/services/GlassesSensorService.js.map +1 -1
- package/build/services/LocalLlmModelManager.d.ts +22 -2
- package/build/services/LocalLlmModelManager.d.ts.map +1 -1
- package/build/services/LocalLlmModelManager.js +104 -2
- package/build/services/LocalLlmModelManager.js.map +1 -1
- package/build/services/LocalLlmProvider.d.ts.map +1 -1
- package/build/services/LocalLlmProvider.js +11 -0
- package/build/services/LocalLlmProvider.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +13 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +57 -1
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/controllers.d.ts.map +1 -1
- package/build/services/controllers.js +2 -0
- package/build/services/controllers.js.map +1 -1
- package/build/services/neuralBand/HandwritingGate.d.ts +38 -0
- package/build/services/neuralBand/HandwritingGate.d.ts.map +1 -0
- package/build/services/neuralBand/HandwritingGate.js +89 -0
- package/build/services/neuralBand/HandwritingGate.js.map +1 -0
- package/build/services/neuralBand/NeuralBandInput.d.ts +12 -1
- package/build/services/neuralBand/NeuralBandInput.d.ts.map +1 -1
- package/build/services/neuralBand/NeuralBandInput.js +27 -0
- package/build/services/neuralBand/NeuralBandInput.js.map +1 -1
- package/build/services/neuralBand/NeuralBandSettingsSync.d.ts.map +1 -1
- package/build/services/neuralBand/NeuralBandSettingsSync.js +36 -3
- package/build/services/neuralBand/NeuralBandSettingsSync.js.map +1 -1
- package/build/stores/appVisibility.d.ts.map +1 -1
- package/build/stores/appVisibility.js +0 -1
- package/build/stores/appVisibility.js.map +1 -1
- package/build/stores/settings.d.ts.map +1 -1
- package/build/stores/settings.js +18 -0
- package/build/stores/settings.js.map +1 -1
- package/build/types/enums.d.ts +3 -1
- package/build/types/enums.d.ts.map +1 -1
- package/build/types/enums.js +2 -0
- package/build/types/enums.js.map +1 -1
- package/package.json +7 -7
- package/src/facades/ai.ts +6 -0
- package/src/facades/glasses.ts +17 -0
- package/src/generated/releaseMetadata.ts +4 -4
- package/src/index.ts +1 -0
- package/src/services/CloudClientService.ts +8 -0
- package/src/services/DeviceEventRouter.ts +1 -0
- package/src/services/GlassesSensorService.ts +1 -1
- package/src/services/LocalLlmModelManager.ts +110 -3
- package/src/services/LocalLlmProvider.ts +8 -0
- package/src/services/LocalMiniappRuntime.ts +62 -1
- package/src/services/controllers.ts +2 -0
- package/src/services/neuralBand/HandwritingGate.ts +111 -0
- package/src/services/neuralBand/NeuralBandInput.ts +37 -1
- package/src/services/neuralBand/NeuralBandSettingsSync.ts +46 -3
- package/src/stores/appVisibility.ts +0 -1
- package/src/stores/settings.ts +18 -0
- package/src/types/enums.ts +2 -0
package/src/facades/ai.ts
CHANGED
|
@@ -21,6 +21,12 @@ export const ai = {
|
|
|
21
21
|
/** The Settings > Offline Models pick. */
|
|
22
22
|
selectedModelId: (): LocalLlmModelId => localLlmModelManager.selectedModelId(),
|
|
23
23
|
select: (id: LocalLlmModelId): Promise<void> => localLlmModelManager.select(id),
|
|
24
|
+
/** Whether the model fits this phone's memory at all. */
|
|
25
|
+
fitsDevice: (id: LocalLlmModelId): boolean => localLlmModelManager.fitsDevice(id),
|
|
26
|
+
/** The OS killed the app while this model loaded; picking it again retries it. */
|
|
27
|
+
isBlocked: (id: LocalLlmModelId): boolean => localLlmModelManager.isBlocked(id),
|
|
28
|
+
/** Why the model will not load on this phone, or null when it can. */
|
|
29
|
+
unusableReason: (id: LocalLlmModelId): string | null => localLlmModelManager.unusableReason(id),
|
|
24
30
|
isDownloaded: (id?: LocalLlmModelId): Promise<boolean> => localLlmModelManager.isModelAvailable(id),
|
|
25
31
|
downloadedModelIds: (): Promise<LocalLlmModelId[]> => localLlmModelManager.downloadedModelIds(),
|
|
26
32
|
download: (id?: LocalLlmModelId): Promise<void> => localLlmModelManager.downloadModel(id),
|
package/src/facades/glasses.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
Device,
|
|
20
20
|
KeyboardAdvertEvent,
|
|
21
21
|
KeyboardPairingEvent,
|
|
22
|
+
NeuralBandFirmwareEvent,
|
|
22
23
|
PairedController,
|
|
23
24
|
TouchEvent,
|
|
24
25
|
} from "@glassly/bluetooth-sdk"
|
|
@@ -39,6 +40,7 @@ import {pushAllBluetoothSettings} from "../services/GlassesSettingsSync"
|
|
|
39
40
|
import {isControllerReady, pairedControllersFrom, readyControllers} from "../services/controllers"
|
|
40
41
|
import neuralBandInput, {
|
|
41
42
|
type NeuralBandClaimStatus,
|
|
43
|
+
type NeuralBandFirmwareStatus,
|
|
42
44
|
type NeuralBandHandwritingStatus,
|
|
43
45
|
type NeuralBandHandwritingUpdate,
|
|
44
46
|
} from "../services/neuralBand/NeuralBandInput"
|
|
@@ -344,6 +346,21 @@ export const glasses = {
|
|
|
344
346
|
/** The band's air mouse; movement reaches miniapps on the pointer stream. */
|
|
345
347
|
setPointer: (id: string, enabled: boolean): Promise<void> => BluetoothSdk.neuralBandSetPointer(id, enabled),
|
|
346
348
|
setHand: (id: string, hand: "left" | "right"): Promise<void> => BluetoothSdk.neuralBandSetHand(id, hand),
|
|
349
|
+
firmwareStatus: (id: string): NeuralBandFirmwareStatus => ({...neuralBandInput.firmwareStatus(id)}),
|
|
350
|
+
onFirmwareStatus: (cb: (update: {id: string; status: NeuralBandFirmwareStatus}) => void): (() => void) =>
|
|
351
|
+
neuralBandInput.onFirmwareStatus(cb),
|
|
352
|
+
refreshFirmware: async (id: string): Promise<NeuralBandFirmwareStatus> => {
|
|
353
|
+
const info = await BluetoothSdk.neuralBandFirmwareInfo(id)
|
|
354
|
+
neuralBandInput.handleFirmware({
|
|
355
|
+
...(info as object),
|
|
356
|
+
type: "neural_band_firmware",
|
|
357
|
+
controllerId: id,
|
|
358
|
+
} as NeuralBandFirmwareEvent)
|
|
359
|
+
return neuralBandInput.firmwareStatus(id)
|
|
360
|
+
},
|
|
361
|
+
checkFirmware: (id: string): Promise<void> => BluetoothSdk.neuralBandCheckFirmware(id),
|
|
362
|
+
updateFirmware: (id: string, mode: "update" | "cancel" | "key" | "send" | "trial" | "install"): Promise<void> =>
|
|
363
|
+
BluetoothSdk.neuralBandUpdateFirmware(id, mode),
|
|
347
364
|
},
|
|
348
365
|
|
|
349
366
|
// --- watch: an Apple Watch over WatchConnectivity (iOS), a Wear OS watch over the Data Layer (Android) ---
|
|
@@ -11,10 +11,10 @@ export interface EngineReleaseMetadata {
|
|
|
11
11
|
|
|
12
12
|
export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
|
|
13
13
|
"schemaVersion": 1,
|
|
14
|
-
"familyBaseVersion": "0.
|
|
15
|
-
"releaseIdentity": "0.
|
|
16
|
-
"releaseSetId": "glassly-0.
|
|
17
|
-
"sourceCommit": "
|
|
14
|
+
"familyBaseVersion": "0.4.0",
|
|
15
|
+
"releaseIdentity": "0.4.0-dev.1017",
|
|
16
|
+
"releaseSetId": "glassly-0.4.0-dev.1017",
|
|
17
|
+
"sourceCommit": "2d95873e90b2de55f29df33e0e361469fc2b58f8",
|
|
18
18
|
"otaManifestUrl": null,
|
|
19
19
|
"otaManifestSha256": null
|
|
20
20
|
})
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
RuntimeSnapshot,
|
|
20
20
|
StoreCatalogResponse,
|
|
21
21
|
StoreListing,
|
|
22
|
+
StoreReleasesResponse,
|
|
22
23
|
} from "@glassly/cloud-client/react-native"
|
|
23
24
|
import type {SubjectTokenType} from "@glassly/cloud-client"
|
|
24
25
|
import {AppState, Platform} from "react-native"
|
|
@@ -778,6 +779,13 @@ export const cloudClientService = {
|
|
|
778
779
|
return c.core.store.get(packageName)
|
|
779
780
|
},
|
|
780
781
|
|
|
782
|
+
async getStoreReleases(packageName: string): Promise<StoreReleasesResponse> {
|
|
783
|
+
if (!client) this.init()
|
|
784
|
+
const c = client
|
|
785
|
+
if (!c?.core) throw new Error("cloud client core is unavailable")
|
|
786
|
+
return c.core.store.releases(packageName)
|
|
787
|
+
},
|
|
788
|
+
|
|
781
789
|
/** Bytes of a store icon or screenshot; these routes need the user Bearer. */
|
|
782
790
|
async fetchStoreAsset(url: string): Promise<Uint8Array> {
|
|
783
791
|
if (!client) this.init()
|
|
@@ -245,6 +245,7 @@ export function startDeviceEventRouter(): void {
|
|
|
245
245
|
subs.push(BluetoothSdk.addListener("neural_band_claim", (event) => neuralBandInput.handleClaim(event)))
|
|
246
246
|
subs.push(BluetoothSdk.addListener("neural_band_handwriting", (event) => neuralBandInput.handleHandwriting(event)))
|
|
247
247
|
subs.push(BluetoothSdk.addListener("neural_band_pointer", (event) => neuralBandInput.handlePointer(event)))
|
|
248
|
+
subs.push(BluetoothSdk.addListener("neural_band_firmware", (event) => neuralBandInput.handleFirmware(event)))
|
|
248
249
|
subs.push({
|
|
249
250
|
remove: useGlassesStore.subscribe(
|
|
250
251
|
(s) => s.keyboardConnected === true,
|
|
@@ -110,7 +110,7 @@ export class GlassesSensorService {
|
|
|
110
110
|
private commandFailed(): never {
|
|
111
111
|
fail(
|
|
112
112
|
MiniappErrorCode.INTERNAL,
|
|
113
|
-
"Sensor command failed:
|
|
113
|
+
"Sensor command failed: the glasses need Glassly custom firmware with this sensor, and on the G2 an active miniapp display",
|
|
114
114
|
)
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -15,6 +15,7 @@ import {Platform} from "react-native"
|
|
|
15
15
|
import * as RNFS from "@dr.pogodin/react-native-fs"
|
|
16
16
|
|
|
17
17
|
import {SETTINGS, useSettingsStore} from "../stores/settings"
|
|
18
|
+
import {storage} from "../utils/storage"
|
|
18
19
|
|
|
19
20
|
export type LocalLlmModelId = "qwen3-0.6b" | "qwen3-1.7b" | "qwen3-4b"
|
|
20
21
|
|
|
@@ -28,7 +29,7 @@ export interface LocalLlmModelConfig {
|
|
|
28
29
|
sizeBytes: number
|
|
29
30
|
/** Exact size of the published .pte; the availability check compares against it. */
|
|
30
31
|
modelBytes: number
|
|
31
|
-
/** Rough resident memory while loaded;
|
|
32
|
+
/** Rough resident memory while loaded; `fitsDevice` holds it against the phone's RAM. */
|
|
32
33
|
ramBytes: number
|
|
33
34
|
urls: {
|
|
34
35
|
model: string
|
|
@@ -106,6 +107,36 @@ export function isLocalLlmModelId(value: unknown): value is LocalLlmModelId {
|
|
|
106
107
|
return typeof value === "string" && value in MODELS
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
// Without the increased-memory-limit entitlement iOS kills a foreground app
|
|
111
|
+
// at roughly 60% of physical RAM (Android's low-memory killer is no kinder),
|
|
112
|
+
// and the host holds about 1 GB besides the model: JS runtimes, WebViews,
|
|
113
|
+
// the speech model. A 4 GB phone fits the 0.6B model and nothing larger.
|
|
114
|
+
const APP_MEMORY_SHARE = 0.6
|
|
115
|
+
const HOST_BASELINE_BYTES = 1 * GB
|
|
116
|
+
|
|
117
|
+
export function modelFitsMemory(model: LocalLlmModelConfig, deviceMemoryBytes: number | null): boolean {
|
|
118
|
+
if (!deviceMemoryBytes) return true
|
|
119
|
+
return model.ramBytes + HOST_BASELINE_BYTES <= deviceMemoryBytes * APP_MEMORY_SHARE
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let deviceMemory: number | null | undefined
|
|
123
|
+
function deviceMemoryBytes(): number | null {
|
|
124
|
+
if (deviceMemory === undefined) {
|
|
125
|
+
try {
|
|
126
|
+
deviceMemory = (require("expo-device") as {totalMemory?: number | null}).totalMemory ?? null
|
|
127
|
+
} catch {
|
|
128
|
+
deviceMemory = null
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return deviceMemory
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Written before a model's first load and generation, cleared after either
|
|
135
|
+
// ends. A marker still there at the next launch means the OS killed the app
|
|
136
|
+
// mid-load (out of memory), so that model stays off until picked again.
|
|
137
|
+
const COLD_LOAD_KEY = "local_llm_cold_load"
|
|
138
|
+
const BLOCKED_MODELS_KEY = "local_llm_blocked_models"
|
|
139
|
+
|
|
109
140
|
type StatusListener = (status: LocalLlmDownloadStatus | null) => void
|
|
110
141
|
|
|
111
142
|
class LocalLlmModelManager {
|
|
@@ -114,6 +145,7 @@ class LocalLlmModelManager {
|
|
|
114
145
|
private downloadJobId?: number
|
|
115
146
|
private inFlight: {modelId: LocalLlmModelId; promise: Promise<void>} | null = null
|
|
116
147
|
private cancelled = false
|
|
148
|
+
private coldLoadRecovered = false
|
|
117
149
|
private currentStatus: LocalLlmDownloadStatus | null = null
|
|
118
150
|
private listeners = new Set<StatusListener>()
|
|
119
151
|
private modelReadyListeners = new Set<(modelId: LocalLlmModelId) => void>()
|
|
@@ -135,16 +167,89 @@ class LocalLlmModelManager {
|
|
|
135
167
|
return MODELS[id]
|
|
136
168
|
}
|
|
137
169
|
|
|
138
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* The model on-device requests use: the Settings > Offline Models pick
|
|
172
|
+
* (garbage resolves to the default) while it is usable, else the largest
|
|
173
|
+
* model that is. With nothing usable it stays the pick, and loading it
|
|
174
|
+
* fails with `unusableReason`.
|
|
175
|
+
*/
|
|
139
176
|
selectedModelId(): LocalLlmModelId {
|
|
140
177
|
const raw = useSettingsStore.getState().getSetting(SETTINGS.local_llm_model.key)
|
|
141
|
-
|
|
178
|
+
const picked = isLocalLlmModelId(raw) ? raw : DEFAULT_LOCAL_LLM_MODEL
|
|
179
|
+
if (this.isUsable(picked)) return picked
|
|
180
|
+
const fallback = this.models()
|
|
181
|
+
.filter((m) => this.isUsable(m.id))
|
|
182
|
+
.sort((a, b) => b.ramBytes - a.ramBytes)[0]
|
|
183
|
+
return fallback?.id ?? picked
|
|
142
184
|
}
|
|
143
185
|
|
|
186
|
+
/** Picking a model in Settings is also the way to retry one that crashed the app. */
|
|
144
187
|
async select(id: LocalLlmModelId): Promise<void> {
|
|
188
|
+
this.unblock(id)
|
|
145
189
|
await useSettingsStore.getState().setSetting(SETTINGS.local_llm_model.key, id)
|
|
146
190
|
}
|
|
147
191
|
|
|
192
|
+
fitsDevice(id: LocalLlmModelId): boolean {
|
|
193
|
+
return modelFitsMemory(MODELS[id], deviceMemoryBytes())
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The OS killed the app while this model loaded; it stays off until picked again. */
|
|
197
|
+
isBlocked(id: LocalLlmModelId): boolean {
|
|
198
|
+
return this.blockedModelIds().includes(id)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
isUsable(id: LocalLlmModelId): boolean {
|
|
202
|
+
return this.fitsDevice(id) && !this.isBlocked(id)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Why `id` will not load on this phone, or null when it can. */
|
|
206
|
+
unusableReason(id: LocalLlmModelId): string | null {
|
|
207
|
+
const label = MODELS[id].label
|
|
208
|
+
if (!this.fitsDevice(id)) {
|
|
209
|
+
return `${label} needs more memory than this phone has. Pick a smaller model in Settings > Offline Models.`
|
|
210
|
+
}
|
|
211
|
+
if (this.isBlocked(id)) {
|
|
212
|
+
return `${label} closed the app the last time it loaded. Pick it again in Settings > Offline Models to retry, or pick a smaller model.`
|
|
213
|
+
}
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Mark the start of a model's first load and generation in this process. */
|
|
218
|
+
beginColdLoad(id: LocalLlmModelId): void {
|
|
219
|
+
this.recoverColdLoad()
|
|
220
|
+
storage.save(COLD_LOAD_KEY, id)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
endColdLoad(): void {
|
|
224
|
+
storage.removeMultiple([COLD_LOAD_KEY])
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private blockedModelIds(): LocalLlmModelId[] {
|
|
228
|
+
this.recoverColdLoad()
|
|
229
|
+
const stored = storage.load<unknown>(BLOCKED_MODELS_KEY)
|
|
230
|
+
const ids = stored.is_ok() ? stored.value : []
|
|
231
|
+
return Array.isArray(ids) ? ids.filter(isLocalLlmModelId) : []
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private unblock(id: LocalLlmModelId): void {
|
|
235
|
+
const remaining = this.blockedModelIds().filter((blocked) => blocked !== id)
|
|
236
|
+
storage.save(BLOCKED_MODELS_KEY, remaining)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// A cold-load marker left by an earlier process: that load never finished.
|
|
240
|
+
private recoverColdLoad(): void {
|
|
241
|
+
if (this.coldLoadRecovered) return
|
|
242
|
+
this.coldLoadRecovered = true
|
|
243
|
+
const marker = storage.load<unknown>(COLD_LOAD_KEY)
|
|
244
|
+
if (!marker.is_ok() || !isLocalLlmModelId(marker.value)) return
|
|
245
|
+
const id = marker.value
|
|
246
|
+
console.warn(`LOCAL_LLM: the app was killed while ${id} loaded; keeping it off until it is picked again`)
|
|
247
|
+
const stored = storage.load<unknown>(BLOCKED_MODELS_KEY)
|
|
248
|
+
const blocked = stored.is_ok() && Array.isArray(stored.value) ? stored.value.filter(isLocalLlmModelId) : []
|
|
249
|
+
if (!blocked.includes(id)) storage.save(BLOCKED_MODELS_KEY, [...blocked, id])
|
|
250
|
+
storage.removeMultiple([COLD_LOAD_KEY])
|
|
251
|
+
}
|
|
252
|
+
|
|
148
253
|
getModelDirectory(): string {
|
|
149
254
|
return `${RNFS.DocumentDirectoryPath}/llm_models`
|
|
150
255
|
}
|
|
@@ -210,6 +315,7 @@ class LocalLlmModelManager {
|
|
|
210
315
|
async ensureSelectedModel(): Promise<boolean> {
|
|
211
316
|
const id = this.selectedModelId()
|
|
212
317
|
if (await this.isModelAvailable(id)) return true
|
|
318
|
+
if (!this.fitsDevice(id)) return false
|
|
213
319
|
void this.downloadModel(id).catch((error) => {
|
|
214
320
|
console.warn(`LOCAL_LLM: background download of ${id} failed:`, error)
|
|
215
321
|
})
|
|
@@ -222,6 +328,7 @@ class LocalLlmModelManager {
|
|
|
222
328
|
* the running one — the RNFS job slot is singular.
|
|
223
329
|
*/
|
|
224
330
|
async downloadModel(id: LocalLlmModelId = this.selectedModelId()): Promise<void> {
|
|
331
|
+
if (!this.fitsDevice(id)) throw new Error(this.unusableReason(id) ?? `${MODELS[id].label} does not fit this phone.`)
|
|
225
332
|
if (this.inFlight) {
|
|
226
333
|
if (this.inFlight.modelId === id) return this.inFlight.promise
|
|
227
334
|
await this.inFlight.promise.catch(() => undefined)
|
|
@@ -187,6 +187,11 @@ class LocalLlmProvider {
|
|
|
187
187
|
|
|
188
188
|
private async completeInner(request: LlmCompleteRequest): Promise<LocalLlmCompleteResult> {
|
|
189
189
|
this.clearIdleTimer()
|
|
190
|
+
// Loading and the first generation are where memory peaks; if the OS kills
|
|
191
|
+
// the app in between, the marker keeps the next launch from looping on it.
|
|
192
|
+
const selectedId = localLlmModelManager.selectedModelId()
|
|
193
|
+
const coldModelId = this.loaded?.modelId === selectedId ? null : selectedId
|
|
194
|
+
if (coldModelId) localLlmModelManager.beginColdLoad(coldModelId)
|
|
190
195
|
try {
|
|
191
196
|
const model = await this.ensureLoaded()
|
|
192
197
|
const jsonMode = request.jsonMode === true
|
|
@@ -248,6 +253,7 @@ class LocalLlmProvider {
|
|
|
248
253
|
)
|
|
249
254
|
return {...completion, modelId: model.modelId}
|
|
250
255
|
} finally {
|
|
256
|
+
if (coldModelId) localLlmModelManager.endColdLoad()
|
|
251
257
|
this.armIdleTimer()
|
|
252
258
|
}
|
|
253
259
|
}
|
|
@@ -272,6 +278,8 @@ class LocalLlmProvider {
|
|
|
272
278
|
// Runs inside the request queue, so nothing is generating; unload() would
|
|
273
279
|
// wait on that same queue.
|
|
274
280
|
this.releaseRunner()
|
|
281
|
+
const unusable = localLlmModelManager.unusableReason(modelId)
|
|
282
|
+
if (unusable) throw new Error(unusable)
|
|
275
283
|
if (!(await localLlmModelManager.isModelAvailable(modelId))) {
|
|
276
284
|
throw new Error(`on-device model ${modelId} is not downloaded`)
|
|
277
285
|
}
|
|
@@ -673,6 +673,9 @@ class LocalMiniappRuntime {
|
|
|
673
673
|
private connectedApps: Map<string, ConnectedMiniapp> = new Map()
|
|
674
674
|
/** Claimed gestures (CLAIM_GESTURES): a stack per gesture, the most recent claimer holding it. */
|
|
675
675
|
private gestureClaims: GestureClaimState = emptyGestureClaims()
|
|
676
|
+
/** Apps with a text field open on the glasses (TEXT_FIELD). */
|
|
677
|
+
private textFieldApps = new Set<string>()
|
|
678
|
+
private textFieldListeners = new Set<(open: boolean) => void>()
|
|
676
679
|
// The last main-view frame each app asked to render. Widget mode refuses
|
|
677
680
|
// frames and demotion clears them, and an app that has drawn once may not
|
|
678
681
|
// draw again on its own, so promotion replays this.
|
|
@@ -1295,6 +1298,7 @@ class LocalMiniappRuntime {
|
|
|
1295
1298
|
}
|
|
1296
1299
|
// Its gesture claims go back to whoever claimed them before.
|
|
1297
1300
|
this.applyGestureClaimChanges(dropGestureClaims(this.gestureClaims, packageName))
|
|
1301
|
+
this.setTextFieldOpen(packageName, false)
|
|
1298
1302
|
this.retainedRenders.delete(packageName)
|
|
1299
1303
|
|
|
1300
1304
|
// Warm-up is a request-owned camera lease. Cancel it even if the app record was already
|
|
@@ -1478,6 +1482,9 @@ class LocalMiniappRuntime {
|
|
|
1478
1482
|
case MiniappRequestType.CLAIM_GESTURES:
|
|
1479
1483
|
this.handleClaimGestures(packageName, payload, requestId)
|
|
1480
1484
|
break
|
|
1485
|
+
case MiniappRequestType.TEXT_FIELD:
|
|
1486
|
+
this.handleTextField(packageName, payload, requestId)
|
|
1487
|
+
break
|
|
1481
1488
|
case MiniappRequestType.DISPLAY:
|
|
1482
1489
|
this.handleDisplay(packageName, payload, requestId)
|
|
1483
1490
|
break
|
|
@@ -2214,6 +2221,48 @@ class LocalMiniappRuntime {
|
|
|
2214
2221
|
}
|
|
2215
2222
|
}
|
|
2216
2223
|
|
|
2224
|
+
/**
|
|
2225
|
+
* TEXT_FIELD (session.input.setTextFieldOpen): the app has a text field on
|
|
2226
|
+
* the glasses, or no longer does. Text entry that costs power to leave on
|
|
2227
|
+
* (Neural Band handwriting) runs while any app has one. Cleared on app stop.
|
|
2228
|
+
*/
|
|
2229
|
+
private handleTextField(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
|
|
2230
|
+
if (typeof payload.open !== "boolean") {
|
|
2231
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
2232
|
+
code: MiniappErrorCode.INVALID_ARGUMENT,
|
|
2233
|
+
message: "Text field open must be a boolean",
|
|
2234
|
+
})
|
|
2235
|
+
return
|
|
2236
|
+
}
|
|
2237
|
+
if (this.connectedApps.has(packageName)) this.setTextFieldOpen(packageName, payload.open)
|
|
2238
|
+
this.sendResult(packageName, requestId, true)
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
private setTextFieldOpen(packageName: string, open: boolean): void {
|
|
2242
|
+
const before = this.textFieldApps.size > 0
|
|
2243
|
+
if (open) this.textFieldApps.add(packageName)
|
|
2244
|
+
else this.textFieldApps.delete(packageName)
|
|
2245
|
+
const after = this.textFieldApps.size > 0
|
|
2246
|
+
if (before === after) return
|
|
2247
|
+
for (const cb of this.textFieldListeners) {
|
|
2248
|
+
try {
|
|
2249
|
+
cb(after)
|
|
2250
|
+
} catch (err) {
|
|
2251
|
+
console.warn(`${LOG_TAG}: text field listener failed:`, err)
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/** True while any running miniapp has a text field open on the glasses. */
|
|
2257
|
+
public hasOpenTextField(): boolean {
|
|
2258
|
+
return this.textFieldApps.size > 0
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
public onTextFieldChange(cb: (open: boolean) => void): () => void {
|
|
2262
|
+
this.textFieldListeners.add(cb)
|
|
2263
|
+
return () => this.textFieldListeners.delete(cb)
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2217
2266
|
/** The app holding `gesture` (the most recent claimer), or null when it is unclaimed. */
|
|
2218
2267
|
public gestureClaimant(gesture: string): string | null {
|
|
2219
2268
|
const claimant = gestureClaimant(this.gestureClaims, gesture)
|
|
@@ -4322,6 +4371,18 @@ class LocalMiniappRuntime {
|
|
|
4322
4371
|
primaryError: {code: MiniappErrorCode; message: string} | null,
|
|
4323
4372
|
): Promise<void> {
|
|
4324
4373
|
const modelId = localLlmModelManager.selectedModelId()
|
|
4374
|
+
const unusable = localLlmModelManager.unusableReason(modelId)
|
|
4375
|
+
if (unusable) {
|
|
4376
|
+
console.warn(`${LOG_TAG}: ai.complete on-device for ${packageName} (${reason}): ${unusable}`)
|
|
4377
|
+
this.sendResult(
|
|
4378
|
+
packageName,
|
|
4379
|
+
requestId,
|
|
4380
|
+
false,
|
|
4381
|
+
undefined,
|
|
4382
|
+
primaryError ?? {code: MiniappErrorCode.AI_LOCAL_UNAVAILABLE, message: unusable},
|
|
4383
|
+
)
|
|
4384
|
+
return
|
|
4385
|
+
}
|
|
4325
4386
|
if (!(await localLlmModelManager.ensureSelectedModel())) {
|
|
4326
4387
|
const status = localLlmModelManager.getStatus()
|
|
4327
4388
|
const state =
|
|
@@ -4338,7 +4399,7 @@ class LocalMiniappRuntime {
|
|
|
4338
4399
|
undefined,
|
|
4339
4400
|
primaryError ?? {
|
|
4340
4401
|
code: MiniappErrorCode.AI_LOCAL_UNAVAILABLE,
|
|
4341
|
-
message: `The on-device model (${localLlmModelManager.model(modelId).label}) ${state}. Manage it in Settings >
|
|
4402
|
+
message: `The on-device model (${localLlmModelManager.model(modelId).label}) ${state}. Manage it in Settings > Offline Models.`,
|
|
4342
4403
|
},
|
|
4343
4404
|
)
|
|
4344
4405
|
return
|
|
@@ -55,6 +55,8 @@ const INPUT_SURFACES: Readonly<Record<string, readonly InputSurface[]>> = {
|
|
|
55
55
|
[ControllerTypes.WEAR_OS]: [WATCH_SURFACE],
|
|
56
56
|
[ControllerTypes.GAMEBRICK]: [GAMEBRICK_SURFACE],
|
|
57
57
|
[ControllerTypes.IINE_ANANKE]: [GAMEBRICK_SURFACE],
|
|
58
|
+
[ControllerTypes.ZERO2]: [GAMEBRICK_SURFACE],
|
|
59
|
+
[ControllerTypes.MICRO]: [GAMEBRICK_SURFACE],
|
|
58
60
|
[ControllerTypes.R1]: [],
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides when each connected Meta Neural Band runs its handwriting model. The
|
|
3
|
+
* model streams to the phone for as long as it runs, written on or not, so it
|
|
4
|
+
* runs only while it can type somewhere: the wearer has handwriting enabled
|
|
5
|
+
* and a miniapp has a text field open on the glasses. It outlives the last
|
|
6
|
+
* field by OFF_DELAY_MS, so moving between fields doesn't restart it. A session
|
|
7
|
+
* the band ends on its own (timeout, refused setting, silence) isn't retried
|
|
8
|
+
* until no field is open, so a band that can't run the model isn't asked again
|
|
9
|
+
* and again.
|
|
10
|
+
*/
|
|
11
|
+
import type {NeuralBandHandwritingStatus} from "./NeuralBandInput"
|
|
12
|
+
|
|
13
|
+
type Phase = NeuralBandHandwritingStatus["phase"]
|
|
14
|
+
|
|
15
|
+
export interface HandwritingGateDeps {
|
|
16
|
+
enabled: () => boolean
|
|
17
|
+
textFieldOpen: () => boolean
|
|
18
|
+
setHandwriting: (id: string, enabled: boolean) => Promise<void>
|
|
19
|
+
setTimeout: (fn: () => void, ms: number) => unknown
|
|
20
|
+
clearTimeout: (handle: unknown) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Band {
|
|
24
|
+
/** What we last asked this band for. */
|
|
25
|
+
requested: boolean
|
|
26
|
+
failed: boolean
|
|
27
|
+
phase: Phase
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const OFF_DELAY_MS = 5_000
|
|
31
|
+
const ACTIVE = new Set<Phase>(["preparing", "ready", "restoring"])
|
|
32
|
+
|
|
33
|
+
export class HandwritingGate {
|
|
34
|
+
private bands = new Map<string, Band>()
|
|
35
|
+
private offTimer: unknown = null
|
|
36
|
+
|
|
37
|
+
constructor(private readonly deps: HandwritingGateDeps) {}
|
|
38
|
+
|
|
39
|
+
/** The connected bands. A band keeps nothing across connections, so a reconnect starts fresh. */
|
|
40
|
+
setBands(ids: string[]): void {
|
|
41
|
+
for (const id of [...this.bands.keys()]) if (!ids.includes(id)) this.bands.delete(id)
|
|
42
|
+
for (const id of ids) if (!this.bands.has(id)) this.bands.set(id, {requested: false, failed: false, phase: "idle"})
|
|
43
|
+
this.update()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A band reported its handwriting phase. */
|
|
47
|
+
status(id: string, phase: Phase): void {
|
|
48
|
+
const band = this.bands.get(id)
|
|
49
|
+
if (!band) return
|
|
50
|
+
band.phase = phase
|
|
51
|
+
if (phase === "finished" && band.requested) {
|
|
52
|
+
band.requested = false
|
|
53
|
+
band.failed = true
|
|
54
|
+
}
|
|
55
|
+
this.update()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Re-evaluate after the setting or the open text fields changed. */
|
|
59
|
+
update(): void {
|
|
60
|
+
const enabled = this.deps.enabled()
|
|
61
|
+
if (enabled && this.deps.textFieldOpen()) {
|
|
62
|
+
this.clearOffTimer()
|
|
63
|
+
for (const [id, band] of this.bands) {
|
|
64
|
+
// A session still winding down (or one we didn't start) has to finish first.
|
|
65
|
+
if (band.requested || band.failed || ACTIVE.has(band.phase)) continue
|
|
66
|
+
this.request(id, band, true)
|
|
67
|
+
}
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
for (const band of this.bands.values()) band.failed = false
|
|
71
|
+
if (![...this.bands.values()].some((band) => band.requested)) {
|
|
72
|
+
this.clearOffTimer()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (!enabled) {
|
|
76
|
+
this.clearOffTimer()
|
|
77
|
+
this.stopAll()
|
|
78
|
+
} else if (this.offTimer === null) {
|
|
79
|
+
this.offTimer = this.deps.setTimeout(() => {
|
|
80
|
+
this.offTimer = null
|
|
81
|
+
if (!this.deps.textFieldOpen() || !this.deps.enabled()) this.stopAll()
|
|
82
|
+
}, OFF_DELAY_MS)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
dispose(): void {
|
|
87
|
+
this.clearOffTimer()
|
|
88
|
+
this.bands.clear()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private stopAll(): void {
|
|
92
|
+
for (const [id, band] of this.bands) if (band.requested) this.request(id, band, false)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private request(id: string, band: Band, enabled: boolean): void {
|
|
96
|
+
band.requested = enabled
|
|
97
|
+
this.deps.setHandwriting(id, enabled).catch((err) => {
|
|
98
|
+
console.warn(`HandwritingGate: turning handwriting ${enabled ? "on" : "off"} failed:`, err)
|
|
99
|
+
if (enabled && this.bands.get(id) === band && band.requested) {
|
|
100
|
+
band.requested = false
|
|
101
|
+
band.failed = true
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private clearOffTimer(): void {
|
|
107
|
+
if (this.offTimer === null) return
|
|
108
|
+
this.deps.clearTimeout(this.offTimer)
|
|
109
|
+
this.offTimer = null
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
* latest claim is also kept on its own, since the pairing screen runs before
|
|
7
7
|
* the band's id reaches JS.
|
|
8
8
|
*/
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
NeuralBandClaimEvent,
|
|
11
|
+
NeuralBandFirmwareEvent,
|
|
12
|
+
NeuralBandHandwritingEvent,
|
|
13
|
+
NeuralBandPointerEvent,
|
|
14
|
+
} from "@glassly/bluetooth-sdk"
|
|
10
15
|
import {MiniappStreamType} from "@glassly/miniapp"
|
|
11
16
|
|
|
12
17
|
import type {KeyboardEventPayload} from "../GlassesKeyboardInput"
|
|
@@ -34,6 +39,8 @@ export interface NeuralBandHandwritingUpdate {
|
|
|
34
39
|
status: NeuralBandHandwritingStatus
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
export type NeuralBandFirmwareStatus = Omit<NeuralBandFirmwareEvent, "type" | "controllerId"> & {known: boolean}
|
|
43
|
+
|
|
37
44
|
export interface NeuralBandInputOptions {
|
|
38
45
|
forward?: (stream: string, payload: unknown) => void
|
|
39
46
|
now?: () => number
|
|
@@ -67,13 +74,25 @@ function codeFor(char: string): string {
|
|
|
67
74
|
|
|
68
75
|
const IDLE_CLAIM: NeuralBandClaimStatus = {stage: "idle", sessionExpired: false}
|
|
69
76
|
const IDLE_HANDWRITING: NeuralBandHandwritingStatus = {phase: "idle", message: "", text: ""}
|
|
77
|
+
const IDLE_FIRMWARE: NeuralBandFirmwareStatus = {
|
|
78
|
+
known: false,
|
|
79
|
+
connected: false,
|
|
80
|
+
hasMetaSession: false,
|
|
81
|
+
checked: false,
|
|
82
|
+
updateAvailable: false,
|
|
83
|
+
phase: "idle",
|
|
84
|
+
progress: 0,
|
|
85
|
+
message: "",
|
|
86
|
+
}
|
|
70
87
|
|
|
71
88
|
export class NeuralBandInput {
|
|
72
89
|
private latestClaim: NeuralBandClaimStatus = IDLE_CLAIM
|
|
73
90
|
private claims = new Map<string, NeuralBandClaimStatus>()
|
|
74
91
|
private handwriting = new Map<string, NeuralBandHandwritingStatus>()
|
|
92
|
+
private firmware = new Map<string, NeuralBandFirmwareStatus>()
|
|
75
93
|
private claimListeners = new Set<(status: NeuralBandClaimStatus) => void>()
|
|
76
94
|
private handwritingListeners = new Set<(update: NeuralBandHandwritingUpdate) => void>()
|
|
95
|
+
private firmwareListeners = new Set<(update: {id: string; status: NeuralBandFirmwareStatus}) => void>()
|
|
77
96
|
private readonly forward: (stream: string, payload: unknown) => void
|
|
78
97
|
private readonly now: () => number
|
|
79
98
|
|
|
@@ -102,6 +121,15 @@ export class NeuralBandInput {
|
|
|
102
121
|
return () => this.handwritingListeners.delete(cb)
|
|
103
122
|
}
|
|
104
123
|
|
|
124
|
+
firmwareStatus(controllerId: string): NeuralBandFirmwareStatus {
|
|
125
|
+
return this.firmware.get(controllerId) ?? IDLE_FIRMWARE
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
onFirmwareStatus(cb: (update: {id: string; status: NeuralBandFirmwareStatus}) => void): () => void {
|
|
129
|
+
this.firmwareListeners.add(cb)
|
|
130
|
+
return () => this.firmwareListeners.delete(cb)
|
|
131
|
+
}
|
|
132
|
+
|
|
105
133
|
/** A new pairing attempt starts from a clean slate. */
|
|
106
134
|
resetClaim(): void {
|
|
107
135
|
this.latestClaim = IDLE_CLAIM
|
|
@@ -112,6 +140,14 @@ export class NeuralBandInput {
|
|
|
112
140
|
forget(controllerId: string): void {
|
|
113
141
|
this.claims.delete(controllerId)
|
|
114
142
|
this.handwriting.delete(controllerId)
|
|
143
|
+
this.firmware.delete(controllerId)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
handleFirmware(event: NeuralBandFirmwareEvent): void {
|
|
147
|
+
const {type: _type, controllerId, ...rest} = event
|
|
148
|
+
const status: NeuralBandFirmwareStatus = {...rest, known: true}
|
|
149
|
+
this.firmware.set(controllerId, status)
|
|
150
|
+
for (const cb of this.firmwareListeners) cb({id: controllerId, status})
|
|
115
151
|
}
|
|
116
152
|
|
|
117
153
|
handleClaim(event: NeuralBandClaimEvent): void {
|