@glassly/engine 0.3.3-dev.670 → 0.3.4-beta.685
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 +14 -3
- package/build/services/GestureDotsService.d.ts.map +1 -1
- package/build/services/GestureDotsService.js +48 -13
- 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 +218 -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 +29 -3
- 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 +52 -15
- 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 +290 -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 +29 -3
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalLlmModelManager — the on-device LLM model files: a catalog of
|
|
3
|
+
* ExecuTorch exports (Software Mansion's Qwen3 .pte builds on Hugging Face),
|
|
4
|
+
* their download into Documents/llm_models/<id>/, availability checks, and a
|
|
5
|
+
* status stream the Settings > AI Models screen renders. Modelled on
|
|
6
|
+
* STTModelManager; the download itself is RNFS so the Settings screen can
|
|
7
|
+
* list, cancel, and delete models with the same calls the STT rows use.
|
|
8
|
+
*
|
|
9
|
+
* Which model is "the" on-device model is the `local_llm_model` setting
|
|
10
|
+
* (Settings > AI Models). Downloads are on demand: the first request that
|
|
11
|
+
* routes on-device without a model present starts one (ensureSelectedModel),
|
|
12
|
+
* and the Settings screen can start one explicitly.
|
|
13
|
+
*/
|
|
14
|
+
import {Platform} from "react-native"
|
|
15
|
+
import * as RNFS from "@dr.pogodin/react-native-fs"
|
|
16
|
+
|
|
17
|
+
import {SETTINGS, useSettingsStore} from "../stores/settings"
|
|
18
|
+
|
|
19
|
+
export type LocalLlmModelId = "qwen3-0.6b" | "qwen3-1.7b" | "qwen3-4b"
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_LOCAL_LLM_MODEL: LocalLlmModelId = "qwen3-1.7b"
|
|
22
|
+
|
|
23
|
+
export interface LocalLlmModelConfig {
|
|
24
|
+
id: LocalLlmModelId
|
|
25
|
+
/** Human label for the Settings dropdown. */
|
|
26
|
+
label: string
|
|
27
|
+
/** Approximate download size in bytes (the .pte dominates). */
|
|
28
|
+
sizeBytes: number
|
|
29
|
+
/** Rough resident memory while loaded; the Settings copy warns above ~half of device RAM. */
|
|
30
|
+
ramBytes: number
|
|
31
|
+
urls: {
|
|
32
|
+
model: string
|
|
33
|
+
tokenizer: string
|
|
34
|
+
tokenizerConfig: string
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The three files every model directory holds; names are fixed so paths never depend on the URL. */
|
|
39
|
+
export interface LocalLlmModelPaths {
|
|
40
|
+
model: string
|
|
41
|
+
tokenizer: string
|
|
42
|
+
tokenizerConfig: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type LocalLlmDownloadStage = "idle" | "downloading" | "complete" | "failed"
|
|
46
|
+
|
|
47
|
+
export interface LocalLlmDownloadStatus {
|
|
48
|
+
modelId: LocalLlmModelId
|
|
49
|
+
stage: LocalLlmDownloadStage
|
|
50
|
+
/** 0–100, meaningful while stage is "downloading". */
|
|
51
|
+
percent: number
|
|
52
|
+
error?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// XNNPACK 8da4w exports from software-mansion/react-native-executorch-qwen-3,
|
|
56
|
+
// pinned to the v0.10.0 release tag that react-native-executorch 0.10.x
|
|
57
|
+
// expects. Sizes are the .pte file sizes on the Hub (tokenizer adds ~11 MB).
|
|
58
|
+
const QWEN3_BASE = "https://huggingface.co/software-mansion/react-native-executorch-qwen-3/resolve/v0.10.0"
|
|
59
|
+
const MB = 1024 * 1024
|
|
60
|
+
const GB = 1024 * MB
|
|
61
|
+
|
|
62
|
+
const MODELS: Record<LocalLlmModelId, LocalLlmModelConfig> = {
|
|
63
|
+
"qwen3-0.6b": {
|
|
64
|
+
id: "qwen3-0.6b",
|
|
65
|
+
label: "Qwen3 0.6B",
|
|
66
|
+
sizeBytes: 517 * MB,
|
|
67
|
+
ramBytes: 0.9 * GB,
|
|
68
|
+
urls: {
|
|
69
|
+
model: `${QWEN3_BASE}/0_6b/xnnpack/qwen_3_0_6b_xnnpack_8da4w.pte`,
|
|
70
|
+
tokenizer: `${QWEN3_BASE}/tokenizer.json`,
|
|
71
|
+
tokenizerConfig: `${QWEN3_BASE}/tokenizer_config.json`,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
"qwen3-1.7b": {
|
|
75
|
+
id: "qwen3-1.7b",
|
|
76
|
+
label: "Qwen3 1.7B",
|
|
77
|
+
sizeBytes: 1315 * MB,
|
|
78
|
+
ramBytes: 1.8 * GB,
|
|
79
|
+
urls: {
|
|
80
|
+
model: `${QWEN3_BASE}/1_7b/xnnpack/qwen_3_1_7b_xnnpack_8da4w.pte`,
|
|
81
|
+
tokenizer: `${QWEN3_BASE}/tokenizer.json`,
|
|
82
|
+
tokenizerConfig: `${QWEN3_BASE}/tokenizer_config.json`,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
"qwen3-4b": {
|
|
86
|
+
id: "qwen3-4b",
|
|
87
|
+
label: "Qwen3 4B",
|
|
88
|
+
sizeBytes: 2693 * MB,
|
|
89
|
+
ramBytes: 3.4 * GB,
|
|
90
|
+
urls: {
|
|
91
|
+
model: `${QWEN3_BASE}/4b/xnnpack/qwen_3_4b_xnnpack_8da4w.pte`,
|
|
92
|
+
tokenizer: `${QWEN3_BASE}/tokenizer.json`,
|
|
93
|
+
tokenizerConfig: `${QWEN3_BASE}/tokenizer_config.json`,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isLocalLlmModelId(value: unknown): value is LocalLlmModelId {
|
|
99
|
+
return typeof value === "string" && value in MODELS
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type StatusListener = (status: LocalLlmDownloadStatus | null) => void
|
|
103
|
+
|
|
104
|
+
class LocalLlmModelManager {
|
|
105
|
+
private static instance: LocalLlmModelManager
|
|
106
|
+
|
|
107
|
+
private downloadJobId?: number
|
|
108
|
+
private inFlight: {modelId: LocalLlmModelId; promise: Promise<void>} | null = null
|
|
109
|
+
private cancelled = false
|
|
110
|
+
private currentStatus: LocalLlmDownloadStatus | null = null
|
|
111
|
+
private listeners = new Set<StatusListener>()
|
|
112
|
+
private modelReadyListeners = new Set<(modelId: LocalLlmModelId) => void>()
|
|
113
|
+
|
|
114
|
+
private constructor() {}
|
|
115
|
+
|
|
116
|
+
static getInstance(): LocalLlmModelManager {
|
|
117
|
+
if (!LocalLlmModelManager.instance) {
|
|
118
|
+
LocalLlmModelManager.instance = new LocalLlmModelManager()
|
|
119
|
+
}
|
|
120
|
+
return LocalLlmModelManager.instance
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
models(): LocalLlmModelConfig[] {
|
|
124
|
+
return Object.values(MODELS)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
model(id: LocalLlmModelId): LocalLlmModelConfig {
|
|
128
|
+
return MODELS[id]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The Settings > AI Models pick; garbage in the store resolves to the default. */
|
|
132
|
+
selectedModelId(): LocalLlmModelId {
|
|
133
|
+
const raw = useSettingsStore.getState().getSetting(SETTINGS.local_llm_model.key)
|
|
134
|
+
return isLocalLlmModelId(raw) ? raw : DEFAULT_LOCAL_LLM_MODEL
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async select(id: LocalLlmModelId): Promise<void> {
|
|
138
|
+
await useSettingsStore.getState().setSetting(SETTINGS.local_llm_model.key, id)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getModelDirectory(): string {
|
|
142
|
+
return `${RNFS.DocumentDirectoryPath}/llm_models`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
paths(id: LocalLlmModelId = this.selectedModelId()): LocalLlmModelPaths {
|
|
146
|
+
const dir = `${this.getModelDirectory()}/${id}`
|
|
147
|
+
return {
|
|
148
|
+
model: `${dir}/model.pte`,
|
|
149
|
+
tokenizer: `${dir}/tokenizer.json`,
|
|
150
|
+
tokenizerConfig: `${dir}/tokenizer_config.json`,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async isModelAvailable(id: LocalLlmModelId = this.selectedModelId()): Promise<boolean> {
|
|
155
|
+
const paths = this.paths(id)
|
|
156
|
+
try {
|
|
157
|
+
for (const path of [paths.tokenizerConfig, paths.tokenizer, paths.model]) {
|
|
158
|
+
if (!(await RNFS.exists(path))) return false
|
|
159
|
+
}
|
|
160
|
+
// A truncated .pte loads as garbage or crashes the runtime: insist on
|
|
161
|
+
// most of the published size. Downloads land in a .part file and are
|
|
162
|
+
// renamed only once whole, so this only catches external tampering.
|
|
163
|
+
const stat = await RNFS.stat(paths.model)
|
|
164
|
+
return Number(stat.size) >= MODELS[id].sizeBytes * 0.95
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.warn("LOCAL_LLM: availability check failed:", error)
|
|
167
|
+
return false
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async downloadedModelIds(): Promise<LocalLlmModelId[]> {
|
|
172
|
+
const ids: LocalLlmModelId[] = []
|
|
173
|
+
for (const id of Object.keys(MODELS) as LocalLlmModelId[]) {
|
|
174
|
+
if (await this.isModelAvailable(id)) ids.push(id)
|
|
175
|
+
}
|
|
176
|
+
return ids
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Subscribe to download status changes. Fires immediately with the current status. */
|
|
180
|
+
subscribe(listener: StatusListener): () => void {
|
|
181
|
+
this.listeners.add(listener)
|
|
182
|
+
listener(this.currentStatus)
|
|
183
|
+
return () => {
|
|
184
|
+
this.listeners.delete(listener)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
getStatus(): LocalLlmDownloadStatus | null {
|
|
189
|
+
return this.currentStatus
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Fired once a model's files are complete on disk. */
|
|
193
|
+
onModelReady(listener: (modelId: LocalLlmModelId) => void): () => void {
|
|
194
|
+
this.modelReadyListeners.add(listener)
|
|
195
|
+
return () => this.modelReadyListeners.delete(listener)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* True when the selected model is on disk. Otherwise starts (or joins) its
|
|
200
|
+
* download in the background and returns false — the caller reports
|
|
201
|
+
* "downloading" rather than waiting out a multi-GB transfer.
|
|
202
|
+
*/
|
|
203
|
+
async ensureSelectedModel(): Promise<boolean> {
|
|
204
|
+
const id = this.selectedModelId()
|
|
205
|
+
if (await this.isModelAvailable(id)) return true
|
|
206
|
+
void this.downloadModel(id).catch((error) => {
|
|
207
|
+
console.warn(`LOCAL_LLM: background download of ${id} failed:`, error)
|
|
208
|
+
})
|
|
209
|
+
return false
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Download one model (three files, model last). Concurrent calls for the
|
|
214
|
+
* same model share the transfer; a call for a different model waits for
|
|
215
|
+
* the running one — the RNFS job slot is singular.
|
|
216
|
+
*/
|
|
217
|
+
async downloadModel(id: LocalLlmModelId = this.selectedModelId()): Promise<void> {
|
|
218
|
+
if (this.inFlight) {
|
|
219
|
+
if (this.inFlight.modelId === id) return this.inFlight.promise
|
|
220
|
+
await this.inFlight.promise.catch(() => undefined)
|
|
221
|
+
}
|
|
222
|
+
const promise = this.downloadInner(id).finally(() => {
|
|
223
|
+
this.inFlight = null
|
|
224
|
+
})
|
|
225
|
+
this.inFlight = {modelId: id, promise}
|
|
226
|
+
return promise
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private async downloadInner(id: LocalLlmModelId): Promise<void> {
|
|
230
|
+
const config = MODELS[id]
|
|
231
|
+
const paths = this.paths(id)
|
|
232
|
+
const dir = `${this.getModelDirectory()}/${id}`
|
|
233
|
+
this.cancelled = false
|
|
234
|
+
this.setStatus({modelId: id, stage: "downloading", percent: 0})
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
await RNFS.mkdir(dir, {NSURLIsExcludedFromBackupKey: true})
|
|
238
|
+
// Tokenizer files first (small), the weights last: a model directory
|
|
239
|
+
// is only "available" once model.pte exists, so an interrupted run
|
|
240
|
+
// never leaves a complete-looking directory behind.
|
|
241
|
+
await this.fetchFile(config.urls.tokenizerConfig, paths.tokenizerConfig)
|
|
242
|
+
await this.fetchFile(config.urls.tokenizer, paths.tokenizer)
|
|
243
|
+
await this.fetchFile(config.urls.model, paths.model, (percent) =>
|
|
244
|
+
this.setStatus({modelId: id, stage: "downloading", percent}),
|
|
245
|
+
)
|
|
246
|
+
this.setStatus({modelId: id, stage: "complete", percent: 100})
|
|
247
|
+
for (const listener of this.modelReadyListeners) {
|
|
248
|
+
try {
|
|
249
|
+
listener(id)
|
|
250
|
+
} catch (error) {
|
|
251
|
+
console.warn("LOCAL_LLM: model-ready listener failed:", error)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch (error) {
|
|
255
|
+
const message = this.cancelled ? "download cancelled" : error instanceof Error ? error.message : String(error)
|
|
256
|
+
this.setStatus({modelId: id, stage: this.cancelled ? "idle" : "failed", percent: 0, error: message})
|
|
257
|
+
try {
|
|
258
|
+
if (await RNFS.exists(`${paths.model}.part`)) await RNFS.unlink(`${paths.model}.part`)
|
|
259
|
+
} catch (cleanupError) {
|
|
260
|
+
console.warn("LOCAL_LLM: partial cleanup failed:", cleanupError)
|
|
261
|
+
}
|
|
262
|
+
throw error
|
|
263
|
+
} finally {
|
|
264
|
+
this.downloadJobId = undefined
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private async fetchFile(url: string, dest: string, onPercent?: (percent: number) => void): Promise<void> {
|
|
269
|
+
if (this.cancelled) throw new Error("download cancelled")
|
|
270
|
+
const partial = `${dest}.part`
|
|
271
|
+
const download = RNFS.downloadFile({
|
|
272
|
+
fromUrl: url,
|
|
273
|
+
toFile: partial,
|
|
274
|
+
// iOS keeps a background URLSession transfer going while the app is
|
|
275
|
+
// suspended; the files here are large enough to need it.
|
|
276
|
+
background: Platform.OS === "ios",
|
|
277
|
+
discretionary: false,
|
|
278
|
+
progressDivider: 1,
|
|
279
|
+
connectionTimeout: 30_000,
|
|
280
|
+
readTimeout: 60_000,
|
|
281
|
+
progress: (res) => {
|
|
282
|
+
if (!onPercent || res.contentLength <= 0) return
|
|
283
|
+
onPercent(Math.min(99, Math.floor((res.bytesWritten / res.contentLength) * 100)))
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
this.downloadJobId = download.jobId
|
|
287
|
+
const result = await download.promise
|
|
288
|
+
if (result.statusCode !== 200) {
|
|
289
|
+
throw new Error(`download failed with status ${result.statusCode}`)
|
|
290
|
+
}
|
|
291
|
+
if (await RNFS.exists(dest)) await RNFS.unlink(dest)
|
|
292
|
+
await RNFS.moveFile(partial, dest)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async cancelDownload(): Promise<void> {
|
|
296
|
+
if (this.downloadJobId === undefined) return
|
|
297
|
+
this.cancelled = true
|
|
298
|
+
await RNFS.stopDownload(this.downloadJobId)
|
|
299
|
+
this.downloadJobId = undefined
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async deleteModel(id: LocalLlmModelId = this.selectedModelId()): Promise<void> {
|
|
303
|
+
const dir = `${this.getModelDirectory()}/${id}`
|
|
304
|
+
if (await RNFS.exists(dir)) await RNFS.unlink(dir)
|
|
305
|
+
if (this.currentStatus?.modelId === id) this.setStatus(null)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private setStatus(status: LocalLlmDownloadStatus | null): void {
|
|
309
|
+
this.currentStatus = status
|
|
310
|
+
for (const listener of this.listeners) {
|
|
311
|
+
try {
|
|
312
|
+
listener(status)
|
|
313
|
+
} catch (error) {
|
|
314
|
+
console.warn("LOCAL_LLM: status listener threw:", error)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
static formatBytes(bytes: number): string {
|
|
320
|
+
if (bytes >= GB) return `${(bytes / GB).toFixed(1)} GB`
|
|
321
|
+
return `${Math.round(bytes / MB)} MB`
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const instance = LocalLlmModelManager.getInstance()
|
|
326
|
+
export {LocalLlmModelManager}
|
|
327
|
+
export default instance
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalLlmPrompt — the pure half of the on-device LLM path: turning a neutral
|
|
3
|
+
* session.ai request into chat-template messages, fitting them into the
|
|
4
|
+
* model's context, and reading the model's text back into the neutral result
|
|
5
|
+
* (tool calls, JSON mode, stop reason). No native imports, so it is
|
|
6
|
+
* bun-testable; LocalLlmProvider owns the runner.
|
|
7
|
+
*
|
|
8
|
+
* Written against Qwen3's chat template: tools are advertised by the
|
|
9
|
+
* template itself, calls come back as `<tool_call>{json}</tool_call>`, and
|
|
10
|
+
* thinking is a `<think>…</think>` block we never ask for but still strip.
|
|
11
|
+
*/
|
|
12
|
+
import {jsonrepair} from "jsonrepair"
|
|
13
|
+
|
|
14
|
+
import type {LlmMessage, LlmStopReason, LlmTool, LlmToolCall} from "@glassly/cloud-protocol/llm"
|
|
15
|
+
|
|
16
|
+
export interface TemplateToolCall {
|
|
17
|
+
id: string
|
|
18
|
+
type: "function"
|
|
19
|
+
function: {name: string; arguments: Record<string, unknown>}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type TemplateMessage =
|
|
23
|
+
| {role: "system" | "user"; content: string}
|
|
24
|
+
| {role: "assistant"; content: string; tool_calls?: TemplateToolCall[]}
|
|
25
|
+
| {role: "tool"; content: string; name?: string; tool_call_id?: string}
|
|
26
|
+
|
|
27
|
+
export interface TemplateTool {
|
|
28
|
+
type: "function"
|
|
29
|
+
function: {name: string; description: string; parameters: Record<string, unknown>}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Appended to the system prompt under `jsonMode`; small models need it said twice. */
|
|
33
|
+
export const JSON_MODE_INSTRUCTION =
|
|
34
|
+
"Respond with exactly one JSON object and nothing else: no prose before or after it, no markdown fences."
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Neutral messages → template messages. A user turn carrying toolResults
|
|
38
|
+
* becomes one `tool` message per result (the template groups consecutive
|
|
39
|
+
* tool messages into a single user block), followed by the user text if any.
|
|
40
|
+
*/
|
|
41
|
+
export function buildTemplateMessages(request: {
|
|
42
|
+
messages: LlmMessage[]
|
|
43
|
+
system?: string
|
|
44
|
+
jsonMode?: boolean
|
|
45
|
+
}): TemplateMessage[] {
|
|
46
|
+
const out: TemplateMessage[] = []
|
|
47
|
+
const systemParts = [request.system?.trim(), request.jsonMode ? JSON_MODE_INSTRUCTION : undefined].filter(
|
|
48
|
+
(part): part is string => Boolean(part),
|
|
49
|
+
)
|
|
50
|
+
if (systemParts.length) out.push({role: "system", content: systemParts.join("\n\n")})
|
|
51
|
+
|
|
52
|
+
for (const message of request.messages) {
|
|
53
|
+
if (message.role === "assistant") {
|
|
54
|
+
const toolCalls = message.toolCalls?.map<TemplateToolCall>((call) => ({
|
|
55
|
+
id: call.id,
|
|
56
|
+
type: "function",
|
|
57
|
+
function: {name: call.name, arguments: call.input},
|
|
58
|
+
}))
|
|
59
|
+
out.push(
|
|
60
|
+
toolCalls?.length
|
|
61
|
+
? {role: "assistant", content: message.content, tool_calls: toolCalls}
|
|
62
|
+
: {role: "assistant", content: message.content},
|
|
63
|
+
)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
for (const result of message.toolResults ?? []) {
|
|
67
|
+
out.push({
|
|
68
|
+
role: "tool",
|
|
69
|
+
tool_call_id: result.toolCallId,
|
|
70
|
+
content: result.isError ? `Error: ${result.content}` : result.content,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
if (message.content.trim().length > 0 || !message.toolResults?.length) {
|
|
74
|
+
out.push({role: "user", content: message.content})
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function toTemplateTools(tools?: LlmTool[]): TemplateTool[] | undefined {
|
|
81
|
+
if (!tools?.length) return undefined
|
|
82
|
+
return tools.map((tool) => ({
|
|
83
|
+
type: "function",
|
|
84
|
+
function: {name: tool.name, description: tool.description, parameters: tool.inputSchema},
|
|
85
|
+
}))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Rough tokens-per-character for a BPE vocabulary over mixed prose/JSON. */
|
|
89
|
+
const CHARS_PER_TOKEN = 3.2
|
|
90
|
+
/** Template framing per message (`<|im_start|>role … <|im_end|>`). */
|
|
91
|
+
const TOKENS_PER_MESSAGE = 6
|
|
92
|
+
|
|
93
|
+
export function estimateTokens(text: string): number {
|
|
94
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function estimateMessageTokens(message: TemplateMessage): number {
|
|
98
|
+
let total = TOKENS_PER_MESSAGE + estimateTokens(message.content)
|
|
99
|
+
if (message.role === "assistant" && message.tool_calls) {
|
|
100
|
+
for (const call of message.tool_calls) total += estimateTokens(JSON.stringify(call.function)) + 8
|
|
101
|
+
}
|
|
102
|
+
return total
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const MIN_TOOL_CONTENT_CHARS = 1_200
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Fit the conversation into `budgetTokens`. History is grouped into turns
|
|
109
|
+
* (a user message plus everything up to the next user message, so a tool
|
|
110
|
+
* round stays with the question it answers); the oldest turns go first and
|
|
111
|
+
* the last turn is never dropped — the model must at least see the question.
|
|
112
|
+
* When that last turn alone still does not fit, tool outputs are cut to
|
|
113
|
+
* their head.
|
|
114
|
+
*/
|
|
115
|
+
export function fitToBudget(messages: TemplateMessage[], budgetTokens: number, toolTokens = 0): TemplateMessage[] {
|
|
116
|
+
const total = (list: TemplateMessage[]) => list.reduce((sum, m) => sum + estimateMessageTokens(m), toolTokens)
|
|
117
|
+
const system = messages[0]?.role === "system" ? [messages[0]] : []
|
|
118
|
+
const turns: TemplateMessage[][] = []
|
|
119
|
+
for (const message of messages.slice(system.length)) {
|
|
120
|
+
if (message.role === "user" || turns.length === 0) turns.push([message])
|
|
121
|
+
else turns[turns.length - 1]!.push(message)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
while (turns.length > 1 && total([...system, ...turns.flat()]) > budgetTokens) {
|
|
125
|
+
turns.shift()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let fitted = [...system, ...turns.flat()]
|
|
129
|
+
if (total(fitted) > budgetTokens) {
|
|
130
|
+
fitted = fitted.map((m) =>
|
|
131
|
+
m.role === "tool" && m.content.length > MIN_TOOL_CONTENT_CHARS
|
|
132
|
+
? {...m, content: `${m.content.slice(0, MIN_TOOL_CONTENT_CHARS)}\n…[truncated]`}
|
|
133
|
+
: m,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
return fitted
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Remove a leading `<think>…</think>` block (or an unterminated one). */
|
|
140
|
+
export function stripThinking(text: string): string {
|
|
141
|
+
const trimmed = text.replace(/^\s*<think>[\s\S]*?<\/think>\s*/u, "")
|
|
142
|
+
if (trimmed !== text) return trimmed
|
|
143
|
+
return text.replace(/^\s*<think>[\s\S]*$/u, "").trimStart()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const TOOL_CALL_RE = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/gu
|
|
147
|
+
|
|
148
|
+
/** Pull `<tool_call>` blocks out of the reply. Unparseable blocks are dropped, not surfaced. */
|
|
149
|
+
export function parseToolCalls(
|
|
150
|
+
text: string,
|
|
151
|
+
makeId: (index: number) => string,
|
|
152
|
+
): {toolCalls: LlmToolCall[]; text: string} {
|
|
153
|
+
const toolCalls: LlmToolCall[] = []
|
|
154
|
+
let index = 0
|
|
155
|
+
const remaining = text
|
|
156
|
+
.replace(TOOL_CALL_RE, (_match, body: string) => {
|
|
157
|
+
const parsed = parseJsonLoose(body)
|
|
158
|
+
if (parsed && typeof parsed === "object" && typeof (parsed as {name?: unknown}).name === "string") {
|
|
159
|
+
const {name, arguments: args} = parsed as {name: string; arguments?: unknown}
|
|
160
|
+
toolCalls.push({
|
|
161
|
+
id: makeId(index++),
|
|
162
|
+
name,
|
|
163
|
+
input: args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {},
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
return ""
|
|
167
|
+
})
|
|
168
|
+
.trim()
|
|
169
|
+
return {toolCalls, text: remaining}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseJsonLoose(body: string): unknown {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(body)
|
|
175
|
+
} catch {
|
|
176
|
+
try {
|
|
177
|
+
return JSON.parse(jsonrepair(body))
|
|
178
|
+
} catch {
|
|
179
|
+
return undefined
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The first JSON object in the reply, repaired if the model left it slightly
|
|
186
|
+
* broken (trailing commas, single quotes, a dropped brace). Null when there
|
|
187
|
+
* is nothing object-shaped: the caller keeps the raw text and the miniapp's
|
|
188
|
+
* own parser decides.
|
|
189
|
+
*/
|
|
190
|
+
export function extractJsonObject(text: string): string | null {
|
|
191
|
+
const unfenced = text.replace(/```(?:json)?/giu, "").trim()
|
|
192
|
+
const start = unfenced.indexOf("{")
|
|
193
|
+
if (start < 0) return null
|
|
194
|
+
const end = unfenced.lastIndexOf("}")
|
|
195
|
+
const candidate = end > start ? unfenced.slice(start, end + 1) : unfenced.slice(start)
|
|
196
|
+
try {
|
|
197
|
+
JSON.parse(candidate)
|
|
198
|
+
return candidate
|
|
199
|
+
} catch {
|
|
200
|
+
try {
|
|
201
|
+
const repaired = jsonrepair(candidate)
|
|
202
|
+
JSON.parse(repaired)
|
|
203
|
+
return repaired
|
|
204
|
+
} catch {
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface LocalGeneration {
|
|
211
|
+
text: string
|
|
212
|
+
promptTokens: number
|
|
213
|
+
generatedTokens: number
|
|
214
|
+
maxNewTokens: number
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface LocalCompletion {
|
|
218
|
+
text: string
|
|
219
|
+
stopReason: LlmStopReason
|
|
220
|
+
toolCalls?: LlmToolCall[]
|
|
221
|
+
usage: {inputTokens: number; outputTokens: number}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Raw generation → neutral completion. */
|
|
225
|
+
export function finalizeLocalResult(
|
|
226
|
+
generation: LocalGeneration,
|
|
227
|
+
opts: {jsonMode: boolean; toolsOffered: boolean; makeToolCallId: (index: number) => string},
|
|
228
|
+
): LocalCompletion {
|
|
229
|
+
const usage = {inputTokens: generation.promptTokens, outputTokens: generation.generatedTokens}
|
|
230
|
+
let text = stripThinking(generation.text)
|
|
231
|
+
|
|
232
|
+
if (opts.toolsOffered) {
|
|
233
|
+
const parsed = parseToolCalls(text, opts.makeToolCallId)
|
|
234
|
+
if (parsed.toolCalls.length) {
|
|
235
|
+
return {text: parsed.text, stopReason: "tool_use", toolCalls: parsed.toolCalls, usage}
|
|
236
|
+
}
|
|
237
|
+
text = parsed.text
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (opts.jsonMode) {
|
|
241
|
+
text = extractJsonObject(text) ?? text
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const stopReason: LlmStopReason = generation.generatedTokens >= generation.maxNewTokens ? "max_tokens" : "end_turn"
|
|
245
|
+
return {text: text.trim(), stopReason, usage}
|
|
246
|
+
}
|