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

This commit is contained in:
2026-02-27 15:48:50 +11:00
parent 1a3d6bde7c
commit 8b474d81be
4 changed files with 137 additions and 54 deletions

View File

@@ -11,58 +11,54 @@ import java.time.Duration
object LlmManager { 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() private val chatHistory = mutableListOf<ChatMessage>(
.baseUrl(LOCAL_LLM_URL) SystemMessage("You are Alice, a highly capable local AI assistant. You provide concise, direct answers.")
)
// 2. The UI will call this when you hit "Save"
fun initialize(url: String, modelName: String) {
chatModel = OpenAiChatModel.builder()
.baseUrl(url)
.apiKey("dummy-key") .apiKey("dummy-key")
.modelName("qwen3:8b") // Ensure this matches your Ollama model! .modelName(modelName)
.timeout(Duration.ofMinutes(3)) .timeout(Duration.ofMinutes(3))
.maxRetries(0) .maxRetries(0)
.logRequests(true) .logRequests(true)
.logResponses(true) .logResponses(true)
.build() .build()
}
// We manually maintain the conversation state now
private val chatHistory = mutableListOf<ChatMessage>(
SystemMessage("You are Alice, a highly capable local AI assistant. You provide concise, direct answers.")
)
fun chat(userText: String): String { fun chat(userText: String): String {
// 1. Add user's new message to memory // Safety check in case the model hasn't been built yet
chatHistory.add(UserMessage(userText)) val currentModel = chatModel ?: return "Error: LLM engine not initialized. Please check Settings."
// 2. Fetch all current tools dynamically from the folder chatHistory.add(UserMessage(userText))
val toolSpecs = SkillManager.loadSkills() val toolSpecs = SkillManager.loadSkills()
// 3. Send the entire history and the tools to the LLM // Use the active model
var response = chatModel.generate(chatHistory, toolSpecs) var response = currentModel.generate(chatHistory, toolSpecs)
var aiMessage: AiMessage = response.content() var aiMessage: AiMessage = response.content()
chatHistory.add(aiMessage) 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()) { while (aiMessage.hasToolExecutionRequests()) {
for (request in aiMessage.toolExecutionRequests()) { for (request in aiMessage.toolExecutionRequests()) {
val toolName = request.name() val toolName = request.name()
val arguments = request.arguments() val arguments = request.arguments()
// Send the request across the bridge to Python!
val toolResult = SkillManager.executeSkill(toolName, arguments) val toolResult = SkillManager.executeSkill(toolName, arguments)
// Package the result and add it to the memory
val toolMessage = ToolExecutionResultMessage(request.id(), toolName, toolResult) val toolMessage = ToolExecutionResultMessage(request.id(), toolName, toolResult)
chatHistory.add(toolMessage) chatHistory.add(toolMessage)
} }
// Ping the LLM again with the new tool results so it can formulate a final answer response = currentModel.generate(chatHistory, toolSpecs)
response = chatModel.generate(chatHistory, toolSpecs)
aiMessage = response.content() aiMessage = response.content()
chatHistory.add(aiMessage) chatHistory.add(aiMessage)
} }
// 5. Return the final conversational text to the UI
return aiMessage.text() return aiMessage.text()
} }
} }

View File

@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape 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.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Build
@@ -18,9 +20,12 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import android.content.Context
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// 1. A simple data class to hold our messages // 1. A simple data class to hold our messages
data class ChatMessage(val text: String, val isUser: Boolean) data class ChatMessage(val text: String, val isUser: Boolean)
@@ -72,7 +77,7 @@ fun MainChatScreen() {
// Navigation item for Skills Library // Navigation item for Skills Library
NavigationDrawerItem( NavigationDrawerItem(
label = { Text("Skills Library") }, label = { Text("Settings & Skills") },
selected = currentScreen == "Skills", selected = currentScreen == "Skills",
icon = { Icon(Icons.Default.Build, contentDescription = "Skills") }, icon = { Icon(Icons.Default.Build, contentDescription = "Skills") },
onClick = { onClick = {
@@ -176,7 +181,7 @@ fun MainChatScreen() {
} else if (currentScreen == "Skills") { } else if (currentScreen == "Skills") {
// LOAD THE NEW SCREEN // LOAD THE NEW SCREEN
SkillsLibraryScreen( SettingsScreen(
onBackClicked = { currentScreen = "Chat" } onBackClicked = { currentScreen = "Chat" }
) )
} }
@@ -212,14 +217,20 @@ fun ChatBubble(message: ChatMessage) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SkillsLibraryScreen(onBackClicked: () -> Unit) { fun SettingsScreen(onBackClicked: () -> Unit) {
// Grab the path from our Manager val context = LocalContext.current
val skillsPath = SkillManager.skillsDirectory?.absolutePath ?: "Path unavailable" 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Skills Library") }, title = { Text("Settings & Skills") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBackClicked) { IconButton(onClick = onBackClicked) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back") Icon(Icons.Default.ArrowBack, contentDescription = "Back")
@@ -232,38 +243,88 @@ fun SkillsLibraryScreen(onBackClicked: () -> Unit) {
) )
} }
) { paddingValues -> ) { paddingValues ->
// We use verticalScroll so the keyboard doesn't hide the text fields!
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
.padding(16.dp) .padding(16.dp)
.verticalScroll(rememberScrollState())
) { ) {
Text( Text("LLM Engine Configuration", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
text = "Local Skill Directory",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Displays the actual folder path on the device // The Toggle!
Surface( Row(verticalAlignment = Alignment.CenterVertically) {
color = MaterialTheme.colorScheme.surfaceVariant, RadioButton(
shape = RoundedCornerShape(8.dp), selected = llmMode == "Remote",
modifier = Modifier.fillMaxWidth() onClick = {
) { llmMode = "Remote"
Text( llmUrl = "http://10.0.2.2:11434/v1" // Auto-fill standard Ollama host
text = skillsPath, }
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
) )
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)) Spacer(modifier = Modifier.height(8.dp))
Text( OutlinedTextField(
text = "Drop your Python scripts (script.py) and JSON configurations (manifest.json) into subfolders within this directory to expand Alice's capabilities.", value = llmUrl,
style = MaterialTheme.typography.bodyMedium 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")
}
} }
} }
} }

View File

@@ -22,6 +22,15 @@ object SkillManager {
skillsDirectory = skillsDir 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 // 1. THE PARSER: Reads the folders and creates LangChain4j Tool Rules
fun loadSkills(): List<ToolSpecification> { fun loadSkills(): List<ToolSpecification> {
val toolSpecs = mutableListOf<ToolSpecification>() val toolSpecs = mutableListOf<ToolSpecification>()

View File

@@ -1,6 +1,7 @@
package net.mmanningau.alice package net.mmanningau.alice
import android.app.Application import android.app.Application
import android.content.Context
import com.chaquo.python.Python import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform import com.chaquo.python.android.AndroidPlatform
@@ -12,7 +13,23 @@ class alice : Application() {
if (!Python.isStarted()) { if (!Python.isStarted()) {
Python.start(AndroidPlatform(this)) Python.start(AndroidPlatform(this))
} }
// ADD THIS: Create the skills folder and resolve the path immediately // 1. Grab saved settings (or defaults if it's the first time booting)
SkillManager.initialize(this) 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)
} }
} }