@glassly/engine 0.3.3-dev.669 → 0.3.4-dev.674
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 +38 -14
- package/build/engine.d.ts.map +1 -1
- package/build/engine.js +3 -0
- package/build/engine.js.map +1 -1
- package/build/facades/ai.d.ts +32 -0
- package/build/facades/ai.d.ts.map +1 -0
- package/build/facades/ai.js +38 -0
- package/build/facades/ai.js.map +1 -0
- package/build/facades/glasses.d.ts +30 -24
- package/build/facades/glasses.d.ts.map +1 -1
- package/build/facades/glasses.js +49 -30
- package/build/facades/glasses.js.map +1 -1
- package/build/facades/glassesSettings.d.ts.map +1 -1
- package/build/facades/glassesSettings.js +1 -0
- package/build/facades/glassesSettings.js.map +1 -1
- package/build/facades/pairing.d.ts +6 -5
- package/build/facades/pairing.d.ts.map +1 -1
- package/build/facades/pairing.js +14 -14
- package/build/facades/pairing.js.map +1 -1
- package/build/generated/releaseMetadata.js +4 -4
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +5 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/services/DeviceEventRouter.d.ts.map +1 -1
- package/build/services/DeviceEventRouter.js +9 -1
- package/build/services/DeviceEventRouter.js.map +1 -1
- package/build/services/GestureDotsService.d.ts +4 -3
- package/build/services/GestureDotsService.d.ts.map +1 -1
- package/build/services/GestureDotsService.js +17 -12
- package/build/services/GestureDotsService.js.map +1 -1
- package/build/services/GlassesTouchInput.d.ts.map +1 -1
- package/build/services/GlassesTouchInput.js +11 -9
- package/build/services/GlassesTouchInput.js.map +1 -1
- package/build/services/LlmRouting.d.ts +35 -0
- package/build/services/LlmRouting.d.ts.map +1 -0
- package/build/services/LlmRouting.js +25 -0
- package/build/services/LlmRouting.js.map +1 -0
- package/build/services/LocalLlmModelManager.d.ts +79 -0
- package/build/services/LocalLlmModelManager.d.ts.map +1 -0
- package/build/services/LocalLlmModelManager.js +278 -0
- package/build/services/LocalLlmModelManager.js.map +1 -0
- package/build/services/LocalLlmPrompt.d.ts +90 -0
- package/build/services/LocalLlmPrompt.d.ts.map +1 -0
- package/build/services/LocalLlmPrompt.js +189 -0
- package/build/services/LocalLlmPrompt.js.map +1 -0
- package/build/services/LocalLlmProvider.d.ts +35 -0
- package/build/services/LocalLlmProvider.d.ts.map +1 -0
- package/build/services/LocalLlmProvider.js +217 -0
- package/build/services/LocalLlmProvider.js.map +1 -0
- package/build/services/LocalMiniappRuntime.d.ts +9 -1
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +91 -17
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/STTModelManager.d.ts +25 -2
- package/build/services/STTModelManager.d.ts.map +1 -1
- package/build/services/STTModelManager.js +137 -6
- package/build/services/STTModelManager.js.map +1 -1
- package/build/services/controllers.d.ts +21 -0
- package/build/services/controllers.d.ts.map +1 -0
- package/build/services/controllers.js +62 -0
- package/build/services/controllers.js.map +1 -0
- package/build/services/effectiveCapabilities.d.ts.map +1 -1
- package/build/services/effectiveCapabilities.js +18 -24
- package/build/services/effectiveCapabilities.js.map +1 -1
- package/build/services/neuralBand/NeuralBandInput.d.ts +17 -6
- package/build/services/neuralBand/NeuralBandInput.d.ts.map +1 -1
- package/build/services/neuralBand/NeuralBandInput.js +40 -23
- 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 +23 -26
- package/build/services/neuralBand/NeuralBandSettingsSync.js.map +1 -1
- package/build/services/settingsBridge.d.ts +26 -18
- package/build/services/settingsBridge.d.ts.map +1 -1
- package/build/services/settingsBridge.js +36 -15
- package/build/services/settingsBridge.js.map +1 -1
- package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
- package/build/stores/bluetoothSettingKeys.js +3 -1
- package/build/stores/bluetoothSettingKeys.js.map +1 -1
- package/build/stores/core.d.ts.map +1 -1
- package/build/stores/core.js +0 -1
- package/build/stores/core.js.map +1 -1
- package/build/stores/glasses.d.ts.map +1 -1
- package/build/stores/glasses.js +2 -6
- package/build/stores/glasses.js.map +1 -1
- package/build/stores/settings.d.ts.map +1 -1
- package/build/stores/settings.js +28 -2
- package/build/stores/settings.js.map +1 -1
- package/package.json +12 -8
- package/src/engine.ts +3 -0
- package/src/facades/ai.ts +43 -0
- package/src/facades/glasses.ts +60 -31
- package/src/facades/glassesSettings.ts +1 -0
- package/src/facades/pairing.ts +18 -14
- package/src/generated/releaseMetadata.ts +4 -4
- package/src/index.ts +16 -2
- package/src/services/DeviceEventRouter.ts +8 -1
- package/src/services/GestureDotsService.ts +20 -14
- package/src/services/GlassesTouchInput.ts +11 -9
- package/src/services/LlmRouting.ts +49 -0
- package/src/services/LocalLlmModelManager.ts +327 -0
- package/src/services/LocalLlmPrompt.ts +246 -0
- package/src/services/LocalLlmProvider.ts +289 -0
- package/src/services/LocalMiniappRuntime.ts +137 -21
- package/src/services/STTModelManager.ts +140 -8
- package/src/services/controllers.ts +77 -0
- package/src/services/effectiveCapabilities.ts +16 -32
- package/src/services/neuralBand/NeuralBandInput.ts +50 -26
- package/src/services/neuralBand/NeuralBandSettingsSync.ts +28 -26
- package/src/services/settingsBridge.ts +60 -22
- package/src/stores/bluetoothSettingKeys.ts +3 -1
- package/src/stores/core.ts +0 -1
- package/src/stores/glasses.ts +2 -6
- package/src/stores/settings.ts +28 -2
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalLlmProvider — session.ai.complete served by the phone itself: an
|
|
3
|
+
* ExecuTorch runner (react-native-executorch) over the Qwen3 export that
|
|
4
|
+
* LocalLlmModelManager downloaded. Stateless per request like the vendor
|
|
5
|
+
* providers: every call renders the whole conversation through the model's
|
|
6
|
+
* chat template, resets the KV cache, and generates on ExecuTorch's worklet
|
|
7
|
+
* thread so the JS thread stays free.
|
|
8
|
+
*
|
|
9
|
+
* The runner is loaded on the first request and kept resident while calls
|
|
10
|
+
* keep coming (loading a 1–3 GB model is seconds, not milliseconds); it is
|
|
11
|
+
* dropped after an idle period and whenever the selected model changes.
|
|
12
|
+
* Requests are serialized — one model, one KV cache.
|
|
13
|
+
*
|
|
14
|
+
* react-native-executorch is imported lazily so the engine module graph
|
|
15
|
+
* loads without the native runtime (bun tests, hosts without the dependency).
|
|
16
|
+
*/
|
|
17
|
+
import * as RNFS from "@dr.pogodin/react-native-fs"
|
|
18
|
+
import {Template} from "@huggingface/jinja"
|
|
19
|
+
|
|
20
|
+
import type {LlmCompleteRequest} from "@glassly/cloud-protocol/llm"
|
|
21
|
+
import localLlmModelManager, {type LocalLlmModelId} from "./LocalLlmModelManager"
|
|
22
|
+
import {
|
|
23
|
+
buildTemplateMessages,
|
|
24
|
+
estimateTokens,
|
|
25
|
+
finalizeLocalResult,
|
|
26
|
+
fitToBudget,
|
|
27
|
+
toTemplateTools,
|
|
28
|
+
type LocalCompletion,
|
|
29
|
+
} from "./LocalLlmPrompt"
|
|
30
|
+
import {SETTINGS, useSettingsStore} from "../stores/settings"
|
|
31
|
+
|
|
32
|
+
type Rne = typeof import("react-native-executorch")
|
|
33
|
+
// The runner API lives on the `llm` namespace; derive its types from there so
|
|
34
|
+
// the import stays a type-only reference (no native module at load time).
|
|
35
|
+
type LLMRunner = ReturnType<Rne["llm"]["createLLMRunner"]>
|
|
36
|
+
type LLMGenerationStats = ReturnType<LLMRunner["generate"]>
|
|
37
|
+
type LLMGenerationConfig = NonNullable<Parameters<LLMRunner["generate"]>[1]>
|
|
38
|
+
|
|
39
|
+
const LOG_TAG = "LOCAL_LLM"
|
|
40
|
+
|
|
41
|
+
/** Drop the resident model after this long without a request. */
|
|
42
|
+
const IDLE_UNLOAD_MS = 5 * 60_000
|
|
43
|
+
/** Hard stop on one generation — the SDK request times out at 120 s anyway. */
|
|
44
|
+
const GENERATION_DEADLINE_MS = 100_000
|
|
45
|
+
const DEFAULT_MAX_NEW_TOKENS = 512
|
|
46
|
+
const MAX_NEW_TOKENS_CEILING = 1_024
|
|
47
|
+
/** Headroom between the estimated prompt and the context end; estimates are rough. */
|
|
48
|
+
const CONTEXT_MARGIN_TOKENS = 96
|
|
49
|
+
|
|
50
|
+
interface LoadedModel {
|
|
51
|
+
modelId: LocalLlmModelId
|
|
52
|
+
runner: LLMRunner
|
|
53
|
+
template: Template
|
|
54
|
+
stopTokens: string[]
|
|
55
|
+
maxSeqLen: number
|
|
56
|
+
generate: (
|
|
57
|
+
runner: LLMRunner,
|
|
58
|
+
prompt: string,
|
|
59
|
+
config: LLMGenerationConfig,
|
|
60
|
+
stopTokens: string[],
|
|
61
|
+
) => Promise<{text: string; stats: LLMGenerationStats}>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface LocalLlmCompleteResult extends LocalCompletion {
|
|
65
|
+
modelId: LocalLlmModelId
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Runs on ExecuTorch's worklet runtime: `runner.generate` blocks its thread
|
|
70
|
+
* until EOS, `maxNewTokens`, or `runner.stop()` from the JS thread.
|
|
71
|
+
*/
|
|
72
|
+
function generateWorklet(
|
|
73
|
+
runner: LLMRunner,
|
|
74
|
+
prompt: string,
|
|
75
|
+
config: LLMGenerationConfig,
|
|
76
|
+
stopTokens: string[],
|
|
77
|
+
): {text: string; stats: LLMGenerationStats} {
|
|
78
|
+
"worklet"
|
|
79
|
+
let text = ""
|
|
80
|
+
const stats = runner.generate(prompt, config, (token: string) => {
|
|
81
|
+
if (stopTokens.includes(token)) return
|
|
82
|
+
text += token
|
|
83
|
+
})
|
|
84
|
+
return {text, stats}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class LocalLlmProvider {
|
|
88
|
+
private static instance: LocalLlmProvider
|
|
89
|
+
|
|
90
|
+
private loaded: LoadedModel | null = null
|
|
91
|
+
private loading: Promise<LoadedModel> | null = null
|
|
92
|
+
private queue: Promise<unknown> = Promise.resolve()
|
|
93
|
+
private idleTimer: ReturnType<typeof setTimeout> | null = null
|
|
94
|
+
private rne: Rne | null = null
|
|
95
|
+
|
|
96
|
+
private constructor() {
|
|
97
|
+
// A different model picked in Settings invalidates the resident runner.
|
|
98
|
+
useSettingsStore.subscribe(
|
|
99
|
+
(state) => state.settings[SETTINGS.local_llm_model.key],
|
|
100
|
+
() => {
|
|
101
|
+
void this.unload()
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
static getInstance(): LocalLlmProvider {
|
|
107
|
+
if (!LocalLlmProvider.instance) LocalLlmProvider.instance = new LocalLlmProvider()
|
|
108
|
+
return LocalLlmProvider.instance
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether a runner is resident right now (diagnostics; not a readiness check). */
|
|
112
|
+
isLoaded(): boolean {
|
|
113
|
+
return this.loaded !== null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
loadedModelId(): LocalLlmModelId | null {
|
|
117
|
+
return this.loaded?.modelId ?? null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Serve one neutral completion on-device. Throws when the model is missing or generation fails. */
|
|
121
|
+
complete(request: LlmCompleteRequest): Promise<LocalLlmCompleteResult> {
|
|
122
|
+
const run = this.queue.then(() => this.completeInner(request))
|
|
123
|
+
this.queue = run.catch(() => undefined)
|
|
124
|
+
return run
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Release the resident runner (idle, model switch, memory pressure). Safe to call repeatedly. */
|
|
128
|
+
async unload(): Promise<void> {
|
|
129
|
+
this.clearIdleTimer()
|
|
130
|
+
// Never dispose under a running generation: let the queue drain first.
|
|
131
|
+
await this.queue
|
|
132
|
+
const pending = this.loading
|
|
133
|
+
if (pending) {
|
|
134
|
+
try {
|
|
135
|
+
await pending
|
|
136
|
+
} catch {
|
|
137
|
+
// A failed load left nothing to release.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
this.releaseRunner()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Synchronous dispose; callers guarantee no generation is in flight. */
|
|
144
|
+
private releaseRunner(): void {
|
|
145
|
+
const loaded = this.loaded
|
|
146
|
+
this.loaded = null
|
|
147
|
+
if (!loaded) return
|
|
148
|
+
try {
|
|
149
|
+
loaded.runner.dispose()
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.warn(`${LOG_TAG}: runner dispose failed:`, error)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async completeInner(request: LlmCompleteRequest): Promise<LocalLlmCompleteResult> {
|
|
156
|
+
this.clearIdleTimer()
|
|
157
|
+
try {
|
|
158
|
+
const model = await this.ensureLoaded()
|
|
159
|
+
const jsonMode = request.jsonMode === true
|
|
160
|
+
const tools = toTemplateTools(request.tools)
|
|
161
|
+
const toolTokens = tools ? estimateTokens(JSON.stringify(tools)) + 64 : 0
|
|
162
|
+
|
|
163
|
+
const maxNewTokens = Math.max(
|
|
164
|
+
16,
|
|
165
|
+
Math.min(request.maxTokens ?? DEFAULT_MAX_NEW_TOKENS, MAX_NEW_TOKENS_CEILING, Math.floor(model.maxSeqLen / 4)),
|
|
166
|
+
)
|
|
167
|
+
const promptBudget = model.maxSeqLen - maxNewTokens - CONTEXT_MARGIN_TOKENS
|
|
168
|
+
const messages = fitToBudget(buildTemplateMessages({...request, jsonMode}), promptBudget, toolTokens)
|
|
169
|
+
|
|
170
|
+
const prompt = model.template.render({
|
|
171
|
+
messages,
|
|
172
|
+
tools,
|
|
173
|
+
add_generation_prompt: true,
|
|
174
|
+
enable_thinking: false,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
model.runner.reset(0)
|
|
178
|
+
const deadline = setTimeout(() => {
|
|
179
|
+
console.warn(`${LOG_TAG}: generation deadline hit, stopping`)
|
|
180
|
+
model.runner.stop()
|
|
181
|
+
}, GENERATION_DEADLINE_MS)
|
|
182
|
+
let generated: {text: string; stats: LLMGenerationStats}
|
|
183
|
+
const startedAt = Date.now()
|
|
184
|
+
try {
|
|
185
|
+
generated = await model.generate(
|
|
186
|
+
model.runner,
|
|
187
|
+
prompt,
|
|
188
|
+
{maxNewTokens, temperature: request.temperature ?? 0.7},
|
|
189
|
+
model.stopTokens,
|
|
190
|
+
)
|
|
191
|
+
} finally {
|
|
192
|
+
clearTimeout(deadline)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const elapsedMs = Date.now() - startedAt
|
|
196
|
+
console.log(
|
|
197
|
+
`${LOG_TAG}: ${model.modelId} prompt=${generated.stats.numPromptTokens} out=${generated.stats.numGeneratedTokens} tokens in ${elapsedMs} ms`,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
const seed = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
|
201
|
+
const completion = finalizeLocalResult(
|
|
202
|
+
{
|
|
203
|
+
text: generated.text,
|
|
204
|
+
promptTokens: generated.stats.numPromptTokens,
|
|
205
|
+
generatedTokens: generated.stats.numGeneratedTokens,
|
|
206
|
+
maxNewTokens,
|
|
207
|
+
},
|
|
208
|
+
{jsonMode, toolsOffered: Boolean(tools), makeToolCallId: (index) => `local_${seed}_${index}`},
|
|
209
|
+
)
|
|
210
|
+
return {...completion, modelId: model.modelId}
|
|
211
|
+
} finally {
|
|
212
|
+
this.armIdleTimer()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private async ensureLoaded(): Promise<LoadedModel> {
|
|
217
|
+
const modelId = localLlmModelManager.selectedModelId()
|
|
218
|
+
if (this.loaded?.modelId === modelId) return this.loaded
|
|
219
|
+
if (this.loading) return this.loading
|
|
220
|
+
|
|
221
|
+
this.loading = this.load(modelId)
|
|
222
|
+
.then((model) => {
|
|
223
|
+
this.loaded = model
|
|
224
|
+
return model
|
|
225
|
+
})
|
|
226
|
+
.finally(() => {
|
|
227
|
+
this.loading = null
|
|
228
|
+
})
|
|
229
|
+
return this.loading
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async load(modelId: LocalLlmModelId): Promise<LoadedModel> {
|
|
233
|
+
// Runs inside the request queue, so nothing is generating; unload() would
|
|
234
|
+
// wait on that same queue.
|
|
235
|
+
this.releaseRunner()
|
|
236
|
+
if (!(await localLlmModelManager.isModelAvailable(modelId))) {
|
|
237
|
+
throw new Error(`on-device model ${modelId} is not downloaded`)
|
|
238
|
+
}
|
|
239
|
+
const rne = await this.runtime()
|
|
240
|
+
const paths = localLlmModelManager.paths(modelId)
|
|
241
|
+
|
|
242
|
+
const tokenizerConfig = rne.llm.parseTokenizerConfig(JSON.parse(await RNFS.readFile(paths.tokenizerConfig, "utf8")))
|
|
243
|
+
const template = new Template(tokenizerConfig.chatTemplate)
|
|
244
|
+
|
|
245
|
+
const startedAt = Date.now()
|
|
246
|
+
const createRunner = rne.wrapAsync(rne.llm.createLLMRunner)
|
|
247
|
+
const runner = await createRunner(paths.model, paths.tokenizer, [])
|
|
248
|
+
const maxSeqLen = runner.getKVCacheState().maxSeqLen
|
|
249
|
+
console.log(`${LOG_TAG}: loaded ${modelId} (context ${maxSeqLen}) in ${Date.now() - startedAt} ms`)
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
modelId,
|
|
253
|
+
runner,
|
|
254
|
+
template,
|
|
255
|
+
stopTokens: [...tokenizerConfig.stopTokens],
|
|
256
|
+
maxSeqLen,
|
|
257
|
+
generate: rne.wrapAsync(generateWorklet),
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async runtime(): Promise<Rne> {
|
|
262
|
+
if (this.rne) return this.rne
|
|
263
|
+
const rne: Rne = await import("react-native-executorch")
|
|
264
|
+
// Software Mansion's anonymous download analytics; the host has its own telemetry.
|
|
265
|
+
rne.setTelemetryEnabled(false)
|
|
266
|
+
this.rne = rne
|
|
267
|
+
return rne
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private armIdleTimer(): void {
|
|
271
|
+
this.clearIdleTimer()
|
|
272
|
+
this.idleTimer = setTimeout(() => {
|
|
273
|
+
this.idleTimer = null
|
|
274
|
+
console.log(`${LOG_TAG}: idle, unloading ${this.loaded?.modelId ?? "nothing"}`)
|
|
275
|
+
void this.unload()
|
|
276
|
+
}, IDLE_UNLOAD_MS)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private clearIdleTimer(): void {
|
|
280
|
+
if (this.idleTimer) {
|
|
281
|
+
clearTimeout(this.idleTimer)
|
|
282
|
+
this.idleTimer = null
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const localLlmProvider = LocalLlmProvider.getInstance()
|
|
288
|
+
export {LocalLlmProvider}
|
|
289
|
+
export default localLlmProvider
|
|
@@ -53,8 +53,17 @@ import {
|
|
|
53
53
|
|
|
54
54
|
import {normalizeAiTier, resolveAiTier} from "./AiTierResolution"
|
|
55
55
|
import type {AiTier, AiTierProvider, SavedLlmKeys} from "./AiTierResolution"
|
|
56
|
+
import {decideLlmRoute, localLlmReason, type LocalLlmReason} from "./LlmRouting"
|
|
57
|
+
import localLlmModelManager from "./LocalLlmModelManager"
|
|
58
|
+
import localLlmProvider from "./LocalLlmProvider"
|
|
56
59
|
import {HttpError} from "@glassly/cloud-client"
|
|
57
|
-
import type {
|
|
60
|
+
import type {
|
|
61
|
+
QuotaStatusPayload,
|
|
62
|
+
AudioSubscription,
|
|
63
|
+
LanguageSource,
|
|
64
|
+
TranscriptionData,
|
|
65
|
+
TranslationData,
|
|
66
|
+
} from "@glassly/cloud-protocol"
|
|
58
67
|
import localDisplayManager from "./LocalDisplayManager"
|
|
59
68
|
import sttModelManager from "./STTModelManager"
|
|
60
69
|
import localTranslationService, {type LocalTranslationSubscription} from "./LocalTranslationService"
|
|
@@ -73,12 +82,12 @@ import dashboardOpenTrace from "./debug/DashboardOpenTrace"
|
|
|
73
82
|
import {isAppForegrounded, isPhoneActive, onAppForegroundChange, onPhoneActivityChange} from "./PhoneActivity"
|
|
74
83
|
import {installedNotificationApps} from "./PhoneNotificationsSync"
|
|
75
84
|
import {MiniappIcons} from "./MiniappIcons"
|
|
85
|
+
import {pairedControllersFrom, readyControllersKey} from "./controllers"
|
|
76
86
|
import {
|
|
77
87
|
DERIVED_SETTINGS_KEYS,
|
|
78
88
|
SETTINGS_BRIDGE_KEYS,
|
|
79
|
-
|
|
89
|
+
controllersSettingFrom,
|
|
80
90
|
isDerivedSettingsKey,
|
|
81
|
-
ringBatteryFrom,
|
|
82
91
|
selectSettingsBridgeKeys,
|
|
83
92
|
validateSettingsSet,
|
|
84
93
|
type DerivedSettingsKey,
|
|
@@ -136,7 +145,6 @@ import {invokeScanQrSeam} from "../runtime/scanQrSeam"
|
|
|
136
145
|
import {normalizeStreamAudioConfig, normalizeStreamVideoConfig} from "../runtime/streamConfig"
|
|
137
146
|
import {visibleActionRows} from "./manifestActions"
|
|
138
147
|
import {toLanguageHint} from "@glassly/cloud-protocol/languages"
|
|
139
|
-
import type {AudioSubscription, LanguageSource, TranscriptionData, TranslationData} from "@glassly/cloud-protocol"
|
|
140
148
|
import {buildMiniappManifestSnapshot, type MiniappRuntimeDiagnosticSnapshot} from "../utils/miniappDiagnostics"
|
|
141
149
|
import ttsModelManager from "./TTSModelManager"
|
|
142
150
|
import {NavigationHandlers} from "./NavigationHandlers"
|
|
@@ -416,6 +424,9 @@ function directLlmProvider(name: AiTierProvider): LlmProviderImpl {
|
|
|
416
424
|
return built
|
|
417
425
|
}
|
|
418
426
|
|
|
427
|
+
const QUOTA_EXCEEDED_MESSAGE =
|
|
428
|
+
"Monthly AI credit is used up. Upgrade in Settings > Membership, or add your own API key in Settings > API Keys."
|
|
429
|
+
|
|
419
430
|
/** The user's saved key (Settings > API Keys) for an LLM provider, "" if none. */
|
|
420
431
|
function userLlmKey(provider: AiTierProvider): string {
|
|
421
432
|
const raw = useSettingsStore.getState().getSetting(MINIAPP_READABLE_API_KEYS[provider])
|
|
@@ -2560,11 +2571,10 @@ class LocalMiniappRuntime {
|
|
|
2560
2571
|
return isPhoneActive()
|
|
2561
2572
|
case "foreground_app_on_screen":
|
|
2562
2573
|
return localDisplayManager.foregroundAppOnScreen()
|
|
2563
|
-
case "
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
return input2dFrom(useGlassesStore.getState(), controller)
|
|
2574
|
+
case "controllers": {
|
|
2575
|
+
const glasses = useGlassesStore.getState()
|
|
2576
|
+
const records = pairedControllersFrom(useSettingsStore.getState().getSetting(SETTINGS.controllers.key))
|
|
2577
|
+
return controllersSettingFrom(records, glasses.controllers, glasses)
|
|
2568
2578
|
}
|
|
2569
2579
|
}
|
|
2570
2580
|
}
|
|
@@ -2619,7 +2629,7 @@ class LocalMiniappRuntime {
|
|
|
2619
2629
|
/**
|
|
2620
2630
|
* Push bridged settings as they change: stored keys from the store, the
|
|
2621
2631
|
* derived keys whenever their inputs move (phone activity, the display,
|
|
2622
|
-
* the apps store, the
|
|
2632
|
+
* the apps store, the controllers, the dashboard role). Subscribers declared
|
|
2623
2633
|
* PLATFORM_SETTINGS (gated at SUBSCRIBE), so this is a no-op fan-out for
|
|
2624
2634
|
* everyone else.
|
|
2625
2635
|
*/
|
|
@@ -2659,10 +2669,10 @@ class LocalMiniappRuntime {
|
|
|
2659
2669
|
useAppStatusStore.subscribe(pushDerived)
|
|
2660
2670
|
onOverlayAppsChange(pushDerived)
|
|
2661
2671
|
useGlassesStore.subscribe(
|
|
2662
|
-
(s) => `${s.ringConnected}:${s.ringBatteryLevel}:${s.
|
|
2672
|
+
(s) => `${s.ringConnected}:${s.ringBatteryLevel}:${s.ringCharging}:${JSON.stringify(s.controllers ?? {})}`,
|
|
2663
2673
|
pushDerived,
|
|
2664
2674
|
)
|
|
2665
|
-
useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.
|
|
2675
|
+
useSettingsStore.subscribe((s) => JSON.stringify(s.getSetting(SETTINGS.controllers.key) ?? null), pushDerived)
|
|
2666
2676
|
}
|
|
2667
2677
|
|
|
2668
2678
|
/**
|
|
@@ -4063,16 +4073,39 @@ class LocalMiniappRuntime {
|
|
|
4063
4073
|
// here (the user's Settings > AI Models dropdowns, or "auto" from which
|
|
4064
4074
|
// keys are saved — see AiTierResolution). Legacy provider/model pins from
|
|
4065
4075
|
// pre-tier miniapps are deliberately stripped, not honored.
|
|
4066
|
-
const {
|
|
4076
|
+
const {
|
|
4077
|
+
tier: rawTier,
|
|
4078
|
+
provider: _legacyProvider,
|
|
4079
|
+
model: _legacyModel,
|
|
4080
|
+
forceLocal: rawForceLocal,
|
|
4081
|
+
localFallback: rawLocalFallback,
|
|
4082
|
+
...neutralRequest
|
|
4083
|
+
} = request
|
|
4067
4084
|
const tier = normalizeAiTier(rawTier)
|
|
4068
4085
|
const resolved = resolveAiTier(tier, tierSettingValue(tier), savedLlmKeys())
|
|
4069
4086
|
|
|
4087
|
+
// ON-DEVICE ROUTING — the request's forceLocal / localFallback flags
|
|
4088
|
+
// against the primary path's health (see LlmRouting). The primary is the
|
|
4089
|
+
// vendor-direct call when a user key is saved, else the cloud relay.
|
|
4090
|
+
const directKey = resolved.provider ? userLlmKey(resolved.provider) : ""
|
|
4091
|
+
const forceLocal = rawForceLocal === true
|
|
4092
|
+
const localFallback = rawLocalFallback === true
|
|
4093
|
+
const cloudConnected = cloudClientService.isConnected()
|
|
4094
|
+
const cloudDisabled = cloudClientService.isCloudDisabled()
|
|
4095
|
+
const primaryUp = directKey.length > 0 || (cloudConnected && !cloudDisabled && !this.llmQuotaBlocked)
|
|
4096
|
+
const route = decideLlmRoute({forceLocal, localFallback, primaryUp})
|
|
4097
|
+
|
|
4098
|
+
if (route === "local") {
|
|
4099
|
+
const reason = localLlmReason({forceLocal, cloudDisabled, cloudConnected, quotaBlocked: this.llmQuotaBlocked})
|
|
4100
|
+
await this.serveLocalAi(packageName, requestId, neutralRequest, reason, null)
|
|
4101
|
+
return
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4070
4104
|
try {
|
|
4071
4105
|
// Direct when the resolved provider has a user key (BYO bill, unmetered,
|
|
4072
4106
|
// works with the cloud disabled); otherwise relay to the cloud LLM
|
|
4073
4107
|
// service, forwarding the resolved pin so platform-funded calls land on
|
|
4074
4108
|
// the same model the user chose.
|
|
4075
|
-
const directKey = resolved.provider ? userLlmKey(resolved.provider) : ""
|
|
4076
4109
|
if (resolved.provider && directKey) {
|
|
4077
4110
|
const result = await directLlmProvider(resolved.provider).complete({
|
|
4078
4111
|
...neutralRequest,
|
|
@@ -4094,18 +4127,38 @@ class LocalMiniappRuntime {
|
|
|
4094
4127
|
// is spent. Distinct code (miniapps may fall back to a local path) and
|
|
4095
4128
|
// a host hook so the app can offer the upgrade paywall — the miniapp
|
|
4096
4129
|
// itself must never own that conversation.
|
|
4097
|
-
|
|
4130
|
+
const quotaExceeded = err instanceof HttpError && (err.code === "quota_exceeded" || err.status === 402)
|
|
4131
|
+
if (quotaExceeded && !this.llmQuotaBlocked) {
|
|
4098
4132
|
console.warn(`${LOG_TAG}: ai.complete quota exceeded for ${packageName}`)
|
|
4099
4133
|
this.llmQuotaBlocked = true
|
|
4100
4134
|
this.onQuotaExceeded?.("llm")
|
|
4135
|
+
}
|
|
4136
|
+
const message = err instanceof Error ? err.message : "ai.complete failed"
|
|
4137
|
+
|
|
4138
|
+
// localFallback: the same request, on-device. The primary's error
|
|
4139
|
+
// stands only when the phone cannot serve it either.
|
|
4140
|
+
if (route === "primary_then_local") {
|
|
4141
|
+
console.warn(`${LOG_TAG}: ai.complete primary failed for ${packageName} (${message}); trying on-device`)
|
|
4142
|
+
await this.serveLocalAi(
|
|
4143
|
+
packageName,
|
|
4144
|
+
requestId,
|
|
4145
|
+
neutralRequest,
|
|
4146
|
+
quotaExceeded ? "llm_quota" : "primary_failed",
|
|
4147
|
+
{
|
|
4148
|
+
code: quotaExceeded ? MiniappErrorCode.QUOTA_EXCEEDED : MiniappErrorCode.INTERNAL,
|
|
4149
|
+
message: quotaExceeded ? QUOTA_EXCEEDED_MESSAGE : message,
|
|
4150
|
+
},
|
|
4151
|
+
)
|
|
4152
|
+
return
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
if (quotaExceeded) {
|
|
4101
4156
|
this.sendResult(packageName, requestId, false, undefined, {
|
|
4102
4157
|
code: MiniappErrorCode.QUOTA_EXCEEDED,
|
|
4103
|
-
message:
|
|
4104
|
-
"Monthly AI credit is used up. Upgrade in Settings > Membership, or add your own API key in Settings > API Keys.",
|
|
4158
|
+
message: QUOTA_EXCEEDED_MESSAGE,
|
|
4105
4159
|
})
|
|
4106
4160
|
return
|
|
4107
4161
|
}
|
|
4108
|
-
const message = err instanceof Error ? err.message : "ai.complete failed"
|
|
4109
4162
|
console.warn(`${LOG_TAG}: ai.complete failed for ${packageName}: ${message}`)
|
|
4110
4163
|
this.sendResult(packageName, requestId, false, undefined, {
|
|
4111
4164
|
code: MiniappErrorCode.INTERNAL,
|
|
@@ -4114,6 +4167,70 @@ class LocalMiniappRuntime {
|
|
|
4114
4167
|
}
|
|
4115
4168
|
}
|
|
4116
4169
|
|
|
4170
|
+
/**
|
|
4171
|
+
* Serve one ai.complete on the phone's own model (LocalLlmProvider). With
|
|
4172
|
+
* no model on disk the download starts (or continues) in the background
|
|
4173
|
+
* and the miniapp gets AI_LOCAL_UNAVAILABLE naming the state — unless
|
|
4174
|
+
* `primaryError` is given (a failed fallback), in which case the primary's
|
|
4175
|
+
* own error is what the miniapp sees.
|
|
4176
|
+
*/
|
|
4177
|
+
private async serveLocalAi(
|
|
4178
|
+
packageName: string,
|
|
4179
|
+
requestId: string | undefined,
|
|
4180
|
+
request: Record<string, unknown>,
|
|
4181
|
+
reason: LocalLlmReason,
|
|
4182
|
+
primaryError: {code: MiniappErrorCode; message: string} | null,
|
|
4183
|
+
): Promise<void> {
|
|
4184
|
+
const modelId = localLlmModelManager.selectedModelId()
|
|
4185
|
+
if (!(await localLlmModelManager.ensureSelectedModel())) {
|
|
4186
|
+
const status = localLlmModelManager.getStatus()
|
|
4187
|
+
const state =
|
|
4188
|
+
status?.modelId === modelId && status.stage === "downloading"
|
|
4189
|
+
? `is downloading (${status.percent}%)`
|
|
4190
|
+
: status?.modelId === modelId && status.stage === "failed"
|
|
4191
|
+
? `failed to download (${status.error ?? "unknown error"})`
|
|
4192
|
+
: "is not downloaded yet"
|
|
4193
|
+
console.warn(`${LOG_TAG}: ai.complete on-device for ${packageName} (${reason}): model ${modelId} ${state}`)
|
|
4194
|
+
this.sendResult(
|
|
4195
|
+
packageName,
|
|
4196
|
+
requestId,
|
|
4197
|
+
false,
|
|
4198
|
+
undefined,
|
|
4199
|
+
primaryError ?? {
|
|
4200
|
+
code: MiniappErrorCode.AI_LOCAL_UNAVAILABLE,
|
|
4201
|
+
message: `The on-device model (${localLlmModelManager.model(modelId).label}) ${state}. Manage it in Settings > AI Models.`,
|
|
4202
|
+
},
|
|
4203
|
+
)
|
|
4204
|
+
return
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
try {
|
|
4208
|
+
// webSearch is a vendor server-side grant; the phone has none.
|
|
4209
|
+
const {webSearch: _webSearch, ...localRequest} = request
|
|
4210
|
+
const result = await localLlmProvider.complete(localRequest as never)
|
|
4211
|
+
console.log(`${LOG_TAG}: ai.complete served on-device for ${packageName} (${reason}, ${result.modelId})`)
|
|
4212
|
+
this.sendResult(packageName, requestId, true, {
|
|
4213
|
+
text: result.text,
|
|
4214
|
+
stopReason: result.stopReason,
|
|
4215
|
+
...(result.toolCalls ? {toolCalls: result.toolCalls} : {}),
|
|
4216
|
+
usage: result.usage,
|
|
4217
|
+
provider: "device",
|
|
4218
|
+
model: result.modelId,
|
|
4219
|
+
billedToUserKey: false,
|
|
4220
|
+
})
|
|
4221
|
+
} catch (err) {
|
|
4222
|
+
const message = err instanceof Error ? err.message : "on-device completion failed"
|
|
4223
|
+
console.warn(`${LOG_TAG}: ai.complete on-device failed for ${packageName}: ${message}`)
|
|
4224
|
+
this.sendResult(
|
|
4225
|
+
packageName,
|
|
4226
|
+
requestId,
|
|
4227
|
+
false,
|
|
4228
|
+
undefined,
|
|
4229
|
+
primaryError ?? {code: MiniappErrorCode.AI_LOCAL_UNAVAILABLE, message},
|
|
4230
|
+
)
|
|
4231
|
+
}
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4117
4234
|
/**
|
|
4118
4235
|
* ai.generate — agentic visual generation. Always relayed to the cloud
|
|
4119
4236
|
* runtime (the server drives the provider's agentic loop; there is no
|
|
@@ -5318,13 +5435,12 @@ class LocalMiniappRuntime {
|
|
|
5318
5435
|
this.capabilitiesWired = true
|
|
5319
5436
|
const broadcast = () => this.broadcastCapabilities()
|
|
5320
5437
|
// Connection, model and firmware also decide the dashboard view's eligibility;
|
|
5321
|
-
//
|
|
5438
|
+
// connected controllers add their input surfaces to `button`.
|
|
5322
5439
|
useGlassesStore.subscribe(
|
|
5323
5440
|
(s) =>
|
|
5324
|
-
`${s.sceneCanvasWidth ?? 0}x${s.sceneCanvasHeight ?? 0}:${s.sceneVector === true}:${s.firmwareVersion}:${s.connection.state}:${s.deviceModel}:${s.
|
|
5441
|
+
`${s.sceneCanvasWidth ?? 0}x${s.sceneCanvasHeight ?? 0}:${s.sceneVector === true}:${s.firmwareVersion}:${s.connection.state}:${s.deviceModel}:${readyControllersKey(s.controllers)}`,
|
|
5325
5442
|
broadcast,
|
|
5326
5443
|
)
|
|
5327
|
-
useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.default_controller.key), broadcast)
|
|
5328
5444
|
// The debug knob flips hasNativeNavigation on custom firmware.
|
|
5329
5445
|
useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.cfw_native_navigation.key), broadcast)
|
|
5330
5446
|
// The dashboard master switch flips the dashboard view.
|