Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions android/app/src/main/cpp/gguf_runtime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -431,9 +431,10 @@ static CreateResult try_create_session(const char* path, int n_gpu_layers, int n
cparams.abort_callback = abort_callback;
cparams.abort_callback_data = &operation->abort_flag;
if (n_gpu_layers > 0) {
cparams.flash_attn_type = LLAMA_FLASH_ATTN_TYPE_DISABLED;
cparams.type_k = GGML_TYPE_F16;
cparams.type_v = GGML_TYPE_F16;
// Standard attention hard-aborts on some Vulkan GPUs (uncatchable); flash attn avoids it and requires quantized KV.
cparams.flash_attn_type = LLAMA_FLASH_ATTN_TYPE_ENABLED;
cparams.type_k = GGML_TYPE_Q4_0;
cparams.type_v = GGML_TYPE_Q4_0;
cparams.offload_kqv = true;
} else {
cparams.flash_attn_type = LLAMA_FLASH_ATTN_TYPE_ENABLED;
Expand Down Expand Up @@ -792,6 +793,8 @@ Java_dev_androidagent_localmodel_gguf_GgufNative_generate(
sampler_guard.smpl = llama_sampler_chain_init(sparams);
llama_sampler_chain_add(sampler_guard.smpl, llama_sampler_init_top_k(static_cast<int32_t>(top_k)));
llama_sampler_chain_add(sampler_guard.smpl, llama_sampler_init_top_p(static_cast<float>(top_p), 1));
// Prevents small-model repetition loops; must come after top-k/top-p per llama.cpp's guidance.
llama_sampler_chain_add(sampler_guard.smpl, llama_sampler_init_penalties(64, 1.1f, 0.0f, 0.0f));
llama_sampler_chain_add(sampler_guard.smpl, llama_sampler_init_temp(static_cast<float>(temperature)));
llama_sampler_chain_add(sampler_guard.smpl, llama_sampler_init_dist(static_cast<uint32_t>(time(nullptr))));

Expand Down
11 changes: 10 additions & 1 deletion android/app/src/main/java/dev/androidagent/AppShellActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope
import dev.androidagent.chat.ChatAttachmentKind
import dev.androidagent.chat.ChatAttachmentStore
import dev.androidagent.localmodel.LocalModelImportStatus
import dev.androidagent.localmodel.LocalModelStore
import dev.androidagent.settings.DiagnosticsEventLog
import dev.androidagent.settings.DiagnosticsEventLevel
Expand Down Expand Up @@ -102,6 +103,7 @@ class AppShellActivity : ComponentActivity() {
}
localModelImportJob?.cancel()
DiagnosticsEventLog.append(DiagnosticsEventLevel.Info, "Importing local model")
LocalModelImportStatus.publish("Importing local model…")
localModelImportJob = lifecycleScope.launch {
try {
var lastProgressMarker = -1L
Expand All @@ -116,14 +118,21 @@ class AppShellActivity : ComponentActivity() {
"${copied / (1024 * 1024)} MB"
}
DiagnosticsEventLog.append(DiagnosticsEventLevel.Info, "Importing local model: $label")
LocalModelImportStatus.publish("Importing local model… $label")
}
}
pendingLocalModelPathField?.setText(path)
DiagnosticsEventLog.append(DiagnosticsEventLevel.Success, "Imported local model")
LocalModelImportStatus.publish("Imported ✓ ${LocalModelStore.displayName(path)}")
mainHandler.postDelayed({ LocalModelImportStatus.publish(null) }, 2500)
} catch (cancelled: CancellationException) {
LocalModelImportStatus.publish(null)
throw cancelled
} catch (error: Exception) {
DiagnosticsEventLog.append(DiagnosticsEventLevel.Error, error.message ?: "Import failed")
val message = error.message ?: "Import failed"
DiagnosticsEventLog.append(DiagnosticsEventLevel.Error, message)
LocalModelImportStatus.publish("Import failed: $message")
mainHandler.postDelayed({ LocalModelImportStatus.publish(null) }, 4000)
}
}
}
Expand Down
15 changes: 13 additions & 2 deletions android/app/src/main/java/dev/androidagent/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope
import dev.androidagent.avatar.AvatarLibrary
import dev.androidagent.localmodel.LocalModelImportStatus
import dev.androidagent.localmodel.LocalModelStore
import dev.androidagent.settings.SettingsHost
import dev.androidagent.settings.SettingsUi
Expand Down Expand Up @@ -67,21 +68,31 @@ class MainActivity : ComponentActivity() {
}
localModelImportJob?.cancel()
setupBanner.text = "Importing local model..."
LocalModelImportStatus.publish("Importing local model…")
localModelImportJob = lifecycleScope.launch {
try {
var lastProgressMarker = -1L
val path = LocalModelStore.importModel(this@MainActivity, uri) { copied, total ->
val marker = if (total != null && total > 0L) copied * 100L / total else copied / PROGRESS_STEP_BYTES
if (marker == lastProgressMarker) return@importModel
lastProgressMarker = marker
mainHandler.post { setupBanner.text = modelImportProgress(copied, total) }
mainHandler.post {
setupBanner.text = modelImportProgress(copied, total)
LocalModelImportStatus.publish(modelImportProgress(copied, total))
}
}
pendingLocalModelPathField?.setText(path)
setupBanner.text = "Imported local model."
LocalModelImportStatus.publish("Imported ✓ ${LocalModelStore.displayName(path)}")
mainHandler.postDelayed({ LocalModelImportStatus.publish(null) }, 2500)
} catch (cancelled: CancellationException) {
LocalModelImportStatus.publish(null)
throw cancelled
} catch (error: Exception) {
setupBanner.text = error.message ?: "Could not import local model."
val message = error.message ?: "Could not import local model."
setupBanner.text = message
LocalModelImportStatus.publish("Import failed: $message")
mainHandler.postDelayed({ LocalModelImportStatus.publish(null) }, 4000)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ class LocalAgentController(
val systemPrompt = LocalPromptBuilder.systemPrompt(
basePrompt = config.systemPrompt,
toolsAllowed = toolsAllowed,
toolDescriptionsJson = tools.toolDescriptions(runtimeProfile, toolAccess).toString()
toolDescriptionsJson = tools.toolDescriptions(runtimeProfile, toolAccess).toString(),
isTinyModel = LocalModelSize.isTiny(LocalModelStore.displayName(config.localModelPath))
)
val transcript = selectNewestHistory(
history = history,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package dev.androidagent.localmodel

// Only one screen is visible at a time, so a single overwrite-on-build slot is enough.
object LocalModelImportStatus {
@Volatile
private var listener: ((String?) -> Unit)? = null

fun observe(onUpdate: (String?) -> Unit) {
listener = onUpdate
}

fun clearObserver(onUpdate: (String?) -> Unit) {
if (listener === onUpdate) listener = null
}

fun publish(status: String?) {
listener?.invoke(status)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package dev.androidagent.localmodel

// Parses param count from the display name; file size isn't reliable across quant schemes.
internal object LocalModelSize {
private val PARAM_COUNT = Regex("""(\d+(?:\.\d+)?)\s*[Bb](?:\b|-)""")

// Below this, the tool-call JSON protocol degenerates into non-answers.
private const val TINY_THRESHOLD_BILLIONS = 2.0

fun isTiny(displayName: String): Boolean {
val billions = PARAM_COUNT.find(displayName)?.groupValues?.get(1)?.toDoubleOrNull() ?: return false
return billions < TINY_THRESHOLD_BILLIONS
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import dev.androidagent.storage.StoredBlob
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.io.InputStream
Expand All @@ -25,7 +26,8 @@ object LocalModelStore {
)
private val GGUF_LIMITS = modelLimits(
maxItemBytes = 12L * 1024L * 1024L * 1024L,
maxAggregateBytes = 24L * 1024L * 1024L * 1024L
maxAggregateBytes = 24L * 1024L * 1024L * 1024L,
maxBlobCount = 8
)

suspend fun importModel(
Expand Down Expand Up @@ -102,10 +104,53 @@ object LocalModelStore {
formatForDisplayName(path) != null && File(path.trim()).isFile

fun displayName(path: String): String {
val name = File(path.trim()).name
return name.removeSuffix(".litertlm").removeSuffix(".gguf").ifBlank { "Local model" }
val trimmed = path.trim()
if (trimmed.isBlank()) return "No model selected"
val file = File(trimmed)
val fallback = file.name.removeSuffix(".litertlm").removeSuffix(".gguf").ifBlank { "Local model" }
val metadataFile = File(file.parentFile, "${file.nameWithoutExtension}.json")
val storedDisplayName = runCatching {
if (metadataFile.isFile) {
JSONObject(metadataFile.readText()).optString("displayName").takeIf { it.isNotBlank() }
} else {
null
}
}.getOrNull()
return storedDisplayName ?: fallback
}

// Newest first.
internal fun listImportedModels(context: Context): List<ImportedModel> {
return ModelFormat.values().flatMap { format ->
val directory = File(context.filesDir, format.directoryName)
directory.listFiles().orEmpty()
.filter { it.name.endsWith(".json") }
.mapNotNull { metadataFile ->
runCatching {
val json = JSONObject(metadataFile.readText())
val id = json.getString("id")
val payloadFile = File(directory, "$id${format.extension}")
if (!payloadFile.isFile) return@runCatching null
ImportedModel(
path = payloadFile.absolutePath,
displayName = json.optString("displayName").takeIf { it.isNotBlank() } ?: id,
format = format,
sizeBytes = json.optLong("sizeBytes", payloadFile.length()),
createdAt = json.optLong("createdAt", payloadFile.lastModified())
)
}.getOrNull()
}
}.sortedByDescending { it.createdAt }
}

internal data class ImportedModel(
val path: String,
val displayName: String,
val format: ModelFormat,
val sizeBytes: Long,
val createdAt: Long
)

internal enum class ModelFormat(
val extension: String,
val directoryName: String,
Expand All @@ -121,10 +166,10 @@ object LocalModelStore {
val mimeType: String?
)

private fun modelLimits(maxItemBytes: Long, maxAggregateBytes: Long) = BlobStoreLimits(
private fun modelLimits(maxItemBytes: Long, maxAggregateBytes: Long, maxBlobCount: Int = 3) = BlobStoreLimits(
minItemBytes = MIN_MODEL_BYTES,
maxItemBytes = maxItemBytes,
maxBlobCount = 3,
maxBlobCount = maxBlobCount,
maxAggregateBytes = maxAggregateBytes,
freeSpaceReserveBytes = 512L * 1024L * 1024L,
retentionMillis = Long.MAX_VALUE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ internal object LocalPromptBuilder {
fun systemPrompt(
basePrompt: String,
toolsAllowed: Boolean,
toolDescriptionsJson: String
toolDescriptionsJson: String,
isTinyModel: Boolean = false
): String {
// Sub-2B models can't reliably use the tool-call protocol below; skip to a plain prompt.
if (isTinyModel) {
return "Answer the user's question directly and completely, in plain conversational language."
}
val toolPolicy = if (toolsAllowed) {
"""
Tool mode:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import android.widget.LinearLayout
import dev.androidagent.AgentConfigStore
import dev.androidagent.LocalModelBackend
import dev.androidagent.R
import dev.androidagent.localmodel.LocalModelImportStatus
import dev.androidagent.localmodel.LocalModelStore
import dev.androidagent.settings.ColorUtils
import dev.androidagent.settings.SettingsComponents
import dev.androidagent.settings.SettingsComponents.BadgeTone
Expand Down Expand Up @@ -75,8 +77,46 @@ object LocalModelSettingsScreen {
exposeToAccessibility(R.id.openclaw_local_model_path_field, "Local model path")
}

val modelPickerPaths = mutableListOf<String>()
fun modelPickerChoices(): List<String> {
val models = LocalModelStore.listImportedModels(activity)
modelPickerPaths.clear()
modelPickerPaths.addAll(models.map { it.path })
return if (models.isEmpty()) {
listOf("No models imported yet")
} else {
models.map { "${it.displayName} (${it.sizeBytes / (1024 * 1024)} MB)" }
}
}
val initialModelChoices = modelPickerChoices()
val initialModelSelection = modelPickerPaths.indexOf(config.localModelPath).coerceAtLeast(0)
val modelPickerSpinner = SettingsUi.styledSpinner(activity, initialModelChoices, initialModelSelection, tokens)
root.addView(modelPickerSpinner, SettingsComponents.verticalMargin(activity, bottom = DesignTokens.Spacing.md))
SettingsUi.onSpinnerSelectionChanged(modelPickerSpinner) { index ->
modelPickerPaths.getOrNull(index)?.let { path -> pathInput.setText(path) }
}

// Import model card
root.addView(buildImportCard(activity, tokens, callbacks, pathInput, config.localModelPath))
val importProgressLabel = SettingsComponents.body(activity, tokens, "").apply {
visibility = View.GONE
}
root.addView(buildImportCard(activity, tokens, callbacks, pathInput, config.localModelPath, importProgressLabel))
LocalModelImportStatus.observe { status ->
if (status == null) {
importProgressLabel.visibility = View.GONE
val choices = modelPickerChoices()
(modelPickerSpinner.adapter as? android.widget.ArrayAdapter<String>)?.let { adapter ->
adapter.clear()
adapter.addAll(choices)
adapter.notifyDataSetChanged()
}
val selection = modelPickerPaths.indexOf(pathInput.text.toString()).coerceAtLeast(0)
modelPickerSpinner.setSelection(selection)
} else {
importProgressLabel.visibility = View.VISIBLE
importProgressLabel.text = status
}
}

// Backend card
val backends = LocalModelBackend.values().toList()
Expand Down Expand Up @@ -158,7 +198,8 @@ object LocalModelSettingsScreen {
tokens: ThemeTokens,
callbacks: Callbacks,
pathInput: EditText,
currentPath: String
currentPath: String,
progressLabel: android.widget.TextView
): LinearLayout {
val card = SettingsComponents.card(activity, tokens, padding = DesignTokens.Spacing.md)
val row = LinearLayout(activity).apply {
Expand Down Expand Up @@ -191,6 +232,7 @@ object LocalModelSettingsScreen {
}.exposeToAccessibility(R.id.openclaw_local_model_import_button, "Import local model"))

card.addView(row)
card.addView(progressLabel, SettingsComponents.verticalMargin(activity, top = DesignTokens.Spacing.sm))
// Hidden text field driver
card.addView(pathInput)
return card
Expand Down
Loading