Spaces:
Sleeping
Sleeping
| // Capacitor plugin facade for the Cactus on-device inference SDK. | |
| // The Kotlin-side plugin (CactusPlugin.kt) is wired in Saturday H4 per the plan. | |
| // This JS side can be imported safely in a browser / PWA build — it just | |
| // returns { available: false } when the plugin isn't registered. | |
| import { Capacitor, registerPlugin } from '@capacitor/core' | |
| // registerPlugin returns a proxy that forwards method calls to the native | |
| // implementation if available. On web, all methods reject with UNIMPLEMENTED. | |
| const CactusNative = registerPlugin('Cactus') | |
| let _handle = null | |
| let _initPromise = null | |
| // Browser-mode simulator state. None of these are touched on Android — the | |
| // native plugin owns model state there. | |
| const isBrowserSim = () => Capacitor.getPlatform() !== 'android' | |
| let _simHasModel = false | |
| let _simLoaded = false | |
| /** | |
| * Quick availability check. Returns immediately without touching native code | |
| * on platforms where the plugin isn't registered. | |
| */ | |
| export async function isAvailable() { | |
| if (isBrowserSim()) { | |
| return { | |
| available: true, | |
| handle: _simLoaded ? 9999 : 0, | |
| modelPath: _simHasModel ? '/sim/files/models/gemma-4-e2b-it-int4' : '', | |
| modelPresent: _simHasModel, | |
| modelFound: _simHasModel ? '/sim/files/models/gemma-4-e2b-it-int4' : undefined, | |
| loaded: _simLoaded, | |
| simulated: true, | |
| } | |
| } | |
| try { | |
| const res = await CactusNative.isAvailable() | |
| return { available: true, ...res } | |
| } catch (err) { | |
| return { available: false, reason: 'plugin-not-registered', error: String(err) } | |
| } | |
| } | |
| /** | |
| * Lazy init — reuses handle across calls. | |
| * @param {{ modelPath?: string; contextSize?: number }} opts | |
| */ | |
| export async function init(opts = {}) { | |
| if (isBrowserSim()) { | |
| if (!_simHasModel) throw new Error('No model file found. Run Import model first (simulator).') | |
| if (_simLoaded) return { handle: 9999, cached: true, modelPath: '/sim/files/models/gemma-4-e2b-it-int4' } | |
| await sleep(900) // pretend Cactus loaded ~1 s | |
| _simLoaded = true | |
| return { handle: 9999, cached: false, modelPath: '/sim/files/models/gemma-4-e2b-it-int4', initMs: 900 } | |
| } | |
| if (_handle != null) return { handle: _handle, cached: true } | |
| if (_initPromise) return _initPromise | |
| _initPromise = CactusNative.init(opts).then( | |
| (res) => { | |
| _handle = res.handle | |
| _initPromise = null | |
| return res | |
| }, | |
| (err) => { | |
| _initPromise = null | |
| throw err | |
| } | |
| ) | |
| return _initPromise | |
| } | |
| /** | |
| * Run text completion. All Cactus I/O is JSON strings at the C level; | |
| * the Kotlin plugin takes structured inputs and serializes them before | |
| * calling the native bridge. | |
| * | |
| * @param {{ | |
| * messages: Array<{role: string, content: string}>, | |
| * tools?: object[], | |
| * options?: { max_tokens?: number, temperature?: number, top_p?: number } | |
| * }} req | |
| * @returns {Promise<{ text: string, toolCalls?: object[], tokensPerSec?: number, elapsedMs?: number }>} | |
| */ | |
| export async function complete(req) { | |
| if (isBrowserSim()) { | |
| if (!_simLoaded) throw new Error('model not initialized — call init() first') | |
| await sleep(600) | |
| // Echo a canned Hindi response so Test Hindi shows something visible. | |
| const userMsg = (req?.messages || []).filter((m) => m.role === 'user').slice(-1)[0]?.content || '' | |
| const reply = `[simulator] नमस्ते! आप कैसे हैं? (echo of: ${userMsg.slice(0, 40)}${userMsg.length > 40 ? '…' : ''})` | |
| return { | |
| text: reply, | |
| raw: JSON.stringify({ response: reply, success: true, decode_tps: 4.7, prefill_tps: 12.0 }), | |
| elapsedMs: 600, | |
| decodeTps: 4.7, | |
| prefillTps: 12.0, | |
| success: true, | |
| } | |
| } | |
| if (_handle == null) { | |
| await init() | |
| } | |
| return CactusNative.complete(req) | |
| } | |
| /** | |
| * Free the loaded model. Call on app pause to release phone RAM. | |
| */ | |
| export async function destroy() { | |
| if (isBrowserSim()) { | |
| _simLoaded = false | |
| return | |
| } | |
| if (_handle == null) return | |
| try { | |
| await CactusNative.destroy() | |
| } finally { | |
| _handle = null | |
| } | |
| } | |
| /** | |
| * Launch the system file picker (SAF) so the user can choose a locally | |
| * downloaded Cactus model zip (Downloads folder, USB OTG, etc). | |
| * The plugin extracts the zip into app-private storage; afterwards | |
| * init() will see the new model folder. The zip should be on local | |
| * storage, not streamed from a cloud content provider — a 4 GB+ stream | |
| * over LTE is fragile. | |
| * | |
| * Progress callback fires at scan-complete, every 10% bucket during | |
| * extraction, and at done. Event shape: | |
| * { phase: 'scanning_done', totalEntries } | |
| * { phase: 'extracting', entries, totalEntries, bytes, pct } | |
| * { phase: 'done', entries, totalEntries, bytes, pct: 100 } | |
| * | |
| * @param {(evt: object) => void} [onProgress] | |
| * @returns {Promise<{ | |
| * cancelled?: true, | |
| * modelName?: string, | |
| * modelPath?: string, | |
| * entries?: number, | |
| * bytes?: number | |
| * }>} | |
| */ | |
| export async function importModelFromZip(onProgress) { | |
| // Browser simulator: when there's no native plugin (Vite dev, desktop browser), | |
| // fake the SAF picker + extraction so the UI wiring (progress bar, log card, | |
| // listener subscribe/unsubscribe) can be exercised end-to-end without an APK | |
| // rebuild. Set localStorage.sakhi_sim_cancel = '1' to test the cancel path. | |
| if (Capacitor.getPlatform() !== 'android') { | |
| return simulateImport(onProgress) | |
| } | |
| let listener = null | |
| if (typeof onProgress === 'function') { | |
| listener = await CactusNative.addListener('importProgress', onProgress) | |
| } | |
| try { | |
| return await CactusNative.importModelFromZip() | |
| } finally { | |
| try { listener?.remove?.() } catch (_) {} | |
| } | |
| } | |
| /** | |
| * Pretend we picked a 4.68 GB zip and extracted 1963 files over ~5 s | |
| * (compressed from ~5 min on real hardware). Lets the desktop browser | |
| * exercise the full UI without an APK round-trip. | |
| */ | |
| async function simulateImport(onProgress) { | |
| const cancelled = typeof localStorage !== 'undefined' && localStorage.getItem('sakhi_sim_cancel') === '1' | |
| if (cancelled) return { cancelled: true } | |
| const TOTAL_ENTRIES = 1963 | |
| const TOTAL_BYTES = 4679429616 | |
| await sleep(150) // SAF picker open | |
| if (typeof onProgress === 'function') { | |
| onProgress({ phase: 'scanning_done', totalBytes: TOTAL_BYTES }) | |
| } | |
| // 100 events at ~50 ms each = 5 s total. Matches the Kotlin path's 1% | |
| // bucket cadence so the bar renders identically in browser vs phone. | |
| for (let pct = 1; pct <= 99; pct++) { | |
| await sleep(50) | |
| const entries = Math.round((TOTAL_ENTRIES * pct) / 100) | |
| const bytes = Math.round((TOTAL_BYTES * pct) / 100) | |
| if (typeof onProgress === 'function') { | |
| onProgress({ phase: 'extracting', entries, bytes, totalBytes: TOTAL_BYTES, pct }) | |
| } | |
| } | |
| if (typeof onProgress === 'function') { | |
| onProgress({ phase: 'done', entries: TOTAL_ENTRIES, bytes: TOTAL_BYTES, totalBytes: TOTAL_BYTES, pct: 100 }) | |
| } | |
| _simHasModel = true | |
| return { | |
| modelName: 'gemma-4-e2b-it-int4', | |
| modelPath: '/sim/files/models/gemma-4-e2b-it-int4', | |
| entries: TOTAL_ENTRIES, | |
| bytes: TOTAL_BYTES, | |
| } | |
| } | |
| function sleep(ms) { return new Promise((r) => setTimeout(r, ms)) } | |
| export const Cactus = { isAvailable, init, complete, destroy, importModelFromZip } | |
| export default Cactus | |