From 8b474d81be46069bc592e8c21b50432211e91c61 Mon Sep 17 00:00:00 2001 From: mmanningau Date: Fri, 27 Feb 2026 15:48:50 +1100 Subject: [PATCH] UI and Skills settings update to now allow for on the fly model changes and the skills directory to be changed. Both confirmed as working --- .../java/net/mmanningau/alice/LlmManager.kt | 48 ++++---- .../java/net/mmanningau/alice/MainActivity.kt | 113 ++++++++++++++---- .../java/net/mmanningau/alice/SkillManager.kt | 9 ++ .../mmanningau/alice/net/mmanningau/alice.kt | 21 +++- 4 files changed, 137 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/net/mmanningau/alice/LlmManager.kt b/app/src/main/java/net/mmanningau/alice/LlmManager.kt index 455072d..a323310 100644 --- a/app/src/main/java/net/mmanningau/alice/LlmManager.kt +++ b/app/src/main/java/net/mmanningau/alice/LlmManager.kt @@ -11,58 +11,54 @@ import java.time.Duration object LlmManager { - private const val LOCAL_LLM_URL = "http://10.0.2.2:11434/v1" + // 1. We make the model variable so we can rebuild it when settings change + private var chatModel: ChatLanguageModel? = null - private val chatModel: ChatLanguageModel = OpenAiChatModel.builder() - .baseUrl(LOCAL_LLM_URL) - .apiKey("dummy-key") - .modelName("qwen3:8b") // Ensure this matches your Ollama model! - .timeout(Duration.ofMinutes(3)) - .maxRetries(0) - .logRequests(true) - .logResponses(true) - .build() - - // We manually maintain the conversation state now private val chatHistory = mutableListOf( SystemMessage("You are Alice, a highly capable local AI assistant. You provide concise, direct answers.") ) - fun chat(userText: String): String { - // 1. Add user's new message to memory - chatHistory.add(UserMessage(userText)) + // 2. The UI will call this when you hit "Save" + fun initialize(url: String, modelName: String) { + chatModel = OpenAiChatModel.builder() + .baseUrl(url) + .apiKey("dummy-key") + .modelName(modelName) + .timeout(Duration.ofMinutes(3)) + .maxRetries(0) + .logRequests(true) + .logResponses(true) + .build() + } - // 2. Fetch all current tools dynamically from the folder + fun chat(userText: String): String { + // Safety check in case the model hasn't been built yet + val currentModel = chatModel ?: return "Error: LLM engine not initialized. Please check Settings." + + chatHistory.add(UserMessage(userText)) val toolSpecs = SkillManager.loadSkills() - // 3. Send the entire history and the tools to the LLM - var response = chatModel.generate(chatHistory, toolSpecs) + // Use the active model + var response = currentModel.generate(chatHistory, toolSpecs) var aiMessage: AiMessage = response.content() chatHistory.add(aiMessage) - // 4. THE EXECUTION LOOP - // If the LLM decides it needs to run a tool, it will set hasToolExecutionRequests() to true. while (aiMessage.hasToolExecutionRequests()) { - for (request in aiMessage.toolExecutionRequests()) { val toolName = request.name() val arguments = request.arguments() - // Send the request across the bridge to Python! val toolResult = SkillManager.executeSkill(toolName, arguments) - // Package the result and add it to the memory val toolMessage = ToolExecutionResultMessage(request.id(), toolName, toolResult) chatHistory.add(toolMessage) } - // Ping the LLM again with the new tool results so it can formulate a final answer - response = chatModel.generate(chatHistory, toolSpecs) + response = currentModel.generate(chatHistory, toolSpecs) aiMessage = response.content() chatHistory.add(aiMessage) } - // 5. Return the final conversational text to the UI return aiMessage.text() } } \ No newline at end of file diff --git a/app/src/main/java/net/mmanningau/alice/MainActivity.kt b/app/src/main/java/net/mmanningau/alice/MainActivity.kt index b5b1fd1..1f1d835 100644 --- a/app/src/main/java/net/mmanningau/alice/MainActivity.kt +++ b/app/src/main/java/net/mmanningau/alice/MainActivity.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Build @@ -18,9 +20,12 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalContext +import android.content.Context import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch + // 1. A simple data class to hold our messages data class ChatMessage(val text: String, val isUser: Boolean) @@ -72,7 +77,7 @@ fun MainChatScreen() { // Navigation item for Skills Library NavigationDrawerItem( - label = { Text("Skills Library") }, + label = { Text("Settings & Skills") }, selected = currentScreen == "Skills", icon = { Icon(Icons.Default.Build, contentDescription = "Skills") }, onClick = { @@ -176,7 +181,7 @@ fun MainChatScreen() { } else if (currentScreen == "Skills") { // LOAD THE NEW SCREEN - SkillsLibraryScreen( + SettingsScreen( onBackClicked = { currentScreen = "Chat" } ) } @@ -212,14 +217,20 @@ fun ChatBubble(message: ChatMessage) { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SkillsLibraryScreen(onBackClicked: () -> Unit) { - // Grab the path from our Manager - val skillsPath = SkillManager.skillsDirectory?.absolutePath ?: "Path unavailable" +fun SettingsScreen(onBackClicked: () -> Unit) { + val context = LocalContext.current + val prefs = context.getSharedPreferences("AlicePrefs", Context.MODE_PRIVATE) + + // Load state from SharedPreferences + var llmMode by remember { mutableStateOf(prefs.getString("llmMode", "Remote") ?: "Remote") } + var llmUrl by remember { mutableStateOf(prefs.getString("llmUrl", "http://10.0.2.2:11434/v1") ?: "") } + var modelName by remember { mutableStateOf(prefs.getString("modelName", "llama3.2") ?: "") } + var skillsPath by remember { mutableStateOf(prefs.getString("skillsPath", SkillManager.skillsDirectory?.absolutePath ?: "") ?: "") } Scaffold( topBar = { TopAppBar( - title = { Text("Skills Library") }, + title = { Text("Settings & Skills") }, navigationIcon = { IconButton(onClick = onBackClicked) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") @@ -232,38 +243,88 @@ fun SkillsLibraryScreen(onBackClicked: () -> Unit) { ) } ) { paddingValues -> + // We use verticalScroll so the keyboard doesn't hide the text fields! Column( modifier = Modifier .fillMaxSize() .padding(paddingValues) .padding(16.dp) + .verticalScroll(rememberScrollState()) ) { - Text( - text = "Local Skill Directory", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) + Text("LLM Engine Configuration", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) Spacer(modifier = Modifier.height(8.dp)) - // Displays the actual folder path on the device - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = skillsPath, - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyMedium, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + // The Toggle! + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = llmMode == "Remote", + onClick = { + llmMode = "Remote" + llmUrl = "http://10.0.2.2:11434/v1" // Auto-fill standard Ollama host + } ) + Text("Remote Host (Ollama)") + Spacer(modifier = Modifier.width(16.dp)) + RadioButton( + selected = llmMode == "Local", + onClick = { + llmMode = "Local" + llmUrl = "http://localhost:8080/v1" // Auto-fill standard Llama.cpp host + } + ) + Text("On-Device") } - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = "Drop your Python scripts (script.py) and JSON configurations (manifest.json) into subfolders within this directory to expand Alice's capabilities.", - style = MaterialTheme.typography.bodyMedium + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = llmUrl, + onValueChange = { llmUrl = it }, + label = { Text("LLM Server URL") }, + modifier = Modifier.fillMaxWidth() ) + + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = modelName, + onValueChange = { modelName = it }, + label = { Text("Model Name (e.g., llama3.2)") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(32.dp)) + Text("Skills Library Path", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = skillsPath, + onValueChange = { skillsPath = it }, + label = { Text("Local Directory Path") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(32.dp)) + Button( + onClick = { + // 1. Save all inputs to device memory + prefs.edit() + .putString("llmMode", llmMode) + .putString("llmUrl", llmUrl) + .putString("modelName", modelName) + .putString("skillsPath", skillsPath) + .apply() + + // 2. Hot-reload the engines with the new settings + LlmManager.initialize(llmUrl, modelName) + SkillManager.updateDirectory(skillsPath) + + // 3. Return to chat + onBackClicked() + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) { + Text("Save and Apply") + } } } } \ No newline at end of file diff --git a/app/src/main/java/net/mmanningau/alice/SkillManager.kt b/app/src/main/java/net/mmanningau/alice/SkillManager.kt index ba56ac6..81d096c 100644 --- a/app/src/main/java/net/mmanningau/alice/SkillManager.kt +++ b/app/src/main/java/net/mmanningau/alice/SkillManager.kt @@ -22,6 +22,15 @@ object SkillManager { skillsDirectory = skillsDir } + fun updateDirectory(newPath: String) { + val newDir = File(newPath) + if (!newDir.exists()) { + newDir.mkdirs() // Create it if the user typed a new path + } + skillsDirectory = newDir + Log.i("AliceSkills", "Skills directory updated to: ${newDir.absolutePath}") + } + // 1. THE PARSER: Reads the folders and creates LangChain4j Tool Rules fun loadSkills(): List { val toolSpecs = mutableListOf() diff --git a/app/src/main/java/net/mmanningau/alice/net/mmanningau/alice.kt b/app/src/main/java/net/mmanningau/alice/net/mmanningau/alice.kt index 5694d7f..f968032 100644 --- a/app/src/main/java/net/mmanningau/alice/net/mmanningau/alice.kt +++ b/app/src/main/java/net/mmanningau/alice/net/mmanningau/alice.kt @@ -1,6 +1,7 @@ package net.mmanningau.alice import android.app.Application +import android.content.Context import com.chaquo.python.Python import com.chaquo.python.android.AndroidPlatform @@ -12,7 +13,23 @@ class alice : Application() { if (!Python.isStarted()) { Python.start(AndroidPlatform(this)) } - // ADD THIS: Create the skills folder and resolve the path immediately - SkillManager.initialize(this) + // 1. Grab saved settings (or defaults if it's the first time booting) + val prefs = getSharedPreferences("AlicePrefs", Context.MODE_PRIVATE) + val savedUrl = prefs.getString("llmUrl", "http://10.0.2.2:11434/v1") ?: "http://10.0.2.2:11434/v1" + val savedModel = prefs.getString("modelName", "llama3.2") ?: "llama3.2" + val savedSkillsPath = prefs.getString("skillsPath", "") ?: "" + + // 2. Initialize the Skills folder + SkillManager.initialize(this) // This creates the safe default + + if (savedSkillsPath.isNotBlank()) { + SkillManager.updateDirectory(savedSkillsPath) // Override if the user saved a custom one + } else { + // If it's the first boot, save the default path so the UI can display it + prefs.edit().putString("skillsPath", SkillManager.skillsDirectory?.absolutePath).apply() + } + + // 3. Boot the LLM Engine + LlmManager.initialize(savedUrl, savedModel) } } \ No newline at end of file