diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 23603c6..43b9412 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "com.frischar.fantareal" minSdk = 26 targetSdk = 34 - versionCode = 14 - versionName = "1.6.3" + versionCode = 15 + versionName = "1.6.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/frischar/fantareal/app/usecase/ChatOrchestrator.kt b/app/src/main/java/com/frischar/fantareal/app/usecase/ChatOrchestrator.kt index 7170181..f564188 100644 --- a/app/src/main/java/com/frischar/fantareal/app/usecase/ChatOrchestrator.kt +++ b/app/src/main/java/com/frischar/fantareal/app/usecase/ChatOrchestrator.kt @@ -18,6 +18,9 @@ import com.frischar.fantareal.domain.worldbook.InjectionPosition import com.frischar.fantareal.domain.worldbook.WorldbookEngine import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.JsonArray import java.util.UUID class ChatOrchestrator( @@ -161,12 +164,71 @@ class ChatOrchestrator( } is LlmStreamEvent.Done -> { val parsedContent = parseAssistantContent(currentContent) + val finalVisible = parsedContent.visible.ifBlank { "[Error: Empty assistant response]" } conversationRepository.updateMessage( id = assistantMsgId, - newContent = parsedContent.visible.ifBlank { "[Error: Empty assistant response]" }, + newContent = finalVisible, thinking = parsedContent.thinking, saveToDisk = true ) + + if (parsedContent.visible.isNotBlank() && settings.useSmartSplit) { + try { + val subagentPrompt = """ + 你是一个聊天输出后处理器。 + + 你的任务是将输入文本整理成用于前端聊天气泡显示的内容。 + + 规则: + 1. 不得改写原文内容。 + 2. 不得新增剧情、补充描写或解释。 + 3. 不得润色文本。 + 4. 只移除明显不属于聊天正文的状态栏、系统栏、格式标签、空行和无意义分隔符。 + 5. 保留有效的角色动作、旁白、对白和剧情正文。 + 6. 按自然语义或句子进行切分。 + 7. 每个聊天气泡的内容之间,必须严格使用 `===` 作为唯一分隔符。 + 8. 只输出纯文本,绝对不要输出 JSON 或 Markdown,不要输出解释。 + + 输入文本: + ${parsedContent.visible} + """.trimIndent() + val subagentReq = LlmRequest( + model = settings.model, + system = "You are a text formatter. Output only plain text separated by ===.", + messages = listOf(com.frischar.fantareal.domain.llm.LlmMessage(role = "user", content = subagentPrompt)), + temperature = 0.1, + stream = true + ) + var subagentResponse = "" + conversationRepository.updateMessageBubbles(assistantMsgId, listOf("子代理正在切分气泡..."), saveToDisk = false) + + provider.stream(subagentReq).collect { event -> + if (event is LlmStreamEvent.Token) { + subagentResponse += event.text + val currentVisible = parseAssistantContent(subagentResponse).visible + val streamingBubbles = currentVisible.split("===") + .map { it.trim() } + .filter { it.isNotEmpty() } + if (streamingBubbles.isNotEmpty()) { + conversationRepository.updateMessageBubbles(assistantMsgId, streamingBubbles, saveToDisk = false) + } + } + } + + val finalVisibleSubagent = parseAssistantContent(subagentResponse).visible + val finalBubbles = finalVisibleSubagent.split("===") + .map { it.trim() } + .filter { it.isNotEmpty() } + + if (finalBubbles.isNotEmpty()) { + conversationRepository.updateMessageBubbles(assistantMsgId, finalBubbles, saveToDisk = true) + } else { + conversationRepository.updateMessageBubbles(assistantMsgId, listOf(finalVisible), saveToDisk = true) + } + } catch (e: Exception) { + conversationRepository.updateMessageBubbles(assistantMsgId, listOf(finalVisible), saveToDisk = true) + } + } } } } diff --git a/app/src/main/java/com/frischar/fantareal/data/repository/ConversationRepository.kt b/app/src/main/java/com/frischar/fantareal/data/repository/ConversationRepository.kt index c3f510a..da8aaa2 100644 --- a/app/src/main/java/com/frischar/fantareal/data/repository/ConversationRepository.kt +++ b/app/src/main/java/com/frischar/fantareal/data/repository/ConversationRepository.kt @@ -64,6 +64,21 @@ class ConversationRepository( } } + suspend fun updateMessageBubbles(id: String, bubbles: List, saveToDisk: Boolean = true) { + initialLoad.join() + if (saveToDisk) { + mutateMessages { current -> + current.map { + if (it.id == id) it.copy(bubbles = bubbles) else it + } + } + } else { + _messages.value = _messages.value.map { + if (it.id == id) it.copy(bubbles = bubbles) else it + } + } + } + suspend fun clearMessages() { initialLoad.join() mutateMessages { emptyList() } diff --git a/app/src/main/java/com/frischar/fantareal/data/repository/SettingsRepository.kt b/app/src/main/java/com/frischar/fantareal/data/repository/SettingsRepository.kt index 69d136f..4fdb9bc 100644 --- a/app/src/main/java/com/frischar/fantareal/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/frischar/fantareal/data/repository/SettingsRepository.kt @@ -1,4 +1,4 @@ -package com.frischar.fantareal.data.repository +package com.frischar.fantareal.data.repository import android.content.Context import android.content.SharedPreferences @@ -24,7 +24,8 @@ data class AppSettings( val aiAvatarUri: String? = null, val fontSize: Float = 16f, val fontColor: String = "", - val splitRegex: String = "" + val splitRegex: String = "", + val useSmartSplit: Boolean = true ) @Serializable @@ -42,7 +43,8 @@ private data class StoredSettings( val aiAvatarUri: String? = null, val fontSize: Float = 16f, val fontColor: String = "", - val splitRegex: String = "" + val splitRegex: String = "", + val useSmartSplit: Boolean = true ) class SettingsRepository( @@ -80,7 +82,8 @@ class SettingsRepository( aiAvatarUri = storedSettings.aiAvatarUri, fontSize = storedSettings.fontSize, fontColor = storedSettings.fontColor, - splitRegex = storedSettings.splitRegex + splitRegex = storedSettings.splitRegex, + useSmartSplit = storedSettings.useSmartSplit ) } @@ -103,7 +106,8 @@ class SettingsRepository( aiAvatarUri = newSettings.aiAvatarUri, fontSize = newSettings.fontSize, fontColor = newSettings.fontColor, - splitRegex = newSettings.splitRegex + splitRegex = newSettings.splitRegex, + useSmartSplit = newSettings.useSmartSplit ) ) } @@ -122,6 +126,7 @@ class SettingsRepository( .putFloat("fontSize", newSettings.fontSize) .putString("fontColor", newSettings.fontColor) .putString("splitRegex", newSettings.splitRegex) + .putBoolean("useSmartSplit", newSettings.useSmartSplit) .remove("apiKey") .apply() secretStore.saveApiKey(newSettings.apiKey) @@ -160,7 +165,8 @@ class SettingsRepository( aiAvatarUri = prefs.getString("aiAvatarUri", null), fontSize = prefs.getFloat("fontSize", 16f), fontColor = prefs.getString("fontColor", "") ?: "", - splitRegex = prefs.getString("splitRegex", "") ?: "" + splitRegex = prefs.getString("splitRegex", "") ?: "", + useSmartSplit = prefs.getBoolean("useSmartSplit", true) ) } } diff --git a/app/src/main/java/com/frischar/fantareal/domain/chat/ChatModels.kt b/app/src/main/java/com/frischar/fantareal/domain/chat/ChatModels.kt index e0afd91..1dda18d 100644 --- a/app/src/main/java/com/frischar/fantareal/domain/chat/ChatModels.kt +++ b/app/src/main/java/com/frischar/fantareal/domain/chat/ChatModels.kt @@ -14,5 +14,6 @@ data class ConversationMessage( val content: String, val createdAt: Long, val thinking: String? = null, - val error: Boolean = false + val error: Boolean = false, + val bubbles: List? = null ) 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 386b65c..dd4257b 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 @@ -169,6 +169,7 @@ fun AppScaffold() { createdAtText = "", thinkingText = it.thinking, error = it.error, + bubbles = it.bubbles, isStreaming = uiState.isSending && it.id == lastMsgId && it.role == com.frischar.fantareal.domain.chat.MessageRole.Assistant ) } @@ -211,6 +212,7 @@ fun AppScaffold() { onFontSizeChange = settingsViewModel::updateFontSize, onFontColorChange = settingsViewModel::updateFontColor, onSplitRegexChange = settingsViewModel::updateSplitRegex, + onUseSmartSplitChange = settingsViewModel::updateUseSmartSplit, onBackgroundImageUriChange = settingsViewModel::updateBackgroundImageUri, onUserAvatarUriChange = settingsViewModel::updateUserAvatarUri, onAiAvatarUriChange = settingsViewModel::updateAiAvatarUri, diff --git a/app/src/main/java/com/frischar/fantareal/ui/chat/ChatScreen.kt b/app/src/main/java/com/frischar/fantareal/ui/chat/ChatScreen.kt index fb5e412..c818c0f 100644 --- a/app/src/main/java/com/frischar/fantareal/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/frischar/fantareal/ui/chat/ChatScreen.kt @@ -77,7 +77,8 @@ data class ChatMessageUiModel( val isStreaming: Boolean, val spriteTag: String? = null, val thinkingText: String? = null, - val error: Boolean = false + val error: Boolean = false, + val bubbles: List? = null ) @OptIn(ExperimentalMaterial3Api::class) @@ -168,7 +169,13 @@ fun ChatScreen(personaName: String = "Fantareal", } else { Column { val splitRegex = settings.splitRegex - val contents = if (splitRegex.isNotBlank()) { + val contents = if (settings.useSmartSplit) { + if (msg.bubbles.isNullOrEmpty()) { + if (msg.isStreaming) listOf("思考剧情中...") else listOf(msg.content) + } else { + msg.bubbles + } + } else if (splitRegex.isNotBlank()) { try { val parts = msg.content.split(Regex(splitRegex)).filter { it.isNotBlank() } val mergedParts = mutableListOf() 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 5cba250..2e8fce6 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,4 +1,4 @@ -package com.frischar.fantareal.ui.settings +package com.frischar.fantareal.ui.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -51,6 +51,7 @@ fun SettingsScreen( onFontSizeChange: (Float) -> Unit, onFontColorChange: (String) -> Unit, onSplitRegexChange: (String) -> Unit, + onUseSmartSplitChange: (Boolean) -> Unit, onBackgroundImageUriChange: (String?) -> Unit, onUserAvatarUriChange: (String?) -> Unit, onAiAvatarUriChange: (String?) -> Unit, @@ -80,9 +81,11 @@ fun SettingsScreen( fontSize = uiState.fontSize, fontColor = uiState.fontColor, splitRegex = uiState.splitRegex, + useSmartSplit = uiState.useSmartSplit, onFontSizeChange = onFontSizeChange, onFontColorChange = onFontColorChange, onSplitRegexChange = onSplitRegexChange, + onUseSmartSplitChange = onUseSmartSplitChange, onBackgroundImageUriChange = onBackgroundImageUriChange, onUserAvatarUriChange = onUserAvatarUriChange, onAiAvatarUriChange = onAiAvatarUriChange @@ -120,6 +123,8 @@ fun AppearanceCard( onFontSizeChange: (Float) -> Unit, onFontColorChange: (String) -> Unit, onSplitRegexChange: (String) -> Unit, + useSmartSplit: Boolean, + onUseSmartSplitChange: (Boolean) -> Unit, darkMode: Boolean, showAvatar: Boolean, backgroundOpacity: Float, @@ -232,13 +237,19 @@ fun AppearanceCard( } } } - OutlinedTextField( - value = splitRegex, - onValueChange = onSplitRegexChange, - label = { Text("AI输出分段正则切割 (留空则不切割)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("子代理切分消息") + Switch(checked = useSmartSplit, onCheckedChange = onUseSmartSplitChange) + } + if (!useSmartSplit) { + OutlinedTextField( + value = splitRegex, + onValueChange = onSplitRegexChange, + label = { Text("AI输出分段正则切割 (留空则不切割)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } } } } 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 97d7b9b..f0b2014 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 @@ -1,4 +1,4 @@ -package com.frischar.fantareal.ui.viewmodel +package com.frischar.fantareal.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -27,6 +27,7 @@ data class SettingsUiState( val fontSize: Float = 16f, val fontColor: String = "", val splitRegex: String = "", + val useSmartSplit: Boolean = true, val saved: Boolean = false ) @@ -94,6 +95,10 @@ class SettingsViewModel( fun updateFontSize(value: Float) { _uiState.update { it.copy(fontSize = value, saved = false) } } fun updateFontColor(value: String) { _uiState.update { it.copy(fontColor = value, saved = false) } } fun updateSplitRegex(value: String) { _uiState.update { it.copy(splitRegex = value, saved = false) } } + fun updateUseSmartSplit(value: Boolean) { + _uiState.update { it.copy(useSmartSplit = value, saved = false) } + save() + } fun save() { val current = _uiState.value @@ -114,7 +119,8 @@ class SettingsViewModel( aiAvatarUri = current.aiAvatarUri, fontSize = current.fontSize, fontColor = current.fontColor, - splitRegex = current.splitRegex + splitRegex = current.splitRegex, + useSmartSplit = current.useSmartSplit ) ) _uiState.update { settingsRepository.settings.value.toUiState(saved = true) } @@ -138,6 +144,7 @@ class SettingsViewModel( fontSize = fontSize, fontColor = fontColor, splitRegex = splitRegex, + useSmartSplit = useSmartSplit, saved = saved ) }