Skip to content

Commit d65133a

Browse files
committed
feat: implement Nexa SDK GGUF polymorphic backend & transparent live wallpaper
1 parent 53e6095 commit d65133a

14 files changed

Lines changed: 487 additions & 99 deletions

app/build.gradle.kts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,14 @@ dependencies {
104104
// (NOT mediapipe:tasks-genai - that's a different/older API)
105105
implementation("com.google.ai.edge.litertlm:litertlm-android:0.9.0-alpha01")
106106

107+
// Nexa SDK: GGUF Model Inference Engine (Uncensored Llama-3, etc)
108+
implementation("ai.nexa:core:0.0.24")
109+
107110
// TFLite GPU delegate - Use Google Play Services versions (like Gallery app)
108111
implementation("com.google.android.gms:play-services-tflite-java:16.4.0")
109112
implementation("com.google.android.gms:play-services-tflite-gpu:16.4.0")
110113
implementation("com.google.android.gms:play-services-tflite-support:16.4.0")
111114

112-
113115
implementation("androidx.core:core-ktx:1.13.1")
114116

115117
// UI & Layouts

app/src/main/AndroidManifest.xml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,19 @@
133133
Filtering to conversations|alerting was blocking all media notifications. -->
134134
</service>
135135

136+
<!-- TRANSPARENT CAMERA WALLPAPER -->
137+
<service
138+
android:name=".ui.CameraWallpaperService"
139+
android:permission="android.permission.BIND_WALLPAPER"
140+
android:exported="true">
141+
<intent-filter>
142+
<action android:name="android.service.wallpaper.WallpaperService" />
143+
</intent-filter>
144+
<meta-data
145+
android:name="android.service.wallpaper"
146+
android:resource="@xml/transparent_wallpaper" />
147+
</service>
148+
136149
<!-- Launcher Activity (starts service and exits) -->
137150
<activity
138151
android:name=".MainActivity"

app/src/main/kotlin/com/gemma/api/ApiServer.kt

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ class ApiServer(
4242
Timber.d("API: ${prompt.take(30)}...")
4343

4444
// DELEGATE TO GEMMA SERVICE (Orchestrator)
45-
// Storage is handled atomically inside processQuery
4645
val aiResponse = withContext(Dispatchers.Default) {
4746
gemmaService.processQuery(prompt, sessionId) ?: "Error: No response generated"
4847
}
@@ -61,6 +60,80 @@ class ApiServer(
6160
}
6261
}
6362

63+
// STANDARD OPENAI-COMPATIBLE ENDPOINT FOR OPENCLAW / HONCHO
64+
post("/v1/chat/completions") {
65+
try {
66+
val request = call.receiveText()
67+
val parsed = gson.fromJson(request, Map::class.java)
68+
69+
@Suppress("UNCHECKED_CAST")
70+
val messages = parsed["messages"] as? List<Map<String, Any>> ?: emptyList()
71+
72+
// Extract the query. For our stateful agent, we ideally just want the latest user message
73+
// since we maintain our own KV cache and SQLite history natively.
74+
val lastMessage = messages.lastOrNull()
75+
val content = lastMessage?.get("content")?.toString() ?: ""
76+
77+
// Fallback: If it's a completely stateless external request, we might need all messages.
78+
// But Oracle_OS is inherently stateful. Let's just pass the payload contents.
79+
val promptBuilder = StringBuilder()
80+
if (messages.size > 1) {
81+
// If they passed history, let's concatenate loosely just in case
82+
for (msg in messages) {
83+
val role = msg["role"]?.toString() ?: "user"
84+
val text = msg["content"]?.toString() ?: ""
85+
promptBuilder.append("[\${role.uppercase()}]: \$text\n")
86+
}
87+
} else {
88+
promptBuilder.append(content)
89+
}
90+
91+
val prompt = promptBuilder.toString().trim()
92+
val sessionId = "openclaw_session"
93+
94+
Timber.d("OpenAI API: \${prompt.take(50)}...")
95+
96+
val aiResponse = withContext(Dispatchers.Default) {
97+
gemmaService.processQuery(prompt, sessionId) ?: "Error: No response generated"
98+
}
99+
100+
// Build OpenAI Schema conformant response
101+
val responseMap = mapOf(
102+
"id" to "chatcmpl-\${UUID.randomUUID()}",
103+
"object" to "chat.completion",
104+
"created" to (System.currentTimeMillis() / 1000),
105+
"model" to (parsed["model"]?.toString() ?: "gemma-local"),
106+
"choices" to listOf(
107+
mapOf(
108+
"index" to 0,
109+
"message" to mapOf(
110+
"role" to "assistant",
111+
"content" to aiResponse
112+
),
113+
"finish_reason" to "stop"
114+
)
115+
),
116+
"usage" to mapOf(
117+
"prompt_tokens" to prompt.length / 4,
118+
"completion_tokens" to aiResponse.length / 4,
119+
"total_tokens" to (prompt.length + aiResponse.length) / 4
120+
)
121+
)
122+
123+
call.respondText(gson.toJson(responseMap), ContentType.Application.Json)
124+
} catch (e: Exception) {
125+
Timber.e(e, "OpenAI API error")
126+
val errorJson = gson.toJson(mapOf(
127+
"error" to mapOf(
128+
"message" to (e.message ?: "Unknown error"),
129+
"type" to "server_error",
130+
"code" to 500
131+
)
132+
))
133+
call.respondText(errorJson, ContentType.Application.Json, HttpStatusCode.InternalServerError)
134+
}
135+
}
136+
64137
// ... (Keep existing GET routes) ...
65138

66139
get("/context") {

app/src/main/kotlin/com/gemma/api/GemmaEngine.kt

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@ import kotlin.coroutines.resumeWithException
2525
* Uses the proper Google AI Edge LiteRT-LM library for Gemma 3n inference
2626
* with full support for images and audio (omnimodal!)
2727
*/
28-
class GemmaEngine(private val context: Context) {
28+
class GemmaEngine(private val context: Context) : LlmBackend {
2929

3030
private var engine: Engine? = null
3131
private var conversation: Conversation? = null
3232
private val sessionLock = Object()
3333

3434
@Volatile private var isResetting = false
3535

36-
var activeBackend: String? = null
36+
override var activeBackend: String? = null
3737
private set
3838

3939
// Phase 12: Cache initialization params for hardReset
@@ -103,10 +103,10 @@ class GemmaEngine(private val context: Context) {
103103
}
104104
}
105105

106-
suspend fun generateResponse(
106+
override suspend fun generateResponse(
107107
prompt: String,
108-
images: List<Bitmap> = emptyList(),
109-
audioData: ByteArray? = null
108+
images: List<Bitmap>,
109+
audioData: ByteArray?
110110
): String {
111111
// Wait if reset is in progress or conversation is transiently null
112112
var waitCount = 0
@@ -207,10 +207,10 @@ class GemmaEngine(private val context: Context) {
207207
}
208208
}
209209

210-
fun streamResponse(
210+
override fun streamResponse(
211211
prompt: String,
212-
images: List<Bitmap> = emptyList(),
213-
audioData: ByteArray? = null,
212+
images: List<Bitmap>,
213+
audioData: ByteArray?,
214214
onToken: (String) -> Unit,
215215
onComplete: (String) -> Unit,
216216
onError: (String) -> Unit
@@ -267,7 +267,8 @@ class GemmaEngine(private val context: Context) {
267267
}
268268
}
269269

270-
fun softReset(systemPrompt: String) {
270+
override fun softReset(systemPrompt: String) {
271+
Timber.i("GemmaEngine Soft Reset with robust prompt injection")
271272
isResetting = true
272273
try {
273274
synchronized(sessionLock) {
@@ -299,7 +300,7 @@ class GemmaEngine(private val context: Context) {
299300
}
300301

301302
// Phase 12: Implement hardReset to cure Hexagon DSP NPU hardware timeouts.
302-
fun hardReset() {
303+
override fun hardReset() {
303304
if (lastModelPath.isBlank()) {
304305
Timber.e("Cannot hard reset: Engine was never initialized")
305306
return
@@ -373,7 +374,8 @@ class GemmaEngine(private val context: Context) {
373374
* Creates a temporary conversation, runs inference, cleans up.
374375
* Used by RLM for recursive sub-calls that shouldn't pollute the main conversation.
375376
*/
376-
suspend fun generateOneShot(prompt: String): String {
377+
override suspend fun generateOneShot(prompt: String): String {
378+
Timber.i("Executing one-shot prompt...")
377379
val currentEngine = synchronized(sessionLock) { engine }
378380
?: return "Error: Engine not initialized"
379381

@@ -454,7 +456,7 @@ class GemmaEngine(private val context: Context) {
454456
}
455457
}
456458

457-
fun cleanup() {
459+
override fun cleanup() {
458460
runCatching {
459461
synchronized(sessionLock) {
460462
conversation?.close()

app/src/main/kotlin/com/gemma/api/GemmaService.kt

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,10 @@ class GemmaService : Service(), AgentPlatformCallbacks {
7171

7272
// REPLACE: private lateinit var engine: GemmaEngine
7373
// WITH:
74-
private val engineRef = AtomicReference<GemmaEngine?>(null)
74+
private val engineRef = AtomicReference<LlmBackend?>(null)
7575
private val engineMutex = kotlinx.coroutines.sync.Mutex()
7676

77-
val engine: GemmaEngine?
77+
val engine: LlmBackend?
7878
get() = engineRef.get()
7979

8080
fun isGemmaLoaded(): Boolean = engineRef.get()?.let {
@@ -601,50 +601,71 @@ class GemmaService : Service(), AgentPlatformCallbacks {
601601
val modelNames = listOf(
602602
"gemma-3n-E4B-it-int4.litertlm",
603603
"gemma-3n-E2B-it-int4.litertlm",
604-
"gemma.litertlm"
604+
"gemma.litertlm",
605+
"model.gguf" // Add support for catching GGUF models directly
605606
)
607+
// Let it find ANY .gguf file as a fallback if explicit name isn't matched
606608
val searchDirs = listOf(
607609
getExternalFilesDir(null), // App storage (survives Downloads cleanup)
608610
downloadDir // Downloads folder
609611
)
610-
val modelFile = searchDirs.flatMap { dir ->
612+
613+
// Modified: Search for explicit model names first, if not found, scan for any .gguf
614+
var modelFile = searchDirs.flatMap { dir ->
611615
modelNames.map { name -> File(dir, name) }
612616
}.firstOrNull { it.exists() }
617+
618+
if (modelFile == null) {
619+
// Secondary heuristic: Pick any .gguf file available
620+
modelFile = searchDirs.mapNotNull { dir ->
621+
dir?.listFiles { _, name -> name.endsWith(".gguf") || name.endsWith(".nexa") }?.firstOrNull()
622+
}.firstOrNull()
623+
}
613624

614625
if (modelFile != null) {
615626
val variant = when {
616627
modelFile.name.contains("E4B") -> "E4B (full)"
617628
modelFile.name.contains("E2B") -> "E2B (lite)"
629+
modelFile.name.endsWith(".gguf") -> "GGUF Open Weights"
618630
else -> "unknown variant"
619631
}
620632
Timber.i("📦 Found model: ${modelFile.name} ($variant) in ${modelFile.parent}")
621633
}
622634

623635
if (modelFile == null) {
624636
val searchedPaths = searchDirs.mapNotNull { it?.absolutePath }
625-
Timber.e("No model found! Searched: $searchedPaths for: $modelNames")
626-
updateNotification("ERROR: No model found. Place .litertlm in app folder or Downloads")
637+
Timber.e("No model found! Searched: $searchedPaths")
638+
updateNotification("ERROR: No model found. Place .litertlm or .gguf in app folder/Downloads")
627639
return
628640
}
629641

630-
updateNotification("Loading Gemma...")
631-
val newEngine = GemmaEngine(applicationContext)
632-
// Pass empty system prompt here — KoogAgent.initialize() sets the real one
633-
// via softReset(buildSystemPrompt()) immediately after engine init.
634-
// Previously BASE_SYSTEM_PROMPT was injected here AND buildSystemPrompt() was
635-
// called on first flush, creating two competing identities on cold start.
636-
val error = newEngine.initialize(modelFile.absolutePath, "")
637-
if (error != null) {
638-
val hint = when {
639-
error.contains("memory", ignoreCase = true) || error.contains("OOM", ignoreCase = true) ->
640-
" (Try E2B model on this device)"
641-
error.contains("GPU", ignoreCase = true) ->
642-
" (GPU init failed — device may not support this model)"
643-
else -> ""
642+
updateNotification("Loading Model Core...")
643+
644+
val newEngine: LlmBackend = if (modelFile.name.endsWith(".gguf") || modelFile.name.endsWith(".nexa")) {
645+
NexaEngine(applicationContext).apply {
646+
val error = initialize(modelFile.absolutePath, "")
647+
if (error != null) {
648+
Timber.e("NexaEngine load failed: $error")
649+
updateNotification("GGUF Load Error: ${error.take(80)}")
650+
return
651+
}
652+
}
653+
} else {
654+
GemmaEngine(applicationContext).apply {
655+
val error = initialize(modelFile.absolutePath, "")
656+
if (error != null) {
657+
val hint = when {
658+
error.contains("memory", ignoreCase = true) || error.contains("OOM", ignoreCase = true) ->
659+
" (Try E2B model on this device)"
660+
error.contains("GPU", ignoreCase = true) ->
661+
" (GPU init failed — device may not support this model)"
662+
else -> ""
663+
}
664+
Timber.e("GemmaEngine load failed: $error")
665+
updateNotification("LiteRT Load Error: ${error.take(80)}$hint")
666+
return
667+
}
644668
}
645-
Timber.e("Model load failed: $error")
646-
updateNotification("Load Error: ${error.take(80)}$hint")
647-
return
648669
}
649670

650671
// Atomic set (Kimi K2 Fix)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.gemma.api
2+
3+
import android.graphics.Bitmap
4+
5+
interface LlmBackend {
6+
val activeBackend: String?
7+
fun softReset(systemPrompt: String)
8+
fun hardReset()
9+
fun cleanup()
10+
11+
suspend fun generateOneShot(prompt: String): String
12+
13+
suspend fun generateResponse(
14+
prompt: String,
15+
images: List<Bitmap> = emptyList(),
16+
audioData: ByteArray? = null
17+
): String
18+
19+
fun streamResponse(
20+
prompt: String,
21+
images: List<Bitmap> = emptyList(),
22+
audioData: ByteArray? = null,
23+
onToken: (String) -> Unit,
24+
onComplete: (String) -> Unit,
25+
onError: (String) -> Unit
26+
)
27+
}

app/src/main/kotlin/com/gemma/api/MainActivity.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class MainActivity : Activity(), GemmaService.UiCallback {
8181

8282
override fun onCreate(savedInstanceState: Bundle?) {
8383
super.onCreate(savedInstanceState)
84+
actionBar?.hide()
8485
Timber.i("🌀 Entering the System Check...")
8586

8687
// Init Voice
@@ -283,6 +284,30 @@ class MainActivity : Activity(), GemmaService.UiCallback {
283284
btnSend = findViewById(R.id.btnSend)
284285
thinkingProgress = findViewById(R.id.thinkingProgress)
285286
thinkingText = findViewById(R.id.thinkingText)
287+
val btnSettings = findViewById<android.view.View>(R.id.btnSettings)
288+
289+
btnSettings?.setOnClickListener { view ->
290+
val popup = android.widget.PopupMenu(this, view)
291+
popup.menuInflater.inflate(R.menu.chat_settings_menu, popup.menu)
292+
popup.setOnMenuItemClickListener { item ->
293+
when (item.itemId) {
294+
R.id.action_transparent_wallpaper -> {
295+
try {
296+
val intent = Intent(android.app.WallpaperManager.ACTION_CHANGE_LIVE_WALLPAPER).apply {
297+
putExtra(android.app.WallpaperManager.EXTRA_LIVE_WALLPAPER_COMPONENT,
298+
ComponentName(this@MainActivity, com.gemma.api.ui.CameraWallpaperService::class.java))
299+
}
300+
startActivity(intent)
301+
} catch (e: Exception) {
302+
Timber.e(e, "Setup Live Wallpaper Intent Failed")
303+
}
304+
true
305+
}
306+
else -> false
307+
}
308+
}
309+
popup.show()
310+
}
286311

287312
chatAdapter = ChatAdapter()
288313
chatRecyclerView?.apply {

0 commit comments

Comments
 (0)