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 f564188..5551d46 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 @@ -1,5 +1,7 @@ package com.frischar.fantareal.app.usecase +import com.frischar.fantareal.core.AppJson +import com.frischar.fantareal.data.repository.AppSettings import com.frischar.fantareal.data.repository.ConversationRepository import com.frischar.fantareal.data.repository.MemoryRepository import com.frischar.fantareal.data.repository.PersonaRepository @@ -11,6 +13,7 @@ import com.frischar.fantareal.domain.prompt.PromptBuildInput import com.frischar.fantareal.domain.prompt.PromptBuilder import com.frischar.fantareal.domain.chat.ConversationMessage import com.frischar.fantareal.domain.chat.MessageRole +import com.frischar.fantareal.domain.llm.LlmMessage import com.frischar.fantareal.domain.llm.LlmRequest import com.frischar.fantareal.domain.llm.LlmStreamEvent import com.frischar.fantareal.domain.llm.OpenAiProvider @@ -18,9 +21,10 @@ 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 kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull import java.util.UUID class ChatOrchestrator( @@ -59,9 +63,8 @@ class ChatOrchestrator( val history = conversationRepository.messages.value .filter { it.content.isNotBlank() && it.id != "welcome" && it.id != assistantMsgId && it.id != userMsg.id } val memories = memoryRepository.listMemories().map { it.text } - val presetModules = presetRepository.listPresets() - .filter { it.enabled } - .map { it.content } + val presetSegments = presetRepository.listPresets() + .flatMap { it.enabledPromptSegments() } val workshopState = workshopRepository.loadState() val workshopPrompt = "剧情阶段 ${workshopState.stage}: ${workshopState.stageName}\n${workshopState.stagePrompt}" val worldbookScan = worldbookEngine.scan( @@ -121,7 +124,8 @@ class ChatOrchestrator( # [权限解锁,禁词过滤器已挂载。DeepSeek 引擎已锁定世界书。开始演绎。] """.trimIndent(), persona = persona, - presetModules = presetModules + workshopPrompt, + presetModules = listOf(workshopPrompt), + presetSegments = presetSegments, memories = memories, worldbookBeforePersona = worldbookBeforePersona, worldbookRecent = worldbookRecent, @@ -299,7 +303,686 @@ class ChatOrchestrator( val thinking: String? ) + suspend fun archiveCurrentConversationToMemory() { + val history = conversationRepository.messages.value.filter { it.content.isNotBlank() && it.id != "welcome" } + if (history.size < 2) { + conversationRepository.clearMessages() + return + } + + val compressingMessageId = "memory_compressing_${UUID.randomUUID()}" + conversationRepository.addMessage( + ConversationMessage( + id = compressingMessageId, + role = MessageRole.Assistant, + content = "正在压缩记忆中...", + createdAt = System.currentTimeMillis() + ) + ) + + val settings = settingsRepository.settings.value + val provider = OpenAiProvider(settings.apiBaseUrl, settings.apiKey) + try { + val fallback = fallbackMemoryFromConversation(history) + val summary = summarizeConversationToMemory(history, provider, settings, fallback) + val saved = memoryRepository.addMemory( + text = summary.content, + tags = summary.tags, + title = summary.title, + notes = summary.notes + ) + if (saved != null) { + conversationRepository.clearMessages() + } else { + conversationRepository.updateMessage( + id = compressingMessageId, + newContent = "记忆未保存:内容可能为空、重复,或已经被删除记录拦截。当前对话已保留。", + saveToDisk = true + ) + } + } catch (e: Exception) { + conversationRepository.updateMessage( + id = compressingMessageId, + newContent = "记忆生成失败:${e.message ?: "未知错误"}。当前对话已保留。", + saveToDisk = true + ) + } + } + + private suspend fun summarizeConversationToMemory( + history: List, + provider: OpenAiProvider, + settings: AppSettings, + fallback: MemorySummaryPayload + ): MemorySummaryPayload { + // API 不可用(无网、HTTP 错误、空响应)时直接向上抛出, + // 由 archiveCurrentConversationToMemory 的 catch 提示失败并保留对话; + // 不再静默写入 fallback 伪摘要,否则会把对话原文拼接冒充 AI 摘要并清空原对话。 + val rawSummary = requestConversationSummaryWithModel(history, provider, settings) + + val parsed = runCatching { + parseSummaryJson(rawSummary) + }.getOrElse { + val repaired = runCatching { + requestSummaryRepair(rawSummary, provider, settings) + }.getOrNull() + if (repaired.isNullOrBlank()) return sanitizeMemorySummary(fallback, fallback) + runCatching { parseSummaryJson(repaired) } + .getOrElse { return sanitizeMemorySummary(fallback, fallback) } + } + + val localized = if (isMostlyEnglishSummary(parsed)) { + runCatching { + parseSummaryJson(requestSummaryRewriteToChinese(parsed, provider, settings)) + }.getOrElse { parsed } + } else { + parsed + } + + return sanitizeMemorySummary(localized, fallback) + } + + private suspend fun requestConversationSummaryWithModel( + history: List, + provider: OpenAiProvider, + settings: AppSettings + ): String { + return requestModelText( + provider = provider, + settings = settings, + temperature = 0.2, + system = """ +你是用于长期记忆归档的对话总结器。你必须只返回一个严格 JSON 对象,不要输出 markdown、解释、旁白、XML 或任何额外文本。 +JSON 对象只能包含四个键:title、content、tags、notes。 +总结时要记录具体事件,不要只写空泛主题。 +优先保留具体信息:请求、决定、承诺、结果、情绪变化、状态变化、后续待办。 +title 必须是简短中文标题,不超过 32 个汉字或 64 个 ASCII 字符。 +content 必须是较详细但紧凑的中文总结,通常 2 到 5 句;信息足够时不少于 80 个汉字。 +tags 必须是 2 到 6 个短标签,优先使用简体中文。 +notes 可以为空;有帮助时补充人名、地点、时间、数字、约定和未解决事项。 +输出必须以 { 开始,并以 } 结束。 + """.trimIndent(), + user = """ +请把下面完整对话总结为一条长期记忆。 +不要为了简短而省略重要事件。 +重点总结:实际发生了什么、有什么变化、做了什么决定、承诺了什么、后续还有什么重要事项。 +只返回 JSON。 + +格式示例: +${memorySchemaHint()} + +对话内容: +${buildConversationTranscript(history)} + """.trimIndent() + ) + } + + private suspend fun requestSummaryRepair( + rawText: String, + provider: OpenAiProvider, + settings: AppSettings + ): String { + return requestModelText( + provider = provider, + settings = settings, + temperature = 0.0, + system = """ +请把提供的内容修复为一个严格 JSON 对象。 +不要输出 markdown、解释或额外文本。 +对象只能包含四个键:title、content、tags、notes。 +除专有名词外,title、content、notes 必须使用简体中文。 + """.trimIndent(), + user = """ +请将下面内容修复为严格 JSON。如果原文已经是 JSON,请只修正格式,不要扩写。 + +格式示例: +${memorySchemaHint()} + +原始内容: +$rawText + """.trimIndent() + ) + } + + private suspend fun requestSummaryRewriteToChinese( + summary: MemorySummaryPayload, + provider: OpenAiProvider, + settings: AppSettings + ): String { + return requestModelText( + provider = provider, + settings = settings, + temperature = 0.0, + system = """ +你是 JSON 语言修正器。请在不改变原意的前提下,把输入中的长期记忆内容改写为简体中文。 +只返回一个严格 JSON 对象,不要输出 markdown、解释或额外文本。 +对象只能包含四个键:title、content、tags、notes。 + """.trimIndent(), + user = """ +请把下面 JSON 中的长期记忆内容改写为简体中文,并保持原意。 +不要丢失事件、决定、承诺、结果、情绪变化和未解决事项。 +只返回 JSON。 + +原始 JSON: +${summary.toJsonLikeString()} + """.trimIndent() + ) + } + + private suspend fun requestModelText( + provider: OpenAiProvider, + settings: AppSettings, + temperature: Double, + system: String, + user: String + ): String { + val request = LlmRequest( + model = settings.model, + system = system, + messages = listOf(LlmMessage(role = "user", content = user)), + temperature = temperature, + stream = false + ) + var output = "" + var error: String? = null + provider.stream(request).collect { event -> + when (event) { + is LlmStreamEvent.Token -> output += event.text + is LlmStreamEvent.Error -> error = event.message + LlmStreamEvent.Done -> Unit + } + } + if (output.isBlank()) { + throw IllegalStateException(error ?: "Empty summary response") + } + return parseAssistantContent(output).visible.trim() + } + + private fun fallbackMemoryFromConversation(history: List): MemorySummaryPayload { + val transcript = buildConversationTranscript(history) + val lastUser = history.asReversed().firstOrNull { it.role == MessageRole.User }?.content.orEmpty() + val highlightedTurns = history.takeLast(8).mapNotNull { message -> + val content = compactText(message.content, 110) + if (content.isBlank()) return@mapNotNull null + val speaker = if (message.role == MessageRole.User) "用户" else "AI" + "$speaker:$content" + } + val recentExchange = highlightedTurns.joinToString(" | ") + val notes = buildList { + if (lastUser.isNotBlank()) add("用户最后一次请求:${compactText(lastUser, 140)}") + if (recentExchange.isNotBlank()) add("最近关键轮次:$recentExchange") + }.joinToString("\n") + return MemorySummaryPayload( + title = compactText(lastUser.ifBlank { transcript }.ifBlank { "对话记忆" }, 32).ifBlank { "对话记忆" }, + content = recentExchange.ifBlank { compactText(transcript, 420) }.ifBlank { "系统已为本轮对话生成一条长期记忆摘要。" }, + tags = listOf("自动记忆", "对话总结"), + notes = notes + ) + } + + private fun sanitizeMemorySummary(payload: MemorySummaryPayload, fallback: MemorySummaryPayload): MemorySummaryPayload { + val title = compactText(payload.title.trim().ifBlank { fallback.title }, 60).ifBlank { "对话记忆" } + val normalizedContent = payload.content.replace(Regex("\\s+"), " ").trim() + val fallbackContent = fallback.content.trim() + val content = (if (normalizedContent.length < 80 && fallbackContent.length > normalizedContent.length) { + fallbackContent + } else { + normalizedContent + }).ifBlank { fallbackContent }.take(520) + val notes = payload.notes.trim().ifBlank { fallback.notes }.take(800) + val tags = sanitizeTags(payload.tags).ifEmpty { listOf("自动记忆", "对话总结") }.take(8) + return MemorySummaryPayload(title, content, tags, notes) + } + + private fun parseSummaryJson(candidate: String): MemorySummaryPayload { + var cleaned = candidate.trim() + if (cleaned.startsWith("```")) { + cleaned = cleaned + .replace(Regex("^```(?:json)?\\s*", RegexOption.IGNORE_CASE), "") + .replace(Regex("\\s*```$"), "") + .trim() + } + if (!cleaned.startsWith("{")) { + val start = cleaned.indexOf('{') + val end = cleaned.lastIndexOf('}') + if (start == -1 || end <= start) throw IllegalArgumentException("summary is not json") + cleaned = cleaned.substring(start, end + 1) + } + val obj = AppJson.parseToJsonElement(cleaned) as? JsonObject + ?: throw IllegalArgumentException("summary json must be an object") + val title = obj.stringField("title") + val content = obj.stringField("content") + val tags = obj.tagsField("tags") + val notes = obj.stringField("notes") + if (title.isBlank() || content.isBlank()) { + throw IllegalArgumentException("summary json missing required content") + } + return MemorySummaryPayload(title, content, tags, notes) + } + + private fun isMostlyEnglishSummary(payload: MemorySummaryPayload): Boolean { + val combined = listOf(payload.title, payload.content, payload.notes, payload.tags.joinToString(" ")).joinToString(" ") + if (combined.isBlank()) return false + val englishCount = Regex("[A-Za-z]").findAll(combined).count() + val chineseCount = Regex("[\\u4e00-\\u9fff]").findAll(combined).count() + return englishCount >= maxOf(40, chineseCount * 2) && chineseCount < 40 + } + + private fun buildConversationTranscript(history: List): String { + return history.joinToString("\n") { message -> + val speaker = when (message.role) { + MessageRole.User -> "User" + MessageRole.Assistant -> "AI" + MessageRole.System -> "System" + } + "$speaker: ${message.content}" + } + } + + private fun memorySchemaHint(): String { + return """ +{ + "title": "简短中文标题", + "content": "一段较详细的中文长期记忆总结,覆盖重要事件、决定、结果、情绪变化和未解决事项。", + "tags": ["标签1", "标签2", "标签3"], + "notes": "可选补充细节,例如人名、地点、约定、数字、时间点和待办事项。" +} + """.trimIndent() + } + + private fun sanitizeTags(tags: List): List { + return tags.map { it.trim() }.filter { it.isNotBlank() }.distinct().take(8) + } + + private fun compactText(value: String, limit: Int): String { + val text = value.replace(Regex("\\s+"), " ").trim() + return if (text.length <= limit) text else text.take((limit - 3).coerceAtLeast(0)).trimEnd() + "..." + } + + private fun JsonObject.stringField(key: String): String { + return (this[key] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty() + } + + private fun JsonObject.tagsField(key: String): List { + return when (val value = this[key]) { + is JsonArray -> value.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { tag -> tag.isNotBlank() } } + is JsonPrimitive -> value.contentOrNull + ?.split(",", ",", "、", "|") + ?.map { it.trim() } + ?.filter { it.isNotBlank() } + .orEmpty() + else -> emptyList() + } + } + + private data class MemorySummaryPayload( + val title: String, + val content: String, + val tags: List, + val notes: String + ) + + private fun MemorySummaryPayload.toJsonLikeString(): String { + val escapedTags = tags.joinToString(", ") { "\"${it.escapeJsonText()}\"" } + return """ +{ + "title": "${title.escapeJsonText()}", + "content": "${content.escapeJsonText()}", + "tags": [$escapedTags], + "notes": "${notes.escapeJsonText()}" +} + """.trimIndent() + } + + private fun String.escapeJsonText(): String { + return replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\r", "\\r") + .replace("\n", "\\n") + } + + /* + suspend fun archiveCurrentConversationToMemory() { + val history = conversationRepository.messages.value.filter { it.content.isNotBlank() && it.id != "welcome" } + if (history.size < 2) { + conversationRepository.clearMessages() + return + } + + val compressingMessageId = "memory_compressing_${UUID.randomUUID()}" + conversationRepository.addMessage( + ConversationMessage( + id = compressingMessageId, + role = MessageRole.Assistant, + content = "正在压缩记忆中...", + createdAt = System.currentTimeMillis() + ) + ) + + val settings = settingsRepository.settings.value + val provider = OpenAiProvider(settings.apiBaseUrl, settings.apiKey) + try { + val fallback = fallbackMemoryFromConversation(history) + val summary = summarizeConversationToMemory(history, provider, settings, fallback) + val saved = memoryRepository.addMemory( + text = summary.content, + tags = summary.tags, + title = summary.title, + notes = summary.notes + ) + if (saved != null) { + conversationRepository.clearMessages() + } else { + conversationRepository.updateMessage( + id = compressingMessageId, + newContent = "记忆未保存:内容可能为空、重复,或已被删除记录拦截。当前对话已保留。", + saveToDisk = true + ) + } + } catch (e: Exception) { + conversationRepository.updateMessage( + id = compressingMessageId, + newContent = "记忆生成失败:${e.message ?: "未知错误"}。当前对话已保留。", + saveToDisk = true + ) + } + } + + private suspend fun summarizeConversationToMemory( + history: List, + provider: OpenAiProvider, + settings: AppSettings, + fallback: MemorySummaryPayload + ): MemorySummaryPayload { + val rawSummary = runCatching { + requestConversationSummaryWithModel(history, provider, settings) + }.getOrElse { + return sanitizeMemorySummary(fallback, fallback) + } + + val parsed = runCatching { + parseSummaryJson(rawSummary) + }.getOrElse { + val repaired = requestSummaryRepair(rawSummary, provider, settings) + parseSummaryJson(repaired) + } + + val localized = if (isMostlyEnglishSummary(parsed)) { + runCatching { + parseSummaryJson(requestSummaryRewriteToChinese(parsed, provider, settings)) + }.getOrElse { parsed } + } else { + parsed + } + + return sanitizeMemorySummary(localized, fallback) + } + + private suspend fun requestConversationSummaryWithModel( + history: List, + provider: OpenAiProvider, + settings: AppSettings + ): String { + return requestModelText( + provider = provider, + settings = settings, + temperature = 0.2, + system = """ +你是用于长期记忆归档的对话总结器。 +你必须只返回一个严格 JSON 对象,不要输出 markdown、解释、旁白、XML 或任何额外文本。 +JSON 对象只能包含四个键:title、content、tags、notes。 +总结时要记录具体事件,不要只写空泛主题。 +优先保留具体信息:请求、决定、承诺、结果、情绪转折、状态变化、后续待办。 +title 必须是简短中文标题,不超过 32 个汉字或 64 个 ASCII 字符。 +content 必须是较详细但紧凑的中文总结,通常 2 到 5 句;信息足够时不少于 80 个汉字。 +tags 必须是 2 到 6 个短标签,优先使用简体中文。 +notes 可以为空;有帮助时补充人名、地点、时间、数字、约定和未解决事项。 +输出必须以 { 开始,并以 } 结束。 + """.trimIndent(), + user = """ +请把下面完整对话总结为一条长期记忆。 +不要为了简短而省略重要事件。 +重点总结:实际发生了什么、有什么变化、做了什么决定、承诺了什么、后续还有什么重要事项。 +只返回 JSON。 + +格式示例: +${memorySchemaHint()} + +对话内容: +${buildConversationTranscript(history)} + """.trimIndent() + ) + } + + private suspend fun requestSummaryRepair( + rawText: String, + provider: OpenAiProvider, + settings: AppSettings + ): String { + return requestModelText( + provider = provider, + settings = settings, + temperature = 0.0, + system = """ +请把提供的内容修复为一个严格 JSON 对象。 +不要输出 markdown、解释或额外文本。 +对象只能包含四个键:title、content、tags、notes。 +除专有名词外,title、content、notes 必须使用简体中文。 + """.trimIndent(), + user = """ +请将下面内容修复为严格 JSON。 +如果原文已经是 JSON,请只修正格式,不要扩写。 + +格式示例: +${memorySchemaHint()} + +原始内容: +$rawText + """.trimIndent() + ) + } + + private suspend fun requestSummaryRewriteToChinese( + summary: MemorySummaryPayload, + provider: OpenAiProvider, + settings: AppSettings + ): String { + return requestModelText( + provider = provider, + settings = settings, + temperature = 0.0, + system = """ +你是 JSON 语言修正器。 +请在不改变原意的前提下,把输入中的长期记忆内容改写为简体中文。 +只返回一个严格 JSON 对象,不要输出 markdown、解释或额外文本。 +对象只能包含四个键:title、content、tags、notes。 + """.trimIndent(), + user = """ +请把下面 JSON 中的长期记忆内容改写为简体中文,并保持原意。 +不要丢失事件、决定、承诺、结果、情绪变化和未解决事项。 +只返回 JSON。 + +原始 JSON: +${summary.toJsonLikeString()} + """.trimIndent() + ) + } + + private suspend fun requestModelText( + provider: OpenAiProvider, + settings: AppSettings, + temperature: Double, + system: String, + user: String + ): String { + val request = LlmRequest( + model = settings.model, + system = system, + messages = listOf(LlmMessage(role = "user", content = user)), + temperature = temperature, + stream = false + ) + var output = "" + var error: String? = null + provider.stream(request).collect { event -> + when (event) { + is LlmStreamEvent.Token -> output += event.text + is LlmStreamEvent.Error -> error = event.message + LlmStreamEvent.Done -> Unit + } + } + if (output.isBlank()) { + throw IllegalStateException(error ?: "Empty summary response") + } + return parseAssistantContent(output).visible.trim() + } + + private fun fallbackMemoryFromConversation(history: List): MemorySummaryPayload { + val transcript = buildConversationTranscript(history) + val lastUser = history.asReversed().firstOrNull { it.role == MessageRole.User }?.content.orEmpty() + val highlightedTurns = history.takeLast(8).mapNotNull { message -> + val content = compactText(message.content, 110) + if (content.isBlank()) return@mapNotNull null + val speaker = if (message.role == MessageRole.User) "用户" else "AI" + "$speaker:$content" + } + val recentExchange = highlightedTurns.joinToString(" | ") + val notes = buildList { + if (lastUser.isNotBlank()) add("用户最后一次请求:${compactText(lastUser, 140)}") + if (recentExchange.isNotBlank()) add("最近关键轮次:$recentExchange") + }.joinToString("\n") + return MemorySummaryPayload( + title = compactText(lastUser.ifBlank { transcript }.ifBlank { "对话记忆" }, 32).ifBlank { "对话记忆" }, + content = recentExchange.ifBlank { compactText(transcript, 420) }.ifBlank { "系统已为本轮对话生成一条长期记忆摘要。" }, + tags = listOf("自动记忆", "对话总结"), + notes = notes + ) + } + + private fun sanitizeMemorySummary(payload: MemorySummaryPayload, fallback: MemorySummaryPayload): MemorySummaryPayload { + val title = payload.title.trim().ifBlank { fallback.title }.take(60) + val normalizedContent = payload.content.replace(Regex("\\s+"), " ").trim() + val fallbackContent = fallback.content.trim() + val content = (if (normalizedContent.length < 80 && fallbackContent.length > normalizedContent.length) { + fallbackContent + } else { + normalizedContent + }).ifBlank { fallbackContent }.take(520) + val notes = payload.notes.trim().ifBlank { fallback.notes }.take(800) + val tags = sanitizeTags(payload.tags).ifEmpty { listOf("自动记忆", "对话总结") }.take(8) + return MemorySummaryPayload(title.ifBlank { "对话记忆" }, content, tags, notes) + } + + private fun parseSummaryJson(candidate: String): MemorySummaryPayload { + var cleaned = candidate.trim() + if (cleaned.startsWith("```")) { + cleaned = cleaned + .replace(Regex("^```(?:json)?\\s*", RegexOption.IGNORE_CASE), "") + .replace(Regex("\\s*```$"), "") + .trim() + } + if (!cleaned.startsWith("{")) { + val start = cleaned.indexOf('{') + val end = cleaned.lastIndexOf('}') + if (start == -1 || end <= start) throw IllegalArgumentException("summary is not json") + cleaned = cleaned.substring(start, end + 1) + } + val obj = AppJson.parseToJsonElement(cleaned) as? JsonObject + ?: throw IllegalArgumentException("summary json must be an object") + val title = obj.stringField("title") + val content = obj.stringField("content") + val tags = obj.tagsField("tags") + val notes = obj.stringField("notes") + if (title.isBlank() || content.isBlank()) { + throw IllegalArgumentException("summary json missing required content") + } + return MemorySummaryPayload(title, content, tags, notes) + } + + private fun isMostlyEnglishSummary(payload: MemorySummaryPayload): Boolean { + val combined = listOf(payload.title, payload.content, payload.notes, payload.tags.joinToString(" ")).joinToString(" ") + if (combined.isBlank()) return false + val englishCount = Regex("[A-Za-z]").findAll(combined).count() + val chineseCount = Regex("[\\u4e00-\\u9fff]").findAll(combined).count() + return englishCount >= maxOf(40, chineseCount * 2) && chineseCount < 40 + } + + private fun buildConversationTranscript(history: List): String { + return history.joinToString("\n") { message -> + val speaker = when (message.role) { + MessageRole.User -> "User" + MessageRole.Assistant -> "AI" + MessageRole.System -> "System" + } + "$speaker: ${message.content}" + } + } + + private fun memorySchemaHint(): String { + return """ +{ + "title": "简短中文标题", + "content": "一段较详细的中文长期记忆总结,覆盖重要事件、决定、结果、情绪变化和未解决事项", + "tags": ["标签1", "标签2", "标签3"], + "notes": "可选补充细节,例如人名、地点、约定、数字、时间点和待办事项" +} + """.trimIndent() + } + + private fun sanitizeTags(tags: List): List { + return tags.map { it.trim() }.filter { it.isNotBlank() }.distinct().take(8) + } + + private fun compactText(value: String, limit: Int): String { + val text = value.replace(Regex("\\s+"), " ").trim() + return if (text.length <= limit) text else text.take((limit - 3).coerceAtLeast(0)).trimEnd() + "..." + } + + private fun JsonObject.stringField(key: String): String { + return (this[key] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty() + } + + private fun JsonObject.tagsField(key: String): List { + return when (val value = this[key]) { + is JsonArray -> value.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotBlank) } + is JsonPrimitive -> value.contentOrNull + ?.split(",", ",", "、", "|") + ?.map { it.trim() } + ?.filter { it.isNotBlank() } + .orEmpty() + else -> emptyList() + } + } + + private data class MemorySummaryPayload( + val title: String, + val content: String, + val tags: List, + val notes: String + ) { + fun toJsonLikeString(): String { + val escapedTags = tags.joinToString(", ") { "\"${it.escapeJsonText()}\"" } + return """ +{ + "title": "${title.escapeJsonText()}", + "content": "${content.escapeJsonText()}", + "tags": [$escapedTags], + "notes": "${notes.escapeJsonText()}" +} + """.trimIndent() + } + } + + private fun String.escapeJsonText(): String { + return replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + } + + */ + suspend fun endChatAndSummarize() { + archiveCurrentConversationToMemory() + /* + val history = conversationRepository.messages.value.filter { it.content.isNotBlank() && it.id != "welcome" } if (history.size < 2) { conversationRepository.clearMessages() @@ -343,5 +1026,6 @@ class ChatOrchestrator( } finally { conversationRepository.clearMessages() } + */ } } diff --git a/app/src/main/java/com/frischar/fantareal/data/memory/MemoryService.kt b/app/src/main/java/com/frischar/fantareal/data/memory/MemoryService.kt index e5720d5..c893e05 100644 --- a/app/src/main/java/com/frischar/fantareal/data/memory/MemoryService.kt +++ b/app/src/main/java/com/frischar/fantareal/data/memory/MemoryService.kt @@ -2,8 +2,16 @@ package com.frischar.fantareal.data.memory import com.frischar.fantareal.core.AppJson import com.frischar.fantareal.domain.memory.LongTermMemory -import kotlinx.serialization.json.* import java.util.UUID +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.put class MemoryService { fun parseMemories(jsonText: String): List { @@ -16,29 +24,32 @@ class MemoryService { val parsed = mutableListOf() if (root is JsonArray) { - for (el in root) { - if (el is JsonObject) { - parseMemory(el)?.let(parsed::add) + for (element in root) { + if (element is JsonObject) { + parseMemory(element)?.let(parsed::add) } } } else if (root is JsonObject) { - val itemsArr = root["items"] - if (itemsArr is JsonArray) { - for (el in itemsArr) { - if (el is JsonObject) { - parseMemory(el)?.let(parsed::add) + val items = root["items"] + if (items is JsonArray) { + for (element in items) { + if (element is JsonObject) { + parseMemory(element)?.let(parsed::add) } } } else if (root.containsKey("data") && root["data"] is JsonObject) { - val dataObj = root["data"] as? JsonObject - val mesExample = (dataObj?.get("mes_example") as? JsonPrimitive)?.contentOrNull + val dataObject = root["data"] as? JsonObject + val mesExample = (dataObject?.get("mes_example") as? JsonPrimitive)?.contentOrNull if (!mesExample.isNullOrBlank()) { - parsed.add(LongTermMemory( - id = UUID.randomUUID().toString(), - text = mesExample, - tags = listOf("mes_example"), - createdAt = System.currentTimeMillis() - )) + parsed.add( + LongTermMemory( + id = UUID.randomUUID().toString(), + title = "\u793a\u4f8b\u5bf9\u8bdd", + text = mesExample, + tags = listOf("mes_example"), + createdAt = System.currentTimeMillis() + ) + ) } } else { parseMemory(root)?.let(parsed::add) @@ -50,46 +61,51 @@ class MemoryService { private fun parseMemory(obj: JsonObject): LongTermMemory? { val id = (obj["id"] as? JsonPrimitive)?.contentOrNull ?: UUID.randomUUID().toString() - val title = (obj["title"] as? JsonPrimitive)?.contentOrNull ?: "" + val title = (obj["title"] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty() val content = (obj["content"] as? JsonPrimitive)?.contentOrNull ?: (obj["text"] as? JsonPrimitive)?.contentOrNull ?: "" - val tags = obj["tags"]?.let { - if (it is JsonArray) { - it.mapNotNull { t -> (t as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotBlank) } - } else if (it is JsonPrimitive) { - it.contentOrNull - ?.split(",", ",", "、", "|") - ?.map { tag -> tag.trim() } - ?.filter { tag -> tag.isNotBlank() } - } else null - } ?: emptyList() - - // Use title + notes + content as the combined memory text - val notes = (obj["notes"] as? JsonPrimitive)?.contentOrNull ?: "" - val combinedContent = buildString { - if (title.isNotBlank()) append(title).append(": ") - append(content) - if (notes.isNotBlank()) append(" (").append(notes).append(")") - } - if (combinedContent.isBlank()) return null + val notes = (obj["notes"] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty() + val tags = parseTags(obj["tags"]) + + if (content.isBlank() && title.isBlank() && notes.isBlank()) return null return LongTermMemory( id = id, - text = combinedContent, + title = title, + text = content.trim().ifBlank { notes.ifBlank { title } }, + notes = notes, tags = tags, createdAt = System.currentTimeMillis() ) } + private fun parseTags(value: JsonElement?): List { + return when (value) { + is JsonArray -> value.mapNotNull { item -> + (item as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotBlank() } + } + is JsonPrimitive -> value.contentOrNull + ?.split(",", "\uFF0C", "\u3001", "|") + ?.map { it.trim() } + ?.filter { it.isNotBlank() } + .orEmpty() + else -> emptyList() + } + } + fun exportToJson(memories: List): String { val jsonArray = buildJsonArray { - memories.forEach { mem -> - add(buildJsonObject { - put("id", mem.id) - put("content", mem.text) - put("tags", buildJsonArray { mem.tags.forEach { add(it) } }) - }) + memories.forEach { memory -> + add( + buildJsonObject { + put("id", memory.id) + put("title", memory.title) + put("content", memory.text) + put("notes", memory.notes) + put("tags", buildJsonArray { memory.tags.forEach { add(it) } }) + } + ) } } val root = buildJsonObject { diff --git a/app/src/main/java/com/frischar/fantareal/data/preset/PresetService.kt b/app/src/main/java/com/frischar/fantareal/data/preset/PresetService.kt index 0e0aae4..348c06a 100644 --- a/app/src/main/java/com/frischar/fantareal/data/preset/PresetService.kt +++ b/app/src/main/java/com/frischar/fantareal/data/preset/PresetService.kt @@ -1,8 +1,24 @@ package com.frischar.fantareal.data.preset import com.frischar.fantareal.core.AppJson +import com.frischar.fantareal.domain.preset.PresetBlock +import com.frischar.fantareal.domain.preset.PresetGroup +import com.frischar.fantareal.domain.preset.PresetGroupItem +import com.frischar.fantareal.domain.preset.PresetModule import com.frischar.fantareal.domain.preset.PromptPreset -import kotlinx.serialization.json.* +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject import java.util.UUID class PresetService { @@ -10,103 +26,453 @@ class PresetService { fun parsePresets(jsonText: String): List { val root = try { AppJson.parseToJsonElement(jsonText) - } catch (e: Exception) { + } catch (_: Exception) { return emptyList() } - val parsed = mutableListOf() - extractPresetsRecursively(root, parsed) - return parsed.filter { it.content.isNotBlank() } + return collectPresetObjects(root) + .mapNotNull(::parsePresetObject) + .distinctBy { it.id } + .filter { it.runtimeContent().isNotBlank() || it.hasStructuredContent() } } - private fun extractPresetsRecursively(element: JsonElement, results: MutableList) { - when (element) { - is JsonArray -> { - for (el in element) { - extractPresetsRecursively(el, results) + fun exportToJson(presets: List): String { + val activeId = presets.firstOrNull { it.enabled }?.id ?: presets.firstOrNull()?.id.orEmpty() + return buildJsonObject { + put("active_preset_id", activeId) + putJsonArray("presets") { + presets.forEach { preset -> + add(buildJsonObject { + put("id", preset.id) + put("name", preset.title) + put("title", preset.title) + put("enabled", preset.enabled) + put("content", preset.runtimeContent()) + if (preset.hasStructuredContent()) { + put("base_system_prompt", preset.baseSystemPrompt) + putJsonObject("modules") { + preset.modules.forEach { module -> + put(module.key, module.enabled) + } + } + putJsonArray("extra_prompts") { + preset.extraPrompts.forEach { block -> + add(buildJsonObject { + put("id", block.id) + put("name", block.title) + put("title", block.title) + put("enabled", block.enabled) + put("content", block.content) + put("order", block.order) + putSegmentFields( + placement = block.placement, + role = block.role, + depth = block.depth, + kind = block.kind, + strength = block.strength, + required = block.required, + tokenBudget = block.tokenBudget, + activationTags = block.activationTags + ) + }) + } + } + putJsonArray("prompt_groups") { + preset.promptGroups.forEach { group -> + add(buildJsonObject { + put("id", group.id) + put("name", group.title) + put("title", group.title) + put("enabled", group.enabled) + put("selection_mode", group.selectionMode) + putJsonArray("selected_ids") { + group.selectedIds.forEach { add(JsonPrimitive(it)) } + } + put("order", group.order) + putJsonArray("items") { + group.items.forEach { item -> + add(buildJsonObject { + put("id", item.id) + put("name", item.title) + put("title", item.title) + put("enabled", item.enabled) + put("content", item.content) + put("order", item.order) + putSegmentFields( + placement = item.placement, + role = item.role, + depth = item.depth, + kind = item.kind, + strength = item.strength, + required = item.required, + tokenBudget = item.tokenBudget, + activationTags = item.activationTags + ) + }) + } + } + }) + } + } + } + }) } } + }.toString() + } + + private fun collectPresetObjects(element: JsonElement): List { + val results = mutableListOf() + collectPresetObjects(element, results) + return results + } + + private fun collectPresetObjects(element: JsonElement, results: MutableList) { + when (element) { + is JsonArray -> element.forEach { collectPresetObjects(it, results) } is JsonObject -> { - val content = buildPresetContent(element) - if (content.isNotBlank()) { - val id = (element["id"] as? JsonPrimitive)?.contentOrNull ?: UUID.randomUUID().toString() - val title = (element["title"] as? JsonPrimitive)?.contentOrNull - ?: (element["name"] as? JsonPrimitive)?.contentOrNull - ?: "Imported Preset" - val enabled = (element["enabled"] as? JsonPrimitive)?.booleanOrNull ?: true - - results.add(PromptPreset(id, title, content, enabled)) + val presetArray = element["presets"] as? JsonArray + if (presetArray != null) { + presetArray.forEach { collectPresetObjects(it, results) } + return + } + + if (looksLikePreset(element)) { + results += element + return } - // Recursively check all children arrays/objects for more prompts, - // but avoid re-extracting extra_prompts or prompt_groups since we handled them. - for ((key, child) in element) { - if (key == "modules" || key == "extra_prompts" || key == "prompt_groups" || key == "items") continue - if (child is JsonArray || child is JsonObject) { - extractPresetsRecursively(child, results) + element.forEach { (key, child) -> + if (key in INTERNAL_PRESET_KEYS) return@forEach + if (child is JsonObject || child is JsonArray) { + collectPresetObjects(child, results) } } } - else -> {} + else -> Unit } } - fun exportToJson(presets: List): String { - val jsonArray = buildJsonArray { - presets.forEach { preset -> - add(buildJsonObject { - put("id", preset.id) - put("title", preset.title) - put("content", preset.content) - put("enabled", preset.enabled) - }) - } + private fun looksLikePreset(element: JsonObject): Boolean { + if (element.containsKey("base_system_prompt") || + element.containsKey("baseSystemPrompt") || + element.containsKey("modules") || + element.containsKey("extra_prompts") || + element.containsKey("prompt_groups") + ) { + return true + } + + val hasPromptText = element.stringField("content").isNotBlank() || + element.stringField("prompt").isNotBlank() + val hasPresetIdentity = element.containsKey("id") || + element.containsKey("title") || + element.containsKey("name") || + element.containsKey("enabled") + return hasPromptText && hasPresetIdentity + } + + private fun parsePresetObject(element: JsonObject): PromptPreset? { + val flatContent = firstText(element, "content", "prompt") + val basePrompt = firstText(element, "base_system_prompt", "baseSystemPrompt") + val preset = PromptPreset( + id = element.stringField("id").ifBlank { UUID.randomUUID().toString() }, + title = firstText(element, "title", "name").ifBlank { "Imported Preset" }, + content = flatContent, + enabled = element.booleanField("enabled", true), + baseSystemPrompt = basePrompt, + modules = parseModules(element["modules"]), + extraPrompts = parseBlocks(element["extra_prompts"] ?: element["blocks"]), + promptGroups = parseGroups(element["prompt_groups"] ?: element["groups"]) + ) + val content = if (preset.hasStructuredContent()) { + preset.runtimeContent().ifBlank { flatContent } + } else { + flatContent + } + return preset.copy(content = content) + } + + private fun parseModules(element: JsonElement?): List { + if (element == null) return emptyList() + if (element is JsonArray) { + return applyModuleMutex( + element.mapIndexedNotNull { index, item -> + val obj = item as? JsonObject ?: return@mapIndexedNotNull null + val key = obj.stringField("key").ifBlank { return@mapIndexedNotNull null } + PresetModule( + key = key, + title = firstText(obj, "title", "name").ifBlank { moduleLabel(key) }, + content = obj.stringField("content").ifBlank { modulePrompt(key) }, + enabled = obj.booleanField("enabled", false), + order = obj.intField("order", moduleOrder(key, index)) + ) + } + ) } - return jsonArray.toString() + + val obj = element as? JsonObject ?: return emptyList() + val drafts = mutableMapOf() + obj.forEach { (key, value) -> + val valueObject = value as? JsonObject + drafts[key] = ModuleDraft( + enabled = valueObject?.booleanField("enabled", false) ?: value.booleanValue(default = false), + title = valueObject?.let { firstText(it, "title", "name") }.orEmpty(), + content = valueObject?.stringField("content").orEmpty(), + order = valueObject?.intField("order", moduleOrder(key, drafts.size)) ?: moduleOrder(key, drafts.size) + ) + } + + val keys = (DEFAULT_PRESET_MODULES.keys + drafts.keys).distinct() + return applyModuleMutex( + keys.mapIndexed { index, key -> + val draft = drafts[key] + PresetModule( + key = key, + title = draft?.title?.takeIf { it.isNotBlank() } ?: moduleLabel(key), + content = draft?.content?.takeIf { it.isNotBlank() } ?: modulePrompt(key), + enabled = draft?.enabled ?: false, + order = draft?.order ?: moduleOrder(key, index) + ) + } + ) } - private fun buildPresetContent(element: JsonObject): String { - val sections = mutableListOf() - listOf("base_system_prompt", "content", "prompt").firstNotNullOfOrNull { key -> - (element[key] as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotBlank() } - }?.let { sections += it } + private fun parseBlocks(element: JsonElement?): List { + val array = element as? JsonArray ?: return emptyList() + return array.mapIndexedNotNull { index, item -> + val obj = item as? JsonObject ?: return@mapIndexedNotNull null + PresetBlock( + id = obj.stringField("id").ifBlank { "preset-block-${index + 1}" }, + title = firstText(obj, "title", "name").ifBlank { "规则块 ${index + 1}" }, + content = obj.stringField("content").trim(), + enabled = obj.booleanField("enabled", true), + order = obj.intField("order", (index + 1) * 100), + placement = obj.optionalStringField("placement"), + role = obj.optionalStringField("role"), + depth = obj.optionalIntField("depth"), + kind = obj.optionalStringField("kind"), + strength = obj.optionalStringField("strength"), + required = obj.optionalBooleanField("required"), + tokenBudget = obj.optionalIntField("tokenBudget"), + activationTags = obj.stringListField("activation_tags") + ) + } + } - val modules = element["modules"] as? JsonObject - modules?.forEach { (key, value) -> - if ((value as? JsonPrimitive)?.booleanOrNull == true) { - PRESET_MODULE_PROMPTS[key]?.let { sections += it } + private fun parseGroups(element: JsonElement?): List { + val array = element as? JsonArray ?: return emptyList() + return array.mapIndexedNotNull { groupIndex, item -> + val obj = item as? JsonObject ?: return@mapIndexedNotNull null + val items = parseGroupItems(obj["items"]) + val validIds = items.map { it.id }.toSet() + val selectedIds = parseSelectedIds(obj["selected_ids"], validIds).ifEmpty { + parseSelectedIdsFromItems(obj["items"], items) + }.let { ids -> + if (normalizeSelectionMode(obj.stringField("selection_mode")) == "single") ids.take(1) else ids } + + PresetGroup( + id = obj.stringField("id").ifBlank { "preset-group-${groupIndex + 1}" }, + title = firstText(obj, "title", "name").ifBlank { "规则组 ${groupIndex + 1}" }, + enabled = obj.booleanField("enabled", true), + selectionMode = normalizeSelectionMode(obj.stringField("selection_mode")), + selectedIds = selectedIds, + items = items, + order = obj.intField("order", (groupIndex + 1) * 100) + ) } + } - val extraPrompts = element["extra_prompts"] as? JsonArray - extraPrompts?.forEach { extra -> - val obj = extra as? JsonObject ?: return@forEach - if (!obj.isEnabled()) return@forEach - (obj["content"] as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }?.let { sections += it } + private fun parseGroupItems(element: JsonElement?): List { + val array = element as? JsonArray ?: return emptyList() + return array.mapIndexedNotNull { index, item -> + val obj = item as? JsonObject ?: return@mapIndexedNotNull null + PresetGroupItem( + id = obj.stringField("id").ifBlank { "preset-group-item-${index + 1}" }, + title = firstText(obj, "title", "name").ifBlank { "规则项 ${index + 1}" }, + content = obj.stringField("content").trim(), + enabled = obj.booleanField("enabled", true), + order = obj.intField("order", index), + placement = obj.optionalStringField("placement"), + role = obj.optionalStringField("role"), + depth = obj.optionalIntField("depth"), + kind = obj.optionalStringField("kind"), + strength = obj.optionalStringField("strength"), + required = obj.optionalBooleanField("required"), + tokenBudget = obj.optionalIntField("tokenBudget"), + activationTags = obj.stringListField("activation_tags") + ) } + } + + private fun parseSelectedIds(element: JsonElement?, validIds: Set): List { + val array = element as? JsonArray ?: return emptyList() + return array.mapNotNull { selected -> + selected.stringValue().takeIf { it in validIds } + }.distinct() + } - val promptGroups = element["prompt_groups"] as? JsonArray - if (promptGroups != null) { - for (group in promptGroups) { - val groupObj = group as? JsonObject ?: continue - if (!groupObj.isEnabled()) continue - val items = groupObj["items"] as? JsonArray ?: continue - for (item in items) { - val itemObj = item as? JsonObject ?: continue - if (!itemObj.isEnabled()) continue - (itemObj["content"] as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }?.let { sections += it } + private fun parseSelectedIdsFromItems(element: JsonElement?, items: List): List { + val array = element as? JsonArray ?: return emptyList() + return array.mapIndexedNotNull { index, item -> + val obj = item as? JsonObject ?: return@mapIndexedNotNull null + val selected = obj.booleanField("selected", false) || obj.booleanField("checked", false) + if (selected) items.getOrNull(index)?.id else null + }.distinct() + } + + private fun applyModuleMutex(modules: List): List { + var result = modules + PRESET_MODULE_MUTEX.forEach { (key, opposites) -> + if (result.any { it.key == key && it.enabled }) { + result = result.map { module -> + if (module.key in opposites) module.copy(enabled = false) else module } } } + return result + } - return sections.joinToString("\n\n") + private fun normalizeSelectionMode(value: String): String { + val normalized = value.trim().lowercase() + return if (normalized in setOf("multi", "multiple", "checkbox", "checkboxes")) "multiple" else "single" } - private fun JsonObject.isEnabled(): Boolean { - return (this["enabled"] as? JsonPrimitive)?.booleanOrNull ?: true + private fun moduleLabel(key: String): String { + return PRESET_MODULE_LABELS[key] ?: key } + private fun modulePrompt(key: String): String { + return PRESET_MODULE_PROMPTS[key].orEmpty() + } + + private fun moduleOrder(key: String, fallbackIndex: Int): Int { + return DEFAULT_PRESET_MODULES.keys.indexOf(key).takeIf { it >= 0 } ?: fallbackIndex + } + + private fun firstText(element: JsonObject, vararg keys: String): String { + return keys.firstNotNullOfOrNull { key -> + element.stringField(key).trim().takeIf { it.isNotBlank() } + }.orEmpty() + } + + private fun JsonObject.stringField(key: String): String { + return (this[key] as? JsonPrimitive)?.contentOrNull.orEmpty() + } + + private fun JsonObject.optionalStringField(key: String): String? { + return stringField(key).trim().takeIf { it.isNotBlank() } + } + + private fun JsonObject.booleanField(key: String, default: Boolean): Boolean { + return this[key].booleanValue(default) + } + + private fun JsonObject.optionalBooleanField(key: String): Boolean? { + return (this[key] as? JsonPrimitive)?.booleanOrNull + } + + private fun JsonObject.intField(key: String, default: Int): Int { + return (this[key] as? JsonPrimitive)?.intOrNull ?: default + } + + private fun JsonObject.optionalIntField(key: String): Int? { + return (this[key] as? JsonPrimitive)?.intOrNull + } + + private fun JsonObject.stringListField(key: String): List { + val array = this[key] as? JsonArray ?: return emptyList() + return array.mapNotNull { item -> + (item as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotBlank() } + }.distinct() + } + + private fun JsonObjectBuilder.putSegmentFields( + placement: String?, + role: String?, + depth: Int?, + kind: String?, + strength: String?, + required: Boolean?, + tokenBudget: Int?, + activationTags: List + ) { + placement?.takeIf { it.isNotBlank() }?.let { put("placement", it) } + role?.takeIf { it.isNotBlank() }?.let { put("role", it) } + depth?.let { put("depth", it.coerceIn(0, 999)) } + kind?.takeIf { it.isNotBlank() }?.let { put("kind", it) } + strength?.takeIf { it.isNotBlank() }?.let { put("strength", it) } + required?.let { put("required", it) } + tokenBudget?.takeIf { it > 0 }?.let { put("tokenBudget", it) } + if (activationTags.isNotEmpty()) { + putJsonArray("activation_tags") { + activationTags.forEach { add(JsonPrimitive(it)) } + } + } + } + + private fun JsonElement?.booleanValue(default: Boolean): Boolean { + val primitive = this as? JsonPrimitive ?: return default + primitive.booleanOrNull?.let { return it } + return primitive.contentOrNull?.trim()?.lowercase()?.let { value -> + value in setOf("1", "true", "yes", "on") + } ?: default + } + + private fun JsonElement?.stringValue(): String { + return (this as? JsonPrimitive)?.contentOrNull?.trim().orEmpty() + } + + private data class ModuleDraft( + val enabled: Boolean, + val title: String, + val content: String, + val order: Int + ) + companion object { + private val INTERNAL_PRESET_KEYS = setOf("modules", "extra_prompts", "prompt_groups", "items") + + val DEFAULT_PRESET_MODULES = linkedMapOf( + "no_user_speaking" to true, + "short_paragraph" to false, + "long_paragraph" to false, + "second_person" to false, + "third_person" to false, + "anti_repeat" to true, + "no_closing_feel" to true, + "emotion_detail" to true, + "multi_character_boundary" to true, + "scene_continuation" to true, + "anti_horny" to false, + "anti_deification" to false, + "v4f_output_guard" to false + ) + + private val PRESET_MODULE_MUTEX = mapOf( + "short_paragraph" to listOf("long_paragraph"), + "long_paragraph" to listOf("short_paragraph"), + "second_person" to listOf("third_person"), + "third_person" to listOf("second_person") + ) + + private val PRESET_MODULE_LABELS = mapOf( + "no_user_speaking" to "防抢话", + "short_paragraph" to "短段落", + "long_paragraph" to "长段落", + "second_person" to "第二人称", + "third_person" to "第三人称", + "anti_repeat" to "抗重复", + "no_closing_feel" to "弱收尾", + "emotion_detail" to "情绪细节", + "multi_character_boundary" to "多角色边界", + "scene_continuation" to "场景延续", + "anti_horny" to "抗发情", + "anti_deification" to "抗神化", + "v4f_output_guard" to "V4F 稳定器" + ) + private val PRESET_MODULE_PROMPTS = mapOf( "no_user_speaking" to "Strictly do not write actions, dialogue, decisions, emotions, or thoughts on behalf of the user. Only describe non-user characters, the environment, and scene changes unless the user explicitly asks otherwise.", "short_paragraph" to "Keep natural paragraphs short, usually one or two sentences. Put dialogue in separate paragraphs and avoid dense walls of text.", @@ -114,7 +480,13 @@ class PresetService { "second_person" to "When referring to the user, use second person.", "third_person" to "When referring to the user, avoid second person and use third-person description instead.", "anti_repeat" to "Avoid repeating sentence patterns, scene beats, wording, and endings that have already appeared frequently.", - "no_closing_feel" to "Do not end with a summary, moral, curtain-call, or strong closing beat. Leave the reply in an ongoing moment." + "no_closing_feel" to "Do not end with a summary, moral, curtain-call, or strong closing beat. Leave the reply in an ongoing moment.", + "emotion_detail" to "Prefer observable emotional details from non-user characters, such as gaze, pauses, breath, hand movement, distance, and changes in tone. Avoid replacing description with blunt emotion labels.", + "multi_character_boundary" to "When multiple characters are present, keep each character's personality, voice, stance, and reactions distinct. Do not mix one character's thoughts, lines, or actions into another.", + "scene_continuation" to "Continue from the previous turn's actions, emotions, space, and scene state. Do not abruptly reset time, place, relationship stage, or plot direction unless the user clearly changes them.", + "anti_horny" to "Keep intimate or desire-driven escalation gated by character logic, trust, relationship stage, and scene necessity. Do not force sensual escalation when it does not fit the current moment.", + "anti_deification" to "Treat the world and NPCs as autonomous. Events, consequences, resources, distance, pain, fatigue, and other characters should follow their own internal logic instead of orbiting the user.", + "v4f_output_guard" to "Before each reply, apply a near-turn stability guard: obey current character, preset, lore, memory, and output-format constraints; do not explain rules; do not take over the user's actions, speech, thoughts, decisions, emotions, or physical reactions; keep the ending open for continued interaction." ) } } diff --git a/app/src/main/java/com/frischar/fantareal/data/repository/MemoryRepository.kt b/app/src/main/java/com/frischar/fantareal/data/repository/MemoryRepository.kt index c0cdadc..dd35b3b 100644 --- a/app/src/main/java/com/frischar/fantareal/data/repository/MemoryRepository.kt +++ b/app/src/main/java/com/frischar/fantareal/data/repository/MemoryRepository.kt @@ -32,7 +32,13 @@ class MemoryRepository( ) } - suspend fun addMemory(text: String, tags: List = emptyList(), similarityThreshold: Double = 0.86): LongTermMemory? { + suspend fun addMemory( + text: String, + tags: List = emptyList(), + similarityThreshold: Double = 0.86, + title: String = "", + notes: String = "" + ): LongTermMemory? { val normalizedText = text.trim() if (normalizedText.isBlank()) return null if (isBlockedByTombstone(normalizedText, similarityThreshold)) return null @@ -45,7 +51,9 @@ class MemoryRepository( val now = System.currentTimeMillis() val memory = LongTermMemory( id = UUID.randomUUID().toString(), + title = title.trim(), text = normalizedText, + notes = notes.trim(), tags = tags, createdAt = now ) diff --git a/app/src/main/java/com/frischar/fantareal/data/repository/PresetRepository.kt b/app/src/main/java/com/frischar/fantareal/data/repository/PresetRepository.kt index 1fccefb..c139c1e 100644 --- a/app/src/main/java/com/frischar/fantareal/data/repository/PresetRepository.kt +++ b/app/src/main/java/com/frischar/fantareal/data/repository/PresetRepository.kt @@ -21,8 +21,79 @@ class PresetRepository( } suspend fun setEnabled(id: String, enabled: Boolean) { + updatePreset(id) { it.copy(enabled = enabled) } + } + + suspend fun setModuleEnabled(presetId: String, moduleKey: String, enabled: Boolean) { + updatePreset(presetId) { preset -> + val nextModules = preset.modules.map { module -> + val toggled = if (module.key == moduleKey) module.copy(enabled = enabled) else module + if (enabled && module.key in moduleMutex[moduleKey].orEmpty()) { + toggled.copy(enabled = false) + } else { + toggled + } + } + preset.copy(modules = nextModules) + } + } + + suspend fun setBlockEnabled(presetId: String, blockId: String, enabled: Boolean) { + updatePreset(presetId) { preset -> + preset.copy( + extraPrompts = preset.extraPrompts.map { block -> + if (block.id == blockId) block.copy(enabled = enabled) else block + } + ) + } + } + + suspend fun setGroupEnabled(presetId: String, groupId: String, enabled: Boolean) { + updatePreset(presetId) { preset -> + preset.copy( + promptGroups = preset.promptGroups.map { group -> + if (group.id == groupId) group.copy(enabled = enabled) else group + } + ) + } + } + + suspend fun setGroupItemEnabled(presetId: String, groupId: String, itemId: String, enabled: Boolean) { + updatePreset(presetId) { preset -> + preset.copy( + promptGroups = preset.promptGroups.map { group -> + if (group.id != groupId) return@map group + + val nextSelectedIds = when { + enabled && group.selectionMode == "single" -> listOf(itemId) + enabled -> (group.selectedIds + itemId).distinct() + else -> group.selectedIds.filterNot { it == itemId } + } + group.copy( + selectedIds = nextSelectedIds, + items = group.items.map { item -> + if (item.id == itemId) item.copy(enabled = enabled) else item + } + ) + } + ) + } + } + + private suspend fun updatePreset(id: String, transform: (PromptPreset) -> PromptPreset) { val current = listPresets() - savePresets(current.map { if (it.id == id) it.copy(enabled = enabled) else it }) + val next = current.map { preset -> + if (preset.id == id) { + transform(preset).withRuntimeContent() + } else { + preset + } + } + savePresets(next) + } + + private fun PromptPreset.withRuntimeContent(): PromptPreset { + return copy(content = runtimeContent()) } private fun defaultPresets(): List { @@ -47,4 +118,13 @@ class PresetRepository( ) ) } + + companion object { + private val moduleMutex = mapOf( + "short_paragraph" to listOf("long_paragraph"), + "long_paragraph" to listOf("short_paragraph"), + "second_person" to listOf("third_person"), + "third_person" to listOf("second_person") + ) + } } diff --git a/app/src/main/java/com/frischar/fantareal/domain/memory/MemoryModels.kt b/app/src/main/java/com/frischar/fantareal/domain/memory/MemoryModels.kt index 6714be0..ae6603d 100644 --- a/app/src/main/java/com/frischar/fantareal/domain/memory/MemoryModels.kt +++ b/app/src/main/java/com/frischar/fantareal/domain/memory/MemoryModels.kt @@ -5,11 +5,19 @@ import kotlinx.serialization.Serializable @Serializable data class LongTermMemory( val id: String, + val title: String = "", val text: String, + val notes: String = "", val tags: List = emptyList(), val createdAt: Long, val updatedAt: Long = createdAt -) +) { + fun displayTitle(): String { + return title.ifBlank { + text.replace(Regex("\\s+"), " ").trim().take(32).ifBlank { "\u5bf9\u8bdd\u8bb0\u5fc6" } + } + } +} @Serializable data class MemoryTombstone( diff --git a/app/src/main/java/com/frischar/fantareal/domain/preset/PresetModels.kt b/app/src/main/java/com/frischar/fantareal/domain/preset/PresetModels.kt index 2e8edc2..ae04bd3 100644 --- a/app/src/main/java/com/frischar/fantareal/domain/preset/PresetModels.kt +++ b/app/src/main/java/com/frischar/fantareal/domain/preset/PresetModels.kt @@ -1,11 +1,315 @@ package com.frischar.fantareal.domain.preset +import com.frischar.fantareal.domain.prompt.PromptSegment +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class PromptPreset( val id: String, val title: String, - val content: String, - val enabled: Boolean = false + val content: String = "", + val enabled: Boolean = false, + @SerialName("base_system_prompt") + val baseSystemPrompt: String = "", + val modules: List = emptyList(), + @SerialName("extra_prompts") + val extraPrompts: List = emptyList(), + @SerialName("prompt_groups") + val promptGroups: List = emptyList() +) { + fun hasStructuredContent(): Boolean { + return baseSystemPrompt.isNotBlank() || + modules.isNotEmpty() || + extraPrompts.isNotEmpty() || + promptGroups.isNotEmpty() + } + + fun structuredPromptSections(): List { + return structuredPromptSegments().map { it.content } + } + + fun enabledPromptSections(): List { + return if (enabled) structuredPromptSections() else emptyList() + } + + fun structuredPromptSegments(): List { + if (!hasStructuredContent()) { + return content.trim().takeIf { it.isNotBlank() }?.let { + listOf( + PromptSegment( + id = "preset.$id.content", + title = title, + content = it, + placement = "before_history", + kind = "style", + order = 1000 + ) + ) + }.orEmpty() + } + + val segments = mutableListOf() + baseSystemPrompt.trim().takeIf { it.isNotBlank() }?.let { + segments += PromptSegment( + id = "preset.$id.base_system_prompt", + title = title.ifBlank { "Base" }, + content = it, + role = "system", + placement = "system_core", + order = 10, + kind = "base", + strength = "hard", + required = true + ) + } + + modules + .filter { it.enabled && it.content.isNotBlank() } + .sortedBy { it.order } + .forEach { module -> + val outputGuard = module.key == "v4f_output_guard" + segments += PromptSegment( + id = "preset.$id.module.${module.key}", + title = module.title, + content = module.content.trim(), + role = "system", + placement = if (outputGuard) "output_guard" else "system_core", + order = if (outputGuard) 900 else 100 + module.order * 10, + kind = if (outputGuard) "output_guard" else "output_rule", + strength = "hard", + required = true + ) + } + + var sequence = 0 + for (group in promptGroups.sortedBy { it.order }) { + if (!group.enabled || group.selectedIds.isEmpty()) continue + val selectedIds = group.selectedIds.toSet() + for (item in group.items) { + if (item.id !in selectedIds || !item.enabled || item.content.isBlank()) continue + sequence += 1 + segments += item.toPromptSegment( + id = "preset.$id.group.${group.id}.${item.id}", + title = "${group.title} / ${item.title}", + content = "[规则组:${group.title} / ${item.title}]\n${item.content.trim()}", + defaultOrder = 1000 + group.order + sequence + ) + } + } + + extraPrompts + .filter { it.enabled && it.content.isNotBlank() } + .forEachIndexed { index, block -> + segments += block.toPromptSegment( + id = "preset.$id.extra.${block.id}", + content = "[${block.title}]\n${block.content.trim()}", + defaultOrder = 2000 + block.order + index + ) + } + + return segments.sortedWith(compareBy { it.order }.thenBy { it.id }) + } + + fun enabledPromptSegments(): List { + return if (enabled) structuredPromptSegments() else emptyList() + } + + fun runtimeContent(): String { + return structuredPromptSections().joinToString("\n\n") + } + + fun switchableItemCount(): Int { + return modules.size + extraPrompts.size + promptGroups.size + promptGroups.sumOf { it.items.size } + } + + fun enabledSwitchableItemCount(): Int { + return modules.count { it.enabled } + + extraPrompts.count { it.enabled } + + promptGroups.count { it.enabled } + + promptGroups.sumOf { group -> group.items.count { group.isItemSelected(it) } } + } +} + +@Serializable +data class PresetModule( + val key: String, + val title: String, + val content: String = "", + val enabled: Boolean = false, + val order: Int = 0 +) + +@Serializable +data class PresetBlock( + val id: String, + val title: String, + val content: String = "", + val enabled: Boolean = true, + val order: Int = 0, + val placement: String? = null, + val role: String? = null, + val depth: Int? = null, + val kind: String? = null, + val strength: String? = null, + val required: Boolean? = null, + @SerialName("tokenBudget") + val tokenBudget: Int? = null, + @SerialName("activation_tags") + val activationTags: List = emptyList() +) { + fun metaLabel(): String = segmentMetaLabel(placement, role, depth, kind) +} + +@Serializable +data class PresetGroup( + val id: String, + val title: String, + val enabled: Boolean = true, + @SerialName("selection_mode") + val selectionMode: String = "single", + @SerialName("selected_ids") + val selectedIds: List = emptyList(), + val items: List = emptyList(), + val order: Int = 0 +) { + fun isItemSelected(item: PresetGroupItem): Boolean { + return item.enabled && selectedIds.contains(item.id) + } +} + +@Serializable +data class PresetGroupItem( + val id: String, + val title: String, + val content: String = "", + val enabled: Boolean = true, + val order: Int = 0, + val placement: String? = null, + val role: String? = null, + val depth: Int? = null, + val kind: String? = null, + val strength: String? = null, + val required: Boolean? = null, + @SerialName("tokenBudget") + val tokenBudget: Int? = null, + @SerialName("activation_tags") + val activationTags: List = emptyList() +) { + fun metaLabel(): String = segmentMetaLabel(placement, role, depth, kind) +} + +private fun PresetBlock.toPromptSegment(id: String, content: String, defaultOrder: Int): PromptSegment { + return PromptSegment( + id = id, + title = title, + content = content, + role = normalizePresetRole(role), + placement = normalizePresetPlacement(placement, "before_history"), + order = order.takeIf { it > 0 } ?: defaultOrder, + depth = normalizePresetDepth(depth), + kind = normalizePresetKind(kind, "style"), + strength = normalizePresetStrength(strength), + required = required ?: false, + tokenBudget = tokenBudget?.takeIf { it > 0 }, + activationTags = activationTags + ) +} + +private fun PresetGroupItem.toPromptSegment( + id: String, + title: String, + content: String, + defaultOrder: Int +): PromptSegment { + return PromptSegment( + id = id, + title = title, + content = content, + role = normalizePresetRole(role), + placement = normalizePresetPlacement(placement, "before_history"), + order = order.takeIf { it > 0 } ?: defaultOrder, + depth = normalizePresetDepth(depth), + kind = normalizePresetKind(kind, "style"), + strength = normalizePresetStrength(strength), + required = required ?: false, + tokenBudget = tokenBudget?.takeIf { it > 0 }, + activationTags = activationTags + ) +} + +private fun normalizePresetPlacement(value: String?, default: String): String { + val normalized = value?.trim().orEmpty() + return normalized.takeIf { it in presetPlacements } ?: default +} + +private fun normalizePresetRole(value: String?): String { + return when (value?.trim()?.lowercase()) { + "ai", "model" -> "assistant" + "assistant", "user", "system" -> value.trim().lowercase() + else -> "system" + } +} + +private fun normalizePresetDepth(value: Int?): Int { + return (value ?: 0).coerceIn(0, 999) +} + +private fun normalizePresetKind(value: String?, default: String): String { + val normalized = value?.trim().orEmpty() + return normalized.takeIf { it in presetKinds } ?: default +} + +private fun normalizePresetStrength(value: String?): String { + val normalized = value?.trim().orEmpty() + return normalized.takeIf { it in presetStrengths } ?: "soft" +} + +private fun segmentMetaLabel(placement: String?, role: String?, depth: Int?, kind: String?): String { + val parts = mutableListOf() + parts += normalizePresetPlacement(placement, "before_history") + parts += normalizePresetRole(role) + if (normalizePresetPlacement(placement, "before_history") == "at_depth") { + parts += "d${normalizePresetDepth(depth)}" + } + normalizePresetKind(kind, "").takeIf { it.isNotBlank() }?.let { parts += it } + return parts.joinToString(" · ") +} + +private val presetPlacements = setOf( + "system_core", + "system_format", + "before_character", + "character", + "after_character", + "lore_context", + "memory_context", + "before_history", + "at_depth", + "near_latest_user", + "output_guard" ) + +private val presetKinds = setOf( + "base", + "creator_persona", + "thinking_protocol", + "format", + "html_protocol", + "output_rule", + "output_guard", + "style", + "tone", + "plot", + "dialogue", + "scene", + "relationship", + "character_policy", + "lore_policy", + "reference", + "memory", + "director_note", + "mod" +) + +private val presetStrengths = setOf("hard", "soft") diff --git a/app/src/main/java/com/frischar/fantareal/domain/prompt/PromptBuilder.kt b/app/src/main/java/com/frischar/fantareal/domain/prompt/PromptBuilder.kt index 1458152..92a308c 100644 --- a/app/src/main/java/com/frischar/fantareal/domain/prompt/PromptBuilder.kt +++ b/app/src/main/java/com/frischar/fantareal/domain/prompt/PromptBuilder.kt @@ -15,9 +15,25 @@ data class PromptSection( val content: String ) +data class PromptSegment( + val id: String, + val title: String = "", + val content: String, + val role: String = "system", + val placement: String = "before_history", + val order: Int = 0, + val depth: Int = 0, + val kind: String = "style", + val strength: String = "soft", + val required: Boolean = false, + val tokenBudget: Int? = null, + val activationTags: List = emptyList() +) + data class PromptBuildInput( val systemRules: String, val presetModules: List = emptyList(), + val presetSegments: List = emptyList(), val worldbookBeforePersona: List = emptyList(), val persona: PersonaRuntime, val memories: List = emptyList(), @@ -35,19 +51,23 @@ data class PromptBuildResult( class PromptBuilder { fun build(input: PromptBuildInput): PromptBuildResult { - val systemText = buildSystemText(input) - val historyMessages = input.history + val segments = input.presetSegments + .filter { it.content.isNotBlank() } + .sortedWith(compareBy { it.order }.thenBy { it.id }) + val prefixMessages = mutableListOf() + val systemText = buildSystemText(input, segments, prefixMessages) + val history = input.history .filter { it.content.isNotBlank() && it.role != MessageRole.System } .takeLast(input.maxHistoryMessages) - .mapNotNull { message -> - when (message.role) { - MessageRole.User -> LlmMessage("user", message.content) - MessageRole.Assistant -> LlmMessage("assistant", message.content) - MessageRole.System -> null - } - } + val historyMessages = buildHistoryMessages(history, segments) + val tailMessages = buildList { + addAll(segmentsToMessages(segmentsAtDepth(segments, 0))) + addAll(segmentsToMessages(segmentsForPlacement(segments, "output_guard"))) + addAll(segmentsToMessages(segmentsForPlacement(segments, "near_latest_user"))) + add(LlmMessage("user", input.latestUserInput)) + } - val messages = historyMessages + LlmMessage("user", input.latestUserInput) + val messages = prefixMessages + historyMessages + tailMessages return when (input.providerFormat) { PromptProviderFormat.OpenAi -> PromptBuildResult(systemText, messages) @@ -55,16 +75,46 @@ class PromptBuilder { } } - private fun buildSystemText(input: PromptBuildInput): String { + private fun buildHistoryMessages( + history: List, + segments: List + ): List { + val messages = mutableListOf() + val historyCount = history.size + history.forEachIndexed { index, message -> + val tailDepth = historyCount - index + messages += segmentsToMessages(segmentsAtDepth(segments, tailDepth)) + when (message.role) { + MessageRole.User -> messages += LlmMessage("user", message.content) + MessageRole.Assistant -> messages += LlmMessage("assistant", message.content) + MessageRole.System -> Unit + } + } + return messages + } + + private fun buildSystemText( + input: PromptBuildInput, + segments: List, + prefixMessages: MutableList + ): String { val sections = buildList { add(PromptSection("System Rules", input.systemRules)) + addPlacementSections("Preset System Core", segments, "system_core", prefixMessages) addAll(input.presetModules.map { PromptSection("Preset", it) }) addAll(input.worldbookBeforePersona.map { PromptSection("Worldbook Before Persona", it) }) + addPlacementSections("Preset Before Character", segments, "before_character", prefixMessages) + addPlacementSections("Preset Character", segments, "character", prefixMessages) add(PromptSection("Persona", input.persona.systemPrompt)) + addPlacementSections("Preset After Character", segments, "after_character", prefixMessages) + addPlacementSections("Preset Lore Context", segments, "lore_context", prefixMessages) if (input.memories.isNotEmpty()) { add(PromptSection("Long Term Memory", input.memories.joinToString("\n"))) } + addPlacementSections("Preset Memory Context", segments, "memory_context", prefixMessages) addAll(input.worldbookRecent.map { PromptSection("Recent Worldbook", it) }) + addPlacementSections("Preset Before History", segments, "before_history", prefixMessages) + addPlacementSections("Preset System Format", segments, "system_format", prefixMessages) } return sections @@ -73,4 +123,66 @@ class PromptBuilder { "## ${section.title}\n${section.content.trim()}" } } + + private fun MutableList.addPlacementSections( + title: String, + segments: List, + placement: String, + prefixMessages: MutableList + ) { + val placementSegments = segmentsForPlacement(segments, placement) + val systemSections = placementSegments.filter { normalizeRole(it.role) == "system" } + if (systemSections.isNotEmpty()) { + add(PromptSection(title, systemSections.joinToString("\n\n") { it.content.trim() })) + } + val messageSegments = placementSegments.filter { normalizeRole(it.role) != "system" } + prefixMessages += segmentsToMessages(messageSegments) + } + + private fun segmentsForPlacement(segments: List, placement: String): List { + return segments + .filter { it.placement == placement } + .sortedWith(compareBy { it.order }.thenBy { it.id }) + } + + private fun segmentsAtDepth(segments: List, depth: Int): List { + return segments + .filter { it.placement == "at_depth" && it.depth.coerceIn(0, 999) == depth.coerceIn(0, 999) } + .sortedWith(compareBy { it.order }.thenBy { it.id }) + } + + private fun segmentsToMessages(segments: List): List { + val messages = mutableListOf() + var pendingRole = "" + val pendingSections = mutableListOf() + + fun flush() { + if (pendingSections.isEmpty()) return + messages += LlmMessage(pendingRole.ifBlank { "system" }, pendingSections.joinToString("\n\n")) + pendingRole = "" + pendingSections.clear() + } + + segments.forEach { segment -> + val role = normalizeRole(segment.role) + val content = segment.content.trim() + if (content.isBlank()) return@forEach + if (pendingSections.isNotEmpty() && role != pendingRole) { + flush() + } + pendingRole = role + pendingSections += content + } + flush() + return messages + } + + private fun normalizeRole(role: String): String { + val normalized = role.trim().lowercase() + return when (normalized) { + "ai", "model" -> "assistant" + "assistant", "user", "system" -> normalized + else -> "system" + } + } } 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 dd4257b..3c91eda 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 @@ -287,6 +287,10 @@ fun AppScaffold() { onImportBytes = presetViewModel::importFromBytes, onExportJson = presetViewModel::exportJson, onPresetEnabledChange = presetViewModel::setEnabled, + onModuleEnabledChange = presetViewModel::setModuleEnabled, + onBlockEnabledChange = presetViewModel::setBlockEnabled, + onGroupEnabledChange = presetViewModel::setGroupEnabled, + onGroupItemEnabledChange = presetViewModel::setGroupItemEnabled, onOpenDrawer = { scope.launch { drawerState.open() } } ) } diff --git a/app/src/main/java/com/frischar/fantareal/ui/memory/MemoryScreen.kt b/app/src/main/java/com/frischar/fantareal/ui/memory/MemoryScreen.kt index bfc88cf..26418a0 100644 --- a/app/src/main/java/com/frischar/fantareal/ui/memory/MemoryScreen.kt +++ b/app/src/main/java/com/frischar/fantareal/ui/memory/MemoryScreen.kt @@ -1,6 +1,5 @@ package com.frischar.fantareal.ui.memory -import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement @@ -16,7 +15,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -26,7 +24,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.frischar.fantareal.domain.memory.LongTermMemory @@ -37,7 +37,6 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MemoryScreen( uiState: MemoryUiState, @@ -48,13 +47,23 @@ fun MemoryScreen( ) { val context = LocalContext.current val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - uri?.let { val bytes = context.contentResolver.openInputStream(it)?.readBytes(); if (bytes != null) onImportBytes(bytes) } + uri?.let { + val bytes = context.contentResolver.openInputStream(it)?.use { input -> input.readBytes() } + if (bytes != null) onImportBytes(bytes) + } } val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> - uri?.let { destUri -> onExportJson { jsonString -> context.contentResolver.openOutputStream(destUri)?.use { out -> out.write(jsonString.toByteArray(Charsets.UTF_8)) } } } + uri?.let { destUri -> + onExportJson { jsonString -> + context.contentResolver.openOutputStream(destUri)?.use { output -> + output.write(jsonString.toByteArray(Charsets.UTF_8)) + } + } + } } - Scaffold(containerColor = androidx.compose.ui.graphics.Color.Transparent, + Scaffold( + containerColor = Color.Transparent, topBar = { FantarealTopBar(title = "记忆(动态信息)", onOpenDrawer = onOpenDrawer) } @@ -95,7 +104,10 @@ fun MemoryScreen( ) { if (uiState.memories.isEmpty()) { item { - Text("当前没有任何记忆记录。", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = "当前没有任何记忆记录。", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } else { items(uiState.memories, key = { it.id }) { entry -> @@ -114,15 +126,64 @@ fun MemoryCard(entry: LongTermMemory, onDelete: () -> Unit) { GlassPanel(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text("记忆记录: ${entry.id.takeLast(4)}", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.displayTitle(), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "ID: ${entry.id.takeLast(6)} · $dateText", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } IconButton(onClick = onDelete) { - Icon(Icons.Default.Delete, contentDescription = "删除记录", tint = MaterialTheme.colorScheme.error) + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "删除记忆", + tint = MaterialTheme.colorScheme.error + ) } } - Text("时间: $dateText", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(modifier = Modifier.height(8.dp)) - Text(entry.text, style = MaterialTheme.typography.bodyMedium, maxLines = 10, overflow = TextOverflow.Ellipsis) + + if (entry.tags.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = entry.tags.joinToString(" · "), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = entry.text, + style = MaterialTheme.typography.bodyMedium, + maxLines = 10, + overflow = TextOverflow.Ellipsis + ) + + if (entry.notes.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "备注:${entry.notes}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 6, + overflow = TextOverflow.Ellipsis + ) + } } } } diff --git a/app/src/main/java/com/frischar/fantareal/ui/preset/PresetScreen.kt b/app/src/main/java/com/frischar/fantareal/ui/preset/PresetScreen.kt index c249a3d..fbd2e45 100644 --- a/app/src/main/java/com/frischar/fantareal/ui/preset/PresetScreen.kt +++ b/app/src/main/java/com/frischar/fantareal/ui/preset/PresetScreen.kt @@ -1,6 +1,12 @@ package com.frischar.fantareal.ui.preset +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -9,19 +15,40 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults 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.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.frischar.fantareal.domain.preset.PresetBlock +import com.frischar.fantareal.domain.preset.PresetGroup +import com.frischar.fantareal.domain.preset.PresetGroupItem +import com.frischar.fantareal.domain.preset.PresetModule import com.frischar.fantareal.domain.preset.PromptPreset import com.frischar.fantareal.ui.common.FantarealTopBar import com.frischar.fantareal.ui.common.GlassPanel @@ -34,96 +61,683 @@ fun PresetScreen( onImportBytes: (ByteArray) -> Unit, onExportJson: ((String) -> Unit) -> Unit, onPresetEnabledChange: (String, Boolean) -> Unit, + onModuleEnabledChange: (String, String, Boolean) -> Unit, + onBlockEnabledChange: (String, String, Boolean) -> Unit, + onGroupEnabledChange: (String, String, Boolean) -> Unit, + onGroupItemEnabledChange: (String, String, String, Boolean) -> Unit, onOpenDrawer: () -> Unit ) { - Scaffold(containerColor = androidx.compose.ui.graphics.Color.Transparent, + val context = LocalContext.current + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { uri -> + uri?.let { destUri -> + onExportJson { jsonString -> + context.contentResolver.openOutputStream(destUri)?.use { output -> + output.write(jsonString.toByteArray(Charsets.UTF_8)) + } + Toast.makeText(context, "导出成功", Toast.LENGTH_SHORT).show() + } + } + } + val importLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + try { + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + if (bytes != null) { + onImportBytes(bytes) + } + } catch (_: Exception) { + Toast.makeText(context, "读取失败", Toast.LENGTH_SHORT).show() + } + } + + Scaffold( + containerColor = Color.Transparent, topBar = { FantarealTopBar(title = "预设(指令集)", onOpenDrawer = onOpenDrawer) } ) { paddingValues -> - Column(modifier = Modifier.fillMaxSize().padding(paddingValues)) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - val context = androidx.compose.ui.platform.LocalContext.current - val exportLauncher = androidx.activity.compose.rememberLauncherForActivityResult(androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/json")) { uri -> uri?.let { destUri -> onExportJson { jsonString -> context.contentResolver.openOutputStream(destUri)?.use { out -> out.write(jsonString.toByteArray(Charsets.UTF_8)) }; android.widget.Toast.makeText(context, "导出成功", android.widget.Toast.LENGTH_SHORT).show() } } } - val launcher = androidx.activity.compose.rememberLauncherForActivityResult( - androidx.activity.result.contract.ActivityResultContracts.GetContent() - ) { uri -> - if (uri != null) { - try { - val inputStream = context.contentResolver.openInputStream(uri) - val bytes = inputStream?.readBytes() - if (bytes != null) { - onImportBytes(bytes) - } - } catch (e: Exception) { - android.widget.Toast.makeText(context, "读取失败", android.widget.Toast.LENGTH_SHORT).show() - } - } + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + item { + PresetWorkbenchPanel( + totalCount = uiState.presets.size, + enabledCount = uiState.presets.count { it.enabled }, + switchCount = uiState.presets.sumOf { it.switchableItemCount() }, + enabledSwitchCount = uiState.presets.sumOf { it.enabledSwitchableItemCount() } + ) + } + item { + PresetActionPanel( + onImport = { importLauncher.launch("*/*") }, + onExport = { exportLauncher.launch("presets_export.json") } + ) + } + if (!uiState.statusMessage.isNullOrBlank()) { + item { + PresetNoticePanel( + text = uiState.statusMessage, + isError = false + ) } - - OutlinedButton(onClick = { launcher.launch("*/*") }) { - Text("导入并覆盖预设") + } + if (!uiState.error.isNullOrBlank()) { + item { + PresetNoticePanel( + text = uiState.error, + isError = true + ) + } + } + if (uiState.presets.isEmpty()) { + item { + PresetEmptyState() } - OutlinedButton(onClick = { - exportLauncher.launch("presets_export.json") - }) { - Text("导出预设") + } else { + itemsIndexed(uiState.presets, key = { _, preset -> preset.id }) { index, preset -> + PresetCard( + preset = preset, + index = index, + isActive = preset.enabled, + onToggle = { active -> onPresetEnabledChange(preset.id, active) }, + onModuleEnabledChange = onModuleEnabledChange, + onBlockEnabledChange = onBlockEnabledChange, + onGroupEnabledChange = onGroupEnabledChange, + onGroupItemEnabledChange = onGroupItemEnabledChange + ) } } + } + } +} - if (!uiState.statusMessage.isNullOrBlank()) { - Text( - text = uiState.statusMessage, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp) +@Composable +private fun PresetWorkbenchPanel( + totalCount: Int, + enabledCount: Int, + switchCount: Int, + enabledSwitchCount: Int +) { + GlassPanel( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.54f), + borderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.24f) + ) { + Column(modifier = Modifier.padding(18.dp)) { + Text( + text = "Preset library", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "全局预设库", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "导入 PC 预设后,可以直接查看和切换内置模块、规则块、分组与词条。", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(14.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PresetMetric(label = "预设总数", value = totalCount.toString(), modifier = Modifier.weight(1f)) + PresetMetric(label = "启用中", value = enabledCount.toString(), modifier = Modifier.weight(1f)) + } + if (switchCount > 0) { + Spacer(modifier = Modifier.height(10.dp)) + PresetMetric( + label = "分项开关", + value = "$enabledSwitchCount/$switchCount", + modifier = Modifier.fillMaxWidth() ) } + } + } +} - if (!uiState.error.isNullOrBlank()) { - Text( - text = uiState.error, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(horizontal = 16.dp) +@Composable +private fun PresetMetric(label: String, value: String, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.18f)) + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +private fun PresetActionPanel(onImport: () -> Unit, onExport: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + PresetActionButton( + text = "导入并覆盖", + onClick = onImport, + modifier = Modifier.weight(1f) + ) + PresetActionButton( + text = "导出预设", + onClick = onExport, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun PresetActionButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + OutlinedButton( + onClick = onClick, + modifier = modifier.height(52.dp), + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.28f), + contentColor = MaterialTheme.colorScheme.primary + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.28f)), + contentPadding = PaddingValues(horizontal = 10.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun PresetNoticePanel(text: String, isError: Boolean) { + val color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = color.copy(alpha = 0.12f), + border = BorderStroke(1.dp, color.copy(alpha = 0.32f)) + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = color, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +private fun PresetEmptyState() { + GlassPanel( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.46f), + borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.18f) + ) { + Column( + modifier = Modifier.padding(22.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "当前没有任何预设记录", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "可以导入 PC 端导出的预设 JSON,导入后会覆盖当前预设库。", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +fun PresetCard( + preset: PromptPreset, + index: Int, + isActive: Boolean, + onToggle: (Boolean) -> Unit, + onModuleEnabledChange: (String, String, Boolean) -> Unit, + onBlockEnabledChange: (String, String, Boolean) -> Unit, + onGroupEnabledChange: (String, String, Boolean) -> Unit, + onGroupItemEnabledChange: (String, String, String, Boolean) -> Unit +) { + val accent = MaterialTheme.colorScheme.primary + val hasDetails = preset.hasStructuredContent() + var expanded by rememberSaveable(preset.id) { mutableStateOf(index == 0 && hasDetails) } + + GlassPanel( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = if (isActive) 0.64f else 0.48f), + borderColor = if (isActive) accent.copy(alpha = 0.48f) else MaterialTheme.colorScheme.outline.copy(alpha = 0.20f) + ) { + Column(modifier = Modifier.padding(18.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + PresetIndexBadge(index = index + 1, isActive = isActive) + Spacer(modifier = Modifier.width(8.dp)) + PresetStateChip(text = if (isActive) "启用" else "停用", isActive = isActive) + } + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = preset.title.ifBlank { "未命名预设" }, + style = MaterialTheme.typography.titleLarge, + color = if (isActive) accent else MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(modifier = Modifier.width(14.dp)) + Switch( + checked = isActive, + onCheckedChange = onToggle, + colors = presetSwitchColors() ) } - - LazyColumn( - modifier = Modifier.fillMaxWidth().weight(1f), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = preset.runtimeContent().ifBlank { "这个预设还没有可注入内容。" }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = if (expanded) 7 else 4, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(14.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - if (uiState.presets.isEmpty()) { - item { - Text("当前没有任何预设记录。", color = MaterialTheme.colorScheme.onSurfaceVariant) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "${preset.runtimeContent().length} 字", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (preset.switchableItemCount() > 0) { + Text( + text = "分项 ${preset.enabledSwitchableItemCount()}/${preset.switchableItemCount()} 已开启", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - } else { - items(uiState.presets, key = { it.id }) { preset -> - val isActive = preset.enabled - PresetCard( - preset = preset, - isActive = isActive, - onToggle = { active -> onPresetEnabledChange(preset.id, active) } + } + if (hasDetails) { + TextButton(onClick = { expanded = !expanded }) { + Text( + text = if (expanded) "收起分项" else "展开分项", + fontWeight = FontWeight.Bold ) } + } else { + Text( + text = if (isActive) "聊天时会注入" else "保留但不注入", + style = MaterialTheme.typography.labelMedium, + color = if (isActive) accent else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Medium + ) } } + + if (expanded && hasDetails) { + Spacer(modifier = Modifier.height(14.dp)) + PresetDetailList( + preset = preset, + onModuleEnabledChange = onModuleEnabledChange, + onBlockEnabledChange = onBlockEnabledChange, + onGroupEnabledChange = onGroupEnabledChange, + onGroupItemEnabledChange = onGroupItemEnabledChange + ) + } + } + } +} + +@Composable +private fun PresetDetailList( + preset: PromptPreset, + onModuleEnabledChange: (String, String, Boolean) -> Unit, + onBlockEnabledChange: (String, String, Boolean) -> Unit, + onGroupEnabledChange: (String, String, Boolean) -> Unit, + onGroupItemEnabledChange: (String, String, String, Boolean) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (preset.baseSystemPrompt.isNotBlank()) { + PresetReadOnlySection( + title = "基础指令", + description = "随总开关注入", + content = preset.baseSystemPrompt + ) + } + if (preset.modules.isNotEmpty()) { + PresetModuleSection( + presetId = preset.id, + modules = preset.modules, + onModuleEnabledChange = onModuleEnabledChange + ) + } + if (preset.promptGroups.isNotEmpty()) { + PresetGroupSection( + presetId = preset.id, + groups = preset.promptGroups, + onGroupEnabledChange = onGroupEnabledChange, + onGroupItemEnabledChange = onGroupItemEnabledChange + ) + } + if (preset.extraPrompts.isNotEmpty()) { + PresetBlockSection( + presetId = preset.id, + blocks = preset.extraPrompts, + onBlockEnabledChange = onBlockEnabledChange + ) + } + } +} + +@Composable +private fun PresetReadOnlySection(title: String, description: String, content: String) { + PresetSectionSurface(title = title, description = description) { + Text( + text = content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 5, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun PresetModuleSection( + presetId: String, + modules: List, + onModuleEnabledChange: (String, String, Boolean) -> Unit +) { + PresetSectionSurface(title = "内置模块", description = "${modules.count { it.enabled }}/${modules.size} 已开启") { + modules.forEachIndexed { index, module -> + if (index > 0) PresetDivider() + PresetToggleRow( + title = module.title, + description = module.content.ifBlank { module.key }, + checked = module.enabled, + onCheckedChange = { checked -> onModuleEnabledChange(presetId, module.key, checked) } + ) } } } @Composable -fun PresetCard(preset: PromptPreset, isActive: Boolean, onToggle: (Boolean) -> Unit) { - GlassPanel(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text(preset.title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) - Switch(checked = isActive, onCheckedChange = onToggle) +private fun PresetBlockSection( + presetId: String, + blocks: List, + onBlockEnabledChange: (String, String, Boolean) -> Unit +) { + PresetSectionSurface(title = "规则块", description = "${blocks.count { it.enabled }}/${blocks.size} 已开启") { + blocks.forEachIndexed { index, block -> + if (index > 0) PresetDivider() + PresetToggleRow( + title = block.title, + description = "${block.metaLabel()}\n${block.content.ifBlank { "空规则块" }}", + checked = block.enabled, + onCheckedChange = { checked -> onBlockEnabledChange(presetId, block.id, checked) } + ) + } + } +} + +@Composable +private fun PresetGroupSection( + presetId: String, + groups: List, + onGroupEnabledChange: (String, String, Boolean) -> Unit, + onGroupItemEnabledChange: (String, String, String, Boolean) -> Unit +) { + PresetSectionSurface(title = "规则分组", description = "${groups.count { it.enabled }}/${groups.size} 组开启") { + groups.forEachIndexed { groupIndex, group -> + if (groupIndex > 0) PresetDivider() + PresetToggleRow( + title = group.title, + description = if (group.selectionMode == "multiple") "多选分组" else "单选分组", + checked = group.enabled, + onCheckedChange = { checked -> onGroupEnabledChange(presetId, group.id, checked) } + ) + if (group.items.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + group.items.forEachIndexed { itemIndex, item -> + if (itemIndex > 0) PresetSubDivider() + PresetGroupItemRow( + item = item, + checked = group.isItemSelected(item), + groupEnabled = group.enabled, + onCheckedChange = { checked -> + onGroupItemEnabledChange(presetId, group.id, item.id, checked) + } + ) + } } - Spacer(modifier = Modifier.height(8.dp)) - Text(preset.content, style = MaterialTheme.typography.bodyMedium, maxLines = 5, overflow = TextOverflow.Ellipsis) } } } + +@Composable +private fun PresetGroupItemRow( + item: PresetGroupItem, + checked: Boolean, + groupEnabled: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + val textColor = if (groupEnabled && checked) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, top = 6.dp, bottom = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.title, + style = MaterialTheme.typography.titleSmall, + color = textColor, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "${item.metaLabel()}\n${item.content.ifBlank { "空词条" }}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = presetSwitchColors() + ) + } +} + +@Composable +private fun PresetSectionSurface( + title: String, + description: String, + content: @Composable () -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.22f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.16f)) + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold + ) + Text( + text = description, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(10.dp)) + content() + } + } +} + +@Composable +private fun PresetToggleRow( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = presetSwitchColors() + ) + } +} + +@Composable +private fun PresetDivider() { + HorizontalDivider( + modifier = Modifier.padding(vertical = 6.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.14f) + ) +} + +@Composable +private fun PresetSubDivider() { + HorizontalDivider( + modifier = Modifier.padding(start = 12.dp, top = 2.dp, bottom = 2.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.08f) + ) +} + +@Composable +private fun PresetIndexBadge(index: Int, isActive: Boolean) { + val color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + Box( + modifier = Modifier + .size(30.dp) + .clip(CircleShape) + .background(color.copy(alpha = if (isActive) 0.18f else 0.10f)), + contentAlignment = Alignment.Center + ) { + Text( + text = index.toString().padStart(2, '0'), + style = MaterialTheme.typography.labelSmall, + color = color, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +private fun PresetStateChip(text: String, isActive: Boolean) { + val color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + Surface( + shape = RoundedCornerShape(999.dp), + color = color.copy(alpha = if (isActive) 0.14f else 0.08f), + border = BorderStroke(1.dp, color.copy(alpha = if (isActive) 0.34f else 0.16f)) + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + style = MaterialTheme.typography.labelMedium, + color = color, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +private fun presetSwitchColors() = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.70f), + uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, + uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.70f) +) diff --git a/app/src/main/java/com/frischar/fantareal/ui/viewmodel/ChatViewModel.kt b/app/src/main/java/com/frischar/fantareal/ui/viewmodel/ChatViewModel.kt index 8e32d8d..79b2c78 100644 --- a/app/src/main/java/com/frischar/fantareal/ui/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/frischar/fantareal/ui/viewmodel/ChatViewModel.kt @@ -56,7 +56,7 @@ class ChatViewModel( viewModelScope.launch { isSending.value = true try { - orchestrator.endChatAndSummarize() + orchestrator.archiveCurrentConversationToMemory() } catch (e: Exception) { e.printStackTrace() } finally { diff --git a/app/src/main/java/com/frischar/fantareal/ui/viewmodel/PresetViewModel.kt b/app/src/main/java/com/frischar/fantareal/ui/viewmodel/PresetViewModel.kt index 6602463..1186ef7 100644 --- a/app/src/main/java/com/frischar/fantareal/ui/viewmodel/PresetViewModel.kt +++ b/app/src/main/java/com/frischar/fantareal/ui/viewmodel/PresetViewModel.kt @@ -43,6 +43,34 @@ class PresetViewModel( } } + fun setModuleEnabled(presetId: String, moduleKey: String, enabled: Boolean) { + viewModelScope.launch { + presetRepository.setModuleEnabled(presetId, moduleKey, enabled) + reload() + } + } + + fun setBlockEnabled(presetId: String, blockId: String, enabled: Boolean) { + viewModelScope.launch { + presetRepository.setBlockEnabled(presetId, blockId, enabled) + reload() + } + } + + fun setGroupEnabled(presetId: String, groupId: String, enabled: Boolean) { + viewModelScope.launch { + presetRepository.setGroupEnabled(presetId, groupId, enabled) + reload() + } + } + + fun setGroupItemEnabled(presetId: String, groupId: String, itemId: String, enabled: Boolean) { + viewModelScope.launch { + presetRepository.setGroupItemEnabled(presetId, groupId, itemId, enabled) + reload() + } + } + fun importFromBytes(bytes: ByteArray) { viewModelScope.launch { try { diff --git a/app/src/test/java/com/frischar/fantareal/ParserVerificationTest.kt b/app/src/test/java/com/frischar/fantareal/ParserVerificationTest.kt index 7ff0cc7..2aca9a3 100644 --- a/app/src/test/java/com/frischar/fantareal/ParserVerificationTest.kt +++ b/app/src/test/java/com/frischar/fantareal/ParserVerificationTest.kt @@ -5,12 +5,22 @@ import com.frischar.fantareal.data.preset.PresetService import com.frischar.fantareal.data.memory.MemoryService import com.frischar.fantareal.data.rolecard.RoleCardService import com.frischar.fantareal.data.worldbook.WorldbookService +import com.frischar.fantareal.domain.chat.ConversationMessage +import com.frischar.fantareal.domain.chat.MessageRole import com.frischar.fantareal.domain.llm.LlmMessage import com.frischar.fantareal.domain.llm.LlmRequest import com.frischar.fantareal.domain.llm.LlmStreamEvent import com.frischar.fantareal.domain.llm.OpenAiProvider import com.frischar.fantareal.domain.memory.LongTermMemory +import com.frischar.fantareal.domain.prompt.PromptBuildInput +import com.frischar.fantareal.domain.prompt.PromptBuilder +import com.frischar.fantareal.domain.prompt.PromptSegment +import com.frischar.fantareal.domain.preset.PresetBlock +import com.frischar.fantareal.domain.preset.PresetGroup +import com.frischar.fantareal.domain.preset.PresetGroupItem +import com.frischar.fantareal.domain.preset.PresetModule import com.frischar.fantareal.domain.preset.PromptPreset +import com.frischar.fantareal.domain.rolecard.PersonaRuntime import com.frischar.fantareal.domain.rolecard.RoleCard import com.frischar.fantareal.domain.worldbook.InjectionPosition import com.frischar.fantareal.domain.worldbook.TriggerLogic @@ -128,6 +138,49 @@ class ParserVerificationTest { assertTrue(MemoryService().parseMemories("{}").isEmpty()) } + @Test + fun memoryParserPreservesStructuredFieldsAcrossExport() { + val service = MemoryService() + val json = """ + { + "items": [ + { + "id": "memory_structured", + "title": "阶段总结", + "content": "用户确认要把 PE 的结束对话记忆改成结构化提取,并要求保存成功后再清空对话。", + "notes": "需要保留 title、content、tags、notes 四个字段。", + "tags": ["记忆", "PE", "结构化"] + } + ] + } + """.trimIndent() + + val parsed = service.parseMemories(json).single() + assertEquals("memory_structured", parsed.id) + assertEquals("阶段总结", parsed.title) + assertEquals("用户确认要把 PE 的结束对话记忆改成结构化提取,并要求保存成功后再清空对话。", parsed.text) + assertEquals("需要保留 title、content、tags、notes 四个字段。", parsed.notes) + assertEquals(listOf("记忆", "PE", "结构化"), parsed.tags) + + val reparsed = service.parseMemories(service.exportToJson(listOf(parsed))).single() + assertEquals(parsed.title, reparsed.title) + assertEquals(parsed.text, reparsed.text) + assertEquals(parsed.notes, reparsed.notes) + assertEquals(parsed.tags, reparsed.tags) + } + + @Test + fun memoryParserKeepsLegacyTextMemoriesCompatible() { + val json = """[{"id":"legacy_memory","text":"Legacy saved memory.","tags":"summary,legacy"}]""" + + val parsed = MemoryService().parseMemories(json).single() + + assertEquals("legacy_memory", parsed.id) + assertEquals("", parsed.title) + assertEquals("Legacy saved memory.", parsed.text) + assertEquals(listOf("summary", "legacy"), parsed.tags) + } + @Test fun presetParserExpandsEnabledModules() { val json = """ @@ -156,6 +209,11 @@ class ParserVerificationTest { assertEquals("preset_1", preset.id) assertEquals("Story Preset", preset.title) + assertTrue(preset.hasStructuredContent()) + assertTrue(preset.modules.any { it.key == "emotion_detail" }) + assertTrue(preset.modules.first { it.key == "anti_repeat" }.enabled) + assertTrue(preset.modules.first { it.key == "short_paragraph" }.enabled) + assertTrue(!preset.modules.first { it.key == "long_paragraph" }.enabled) assertTrue(preset.content.contains("Base rule.")) assertTrue(preset.content.contains("Avoid repeating")) assertTrue(preset.content.contains("Keep natural paragraphs short")) @@ -163,6 +221,230 @@ class ParserVerificationTest { assertTrue(!preset.content.contains("Disabled rule.")) } + @Test + fun presetParserKeepsPromptGroupsAndSelectedItems() { + val json = """ + { + "id": "preset_grouped", + "name": "Grouped Preset", + "enabled": true, + "base_system_prompt": "Base rule.", + "modules": { + "v4f_output_guard": true, + "emotion_detail": true + }, + "prompt_groups": [ + { + "id": "tone_group", + "name": "Tone", + "enabled": true, + "selection_mode": "multiple", + "selected_ids": ["soft"], + "items": [ + {"id": "soft", "name": "Soft", "content": "Selected group rule."}, + {"id": "sharp", "name": "Sharp", "content": "Unselected group rule."} + ] + } + ], + "extra_prompts": [ + {"id": "extra_1", "name": "Extra", "enabled": true, "content": "Extra rule."} + ] + } + """.trimIndent() + + val preset = PresetService().parsePresets(json).single() + val runtime = preset.enabledPromptSections().joinToString("\n\n") + + assertEquals("preset_grouped", preset.id) + assertEquals("Grouped Preset", preset.title) + assertEquals(listOf("soft"), preset.promptGroups.single().selectedIds) + assertTrue(preset.modules.first { it.key == "v4f_output_guard" }.enabled) + assertTrue(runtime.contains("Selected group rule.")) + assertTrue(!runtime.contains("Unselected group rule.")) + assertTrue(runtime.contains("Extra rule.")) + } + + @Test + fun presetParserPreservesAdvancedSegmentFieldsAcrossExport() { + val json = """ + { + "presets": [ + { + "id": "preset_advanced", + "name": "Advanced Preset", + "enabled": true, + "extra_prompts": [ + { + "id": "depth_block", + "name": "Depth Block", + "enabled": true, + "content": "Depth rule.", + "order": 42, + "placement": "at_depth", + "role": "assistant", + "depth": 7, + "kind": "tone", + "strength": "hard", + "required": true, + "tokenBudget": 321, + "activation_tags": ["night", "rain"] + } + ], + "prompt_groups": [ + { + "id": "group_advanced", + "name": "Advanced Group", + "enabled": true, + "selection_mode": "single", + "selected_ids": ["near_item"], + "items": [ + { + "id": "near_item", + "name": "Near Item", + "enabled": true, + "content": "Near latest rule.", + "placement": "near_latest_user", + "role": "system", + "depth": 0, + "kind": "plot", + "strength": "soft", + "required": false, + "tokenBudget": 123, + "activation_tags": ["turn"] + } + ] + } + ] + } + ] + } + """.trimIndent() + + val service = PresetService() + val preset = service.parsePresets(json).single() + val exportedPreset = service.parsePresets(service.exportToJson(listOf(preset))).single() + val block = exportedPreset.extraPrompts.single() + val item = exportedPreset.promptGroups.single().items.single() + val segments = exportedPreset.enabledPromptSegments() + + assertEquals("at_depth", block.placement) + assertEquals("assistant", block.role) + assertEquals(7, block.depth) + assertEquals("tone", block.kind) + assertEquals("hard", block.strength) + assertEquals(true, block.required) + assertEquals(321, block.tokenBudget) + assertEquals(listOf("night", "rain"), block.activationTags) + assertEquals("near_latest_user", item.placement) + assertEquals("plot", item.kind) + assertTrue(segments.any { it.placement == "at_depth" && it.depth == 7 && it.role == "assistant" }) + assertTrue(segments.any { it.placement == "near_latest_user" && it.content.contains("Near latest rule.") }) + } + + @Test + fun promptBuilderInjectsPresetSegmentsByPlacementAndDepth() { + val result = PromptBuilder().build( + PromptBuildInput( + systemRules = "System rules.", + persona = PersonaRuntime(name = "Alice", systemPrompt = "Persona prompt."), + history = listOf( + ConversationMessage("u1", MessageRole.User, "Older user.", 1L), + ConversationMessage("a1", MessageRole.Assistant, "Latest assistant.", 2L) + ), + latestUserInput = "Current user.", + presetSegments = listOf( + PromptSegment( + id = "core", + content = "Core preset.", + placement = "system_core", + order = 1 + ), + PromptSegment( + id = "depth1", + content = "Depth one preset.", + placement = "at_depth", + role = "system", + depth = 1, + order = 1 + ), + PromptSegment( + id = "depth0", + content = "Depth zero preset.", + placement = "at_depth", + role = "system", + depth = 0, + order = 1 + ), + PromptSegment( + id = "guard", + content = "Output guard preset.", + placement = "output_guard", + role = "system", + order = 1 + ), + PromptSegment( + id = "near", + content = "Near latest preset.", + placement = "near_latest_user", + role = "system", + order = 1 + ) + ) + ) + ) + + assertTrue(result.system.orEmpty().contains("Core preset.")) + assertEquals("Older user.", result.messages[0].content) + assertEquals("Depth one preset.", result.messages[1].content) + assertEquals("Latest assistant.", result.messages[2].content) + assertEquals("Depth zero preset.", result.messages[3].content) + assertEquals("Output guard preset.", result.messages[4].content) + assertEquals("Near latest preset.", result.messages[5].content) + assertEquals("Current user.", result.messages[6].content) + } + + @Test + fun promptPresetRuntimeSectionsRespectNestedSwitches() { + val preset = PromptPreset( + id = "preset_nested", + title = "Nested", + enabled = true, + baseSystemPrompt = "Base rule.", + modules = listOf( + PresetModule(key = "m1", title = "Module 1", content = "Enabled module.", enabled = true), + PresetModule(key = "m2", title = "Module 2", content = "Disabled module.", enabled = false) + ), + extraPrompts = listOf( + PresetBlock(id = "b1", title = "Block 1", content = "Enabled block.", enabled = true), + PresetBlock(id = "b2", title = "Block 2", content = "Disabled block.", enabled = false) + ), + promptGroups = listOf( + PresetGroup( + id = "g1", + title = "Group 1", + enabled = true, + selectionMode = "single", + selectedIds = listOf("i1"), + items = listOf( + PresetGroupItem(id = "i1", title = "Item 1", content = "Selected item."), + PresetGroupItem(id = "i2", title = "Item 2", content = "Unselected item.") + ) + ) + ) + ) + + val runtime = preset.enabledPromptSections().joinToString("\n\n") + + assertTrue(runtime.contains("Base rule.")) + assertTrue(runtime.contains("Enabled module.")) + assertTrue(!runtime.contains("Disabled module.")) + assertTrue(runtime.contains("Enabled block.")) + assertTrue(!runtime.contains("Disabled block.")) + assertTrue(runtime.contains("Selected item.")) + assertTrue(!runtime.contains("Unselected item.")) + assertTrue(preset.copy(enabled = false).enabledPromptSections().isEmpty()) + } + @Test fun exportedCardsCanBeReimportedByImportParsers() { val roleCardJson = AppJson.encodeToString( @@ -182,7 +464,9 @@ class ParserVerificationTest { listOf( LongTermMemory( id = "memory_1", + title = "Saved title", text = "A saved memory.", + notes = "Saved note.", tags = listOf("summary"), createdAt = 123L ) @@ -190,7 +474,9 @@ class ParserVerificationTest { ) val memory = memoryService.parseMemories(memoryJson).single() assertEquals("memory_1", memory.id) + assertEquals("Saved title", memory.title) assertEquals("A saved memory.", memory.text) + assertEquals("Saved note.", memory.notes) val worldbookService = WorldbookService() val worldbookJson = worldbookService.exportToJson(