@glassly/engine 0.3.8-dev.998 → 0.4.0-beta.1019
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/LlmRouting.d.ts +1 -1
- package/build/services/LlmRouting.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 +61 -5
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/NavigationHandlers.js +1 -1
- package/build/services/NavigationHandlers.js.map +1 -1
- package/build/services/NavigationService.js +6 -6
- package/build/services/NavigationService.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/LlmRouting.ts +1 -1
- package/src/services/LocalLlmModelManager.ts +110 -3
- package/src/services/LocalLlmProvider.ts +8 -0
- package/src/services/LocalMiniappRuntime.ts +66 -5
- package/src/services/NavigationHandlers.ts +1 -1
- package/src/services/NavigationService.ts +6 -6
- 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-beta.1019",
|
|
16
|
+
"releaseSetId": "glassly-0.4.0-beta.1019",
|
|
17
|
+
"sourceCommit": "b0de6d1f62b32238f0b7e916113ecc5164c4bd28",
|
|
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
|
|
|
@@ -13,7 +13,7 @@ export interface LlmRouteInputs {
|
|
|
13
13
|
/**
|
|
14
14
|
* Whether the primary path is currently believed usable: a saved user key
|
|
15
15
|
* for the resolved provider, or cloud connected + not disabled in Privacy
|
|
16
|
-
* +
|
|
16
|
+
* + AI credits not exhausted.
|
|
17
17
|
*/
|
|
18
18
|
primaryUp: boolean
|
|
19
19
|
}
|
|
@@ -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
|
}
|
|
@@ -477,7 +477,7 @@ function directLlmProvider(name: AiTierProvider): LlmProviderImpl {
|
|
|
477
477
|
}
|
|
478
478
|
|
|
479
479
|
const QUOTA_EXCEEDED_MESSAGE =
|
|
480
|
-
"
|
|
480
|
+
"AI credits are used up. Get more in Settings > Membership, or add your own API key in Settings > API Keys."
|
|
481
481
|
|
|
482
482
|
/** The user's saved key (Settings > API Keys) for an LLM provider, "" if none. */
|
|
483
483
|
function userLlmKey(provider: AiTierProvider): string {
|
|
@@ -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)
|
|
@@ -4263,8 +4312,8 @@ class LocalMiniappRuntime {
|
|
|
4263
4312
|
this.noteMeteredLlmSuccess()
|
|
4264
4313
|
this.sendResult(packageName, requestId, true, result)
|
|
4265
4314
|
} catch (err) {
|
|
4266
|
-
// 402 quota_exceeded from the runtime: the account's
|
|
4267
|
-
//
|
|
4315
|
+
// 402 quota_exceeded from the runtime: the account's AI credits are
|
|
4316
|
+
// spent. Distinct code (miniapps may fall back to a local path) and
|
|
4268
4317
|
// a host hook so the app can offer the upgrade paywall — the miniapp
|
|
4269
4318
|
// itself must never own that conversation.
|
|
4270
4319
|
const quotaExceeded = err instanceof HttpError && (err.code === "quota_exceeded" || err.status === 402)
|
|
@@ -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
|
|
@@ -4429,7 +4490,7 @@ class LocalMiniappRuntime {
|
|
|
4429
4490
|
this.sendResult(packageName, requestId, false, undefined, {
|
|
4430
4491
|
code: MiniappErrorCode.QUOTA_EXCEEDED,
|
|
4431
4492
|
message:
|
|
4432
|
-
"
|
|
4493
|
+
"AI credits are used up. Get more in Settings > Membership, or add your own API key in Settings > API Keys.",
|
|
4433
4494
|
})
|
|
4434
4495
|
return
|
|
4435
4496
|
}
|
|
@@ -150,7 +150,7 @@ export class NavigationHandlers {
|
|
|
150
150
|
const simulate = payload.simulate === true
|
|
151
151
|
const speedNum = Number(payload.speedMultiplier)
|
|
152
152
|
const speedMultiplier = Number.isFinite(speedNum) && speedNum > 0 ? speedNum : 5
|
|
153
|
-
const mode = typeof payload.mode === "string" ? payload.mode : "
|
|
153
|
+
const mode = typeof payload.mode === "string" ? payload.mode : "walking"
|
|
154
154
|
const avoidRaw = payload.avoid as Record<string, unknown> | undefined
|
|
155
155
|
const avoid = avoidRaw
|
|
156
156
|
? {
|
|
@@ -109,7 +109,7 @@ class NavigationService {
|
|
|
109
109
|
private lastManeuver: NavManeuver | null = null
|
|
110
110
|
/** Trip configuration captured at start(), surfaced via getState(). */
|
|
111
111
|
private tripStops: Array<{lat: number; lng: number}> = []
|
|
112
|
-
private tripMode: string = "
|
|
112
|
+
private tripMode: string = "walking"
|
|
113
113
|
|
|
114
114
|
private constructor() {}
|
|
115
115
|
|
|
@@ -229,7 +229,7 @@ class NavigationService {
|
|
|
229
229
|
simulate: options?.simulate ?? false,
|
|
230
230
|
speedMultiplier: options?.speedMultiplier ?? 5,
|
|
231
231
|
stops: options?.stops,
|
|
232
|
-
mode: options?.mode ?? "
|
|
232
|
+
mode: options?.mode ?? "walking",
|
|
233
233
|
avoid: options?.avoid,
|
|
234
234
|
missedTurnRerouteMeters: options?.missedTurnRerouteMeters,
|
|
235
235
|
})
|
|
@@ -237,11 +237,11 @@ class NavigationService {
|
|
|
237
237
|
console.warn(`${LOG_TAG}: start failed — ${result.error}`)
|
|
238
238
|
this.state = "idle"
|
|
239
239
|
this.tripStops = []
|
|
240
|
-
this.tripMode = "
|
|
240
|
+
this.tripMode = "walking"
|
|
241
241
|
} else {
|
|
242
242
|
this.state = "navigating"
|
|
243
243
|
this.tripStops = options?.stops ?? [{lat: coords.lat, lng: coords.lng}]
|
|
244
|
-
this.tripMode = options?.mode ?? "
|
|
244
|
+
this.tripMode = options?.mode ?? "walking"
|
|
245
245
|
}
|
|
246
246
|
return result
|
|
247
247
|
}
|
|
@@ -277,7 +277,7 @@ class NavigationService {
|
|
|
277
277
|
this.lastRoute = null
|
|
278
278
|
this.lastManeuver = null
|
|
279
279
|
this.tripStops = []
|
|
280
|
-
this.tripMode = "
|
|
280
|
+
this.tripMode = "walking"
|
|
281
281
|
return result
|
|
282
282
|
}
|
|
283
283
|
|
|
@@ -565,7 +565,7 @@ async function computeRouteViaRoutesApi(payload: Record<string, unknown>): Promi
|
|
|
565
565
|
}> {
|
|
566
566
|
const origin = payload.origin as {lat?: unknown; lng?: unknown} | undefined
|
|
567
567
|
const stopsRaw = payload.stops as Array<{lat?: unknown; lng?: unknown}> | undefined
|
|
568
|
-
const mode = (typeof payload.mode === "string" ? payload.mode : "
|
|
568
|
+
const mode = (typeof payload.mode === "string" ? payload.mode : "walking").toLowerCase()
|
|
569
569
|
const alt = Number(payload.alternatives)
|
|
570
570
|
const alternatives = Number.isFinite(alt) && alt > 0 ? alt : 1
|
|
571
571
|
const avoid = (payload.avoid as Record<string, unknown> | undefined) ?? {}
|
|
@@ -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
|
+
}
|