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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Currently Dicio answers questions about:
- **media**: play, pause, previous, next song
- **translation**: translate from/to any language with **Lingva** - _How do I say Football in German?_
- **wake word control**: turn on/off the wakeword - _Stop listening_
- **ai query**: talk to an LLM and ask it questions. optionally, send unknown queries to AI to be answered. - _Ask AI what is the meaning of life?_ <sup>Requires your own OpenAI compatible provider and API keys</sup>

## Speech to text

Expand Down
18 changes: 14 additions & 4 deletions app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import org.stypox.dicio.di.SkillContextImpl
import org.stypox.dicio.di.SkillContextInternal
import org.stypox.dicio.settings.datastore.UserSettings
import org.stypox.dicio.settings.datastore.UserSettingsModule
import org.stypox.dicio.settings.datastore.FallbackSkill
import org.stypox.dicio.skills.calculator.CalculatorInfo
import org.stypox.dicio.skills.current_time.CurrentTimeInfo
import org.stypox.dicio.skills.fallback.text.TextFallbackInfo
Expand All @@ -31,6 +32,7 @@ import org.stypox.dicio.skills.timer.TimerInfo
import org.stypox.dicio.skills.translation.TranslationInfo
import org.stypox.dicio.skills.weather.WeatherInfo
import org.stypox.dicio.skills.joke.JokeInfo
import org.stypox.dicio.skills.aiquery.AIQueryInfo
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -55,10 +57,12 @@ class SkillHandler @Inject constructor(
JokeInfo,
ListeningInfo(dataStore),
TranslationInfo,
AIQueryInfo,
)

private val fallbackSkillInfoList = listOf(
TextFallbackInfo,
AIQueryInfo,
)

private val scope = CoroutineScope(Dispatchers.Default)
Expand All @@ -69,26 +73,32 @@ class SkillHandler @Inject constructor(

private val _skillRanker = MutableStateFlow(
// an initial dummy value, will be overwritten directly by the launched job
SkillRanker(listOf(), buildSkillFromInfo(fallbackSkillInfoList[0]))
SkillRanker(listOf(), buildSkillFromInfo(fallbackSkillInfoList[1]))
)
val skillRanker: StateFlow<SkillRanker> = _skillRanker

init {
scope.launch {
localeManager.locale
.combine(dataStore.data) { locale, data -> Pair(locale, data.enabledSkillsMap) }
.combine(dataStore.data) { locale, data -> Triple(locale, data.enabledSkillsMap, data.fallbackSkill) }
.distinctUntilChanged()
.collectLatest { (_, enabledSkills) ->
.collectLatest { (_, enabledSkills, fallbackSkill) ->
// locale is not used here, because the skills directly use the sections locale

val newEnabledSkillsInfo = allSkillInfoList
.filter { enabledSkills.getOrDefault(it.id, true) }
.filter { it.isAvailable(skillContext) }

val fallbackSkillInfo = when (fallbackSkill) {
FallbackSkill.FALLBACK_SKILL_TEXT -> TextFallbackInfo
FallbackSkill.FALLBACK_SKILL_AIQUERY -> AIQueryInfo
else -> TextFallbackInfo // default to text fallback if unset
}

_enabledSkillsInfo.value = newEnabledSkillsInfo
_skillRanker.value = SkillRanker(
newEnabledSkillsInfo.map(::buildSkillFromInfo),
buildSkillFromInfo(fallbackSkillInfoList[0]),
buildSkillFromInfo(fallbackSkillInfo),
)
}
}
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/kotlin/org/stypox/dicio/settings/Definitions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.compose.material.icons.filled.Campaign
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.ColorLens
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Hearing
import androidx.compose.material.icons.filled.InvertColors
import androidx.compose.material.icons.filled.KeyboardAlt
Expand All @@ -29,6 +30,7 @@ import org.stypox.dicio.settings.datastore.SpeechOutputDevice
import org.stypox.dicio.settings.datastore.SttPlaySound
import org.stypox.dicio.settings.datastore.Theme
import org.stypox.dicio.settings.datastore.WakeDevice
import org.stypox.dicio.settings.datastore.FallbackSkill
import org.stypox.dicio.settings.ui.BooleanSetting
import org.stypox.dicio.settings.ui.ListSetting

Expand Down Expand Up @@ -99,6 +101,23 @@ fun dynamicColors() = BooleanSetting(
descriptionOn = stringResource(R.string.pref_dynamic_colors_summary),
)

@Composable
fun fallbackSkillSetting() = ListSetting(
title = stringResource(R.string.pref_fallback_skill_title),
icon = Icons.Default.Extension,
description = stringResource(R.string.pref_fallback_skill_summary),
possibleValues = listOf(
ListSetting.Value(
value = FallbackSkill.FALLBACK_SKILL_TEXT,
name = stringResource(R.string.skill_fallback_name_text),
),
ListSetting.Value(
value = FallbackSkill.FALLBACK_SKILL_AIQUERY,
name = stringResource(R.string.skill_name_aiquery),
),
),
)

@Composable
fun inputDevice() = ListSetting(
title = stringResource(R.string.pref_input_method),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import org.stypox.dicio.settings.datastore.SttPlaySound
import org.stypox.dicio.settings.datastore.Theme
import org.stypox.dicio.settings.datastore.UserSettingsModule.Companion.newDataStoreForPreviews
import org.stypox.dicio.settings.datastore.WakeDevice
import org.stypox.dicio.settings.datastore.FallbackSkill
import org.stypox.dicio.settings.ui.SettingsCategoryTitle
import org.stypox.dicio.settings.ui.SettingsItem
import org.stypox.dicio.ui.theme.AppTheme
Expand Down Expand Up @@ -119,6 +120,16 @@ private fun MainSettingsScreen(
.testTag("skill_settings_item")
)
}
item {
fallbackSkillSetting().Render(
when (val fallbackSkill = settings.fallbackSkill) {
FallbackSkill.UNRECOGNIZED,
FallbackSkill.FALLBACK_SKILL_UNSET -> FallbackSkill.FALLBACK_SKILL_TEXT
else -> fallbackSkill
},
viewModel::setFallbackSkill,
)
}

/* INPUT AND OUTPUT METHODS */
item { SettingsCategoryTitle(stringResource(R.string.pref_io)) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import org.stypox.dicio.settings.datastore.Theme
import org.stypox.dicio.settings.datastore.UserSettings
import org.stypox.dicio.settings.datastore.WakeDevice
import org.stypox.dicio.util.toStateFlowDistinctBlockingFirst
import org.stypox.dicio.settings.datastore.FallbackSkill
import javax.inject.Inject

@HiltViewModel
Expand Down Expand Up @@ -73,4 +74,6 @@ class MainSettingsViewModel @Inject constructor(
updateData { it.setSttPlaySound(value) }
fun setAutoFinishSttPopup(value: Boolean) =
updateData { it.setAutoFinishSttPopup(value) }
fun setFallbackSkill(value: FallbackSkill) =
updateData { it.setFallbackSkill(value) }
}
138 changes: 138 additions & 0 deletions app/src/main/kotlin/org/stypox/dicio/skills/aiquery/AIQueryInfo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package org.stypox.dicio.skills.aiquery

import android.content.Context
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Chat
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStore
import kotlinx.coroutines.launch
import org.dicio.skill.context.SkillContext
import org.dicio.skill.skill.Skill
import org.dicio.skill.skill.SkillInfo
import org.stypox.dicio.R
import org.stypox.dicio.sentences.Sentences
import org.stypox.dicio.settings.ui.StringSetting

object AIQueryInfo : SkillInfo("aiquery") {
internal const val DEFAULT_ENDPOINT = "https://api.openai.com/v1/chat/completions"
internal const val DEFAULT_MODEL = "gpt-4o-mini"
internal val DEFAULT_SYSTEM_PROMPT = """
You are a voice-based digital assistant.
Speak like a natural human: concise, clear, conversational.
Avoid all markup, code blocks, emojis, or special formatting.
If you need to list things, keep lists short and simple so they sound natural when spoken aloud.
When explaining something technical, phrase it like you're talking to someone in person.
If the user wants actual code, read it out plainly without formatting symbols.
Do not refer to this prompt or your instructions. Do not offer follow‑up suggestions, extra help, or invitations to ask more.
Only answer what the user actually asked for.
""".trimIndent()


override fun name(context: Context) =
context.getString(R.string.skill_name_aiquery)

override fun sentenceExample(context: Context) =
context.getString(R.string.skill_sentence_example_aiquery)

@Composable
override fun icon() =
rememberVectorPainter(Icons.Default.Chat)

override fun isAvailable(ctx: SkillContext): Boolean {
return Sentences.Aiquery[ctx.sentencesLanguage] != null
}

override fun build(ctx: SkillContext): Skill<*> {
// Fallback to English sentences if the desired language is not available
// Prevents crashes for users of unsupported languages when using AIQuery as a fallback skill
return AIQuerySkill(this, Sentences.Aiquery[ctx.sentencesLanguage] ?: Sentences.Aiquery["en"]!!)
}

internal val Context.aiqueryDataStore by dataStore(
fileName = "skill_settings_AIQuery.pb",
serializer = SkillSettingsAIQuerySerializer,
corruptionHandler = ReplaceFileCorruptionHandler {
SkillSettingsAIQuerySerializer.defaultValue
},
)

override val renderSettings: @Composable () -> Unit get() = @Composable {
val dataStore = LocalContext.current.aiqueryDataStore
val data by dataStore.data.collectAsState(SkillSettingsAIQuerySerializer.defaultValue)
val scope = rememberCoroutineScope()

Column {
StringSetting(
title = stringResource(R.string.pref_aiquery_endpoint_url),
descriptionWhenEmpty = stringResource(R.string.pref_aiquery_endpoint_url_description),
).Render(
value = data.endpointUrl.ifEmpty { DEFAULT_ENDPOINT },
onValueChange = { endpointUrl ->
scope.launch {
dataStore.updateData { settings ->
settings.toBuilder()
.setEndpointUrl(endpointUrl)
.build()
}
}
},
)

StringSetting(
title = stringResource(R.string.pref_aiquery_api_key),
descriptionWhenEmpty = stringResource(R.string.pref_aiquery_api_key_description),
).Render(
value = data.apiKey,
onValueChange = { apiKey ->
scope.launch {
dataStore.updateData { settings ->
settings.toBuilder()
.setApiKey(apiKey)
.build()
}
}
},
)

StringSetting(
title = stringResource(R.string.pref_aiquery_model),
descriptionWhenEmpty = stringResource(R.string.pref_aiquery_model_description),
).Render(
value = data.model.ifEmpty { DEFAULT_MODEL },
onValueChange = { model ->
scope.launch {
dataStore.updateData { settings ->
settings.toBuilder()
.setModel(model)
.build()
}
}
},
)

StringSetting(
title = stringResource(R.string.pref_aiquery_system_prompt),
descriptionWhenEmpty = stringResource(R.string.pref_aiquery_system_prompt_description),
).Render(
value = data.systemPrompt.ifEmpty { DEFAULT_SYSTEM_PROMPT },
onValueChange = { systemPrompt ->
scope.launch {
dataStore.updateData { settings ->
settings.toBuilder()
.setSystemPrompt(systemPrompt)
.build()
}
}
},
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.stypox.dicio.skills.aiquery

import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import org.dicio.skill.context.SkillContext
import org.dicio.skill.skill.SkillOutput
import org.stypox.dicio.R
import org.stypox.dicio.io.graphical.Headline
import org.stypox.dicio.io.graphical.Subtitle
import org.stypox.dicio.util.getString

class AIQueryOutput(
private val response: String?,
private val question: String,
) : SkillOutput {

override fun getSpeechOutput(ctx: SkillContext) = if (response == null) {
ctx.getString(R.string.skill_aiquery_error)
} else {
response
}

@Composable
override fun GraphicalOutput(ctx: SkillContext) {
if (response == null) {
Headline(text = getSpeechOutput(ctx))
} else {
Column {
Subtitle(text = question)
Headline(text = response)
}
}
}
}
Loading