From 76fc58e733685e12512d050149dbcf4560494c8f Mon Sep 17 00:00:00 2001 From: cOkieeman <95237994+cOkieeman@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:41:41 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(android):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=95=86=E9=A2=84=E8=AE=BE=E4=B8=8E=E5=8F=AF?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(android): add model provider presets and discovery * feat(android): refine model settings UI (cherry picked from commit 5ffcd3761bbaa063bb1b9b7460520fad750f84f6) --- .../fantareal/domain/llm/ModelCatalog.kt | 101 +++++ .../fantareal/domain/llm/OpenAiProvider.kt | 58 ++- .../frischar/fantareal/ui/app/AppScaffold.kt | 3 + .../fantareal/ui/settings/SettingsScreen.kt | 359 +++++++++++++++++- .../ui/viewmodel/SettingsViewModel.kt | 160 +++++++- .../fantareal/domain/llm/ModelCatalogTest.kt | 72 ++++ 6 files changed, 720 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/frischar/fantareal/domain/llm/ModelCatalog.kt create mode 100644 app/src/test/java/com/frischar/fantareal/domain/llm/ModelCatalogTest.kt diff --git a/app/src/main/java/com/frischar/fantareal/domain/llm/ModelCatalog.kt b/app/src/main/java/com/frischar/fantareal/domain/llm/ModelCatalog.kt new file mode 100644 index 0000000..eba43da --- /dev/null +++ b/app/src/main/java/com/frischar/fantareal/domain/llm/ModelCatalog.kt @@ -0,0 +1,101 @@ +package com.frischar.fantareal.domain.llm + +data class ModelProviderPreset( + val id: String, + val label: String, + val baseUrl: String, + val recommendedModel: String, + val helper: String, + val requiresApiKey: Boolean = true +) + +object ModelProviderPresets { + val all = listOf( + ModelProviderPreset( + id = "custom", + label = "自定义", + baseUrl = "", + recommendedModel = "", + helper = "保留当前填写内容,适合本地或其他 OpenAI-compatible 服务。", + requiresApiKey = false + ), + ModelProviderPreset( + id = "deepseek", + label = "DeepSeek", + baseUrl = "https://api.deepseek.com/v1", + recommendedModel = "deepseek-chat", + helper = "填入 DeepSeek 官方兼容地址,并优先选择 deepseek-chat。" + ), + ModelProviderPreset( + id = "openai", + label = "OpenAI", + baseUrl = "https://api.openai.com/v1", + recommendedModel = "gpt-4.1-mini", + helper = "填入 OpenAI 官方地址;拉取后以账号实际可用模型为准。" + ), + ModelProviderPreset( + id = "minimax", + label = "MiniMax", + baseUrl = "https://api.minimaxi.com/v1", + recommendedModel = "MiniMax-M3", + helper = "填入 MiniMax 国内官方 OpenAI-compatible 地址。" + ), + ModelProviderPreset( + id = "openrouter", + label = "OpenRouter", + baseUrl = "https://openrouter.ai/api/v1", + recommendedModel = "openrouter/auto", + helper = "优先使用 openrouter/auto,也可以从账号可用列表中改选。" + ), + ModelProviderPreset( + id = "siliconflow", + label = "SiliconFlow", + baseUrl = "https://api.siliconflow.cn/v1", + recommendedModel = "", + helper = "填入 SiliconFlow 地址,拉取后从账号可用模型中选择。" + ) + ) + + fun find(id: String): ModelProviderPreset = all.firstOrNull { it.id == id } ?: all.first() + + fun detect(baseUrl: String): ModelProviderPreset { + val normalized = baseUrl.trim().trimEnd('/').lowercase() + return all.firstOrNull { preset -> + preset.baseUrl.isNotBlank() && preset.baseUrl.trimEnd('/').lowercase() == normalized + } ?: all.first() + } +} + +internal fun choosePreferredChatModel( + currentModel: String, + recommendedModel: String, + availableModels: List +): String { + val models = availableModels.map(String::trim).filter(String::isNotBlank).distinct() + val current = currentModel.trim() + val recommended = recommendedModel.trim() + + if (current in models) return current + if (recommended in models) return recommended + + val chatCandidates = models.filterNot(::isClearlyNonChatModel) + return chatCandidates.singleOrNull().orEmpty() +} + +private fun isClearlyNonChatModel(model: String): Boolean { + val normalized = model.lowercase() + return listOf( + "embedding", + "rerank", + "moderation", + "whisper", + "tts", + "speech", + "audio", + "image", + "dall-e", + "stable-diffusion", + "transcription", + "ocr" + ).any(normalized::contains) || normalized.startsWith("bge-") +} diff --git a/app/src/main/java/com/frischar/fantareal/domain/llm/OpenAiProvider.kt b/app/src/main/java/com/frischar/fantareal/domain/llm/OpenAiProvider.kt index e9907d8..505d452 100644 --- a/app/src/main/java/com/frischar/fantareal/domain/llm/OpenAiProvider.kt +++ b/app/src/main/java/com/frischar/fantareal/domain/llm/OpenAiProvider.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonArray @@ -13,9 +14,6 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -67,6 +65,40 @@ class OpenAiProvider( .connectTimeout(60, TimeUnit.SECONDS) .build() + suspend fun fetchAvailableModels(): List = withContext(Dispatchers.IO) { + val httpReq = Request.Builder() + .url(normalizeEndpoint(baseUrl, "models")) + .apply { + if (apiKey.isNotBlank()) { + addHeader("Authorization", "Bearer $apiKey") + } + } + .get() + .build() + + client.newCall(httpReq).execute().use { response -> + if (!response.isSuccessful) { + throw IllegalStateException("拉取模型失败:HTTP ${response.code}") + } + + val responseText = response.body?.string().orEmpty() + val root = runCatching { AppJson.parseToJsonElement(responseText) as? JsonObject } + .getOrNull() + ?: throw IllegalStateException("模型列表接口没有返回有效 JSON") + val rows = root["data"] as? JsonArray + ?: throw IllegalStateException("模型列表格式无效:缺少 data 数组") + + rows.mapNotNull { item -> + (item as? JsonObject) + ?.get("id") + ?.let { it as? JsonPrimitive } + ?.contentOrNull + ?.trim() + ?.takeIf(String::isNotBlank) + }.distinct() + } + } + fun stream(request: LlmRequest): Flow = flow { val messages = mutableListOf() if (request.system != null) { @@ -84,7 +116,7 @@ class OpenAiProvider( try { val jsonBody = AppJson.encodeToString(openAiReq) val httpReq = Request.Builder() - .url(normalizeEndpoint(baseUrl)) + .url(normalizeEndpoint(baseUrl, "chat/completions")) .apply { if (apiKey.isNotBlank()) { addHeader("Authorization", "Bearer $apiKey") @@ -168,18 +200,24 @@ class OpenAiProvider( } }.flowOn(Dispatchers.IO) - private fun normalizeEndpoint(value: String): String { + private fun normalizeEndpoint(value: String, endpoint: String): String { val trimmed = value.trim().trimEnd('/') require(trimmed.isNotBlank()) { "API base URL is empty" } require(trimmed.startsWith("http://") || trimmed.startsWith("https://")) { "API base URL must start with http:// or https://" } - return if (trimmed.endsWith("/chat/completions")) { - trimmed - } else if (!trimmed.substringAfter("://", "").contains("/")) { - "$trimmed/v1/chat/completions" + + val cleanEndpoint = endpoint.trim('/') + val endpointBase = when { + trimmed.endsWith("/chat/completions") -> trimmed.removeSuffix("/chat/completions") + trimmed.endsWith("/models") -> trimmed.removeSuffix("/models") + else -> trimmed + }.trimEnd('/') + + return if (!endpointBase.substringAfter("://", "").contains("/")) { + "$endpointBase/v1/$cleanEndpoint" } else { - "$trimmed/chat/completions" + "$endpointBase/$cleanEndpoint" } } diff --git a/app/src/main/java/com/frischar/fantareal/ui/app/AppScaffold.kt b/app/src/main/java/com/frischar/fantareal/ui/app/AppScaffold.kt index 932eb61..3c6ad4b 100644 --- a/app/src/main/java/com/frischar/fantareal/ui/app/AppScaffold.kt +++ b/app/src/main/java/com/frischar/fantareal/ui/app/AppScaffold.kt @@ -206,6 +206,9 @@ fun AppScaffold() { onBaseUrlChange = settingsViewModel::updateApiBaseUrl, onApiKeyChange = settingsViewModel::updateApiKey, onModelNameChange = settingsViewModel::updateModel, + onProviderPresetChange = settingsViewModel::selectProviderPreset, + onApplyProviderPreset = settingsViewModel::applyProviderPresetAndFetch, + onFetchModels = settingsViewModel::fetchModels, onTemperatureChange = settingsViewModel::updateTemperature, onSupportStreamingChange = settingsViewModel::updateSupportStreaming, onOpenaiFormatChange = settingsViewModel::updateOpenaiFormat, diff --git a/app/src/main/java/com/frischar/fantareal/ui/settings/SettingsScreen.kt b/app/src/main/java/com/frischar/fantareal/ui/settings/SettingsScreen.kt index 2e8fce6..71bc4f3 100644 --- a/app/src/main/java/com/frischar/fantareal/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/frischar/fantareal/ui/settings/SettingsScreen.kt @@ -1,5 +1,6 @@ package com.frischar.fantareal.ui.settings +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -8,29 +9,49 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.draw.clip import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.frischar.fantareal.domain.llm.ModelProviderPresets import com.frischar.fantareal.ui.common.FantarealTopBar import com.frischar.fantareal.ui.common.GlassPanel import com.frischar.fantareal.ui.viewmodel.SettingsUiState @@ -42,6 +63,9 @@ fun SettingsScreen( onBaseUrlChange: (String) -> Unit, onApiKeyChange: (String) -> Unit, onModelNameChange: (String) -> Unit, + onProviderPresetChange: (String) -> Unit, + onApplyProviderPreset: () -> Unit, + onFetchModels: () -> Unit, onTemperatureChange: (Double) -> Unit, onSupportStreamingChange: (Boolean) -> Unit, onOpenaiFormatChange: (Boolean) -> Unit, @@ -97,6 +121,9 @@ fun SettingsScreen( onBaseUrlChange = onBaseUrlChange, onApiKeyChange = onApiKeyChange, onModelNameChange = onModelNameChange, + onProviderPresetChange = onProviderPresetChange, + onApplyProviderPreset = onApplyProviderPreset, + onFetchModels = onFetchModels, onTemperatureChange = onTemperatureChange, onSupportStreamingChange = onSupportStreamingChange, onOpenaiFormatChange = onOpenaiFormatChange @@ -260,18 +287,80 @@ fun ApiSettingsCard( onBaseUrlChange: (String) -> Unit, onApiKeyChange: (String) -> Unit, onModelNameChange: (String) -> Unit, + onProviderPresetChange: (String) -> Unit, + onApplyProviderPreset: () -> Unit, + onFetchModels: () -> Unit, onTemperatureChange: (Double) -> Unit, onSupportStreamingChange: (Boolean) -> Unit, onOpenaiFormatChange: (Boolean) -> Unit ) { + var modelPickerVisible by remember { mutableStateOf(false) } + var modelSearchQuery by remember { mutableStateOf("") } + val selectedPreset = ModelProviderPresets.find(uiState.selectedProviderId) + val modelOptions = buildList { + if (uiState.model.isNotBlank() && uiState.model !in uiState.availableModels) { + add(uiState.model) + } + addAll(uiState.availableModels) + } + GlassPanel(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("API 配置", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "模型服务", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + "选择服务商,填写凭据并获取账号可用模型。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) + ) { + Column( + modifier = Modifier.padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "服务商预设", + modifier = Modifier.padding(horizontal = 12.dp), + style = MaterialTheme.typography.labelLarge + ) + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(ModelProviderPresets.all, key = { it.id }) { preset -> + FilterChip( + selected = preset.id == selectedPreset.id, + onClick = { onProviderPresetChange(preset.id) }, + label = { Text(preset.label) }, + enabled = !uiState.isFetchingModels + ) + } + } + Text( + selectedPreset.helper, + modifier = Modifier.padding(horizontal = 12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } OutlinedTextField( value = uiState.apiBaseUrl, onValueChange = onBaseUrlChange, - label = { Text("Base URL (如 http://127.0.0.1:5000/v1)") }, + label = { Text("API Base URL") }, + placeholder = { Text("http://127.0.0.1:5000/v1") }, modifier = Modifier.fillMaxWidth(), singleLine = true ) @@ -281,19 +370,103 @@ fun ApiSettingsCard( onValueChange = onApiKeyChange, label = { Text("API Key") }, modifier = Modifier.fillMaxWidth(), - singleLine = true + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = PasswordVisualTransformation(), + supportingText = { Text("使用系统密钥加密保存在本机") } ) - OutlinedTextField( - value = uiState.model, - onValueChange = onModelNameChange, - label = { Text("Model Name") }, + Button( + onClick = onApplyProviderPreset, modifier = Modifier.fillMaxWidth(), - singleLine = true - ) + enabled = !uiState.isFetchingModels + ) { + Text(if (uiState.isFetchingModels) "正在获取模型…" else "应用预设并获取模型") + } - Column { - Text("Temperature: ${String.format("%.2f", uiState.temperature)}") + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.18f)) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("聊天模型", style = MaterialTheme.typography.titleSmall) + + OutlinedTextField( + value = uiState.model, + onValueChange = onModelNameChange, + label = { Text("Model Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + trailingIcon = { + TextButton( + onClick = { + modelSearchQuery = "" + modelPickerVisible = true + }, + enabled = !uiState.isFetchingModels && modelOptions.isNotEmpty() + ) { + Text("选择") + } + } + ) + + if (modelPickerVisible) { + ModelPickerDialog( + models = modelOptions, + selectedModel = uiState.model, + searchQuery = modelSearchQuery, + onSearchQueryChange = { modelSearchQuery = it }, + onSelectModel = { modelName -> + onModelNameChange(modelName) + modelPickerVisible = false + }, + onDismiss = { modelPickerVisible = false } + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + if (uiState.modelStatus.isNotBlank()) { + Text( + text = uiState.modelStatus, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = if (uiState.modelStatusIsError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + TextButton( + onClick = onFetchModels, + enabled = !uiState.isFetchingModels + ) { + val countSuffix = uiState.availableModels.takeIf { it.isNotEmpty() } + ?.let { " (${it.size})" } + .orEmpty() + Text(if (uiState.isFetchingModels) "获取中…" else "刷新模型$countSuffix") + } + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.18f)) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("生成参数", style = MaterialTheme.typography.titleSmall) + Text( + String.format("%.2f", uiState.temperature), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } Slider( value = uiState.temperature.toFloat(), onValueChange = { onTemperatureChange(it.toDouble()) }, @@ -301,15 +474,163 @@ fun ApiSettingsCard( ) } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text("支持流式 (Stream)") - Switch(checked = uiState.supportStreaming, onCheckedChange = onSupportStreamingChange) - } + SettingsToggleRow( + title = "流式响应", + description = "生成时逐步显示回复内容", + checked = uiState.supportStreaming, + onCheckedChange = onSupportStreamingChange + ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text("使用 OpenAI 格式") - Switch(checked = uiState.openaiFormat, onCheckedChange = onOpenaiFormatChange) + SettingsToggleRow( + title = "OpenAI 兼容格式", + description = "使用 chat/completions 请求结构", + checked = uiState.openaiFormat, + onCheckedChange = onOpenaiFormatChange + ) + } + } +} + +@Composable +private fun ModelPickerDialog( + models: List, + selectedModel: String, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onSelectModel: (String) -> Unit, + onDismiss: () -> Unit +) { + val filteredModels = if (searchQuery.isBlank()) { + models + } else { + models.filter { it.contains(searchQuery.trim(), ignoreCase = true) } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier + .widthIn(max = 480.dp) + .fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.22f) + ) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("选择聊天模型", style = MaterialTheme.typography.titleMedium) + Text( + "共 ${models.size} 个候选模型", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + TextButton(onClick = onDismiss) { + Text("关闭") + } + } + + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + label = { Text("搜索模型") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + if (filteredModels.isEmpty()) { + Text( + "没有匹配的模型", + modifier = Modifier.padding(vertical = 24.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + LazyColumn( + modifier = Modifier.heightIn(max = 360.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(filteredModels, key = { it }) { modelName -> + val selected = modelName == selectedModel + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surface + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .clickable { onSelectModel(modelName) } + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = selected, onClick = null) + Spacer(modifier = Modifier.width(8.dp)) + Text( + modelName, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + } } } } } + +@Composable +private fun SettingsToggleRow( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text(title, style = MaterialTheme.typography.bodyLarge) + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} diff --git a/app/src/main/java/com/frischar/fantareal/ui/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/frischar/fantareal/ui/viewmodel/SettingsViewModel.kt index f0b2014..874e038 100644 --- a/app/src/main/java/com/frischar/fantareal/ui/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/frischar/fantareal/ui/viewmodel/SettingsViewModel.kt @@ -4,7 +4,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.frischar.fantareal.data.repository.AppSettings import com.frischar.fantareal.data.repository.SettingsRepository +import com.frischar.fantareal.domain.llm.ModelProviderPresets +import com.frischar.fantareal.domain.llm.OpenAiProvider +import com.frischar.fantareal.domain.llm.choosePreferredChatModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -15,6 +20,11 @@ data class SettingsUiState( val apiBaseUrl: String = "https://api.openai.com/v1/", val apiKey: String = "", val model: String = "gpt-4o-mini", + val selectedProviderId: String = "openai", + val availableModels: List = emptyList(), + val isFetchingModels: Boolean = false, + val modelStatus: String = "", + val modelStatusIsError: Boolean = false, val temperature: Double = 0.7, val supportStreaming: Boolean = true, val openaiFormat: Boolean = true, @@ -36,17 +46,75 @@ class SettingsViewModel( ) : ViewModel() { private val _uiState = MutableStateFlow(settingsRepository.settings.value.toUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var fetchModelsJob: Job? = null fun updateApiBaseUrl(value: String) { - _uiState.update { it.copy(apiBaseUrl = value, saved = false) } + cancelModelFetch() + _uiState.update { + it.copy( + apiBaseUrl = value, + selectedProviderId = ModelProviderPresets.detect(value).id, + availableModels = emptyList(), + modelStatus = "", + modelStatusIsError = false, + saved = false + ) + } } fun updateApiKey(value: String) { - _uiState.update { it.copy(apiKey = value, saved = false) } + cancelModelFetch() + _uiState.update { + it.copy( + apiKey = value, + availableModels = emptyList(), + modelStatus = "", + modelStatusIsError = false, + saved = false + ) + } } fun updateModel(value: String) { - _uiState.update { it.copy(model = value, saved = false) } + _uiState.update { it.copy(model = value, modelStatusIsError = false, saved = false) } + } + + fun selectProviderPreset(presetId: String) { + cancelModelFetch() + val preset = ModelProviderPresets.find(presetId) + _uiState.update { + it.copy( + selectedProviderId = preset.id, + availableModels = emptyList(), + modelStatus = "", + modelStatusIsError = false + ) + } + } + + fun applyProviderPresetAndFetch() { + val current = _uiState.value + val preset = ModelProviderPresets.find(current.selectedProviderId) + _uiState.update { + it.copy( + apiBaseUrl = preset.baseUrl.ifBlank { it.apiBaseUrl }, + model = when { + preset.id == "custom" -> it.model + preset.recommendedModel.isNotBlank() -> preset.recommendedModel + else -> "" + }, + availableModels = emptyList(), + modelStatus = "已填入 ${preset.label} 预设。", + modelStatusIsError = false, + saved = false + ) + } + fetchModels(preset.recommendedModel) + } + + fun fetchModels() { + val preset = ModelProviderPresets.find(_uiState.value.selectedProviderId) + fetchModels(preset.recommendedModel) } fun updateTemperature(value: Double) { @@ -123,8 +191,91 @@ class SettingsViewModel( useSmartSplit = current.useSmartSplit ) ) - _uiState.update { settingsRepository.settings.value.toUiState(saved = true) } + _uiState.update { transientState -> + settingsRepository.settings.value.toUiState(saved = true).copy( + selectedProviderId = transientState.selectedProviderId, + availableModels = transientState.availableModels, + isFetchingModels = transientState.isFetchingModels, + modelStatus = transientState.modelStatus, + modelStatusIsError = transientState.modelStatusIsError + ) + } + } + } + + private fun fetchModels(recommendedModel: String) { + cancelModelFetch() + val current = _uiState.value + if (current.apiBaseUrl.isBlank()) { + _uiState.update { + it.copy(modelStatus = "请先填写 API Base URL。", modelStatusIsError = true) + } + return + } + + val preset = ModelProviderPresets.find(current.selectedProviderId) + if (preset.requiresApiKey && current.apiKey.isBlank()) { + _uiState.update { + it.copy( + modelStatus = "预设已填入;请填写 API Key 后再刷新模型列表。", + modelStatusIsError = false + ) + } + return } + + _uiState.update { + it.copy( + isFetchingModels = true, + modelStatus = "正在拉取模型列表…", + modelStatusIsError = false + ) + } + fetchModelsJob = viewModelScope.launch { + try { + val models = OpenAiProvider(current.apiBaseUrl.trim(), current.apiKey.trim()) + .fetchAvailableModels() + val selected = choosePreferredChatModel( + currentModel = current.model, + recommendedModel = recommendedModel, + availableModels = models + ) + val currentIsAvailable = current.model.isNotBlank() && current.model in models + val status = when { + models.isEmpty() -> "模型列表为空,可以继续手动填写。" + selected.isNotBlank() && selected != current.model -> + "已拉取 ${models.size} 个模型,已选择 $selected。" + currentIsAvailable -> "已拉取 ${models.size} 个模型,当前模型可用。" + else -> "已拉取 ${models.size} 个模型,请从下拉列表选择聊天模型。" + } + _uiState.update { + it.copy( + model = selected.ifBlank { it.model }, + availableModels = models, + isFetchingModels = false, + modelStatus = status, + modelStatusIsError = false, + saved = if (selected.isNotBlank() && selected != current.model) false else it.saved + ) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + _uiState.update { + it.copy( + isFetchingModels = false, + modelStatus = error.message ?: "拉取模型失败。", + modelStatusIsError = true + ) + } + } + } + } + + private fun cancelModelFetch() { + fetchModelsJob?.cancel() + fetchModelsJob = null + _uiState.update { it.copy(isFetchingModels = false) } } private fun AppSettings.toUiState(saved: Boolean = false): SettingsUiState { @@ -132,6 +283,7 @@ class SettingsViewModel( apiBaseUrl = apiBaseUrl, apiKey = apiKey, model = model, + selectedProviderId = ModelProviderPresets.detect(apiBaseUrl).id, temperature = temperature, supportStreaming = supportStreaming, openaiFormat = openaiFormat, diff --git a/app/src/test/java/com/frischar/fantareal/domain/llm/ModelCatalogTest.kt b/app/src/test/java/com/frischar/fantareal/domain/llm/ModelCatalogTest.kt new file mode 100644 index 0000000..db2f063 --- /dev/null +++ b/app/src/test/java/com/frischar/fantareal/domain/llm/ModelCatalogTest.kt @@ -0,0 +1,72 @@ +package com.frischar.fantareal.domain.llm + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Test + +class ModelCatalogTest { + @Test + fun openAiProviderFetchesAndDeduplicatesAvailableModels() = runBlocking { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """{"data":[{"id":"chat-model"},{"id":"chat-model"},{"id":"text-embedding-3-small"},{"owned_by":"test"}]}""" + ) + ) + server.start() + + try { + val models = OpenAiProvider( + server.url("/v1/chat/completions").toString(), + "test-key" + ).fetchAvailableModels() + + assertEquals(listOf("chat-model", "text-embedding-3-small"), models) + val request = server.takeRequest() + assertEquals("/v1/models", request.path) + assertEquals("Bearer test-key", request.getHeader("Authorization")) + } finally { + server.shutdown() + } + } + + @Test + fun preferredChatModelUsesSafeSelectionOrder() { + assertEquals( + "current-chat", + choosePreferredChatModel( + currentModel = "current-chat", + recommendedModel = "recommended-chat", + availableModels = listOf("recommended-chat", "current-chat") + ) + ) + assertEquals( + "recommended-chat", + choosePreferredChatModel( + currentModel = "missing-chat", + recommendedModel = "recommended-chat", + availableModels = listOf("recommended-chat", "other-chat") + ) + ) + assertEquals( + "only-chat", + choosePreferredChatModel( + currentModel = "", + recommendedModel = "", + availableModels = listOf("text-embedding-3-small", "only-chat") + ) + ) + assertEquals( + "", + choosePreferredChatModel( + currentModel = "missing-chat", + recommendedModel = "missing-recommended", + availableModels = listOf("chat-a", "chat-b", "text-embedding-3-small") + ) + ) + } +} From 7ca4ac6df6647381a61583b2ea24b4928fc41761 Mon Sep 17 00:00:00 2001 From: Frischar Date: Sat, 18 Jul 2026 21:10:36 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(android):=20=E5=88=87=E6=8D=A2=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=95=86=E6=97=B6=E9=9A=94=E7=A6=BB=20API=20Key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fantareal/domain/llm/ModelCatalog.kt | 20 +++++++++++++ .../ui/viewmodel/SettingsViewModel.kt | 17 +++++++++-- .../fantareal/domain/llm/ModelCatalogTest.kt | 28 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/frischar/fantareal/domain/llm/ModelCatalog.kt b/app/src/main/java/com/frischar/fantareal/domain/llm/ModelCatalog.kt index eba43da..de617b9 100644 --- a/app/src/main/java/com/frischar/fantareal/domain/llm/ModelCatalog.kt +++ b/app/src/main/java/com/frischar/fantareal/domain/llm/ModelCatalog.kt @@ -82,6 +82,26 @@ internal fun choosePreferredChatModel( return chatCandidates.singleOrNull().orEmpty() } +internal fun shouldClearApiKeyForProviderSwitch( + currentBaseUrl: String, + nextBaseUrl: String, + apiKey: String +): Boolean { + if (apiKey.isBlank()) return false + + val currentProvider = normalizeProviderBaseUrl(currentBaseUrl) + val nextProvider = normalizeProviderBaseUrl(nextBaseUrl) + return nextProvider.isNotBlank() && currentProvider != nextProvider +} + +private fun normalizeProviderBaseUrl(value: String): String = value + .trim() + .trimEnd('/') + .removeSuffix("/chat/completions") + .removeSuffix("/models") + .trimEnd('/') + .lowercase() + private fun isClearlyNonChatModel(model: String): Boolean { val normalized = model.lowercase() return listOf( diff --git a/app/src/main/java/com/frischar/fantareal/ui/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/frischar/fantareal/ui/viewmodel/SettingsViewModel.kt index 874e038..8fb1c74 100644 --- a/app/src/main/java/com/frischar/fantareal/ui/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/frischar/fantareal/ui/viewmodel/SettingsViewModel.kt @@ -7,6 +7,7 @@ import com.frischar.fantareal.data.repository.SettingsRepository import com.frischar.fantareal.domain.llm.ModelProviderPresets import com.frischar.fantareal.domain.llm.OpenAiProvider import com.frischar.fantareal.domain.llm.choosePreferredChatModel +import com.frischar.fantareal.domain.llm.shouldClearApiKeyForProviderSwitch import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -95,20 +96,32 @@ class SettingsViewModel( fun applyProviderPresetAndFetch() { val current = _uiState.value val preset = ModelProviderPresets.find(current.selectedProviderId) + val nextBaseUrl = preset.baseUrl.ifBlank { current.apiBaseUrl } + val clearApiKey = shouldClearApiKeyForProviderSwitch( + currentBaseUrl = current.apiBaseUrl, + nextBaseUrl = nextBaseUrl, + apiKey = current.apiKey + ) _uiState.update { it.copy( - apiBaseUrl = preset.baseUrl.ifBlank { it.apiBaseUrl }, + apiBaseUrl = nextBaseUrl, + apiKey = if (clearApiKey) "" else it.apiKey, model = when { preset.id == "custom" -> it.model preset.recommendedModel.isNotBlank() -> preset.recommendedModel else -> "" }, availableModels = emptyList(), - modelStatus = "已填入 ${preset.label} 预设。", + modelStatus = if (clearApiKey) { + "已切换至 ${preset.label};为保护旧服务商密钥,API Key 已清空,请填写新密钥后再获取模型。" + } else { + "已填入 ${preset.label} 预设。" + }, modelStatusIsError = false, saved = false ) } + if (clearApiKey) return fetchModels(preset.recommendedModel) } diff --git a/app/src/test/java/com/frischar/fantareal/domain/llm/ModelCatalogTest.kt b/app/src/test/java/com/frischar/fantareal/domain/llm/ModelCatalogTest.kt index db2f063..e50fa73 100644 --- a/app/src/test/java/com/frischar/fantareal/domain/llm/ModelCatalogTest.kt +++ b/app/src/test/java/com/frischar/fantareal/domain/llm/ModelCatalogTest.kt @@ -69,4 +69,32 @@ class ModelCatalogTest { ) ) } + + @Test + fun providerSwitchClearsOnlyCredentialsThatWouldCrossServiceBoundaries() { + assertEquals( + true, + shouldClearApiKeyForProviderSwitch( + currentBaseUrl = "https://api.openai.com/v1", + nextBaseUrl = "https://api.deepseek.com/v1", + apiKey = "openai-key" + ) + ) + assertEquals( + false, + shouldClearApiKeyForProviderSwitch( + currentBaseUrl = "https://api.openai.com/v1/chat/completions", + nextBaseUrl = "https://api.openai.com/v1/", + apiKey = "openai-key" + ) + ) + assertEquals( + false, + shouldClearApiKeyForProviderSwitch( + currentBaseUrl = "https://api.openai.com/v1", + nextBaseUrl = "https://api.deepseek.com/v1", + apiKey = "" + ) + ) + } }