File size: 7,227 Bytes
745f62a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
// 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