Skip to content

Commit 2a5ec34

Browse files
tryptzclaude
authored andcommitted
feat: add local GGUF model import from device storage
Users can now import GGUF model files from their device via the Model Manager. The import flow uses Android's document picker (SAF), copies the file to app storage, and lets users configure the model name, chat template, and context length. Imported models appear in a dedicated "Local Models" section and can be loaded for inference alongside registry models. Changes: - Add LOCAL variant to ModelId enum - Add localId field to ModelDescriptor for unique local model IDs - Add LocalModelEntity + LocalModelDao (Room database v2) - Extend ModelRepository with import/query/delete for local models - Update LoadModelUseCase to resolve local models - Update ChatViewModel to include local models in installed list - Add ImportModelDialog and LocalModelCard composables - Add file picker integration to ModelManagerScreen - Update tests for new ModelId.LOCAL and localId behavior https://claude.ai/code/session_01CX6ZwrWFsvw1xuhTtf7eHU
1 parent fcf0694 commit 2a5ec34

13 files changed

Lines changed: 537 additions & 23 deletions

File tree

app/src/main/java/com/tryptz/neuron/data/local/NeuronDatabase.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@ import com.tryptz.neuron.data.local.entity.*
1010
ConversationEntity::class,
1111
MessageEntity::class,
1212
InstalledModelEntity::class,
13+
LocalModelEntity::class,
1314
CodeSnippetEntity::class
1415
],
15-
version = 1,
16+
version = 2,
1617
exportSchema = true
1718
)
1819
abstract class NeuronDatabase : RoomDatabase() {
1920
abstract fun conversationDao(): ConversationDao
2021
abstract fun messageDao(): MessageDao
2122
abstract fun installedModelDao(): InstalledModelDao
23+
abstract fun localModelDao(): LocalModelDao
2224
abstract fun codeSnippetDao(): CodeSnippetDao
2325
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.tryptz.neuron.data.local.dao
2+
3+
import androidx.room.*
4+
import com.tryptz.neuron.data.local.entity.LocalModelEntity
5+
import kotlinx.coroutines.flow.Flow
6+
7+
@Dao
8+
interface LocalModelDao {
9+
@Query("SELECT * FROM local_models ORDER BY installedAt DESC")
10+
fun observeAll(): Flow<List<LocalModelEntity>>
11+
12+
@Query("SELECT * FROM local_models WHERE id = :id")
13+
suspend fun getById(id: String): LocalModelEntity?
14+
15+
@Insert(onConflict = OnConflictStrategy.REPLACE)
16+
suspend fun insert(model: LocalModelEntity)
17+
18+
@Query("DELETE FROM local_models WHERE id = :id")
19+
suspend fun deleteById(id: String)
20+
}

app/src/main/java/com/tryptz/neuron/data/local/entity/Entities.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ data class InstalledModelEntity(
4747
val installedAt: Long
4848
)
4949

50+
@Entity(tableName = "local_models")
51+
data class LocalModelEntity(
52+
@PrimaryKey val id: String,
53+
val name: String,
54+
val fileName: String,
55+
val filePath: String,
56+
val fileSizeBytes: Long,
57+
val chatTemplate: String,
58+
val contextLength: Int,
59+
val installedAt: Long
60+
)
61+
5062
@Entity(
5163
tableName = "code_snippets",
5264
foreignKeys = [ForeignKey(

app/src/main/java/com/tryptz/neuron/data/repository/ModelRepository.kt

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,39 @@ package com.tryptz.neuron.data.repository
22

33
import android.app.ActivityManager
44
import android.content.Context
5+
import android.net.Uri
56
import com.tryptz.neuron.data.local.dao.InstalledModelDao
7+
import com.tryptz.neuron.data.local.dao.LocalModelDao
68
import com.tryptz.neuron.data.local.entity.InstalledModelEntity
9+
import com.tryptz.neuron.data.local.entity.LocalModelEntity
710
import com.tryptz.neuron.data.model.ModelRegistry
8-
import com.tryptz.neuron.domain.model.ModelDescriptor
11+
import com.tryptz.neuron.domain.model.*
912
import dagger.hilt.android.qualifiers.ApplicationContext
13+
import kotlinx.coroutines.Dispatchers
1014
import kotlinx.coroutines.flow.Flow
1115
import kotlinx.coroutines.flow.map
16+
import kotlinx.coroutines.withContext
1217
import java.io.File
18+
import java.util.UUID
1319
import javax.inject.Inject
1420
import javax.inject.Singleton
1521

1622
@Singleton
1723
class ModelRepository @Inject constructor(
1824
@ApplicationContext private val context: Context,
19-
private val installedModelDao: InstalledModelDao
25+
private val installedModelDao: InstalledModelDao,
26+
private val localModelDao: LocalModelDao
2027
) {
2128
val modelsDir: File = File(context.filesDir, "models").also { it.mkdirs() }
2229

2330
fun getAllDescriptors(): List<ModelDescriptor> = ModelRegistry.models
2431
fun getRecommended(): List<ModelDescriptor> = ModelRegistry.getRecommended()
25-
fun getDescriptorById(id: String): ModelDescriptor? = ModelRegistry.getById(id)
32+
fun getDescriptorById(id: String): ModelDescriptor? = ModelRegistry.getByRawId(id)
2633

2734
fun observeInstalled(): Flow<List<ModelDescriptor>> =
2835
installedModelDao.observeAll().map { entities ->
2936
entities.mapNotNull { entity ->
30-
ModelRegistry.getById(entity.descriptorId)
37+
ModelRegistry.getByRawId(entity.descriptorId)
3138
}
3239
}
3340

@@ -55,6 +62,75 @@ class ModelRepository @Inject constructor(
5562
installedModelDao.deleteById(entity.id)
5663
}
5764

65+
// ── Local model import ──
66+
67+
fun observeLocalModels(): Flow<List<LocalModelEntity>> =
68+
localModelDao.observeAll()
69+
70+
suspend fun getLocalModel(id: String): LocalModelEntity? =
71+
localModelDao.getById(id)
72+
73+
suspend fun importLocalModel(
74+
uri: Uri,
75+
name: String,
76+
chatTemplate: ChatTemplate,
77+
contextLength: Int
78+
): Result<LocalModelEntity> = withContext(Dispatchers.IO) {
79+
runCatching {
80+
val fileName = resolveFileName(uri)
81+
val destFile = File(modelsDir, fileName)
82+
83+
context.contentResolver.openInputStream(uri)?.use { input ->
84+
destFile.outputStream().use { output -> input.copyTo(output) }
85+
} ?: throw IllegalStateException("Cannot read file")
86+
87+
val entity = LocalModelEntity(
88+
id = UUID.randomUUID().toString(),
89+
name = name.ifBlank { fileName.removeSuffix(".gguf") },
90+
fileName = fileName,
91+
filePath = destFile.absolutePath,
92+
fileSizeBytes = destFile.length(),
93+
chatTemplate = chatTemplate.raw,
94+
contextLength = contextLength,
95+
installedAt = System.currentTimeMillis()
96+
)
97+
localModelDao.insert(entity)
98+
entity
99+
}
100+
}
101+
102+
suspend fun deleteLocalModel(id: String) {
103+
val entity = localModelDao.getById(id) ?: return
104+
File(entity.filePath).delete()
105+
localModelDao.deleteById(id)
106+
}
107+
108+
fun buildLocalDescriptor(entity: LocalModelEntity): ModelDescriptor =
109+
ModelDescriptor(
110+
modelId = ModelId.LOCAL,
111+
name = entity.name,
112+
family = "local",
113+
totalParams = "Unknown",
114+
quantization = Quantization.Q4_K_M,
115+
fileSizeMb = (entity.fileSizeBytes / (1024 * 1024)).toInt(),
116+
ramRequiredMb = (entity.fileSizeBytes / (1024 * 1024)).toInt() + 500,
117+
maxContext = entity.contextLength,
118+
supportedBackends = listOf(InferenceBackend.GPU, InferenceBackend.CPU),
119+
chatTemplate = ChatTemplate.fromRaw(entity.chatTemplate),
120+
huggingFaceRepo = "",
121+
huggingFaceFile = "",
122+
localId = entity.id
123+
)
124+
125+
private fun resolveFileName(uri: Uri): String {
126+
val cursor = context.contentResolver.query(uri, null, null, null, null)
127+
val nameFromUri = cursor?.use {
128+
val nameIndex = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
129+
if (it.moveToFirst() && nameIndex >= 0) it.getString(nameIndex) else null
130+
}
131+
return nameFromUri ?: "model_${System.currentTimeMillis()}.gguf"
132+
}
133+
58134
fun getAvailableRamMb(): Int {
59135
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
60136
val memInfo = ActivityManager.MemoryInfo()

app/src/main/java/com/tryptz/neuron/di/AppModule.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ object AppModule {
2727
@Provides fun provideConversationDao(db: NeuronDatabase): ConversationDao = db.conversationDao()
2828
@Provides fun provideMessageDao(db: NeuronDatabase): MessageDao = db.messageDao()
2929
@Provides fun provideInstalledModelDao(db: NeuronDatabase): InstalledModelDao = db.installedModelDao()
30+
@Provides fun provideLocalModelDao(db: NeuronDatabase): LocalModelDao = db.localModelDao()
3031
@Provides fun provideCodeSnippetDao(db: NeuronDatabase): CodeSnippetDao = db.codeSnippetDao()
3132

3233
@Provides

app/src/main/java/com/tryptz/neuron/domain/model/ModelDescriptor.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ data class ModelDescriptor(
3232
val estimatedTokSec: Map<InferenceBackend, IntRange> = emptyMap(),
3333
val huggingFaceRepo: String,
3434
val huggingFaceFile: String,
35-
val recommendationTag: String? = null
35+
val recommendationTag: String? = null,
36+
val localId: String? = null
3637
) {
37-
/** Convenience accessor for the raw string ID. */
38-
val id: String get() = modelId.raw
38+
/** Convenience accessor — uses [localId] for imported models, raw enum ID for registry models. */
39+
val id: String get() = localId ?: modelId.raw
3940
}

app/src/main/java/com/tryptz/neuron/domain/model/ModelId.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ enum class ModelId(val raw: String) {
1414
LLAMA32_1B("llama32-1b-int4"),
1515
QWEN25_7B("qwen25-7b-q4km"),
1616
PHI4_MINI("phi4-3b-int4"),
17-
MISTRAL_SMALL4("mistral-small4-int4");
17+
MISTRAL_SMALL4("mistral-small4-int4"),
18+
LOCAL("local");
1819

1920
companion object {
2021
private val byRaw = entries.associateBy { it.raw }

app/src/main/java/com/tryptz/neuron/domain/usecase/LoadModelUseCase.kt

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,23 @@ class LoadModelUseCase @Inject constructor(
1919
modelId: String,
2020
settings: InferenceSettings
2121
): Result<Unit> {
22+
// Try registry model first, then local model
2223
val descriptor = modelRepo.getDescriptorById(modelId)
23-
?: return Result.failure(IllegalArgumentException("Unknown model: $modelId"))
24-
2524
val modelPath = modelRepo.getModelPath(modelId)
26-
?: return Result.failure(IllegalStateException("Model not installed: $modelId"))
27-
28-
val result = inferenceEngine.loadModel(descriptor, modelPath, settings)
2925

30-
if (result.isSuccess) {
31-
settingsStore.setActiveModel(modelId)
26+
if (descriptor != null && modelPath != null) {
27+
val result = inferenceEngine.loadModel(descriptor, modelPath, settings)
28+
if (result.isSuccess) settingsStore.setActiveModel(modelId)
29+
return result
3230
}
3331

32+
// Check local models
33+
val localModel = modelRepo.getLocalModel(modelId)
34+
?: return Result.failure(IllegalArgumentException("Unknown model: $modelId"))
35+
36+
val localDescriptor = modelRepo.buildLocalDescriptor(localModel)
37+
val result = inferenceEngine.loadModel(localDescriptor, localModel.filePath, settings)
38+
if (result.isSuccess) settingsStore.setActiveModel(modelId)
3439
return result
3540
}
3641
}

app/src/main/java/com/tryptz/neuron/ui/chat/viewmodel/ChatViewModel.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,21 @@ class ChatViewModel @Inject constructor(
6060

6161
init {
6262
viewModelScope.launch {
63-
modelRepo.observeInstalled().collect { models ->
63+
modelRepo.observeInstalled().combine(modelRepo.observeLocalModels()) { registry, local ->
64+
registry + local.map { modelRepo.buildLocalDescriptor(it) }
65+
}.collect { models ->
6466
_uiState.update { it.copy(installedModels = models) }
6567
}
6668
}
6769
viewModelScope.launch {
6870
settingsStore.activeModelId.collect { id ->
69-
_uiState.update { it.copy(activeModel = id?.let { modelRepo.getDescriptorById(it) }) }
71+
_uiState.update { state ->
72+
val descriptor = id?.let { modelId ->
73+
modelRepo.getDescriptorById(modelId)
74+
?: modelRepo.getLocalModel(modelId)?.let { modelRepo.buildLocalDescriptor(it) }
75+
}
76+
state.copy(activeModel = descriptor)
77+
}
7078
}
7179
}
7280
viewModelScope.launch {

0 commit comments

Comments
 (0)