catalyst-core 0.3.0-beta.1 → 0.3.0-beta.3
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/dist/globals.d.ts +7 -0
- package/dist/native/androidProject/app/build.gradle.kts +45 -4
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +86 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +8 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/FrameworkServerUtils.kt +173 -13
- package/dist/native/androidProject/settings.gradle.kts +10 -0
- package/dist/native/androidSetup.js +8 -6
- package/dist/native/bridge/constants/NativeInterfaces.js +15 -1
- package/dist/native/bridge/hooks/index.js +7 -0
- package/dist/native/bridge/hooks/useAI.js +94 -0
- package/dist/native/buildAndroid/assets.js +1 -1
- package/dist/native/buildAndroid/index.js +32 -0
- package/dist/native/buildIos/assets.js +1 -1
- package/dist/offline/catalyst-sw.js +1 -0
- package/dist/scripts/build.js +15 -11
- package/dist/scripts/generateOfflineManifest.js +1 -0
- package/dist/scripts/scriptUtils.js +1 -0
- package/dist/scripts/serve.js +24 -22
- package/dist/scripts/start.js +1 -3
- package/dist/server/expressServer.js +47 -0
- package/dist/server/renderer/document/Head.jsx +2 -0
- package/dist/server/renderer/extract.js +1 -0
- package/dist/server/renderer/handler.jsx +29 -11
- package/dist/vite/inject-cache-key-plugin.js +7 -0
- package/dist/vite/loadCustomViteConfig.js +1 -0
- package/dist/vite/manifest-categorization-plugin.js +2 -0
- package/dist/vite/node-loader.mjs +6 -0
- package/dist/vite/vite.config.js +83 -1
- package/mcp_v2/knowledge-base.json +52 -4
- package/mcp_v2/mcp.js +36 -4
- package/mcp_v2/tools/build.js +46 -18
- package/mcp_v2/tools/github.js +11 -0
- package/package.json +3 -3
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Injected by webpack DefinePlugin in base.babel.js at build time.
|
|
2
|
+
// Reflects which @catalyst/* packages are present in the app's node_modules.
|
|
3
|
+
declare const __CATALYST_PACKAGES__: {
|
|
4
|
+
readonly ai: boolean
|
|
5
|
+
readonly webAILocal: boolean
|
|
6
|
+
readonly nativeAILocal: boolean
|
|
7
|
+
}
|
|
@@ -60,6 +60,22 @@ fun isNotificationsEnabled(): Boolean {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
fun isAIEnabled(): Boolean {
|
|
64
|
+
return try {
|
|
65
|
+
if (configPath == null) return false
|
|
66
|
+
val configFile = File(configPath!!)
|
|
67
|
+
if (!configFile.exists()) return false
|
|
68
|
+
|
|
69
|
+
val json = JSONObject(configFile.readText())
|
|
70
|
+
if (!json.has("WEBVIEW_CONFIG")) return false
|
|
71
|
+
|
|
72
|
+
val webviewConfig = json.getJSONObject("WEBVIEW_CONFIG")
|
|
73
|
+
webviewConfig.optJSONObject("ai")?.optBoolean("enabled", false) ?: false
|
|
74
|
+
} catch (e: Exception) {
|
|
75
|
+
false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
fun getLocalIpAddress(): String {
|
|
64
80
|
return NetworkInterface.getNetworkInterfaces().toList()
|
|
65
81
|
.flatMap { it.inetAddresses.toList() }
|
|
@@ -151,6 +167,7 @@ android {
|
|
|
151
167
|
|
|
152
168
|
kotlinOptions {
|
|
153
169
|
jvmTarget = "11"
|
|
170
|
+
freeCompilerArgs += "-Xskip-metadata-version-check"
|
|
154
171
|
}
|
|
155
172
|
|
|
156
173
|
buildFeatures {
|
|
@@ -168,8 +185,9 @@ android {
|
|
|
168
185
|
excludes.add("META-INF/io.netty.versions.properties")
|
|
169
186
|
}
|
|
170
187
|
jniLibs {
|
|
171
|
-
//
|
|
172
|
-
useLegacyPackaging
|
|
188
|
+
// libLiteRtClGlAccelerator.so must be extracted to disk for GPU dlopen().
|
|
189
|
+
// This must live in the app module — AGP ignores useLegacyPackaging in library modules.
|
|
190
|
+
useLegacyPackaging = isAIEnabled()
|
|
173
191
|
}
|
|
174
192
|
}
|
|
175
193
|
|
|
@@ -186,7 +204,7 @@ android {
|
|
|
186
204
|
}
|
|
187
205
|
}
|
|
188
206
|
|
|
189
|
-
// Conditional source sets based on
|
|
207
|
+
// Conditional source sets based on feature config
|
|
190
208
|
sourceSets {
|
|
191
209
|
getByName("main") {
|
|
192
210
|
if (isNotificationsEnabled()) {
|
|
@@ -196,6 +214,20 @@ android {
|
|
|
196
214
|
logger.info("SourceSet selected: noFcm (notifications disabled)")
|
|
197
215
|
java.srcDirs("src/noFcm/java")
|
|
198
216
|
}
|
|
217
|
+
// AI has no source-set swap — CatalystAIBridge self-registers via ServiceLoader
|
|
218
|
+
// when @catalyst/cloud-ai is on the classpath.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
configurations.all {
|
|
224
|
+
resolutionStrategy.eachDependency {
|
|
225
|
+
if (requested.group == "org.jetbrains.kotlin" &&
|
|
226
|
+
(requested.name == "kotlin-stdlib" ||
|
|
227
|
+
requested.name == "kotlin-stdlib-jdk7" ||
|
|
228
|
+
requested.name == "kotlin-stdlib-jdk8" ||
|
|
229
|
+
requested.name == "kotlin-reflect")) {
|
|
230
|
+
useVersion("2.0.21")
|
|
199
231
|
}
|
|
200
232
|
}
|
|
201
233
|
}
|
|
@@ -217,7 +249,16 @@ dependencies {
|
|
|
217
249
|
implementation("io.ktor:ktor-server-core:3.0.3")
|
|
218
250
|
implementation("io.ktor:ktor-server-netty:3.0.3")
|
|
219
251
|
implementation("io.ktor:ktor-server-content-negotiation:3.0.3")
|
|
220
|
-
implementation("
|
|
252
|
+
implementation("io.ktor:ktor-server-sse:3.0.3")
|
|
253
|
+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
|
254
|
+
|
|
255
|
+
// Native AI — compileOnly always (for types), implementation only when ai.enabled=true (for runtime bundling)
|
|
256
|
+
if (project.findProject(":catalyst-cloud-ai") != null) {
|
|
257
|
+
compileOnly(project(":catalyst-cloud-ai"))
|
|
258
|
+
if (isAIEnabled()) {
|
|
259
|
+
implementation(project(":catalyst-cloud-ai"))
|
|
260
|
+
}
|
|
261
|
+
}
|
|
221
262
|
|
|
222
263
|
// Security detection dependencies
|
|
223
264
|
implementation("com.scottyab:rootbeer-lib:0.1.1") // Root detection
|
package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt
CHANGED
|
@@ -16,12 +16,15 @@ import androidx.credentials.exceptions.GetCredentialCancellationException
|
|
|
16
16
|
import androidx.credentials.exceptions.GetCredentialException
|
|
17
17
|
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
|
|
18
18
|
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
|
19
|
+
// LiteRT imports removed — AI logic lives in catalyst-ai (CatalystAIBridge plugin)
|
|
19
20
|
import io.yourname.androidproject.MainActivity
|
|
20
21
|
import io.yourname.androidproject.utils.*
|
|
21
22
|
import kotlinx.coroutines.*
|
|
22
23
|
import org.json.JSONArray
|
|
23
24
|
import org.json.JSONObject
|
|
25
|
+
import java.io.File
|
|
24
26
|
import java.util.Properties
|
|
27
|
+
import java.util.ServiceLoader
|
|
25
28
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
26
29
|
|
|
27
30
|
private data class FilePickerOptions(
|
|
@@ -151,6 +154,61 @@ class NativeBridge(
|
|
|
151
154
|
|
|
152
155
|
private var networkMonitor: NetworkMonitor? = null
|
|
153
156
|
|
|
157
|
+
// AI plugin — loaded via reflection so NativeBridge.kt compiles with no compile-time dep on
|
|
158
|
+
// :catalyst-ai. When the module is absent the try block returns null gracefully.
|
|
159
|
+
private val aiBridge: Any? = try {
|
|
160
|
+
@Suppress("UNCHECKED_CAST")
|
|
161
|
+
run {
|
|
162
|
+
val bridgeClass = Class.forName("io.catalyst.nativeai.AIBridge")
|
|
163
|
+
val callbacksClass = Class.forName("io.catalyst.nativeai.AIBridgeCallbacks")
|
|
164
|
+
@Suppress("UNCHECKED_CAST")
|
|
165
|
+
val loader = ServiceLoader.load(bridgeClass as Class<Any>)
|
|
166
|
+
val bridge = loader.iterator().takeIf { it.hasNext() }?.next() ?: return@run null
|
|
167
|
+
|
|
168
|
+
val callbacksProxy = java.lang.reflect.Proxy.newProxyInstance(
|
|
169
|
+
bridgeClass.classLoader,
|
|
170
|
+
arrayOf(callbacksClass)
|
|
171
|
+
) { _, method, args ->
|
|
172
|
+
val a = args ?: emptyArray()
|
|
173
|
+
when (method.name) {
|
|
174
|
+
"onReady" -> {
|
|
175
|
+
val payload = org.json.JSONObject().apply {
|
|
176
|
+
put("url", a[0]); put("port", a[1]); put("sessionId", a[2])
|
|
177
|
+
}
|
|
178
|
+
BridgeUtils.notifyWebJson(webView, BridgeUtils.WebEvents.ON_AI_READY, payload)
|
|
179
|
+
}
|
|
180
|
+
"onProgress" -> {
|
|
181
|
+
val payload = org.json.JSONObject().apply {
|
|
182
|
+
put("phase", a[0]); put("percent", a[1])
|
|
183
|
+
put("bytesLoaded", a[2]); put("bytesTotal", a[3]); put("detail", a[4])
|
|
184
|
+
}
|
|
185
|
+
BridgeUtils.notifyWebJson(webView, BridgeUtils.WebEvents.ON_AI_PROGRESS, payload)
|
|
186
|
+
}
|
|
187
|
+
"onLog" -> {
|
|
188
|
+
val payload = org.json.JSONObject().apply { put("message", a[0]) }
|
|
189
|
+
BridgeUtils.notifyWebJson(webView, BridgeUtils.WebEvents.ON_AI_LOG, payload)
|
|
190
|
+
}
|
|
191
|
+
"onError" -> BridgeUtils.notifyWebError(webView, BridgeUtils.WebEvents.ON_AI_ERROR, a[0] as String)
|
|
192
|
+
"isFrameworkServerRunning" -> FrameworkServerUtils.isRunning()
|
|
193
|
+
"getFrameworkServerPort" -> FrameworkServerUtils.getServerPort()
|
|
194
|
+
"getFrameworkServerSessionId" -> FrameworkServerUtils.getSessionId()
|
|
195
|
+
"setNativeAiSupplier" -> {
|
|
196
|
+
@Suppress("UNCHECKED_CAST")
|
|
197
|
+
FrameworkServerUtils.setNativeAiSupplier(
|
|
198
|
+
a[0] as suspend (String, org.json.JSONObject, String?) -> Pair<String, kotlinx.coroutines.flow.Flow<String>>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
"setNativeSystemPrompt" -> FrameworkServerUtils.setNativeSystemPrompt(a[0] as String)
|
|
202
|
+
else -> null
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
bridge.javaClass.getMethod("attach", android.app.Activity::class.java, android.webkit.WebView::class.java, callbacksClass)
|
|
207
|
+
.invoke(bridge, mainActivity, webView, callbacksProxy)
|
|
208
|
+
bridge
|
|
209
|
+
}
|
|
210
|
+
} catch (_: Throwable) { null }
|
|
211
|
+
|
|
154
212
|
// Video stream manager
|
|
155
213
|
private var nativeCameraManager: NativeCameraManager? = null
|
|
156
214
|
private var webViewOriginalBackground: Int = android.graphics.Color.WHITE
|
|
@@ -1301,6 +1359,34 @@ class NativeBridge(
|
|
|
1301
1359
|
}
|
|
1302
1360
|
}
|
|
1303
1361
|
|
|
1362
|
+
// -------------------------------------------------------------------------
|
|
1363
|
+
// Native AI bridge methods — delegated to AIBridge plugin (ServiceLoader)
|
|
1364
|
+
// -------------------------------------------------------------------------
|
|
1365
|
+
|
|
1366
|
+
@JavascriptInterface
|
|
1367
|
+
fun isAIAvailable(): Boolean = aiBridge != null
|
|
1368
|
+
|
|
1369
|
+
@JavascriptInterface
|
|
1370
|
+
fun initAI(optionsRaw: String?) {
|
|
1371
|
+
BridgeUtils.safeExecute(webView, BridgeUtils.WebEvents.ON_AI_ERROR, "initialize AI") {
|
|
1372
|
+
aiBridge?.javaClass?.getMethod("initAI", String::class.java)?.invoke(aiBridge, optionsRaw)
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
@JavascriptInterface
|
|
1377
|
+
fun generateNative(optionsRaw: String?) {
|
|
1378
|
+
BridgeUtils.safeExecute(webView, BridgeUtils.WebEvents.ON_AI_ERROR, "generate with native AI") {
|
|
1379
|
+
aiBridge?.javaClass?.getMethod("generateNative", String::class.java)?.invoke(aiBridge, optionsRaw)
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
@JavascriptInterface
|
|
1384
|
+
fun clearNativeConversation() {
|
|
1385
|
+
BridgeUtils.safeExecute(webView, BridgeUtils.WebEvents.ON_AI_ERROR, "clear native conversation") {
|
|
1386
|
+
aiBridge?.javaClass?.getMethod("clearConversation")?.invoke(aiBridge)
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1304
1390
|
/**
|
|
1305
1391
|
* Cleanup method to be called when the bridge is being destroyed
|
|
1306
1392
|
*/
|
package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt
CHANGED
|
@@ -97,6 +97,14 @@ object BridgeUtils {
|
|
|
97
97
|
ON_TRANSITION_CANCELLED("ON_TRANSITION_CANCELLED"),
|
|
98
98
|
ON_TRANSITION_TIMEOUT("ON_TRANSITION_TIMEOUT"),
|
|
99
99
|
|
|
100
|
+
// Native AI events
|
|
101
|
+
ON_AI_READY("ON_AI_READY"),
|
|
102
|
+
ON_AI_ERROR("ON_AI_ERROR"),
|
|
103
|
+
ON_AI_TOKEN("ON_AI_TOKEN"),
|
|
104
|
+
ON_AI_DONE("ON_AI_DONE"),
|
|
105
|
+
ON_AI_PROGRESS("ON_AI_PROGRESS"),
|
|
106
|
+
ON_AI_LOG("ON_AI_LOG"),
|
|
107
|
+
|
|
100
108
|
// Perf telemetry
|
|
101
109
|
CATALYST_PERF_EVENT("CATALYST_PERF_EVENT"),
|
|
102
110
|
}
|
|
@@ -7,15 +7,21 @@ import io.ktor.http.*
|
|
|
7
7
|
import io.ktor.server.application.*
|
|
8
8
|
import io.ktor.server.engine.*
|
|
9
9
|
import io.ktor.server.netty.*
|
|
10
|
+
import io.ktor.server.request.*
|
|
10
11
|
import io.ktor.server.response.*
|
|
11
12
|
import io.ktor.server.routing.*
|
|
13
|
+
import io.ktor.http.ContentType
|
|
14
|
+
import io.ktor.server.response.respondTextWriter
|
|
15
|
+
import io.ktor.server.sse.*
|
|
12
16
|
import kotlinx.coroutines.*
|
|
17
|
+
import kotlinx.coroutines.flow.Flow
|
|
18
|
+
import org.json.JSONObject
|
|
13
19
|
import java.io.File
|
|
14
20
|
import java.io.IOException
|
|
15
21
|
import java.net.InetSocketAddress
|
|
16
22
|
import java.nio.channels.ServerSocketChannel
|
|
17
23
|
import java.security.SecureRandom
|
|
18
|
-
import java.util
|
|
24
|
+
import java.util.UUID
|
|
19
25
|
import java.util.concurrent.ConcurrentHashMap
|
|
20
26
|
|
|
21
27
|
/**
|
|
@@ -36,6 +42,7 @@ object FrameworkServerUtils {
|
|
|
36
42
|
private const val FRAMEWORK_PORT_RANGE_START = 3000
|
|
37
43
|
private const val FRAMEWORK_PORT_RANGE_END = 3099
|
|
38
44
|
private const val SESSION_TIMEOUT_MS = 30 * 60 * 1000L // 30 minutes
|
|
45
|
+
private const val AI_GENERATE_TIMEOUT_MS = 120 * 1000L // 2 minutes
|
|
39
46
|
|
|
40
47
|
// Server state
|
|
41
48
|
private var server: EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration>? = null
|
|
@@ -49,7 +56,14 @@ object FrameworkServerUtils {
|
|
|
49
56
|
// File management
|
|
50
57
|
private val servedFiles = ConcurrentHashMap<String, ServedFile>()
|
|
51
58
|
private var cacheDirectory: File? = null
|
|
52
|
-
|
|
59
|
+
|
|
60
|
+
// Native AI — supplier set by NativeBridge before POST /ai/stream is called
|
|
61
|
+
// Returns (conversationId, tokenFlow) so the route can emit conversationId as an SSE event
|
|
62
|
+
private var nativeAiSupplier: (suspend (prompt: String, genConfig: JSONObject, conversationId: String?) -> Pair<String, Flow<String>>)? = null
|
|
63
|
+
|
|
64
|
+
// System prompt injected into every native AI request (built from attachmentComponents at initAI time)
|
|
65
|
+
private var nativeSystemPrompt: String = ""
|
|
66
|
+
|
|
53
67
|
// Coroutine scope for server operations
|
|
54
68
|
private val serverScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
55
69
|
|
|
@@ -60,7 +74,7 @@ object FrameworkServerUtils {
|
|
|
60
74
|
val fileId: String,
|
|
61
75
|
val createdAt: Long = System.currentTimeMillis()
|
|
62
76
|
)
|
|
63
|
-
|
|
77
|
+
|
|
64
78
|
/**
|
|
65
79
|
* Initialize and start the framework server
|
|
66
80
|
* @param context Android context for file operations
|
|
@@ -223,7 +237,21 @@ object FrameworkServerUtils {
|
|
|
223
237
|
* Get current session ID
|
|
224
238
|
*/
|
|
225
239
|
fun getSessionId(): String = sessionId
|
|
226
|
-
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Register the native AI supplier. Called by NativeBridge once LiteRT-LM is ready.
|
|
243
|
+
* The supplier receives the prompt, gen-config, and optional incomingConversationId,
|
|
244
|
+
* and returns (activeConversationId, tokenFlow) so stateful sessions can be chained.
|
|
245
|
+
*/
|
|
246
|
+
fun setNativeAiSupplier(supplier: suspend (prompt: String, genConfig: JSONObject, conversationId: String?) -> Pair<String, Flow<String>>) {
|
|
247
|
+
nativeAiSupplier = supplier
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fun setNativeSystemPrompt(systemPrompt: String) {
|
|
251
|
+
nativeSystemPrompt = systemPrompt
|
|
252
|
+
Log.d(TAG, "System prompt set (${systemPrompt.length} chars)")
|
|
253
|
+
}
|
|
254
|
+
|
|
227
255
|
// Private implementation methods
|
|
228
256
|
|
|
229
257
|
private fun initializeCacheDirectory(context: Context) {
|
|
@@ -291,7 +319,145 @@ object FrameworkServerUtils {
|
|
|
291
319
|
|
|
292
320
|
private fun startKtorServer() {
|
|
293
321
|
server = embeddedServer(Netty, port = serverPort) {
|
|
322
|
+
install(SSE)
|
|
294
323
|
routing {
|
|
324
|
+
// Native AI streaming endpoint — POST body: { "prompt": "...", "genConfig": {...} }
|
|
325
|
+
// Writes SSE manually over chunked response (Ktor SSE plugin only supports GET).
|
|
326
|
+
post("/framework-$sessionId/ai/stream") {
|
|
327
|
+
call.response.header(HttpHeaders.AccessControlAllowOrigin, allowedOrigin)
|
|
328
|
+
call.response.header(HttpHeaders.AccessControlAllowHeaders, "*")
|
|
329
|
+
call.response.header(HttpHeaders.ContentType, "text/event-stream")
|
|
330
|
+
call.response.header(HttpHeaders.CacheControl, "no-cache")
|
|
331
|
+
call.response.header("X-Accel-Buffering", "no")
|
|
332
|
+
|
|
333
|
+
val body = try {
|
|
334
|
+
JSONObject(call.receiveText())
|
|
335
|
+
} catch (e: Exception) {
|
|
336
|
+
JSONObject()
|
|
337
|
+
}
|
|
338
|
+
val userPrompt = body.optString("prompt", "")
|
|
339
|
+
val genConfig = body.optJSONObject("genConfig") ?: JSONObject()
|
|
340
|
+
val incomingConvId = body.optString("conversationId", "").takeIf { it.isNotBlank() }
|
|
341
|
+
if (nativeSystemPrompt.isNotBlank()) Log.d(TAG, "Injecting system prompt (${nativeSystemPrompt.length} chars)")
|
|
342
|
+
val prompt = if (nativeSystemPrompt.isNotBlank()) "$nativeSystemPrompt\n\n$userPrompt" else userPrompt
|
|
343
|
+
|
|
344
|
+
call.respondTextWriter(contentType = ContentType.Text.EventStream) {
|
|
345
|
+
val supplier = nativeAiSupplier
|
|
346
|
+
if (supplier == null) {
|
|
347
|
+
write("data: {\"error\":\"Native AI not initialised — call initAI() first\"}\n\n")
|
|
348
|
+
flush()
|
|
349
|
+
} else if (prompt.isBlank()) {
|
|
350
|
+
write("data: {\"error\":\"prompt is empty\"}\n\n")
|
|
351
|
+
flush()
|
|
352
|
+
} else {
|
|
353
|
+
try {
|
|
354
|
+
val (activeConvId, tokenFlow) = supplier(prompt, genConfig, incomingConvId)
|
|
355
|
+
write("data: {\"conversationId\":${JSONObject.quote(activeConvId)}}\n\n")
|
|
356
|
+
flush()
|
|
357
|
+
tokenFlow.collect { token ->
|
|
358
|
+
write("data: {\"token\":${JSONObject.quote(token)}}\n\n")
|
|
359
|
+
flush()
|
|
360
|
+
}
|
|
361
|
+
write("data: {\"done\":true}\n\n")
|
|
362
|
+
flush()
|
|
363
|
+
} catch (e: java.io.IOException) {
|
|
364
|
+
// Client disconnected — channel closed, nothing to write
|
|
365
|
+
Log.d(TAG, "Native AI stream: client disconnected (${e.message})")
|
|
366
|
+
} catch (e: Exception) {
|
|
367
|
+
Log.e(TAG, "Native AI stream error", e)
|
|
368
|
+
try {
|
|
369
|
+
write("data: {\"error\":${JSONObject.quote(e.message ?: "stream error")}}\n\n")
|
|
370
|
+
flush()
|
|
371
|
+
} catch (_: java.io.IOException) {
|
|
372
|
+
// Channel already closed, ignore
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// OPTIONS preflight for ai/stream
|
|
380
|
+
options("/framework-$sessionId/ai/stream") {
|
|
381
|
+
call.response.header(HttpHeaders.AccessControlAllowOrigin, allowedOrigin)
|
|
382
|
+
call.response.header(HttpHeaders.AccessControlAllowMethods, "POST, OPTIONS")
|
|
383
|
+
call.response.header(HttpHeaders.AccessControlAllowHeaders, "*")
|
|
384
|
+
call.response.header(HttpHeaders.AccessControlMaxAge, "86400")
|
|
385
|
+
call.respond(HttpStatusCode.OK)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Native AI non-streaming endpoint — POST body: { "prompt": "...", "genConfig": {...} }
|
|
389
|
+
// Drains nativeAiSupplier's tokenFlow fully, then returns a single JSON response.
|
|
390
|
+
post("/framework-$sessionId/ai/generate") {
|
|
391
|
+
call.response.header(HttpHeaders.AccessControlAllowOrigin, allowedOrigin)
|
|
392
|
+
call.response.header(HttpHeaders.AccessControlAllowHeaders, "*")
|
|
393
|
+
|
|
394
|
+
val body = try {
|
|
395
|
+
JSONObject(call.receiveText())
|
|
396
|
+
} catch (e: Exception) {
|
|
397
|
+
JSONObject()
|
|
398
|
+
}
|
|
399
|
+
val userPrompt = body.optString("prompt", "")
|
|
400
|
+
val genConfig = body.optJSONObject("genConfig") ?: JSONObject()
|
|
401
|
+
val incomingConvId = body.optString("conversationId", "").takeIf { it.isNotBlank() }
|
|
402
|
+
if (nativeSystemPrompt.isNotBlank()) Log.d(TAG, "Injecting system prompt (${nativeSystemPrompt.length} chars)")
|
|
403
|
+
val prompt = if (nativeSystemPrompt.isNotBlank()) "$nativeSystemPrompt\n\n$userPrompt" else userPrompt
|
|
404
|
+
|
|
405
|
+
val supplier = nativeAiSupplier
|
|
406
|
+
if (supplier == null) {
|
|
407
|
+
call.respondText(
|
|
408
|
+
JSONObject().put("error", "Native AI not initialised — call initAI() first").toString(),
|
|
409
|
+
ContentType.Application.Json,
|
|
410
|
+
HttpStatusCode.ServiceUnavailable
|
|
411
|
+
)
|
|
412
|
+
return@post
|
|
413
|
+
}
|
|
414
|
+
if (prompt.isBlank()) {
|
|
415
|
+
call.respondText(
|
|
416
|
+
JSONObject().put("error", "prompt is empty").toString(),
|
|
417
|
+
ContentType.Application.Json,
|
|
418
|
+
HttpStatusCode.BadRequest
|
|
419
|
+
)
|
|
420
|
+
return@post
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
val (activeConvId, tokenFlow) = supplier(prompt, genConfig, incomingConvId)
|
|
425
|
+
val output = StringBuilder()
|
|
426
|
+
var tokenCount = 0
|
|
427
|
+
withTimeout(AI_GENERATE_TIMEOUT_MS) {
|
|
428
|
+
tokenFlow.collect { token -> output.append(token); tokenCount++ }
|
|
429
|
+
}
|
|
430
|
+
val responseJson = JSONObject()
|
|
431
|
+
.put("output", output.toString())
|
|
432
|
+
.put("conversationId", activeConvId)
|
|
433
|
+
.put("tokenCount", tokenCount)
|
|
434
|
+
call.respondText(responseJson.toString(), ContentType.Application.Json, HttpStatusCode.OK)
|
|
435
|
+
} catch (e: TimeoutCancellationException) {
|
|
436
|
+
Log.e(TAG, "Native AI generate timed out after ${AI_GENERATE_TIMEOUT_MS}ms", e)
|
|
437
|
+
call.respondText(
|
|
438
|
+
JSONObject().put("error", "generate timed out after ${AI_GENERATE_TIMEOUT_MS / 1000}s").toString(),
|
|
439
|
+
ContentType.Application.Json,
|
|
440
|
+
HttpStatusCode.GatewayTimeout
|
|
441
|
+
)
|
|
442
|
+
} catch (e: Exception) {
|
|
443
|
+
Log.e(TAG, "Native AI generate error", e)
|
|
444
|
+
call.respondText(
|
|
445
|
+
JSONObject().put("error", (e.message ?: "generate error")).toString(),
|
|
446
|
+
ContentType.Application.Json,
|
|
447
|
+
HttpStatusCode.InternalServerError
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// OPTIONS preflight for ai/generate
|
|
453
|
+
options("/framework-$sessionId/ai/generate") {
|
|
454
|
+
call.response.header(HttpHeaders.AccessControlAllowOrigin, allowedOrigin)
|
|
455
|
+
call.response.header(HttpHeaders.AccessControlAllowMethods, "POST, OPTIONS")
|
|
456
|
+
call.response.header(HttpHeaders.AccessControlAllowHeaders, "*")
|
|
457
|
+
call.response.header(HttpHeaders.AccessControlMaxAge, "86400")
|
|
458
|
+
call.respond(HttpStatusCode.OK)
|
|
459
|
+
}
|
|
460
|
+
|
|
295
461
|
get("/framework-$sessionId/file-{fileId}") {
|
|
296
462
|
val fileId = call.parameters["fileId"]
|
|
297
463
|
|
|
@@ -385,22 +551,16 @@ object FrameworkServerUtils {
|
|
|
385
551
|
val expiredFiles = servedFiles.filter { (_, servedFile) ->
|
|
386
552
|
currentTime - servedFile.createdAt > SESSION_TIMEOUT_MS
|
|
387
553
|
}
|
|
388
|
-
|
|
389
554
|
if (expiredFiles.isNotEmpty()) {
|
|
390
555
|
Log.d(TAG, "Cleaning up ${expiredFiles.size} expired files")
|
|
391
|
-
|
|
392
|
-
expiredFiles.forEach { (fileId, _) ->
|
|
393
|
-
removeServedFile(fileId)
|
|
394
|
-
}
|
|
556
|
+
expiredFiles.forEach { (fileId, _) -> removeServedFile(fileId) }
|
|
395
557
|
}
|
|
558
|
+
|
|
396
559
|
}
|
|
397
560
|
|
|
398
561
|
private fun cleanupAllFiles() {
|
|
399
562
|
Log.d(TAG, "Cleaning up all served files (${servedFiles.size} files)")
|
|
400
|
-
|
|
401
|
-
servedFiles.keys.toList().forEach { fileId ->
|
|
402
|
-
removeServedFile(fileId)
|
|
403
|
-
}
|
|
563
|
+
servedFiles.keys.toList().forEach { fileId -> removeServedFile(fileId) }
|
|
404
564
|
|
|
405
565
|
// Clean cache directory
|
|
406
566
|
cacheDirectory?.listFiles()?.forEach { file ->
|
|
@@ -33,3 +33,13 @@ dependencyResolutionManagement {
|
|
|
33
33
|
|
|
34
34
|
rootProject.name = "Catalyst Application"
|
|
35
35
|
include(":app")
|
|
36
|
+
|
|
37
|
+
// @catalyst/cloud-ai — include when the package is installed.
|
|
38
|
+
// The npm package ships an /android library module for native AI; wire it here so Gradle picks it up.
|
|
39
|
+
// After running: npm install @catalyst/cloud-ai && npm run sync-packages -- --packages cloud-ai
|
|
40
|
+
// Also set ai.enabled=true in your app config to enable useLegacyPackaging for GPU dlopen().
|
|
41
|
+
val cloudAiDir = File(rootDir, "../node_modules/@catalyst/cloud-ai/android")
|
|
42
|
+
if (cloudAiDir.exists()) {
|
|
43
|
+
include(":catalyst-cloud-ai")
|
|
44
|
+
project(":catalyst-cloud-ai").projectDir = cloudAiDir
|
|
45
|
+
}
|
|
@@ -209,13 +209,15 @@ async function checkEmulator(ADB_PATH) {
|
|
|
209
209
|
}
|
|
210
210
|
async function startEmulator(EMULATOR_PATH, androidConfig) {
|
|
211
211
|
progress.log(`Starting emulator: ${androidConfig.emulatorName}...`);
|
|
212
|
-
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
212
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - spawn with an argv array (no shell), so androidConfig.emulatorName can't be interpreted as shell syntax even though it comes from a locally-editable config file.
|
|
213
|
+
const child = (0, _child_process.spawn)(EMULATOR_PATH, ["-avd", androidConfig.emulatorName, "-read-only"], {
|
|
214
|
+
detached: true,
|
|
215
|
+
stdio: "ignore"
|
|
216
|
+
});
|
|
217
|
+
child.on("error", error => {
|
|
218
|
+
progress.log(`Error starting emulator: ${error}`, "error");
|
|
218
219
|
});
|
|
220
|
+
child.unref();
|
|
219
221
|
}
|
|
220
222
|
async function updateLocalProperties(sdkPath) {
|
|
221
223
|
progress.start("properties");
|
|
@@ -48,7 +48,10 @@ const NATIVE_COMMANDS = exports.NATIVE_COMMANDS = {
|
|
|
48
48
|
// Security commands
|
|
49
49
|
SET_SCREEN_SECURE: "setScreenSecure",
|
|
50
50
|
GET_SCREEN_SECURE: "getScreenSecure",
|
|
51
|
-
CLEAR_WEB_DATA: "clearWebData"
|
|
51
|
+
CLEAR_WEB_DATA: "clearWebData",
|
|
52
|
+
// Native AI commands
|
|
53
|
+
INIT_AI: "initAI",
|
|
54
|
+
GENERATE_NATIVE: "generateNative"
|
|
52
55
|
};
|
|
53
56
|
|
|
54
57
|
// Callback Interfaces: Native → Web (incoming events/callbacks)
|
|
@@ -105,6 +108,13 @@ const NATIVE_CALLBACKS = exports.NATIVE_CALLBACKS = {
|
|
|
105
108
|
ON_SCREEN_SECURE_ERROR: "ON_SCREEN_SECURE_ERROR",
|
|
106
109
|
ON_WEB_DATA_CLEARED: "ON_WEB_DATA_CLEARED",
|
|
107
110
|
ON_WEB_DATA_CLEAR_ERROR: "ON_WEB_DATA_CLEAR_ERROR",
|
|
111
|
+
// Native AI callbacks
|
|
112
|
+
ON_AI_READY: "ON_AI_READY",
|
|
113
|
+
ON_AI_TOKEN: "ON_AI_TOKEN",
|
|
114
|
+
ON_AI_DONE: "ON_AI_DONE",
|
|
115
|
+
ON_AI_ERROR: "ON_AI_ERROR",
|
|
116
|
+
ON_AI_PROGRESS: "ON_AI_PROGRESS",
|
|
117
|
+
ON_AI_LOG: "ON_AI_LOG",
|
|
108
118
|
// Perf telemetry callbacks (native → web)
|
|
109
119
|
CATALYST_PERF_EVENT: "CATALYST_PERF_EVENT"
|
|
110
120
|
};
|
|
@@ -150,6 +160,10 @@ const INTERFACE_CATEGORIES = exports.INTERFACE_CATEGORIES = {
|
|
|
150
160
|
TRANSITION: {
|
|
151
161
|
commands: [NATIVE_COMMANDS.START_TRANSITION, NATIVE_COMMANDS.COMMIT_TRANSITION, NATIVE_COMMANDS.CANCEL_TRANSITION],
|
|
152
162
|
callbacks: [NATIVE_CALLBACKS.ON_TRANSITION_COMMITTED, NATIVE_CALLBACKS.ON_TRANSITION_CANCELLED, NATIVE_CALLBACKS.ON_TRANSITION_TIMEOUT]
|
|
163
|
+
},
|
|
164
|
+
AI: {
|
|
165
|
+
commands: [NATIVE_COMMANDS.INIT_AI, NATIVE_COMMANDS.GENERATE_NATIVE],
|
|
166
|
+
callbacks: [NATIVE_CALLBACKS.ON_AI_READY, NATIVE_CALLBACKS.ON_AI_TOKEN, NATIVE_CALLBACKS.ON_AI_DONE, NATIVE_CALLBACKS.ON_AI_ERROR, NATIVE_CALLBACKS.ON_AI_PROGRESS, NATIVE_CALLBACKS.ON_AI_LOG]
|
|
153
167
|
}
|
|
154
168
|
};
|
|
155
169
|
|
|
@@ -27,6 +27,12 @@ Object.defineProperty(exports, "sanitizeFilePickerOptions", {
|
|
|
27
27
|
return _useFilePicker.sanitizeFilePickerOptions;
|
|
28
28
|
}
|
|
29
29
|
});
|
|
30
|
+
Object.defineProperty(exports, "useAI", {
|
|
31
|
+
enumerable: true,
|
|
32
|
+
get: function () {
|
|
33
|
+
return _useAI.useAI;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
30
36
|
Object.defineProperty(exports, "useCamera", {
|
|
31
37
|
enumerable: true,
|
|
32
38
|
get: function () {
|
|
@@ -111,6 +117,7 @@ Object.defineProperty(exports, "useVideoStream", {
|
|
|
111
117
|
return _useVideoStream.useVideoStream;
|
|
112
118
|
}
|
|
113
119
|
});
|
|
120
|
+
var _useAI = require("./useAI.js");
|
|
114
121
|
var _useGoogleSignIn = require("./useGoogleSignIn.js");
|
|
115
122
|
var _useCamera = require("./useCamera.js");
|
|
116
123
|
var _useIntent = require("./useIntent.js");
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.useAI = useAI;
|
|
7
|
+
/* global __CATALYST_PACKAGES__ */
|
|
8
|
+
|
|
9
|
+
function getBrowserConfig() {
|
|
10
|
+
try {
|
|
11
|
+
const raw = process.env.AI_PUBLIC_CONFIG;
|
|
12
|
+
if (!raw) return null;
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
return parsed?.browser || null;
|
|
15
|
+
} catch (_) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function resolveMode(provider) {
|
|
20
|
+
if (provider === "transformers") return "local";
|
|
21
|
+
if (provider === "native") return "native";
|
|
22
|
+
return "cloud";
|
|
23
|
+
}
|
|
24
|
+
function isNativeAIAvailable() {
|
|
25
|
+
const nb = typeof window !== "undefined" && window.NativeBridge;
|
|
26
|
+
return nb && typeof nb.isAIAvailable === "function" && nb.isAIAvailable();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// __CATALYST_PACKAGES__ is substituted by webpack's DefinePlugin / Vite's `define` in
|
|
30
|
+
// client bundles only. catalyst-core is externalized (nodeExternals) from the SSR
|
|
31
|
+
// bundle, so on the server this file is loaded directly by Node — including via Vite's
|
|
32
|
+
// ssrLoadModule, which executes real ESM with no bundler transform and no `require`
|
|
33
|
+
// shim. useAI() always discards the SSR result anyway (returns emptyHook() when
|
|
34
|
+
// `window` is undefined), so only ever attempt the client-side `require` — a bare
|
|
35
|
+
// `require()` reached during SSR would throw ReferenceError rather than a catchable
|
|
36
|
+
// MODULE_NOT_FOUND, since this package is "type": "module".
|
|
37
|
+
const _pkg = typeof window !== "undefined" && (typeof __CATALYST_PACKAGES__ === "undefined" || __CATALYST_PACKAGES__.ai) ? (() => {
|
|
38
|
+
try {
|
|
39
|
+
return require(/* webpackIgnore: true */"catalyst-ai");
|
|
40
|
+
} catch (_) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
})() : null;
|
|
44
|
+
|
|
45
|
+
// provider: "transformers" → useWebAI (catalyst-ai)
|
|
46
|
+
// "native" → useNativeAI (catalyst-ai, falls back to useCloudAI if bridge unavailable)
|
|
47
|
+
// anything else → useCloudAI (catalyst-ai, default)
|
|
48
|
+
function useAI(options = {}) {
|
|
49
|
+
const {
|
|
50
|
+
provider
|
|
51
|
+
} = options;
|
|
52
|
+
const config = getBrowserConfig();
|
|
53
|
+
const resolvedProvider = provider || config?.provider;
|
|
54
|
+
const mode = resolveMode(resolvedProvider);
|
|
55
|
+
const cloudResult = _pkg ? _pkg.useCloudAI(options) : emptyHook();
|
|
56
|
+
const webResult = _pkg ? _pkg.useWebAI(options) : emptyHook();
|
|
57
|
+
const nativeResult = _pkg ? _pkg.useNativeAI({
|
|
58
|
+
...options,
|
|
59
|
+
enabled: mode === "native"
|
|
60
|
+
}) : emptyHook();
|
|
61
|
+
if (typeof window === "undefined") return emptyHook();
|
|
62
|
+
if (!_pkg) {
|
|
63
|
+
console.error("\n[catalyst-core] useAI requires catalyst-ai.\n" + "Run: npm install catalyst-ai\n");
|
|
64
|
+
return emptyHook();
|
|
65
|
+
}
|
|
66
|
+
if (mode === "local") return webResult;
|
|
67
|
+
if (mode === "native" && isNativeAIAvailable()) return nativeResult;
|
|
68
|
+
return cloudResult;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Returned when catalyst-ai is missing — keeps hook shape stable so callers don't crash
|
|
72
|
+
function emptyHook() {
|
|
73
|
+
return {
|
|
74
|
+
output: "",
|
|
75
|
+
streaming: false,
|
|
76
|
+
loading: false,
|
|
77
|
+
error: null,
|
|
78
|
+
modelReady: false,
|
|
79
|
+
downloadProgress: null,
|
|
80
|
+
nativeDownloadProgress: null,
|
|
81
|
+
nativeLogs: [],
|
|
82
|
+
metrics: null,
|
|
83
|
+
isLocal: false,
|
|
84
|
+
isNative: false,
|
|
85
|
+
isWeb: false,
|
|
86
|
+
generate: () => {},
|
|
87
|
+
cancel: () => {},
|
|
88
|
+
reset: () => {},
|
|
89
|
+
clearError: () => {},
|
|
90
|
+
conversationId: null,
|
|
91
|
+
getSessionMetrics: () => null,
|
|
92
|
+
resetSessionMetrics: () => {}
|
|
93
|
+
};
|
|
94
|
+
}
|