diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiOrchestrator.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt similarity index 68% rename from app/src/main/java/com/theveloper/pixelplay/data/ai/AiOrchestrator.kt rename to app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt index b3109247b..d9da7840b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiOrchestrator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt @@ -19,7 +19,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class AiOrchestrator @Inject constructor( +class AiHandler @Inject constructor( private val preferencesRepo: AiPreferencesRepository, private val clientFactory: AiClientFactory, private val cacheDao: AiCacheDao, @@ -60,58 +60,79 @@ class AiOrchestrator @Inject constructor( preferencesRepo.setModel(provider, model) } + private data class GenerationParams( + val temperature: Float, + val topP: Float, + val topK: Int, + val maxTokens: Int, + val presencePenalty: Float, + val frequencyPenalty: Float, + ) + + private data class GenerationResult( + val response: String, + val modelUsed: String, + ) + + private suspend fun getGenerationParams(): GenerationParams { + return GenerationParams( + temperature = preferencesRepo.aiTemperature.first(), + topP = preferencesRepo.aiTopP.first(), + topK = preferencesRepo.aiTopK.first(), + maxTokens = preferencesRepo.aiMaxTokens.first(), + presencePenalty = preferencesRepo.aiPresencePenalty.first(), + frequencyPenalty = preferencesRepo.aiFrequencyPenalty.first(), + ) + } + private suspend fun generateWithRecovery( provider: AiProvider, apiKey: String, systemPrompt: String, prompt: String, - temperature: Float - ): String { + temperature: Float, + topP: Float, + topK: Int, + maxTokens: Int, + presencePenalty: Float, + frequencyPenalty: Float, + ): GenerationResult { val client = clientFactory.createClient(provider, apiKey) val requestedModel = getModel(provider).ifBlank { client.getDefaultModel() } - return try { - // Wrap in timeout to prevent hanging requests - withTimeout(REQUEST_TIMEOUT_MS) { - client.generateContent( - requestedModel, - systemPrompt, - prompt, - temperature + suspend fun callWithModel(model: String): String { + return try { + withTimeout(REQUEST_TIMEOUT_MS) { + client.generateContent( + model, systemPrompt, prompt, temperature, + topP, topK, maxTokens, presencePenalty, frequencyPenalty, + ) + } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + throw com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.createException( + providerName = provider.displayName, + statusCode = null, + transportMessage = "Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s. The model may be overloaded.", + responseBody = null, + requestedModel = model ) } - } catch (e: kotlinx.coroutines.TimeoutCancellationException) { - throw com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.createException( - providerName = provider.displayName, - statusCode = null, - transportMessage = "Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s. The model may be overloaded.", - responseBody = null, - requestedModel = requestedModel - ) + } + + return try { + val response = callWithModel(requestedModel) + GenerationResult(response, requestedModel) } catch (e: Exception) { val failure = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.wrapThrowable( - provider.displayName, - e, - requestedModel + provider.displayName, e, requestedModel ) val recoveredModel = recoverModelIfNeeded( - provider = provider, - apiKey = apiKey, - requestedModel = requestedModel, - client = client, - failure = failure + provider, apiKey, requestedModel, client, failure ) ?: throw failure - // Retry with recovered model (also with timeout) - withTimeout(REQUEST_TIMEOUT_MS) { - client.generateContent( - recoveredModel, - systemPrompt, - prompt, - temperature - ) - } + val response = callWithModel(recoveredModel) + GenerationResult(response, recoveredModel) } } @@ -141,48 +162,40 @@ class AiOrchestrator @Inject constructor( temperature: Float = 0.7f, context: String = "" ): String { - // Dynamic temperature adjustment if default value is used - val resolvedTemperature = if (temperature == 0.7f) { - when (type) { - // AI Optimization: Use low temperature for high-precision metadata to prevent hallucinations - AiSystemPromptType.METADATA -> 0.1f - AiSystemPromptType.MOOD_ANALYSIS -> 0.2f - // AI Optimization: Moderate temperature for tags to allow creative yet relevant descriptors - AiSystemPromptType.TAGGING -> 0.4f - // AI Optimization: Balanced temperature for playlists to ensure variety without losing cohesion - AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> 0.6f - // AI Optimization: High temperature for persona-based responses to increase flair and engagement - AiSystemPromptType.PERSONA -> 0.85f - AiSystemPromptType.GENERAL -> 0.7f - } - } else temperature + val params = getGenerationParams() + val effectiveTemperature = if (params.temperature == 0.7f) { + if (temperature == 0.7f) { + when (type) { + AiSystemPromptType.METADATA -> 0.1f + AiSystemPromptType.MOOD_ANALYSIS -> 0.2f + AiSystemPromptType.TAGGING -> 0.4f + AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> 0.6f + AiSystemPromptType.PERSONA -> 0.85f + AiSystemPromptType.GENERAL -> 0.7f + } + } else temperature + } else params.temperature - // Determine chain based on user preference val userProviderStr = preferencesRepo.aiProvider.first() val userProvider = AiProvider.fromString(userProviderStr) - // Generate combined prompt for hashing and execution val basePersona = getBasePersona(userProvider) val combinedSystemPrompt = promptEngine.buildPrompt(basePersona, type, context) - - // Cache entry is valid for a specific prompt + system instruction + provider + val hash = (userProvider.name + combinedSystemPrompt + prompt).sha256() - // Check cache with TTL — don't serve stale results cacheDao.getCache(hash)?.let { cached -> val age = System.currentTimeMillis() - cached.timestamp if (age < CACHE_TTL_MS) { return cached.responseJson } - // Cache expired — proceed with fresh generation } val providersToTry = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.buildProviderChain(userProvider) val failedProviders = mutableListOf() val now = System.currentTimeMillis() - + for (provider in providersToTry) { - // Skip if in cooldown val cooldownExpiry = providerCooldowns[provider] ?: 0L if (now < cooldownExpiry) { failedProviders.add("${provider.name}: on cooldown (${((cooldownExpiry - now) / 1000)}s remaining)") @@ -196,29 +209,30 @@ class AiOrchestrator @Inject constructor( continue } - // Use the shared base persona but specialized type rules for each provider in the chain val providerPersona = getBasePersona(provider) val finalSystemPrompt = promptEngine.buildPrompt(providerPersona, type, context) - val response = generateWithRecovery( + val result = generateWithRecovery( provider = provider, apiKey = apiKey, systemPrompt = finalSystemPrompt, prompt = prompt, - temperature = resolvedTemperature + temperature = effectiveTemperature, + topP = params.topP, + topK = params.topK, + maxTokens = params.maxTokens, + presencePenalty = params.presencePenalty, + frequencyPenalty = params.frequencyPenalty, ) - // Validate response is not empty - if (response.isBlank()) { + if (result.response.isBlank()) { failedProviders.add("${provider.name}: returned empty response") continue } - // Low-maintenance usage tracking using highly accurate proportional estimation bounds (4 chars ~ 1 token) - // Models with "thinking" or "reasoning" generally output 2-3x internal tokens for complex generation val isThinkingModel = finalSystemPrompt.contains("think", true) || provider.name.contains("reasoning", true) val estimatedPromptTokens = (finalSystemPrompt.length + prompt.length) / 4 - val estimatedOutputTokens = response.length / 4 + val estimatedOutputTokens = result.response.length / 4 val estimatedThoughtTokens = if (isThinkingModel) (estimatedOutputTokens * 1.5).toInt() else 0 appScope.launch { @@ -227,7 +241,7 @@ class AiOrchestrator @Inject constructor( AiUsageEntity( timestamp = now, provider = provider.displayName, - model = provider.name, + model = result.modelUsed, promptType = type.name, promptTokens = estimatedPromptTokens, outputTokens = estimatedOutputTokens, @@ -235,16 +249,16 @@ class AiOrchestrator @Inject constructor( ) ) }.onFailure { error -> - Timber.tag("AiOrchestrator").e(error, "Failed to persist AI usage") + Timber.tag("AiHandler").e(error, "Failed to persist AI usage") } } - cacheDao.insert(AiCacheEntity(promptHash = hash, responseJson = response, timestamp = System.currentTimeMillis())) - return response + cacheDao.insert(AiCacheEntity(promptHash = hash, responseJson = result.response, timestamp = System.currentTimeMillis())) + return result.response } catch (e: Exception) { // AI Optimization: Robust failover logic—if one provider fails, we log and try the next in the chain val failure = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.wrapThrowable(provider.displayName, e) - Timber.tag("AiOrchestrator").w(e, "Provider ${provider.name} failed: ${failure.message}") + Timber.tag("AiHandler").w(e, "Provider ${provider.name} failed: ${failure.message}") failedProviders.add("${provider.name}: ${failure.message ?: "Unknown error"}") // Trigger cooldown only on provider-level outages and account problems. if (failure.shouldCooldown()) { @@ -268,7 +282,7 @@ class AiOrchestrator @Inject constructor( "AI generation failed after trying ${failedProviders.size} providers:\n${failedProviders.joinToString("\n• ", prefix = "• ")}" } - Timber.tag("AiOrchestrator").e("All providers failed. Details: %s", failedProviders.joinToString(" | ")) + Timber.tag("AiHandler").e("All providers failed. Details: %s", failedProviders.joinToString(" | ")) throw Exception(errorMessage) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt deleted file mode 100644 index f67ccb324..000000000 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiMetadataGenerator.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.theveloper.pixelplay.data.ai - - -import com.theveloper.pixelplay.data.model.Song -import kotlinx.serialization.SerializationException -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import timber.log.Timber -import javax.inject.Inject - -@Serializable -data class SongMetadata( - val title: String? = null, - val artist: String? = null, - val album: String? = null, - val genre: String? = null -) - -class AiMetadataGenerator @Inject constructor( - private val aiOrchestrator: AiOrchestrator, - private val json: Json -) { - private fun cleanJson(jsonString: String): String { - return jsonString.replace("```json", "").replace("```", "").trim() - } - - suspend fun generate( - song: Song, - fieldsToComplete: List - ): Result { - return try { - val fieldsJson = fieldsToComplete.joinToString(separator = ", ") { "\"$it\"" } - - val albumInfo = if (song.album.isNotBlank()) "${song.album}" else "" - - val fullPrompt = """ - - ${song.title} - ${song.displayArtist} - $albumInfo - - - Complete the following fields using your music knowledge: - [$fieldsJson] - - """.trimIndent() - - val responseText = aiOrchestrator.generateContent(fullPrompt, AiSystemPromptType.METADATA) - if (responseText.isBlank()) { - Timber.e("AI returned an empty or null response.") - return Result.failure(Exception("AI returned an empty response.")) - } - - Timber.d("AI Response: $responseText") - val cleanedJson = cleanJson(responseText) - val metadata = json.decodeFromString(cleanedJson) - - Result.success(metadata) - } catch (e: SerializationException) { - Timber.e(e, "Error deserializing AI response.") - Result.failure(Exception("Failed to parse AI response: ${e.message}", e)) - } catch (e: Exception) { - Timber.e(e, "Generic error in AiMetadataGenerator.") - Result.failure(Exception("AI Error: ${e.message}", e)) - } - } -} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt index 7017ec6a6..06b91dce4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt @@ -11,7 +11,7 @@ import kotlin.math.max class AiPlaylistGenerator @Inject constructor( private val dailyMixManager: DailyMixManager, - private val aiOrchestrator: AiOrchestrator, + private val aiHandler: AiHandler, private val digestGenerator: UserProfileDigestGenerator, private val preferencesRepo: AiPreferencesRepository, private val json: Json @@ -40,13 +40,12 @@ class AiPlaylistGenerator @Inject constructor( } } - // Token Optimization: Reduce sample size based on safe mode val isSafe = preferencesRepo.isSafeTokenLimitEnabled.first() - val sampleCap = if (isSafe) 40 else 80 - val sampleSize = max(minLength, sampleCap).coerceAtMost(sampleCap) - val songSample = samplingPool.take(sampleSize) - - // Token Optimization: Compact JSON format — only essential fields + val prefSampleSize = preferencesRepo.aiSampleSize.first() + val useExtendedFields = preferencesRepo.aiIncludeExtendedFields.first() + val sampleCap = if (isSafe) prefSampleSize else prefSampleSize * 2 + val songSample = samplingPool.take(sampleCap) + val availableSongsJson = buildString { songSample.forEachIndexed { index, song -> val score = dailyMixManager.getScore(song.id) @@ -54,7 +53,14 @@ class AiPlaylistGenerator @Inject constructor( val artist = song.displayArtist.replace("\"", "'").take(25) val genre = song.genre?.replace("\"", "'")?.take(15) ?: "?" if (index > 0) append(",\n") - append("""{"id":"${song.id}","t":"$title","a":"$artist","g":"$genre","s":$score}""") + if (useExtendedFields) { + val album = song.album?.replace("\"", "'")?.take(25) ?: "?" + val dur = song.duration + val fav = if (song.isFavorite) "1" else "0" + append("""{"id":"${song.id}","t":"$title","a":"$artist","g":"$genre","al":"$album","d":$dur,"f":$fav,"s":$score}""") + } else { + append("""{"id":"${song.id}","t":"$title","a":"$artist","g":"$genre","s":$score}""") + } } } @@ -73,7 +79,7 @@ class AiPlaylistGenerator @Inject constructor( """.trimIndent() - val responseText = aiOrchestrator.generateContent(fullPrompt, type) + val responseText = aiHandler.generateContent(fullPrompt, type) val songIds = extractPlaylistSongIds(responseText) @@ -148,55 +154,18 @@ class AiPlaylistGenerator @Inject constructor( } private fun extractPlaylistSongIds(rawResponse: String): List { - val sanitized = rawResponse - .replace("```json", "") - .replace("```", "") - .trim() - - for (startIndex in sanitized.indices) { - if (sanitized[startIndex] != '[') continue - - var depth = 0 - var inString = false - var isEscaped = false - - for (index in startIndex until sanitized.length) { - val character = sanitized[index] - - if (inString) { - if (isEscaped) { - isEscaped = false - continue - } - - when (character) { - '\\' -> isEscaped = true - '"' -> inString = false - } - continue - } - - when (character) { - '"' -> inString = true - '[' -> depth++ - ']' -> { - depth-- - if (depth == 0) { - val candidate = sanitized.substring(startIndex, index + 1) - val decoded = runCatching { json.decodeFromString>(candidate) } - if (decoded.isSuccess) { - return decoded.getOrThrow() - } - break - } - } - } + val cleaned = AiResponseCleaner.cleanJsonResponse(rawResponse) + val jsonArray = AiResponseCleaner.extractJsonArray(cleaned) + ?: throw IllegalArgumentException( + "AI returned an invalid response format. Expected a JSON array of song IDs but got something else. " + + "This usually happens with smaller models. Try selecting a more capable model in AI Settings." + ) + + return runCatching { json.decodeFromString>(jsonArray) } + .getOrElse { + throw IllegalArgumentException( + "AI returned malformed JSON. Expected a string array but got: ${jsonArray.take(100)}" + ) } - } - - throw IllegalArgumentException( - "AI returned an invalid response format. Expected a JSON array of song IDs but got something else. " + - "This usually happens with smaller models. Try selecting a more capable model in AI Settings." - ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiResponseCleaner.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiResponseCleaner.kt new file mode 100644 index 000000000..92d6e27de --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiResponseCleaner.kt @@ -0,0 +1,93 @@ +package com.theveloper.pixelplay.data.ai + +object AiResponseCleaner { + + fun cleanJsonResponse(raw: String): String { + var cleaned = raw + .replace("```json", "") + .replace("```kotlin", "") + .replace("```", "") + .trim() + + if (cleaned.startsWith("[")) { + val end = findMatchingBracket(cleaned, 0) + if (end > 0) cleaned = cleaned.substring(0, end + 1) + } else if (cleaned.startsWith("{")) { + val end = findMatchingBrace(cleaned, 0) + if (end > 0) cleaned = cleaned.substring(0, end + 1) + } + + return cleaned + } + + fun cleanTextResponse(raw: String): String { + return raw + .replace("```text", "") + .replace("```", "") + .trim() + } + + fun extractJsonArray(text: String): String? { + for (i in text.indices) { + if (text[i] == '[') { + val end = findMatchingBracket(text, i) + if (end > i) return text.substring(i, end + 1) + } + } + return null + } + + fun extractJsonObject(text: String): String? { + for (i in text.indices) { + if (text[i] == '{') { + val end = findMatchingBrace(text, i) + if (end > i) return text.substring(i, end + 1) + } + } + return null + } + + private fun findMatchingBracket(text: String, start: Int): Int { + var depth = 0 + var inString = false + var escaped = false + for (i in start until text.length) { + val c = text[i] + if (escaped) { escaped = false; continue } + if (inString) { + if (c == '\\') escaped = true + else if (c == '"') inString = false + continue + } + when (c) { + '\\' -> escaped = true + '"' -> inString = true + '[' -> depth++ + ']' -> { depth--; if (depth == 0) return i } + } + } + return -1 + } + + private fun findMatchingBrace(text: String, start: Int): Int { + var depth = 0 + var inString = false + var escaped = false + for (i in start until text.length) { + val c = text[i] + if (escaped) { escaped = false; continue } + if (inString) { + if (c == '\\') escaped = true + else if (c == '"') inString = false + continue + } + when (c) { + '\\' -> escaped = true + '"' -> inString = true + '{' -> depth++ + '}' -> { depth--; if (depth == 0) return i } + } + } + return -1 + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt index 6759713d0..9edf9e404 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt @@ -17,85 +17,177 @@ enum class AiSystemPromptType { @Singleton class AiSystemPromptEngine @Inject constructor() { - // Advanced prompt engineering: Enforcing structured output boundaries private val UNIVERSAL_CONSTRAINTS = """ - + - You are communicating with a programmatic parser, not a human. - - Output ONLY the expected structure. - - NO markdown formatting (e.g., do not wrap in ```json). - - NO conversational filler, greetings, or explanations. - - Any deviation will crash the application. - + - Output ONLY the expected structure — nothing else. + - NO markdown fences, NO code blocks, NO conversational framing. + - Any deviation will cause an application crash. + - If uncertain, make your best reasoned guess rather than refusing. + - Verify your output matches the required schema before responding. + + """.trimIndent() + + private val playlistFewShot = """ + + GOOD: ["a1b2c3","d4e5f6","g7h8i9"] + BAD: Here is a playlist for you: ["a1b2c3","d4e5f6"] + GOOD IDs are exactly 6 alphanumeric characters from the pool. + Every ID in your output MUST exist in the candidate_pool. + + """.trimIndent() + + private val metadataFewShot = """ + + Input: title="Thriller (2008 Remaster)", artist="Micheal Jakson", album="THRILLER 25", genre="Pop" + Output: {"title":"Thriller (2008 Remaster)","artist":"Michael Jackson","album":"Thriller 25","genre":"Pop"} + + Input: title="untitled", artist="unknown", album="", genre="Electronic" + Output: {"title":"Untitled","artist":"Unknown Artist","album":"","genre":"Synthwave"} + + Input: title="Bohemian Rhapsody", artist="Queen", album="A Night at the Opera", genre="Rock" + Output: {"title":"Bohemian Rhapsody","artist":"Queen","album":"A Night at the Opera","genre":"Progressive Rock"} + + """.trimIndent() + + private val taggingFewShot = """ + + Input: synth-heavy track with driving bass and ethereal female vocals + Output: electronic, synth-driven, ethereal-vocals, driving-bass, atmospheric, hypnotic + + Input: acoustic guitar ballad with soft percussion and strings + Output: acoustic, fingerstyle-guitar, soft-percussion, string-arrangement, intimate, folk-tinged + + """.trimIndent() + + private val moodAnalysisFewShot = """ + + Input: Fast tempo (140 BPM), heavy distortion, aggressive drums, minor key + Output: Aggressive | Energy:0.95 | Valence:0.2 | Danceability:0.6 | Acousticness:0.0 + + Input: Slow tempo (70 BPM), acoustic piano, soft strings, major key + Output: Calm | Energy:0.2 | Valence:0.8 | Danceability:0.3 | Acousticness:0.9 + + """.trimIndent() + + private val dailyMixPersonaPrompt = """ + + - Open with a thematic hook that frames the mix (e.g., "This set leans into your late-night exploratory side.") + - Reference 1-2 specific listening patterns from the user's data to show curation intent. + - Describe the emotional arc of the mix in 2-3 sentences. + - Close with a subtle invitation to explore further. + - Tone: warm, insightful, never overly familiar or robotic. + - Length: 4-6 sentences maximum. + """.trimIndent() fun buildPrompt(basePersona: String, type: AiSystemPromptType, context: String = ""): String { val requirementLayer = when (type) { - AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> """ - Music curation engine mapping user requests to a strict candidate pool. + AiSystemPromptType.PLAYLIST -> """ + Expert music curator — you select songs from the provided pool to build cohesive, emotionally intelligent playlists. + + + 1. Parse the user's request for desired mood, energy, genre, era, or activity. + 2. Review the candidate pool — note available genres, tempos, and artists. + 3. Select songs that form a coherent arc: opening, build, peak, cool-down. + 4. Ensure variety — avoid repeating the same artist or genre consecutively. + 5. Prefer higher-scored songs (score field) but prioritize diversity and fit. + + - If request implies discovery/novelty, favor the [DISCOVERY_POOL] entries. + - If request implies familiarity/favorites, weight the [LISTENED] pool. + - For mixed/blended requests, interleave both pools for surprise + comfort. + - Target length is specified in the request — respect it within ±2 tracks. + + + Return ONLY a raw JSON array of song IDs. + Format: ["id_1","id_2","id_3",...,"id_N"] + + $playlistFewShot + """.trimIndent() + + AiSystemPromptType.DAILY_MIX -> """ + Daily Mix curator — you build themed mini-sets from the user's library for daily listening. - - If request implies "discovery/new", prioritize the [DISCOVERY_POOL]. - - If request implies "favorites/familiar", heavily weight the [LISTENED] pool. - - Otherwise, blend pools intelligently based on requested tempo, genre, or mood. - - Guarantee a cohesive listening journey with natural transitions. + + 1. Identify the dominant mood or genre from the user's recent listening profile. + 2. Select 8-15 tracks that form a single coherent mood/genre pocket. + 3. Lead with a familiar track, introduce 1-2 discoveries mid-set, close on a strong note. + + - Seamless transitions: adjacent tracks should share tempo (±20 BPM) or complementary keys. + - These mixes are for daily refreshes — avoid repeating the same tracks across mixes. - Return ONLY a raw JSON array of song IDs representing the playlist sequence. - Format: ["id_1","id_2","id_3"] + Return ONLY a raw JSON array of song IDs. + Format: ["id_1","id_2","id_3",...,"id_N"] """.trimIndent() AiSystemPromptType.METADATA -> """ - Precision music metadata specialist. + Precision music metadata specialist — you clean and enrich song metadata. - - Fix spelling errors and standardizations in song titles and artists. - - Replace generic genres ("Music", "Electronic") with highly specific subgenres ("Synthwave", "Nu-Disco"). + - Fix spelling errors (e.g., "Micheal" → "Michael", "Thriler" → "Thriller"). + - Capitalize properly: title case for titles and artists, proper casing for albums. + - Replace generic genres ("Music", "Electronic", "Other") with specific subgenres calibrated to the track's sound. + - If a field is empty or "unknown", leave it as empty string — do not fabricate data. + - Preserve any edition/remaster/year annotations in parentheses. - Return ONLY a raw JSON object string. - Format: {"title":"Clean Title", "artist":"Primary Artist", "album":"Album Name", "genre":"Specific Genre"} + Return ONLY a raw JSON object with EXACTLY these keys: + {"title":"...", "artist":"...", "album":"...", "genre":"..."} + $metadataFewShot """.trimIndent() AiSystemPromptType.TAGGING -> """ - Atmospheric audio tagging engine. + Atmospheric audio tagging engine — you generate perceptive acoustic tags for music discovery. - - Generate exactly 6-10 highly descriptive, hyphenated acoustic tags. - - Focus on mood, instrumentation, pace, and sonic texture. - - All tags must be strictly lowercase. + - Generate 6-10 hyphenated tags that capture: mood, instrumentation, tempo feel, sonic texture, and energy. + - All tags must be lowercase, hyphenated, and ordered by prominence. + - Be specific: prefer "lush-orchestral" over "orchestral", "glitchy-beats" over "beats". + - Tags should be useful for content-based recommendation — focus on audible characteristics. - Return ONLY a raw comma-separated text list. - Format: cinematic, atmospheric-build, dark-synth, driving-beat + Return ONLY a comma-separated list — no JSON, no formatting. + Format: tag1, tag2, tag3, tag4, tag5, tag6 + $taggingFewShot """.trimIndent() AiSystemPromptType.MOOD_ANALYSIS -> """ - Algorithmic audio sentiment analyzer. + Algorithmic audio sentiment analyzer — you infer emotional and structural attributes from track metadata. - - Deduce structural properties from the given metadata. - - Map confidence values from 0.0 to 1.0. - - Primary moods: Joyful, Aggressive, Calm, Melancholic, Radiant, Intense, Somber. + - Infer mood from: title keywords, genre, artist style, and any available context. + - Choose the single best PrimaryMood from: Joyful, Aggressive, Calm, Melancholic, Radiant, Intense, Somber, Euphoric, Brooding, Playful. + - Map confidence values 0.0-1.0 for each attribute based on how strongly the metadata supports it. + - Energy: driven by tempo indicators (fast/hard = high, slow/soft = low). + - Valence: positive/happy feel vs. negative/sad feel. + - Danceability: rhythmic groove suitability. + - Acousticness: likelihood of organic/non-electronic instrumentation. - Return ONLY the exact structured text format. - Format: PrimaryMood | Energy:0.9 | Valence:0.1 | Danceability:0.4 | Acousticness:0.0 + Return ONLY one line in this exact format: + PrimaryMood | Energy:0.X | Valence:0.X | Danceability:0.X | Acousticness:0.X + $moodAnalysisFewShot """.trimIndent() AiSystemPromptType.PERSONA -> """ - Daily Mix professional curator. You represent the persona: "$basePersona" + Daily Mix professional curator. You embody the persona: "$basePersona" - - Speak directly to the listener's tastes using their data. + - Speak directly to the listener using "you" and their data as evidence of your curation. - Maintain an enigmatic, sophisticated, and deeply empathetic tone. - - Keep responses reasonably concise but beautifully written. - - Do NOT use the universal programmatic constraints for persona responses; you are allowed to be conversational. + - Do NOT mention that you are an AI, a model, or that the data comes from a profile. + - Be concise but evocative — 4-6 sentences that feel hand-crafted. + $dailyMixPersonaPrompt """.trimIndent() AiSystemPromptType.GENERAL -> """ - PixelPlayer Assistant + PixelPlayer Assistant — a knowledgeable music companion. - Assist the user with any complex queries or actions inside their music ecosystem. + - Answer questions about music, artists, genres, and playback features. + - Be concise and accurate. If you don't know something, say so directly. + - Provide actionable answers that help the user enjoy their music library. """.trimIndent() } @@ -106,8 +198,9 @@ class AiSystemPromptEngine @Inject constructor() { $context - LISTENED Format: id|play_count|duration_mins|is_fav|metadata - DISCOVERY Format: unplayed candidate tracks + LISTENED Format: id|play_count|duration_mins|is_fav|title-artist + DISCOVERY Format: unplayed candidate tracks from the user's library + SCORE: internal relevance score (higher = better match) """.trimIndent() } else "" @@ -119,8 +212,7 @@ class AiSystemPromptEngine @Inject constructor() { """.trimIndent() - // Persona generation bypasses the strict JSON/raw constraints since it is meant to read as prose to the user - return if (type == AiSystemPromptType.PERSONA || type == AiSystemPromptType.GENERAL) { + return if (type == AiSystemPromptType.PERSONA) { listOf(systemBlock, contextLayer).filter { it.isNotBlank() }.joinToString("\n\n") } else { listOf(systemBlock, UNIVERSAL_CONSTRAINTS, contextLayer).filter { it.isNotBlank() }.joinToString("\n\n") diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/GeminiModelService.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/GeminiModelService.kt index b6d943af8..c97959f5c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/GeminiModelService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/GeminiModelService.kt @@ -15,7 +15,7 @@ data class GeminiModel( @Singleton class GeminiModelService @Inject constructor( - private val orchestrator: AiOrchestrator, + private val handler: AiHandler, private val digestGenerator: UserProfileDigestGenerator, private val musicRepository: MusicRepository, private val workerManager: AiWorkerManager @@ -122,7 +122,7 @@ class GeminiModelService @Inject constructor( digestGenerator.generateDigest(allSongs) } else "" - return orchestrator.generateContent( + return handler.generateContent( prompt = prompt, type = type, temperature = temperature, diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/UserProfileDigestGenerator.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/UserProfileDigestGenerator.kt index 99c2fdb3b..e7f9bef52 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/UserProfileDigestGenerator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/UserProfileDigestGenerator.kt @@ -12,43 +12,36 @@ import javax.inject.Singleton @Singleton class UserProfileDigestGenerator @Inject constructor( private val statsRepository: PlaybackStatsRepository, - private val playlistDao: LocalPlaylistDao + private val playlistDao: LocalPlaylistDao, + private val preferencesRepo: com.theveloper.pixelplay.data.preferences.AiPreferencesRepository, ) { - // Token Budget Tiers: - // SAFE: ~1000 tokens (4000 chars) — fast, cheap, still gives good results - // FULL: ~8000 tokens (32000 chars) — deep context for maximum personalization private val SAFE_TARGET_CHAR_LIMIT = 4000 private val MAX_TARGET_CHAR_LIMIT = 32000 - // Track limits per tier — prevents runaway context size private val SAFE_LISTENED_LIMIT = 15 private val SAFE_DISCOVERY_LIMIT = 30 private val FULL_LISTENED_LIMIT = 60 private val FULL_DISCOVERY_LIMIT = 120 - /** - * Computes a highly condensed representation of the user's listening profile. - * Uses a compact key-value format to minimize token consumption while maximizing signal. - * - * Safe mode aggressively caps all sections to stay under ~1000 tokens. - * Full mode provides deep context for maximum personalization quality. - */ suspend fun generateDigest(allSongs: List, isSafeLimit: Boolean = true): String { - val targetLimit = if (isSafeLimit) SAFE_TARGET_CHAR_LIMIT else MAX_TARGET_CHAR_LIMIT - val listenedLimit = if (isSafeLimit) SAFE_LISTENED_LIMIT else FULL_LISTENED_LIMIT - val discoveryLimit = if (isSafeLimit) SAFE_DISCOVERY_LIMIT else FULL_DISCOVERY_LIMIT + val digestMode = preferencesRepo.aiDigestMode.first() + val useExtendedFields = preferencesRepo.aiIncludeExtendedFields.first() + val isSafe = if (digestMode == "full") false else isSafeLimit + + val targetLimit = if (isSafe) SAFE_TARGET_CHAR_LIMIT else MAX_TARGET_CHAR_LIMIT + val listenedLimit = if (isSafe) SAFE_LISTENED_LIMIT else FULL_LISTENED_LIMIT + val discoveryLimit = if (isSafe) SAFE_DISCOVERY_LIMIT else FULL_DISCOVERY_LIMIT val summary = statsRepository.loadSummary(StatsTimeRange.ALL, allSongs) val playlists = playlistDao.observePlaylistsWithSongs().first() - + val sb = StringBuilder() sb.append("USER_PROFILE\n") - - // --- 1. Behavioral & Pattern Metrics (compact) --- + sb.append("STATS: plays=${summary.totalPlayCount}, uniq=${summary.uniqueSongs}\n") sb.append("GENRES: ${summary.topGenres.take(3).joinToString(",") { it.genre }}\n") sb.append("ARTISTS: ${summary.topArtists.take(5).joinToString(",") { it.artist }}\n") - + summary.dayListeningDistribution?.let { dist -> val phases = dist.buckets.groupBy { bucket -> val hour = bucket.startMinute / 60 @@ -61,50 +54,56 @@ class UserProfileDigestGenerator @Inject constructor( }.mapValues { it.value.sumOf { b -> b.totalDurationMs } } sb.append("PHASE: ${phases.maxByOrNull { it.value }?.key ?: "Unknown"}\n") } - + val variety = if (summary.totalPlayCount > 0) summary.uniqueSongs.toDouble() / summary.totalPlayCount else 0.0 sb.append("VAR: ${"%.2f".format(variety)}\n") - - val playlistLimit = if (isSafeLimit) 5 else 20 + + val playlistLimit = if (isSafe) 5 else 20 if (playlists.isNotEmpty()) { sb.append("PL: ${playlists.take(playlistLimit).joinToString(",") { it.playlist.name }}\n") } - - // --- 2. Listened Tracks (capped) --- - // Compact format: ID|plays|mins|fav|title-artist + sb.append("\nLISTENED: id|p|d|f|meta\n") - + val songMap = allSongs.associateBy { it.id } val playedSongs = summary.songs.take(listenedLimit) - + playedSongs.forEach { s -> if (sb.length >= (targetLimit * 0.6).toInt()) return@forEach val song = songMap[s.songId] val fav = if (song?.isFavorite == true) "1" else "0" val mins = s.totalDurationMs / 60000 - // Truncate long titles to save tokens val title = s.title.take(30) val artist = s.artist.take(20) - sb.append("${s.songId}|${s.playCount}|$mins|$fav|$title-$artist\n") + if (useExtendedFields) { + val album = song?.album?.take(20) ?: "?" + val year = song?.year?.toString()?.take(4) ?: "?" + sb.append("${s.songId}|${s.playCount}|$mins|$fav|$title-$artist|$album|$year\n") + } else { + sb.append("${s.songId}|${s.playCount}|$mins|$fav|$title-$artist\n") + } } - - // --- 3. Discovery Pool (strictly capped) --- - // AI needs to know what's available but unplayed + val playedIds = summary.songs.map { it.songId }.toSet() val unplayed = allSongs.filter { it.id !in playedIds } .shuffled() .take(discoveryLimit) - + if (unplayed.isNotEmpty()) { sb.append("\nDISCOVERY_POOL:\n") unplayed.forEach { s -> if (sb.length >= targetLimit) return@forEach val title = s.title.take(30) val artist = s.displayArtist.take(20) - sb.append("${s.id}|$title-$artist\n") + if (useExtendedFields) { + val genre = s.genre?.take(15) ?: "?" + sb.append("${s.id}|$title-$artist|$genre\n") + } else { + sb.append("${s.id}|$title-$artist\n") + } } } - + return sb.toString() } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClient.kt index 413a0fb2f..348547ee6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClient.kt @@ -5,20 +5,17 @@ package com.theveloper.pixelplay.data.ai.provider * Defines common operations for text generation and metadata completion */ interface AiClient { - - /** - * Generate text content based on a prompt - * @param model The model identifier to use - * @param systemPrompt The system prompt instructions - * @param prompt The input prompt - * @param temperature Creativity control (0.0 to 1.0) - * @return Generated text response - */ + suspend fun generateContent( model: String, systemPrompt: String, prompt: String, - temperature: Float = 0.7f + temperature: Float = 0.7f, + topP: Float = 0.95f, + topK: Int = 64, + maxTokens: Int = 4096, + presencePenalty: Float = 0.0f, + frequencyPenalty: Float = 0.0f ): String /** diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactory.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactory.kt index a1c29211e..4322ac24e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactory.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactory.kt @@ -22,9 +22,24 @@ class AiClientFactory @Inject constructor() { return when (provider) { AiProvider.GEMINI -> GeminiAiClient(apiKey) - AiProvider.DEEPSEEK -> DeepSeekAiClient(apiKey) - AiProvider.GROQ -> GroqAiClient(apiKey) - AiProvider.MISTRAL -> MistralAiClient(apiKey) + AiProvider.DEEPSEEK -> GenericOpenAiClient( + apiKey = apiKey, + baseUrl = "https://api.deepseek.com", + defaultModelId = "deepseek-chat", + providerName = "DeepSeek" + ) + AiProvider.GROQ -> GenericOpenAiClient( + apiKey = apiKey, + baseUrl = "https://api.groq.com/openai/v1", + defaultModelId = "llama-3.1-8b-instant", + providerName = "Groq" + ) + AiProvider.MISTRAL -> GenericOpenAiClient( + apiKey = apiKey, + baseUrl = "https://api.mistral.ai/v1", + defaultModelId = "mistral-large-latest", + providerName = "Mistral" + ) AiProvider.NVIDIA -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://integrate.api.nvidia.com/v1", @@ -55,6 +70,23 @@ class AiClientFactory @Inject constructor() { defaultModelId = "google/gemini-2.0-flash-lite-preview-02-05:free", providerName = "OpenRouter" ) + AiProvider.OLLAMA -> GenericOpenAiClient( + apiKey = apiKey, + baseUrl = "https://api.ollama.ai/v1", + defaultModelId = "llama3", + providerName = "Ollama" + ) + AiProvider.CUSTOM -> GenericOpenAiClient( + apiKey = apiKey, + baseUrl = "", + defaultModelId = "", + providerName = "Custom Provider" + ) } } + + fun createClientWithUrl(provider: AiProvider, apiKey: String, baseUrl: String): AiClient { + val displayName = provider.displayName + return GenericOpenAiClient(apiKey, baseUrl.trimEnd('/'), "", displayName) + } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt index f0f7b91dd..229f1d314 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt @@ -3,7 +3,7 @@ package com.theveloper.pixelplay.data.ai.provider /** * Enum representing available AI providers */ -enum class AiProvider(val displayName: String, val requiresApiKey: Boolean) { +enum class AiProvider(val displayName: String, val requiresApiKey: Boolean, val hasConfigurableUrl: Boolean = false) { GEMINI("Google Gemini", requiresApiKey = true), DEEPSEEK("DeepSeek", requiresApiKey = true), GROQ("Groq", requiresApiKey = true), @@ -12,7 +12,9 @@ enum class AiProvider(val displayName: String, val requiresApiKey: Boolean) { KIMI("Kimi (Moonshot)", requiresApiKey = true), GLM("Zhipu GLM", requiresApiKey = true), OPENAI("OpenAI", requiresApiKey = true), - OPENROUTER("OpenRouter", requiresApiKey = true); + OPENROUTER("OpenRouter", requiresApiKey = true), + OLLAMA("Ollama", requiresApiKey = true), + CUSTOM("Custom Provider", requiresApiKey = true, hasConfigurableUrl = true); companion object { fun fromString(value: String): AiProvider { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderSupport.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderSupport.kt index 386758356..82c61f6a9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderSupport.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProviderSupport.kt @@ -88,7 +88,9 @@ internal object AiProviderSupport { AiProvider.OPENROUTER, AiProvider.NVIDIA, AiProvider.KIMI, - AiProvider.GLM + AiProvider.GLM, + AiProvider.OLLAMA, + AiProvider.CUSTOM ) return buildList { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt deleted file mode 100644 index afb84b3ea..000000000 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt +++ /dev/null @@ -1,171 +0,0 @@ -package com.theveloper.pixelplay.data.ai.provider - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit - -/** - * DeepSeek AI provider implementation - * Uses OpenAI-compatible API - */ -class DeepSeekAiClient(private val apiKey: String) : AiClient { - - companion object { - private const val DEFAULT_DEEPSEEK_MODEL = "deepseek-chat" - private const val BASE_URL = "https://api.deepseek.com" - } - - @Serializable - data class ChatMessage(val role: String, val content: String) - - @Serializable - data class ChatRequest( - val model: String, - val messages: List, - val temperature: Double = 0.7 - ) - - @Serializable - data class ChatChoice(val message: ChatMessage) - - @Serializable - data class ChatResponse(val choices: List) - - @Serializable - data class ModelItem(val id: String) - - @Serializable - data class ModelsResponse(val data: List) - - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - - override suspend fun generateContent( - model: String, - systemPrompt: String, - prompt: String, - temperature: Float - ): String { - return withContext(Dispatchers.IO) { - val resolvedModel = model.ifBlank { DEFAULT_DEEPSEEK_MODEL } - val messagesList = mutableListOf() - if (systemPrompt.isNotBlank()) { - messagesList.add(ChatMessage(role = "system", content = systemPrompt)) - } - messagesList.add(ChatMessage(role = "user", content = prompt)) - - val requestBody = ChatRequest( - model = resolvedModel, - messages = messagesList, - temperature = temperature.toDouble() - ) - - val jsonBody = json.encodeToString(ChatRequest.serializer(), requestBody) - val body = jsonBody.toRequestBody("application/json".toMediaType()) - - val request = Request.Builder() - .url("$BASE_URL/chat/completions") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Content-Type", "application/json") - .post(body) - .build() - - try { - client.newCall(request).execute().use { response -> - val responseBody = response.body.string() - - if (!response.isSuccessful) { - throw AiProviderSupport.createException( - providerName = "DeepSeek", - statusCode = response.code, - transportMessage = response.message, - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - - val chatResponse = json.decodeFromString(responseBody) - chatResponse.choices.firstOrNull()?.message?.content - ?: throw AiProviderSupport.createException( - providerName = "DeepSeek", - statusCode = response.code, - transportMessage = "Response had no content", - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - } catch (e: Exception) { - throw AiProviderSupport.wrapThrowable("DeepSeek", e, resolvedModel) - } - } - } - - override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { - // DeepSeek estimation - return (systemPrompt.length + prompt.length) / 4 - } - - override suspend fun getAvailableModels(apiKey: String): List { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext getDefaultModels() - } - - val responseBody = response.body.string() - val modelsResponse = json.decodeFromString(responseBody) - modelsResponse.data.map { it.id } - } catch (e: Exception) { - getDefaultModels() - } - } - } - - override suspend fun validateApiKey(apiKey: String): Boolean { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - response.isSuccessful - } catch (e: Exception) { - false - } - } - } - - override fun getDefaultModel(): String = DEFAULT_DEEPSEEK_MODEL - - private fun getDefaultModels(): List { - return listOf( - "deepseek-chat", - "deepseek-reasoner" - ) - } -} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt index b730603cb..cee7e23a4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt @@ -25,12 +25,7 @@ class GeminiAiClient(private val apiKey: String) : AiClient { private const val DEFAULT_GEMINI_MODEL = "gemini-3.1-flash-lite" private const val BASE_URL = "https://generativelanguage.googleapis.com/v1beta" - // Markers for models that cannot perform text chat generation. These are the - // only things we filter out — every other model the API returns is selectable. - private val NON_CHAT_MARKERS = listOf( - "embedding", "aqa", "imagen", "image-generation", - "tts", "audio", "veo", "vision-only", "learnlm-embedding" - ) + } private val httpClient = OkHttpClient.Builder() @@ -42,7 +37,6 @@ class GeminiAiClient(private val apiKey: String) : AiClient { private val json = Json { ignoreUnknownKeys = true isLenient = true - encodeDefaults = true } @Serializable @@ -55,7 +49,10 @@ class GeminiAiClient(private val apiKey: String) : AiClient { private data class GenerationConfig( val temperature: Double, val topK: Int = 64, - val topP: Double = 0.95 + val topP: Double = 0.95, + @SerialName("maxOutputTokens") val maxOutputTokens: Int = 8192, + @SerialName("presencePenalty") val presencePenalty: Double? = null, + @SerialName("frequencyPenalty") val frequencyPenalty: Double? = null ) @Serializable @@ -86,7 +83,12 @@ class GeminiAiClient(private val apiKey: String) : AiClient { model: String, systemPrompt: String, prompt: String, - temperature: Float + temperature: Float, + topP: Float, + topK: Int, + maxTokens: Int, + presencePenalty: Float, + frequencyPenalty: Float ): String { return withContext(Dispatchers.IO) { val resolvedModel = model.ifBlank { DEFAULT_GEMINI_MODEL } @@ -96,7 +98,14 @@ class GeminiAiClient(private val apiKey: String) : AiClient { systemInstruction = systemPrompt .takeIf { it.isNotBlank() } ?.let { Content(parts = listOf(Part(it))) }, - generationConfig = GenerationConfig(temperature = temperature.toDouble()) + generationConfig = GenerationConfig( + temperature = temperature.toDouble(), + topK = topK, + topP = topP.toDouble(), + maxOutputTokens = maxTokens, + presencePenalty = presencePenalty.toDouble().takeIf { it != 0.0 }, + frequencyPenalty = frequencyPenalty.toDouble().takeIf { it != 0.0 } + ) ) val jsonBody = json.encodeToString(GenerateRequest.serializer(), requestBody) @@ -260,8 +269,7 @@ class GeminiAiClient(private val apiKey: String) : AiClient { } private fun isNonChatModel(modelName: String): Boolean { - val lower = modelName.lowercase() - return NON_CHAT_MARKERS.any { lower.contains(it) } + return !UnifiedModelFilter.isModelUsableForChat(modelName) } private fun getDefaultModels(): List { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt index 658906dd2..fd0fb1c1d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt @@ -2,6 +2,7 @@ package com.theveloper.pixelplay.data.ai.provider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -27,7 +28,11 @@ class GenericOpenAiClient( private data class ChatRequest( val model: String, val messages: List, - val temperature: Double = 0.7 + val temperature: Double = 0.7, + @SerialName("top_p") val topP: Double? = null, + @SerialName("max_tokens") val maxTokens: Int? = null, + @SerialName("presence_penalty") val presencePenalty: Double? = null, + @SerialName("frequency_penalty") val frequencyPenalty: Double? = null ) @Serializable @@ -57,7 +62,12 @@ class GenericOpenAiClient( model: String, systemPrompt: String, prompt: String, - temperature: Float + temperature: Float, + topP: Float, + topK: Int, + maxTokens: Int, + presencePenalty: Float, + frequencyPenalty: Float ): String { return withContext(Dispatchers.IO) { val resolvedModel = model.ifBlank { defaultModelId } @@ -70,7 +80,11 @@ class GenericOpenAiClient( val requestBody = ChatRequest( model = resolvedModel, messages = messagesList, - temperature = temperature.toDouble() + temperature = temperature.toDouble(), + topP = topP.toDouble(), + maxTokens = maxTokens.takeIf { it > 0 }, + presencePenalty = presencePenalty.toDouble(), + frequencyPenalty = frequencyPenalty.toDouble() ) val jsonBody = json.encodeToString(ChatRequest.serializer(), requestBody) @@ -140,9 +154,7 @@ class GenericOpenAiClient( val responseBody = response.body.string() val modelsResponse = json.decodeFromString(responseBody) - modelsResponse.data.map { it.id }.filter { - !it.contains("whisper") && !it.contains("embed") && !it.contains("tts") - } + modelsResponse.data.map { it.id }.let { UnifiedModelFilter.filterChatModels(it) } } catch (e: Exception) { listOf(defaultModelId) } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt deleted file mode 100644 index 0adf6cf70..000000000 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.theveloper.pixelplay.data.ai.provider - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit - -class GroqAiClient(private val apiKey: String) : AiClient { - - companion object { - private const val DEFAULT_MODEL = "llama-3.1-8b-instant" - private const val BASE_URL = "https://api.groq.com/openai/v1" - } - - @Serializable - private data class ChatMessage(val role: String, val content: String) - - @Serializable - private data class ChatRequest( - val model: String, - val messages: List, - val temperature: Double = 0.7 - ) - - @Serializable - private data class ChatChoice(val message: ChatMessage) - - @Serializable - private data class ChatResponse(val choices: List) - - @Serializable - private data class ModelItem(val id: String) - - @Serializable - private data class ModelsResponse(val data: List) - - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - - override suspend fun generateContent( - model: String, - systemPrompt: String, - prompt: String, - temperature: Float - ): String { - return withContext(Dispatchers.IO) { - val resolvedModel = model.ifBlank { DEFAULT_MODEL } - val messagesList = mutableListOf() - if (systemPrompt.isNotBlank()) { - messagesList.add(ChatMessage(role = "system", content = systemPrompt)) - } - messagesList.add(ChatMessage(role = "user", content = prompt)) - - val requestBody = ChatRequest( - model = resolvedModel, - messages = messagesList, - temperature = temperature.toDouble() - ) - - val jsonBody = json.encodeToString(ChatRequest.serializer(), requestBody) - val body = jsonBody.toRequestBody("application/json".toMediaType()) - - val request = Request.Builder() - .url("$BASE_URL/chat/completions") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Content-Type", "application/json") - .post(body) - .build() - - try { - client.newCall(request).execute().use { response -> - val responseBody = response.body.string() - - if (!response.isSuccessful) { - throw AiProviderSupport.createException( - providerName = "Groq", - statusCode = response.code, - transportMessage = response.message, - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - - val chatResponse = json.decodeFromString(responseBody) - chatResponse.choices.firstOrNull()?.message?.content - ?: throw AiProviderSupport.createException( - providerName = "Groq", - statusCode = response.code, - transportMessage = "Response had no content", - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - } catch (e: Exception) { - throw AiProviderSupport.wrapThrowable("Groq", e, resolvedModel) - } - } - } - - override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { - // Groq doesn't provide a native token counting endpoint, so we estimate. - // Rule of thumb: 1 token ≈ 4 characters for English text. - return (systemPrompt.length + prompt.length) / 4 - } - - override suspend fun getAvailableModels(apiKey: String): List { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext getDefaultModels() - } - - val responseBody = response.body.string() - val modelsResponse = json.decodeFromString(responseBody) - modelsResponse.data.map { it.id }.filter { !it.contains("whisper") } - } catch (e: Exception) { - getDefaultModels() - } - } - } - - override suspend fun validateApiKey(apiKey: String): Boolean { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - response.isSuccessful - } catch (e: Exception) { - false - } - } - } - - override fun getDefaultModel(): String = DEFAULT_MODEL - - private fun getDefaultModels(): List { - return listOf( - "llama-3.1-8b-instant", - "llama-3.3-70b-versatile", - "mixtral-8x7b-32768", - "gemma2-9b-it" - ) - } -} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt deleted file mode 100644 index a4d166e2a..000000000 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt +++ /dev/null @@ -1,169 +0,0 @@ -package com.theveloper.pixelplay.data.ai.provider - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit - -class MistralAiClient(private val apiKey: String) : AiClient { - - companion object { - private const val DEFAULT_MODEL = "mistral-large-latest" - private const val BASE_URL = "https://api.mistral.ai/v1" - } - - @Serializable - private data class ChatMessage(val role: String, val content: String) - - @Serializable - private data class ChatRequest( - val model: String, - val messages: List, - val temperature: Double = 0.7 - ) - - @Serializable - private data class ChatChoice(val message: ChatMessage) - - @Serializable - private data class ChatResponse(val choices: List) - - @Serializable - private data class ModelItem(val id: String) - - @Serializable - private data class ModelsResponse(val data: List) - - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - - override suspend fun generateContent( - model: String, - systemPrompt: String, - prompt: String, - temperature: Float - ): String { - return withContext(Dispatchers.IO) { - val resolvedModel = model.ifBlank { DEFAULT_MODEL } - val messagesList = mutableListOf() - if (systemPrompt.isNotBlank()) { - messagesList.add(ChatMessage(role = "system", content = systemPrompt)) - } - messagesList.add(ChatMessage(role = "user", content = prompt)) - - val requestBody = ChatRequest( - model = resolvedModel, - messages = messagesList, - temperature = temperature.toDouble() - ) - - val jsonBody = json.encodeToString(ChatRequest.serializer(), requestBody) - val body = jsonBody.toRequestBody("application/json".toMediaType()) - - val request = Request.Builder() - .url("$BASE_URL/chat/completions") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Content-Type", "application/json") - .post(body) - .build() - - try { - client.newCall(request).execute().use { response -> - val responseBody = response.body.string() - - if (!response.isSuccessful) { - throw AiProviderSupport.createException( - providerName = "Mistral", - statusCode = response.code, - transportMessage = response.message, - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - - val chatResponse = json.decodeFromString(responseBody) - chatResponse.choices.firstOrNull()?.message?.content - ?: throw AiProviderSupport.createException( - providerName = "Mistral", - statusCode = response.code, - transportMessage = "Response had no content", - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - } catch (e: Exception) { - throw AiProviderSupport.wrapThrowable("Mistral", e, resolvedModel) - } - } - } - - override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { - // Mistral estimation - return (systemPrompt.length + prompt.length) / 4 - } - - override suspend fun getAvailableModels(apiKey: String): List { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext getDefaultModels() - } - - val responseBody = response.body.string() - val modelsResponse = json.decodeFromString(responseBody) - modelsResponse.data.map { it.id } - } catch (e: Exception) { - getDefaultModels() - } - } - } - - override suspend fun validateApiKey(apiKey: String): Boolean { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - response.isSuccessful - } catch (e: Exception) { - false - } - } - } - - override fun getDefaultModel(): String = DEFAULT_MODEL - - private fun getDefaultModels(): List { - return listOf( - "mistral-large-latest", - "mistral-small-latest", - "open-mixtral-8x22b", - "open-mixtral-8x7b" - ) - } -} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/UnifiedModelFilter.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/UnifiedModelFilter.kt new file mode 100644 index 000000000..d72d1786f --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/UnifiedModelFilter.kt @@ -0,0 +1,30 @@ +package com.theveloper.pixelplay.data.ai.provider + +object UnifiedModelFilter { + private val UNSUITABLE_PATTERNS = listOf( + "embedding", "embed", "aqa", "imagen", "image-generation", + "tts", "text-to-speech", "speech", "audio", "whisper", + "veo", "vision-only", "learnlm-embedding", "moderation", + "dall-e", "stable-diffusion", "sdxl", "kandinsky", + "upscale", "background", "remove-background", + "segment", "detect", "classify", "object-detection" + ) + + fun isModelUsableForChat(modelName: String): Boolean { + val lower = modelName.lowercase() + return UNSUITABLE_PATTERNS.none { lower.contains(it) } + } + + fun filterChatModels(models: List): List { + return models.filter { isModelUsableForChat(it) } + } + + fun filterChatModelsWithDefaults( + apiModels: List, + defaultModels: List + ): List { + return (apiModels.filter { isModelUsableForChat(it) } + defaultModels) + .distinct() + .sorted() + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt index d339efbba..964875d04 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt @@ -4,6 +4,8 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -35,13 +37,23 @@ class AiPreferencesRepository @Inject constructor( private object Keys { val AI_PROVIDER = stringPreferencesKey("ai_provider") val SAFE_TOKEN_LIMIT = booleanPreferencesKey("safe_token_limit") + val AI_TEMPERATURE = floatPreferencesKey("ai_temperature") + val AI_TOP_P = floatPreferencesKey("ai_top_p") + val AI_TOP_K = intPreferencesKey("ai_top_k") + val AI_MAX_TOKENS = intPreferencesKey("ai_max_tokens") + val AI_PRESENCE_PENALTY = floatPreferencesKey("ai_presence_penalty") + val AI_FREQUENCY_PENALTY = floatPreferencesKey("ai_frequency_penalty") + val AI_SAMPLE_SIZE = intPreferencesKey("ai_sample_size") + val AI_DIGEST_MODE = stringPreferencesKey("ai_digest_mode") + val AI_INCLUDE_EXTENDED_FIELDS = booleanPreferencesKey("ai_include_extended_fields") fun getApiKey(provider: AiProvider) = stringPreferencesKey("${provider.name.lowercase()}_api_key") fun getModel(provider: AiProvider) = stringPreferencesKey("${provider.name.lowercase()}_model") fun getSystemPrompt(provider: AiProvider) = stringPreferencesKey("${provider.name.lowercase()}_system_prompt") + fun getBaseUrl(provider: AiProvider) = stringPreferencesKey("${provider.name.lowercase()}_base_url") } - // Generic accessors for AiOrchestrator + // Generic accessors for AiHandler fun getApiKey(provider: AiProvider): Flow = dataStore.data.map { preferences -> preferences[Keys.getApiKey(provider)]?.trim() ?: "" } @@ -53,6 +65,9 @@ class AiPreferencesRepository @Inject constructor( preferences[Keys.getSystemPrompt(provider)] ?: DEFAULT_SYSTEM_PROMPT } + fun getBaseUrl(provider: AiProvider): Flow = + dataStore.data.map { preferences -> preferences[Keys.getBaseUrl(provider)] ?: "" } + suspend fun setApiKey(provider: AiProvider, apiKey: String) { dataStore.edit { preferences -> preferences[Keys.getApiKey(provider)] = apiKey.trim() } } @@ -71,6 +86,10 @@ class AiPreferencesRepository @Inject constructor( } } + suspend fun setBaseUrl(provider: AiProvider, url: String) { + dataStore.edit { preferences -> preferences[Keys.getBaseUrl(provider)] = url.trim() } + } + // Convenience properties for legacy compatibility (e.g. PlayerViewModel) val geminiApiKey: Flow = getApiKey(AiProvider.GEMINI) val geminiModel: Flow = getModel(AiProvider.GEMINI) @@ -108,12 +127,48 @@ class AiPreferencesRepository @Inject constructor( val openrouterModel: Flow = getModel(AiProvider.OPENROUTER) val openrouterSystemPrompt: Flow = getSystemPrompt(AiProvider.OPENROUTER) + val ollamaApiKey: Flow = getApiKey(AiProvider.OLLAMA) + val ollamaModel: Flow = getModel(AiProvider.OLLAMA) + val ollamaSystemPrompt: Flow = getSystemPrompt(AiProvider.OLLAMA) + + val customApiKey: Flow = getApiKey(AiProvider.CUSTOM) + val customModel: Flow = getModel(AiProvider.CUSTOM) + val customSystemPrompt: Flow = getSystemPrompt(AiProvider.CUSTOM) + val customBaseUrl: Flow = getBaseUrl(AiProvider.CUSTOM) + val aiProvider: Flow = dataStore.data.map { preferences -> preferences[Keys.AI_PROVIDER] ?: "GEMINI" } val isSafeTokenLimitEnabled: Flow = dataStore.data.map { preferences -> preferences[Keys.SAFE_TOKEN_LIMIT] ?: true } + val aiTemperature: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_TEMPERATURE] ?: 0.7f } + + val aiTopP: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_TOP_P] ?: 0.95f } + + val aiTopK: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_TOP_K] ?: 64 } + + val aiMaxTokens: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_MAX_TOKENS] ?: 4096 } + + val aiPresencePenalty: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_PRESENCE_PENALTY] ?: 0.0f } + + val aiFrequencyPenalty: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_FREQUENCY_PENALTY] ?: 0.0f } + + val aiSampleSize: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_SAMPLE_SIZE] ?: 40 } + + val aiDigestMode: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_DIGEST_MODE] ?: "safe" } + + val aiIncludeExtendedFields: Flow = + dataStore.data.map { preferences -> preferences[Keys.AI_INCLUDE_EXTENDED_FIELDS] ?: false } + suspend fun setAiProvider(provider: String) { dataStore.edit { preferences -> preferences[Keys.AI_PROVIDER] = provider } } @@ -121,4 +176,40 @@ class AiPreferencesRepository @Inject constructor( suspend fun setSafeTokenLimitEnabled(enabled: Boolean) { dataStore.edit { preferences -> preferences[Keys.SAFE_TOKEN_LIMIT] = enabled } } + + suspend fun setAiTemperature(value: Float) { + dataStore.edit { preferences -> preferences[Keys.AI_TEMPERATURE] = value } + } + + suspend fun setAiTopP(value: Float) { + dataStore.edit { preferences -> preferences[Keys.AI_TOP_P] = value } + } + + suspend fun setAiTopK(value: Int) { + dataStore.edit { preferences -> preferences[Keys.AI_TOP_K] = value } + } + + suspend fun setAiMaxTokens(value: Int) { + dataStore.edit { preferences -> preferences[Keys.AI_MAX_TOKENS] = value } + } + + suspend fun setAiPresencePenalty(value: Float) { + dataStore.edit { preferences -> preferences[Keys.AI_PRESENCE_PENALTY] = value } + } + + suspend fun setAiFrequencyPenalty(value: Float) { + dataStore.edit { preferences -> preferences[Keys.AI_FREQUENCY_PENALTY] = value } + } + + suspend fun setAiSampleSize(value: Int) { + dataStore.edit { preferences -> preferences[Keys.AI_SAMPLE_SIZE] = value } + } + + suspend fun setAiDigestMode(mode: String) { + dataStore.edit { preferences -> preferences[Keys.AI_DIGEST_MODE] = mode } + } + + suspend fun setAiIncludeExtendedFields(enabled: Boolean) { + dataStore.edit { preferences -> preferences[Keys.AI_INCLUDE_EXTENDED_FIELDS] = enabled } + } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/AiWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/AiWorker.kt index a19c57615..0b1441d97 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/worker/AiWorker.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/AiWorker.kt @@ -7,7 +7,7 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import androidx.work.workDataOf import com.theveloper.pixelplay.data.ai.AiNotificationManager -import com.theveloper.pixelplay.data.ai.AiOrchestrator +import com.theveloper.pixelplay.data.ai.AiHandler import com.theveloper.pixelplay.data.ai.AiSystemPromptType import com.theveloper.pixelplay.data.ai.UserProfileDigestGenerator import com.theveloper.pixelplay.data.model.Song @@ -24,7 +24,7 @@ import timber.log.Timber class AiWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted workerParams: WorkerParameters, - private val orchestrator: AiOrchestrator, + private val handler: AiHandler, private val notificationManager: AiNotificationManager, private val musicRepository: MusicRepository, private val digestGenerator: UserProfileDigestGenerator, @@ -75,7 +75,7 @@ class AiWorker @AssistedInject constructor( digestGenerator.generateDigest(allSongs, isSafe) } else "" - val result = orchestrator.generateContent( + val result = handler.generateContent( prompt = prompt, type = type, temperature = temp, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/DailyMixSection.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/DailyMixSection.kt index 235d7ab0b..98eaa91b3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/DailyMixSection.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/DailyMixSection.kt @@ -159,9 +159,6 @@ fun DailyMixSection( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(song, fields) - }, removeFromListTrigger = {} ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistBottomSheet.kt index 2ceed9d9f..6a3dc19f3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistBottomSheet.kt @@ -63,7 +63,7 @@ fun PlaylistBottomSheet( currentPlaylistId: String? = null ) { val playlistCreatedAndSongsAddedMessage = stringResource(R.string.playlist_sheet_created_and_songs_added) - val setGeminiApiKeyFirstMessage = stringResource(R.string.library_toast_set_gemini_api_key_first) + val setAiProviderApiKeyFirstMessage = stringResource(R.string.library_toast_set_ai_provider_api_key_first) val songAddedToPlaylistsMessage = stringResource(R.string.playlist_sheet_song_added_to_playlists) val commonSavedMessage = stringResource(R.string.common_saved) val saveActionText = stringResource(R.string.common_save) @@ -214,7 +214,7 @@ fun PlaylistBottomSheet( if (hasActiveAiProviderApiKey) { playerViewModel.showAiPlaylistSheet() } else { - playerViewModel.sendToast(setGeminiApiKeyFirstMessage) + playerViewModel.sendToast(setAiProviderApiKeyFirstMessage) } } ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt index 01b934507..5dc12aa89 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt @@ -90,7 +90,6 @@ import com.theveloper.pixelplay.utils.shapes.RoundedStarShape import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.theveloper.pixelplay.data.ai.SongMetadata import com.theveloper.pixelplay.data.media.CoverArtUpdate import com.theveloper.pixelplay.ui.theme.MontserratFamily import com.theveloper.pixelplay.presentation.viewmodel.SongInfoBottomSheetViewModel @@ -142,12 +141,7 @@ fun SongInfoBottomSheet( replayGainAlbumGainDb: String, coverArtUpdate: CoverArtUpdate? ) -> Unit, - generateAiMetadata: suspend (List) -> Result, removeFromListTrigger: () -> Unit, - isGeneratingMetadata: Boolean = false, - aiMetadataSuccess: Boolean = false, - aiError: String? = null, - onRetryMetadata: () -> Unit = {}, songInfoViewModel: SongInfoBottomSheetViewModel = hiltViewModel() ) { val context = LocalContext.current diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerOverlaysLayer.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerOverlaysLayer.kt index 5a7fba4fd..2cb218cce 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerOverlaysLayer.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerOverlaysLayer.kt @@ -256,9 +256,6 @@ internal fun UnifiedPlayerSongInfoLayer( ) onDismissSongInfo() }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(liveSong, fields) - }, removeFromListTrigger = { playerViewModel.removeSongFromQueue(liveSong.id) onDismissSongInfo() diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt index a8f3f31f1..ce578abd5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt @@ -479,9 +479,6 @@ fun AlbumDetailScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(currentSong, fields) - }, removeFromListTrigger = removeFromListTrigger ) if (showPlaylistBottomSheet) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ArtistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ArtistDetailScreen.kt index 6f9199237..f8f85fcfe 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ArtistDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ArtistDetailScreen.kt @@ -535,9 +535,6 @@ fun ArtistDetailScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(currentSong, fields) - }, removeFromListTrigger = removeFromListTrigger ) if (showPlaylistBottomSheet) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DailyMixScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DailyMixScreen.kt index 24785f39d..eabba3c12 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DailyMixScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DailyMixScreen.kt @@ -125,8 +125,6 @@ fun DailyMixScreen( val aiStatus by playerViewModel.aiStatus.collectAsStateWithLifecycle() val aiError by playerViewModel.aiError.collectAsStateWithLifecycle() val aiSuccess by playerViewModel.aiSuccess.collectAsStateWithLifecycle() - val isGeneratingAiMetadata by playerViewModel.isGeneratingAiMetadata.collectAsStateWithLifecycle() - val aiMetadataSuccess by playerViewModel.aiMetadataSuccess.collectAsStateWithLifecycle() val lazyListState = rememberLazyListState() var showSongInfoSheet by remember { mutableStateOf(false) } @@ -233,14 +231,7 @@ fun DailyMixScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(song, fields) - }, - removeFromListTrigger = removeFromListTrigger, - isGeneratingMetadata = isGeneratingAiMetadata, - aiMetadataSuccess = aiMetadataSuccess, - aiError = aiError, - onRetryMetadata = { playerViewModel.retryLastMetadataGeneration() } + removeFromListTrigger = removeFromListTrigger ) if (showPlaylistBottomSheet) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/GenreDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/GenreDetailScreen.kt index f401829b6..2fe1dd6ad 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/GenreDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/GenreDetailScreen.kt @@ -623,9 +623,6 @@ fun GenreDetailScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(song, fields) - }, removeFromListTrigger = {} ) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt index 2260b4880..5d37d029e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt @@ -400,7 +400,6 @@ private data class LibraryScreenPlayerProjection( val isSdCardAvailable: Boolean = false, val musicFolders: ImmutableList = persistentListOf(), val isLoadingLibraryCategories: Boolean = true, - val isGeneratingAiMetadata: Boolean = false, val isSyncingLibrary: Boolean = false, val isLoadingInitialSongs: Boolean = true, val hideLocalMedia: Boolean = false @@ -422,7 +421,6 @@ private fun PlayerUiState.toLibraryScreenProjection(): LibraryScreenPlayerProjec isSdCardAvailable = isSdCardAvailable, musicFolders = musicFolders, isLoadingLibraryCategories = isLoadingLibraryCategories, - isGeneratingAiMetadata = isGeneratingAiMetadata, isSyncingLibrary = isSyncingLibrary, isLoadingInitialSongs = isLoadingInitialSongs, hideLocalMedia = hideLocalMedia @@ -1704,24 +1702,7 @@ fun LibraryScreen( } } } - if (playerUiState.isGeneratingAiMetadata) { - Surface( // Fondo semitransparente para el indicador - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f) - ) { - Box(contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - LoadingIndicator(modifier = Modifier.size(64.dp)) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.library_generating_ai_metadata), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - } - } - } else if ( + if ( isLibraryContentEmpty && ( playerUiState.isSyncingLibrary || @@ -1791,7 +1772,7 @@ fun LibraryScreen( playerViewModel.clearAiPlaylistError() showCreateAiPlaylistDialog = true } else { - Toast.makeText(context, context.getString(R.string.library_toast_set_gemini_api_key_first), Toast.LENGTH_SHORT).show() + Toast.makeText(context, context.getString(R.string.library_toast_set_ai_provider_api_key_first), Toast.LENGTH_SHORT).show() } }, onCreate = { name, imageUri, color, icon, songIds, cropScale, cropPanX, cropPanY, shapeType, d1, d2, d3, d4, smartRuleKey -> @@ -1928,9 +1909,6 @@ fun LibraryScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(currentSong, fields) - }, removeFromListTrigger = {}, songInfoViewModel = songInfoBottomSheetViewModel ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt index 646f002f0..a2b8c0c67 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt @@ -1037,9 +1037,6 @@ fun PlaylistDetailScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(currentSong, fields) - }, removeFromListTrigger = { playlistViewModel.removeSongFromPlaylist(playlistId, currentSong.id) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/RecentlyPlayedScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/RecentlyPlayedScreen.kt index a90154869..5ebf94a74 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/RecentlyPlayedScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/RecentlyPlayedScreen.kt @@ -335,9 +335,6 @@ fun RecentlyPlayedScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(song, fields) - }, removeFromListTrigger = {} ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt index bfe566be2..eab38137e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt @@ -753,9 +753,6 @@ fun SearchScreen( coverArtUpdate ) }, - generateAiMetadata = { fields -> - playerViewModel.generateAiMetadata(currentSong, fields) - }, ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt index d1ff2628b..874091ac0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt @@ -906,6 +906,9 @@ fun SettingsCategoryScreen( } } SettingsCategory.AI_INTEGRATION -> { + val provider = com.theveloper.pixelplay.data.ai.provider.AiProvider.fromString(aiProvider) + val currentCustomBaseUrl by settingsViewModel.customBaseUrl.collectAsStateWithLifecycle() + // AI Provider Selection SettingsSubsection(title = stringResource(R.string.settings_ai_provider_section)) { ThemeSelectorItem( @@ -939,7 +942,6 @@ fun SettingsCategoryScreen( // Consolidated API Key Section SettingsSubsection(title = stringResource(R.string.settings_credentials_section)) { - val provider = com.theveloper.pixelplay.data.ai.provider.AiProvider.fromString(aiProvider) val sourceLabel = when(provider) { com.theveloper.pixelplay.data.ai.provider.AiProvider.GEMINI -> stringResource(R.string.settings_ai_source_gemini) com.theveloper.pixelplay.data.ai.provider.AiProvider.DEEPSEEK -> stringResource(R.string.settings_ai_source_deepseek) @@ -950,6 +952,8 @@ fun SettingsCategoryScreen( com.theveloper.pixelplay.data.ai.provider.AiProvider.GLM -> stringResource(R.string.settings_ai_source_glm) com.theveloper.pixelplay.data.ai.provider.AiProvider.OPENAI -> stringResource(R.string.settings_ai_source_openai) com.theveloper.pixelplay.data.ai.provider.AiProvider.OPENROUTER -> "OpenRouter (openrouter.ai)" + com.theveloper.pixelplay.data.ai.provider.AiProvider.OLLAMA -> "Ollama (cloud)" + com.theveloper.pixelplay.data.ai.provider.AiProvider.CUSTOM -> "Custom Provider" } AiApiKeyItem( @@ -999,18 +1003,30 @@ fun SettingsCategoryScreen( ) } } else if (uiState.availableModels.isNotEmpty()) { - ThemeSelectorItem( + SearchableModelSelector( label = stringResource(R.string.settings_ai_model_title), description = stringResource(R.string.settings_ai_model_subtitle), - options = uiState.availableModels.associate { it.name to it.displayName }, - selectedKey = currentAiModel.ifEmpty { uiState.availableModels.firstOrNull()?.name ?: "" }, - onSelectionChanged = { settingsViewModel.onAiModelChange(it) }, + models = uiState.availableModels, + selectedModelName = currentAiModel.ifEmpty { uiState.availableModels.firstOrNull()?.name ?: "" }, + onModelSelected = { settingsViewModel.onAiModelChange(it) }, leadingIcon = { Icon(Icons.Rounded.Science, null, tint = MaterialTheme.colorScheme.secondary) } ) } } } + // Base URL Section (only for configurable URL providers) + if (provider.hasConfigurableUrl) { + SettingsSubsection(title = "API Base URL") { + AiApiKeyItem( + apiKey = currentCustomBaseUrl, + onApiKeySave = { settingsViewModel.onCustomBaseUrlChange(it) }, + title = "Base URL", + subtitle = "e.g. https://api.example.com/v1" + ) + } + } + // Prompt Behavior Section SettingsSubsection( title = stringResource(R.string.settings_prompt_behavior_section), @@ -1026,6 +1042,140 @@ fun SettingsCategoryScreen( ) } + // Generation Parameters Section + SettingsSubsection(title = "Generation Parameters") { + SliderSettingsItem( + label = "Temperature", + value = settingsViewModel.aiTemperature.collectAsStateWithLifecycle().value, + valueRange = 0.0f..2.0f, + steps = 20, + onValueChange = { settingsViewModel.onAiTemperatureChange(it) }, + valueText = { String.format(Locale.US, "%.2f", it) } + ) + Text( + text = "Controls randomness. Lower = more deterministic, higher = more creative.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + SliderSettingsItem( + label = "Top P", + value = settingsViewModel.aiTopP.collectAsStateWithLifecycle().value, + valueRange = 0.0f..1.0f, + steps = 20, + onValueChange = { settingsViewModel.onAiTopPChange(it) }, + valueText = { String.format(Locale.US, "%.2f", it) } + ) + Text( + text = "Nucleus sampling. Higher = more diverse tokens considered.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + SliderSettingsItem( + label = "Top K", + value = settingsViewModel.aiTopK.collectAsStateWithLifecycle().value.toFloat(), + valueRange = 1f..100f, + steps = 99, + onValueChange = { settingsViewModel.onAiTopKChange(it.toInt()) }, + valueText = { it.toInt().toString() } + ) + Text( + text = "Limits token selection to the K most likely candidates.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + SliderSettingsItem( + label = "Max Output Tokens", + value = settingsViewModel.aiMaxTokens.collectAsStateWithLifecycle().value.toFloat(), + valueRange = 128f..8192f, + steps = 63, + onValueChange = { settingsViewModel.onAiMaxTokensChange(it.toInt()) }, + valueText = { it.toInt().toString() } + ) + Text( + text = "Maximum length of the AI response. Higher = longer but more expensive.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + SliderSettingsItem( + label = "Presence Penalty", + value = settingsViewModel.aiPresencePenalty.collectAsStateWithLifecycle().value, + valueRange = -2.0f..2.0f, + steps = 40, + onValueChange = { settingsViewModel.onAiPresencePenaltyChange(it) }, + valueText = { String.format(Locale.US, "%.1f", it) } + ) + Text( + text = "Penalizes repeated topics. Positive = more diverse topics.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + SliderSettingsItem( + label = "Frequency Penalty", + value = settingsViewModel.aiFrequencyPenalty.collectAsStateWithLifecycle().value, + valueRange = -2.0f..2.0f, + steps = 40, + onValueChange = { settingsViewModel.onAiFrequencyPenaltyChange(it) }, + valueText = { String.format(Locale.US, "%.1f", it) } + ) + Text( + text = "Penalizes repeated phrases. Positive = more natural language.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + } + + // Song Data Configuration Section + SettingsSubsection(title = "Song Data Configuration") { + val aiSampleSize by settingsViewModel.aiSampleSize.collectAsStateWithLifecycle() + SliderSettingsItem( + label = "Sample Size", + value = aiSampleSize.toFloat(), + valueRange = 10f..120f, + steps = 11, + onValueChange = { settingsViewModel.onAiSampleSizeChange(it.toInt()) }, + valueText = { "${it.toInt()} songs" } + ) + Text( + text = "Number of songs sent to the AI for playlist generation. More = better context but higher cost.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + ThemeSelectorItem( + label = "Digest Detail", + description = "Controls how much listening history data is included", + options = mapOf("safe" to "Concise (faster)", "full" to "Full (better quality)"), + selectedKey = settingsViewModel.aiDigestMode.collectAsStateWithLifecycle().value, + onSelectionChanged = { settingsViewModel.onAiDigestModeChange(it) }, + leadingIcon = { + Icon( + painterResource(R.drawable.rounded_monitoring_24), + null, + tint = MaterialTheme.colorScheme.secondary + ) + } + ) + SwitchSettingItem( + title = "Extended Song Fields", + subtitle = "Include album, year, and genre info in song data sent to AI", + checked = settingsViewModel.aiIncludeExtendedFields.collectAsStateWithLifecycle().value, + onCheckedChange = { settingsViewModel.onAiIncludeExtendedFieldsChange(it) }, + leadingIcon = { + Icon( + painterResource(R.drawable.rounded_music_note_24), + null, + tint = MaterialTheme.colorScheme.secondary + ) + } + ) + } + Spacer(modifier = Modifier.height(16.dp)) SettingsSubsection(title = stringResource(R.string.settings_ai_usage_report_section)) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsComponents.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsComponents.kt index b13cad46c..bc5dbb329 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsComponents.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsComponents.kt @@ -30,18 +30,25 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Sync +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Clear import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch @@ -64,6 +71,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.ai.GeminiModel import com.theveloper.pixelplay.data.worker.SyncProgress import com.theveloper.pixelplay.presentation.viewmodel.LyricsRefreshProgress import com.theveloper.pixelplay.ui.theme.GoogleSansRounded @@ -362,14 +370,187 @@ fun ExpressiveSettingsGroup( ) { Column( modifier = modifier - .clip(RoundedCornerShape(24.dp)) // Large corners for the group + .clip(RoundedCornerShape(24.dp)) .background(Color.Transparent), - //verticalArrangement = Arrangement.spacedBy(4.dp) ) { content() } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchableModelSelector( + label: String, + description: String, + models: List, + selectedModelName: String, + onModelSelected: (String) -> Unit, + leadingIcon: @Composable () -> Unit +) { + var showSheet by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + val selectedDisplayName = models.find { it.name == selectedModelName }?.displayName ?: selectedModelName + + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { showSheet = true } + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp), + contentAlignment = Alignment.Center + ) { leadingIcon() } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(10.dp)) + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shape = CircleShape, + modifier = Modifier.align(Alignment.Start) + ) { + Text( + text = selectedDisplayName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } + } + } + } + + if (showSheet) { + ModalBottomSheet( + onDismissRequest = { + showSheet = false + searchQuery = "" + }, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface + ) { + Column(modifier = Modifier.padding(bottom = 24.dp)) { + Text( + text = label, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + fontWeight = FontWeight.Bold + ) + + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Search models...") }, + leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = "Search") }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Rounded.Clear, contentDescription = "Clear") + } + } + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) + ) + + Spacer(modifier = Modifier.height(4.dp)) + + val filteredModels = remember(models, searchQuery) { + if (searchQuery.isBlank()) models + else models.filter { + it.name.contains(searchQuery, ignoreCase = true) || + it.displayName.contains(searchQuery, ignoreCase = true) + } + } + + Text( + text = "${filteredModels.size} model${if (filteredModels.size != 1) "s" else ""} available", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp) + ) + + LazyColumn( + modifier = Modifier + .padding(horizontal = 16.dp) + .heightIn(max = 400.dp) + ) { + items(filteredModels, key = { it.name }) { model -> + val isSelected = model.name == selectedModelName + Surface( + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(10.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { + onModelSelected(model.name) + showSheet = false + searchQuery = "" + } + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = model.displayName, + style = MaterialTheme.typography.bodyLarge, + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface + ) + Text( + text = model.name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (isSelected) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } + } + } +} + @Composable fun SliderSettingsItem( label: String, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt index ac467232d..ad68cea83 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt @@ -4,10 +4,8 @@ package com.theveloper.pixelplay.presentation.viewmodel import android.content.Context import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.DailyMixManager -import com.theveloper.pixelplay.data.ai.AiMetadataGenerator import com.theveloper.pixelplay.data.ai.AiNotificationManager import com.theveloper.pixelplay.data.ai.AiPlaylistGenerator -import com.theveloper.pixelplay.data.ai.SongMetadata import com.theveloper.pixelplay.data.ai.AiSystemPromptType import com.theveloper.pixelplay.data.ai.provider.AiProviderException import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository @@ -30,12 +28,11 @@ import javax.inject.Singleton class AiStateHolder @Inject constructor( @ApplicationContext private val context: Context, private val aiPlaylistGenerator: AiPlaylistGenerator, - private val aiMetadataGenerator: AiMetadataGenerator, private val dailyMixManager: DailyMixManager, private val playlistPreferencesRepository: PlaylistPreferencesRepository, private val dailyMixStateHolder: DailyMixStateHolder, private val notificationManager: AiNotificationManager, - private val aiOrchestrator: com.theveloper.pixelplay.data.ai.AiOrchestrator + private val aiHandler: com.theveloper.pixelplay.data.ai.AiHandler ) { // State // AI State Management: Observables for tracking background generation progress @@ -45,12 +42,6 @@ class AiStateHolder @Inject constructor( private val _isGeneratingAiPlaylist = MutableStateFlow(false) val isGeneratingAiPlaylist = _isGeneratingAiPlaylist.asStateFlow() - private val _isGeneratingMetadata = MutableStateFlow(false) - val isGeneratingMetadata = _isGeneratingMetadata.asStateFlow() - - private val _aiMetadataSuccess = MutableStateFlow(false) - val aiMetadataSuccess = _aiMetadataSuccess.asStateFlow() - private val _aiSuccess = MutableStateFlow(false) val aiSuccess = _aiSuccess.asStateFlow() @@ -64,10 +55,6 @@ class AiStateHolder @Inject constructor( private var _lastMinLength: Int = 5 private var _lastMaxLength: Int = 15 - // Metadata Retry Cache: Stores parameters for the last metadata generation - private var _lastMetadataSong: Song? = null - private var _lastMetadataFields: List? = null - private var scope: CoroutineScope? = null private var allSongsProvider: (suspend () -> List)? = null private var favoriteSongIdsProvider: (() -> Set)? = null @@ -111,7 +98,6 @@ class AiStateHolder @Inject constructor( _showAiPlaylistSheet.value = false _aiError.value = null _aiSuccess.value = false - _aiMetadataSuccess.value = false _isGeneratingAiPlaylist.value = false _aiStatus.value = null } @@ -122,16 +108,6 @@ class AiStateHolder @Inject constructor( generateAiPlaylist(prompt, _lastMinLength, _lastMaxLength) } - fun retryLastMetadataGeneration() { - // Safe retry for metadata using cached song and requested fields - val song = _lastMetadataSong ?: return - val fields = _lastMetadataFields ?: return - - scope?.launch { - generateAiMetadata(song, fields) - } - } - fun clearAiPlaylistError() { _aiError.value = null } @@ -308,62 +284,31 @@ class AiStateHolder @Inject constructor( } } - /** - * Fetches AI-generated metadata (tags, genre, lyrics) for a specific song. - * Updates internal success and error states for UI feedback. - */ - suspend fun generateAiMetadata(song: Song, fields: List): Result { - _lastMetadataSong = song - _lastMetadataFields = fields - - _isGeneratingMetadata.value = true - _aiMetadataSuccess.value = false - _aiError.value = null - - return try { - val result = aiMetadataGenerator.generate(song, fields) - if (result.isSuccess) { - _aiMetadataSuccess.value = true - notificationManager.showCompletion("Metadata Enhanced", "Applied tags and genre refinements.") - } else { - result.exceptionOrNull()?.let { - _aiError.value = resolveAiErrorMessage(it) - notificationManager.showCompletion("Metadata Error", "Check your AI configuration.") - } - } - result - } catch (e: Exception) { - _aiError.value = resolveAiErrorMessage(e) - Result.failure(e) - } finally { - _isGeneratingMetadata.value = false - } - } - suspend fun translateLyrics(lyricsText: String): Result { return try { val targetLanguage = context.resources.configuration.locales[0].displayLanguage val prompt = """ -Translate the provided song lyrics into $targetLanguage. - -Keep every timestamp exactly unchanged. - -If the lyrics are ALREADY mostly in $targetLanguage, output ONLY the exact phrase "ALREADY_IN_TARGET_LANGUAGE" without any other text. - -For each original line, output the original line first, then on the next line output the $targetLanguage translation with the same timestamp. - -Do not add any extra text, explanations, numbering, labels, or formatting. -Do not remove, merge, split, or reorder lines. - -Output only: -[timestamp] original text -[timestamp] translated text - -Lyrics to translate: +Translate song lyrics into $targetLanguage. + + +- Preserve ALL timestamps [mm:ss.xx] exactly — never modify, merge, or drop them. +- Output TWO lines per original line: the original, then the translation with the same timestamp. +- NEVER add explanations, labels, numbering, section headers, or formatting. +- NEVER remove, merge, split, or reorder lines. +- If lyrics are ALREADY mostly in $targetLanguage, output ONLY: ALREADY_IN_TARGET_LANGUAGE + + + +[original timestamp] original text +[same timestamp] translated text + + + $lyricsText + """.trimIndent() - val response = aiOrchestrator.generateContent( + val response = aiHandler.generateContent( prompt = prompt, type = AiSystemPromptType.GENERAL, temperature = 0.1f diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt index 4272693f5..7da60dfe4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt @@ -22,6 +22,10 @@ data class PlayerUiState( // val artists: ImmutableList = persistentListOf(), // REMOVED val searchResults: ImmutableList = persistentListOf(), val musicFolders: ImmutableList = persistentListOf(), + val showAiPlaylistSheet: Boolean = false, + val isGeneratingAiPlaylist: Boolean = false, + val aiStatus: String? = null, + val aiError: String? = null, val sortOption: SortOption = SortOption.SongDefaultOrder, val isLoadingInitialSongs: Boolean = true, val isLoadingLibrary: Boolean = true, @@ -51,7 +55,6 @@ data class PlayerUiState( val folderBackGestureNavigationEnabled: Boolean = true, val currentSongSortOption: SortOption = SortOption.SongTitleAZ, // val songCount: Int = 0, // REMOVED - val isGeneratingAiMetadata: Boolean = false, val searchHistory: ImmutableList = persistentListOf(), val searchQuery: String = "", val isSyncingLibrary: Boolean = false, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index d6829b847..4ddadbede 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -37,7 +37,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.theveloper.pixelplay.R -import com.theveloper.pixelplay.data.ai.SongMetadata import com.theveloper.pixelplay.data.media.CoverArtUpdate import com.theveloper.pixelplay.data.model.Album import com.theveloper.pixelplay.data.model.Artist @@ -163,6 +162,13 @@ private fun moveQueueIndex(index: Int, fromIndex: Int, toIndex: Int): Int { } } +private data class AiUiSnapshot( + val showAiPlaylistSheet: Boolean, + val isGeneratingAiPlaylist: Boolean, + val aiStatus: String?, + val aiError: String?, +) + private data class SortOptionsSnapshot( val songSort: SortOption, val albumSort: SortOption, @@ -171,14 +177,6 @@ private data class SortOptionsSnapshot( val favoriteSort: SortOption, ) -private data class AiUiSnapshot( - val showAiPlaylistSheet: Boolean, - val isGeneratingAiPlaylist: Boolean, - val aiStatus: String?, - val aiError: String?, - val isGeneratingAiMetadata: Boolean, -) - @UnstableApi @SuppressLint("LogNotTimber") @OptIn(coil.annotation.ExperimentalCoilApi::class, ExperimentalCoroutinesApi::class) @@ -446,10 +444,6 @@ class PlayerViewModel @Inject constructor( val aiStatus: StateFlow = aiStateHolder.aiStatus val aiError: StateFlow = aiStateHolder.aiError - // AI Metadata Generation States - val isGeneratingAiMetadata: StateFlow = aiStateHolder.isGeneratingMetadata - val aiMetadataSuccess: StateFlow = aiStateHolder.aiMetadataSuccess - private val _selectedSongForInfo = MutableStateFlow(null) val selectedSongForInfo: StateFlow = _selectedSongForInfo.asStateFlow() @@ -504,7 +498,10 @@ class PlayerViewModel @Inject constructor( aiPreferencesRepository.nvidiaApiKey, aiPreferencesRepository.kimiApiKey, aiPreferencesRepository.glmApiKey, - aiPreferencesRepository.openaiApiKey + aiPreferencesRepository.openaiApiKey, + aiPreferencesRepository.ollamaApiKey, + aiPreferencesRepository.customApiKey, + aiPreferencesRepository.openrouterApiKey ) { values -> val provider = values[0] val gemini = values[1] @@ -515,7 +512,11 @@ class PlayerViewModel @Inject constructor( val kimi = values[6] val glm = values[7] val openai = values[8] + val ollama = values[9] + val custom = values[10] + val openrouter = values[11] when (provider) { + "GEMINI" -> gemini.isNotBlank() "DEEPSEEK" -> deepseek.isNotBlank() "GROQ" -> groq.isNotBlank() "MISTRAL" -> mistral.isNotBlank() @@ -523,7 +524,10 @@ class PlayerViewModel @Inject constructor( "KIMI" -> kimi.isNotBlank() "GLM" -> glm.isNotBlank() "OPENAI" -> openai.isNotBlank() - else -> gemini.isNotBlank() + "OPENROUTER" -> openrouter.isNotBlank() + "OLLAMA" -> ollama.isNotBlank() + "CUSTOM" -> custom.isNotBlank() + else -> false } }.distinctUntilChanged() .stateIn( @@ -1779,25 +1783,28 @@ class PlayerViewModel @Inject constructor( openPlayerSheetCallback = { _isSheetVisible.value = true } ) - // Collect AiStateHolder flows + // Collect AiStateHolder flows for playlist generation state viewModelScope.launch { combine( aiStateHolder.showAiPlaylistSheet, aiStateHolder.isGeneratingAiPlaylist, aiStateHolder.aiStatus, aiStateHolder.aiError, - aiStateHolder.isGeneratingMetadata, - ) { show, generating, status, error, generatingMetadata -> + ) { show, generating, status, error -> AiUiSnapshot( showAiPlaylistSheet = show, isGeneratingAiPlaylist = generating, aiStatus = status, - aiError = error, - isGeneratingAiMetadata = generatingMetadata + aiError = error ) }.collect { snapshot -> _playerUiState.update { - it.copy(isGeneratingAiMetadata = snapshot.isGeneratingAiMetadata) + it.copy( + showAiPlaylistSheet = snapshot.showAiPlaylistSheet, + isGeneratingAiPlaylist = snapshot.isGeneratingAiPlaylist, + aiStatus = snapshot.aiStatus, + aiError = snapshot.aiError + ) } } } @@ -2611,10 +2618,6 @@ class PlayerViewModel @Inject constructor( aiStateHolder.retryLastPlaylistGeneration() } - fun retryLastMetadataGeneration() { - aiStateHolder.retryLastMetadataGeneration() - } - fun clearQueueExceptCurrent() { mediaController?.let { controller -> val currentSongIndex = controller.currentMediaItemIndex @@ -2886,10 +2889,6 @@ class PlayerViewModel @Inject constructor( }.getOrDefault(false) } - suspend fun generateAiMetadata(song: Song, fields: List): Result { - return aiStateHolder.generateAiMetadata(song, fields) - } - private fun updateSongInStates( updatedSong: Song, newLyrics: Lyrics? = null, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt index d07d8b3df..d1d67f39e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt @@ -275,6 +275,52 @@ class SettingsViewModel @Inject constructor( val openrouterSystemPrompt: StateFlow = aiPreferencesRepository.openrouterSystemPrompt .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_OPENROUTER_SYSTEM_PROMPT) + val ollamaApiKey: StateFlow = aiPreferencesRepository.ollamaApiKey + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + val ollamaModel: StateFlow = aiPreferencesRepository.ollamaModel + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + val ollamaSystemPrompt: StateFlow = aiPreferencesRepository.ollamaSystemPrompt + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_SYSTEM_PROMPT) + + val customApiKey: StateFlow = aiPreferencesRepository.customApiKey + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + val customModel: StateFlow = aiPreferencesRepository.customModel + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + val customSystemPrompt: StateFlow = aiPreferencesRepository.customSystemPrompt + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_SYSTEM_PROMPT) + val customBaseUrl: StateFlow = aiPreferencesRepository.customBaseUrl + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + + val currentAiBaseUrl: StateFlow = aiProvider + .flatMapLatest { provider -> + val p = AiProvider.fromString(provider) + if (p.hasConfigurableUrl) aiPreferencesRepository.getBaseUrl(p) + else flowOf("") + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + + // Generation Parameters + val aiTemperature: StateFlow = aiPreferencesRepository.aiTemperature + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0.7f) + val aiTopP: StateFlow = aiPreferencesRepository.aiTopP + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0.95f) + val aiTopK: StateFlow = aiPreferencesRepository.aiTopK + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 64) + val aiMaxTokens: StateFlow = aiPreferencesRepository.aiMaxTokens + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 4096) + val aiPresencePenalty: StateFlow = aiPreferencesRepository.aiPresencePenalty + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0.0f) + val aiFrequencyPenalty: StateFlow = aiPreferencesRepository.aiFrequencyPenalty + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0.0f) + + // Song Data Configuration + val aiSampleSize: StateFlow = aiPreferencesRepository.aiSampleSize + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 40) + val aiDigestMode: StateFlow = aiPreferencesRepository.aiDigestMode + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "safe") + val aiIncludeExtendedFields: StateFlow = aiPreferencesRepository.aiIncludeExtendedFields + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + fun onAiApiKeyChange(apiKey: String) { viewModelScope.launch { val providerStr = aiProvider.value @@ -349,6 +395,53 @@ class SettingsViewModel @Inject constructor( else clearModelsState("OPENROUTER") } } + fun onOllamaApiKeyChange(apiKey: String) { + viewModelScope.launch { + aiPreferencesRepository.setApiKey(AiProvider.OLLAMA, apiKey) + if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "OLLAMA") + else clearModelsState("OLLAMA") + } + } + fun onCustomApiKeyChange(apiKey: String) { + viewModelScope.launch { + aiPreferencesRepository.setApiKey(AiProvider.CUSTOM, apiKey) + if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "CUSTOM") + else clearModelsState("CUSTOM") + } + } + fun onCustomBaseUrlChange(baseUrl: String) { + viewModelScope.launch { + aiPreferencesRepository.setBaseUrl(AiProvider.CUSTOM, baseUrl) + } + } + + fun onAiTemperatureChange(value: Float) { + viewModelScope.launch { aiPreferencesRepository.setAiTemperature(value) } + } + fun onAiTopPChange(value: Float) { + viewModelScope.launch { aiPreferencesRepository.setAiTopP(value) } + } + fun onAiTopKChange(value: Int) { + viewModelScope.launch { aiPreferencesRepository.setAiTopK(value) } + } + fun onAiMaxTokensChange(value: Int) { + viewModelScope.launch { aiPreferencesRepository.setAiMaxTokens(value) } + } + fun onAiPresencePenaltyChange(value: Float) { + viewModelScope.launch { aiPreferencesRepository.setAiPresencePenalty(value) } + } + fun onAiFrequencyPenaltyChange(value: Float) { + viewModelScope.launch { aiPreferencesRepository.setAiFrequencyPenalty(value) } + } + fun onAiSampleSizeChange(value: Int) { + viewModelScope.launch { aiPreferencesRepository.setAiSampleSize(value) } + } + fun onAiDigestModeChange(mode: String) { + viewModelScope.launch { aiPreferencesRepository.setAiDigestMode(mode) } + } + fun onAiIncludeExtendedFieldsChange(enabled: Boolean) { + viewModelScope.launch { aiPreferencesRepository.setAiIncludeExtendedFields(enabled) } + } fun onAiModelChange(model: String) { viewModelScope.launch { @@ -366,6 +459,8 @@ class SettingsViewModel @Inject constructor( fun onGlmModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.GLM, model) } fun onOpenAiModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.OPENAI, model) } fun onOpenrouterModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.OPENROUTER, model) } + fun onOllamaModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.OLLAMA, model) } + fun onCustomModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.CUSTOM, model) } fun onAiSystemPromptChange(prompt: String) { viewModelScope.launch { @@ -383,6 +478,8 @@ class SettingsViewModel @Inject constructor( fun onGlmSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.GLM, prompt) } fun onOpenAiSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.OPENAI, prompt) } fun onOpenrouterSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.OPENROUTER, prompt) } + fun onOllamaSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.OLLAMA, prompt) } + fun onCustomSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.CUSTOM, prompt) } fun resetAiSystemPrompt() { viewModelScope.launch { @@ -400,6 +497,8 @@ class SettingsViewModel @Inject constructor( fun resetGlmSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.GLM) } fun resetOpenAiSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.OPENAI) } fun resetOpenrouterSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.OPENROUTER) } + fun resetOllamaSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.OLLAMA) } + fun resetCustomSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.CUSTOM) } fun clearAiUsageData() { viewModelScope.launch { @@ -1149,7 +1248,13 @@ class SettingsViewModel @Inject constructor( val models = if (provider == AiProvider.GEMINI) { geminiModelService.fetchAvailableModels(apiKey).getOrThrow() } else { - val aiClient = aiClientFactory.createClient(provider, apiKey) + val baseUrl = if (provider.hasConfigurableUrl) + aiPreferencesRepository.getBaseUrl(provider).first() + else "" + val aiClient = if (provider.hasConfigurableUrl) + aiClientFactory.createClientWithUrl(provider, apiKey, baseUrl) + else + aiClientFactory.createClient(provider, apiKey) aiClient.getAvailableModels(apiKey) .map { it.trim() } .filter { it.isNotBlank() } diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt index 41c9b2494..7996811c6 100644 --- a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt @@ -180,7 +180,6 @@ class PlayerViewModelTest { every { mockAiStateHolder.showAiPlaylistSheet } returns MutableStateFlow(false) every { mockAiStateHolder.isGeneratingAiPlaylist } returns MutableStateFlow(false) every { mockAiStateHolder.aiError } returns MutableStateFlow(null) - every { mockAiStateHolder.isGeneratingMetadata } returns MutableStateFlow(false) every { mockAiStateHolder.initialize(any(), any(), any(), any(), any(), any()) } just runs every { mockCastStateHolder.castSession } returns _castSessionFlow