@glassly/engine 0.3.8-dev.1012 → 0.3.8-dev.1015
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 +3 -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/generated/releaseMetadata.js +3 -3
- package/build/generated/releaseMetadata.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.map +1 -1
- package/build/services/LocalMiniappRuntime.js +7 -1
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/package.json +7 -7
- package/src/facades/ai.ts +6 -0
- package/src/generated/releaseMetadata.ts +3 -3
- 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 +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glassly/engine",
|
|
3
|
-
"version": "0.3.8-dev.
|
|
3
|
+
"version": "0.3.8-dev.1015",
|
|
4
4
|
"description": "Glassly Engine — miniapp registry, WebView bridge, and miniapp store",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"react-native": "src/index.ts",
|
|
@@ -97,17 +97,17 @@
|
|
|
97
97
|
"registry": "https://registry.npmjs.org/"
|
|
98
98
|
},
|
|
99
99
|
"dependencies": {
|
|
100
|
-
"@glassly/bluetooth-sdk": "0.3.8-dev.
|
|
101
|
-
"@glassly/cloud-client": "0.3.8-dev.
|
|
102
|
-
"@glassly/cloud-protocol": "0.3.8-dev.
|
|
103
|
-
"@glassly/crust": "0.3.8-dev.
|
|
104
|
-
"@glassly/miniapp": "0.3.8-dev.
|
|
100
|
+
"@glassly/bluetooth-sdk": "0.3.8-dev.1015",
|
|
101
|
+
"@glassly/cloud-client": "0.3.8-dev.1015",
|
|
102
|
+
"@glassly/cloud-protocol": "0.3.8-dev.1015",
|
|
103
|
+
"@glassly/crust": "0.3.8-dev.1015",
|
|
104
|
+
"@glassly/miniapp": "0.3.8-dev.1015",
|
|
105
105
|
"buffer": "^6.0.3",
|
|
106
106
|
"events": "^3.3.0",
|
|
107
107
|
"react-native-marked": "8.3.2",
|
|
108
108
|
"semver": "^7.7.4",
|
|
109
109
|
"typesafe-ts": "^1.7.1",
|
|
110
|
-
"@glassly/llm-providers": "0.3.8-dev.
|
|
110
|
+
"@glassly/llm-providers": "0.3.8-dev.1015",
|
|
111
111
|
"@huggingface/jinja": "^0.5.9",
|
|
112
112
|
"jsonrepair": "^3.12.0"
|
|
113
113
|
},
|
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),
|
|
@@ -12,9 +12,9 @@ export interface EngineReleaseMetadata {
|
|
|
12
12
|
export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
|
|
13
13
|
"schemaVersion": 1,
|
|
14
14
|
"familyBaseVersion": "0.3.8",
|
|
15
|
-
"releaseIdentity": "0.3.8-dev.
|
|
16
|
-
"releaseSetId": "glassly-0.3.8-dev.
|
|
17
|
-
"sourceCommit": "
|
|
15
|
+
"releaseIdentity": "0.3.8-dev.1015",
|
|
16
|
+
"releaseSetId": "glassly-0.3.8-dev.1015",
|
|
17
|
+
"sourceCommit": "5c28c73387e99d17f36887d985459628009a130b",
|
|
18
18
|
"otaManifestUrl": null,
|
|
19
19
|
"otaManifestSha256": null
|
|
20
20
|
})
|
|
@@ -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
|
}
|
|
@@ -4322,6 +4322,18 @@ class LocalMiniappRuntime {
|
|
|
4322
4322
|
primaryError: {code: MiniappErrorCode; message: string} | null,
|
|
4323
4323
|
): Promise<void> {
|
|
4324
4324
|
const modelId = localLlmModelManager.selectedModelId()
|
|
4325
|
+
const unusable = localLlmModelManager.unusableReason(modelId)
|
|
4326
|
+
if (unusable) {
|
|
4327
|
+
console.warn(`${LOG_TAG}: ai.complete on-device for ${packageName} (${reason}): ${unusable}`)
|
|
4328
|
+
this.sendResult(
|
|
4329
|
+
packageName,
|
|
4330
|
+
requestId,
|
|
4331
|
+
false,
|
|
4332
|
+
undefined,
|
|
4333
|
+
primaryError ?? {code: MiniappErrorCode.AI_LOCAL_UNAVAILABLE, message: unusable},
|
|
4334
|
+
)
|
|
4335
|
+
return
|
|
4336
|
+
}
|
|
4325
4337
|
if (!(await localLlmModelManager.ensureSelectedModel())) {
|
|
4326
4338
|
const status = localLlmModelManager.getStatus()
|
|
4327
4339
|
const state =
|
|
@@ -4338,7 +4350,7 @@ class LocalMiniappRuntime {
|
|
|
4338
4350
|
undefined,
|
|
4339
4351
|
primaryError ?? {
|
|
4340
4352
|
code: MiniappErrorCode.AI_LOCAL_UNAVAILABLE,
|
|
4341
|
-
message: `The on-device model (${localLlmModelManager.model(modelId).label}) ${state}. Manage it in Settings >
|
|
4353
|
+
message: `The on-device model (${localLlmModelManager.model(modelId).label}) ${state}. Manage it in Settings > Offline Models.`,
|
|
4342
4354
|
},
|
|
4343
4355
|
)
|
|
4344
4356
|
return
|