diff --git a/app/src/main/assets/packages/extended_memory_tools.js b/app/src/main/assets/packages/extended_memory_tools.js index 071d3edbd..d882b6f84 100644 --- a/app/src/main/assets/packages/extended_memory_tools.js +++ b/app/src/main/assets/packages/extended_memory_tools.js @@ -102,15 +102,10 @@ ] }, { - "name": "update_user_preferences", - "description": { "zh": "更新用户偏好信息(至少提供一个字段)。", "en": "Update user preferences (provide at least one field)." }, + "name": "update_user_profile", + "description": { "zh": "用完整 Markdown 更新 user.md。", "en": "Update user.md with the complete Markdown document." }, "parameters": [ - { "name": "birth_date", "description": { "zh": "可选:出生日期(Unix 毫秒时间戳)", "en": "Optional: birth date (Unix ms timestamp)" }, "type": "number", "required": false }, - { "name": "gender", "description": { "zh": "可选:性别", "en": "Optional: gender" }, "type": "string", "required": false }, - { "name": "personality", "description": { "zh": "可选:性格特征", "en": "Optional: personality" }, "type": "string", "required": false }, - { "name": "identity", "description": { "zh": "可选:身份/角色", "en": "Optional: identity/role" }, "type": "string", "required": false }, - { "name": "occupation", "description": { "zh": "可选:职业", "en": "Optional: occupation" }, "type": "string", "required": false }, - { "name": "ai_style", "description": { "zh": "可选:偏好 AI 交互风格", "en": "Optional: preferred AI interaction style" }, "type": "string", "required": false } + { "name": "markdown", "description": { "zh": "必需:user.md 的完整内容", "en": "Required: complete contents of user.md" }, "type": "string", "required": true } ] } ] @@ -214,21 +209,11 @@ const ExtendedMemoryTools = (function () { }); return { success: typeof result === 'string' ? result.length > 0 : !!result, message: '记忆链接删除完成', data: result }; } - async function update_user_preferences(params) { - const toolParams = {}; - if (params.birth_date !== undefined) - toolParams.birth_date = params.birth_date; - if (params.gender !== undefined) - toolParams.gender = params.gender; - if (params.personality !== undefined) - toolParams.personality = params.personality; - if (params.identity !== undefined) - toolParams.identity = params.identity; - if (params.occupation !== undefined) - toolParams.occupation = params.occupation; - if (params.ai_style !== undefined) - toolParams.ai_style = params.ai_style; - const result = await toolCall({ name: "update_user_preferences", params: toolParams }); + async function update_user_profile(params) { + const result = await toolCall({ + name: "update_user_profile", + params: { markdown: params.markdown }, + }); const success = typeof result === 'string' ? result.length > 0 : !!result; return { success, message: '用户偏好更新完成', data: result }; } @@ -256,7 +241,7 @@ const ExtendedMemoryTools = (function () { results.push({ tool: 'query_memory_links', result: { success: null, message: '未测试(只读查询)' } }); results.push({ tool: 'update_memory_link', result: { success: null, message: '未测试(会修改记忆库链接)' } }); results.push({ tool: 'delete_memory_link', result: { success: null, message: '未测试(会删除记忆库链接)' } }); - results.push({ tool: 'update_user_preferences', result: { success: null, message: '未测试(会修改用户偏好)' } }); + results.push({ tool: 'update_user_profile', result: { success: null, message: '未测试(会修改 user.md)' } }); complete({ success: true, message: "拓展记忆工具包加载完成(未执行破坏性测试)", @@ -272,7 +257,7 @@ const ExtendedMemoryTools = (function () { query_memory_links: (params) => wrapToolExecution(query_memory_links, params), update_memory_link: (params) => wrapToolExecution(update_memory_link, params), delete_memory_link: (params) => wrapToolExecution(delete_memory_link, params), - update_user_preferences: (params) => wrapToolExecution(update_user_preferences, params), + update_user_profile: (params) => wrapToolExecution(update_user_profile, params), main, }; })(); @@ -284,5 +269,5 @@ exports.link_memories = ExtendedMemoryTools.link_memories; exports.query_memory_links = ExtendedMemoryTools.query_memory_links; exports.update_memory_link = ExtendedMemoryTools.update_memory_link; exports.delete_memory_link = ExtendedMemoryTools.delete_memory_link; -exports.update_user_preferences = ExtendedMemoryTools.update_user_preferences; +exports.update_user_profile = ExtendedMemoryTools.update_user_profile; exports.main = ExtendedMemoryTools.main; diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt index f7be6199c..e7414deaf 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt @@ -349,7 +349,7 @@ class EnhancedAIService private constructor(private val context: Context) { var notifyReplyOverride: Boolean? = null, var chatModelConfigIdOverride: String? = null, var chatModelIndexOverride: Int? = null, - var preferenceProfileIdOverride: String? = null, + var memorySpaceIdOverride: String? = null, var stream: Boolean = true, var disableWarning: Boolean = false ) @@ -759,7 +759,7 @@ class EnhancedAIService private constructor(private val context: Context) { isSubTask: Boolean = false, chatModelConfigIdOverride: String? = null, chatModelIndexOverride: Int? = null, - preferenceProfileIdOverride: String? = null, + memorySpaceIdOverride: String? = null, stream: Boolean = true, publishEstimate: Boolean = true ): Int { @@ -785,7 +785,7 @@ class EnhancedAIService private constructor(private val context: Context) { isSubTask = isSubTask, functionType = functionType, modelConfig = modelConfig, - preferenceProfileIdOverride = preferenceProfileIdOverride, + memorySpaceIdOverride = memorySpaceIdOverride, dispatchHistoryHooks = PromptHookRegistry::dispatchPromptEstimateHistoryHooks, dispatchSystemPromptComposeHooks = ::bypassPromptHooks, dispatchToolPromptComposeHooks = ::bypassPromptHooks @@ -906,7 +906,7 @@ class EnhancedAIService private constructor(private val context: Context) { val notifyReplyOverride = options.notifyReplyOverride val chatModelConfigIdOverride = options.chatModelConfigIdOverride val chatModelIndexOverride = options.chatModelIndexOverride - val preferenceProfileIdOverride = options.preferenceProfileIdOverride + val memorySpaceIdOverride = options.memorySpaceIdOverride val stream = options.stream val disableWarning = options.disableWarning val onNonFatalError: suspend (error: String) -> Unit = { error -> @@ -991,7 +991,7 @@ class EnhancedAIService private constructor(private val context: Context) { isSubTask, functionType, modelSnapshot.config, - preferenceProfileIdOverride + memorySpaceIdOverride ) val tAfterPrepareHistory = messageTimingNow() AppLogger.d(TAG, "sendMessage本地耗时: prepareConversationHistory=${tAfterPrepareHistory - startTime}ms") @@ -1284,7 +1284,7 @@ class EnhancedAIService private constructor(private val context: Context) { notifyReplyOverride, chatModelConfigIdOverride, chatModelIndexOverride, - preferenceProfileIdOverride, + memorySpaceIdOverride, stream, enableGroupOrchestrationHint, disableWarning @@ -1709,7 +1709,7 @@ class EnhancedAIService private constructor(private val context: Context) { notifyReplyOverride: Boolean? = null, chatModelConfigIdOverride: String? = null, chatModelIndexOverride: Int? = null, - preferenceProfileIdOverride: String? = null, + memorySpaceIdOverride: String? = null, stream: Boolean = true, enableGroupOrchestrationHint: Boolean = false, disableWarning: Boolean = false @@ -1736,7 +1736,7 @@ class EnhancedAIService private constructor(private val context: Context) { isSubTask = isSubTask, chatId = chatId, notifyReplyOverride = notifyReplyOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride + memorySpaceIdOverride = memorySpaceIdOverride ) return } @@ -1761,7 +1761,7 @@ class EnhancedAIService private constructor(private val context: Context) { characterName = characterName, avatarUri = avatarUri, notifyReplyOverride = notifyReplyOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride + memorySpaceIdOverride = memorySpaceIdOverride ) return } @@ -1803,7 +1803,7 @@ class EnhancedAIService private constructor(private val context: Context) { notifyReplyOverride = notifyReplyOverride, chatModelConfigIdOverride = chatModelConfigIdOverride, chatModelIndexOverride = chatModelIndexOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride, + memorySpaceIdOverride = memorySpaceIdOverride, stream = stream, enableGroupOrchestrationHint = enableGroupOrchestrationHint, toolResultOverrideMessage = pureThinkingWarning, @@ -1878,7 +1878,7 @@ class EnhancedAIService private constructor(private val context: Context) { characterName = characterName, avatarUri = avatarUri, notifyReplyOverride = notifyReplyOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride + memorySpaceIdOverride = memorySpaceIdOverride ) return } @@ -1916,7 +1916,7 @@ class EnhancedAIService private constructor(private val context: Context) { notifyReplyOverride = notifyReplyOverride, chatModelConfigIdOverride = chatModelConfigIdOverride, chatModelIndexOverride = chatModelIndexOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride, + memorySpaceIdOverride = memorySpaceIdOverride, stream = stream, enableGroupOrchestrationHint = enableGroupOrchestrationHint, toolResultOverrideMessage = warningStatus, @@ -1953,7 +1953,7 @@ class EnhancedAIService private constructor(private val context: Context) { notifyReplyOverride, chatModelConfigIdOverride, chatModelIndexOverride, - preferenceProfileIdOverride, + memorySpaceIdOverride, stream = stream, enableGroupOrchestrationHint = enableGroupOrchestrationHint, disableWarning = disableWarning @@ -1971,7 +1971,7 @@ class EnhancedAIService private constructor(private val context: Context) { characterName = characterName, avatarUri = avatarUri, notifyReplyOverride = notifyReplyOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride + memorySpaceIdOverride = memorySpaceIdOverride ) logMessageTiming( stage = "enhanced.processStreamCompletion.complete", @@ -1997,7 +1997,7 @@ class EnhancedAIService private constructor(private val context: Context) { characterName: String? = null, avatarUri: String? = null, notifyReplyOverride: Boolean? = null, - preferenceProfileIdOverride: String? = null + memorySpaceIdOverride: String? = null ) { // Mark conversation as complete context.isConversationActive.set(false) @@ -2019,8 +2019,8 @@ class EnhancedAIService private constructor(private val context: Context) { runCatching { val currentChatId = chatId?.takeIf { it.isNotBlank() } val profileId = - preferenceProfileIdOverride?.takeIf { it.isNotBlank() } - ?: preferencesManager.activeProfileIdFlow.first() + memorySpaceIdOverride?.takeIf { it.isNotBlank() } + ?: preferencesManager.activeMemorySpaceIdFlow.first() if (currentChatId.isNullOrBlank()) { AppLogger.w(TAG, "自动保存长期记忆入队跳过:chatId为空") } else { @@ -2069,7 +2069,7 @@ class EnhancedAIService private constructor(private val context: Context) { notifyReplyOverride: Boolean? = null, chatModelConfigIdOverride: String? = null, chatModelIndexOverride: Int? = null, - preferenceProfileIdOverride: String? = null, + memorySpaceIdOverride: String? = null, stream: Boolean = true, enableGroupOrchestrationHint: Boolean = false, toolResultOverrideMessage: String? = null, @@ -2114,7 +2114,7 @@ class EnhancedAIService private constructor(private val context: Context) { allToolResults, context, functionType, promptFunctionType, collector, enableThinking, enableMemoryAutoUpdate, onNonFatalError, onTokenLimitExceeded, maxTokens, tokenUsageThreshold, isSubTask, characterName, avatarUri, roleCardId, chatId, onToolInvocation, notifyReplyOverride, - chatModelConfigIdOverride, chatModelIndexOverride, preferenceProfileIdOverride, stream, enableGroupOrchestrationHint, + chatModelConfigIdOverride, chatModelIndexOverride, memorySpaceIdOverride, stream, enableGroupOrchestrationHint, disableWarning = disableWarning ) } else if (!toolResultOverrideMessage.isNullOrEmpty()) { @@ -2140,7 +2140,7 @@ class EnhancedAIService private constructor(private val context: Context) { notifyReplyOverride = notifyReplyOverride, chatModelConfigIdOverride = chatModelConfigIdOverride, chatModelIndexOverride = chatModelIndexOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride, + memorySpaceIdOverride = memorySpaceIdOverride, stream = stream, enableGroupOrchestrationHint = enableGroupOrchestrationHint, toolResultMessageOverride = toolResultOverrideMessage, @@ -2188,7 +2188,7 @@ class EnhancedAIService private constructor(private val context: Context) { notifyReplyOverride: Boolean? = null, chatModelConfigIdOverride: String? = null, chatModelIndexOverride: Int? = null, - preferenceProfileIdOverride: String? = null, + memorySpaceIdOverride: String? = null, stream: Boolean = true, enableGroupOrchestrationHint: Boolean = false, toolResultMessageOverride: String? = null, @@ -2470,7 +2470,7 @@ class EnhancedAIService private constructor(private val context: Context) { notifyReplyOverride, chatModelConfigIdOverride, chatModelIndexOverride, - preferenceProfileIdOverride, + memorySpaceIdOverride, stream, enableGroupOrchestrationHint, disableWarning @@ -2649,7 +2649,7 @@ class EnhancedAIService private constructor(private val context: Context) { isSubTask: Boolean = false, functionType: FunctionType = FunctionType.CHAT, modelConfig: ModelConfigData, - preferenceProfileIdOverride: String? = null, + memorySpaceIdOverride: String? = null, dispatchHistoryHooks: (PromptHookContext) -> PromptHookContext = PromptHookRegistry::dispatchPromptHistoryHooks, dispatchSystemPromptComposeHooks: (PromptHookContext) -> PromptHookContext = @@ -2692,7 +2692,7 @@ class EnhancedAIService private constructor(private val context: Context) { useToolCallApi, chatModelHasDirectImage, toolExposureMode, - preferenceProfileIdOverride, + memorySpaceIdOverride, dispatchHistoryHooks, dispatchSystemPromptComposeHooks, dispatchToolPromptComposeHooks @@ -3145,7 +3145,7 @@ class EnhancedAIService private constructor(private val context: Context) { fun saveConversationToMemoryAsync( conversationHistory: List>, lastContent: String, - preferenceProfileIdOverride: String? = null, + memorySpaceIdOverride: String? = null, onSuccess: (suspend () -> Unit)? = null, onError: (suspend (Exception) -> Unit)? = null ) { @@ -3159,7 +3159,7 @@ class EnhancedAIService private constructor(private val context: Context) { conversationHistory = conversationHistory, content = lastContent, aiService = memoryService, - profileIdOverride = preferenceProfileIdOverride, + profileIdOverride = memorySpaceIdOverride, onSuccess = { AppLogger.d(TAG, "手动记忆更新成功") onSuccess?.invoke() diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/enhance/ConversationService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/enhance/ConversationService.kt index babeebaee..f23753554 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/enhance/ConversationService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/enhance/ConversationService.kt @@ -18,7 +18,6 @@ import com.ai.assistance.operit.core.tools.packTool.PackageManager import com.ai.assistance.operit.data.model.AITool import com.ai.assistance.operit.data.model.FunctionType import com.ai.assistance.operit.data.model.ModelParameter -import com.ai.assistance.operit.data.model.PreferenceProfile import com.ai.assistance.operit.data.model.ToolParameter import com.ai.assistance.operit.core.tools.UIPageResultData import com.ai.assistance.operit.core.tools.SimplifiedUINode @@ -30,7 +29,7 @@ import com.ai.assistance.operit.data.preferences.CharacterCardManager import com.ai.assistance.operit.data.preferences.ActivePromptManager import com.ai.assistance.operit.data.preferences.CharacterCardToolAccessResolver import com.ai.assistance.operit.data.model.PromptFunctionType -import com.ai.assistance.operit.data.preferences.preferencesManager +import com.ai.assistance.operit.data.preferences.UserProfileDocumentRepository import com.ai.assistance.operit.core.avatar.impl.factory.AvatarModelFactoryImpl import com.ai.assistance.operit.data.repository.AvatarRepository import com.ai.assistance.operit.util.ChatMarkupRegex @@ -39,7 +38,6 @@ import com.ai.assistance.operit.core.tools.ToolProgressBus import com.ai.assistance.operit.util.streamnative.NativeXmlSplitter import com.github.difflib.DiffUtils import com.github.difflib.UnifiedDiffUtils -import java.util.Calendar import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow @@ -77,7 +75,7 @@ class ConversationService( private val characterCardManager = CharacterCardManager.getInstance(context) private val characterCardToolAccessResolver = CharacterCardToolAccessResolver.getInstance(context) private val activePromptManager = ActivePromptManager.getInstance(context) - private val userPreferencesManager = preferencesManager + private val userProfileDocumentRepository = UserProfileDocumentRepository.getInstance(context) private val avatarRepository by lazy { AvatarRepository.getInstance(context, AvatarModelFactoryImpl()) } @@ -480,7 +478,7 @@ class ConversationService( useToolCallApi: Boolean = false, chatModelHasDirectImage: Boolean = false, toolExposureMode: ToolExposureMode = ToolExposureMode.FULL, - preferenceProfileIdOverride: String? = null, + memorySpaceIdOverride: String? = null, dispatchHistoryHooks: (PromptHookContext) -> PromptHookContext = PromptHookRegistry::dispatchPromptHistoryHooks, dispatchSystemPromptComposeHooks: (PromptHookContext) -> PromptHookContext = PromptHookRegistry::dispatchSystemPromptComposeHooks, dispatchToolPromptComposeHooks: (PromptHookContext) -> PromptHookContext = PromptHookRegistry::dispatchToolPromptComposeHooks @@ -519,25 +517,20 @@ class ConversationService( conversationMutex.withLock { // Add system prompt if not already present if (!effectiveChatHistory.any { it.kind == PromptTurnKind.SYSTEM }) { - val safeProxySenderName = proxySenderName?.takeIf { it.isNotBlank() } - - val preferencesText = if (safeProxySenderName == null) { - val preferenceProfile = - userPreferencesManager.getUserPreferencesFlow( - preferenceProfileIdOverride?.takeIf { it.isNotBlank() }.orEmpty() - ).first() - buildPreferencesText(preferenceProfile) - } else { - val proxyCard = characterCardManager.findCharacterCardByName(safeProxySenderName) - if (proxyCard == null) { - "" - } else { - characterCardManager.combinePrompts( - proxyCard.id, - promptFunctionType = promptFunctionType - ) - } - } + // user.md describes the one human user. Role cards and group proxy senders describe + // assistants, so they must never replace or select a different user document. + val userProfileMarkdown = userProfileDocumentRepository.load().trim() + val proxyRolePrompt = + proxySenderName + ?.takeIf { it.isNotBlank() } + ?.let { name -> characterCardManager.findCharacterCardByName(name) } + ?.let { proxyCard -> + characterCardManager.combinePrompts( + proxyCard.id, + promptFunctionType = promptFunctionType + ) + } + .orEmpty() // 根据功能类型获取对应的提示词 val effectiveRoleCardId = roleCardId?.takeIf { it.isNotBlank() } @@ -622,10 +615,16 @@ class ConversationService( val finalSystemPrompt = buildString { append(avatarMoodRulesText) append(systemPrompt) + if (proxyRolePrompt.isNotEmpty()) { + append("\n\n\n") + append(proxyRolePrompt) + append("\n") + } append(waifuRulesText) - if (!disableUserPreferenceDescription && preferencesText.isNotEmpty()) { - append("\n\nUser preference description: ") - append(preferencesText) + if (!disableUserPreferenceDescription && userProfileMarkdown.isNotEmpty()) { + append("\n\n\n") + append(userProfileMarkdown) + append("\n") } } @@ -845,53 +844,6 @@ class ConversationService( conversationHistory.addAll(mergedSegments) } - /** Build a formatted preferences text string from a PreferenceProfile */ - fun buildPreferencesText(profile: PreferenceProfile): String { - val parts = mutableListOf() - - if (profile.gender.isNotEmpty()) { - parts.add("Gender: ${profile.gender}") - } - - if (profile.birthDate > 0) { - // Convert timestamp to age and format as text - val today = Calendar.getInstance() - val birthCal = Calendar.getInstance().apply { timeInMillis = profile.birthDate } - var age = today.get(Calendar.YEAR) - birthCal.get(Calendar.YEAR) - // Adjust age if birthday hasn't occurred yet this year - if (today.get(Calendar.MONTH) < birthCal.get(Calendar.MONTH) || - (today.get(Calendar.MONTH) == birthCal.get(Calendar.MONTH) && - today.get(Calendar.DAY_OF_MONTH) < - birthCal.get(Calendar.DAY_OF_MONTH)) - ) { - age-- - } - parts.add("Age: $age") - - // Also add birth date for more precise information - val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()) - parts.add("Birth Date: ${dateFormat.format(java.util.Date(profile.birthDate))}") - } - - if (profile.personality.isNotEmpty()) { - parts.add("Personality: ${profile.personality}") - } - - if (profile.identity.isNotEmpty()) { - parts.add("Identity: ${profile.identity}") - } - - if (profile.occupation.isNotEmpty()) { - parts.add("Occupation: ${profile.occupation}") - } - - if (profile.aiStyle.isNotEmpty()) { - parts.add("Expected AI Style: ${profile.aiStyle}") - } - - return parts.joinToString("; ") - } - /** Data class for search-replace operations, used for JSON deserialization. */ private data class SearchReplaceOperation(val search: String, val replace: String) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryAutoSaveScheduler.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryAutoSaveScheduler.kt index 31bf91e03..cd4840669 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryAutoSaveScheduler.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryAutoSaveScheduler.kt @@ -80,7 +80,7 @@ class MemoryAutoSaveScheduler( } private suspend fun scanAndProcessCandidates() { - val profileIds = preferencesManager.profileListFlow.first() + val profileIds = preferencesManager.memorySpaceListFlow.first() if (profileIds.isEmpty()) return val toolHandler = AIToolHandler.getInstance(context) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt index 88e894388..fa332981c 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt @@ -49,8 +49,7 @@ object MemoryLibrary { val extractedEntities: List = emptyList(), val links: List = emptyList(), val updatedEntities: List = emptyList(), - val mergedEntities: List = emptyList(), - val userPreferences: String = "" + val mergedEntities: List = emptyList() ) @@ -141,7 +140,7 @@ object MemoryLibrary { */ private suspend fun autoCategorizeMemories(context: Context, aiService: AIService) { mutex.withLock { - val profileId = preferencesManager.activeProfileIdFlow.first() + val profileId = preferencesManager.activeMemorySpaceIdFlow.first() val memoryRepository = MemoryRepository(context, profileId) // 使用 searchMemories("") 获取所有记忆,然后过滤未分类的 @@ -275,7 +274,7 @@ object MemoryLibrary { profileIdOverride: String? = null ) { mutex.withLock { - val profileId = profileIdOverride ?: preferencesManager.activeProfileIdFlow.first() + val profileId = profileIdOverride ?: preferencesManager.activeMemorySpaceIdFlow.first() val memoryRepository = MemoryRepository(context, profileId) // Prune tool results to reduce token usage @@ -370,22 +369,6 @@ object MemoryLibrary { } } - // Update user preferences (this logic remains) - if (analysis.userPreferences.isNotEmpty()) { - try { - withContext(Dispatchers.IO) { - updateUserPreferencesFromAnalysis( - context = context, - preferencesText = analysis.userPreferences, - profileId = profileId - ) - AppLogger.d(TAG, "用户偏好已更新") - } - } catch (e: Exception) { - AppLogger.e(TAG, "更新用户偏好失败", e) - } - } - // Save the graph structure to the MemoryRepository if (analysis.mainProblem == null) { AppLogger.w(TAG, "分析结果中缺少main_problem,跳过保存记忆图谱") @@ -508,14 +491,6 @@ object MemoryLibrary { ): ParsedAnalysis { try { val useEnglish = LocaleUtils.getCurrentLanguage(context).lowercase().startsWith("en") - val currentPreferences = withContext(Dispatchers.IO) { - var preferences = "" - preferencesManager.getUserPreferencesFlow(profileId).take(1).collect { profile -> - preferences = buildPreferencesText(context, profile) - } - preferences - } - // --- Hybrid Strategy: Local rough search + LLM final decision --- // 1. Use a compact search query (question-focused) for rough candidate selection. val contextQuery = buildCandidateSearchQuery(query, solution) @@ -577,7 +552,6 @@ object MemoryLibrary { duplicatesPromptPart = duplicatesPromptPart, existingMemoriesPrompt = existingMemoriesPrompt, existingFoldersPrompt = existingFoldersPrompt, - currentPreferences = currentPreferences, useEnglish = useEnglish ) @@ -833,17 +807,12 @@ object MemoryLibrary { } } ?: emptyList() - val userPreferences = json.optJSONObject("user")?.let { - parseUserPreferences(context, it) - } ?: "" - ParsedAnalysis( mainProblem = mainProblem, extractedEntities = extractedEntities, links = links, updatedEntities = updatedEntities, - mergedEntities = mergedEntities, - userPreferences = userPreferences + mergedEntities = mergedEntities ) } catch (e: Exception) { AppLogger.e(TAG, "解析分析结果失败: $jsonString", e) @@ -851,99 +820,6 @@ object MemoryLibrary { } } - private fun parseUserPreferences(context: Context, preferencesObj: JSONObject): String { - val preferenceParts = mutableListOf() - // Helper to add preference if it exists and is not "" - fun addPref(key: String, prefix: String) { - if (preferencesObj.has(key) && preferencesObj.get(key) != "") { - val value = preferencesObj.get(key).toString() - if (value.isNotEmpty()) preferenceParts.add("$prefix: $value") - } - } - addPref("age", context.getString(R.string.profile_birth_year)) - addPref("gender", context.getString(R.string.profile_gender)) - addPref("personality", context.getString(R.string.profile_personality)) - addPref("identity", context.getString(R.string.profile_identity)) - addPref("occupation", context.getString(R.string.profile_occupation)) - addPref("aiStyle", context.getString(R.string.profile_ai_style)) - return preferenceParts.joinToString("; ") - } - - - private fun buildPreferencesText(context: Context, profile: com.ai.assistance.operit.data.model.PreferenceProfile): String { - val parts = mutableListOf() - if (profile.gender.isNotEmpty()) parts.add(context.getString(R.string.profile_gender_value, profile.gender)) - if (profile.birthDate > 0) { - val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()) - parts.add(context.getString(R.string.profile_birth_date, dateFormat.format(java.util.Date(profile.birthDate)))) - val today = java.util.Calendar.getInstance() - val birthCal = java.util.Calendar.getInstance().apply { timeInMillis = profile.birthDate } - var age = today.get(java.util.Calendar.YEAR) - birthCal.get(java.util.Calendar.YEAR) - if (today.get(java.util.Calendar.DAY_OF_YEAR) < birthCal.get(java.util.Calendar.DAY_OF_YEAR)) { - age-- - } - parts.add(context.getString(R.string.profile_age, age)) - } - if (profile.personality.isNotEmpty()) parts.add(context.getString(R.string.profile_personality_value, profile.personality)) - if (profile.identity.isNotEmpty()) parts.add(context.getString(R.string.profile_identity_value, profile.identity)) - if (profile.occupation.isNotEmpty()) parts.add(context.getString(R.string.profile_occupation_value, profile.occupation)) - if (profile.aiStyle.isNotEmpty()) parts.add(context.getString(R.string.profile_ai_style_value, profile.aiStyle)) - return parts.joinToString("; ") - } - - private suspend fun updateUserPreferencesFromAnalysis( - context: Context, - preferencesText: String, - profileId: String - ) { - if (preferencesText.isEmpty()) return - - fun extractValue(match: MatchResult?): String? { - if (match == null) return null - return if (match.groupValues.size > 1) match.groupValues.last().trim() else null - } - - val birthDateMatch = "(出生日期|出生年月日|Birth Date|Date of Birth)[::\\s]+([\\d-]+)".toRegex().find(preferencesText) - val birthYearMatch = "(出生年份|年龄|Birth year|Age)[::\\s]+(\\d+)".toRegex().find(preferencesText) - val genderMatch = "(性别|Gender)[::\\s]+([^;]+)".toRegex().find(preferencesText) - val personalityMatch = "(性格(特点)?|Personality( traits)?)[::\\s]+([^;]+)".toRegex().find(preferencesText) - val identityMatch = "(身份(认同)?|Identity( recognition)?)[::\\s]+([^;]+)".toRegex().find(preferencesText) - val occupationMatch = "(职业|Occupation)[::\\s]+([^;]+)".toRegex().find(preferencesText) - val aiStyleMatch = "(AI风格|期待的AI风格|偏好的AI风格|AI Style|Expected AI Style|Preferred AI Style)[::\\s]+([^;]+)".toRegex().find(preferencesText) - - var birthDateTimestamp: Long? = null - if (birthDateMatch != null) { - try { - val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()) - val date = extractValue(birthDateMatch)?.let { dateFormat.parse(it) } - if (date != null) birthDateTimestamp = date.time - } catch (e: Exception) { - AppLogger.e(TAG, "解析出生日期失败: ${e.message}") - } - } else if (birthYearMatch != null) { - try { - val year = extractValue(birthYearMatch)?.toInt() - if (year == null) return - val calendar = java.util.Calendar.getInstance() - calendar.set(year, java.util.Calendar.JANUARY, 1, 0, 0, 0) - calendar.set(java.util.Calendar.MILLISECOND, 0) - birthDateTimestamp = calendar.timeInMillis - } catch (e: Exception) { - AppLogger.e(TAG, "解析出生年份失败: ${e.message}") - } - } - - preferencesManager.updateProfileCategory( - profileId = profileId, - birthDate = birthDateTimestamp, - gender = extractValue(genderMatch), - personality = extractValue(personalityMatch), - identity = extractValue(identityMatch), - occupation = extractValue(occupationMatch), - aiStyle = extractValue(aiStyleMatch) - ) - } - /** * Replaces the content of tags with a placeholder to reduce token count. */ diff --git a/app/src/main/java/com/ai/assistance/operit/core/chat/AIMessageManager.kt b/app/src/main/java/com/ai/assistance/operit/core/chat/AIMessageManager.kt index 94acdb46e..2359a4615 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/chat/AIMessageManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/chat/AIMessageManager.kt @@ -342,7 +342,7 @@ object AIMessageManager { notifyReplyOverride: Boolean? = null, chatModelConfigIdOverride: String? = null, chatModelIndexOverride: Int? = null, - preferenceProfileIdOverride: String? = null, + memorySpaceIdOverride: String? = null, disableWarning: Boolean = false ): SharedStream { val totalStartTime = messageTimingNow() @@ -477,7 +477,7 @@ object AIMessageManager { notifyReplyOverride = notifyReplyOverride, chatModelConfigIdOverride = chatModelConfigIdOverride, chatModelIndexOverride = chatModelIndexOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride, + memorySpaceIdOverride = memorySpaceIdOverride, stream = enableStream, disableWarning = disableWarning ) @@ -519,7 +519,7 @@ object AIMessageManager { proxySenderName: String? = null, chatModelConfigIdOverride: String? = null, chatModelIndexOverride: Int? = null, - preferenceProfileIdOverride: String? = null, + memorySpaceIdOverride: String? = null, publishEstimate: Boolean = true ): Int { val memory = @@ -549,7 +549,7 @@ object AIMessageManager { proxySenderName = proxySenderName, chatModelConfigIdOverride = chatModelConfigIdOverride, chatModelIndexOverride = chatModelIndexOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride, + memorySpaceIdOverride = memorySpaceIdOverride, publishEstimate = publishEstimate ) return windowSize diff --git a/app/src/main/java/com/ai/assistance/operit/core/config/FunctionalPrompts.kt b/app/src/main/java/com/ai/assistance/operit/core/config/FunctionalPrompts.kt index 295f2aaa8..5bba6c8c4 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/config/FunctionalPrompts.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/config/FunctionalPrompts.kt @@ -896,7 +896,6 @@ $toolList duplicatesPromptPart: String, existingMemoriesPrompt: String, existingFoldersPrompt: String, - currentPreferences: String, useEnglish: Boolean ): String { return if (useEnglish) { @@ -967,18 +966,15 @@ $existingFoldersPrompt - Current turn confirms relation between an existing memory and a new/existing event: add a link even if `new` is empty. [Output schema - strict JSON only] -- Keys: `main`, `new`, `update`, `merge`, `links`, `user`. +- Keys: `main`, `new`, `update`, `merge`, `links`. - `main`: `["Title", "Content", ["tags"], "folder_path"]` or `null`. - `new`: `[["Title", "Content", ["tags"], "folder_path", "alias_for_or_null"], ...]`. - `update`: `[["Title", "New full content", "Reason", credibility_or_null, importance_or_null], ...]`. - `merge`: `[{"source_titles":["A","B"],"new_title":"...","new_content":"...","new_tags":["..."],"folder_path":"...","reason":"..."}, ...]`. - `links`: `[["Source", "Target", "RELATION_TYPE", "Description", weight], ...]` (type must be UPPER_SNAKE_CASE). - Numeric ranges: `credibility_or_null`, `importance_or_null`, and link `weight` must be JSON numbers between 0.0 and 1.0 inclusive, or JSON `null` where the schema allows null. -- `user`: structured object; unknown fields should be `""`. - Use JSON `null` for missing optional values. -Existing user preferences: $currentPreferences - Return only a valid JSON object. No extra text. """.trimIndent() } else { @@ -1049,18 +1045,15 @@ $existingFoldersPrompt - 本轮确认了"已有样本记忆"和其他记忆的明确关系:即使没有 `new`,也应在 `links` 中体现。 【输出格式(严格JSON)】 -- 顶层键:`main`、`new`、`update`、`merge`、`links`、`user`。 +- 顶层键:`main`、`new`、`update`、`merge`、`links`。 - `main`: `["标题","内容",["标签"],"folder_path"]` 或 `null`。 - `new`: `[["标题","内容",["标签"],"folder_path","alias_for_or_null"], ...]`。 - `update`: `[["标题","新完整内容","原因",可信度或null,重要性或null], ...]`。 - `merge`: `[{"source_titles":["A","B"],"new_title":"...","new_content":"...","new_tags":["..."],"folder_path":"...","reason":"..."}, ...]`。 - `links`: `[["源","目标","RELATION_TYPE","描述",权重], ...]`,关系类型用大写下划线。 - 数值范围:可信度、重要性、链接权重必须是 0.0 到 1.0(含边界)的 JSON 数字;允许缺失的位置使用 JSON `null`。 -- `user`: 结构化对象,未变化字段填 `""`。 - 可选值缺失时使用 JSON `null`。 -现有用户偏好:$currentPreferences - 只返回合法 JSON 对象,不要输出其他内容。 """.trimIndent() } diff --git a/app/src/main/java/com/ai/assistance/operit/core/config/SystemToolPromptsInternal.kt b/app/src/main/java/com/ai/assistance/operit/core/config/SystemToolPromptsInternal.kt index a2874621f..23cf8d86c 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/config/SystemToolPromptsInternal.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/config/SystemToolPromptsInternal.kt @@ -692,15 +692,10 @@ object SystemToolPromptsInternal { ) ), ToolPrompt( - name = "update_user_preferences", - description = "Updates user preference information directly. Use this when you learn new information about the user that should be remembered (e.g., their birthday, gender, personality traits, identity, occupation, or preferred AI interaction style). This allows immediate updates without waiting for the automatic system.", + name = "update_user_profile", + description = "Replaces the private user.md profile after the user approves the tool call. Preserve useful existing content and write the complete Markdown document.", parametersStructured = listOf( - ToolParameterSchema(name = "birth_date", type = "integer", description = "optional, Unix timestamp in milliseconds", required = false), - ToolParameterSchema(name = "gender", type = "string", description = "optional, string", required = false), - ToolParameterSchema(name = "personality", type = "string", description = "optional, string describing personality traits", required = false), - ToolParameterSchema(name = "identity", type = "string", description = "optional, string describing identity/role", required = false), - ToolParameterSchema(name = "occupation", type = "string", description = "optional, string", required = false), - ToolParameterSchema(name = "ai_style", type = "string", description = "optional, string describing preferred AI interaction style. At least one parameter must be provided", required = false) + ToolParameterSchema(name = "markdown", type = "string", description = "required, complete contents of user.md, maximum 12000 characters", required = true) ) ) ) @@ -3689,15 +3684,10 @@ object SystemToolPromptsInternal { ) ), ToolPrompt( - name = "update_user_preferences", - description = "直接更新用户偏好信息。当你了解到用户的新信息时使用(例如生日、性别、性格特征、身份、职业或首选AI交互风格)。这允许立即更新而无需等待自动系统。", + name = "update_user_profile", + description = "在用户批准工具调用后替换私有 user.md。保留仍然有用的现有内容,并写入完整的 Markdown 文档。", parametersStructured = listOf( - ToolParameterSchema(name = "birth_date", type = "integer", description = "可选, Unix时间戳,毫秒", required = false), - ToolParameterSchema(name = "gender", type = "string", description = "可选, 字符串", required = false), - ToolParameterSchema(name = "personality", type = "string", description = "可选, 描述性格特征的字符串", required = false), - ToolParameterSchema(name = "identity", type = "string", description = "可选, 描述身份/角色的字符串", required = false), - ToolParameterSchema(name = "occupation", type = "string", description = "可选, 字符串", required = false), - ToolParameterSchema(name = "ai_style", type = "string", description = "可选, 描述首选AI交互风格的字符串. 必须提供至少一个参数", required = false) + ToolParameterSchema(name = "markdown", type = "string", description = "必需,user.md 的完整内容,最多 12000 个字符", required = true) ) ) ) diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/ToolRegistration.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/ToolRegistration.kt index 8092b36ee..105a212ad 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/tools/ToolRegistration.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/ToolRegistration.kt @@ -725,29 +725,14 @@ fun registerAllTools(handler: AIToolHandler, context: Context) { } ) - // 注册用户偏好更新工具 + // Register the document-level profile update. The normal tool confirmation UI is the safety + // boundary; unlike the released analyzer, no background process may rewrite user.md. handler.registerTool( - name = "update_user_preferences", + name = "update_user_profile", descriptionGenerator = { tool -> - val params = mutableListOf() - tool.parameters.forEach { param -> - val label = - when (param.name) { - "birth_date" -> s(R.string.toolreg_user_pref_birth_date) - "gender" -> s(R.string.toolreg_user_pref_gender) - "personality" -> s(R.string.toolreg_user_pref_personality) - "identity" -> s(R.string.toolreg_user_pref_identity) - "occupation" -> s(R.string.toolreg_user_pref_occupation) - "ai_style" -> s(R.string.toolreg_user_pref_ai_style) - else -> null - } - if (label != null) { - params.add(label) - } - } s( - R.string.toolreg_update_user_preferences_desc, - params.joinToString(s(R.string.toolreg_list_separator)) + R.string.toolreg_update_user_profile_desc, + "user.md" ) }, executor = { tool -> @@ -756,6 +741,18 @@ fun registerAllTools(handler: AIToolHandler, context: Context) { } ) + // Compatibility for released packages and persisted calls. It is intentionally absent from + // the current system-tool prompt, so new conversations use only update_user_profile. + handler.registerTool( + name = "update_user_preferences", + descriptionGenerator = { + s(R.string.toolreg_update_user_profile_desc, "user.md") + }, + executor = { tool -> + ToolGetter.getMemoryQueryToolExecutor(context).invoke(tool) + } + ) + // 注册创建记忆工具 handler.registerTool( name = "create_memory", diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/MemoryQueryToolExecutor.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/MemoryQueryToolExecutor.kt index 502fd0572..337337795 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/MemoryQueryToolExecutor.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/MemoryQueryToolExecutor.kt @@ -15,6 +15,7 @@ import com.ai.assistance.operit.data.model.ToolResult import com.ai.assistance.operit.data.model.ToolValidationResult import com.ai.assistance.operit.data.preferences.CharacterCardManager import com.ai.assistance.operit.data.preferences.MemorySearchSettingsPreferences +import com.ai.assistance.operit.data.preferences.UserProfileDocumentRepository import com.ai.assistance.operit.data.repository.MemoryRepository import kotlinx.coroutines.flow.first import java.text.ParsePosition @@ -26,7 +27,7 @@ import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap /** - * Executes queries against the AI's memory graph and manages user preferences. + * Executes queries against the AI's memory graph and the explicit user.md update tool. */ class MemoryQueryToolExecutor(private val context: Context) : ToolExecutor { @@ -50,7 +51,7 @@ class MemoryQueryToolExecutor(private val context: Context) : ToolExecutor { private val settingsRepositories = ConcurrentHashMap() private fun resolveGlobalActiveProfileId(): String { - return kotlinx.coroutines.runBlocking { preferencesManager.activeProfileIdFlow.first() } + return kotlinx.coroutines.runBlocking { preferencesManager.activeMemorySpaceIdFlow.first() } } private fun resolveCallerCardId(tool: AITool): String? { @@ -193,7 +194,8 @@ class MemoryQueryToolExecutor(private val context: Context) : ToolExecutor { "update_memory" -> executeUpdateMemory(tool) "delete_memory" -> executeDeleteMemory(tool) "move_memory" -> executeMoveMemory(tool) - "update_user_preferences" -> executeUpdateUserPreferences(tool) + "update_user_profile" -> executeUpdateUserProfile(tool) + "update_user_preferences" -> executeLegacyUserPreferencesUpdate(tool) "link_memories" -> executeLinkMemories(tool) "query_memory_links" -> executeQueryMemoryLinks(tool) "update_memory_link" -> executeUpdateMemoryLink(tool) @@ -705,52 +707,25 @@ class MemoryQueryToolExecutor(private val context: Context) : ToolExecutor { } } - private suspend fun executeUpdateUserPreferences(tool: AITool): ToolResult { - val profileId = resolveActiveProfileId(tool) - AppLogger.d(TAG, "Executing update user preferences") + private suspend fun executeUpdateUserProfile(tool: AITool): ToolResult { + AppLogger.d(TAG, "Executing user.md update") return try { - // 从参数中提取各项偏好设置 - val birthDate = tool.parameters.find { it.name == "birth_date" }?.value?.toLongOrNull() - val gender = tool.parameters.find { it.name == "gender" }?.value - val personality = tool.parameters.find { it.name == "personality" }?.value - val identity = tool.parameters.find { it.name == "identity" }?.value - val occupation = tool.parameters.find { it.name == "occupation" }?.value - val aiStyle = tool.parameters.find { it.name == "ai_style" }?.value - - // 检查是否至少有一个参数 - if (birthDate == null && gender == null && personality == null && - identity == null && occupation == null && aiStyle == null) { + val markdown = tool.parameters.find { it.name == "markdown" }?.value + if (markdown == null) { return ToolResult( toolName = tool.name, success = false, result = StringResultData(""), - error = "At least one preference parameter must be provided" + error = "markdown parameter is required" ) } - // 更新用户偏好 withContext(Dispatchers.IO) { - preferencesManager.updateProfileCategory( - profileId = profileId, - birthDate = birthDate, - gender = gender, - personality = personality, - identity = identity, - occupation = occupation, - aiStyle = aiStyle - ) + UserProfileDocumentRepository.getInstance(context).save(markdown) } - val updatedFields = mutableListOf() - birthDate?.let { updatedFields.add("birth_date") } - gender?.let { updatedFields.add("gender") } - personality?.let { updatedFields.add("personality") } - identity?.let { updatedFields.add("identity") } - occupation?.let { updatedFields.add("occupation") } - aiStyle?.let { updatedFields.add("ai_style") } - - val message = "Successfully updated user preferences: ${updatedFields.joinToString(", ")}" + val message = "Successfully updated user.md" AppLogger.d(TAG, message) ToolResult( @@ -759,12 +734,68 @@ class MemoryQueryToolExecutor(private val context: Context) : ToolExecutor { result = StringResultData(message) ) } catch (e: Exception) { - AppLogger.e(TAG, "Failed to update user preferences", e) + AppLogger.e(TAG, "Failed to update user.md", e) + ToolResult( + toolName = tool.name, + success = false, + result = StringResultData(""), + error = "Failed to update user.md: ${e.message}" + ) + } + } + + /** + * Compatibility adapter for released tool packages and persisted tool calls. The replacement + * prompt exposes only update_user_profile; this path preserves an old call's information in + * user.md without restoring the structured-profile runtime. + */ + private suspend fun executeLegacyUserPreferencesUpdate(tool: AITool): ToolResult { + val fieldLabels = + mapOf( + "birth_date" to "Birth date (Unix milliseconds)", + "gender" to "Gender", + "personality" to "Personality", + "identity" to "Identity", + "occupation" to "Occupation", + "ai_style" to "Preferred assistant style" + ) + val updates = + tool.parameters.mapNotNull { parameter -> + fieldLabels[parameter.name]?.let { label -> label to parameter.value } + } + if (updates.isEmpty()) { + return ToolResult( + toolName = tool.name, + success = false, + result = StringResultData(""), + error = "At least one preference parameter must be provided" + ) + } + + return try { + val repository = UserProfileDocumentRepository.getInstance(context) + withContext(Dispatchers.IO) { + val current = repository.load().trimEnd() + val importedSection = + buildString { + appendLine("## Imported profile update") + appendLine() + updates.forEach { (label, value) -> appendLine("- $label: $value") } + }.trimEnd() + repository.save("$current\n\n$importedSection\n") + } + ToolResult( + toolName = tool.name, + success = true, + result = StringResultData("Successfully preserved the preference update in user.md") + ) + } catch (error: Exception) { + AppLogger.e(TAG, "Failed to preserve legacy preference update in user.md", error) ToolResult( toolName = tool.name, success = false, result = StringResultData(""), - error = "Failed to update user preferences: ${e.message}" + error = "Failed to update user.md: ${error.message}" ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/LegacyUserProfile.kt b/app/src/main/java/com/ai/assistance/operit/data/model/LegacyUserProfile.kt new file mode 100644 index 000000000..e8ab75ff9 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/LegacyUserProfile.kt @@ -0,0 +1,17 @@ +package com.ai.assistance.operit.data.model + +import kotlinx.serialization.Serializable + +/** Released structured profile payload, retained only so schema-v2 migration can preserve data. */ +@Serializable +data class LegacyUserProfile( + val id: String, + val name: String, + val birthDate: Long = 0L, + val gender: String = "", + val personality: String = "", + val identity: String = "", + val occupation: String = "", + val aiStyle: String = "", + val isInitialized: Boolean = false +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/MemorySpace.kt b/app/src/main/java/com/ai/assistance/operit/data/model/MemorySpace.kt new file mode 100644 index 000000000..fa71221ef --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/MemorySpace.kt @@ -0,0 +1,15 @@ +package com.ai.assistance.operit.data.model + +import kotlinx.serialization.Serializable + +/** + * Metadata for an isolated long-term memory database. + * + * The identifier intentionally remains stable across the user-profile migration because it is + * also the ObjectBox database name and may be referenced by character cards. + */ +@Serializable +data class MemorySpace( + val id: String, + val name: String +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/PreferenceProfile.kt b/app/src/main/java/com/ai/assistance/operit/data/model/PreferenceProfile.kt deleted file mode 100644 index fd34aba84..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/model/PreferenceProfile.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.ai.assistance.operit.data.model - -import kotlinx.serialization.Serializable - -/** - * 用户偏好配置文件 - * 包含多个分类的偏好设置,每个分类可以单独锁定 - */ -@Serializable -data class PreferenceProfile( - val id: String, // 配置文件唯一标识符 - val name: String, // 配置文件名称 - val birthDate: Long = 0L, // 完整出生日期(时间戳) - val gender: String = "", // 性别 - val personality: String = "", // 性格特点 - val identity: String = "", // 身份认同 - val occupation: String = "", // 职业 - val aiStyle: String = "", // 期待的AI风格 - val isInitialized: Boolean = false // 是否已初始化 -) \ No newline at end of file diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/UserPreferencesManager.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/UserPreferencesManager.kt index dc04ac685..92e551726 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/UserPreferencesManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/UserPreferencesManager.kt @@ -9,7 +9,9 @@ import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore -import com.ai.assistance.operit.data.model.PreferenceProfile +import com.ai.assistance.operit.data.model.LegacyUserProfile +import com.ai.assistance.operit.data.model.MemorySpace +import com.ai.assistance.operit.data.model.CharacterCardMemoryProfileBindingMode import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -34,15 +36,19 @@ val preferencesManager: UserPreferencesManager fun initUserPreferencesManager(context: Context, defaultProfileName: String = "Default") { val manager = UserPreferencesManager.getInstance(context) - // 在后台初始化默认配置 + // Migration must finish before the default memory space is created. Otherwise a fresh default + // entry could hide the released profile metadata that still owns existing ObjectBox databases. GlobalScope.launch { - val profiles = manager.profileListFlow.first() - if (profiles.isEmpty() || !profiles.contains("default")) { - manager.createProfile(defaultProfileName, isDefault = true) - } + UserProfileDocumentRepository.getInstance(context).initialize() + manager.ensureDefaultMemorySpace(defaultProfileName) } } +data class LegacyUserProfileSnapshot( + val activeProfileId: String, + val profiles: List +) + class UserPreferencesManager private constructor(private val context: Context) { companion object { @Volatile @@ -60,10 +66,14 @@ class UserPreferencesManager private constructor(private val context: Context) { } } - // 基本偏好相关键 + // Released structured-profile keys. These are read only by schema-v2 migration. private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id") private val PROFILE_LIST = stringPreferencesKey("profile_list") + // Memory spaces replace preference profiles while retaining their stable identifiers. + private val ACTIVE_MEMORY_SPACE_ID = stringPreferencesKey("active_memory_space_id") + private val MEMORY_SPACE_LIST = stringPreferencesKey("memory_space_list") + // 应用语言设置 private val APP_LANGUAGE = stringPreferencesKey("app_language") @@ -370,29 +380,171 @@ class UserPreferencesManager private constructor(private val context: Context) { } } - // 获取当前激活的用户偏好配置文件ID - val activeProfileIdFlow: Flow = - context.userPreferencesDataStore.data.map { preferences -> - preferences[ACTIVE_PROFILE_ID] ?: DEFAULT_PROFILE_ID + val activeMemorySpaceIdFlow: Flow = + context.userPreferencesDataStore.data.map { preferences -> + preferences[ACTIVE_MEMORY_SPACE_ID] ?: DEFAULT_PROFILE_ID + } + + val memorySpaceListFlow: Flow> = + context.userPreferencesDataStore.data.map { preferences -> + preferences[MEMORY_SPACE_LIST] + ?.let { Json.decodeFromString>(it) } + .orEmpty() + } + + fun getMemorySpaceFlow(memorySpaceId: String = ""): Flow { + return context.userPreferencesDataStore.data.map { preferences -> + val targetId = + memorySpaceId.ifBlank { + preferences[ACTIVE_MEMORY_SPACE_ID] ?: DEFAULT_PROFILE_ID + } + val encoded = + requireNotNull(preferences[stringPreferencesKey("memory_space_$targetId")]) { + "Missing memory space metadata: $targetId" + } + Json.decodeFromString(encoded) + } + } + + suspend fun ensureDefaultMemorySpace(defaultName: String) { + val ids = memorySpaceListFlow.first() + val storedDefault = + context.userPreferencesDataStore.data.first()[stringPreferencesKey("memory_space_$DEFAULT_PROFILE_ID")] + if (!ids.contains(DEFAULT_PROFILE_ID) || storedDefault == null) { + createMemorySpace(defaultName, isDefault = true) + } + } + + suspend fun createMemorySpace(name: String, isDefault: Boolean = false): String { + val id = if (isDefault) DEFAULT_PROFILE_ID else "memory_${System.currentTimeMillis()}" + val space = MemorySpace(id, name) + context.userPreferencesDataStore.edit { preferences -> + val ids = decodeIdList(preferences[MEMORY_SPACE_LIST]).toMutableList() + if (!ids.contains(id)) ids.add(id) + preferences[MEMORY_SPACE_LIST] = Json.encodeToString(ids) + preferences[stringPreferencesKey("memory_space_$id")] = Json.encodeToString(space) + if (preferences[ACTIVE_MEMORY_SPACE_ID] == null) { + preferences[ACTIVE_MEMORY_SPACE_ID] = id } + } + return id + } - // 获取配置文件列表 - val profileListFlow: Flow> = - context.userPreferencesDataStore.data.map { preferences -> - val profileListJson = preferences[PROFILE_LIST] ?: "[]" - try { - val profileList = - Json.decodeFromString>(profileListJson).toMutableList() - // 确保默认配置总是在列表中,即使在存储中不存在 - if (!profileList.contains(DEFAULT_PROFILE_ID)) { - profileList.add(0, DEFAULT_PROFILE_ID) + suspend fun setActiveMemorySpace(memorySpaceId: String) { + context.userPreferencesDataStore.edit { preferences -> + val ids = decodeIdList(preferences[MEMORY_SPACE_LIST]) + require(ids.contains(memorySpaceId)) { "Unknown memory space: $memorySpaceId" } + preferences[ACTIVE_MEMORY_SPACE_ID] = memorySpaceId + } + } + + suspend fun updateMemorySpace(space: MemorySpace) { + context.userPreferencesDataStore.edit { preferences -> + preferences[stringPreferencesKey("memory_space_${space.id}")] = Json.encodeToString(space) + } + } + + suspend fun deleteMemorySpace(memorySpaceId: String) { + if (memorySpaceId == DEFAULT_PROFILE_ID) return + val characterCardManager = CharacterCardManager.getInstance(context) + characterCardManager.getAllCharacterCards() + .filter { it.memoryProfileId == memorySpaceId } + .forEach { card -> + characterCardManager.updateCharacterCard( + card.copy( + memoryProfileBindingMode = CharacterCardMemoryProfileBindingMode.FOLLOW_GLOBAL, + memoryProfileId = null + ) + ) + } + context.userPreferencesDataStore.edit { preferences -> + val ids = decodeIdList(preferences[MEMORY_SPACE_LIST]).toMutableList() + ids.remove(memorySpaceId) + preferences[MEMORY_SPACE_LIST] = Json.encodeToString(ids) + preferences.remove(stringPreferencesKey("memory_space_$memorySpaceId")) + if (preferences[ACTIVE_MEMORY_SPACE_ID] == memorySpaceId) { + preferences[ACTIVE_MEMORY_SPACE_ID] = DEFAULT_PROFILE_ID + } + } + ObjectBoxManager.delete(context, memorySpaceId) + } + + suspend fun readLegacyUserProfiles(): LegacyUserProfileSnapshot { + val preferences = context.userPreferencesDataStore.data.first() + if (preferences[PROFILE_LIST] == null && preferences[MEMORY_SPACE_LIST] != null) { + // A process may stop after the DataStore rewrite and before the separate schema marker + // is committed. Reconstructing the snapshot from the new keys makes migration + // idempotent and prevents a retry from collapsing existing spaces to only "default". + val memorySpaceIds = decodeIdList(preferences[MEMORY_SPACE_LIST]).toMutableList() + if (!memorySpaceIds.contains(DEFAULT_PROFILE_ID)) { + memorySpaceIds.add(0, DEFAULT_PROFILE_ID) + } + val spaces = memorySpaceIds.distinct().map { id -> + val encoded = + requireNotNull(preferences[stringPreferencesKey("memory_space_$id")]) { + "Missing migrated memory space metadata: $id" } - profileList - } catch (e: Exception) { - // 如果解析失败,至少返回包含默认配置的列表 - listOf(DEFAULT_PROFILE_ID) - } + val name = Json.decodeFromString(encoded).name + LegacyUserProfile(id = id, name = name) + } + return LegacyUserProfileSnapshot( + activeProfileId = preferences[ACTIVE_MEMORY_SPACE_ID] ?: DEFAULT_PROFILE_ID, + profiles = spaces + ) + } + + val activeId = preferences[ACTIVE_PROFILE_ID] ?: DEFAULT_PROFILE_ID + val ids = decodeIdList(preferences[PROFILE_LIST]).toMutableList() + if (!ids.contains(DEFAULT_PROFILE_ID)) ids.add(0, DEFAULT_PROFILE_ID) + val profiles = ids.distinct().map { id -> + val encoded = preferences[stringPreferencesKey("profile_$id")] + if (encoded == null) { + createDefaultProfile(id) + } else { + Json.decodeFromString(encoded) } + } + return LegacyUserProfileSnapshot(activeId, profiles) + } + + suspend fun migrateLegacyProfilesToMemorySpaces(snapshot: LegacyUserProfileSnapshot) { + context.userPreferencesDataStore.edit { preferences -> + val profiles = + snapshot.profiles.ifEmpty { + listOf(createDefaultProfile(DEFAULT_PROFILE_ID)) + } + val ids = profiles.map { it.id }.distinct().toMutableList() + if (!ids.contains(DEFAULT_PROFILE_ID)) ids.add(0, DEFAULT_PROFILE_ID) + preferences[MEMORY_SPACE_LIST] = Json.encodeToString(ids) + val activeId = snapshot.activeProfileId.takeIf(ids::contains) ?: DEFAULT_PROFILE_ID + preferences[ACTIVE_MEMORY_SPACE_ID] = activeId + profiles.forEach { profile -> + val space = MemorySpace(profile.id, profile.name) + preferences[stringPreferencesKey("memory_space_${profile.id}")] = + Json.encodeToString(space) + preferences.remove(stringPreferencesKey("profile_${profile.id}")) + } + preferences.remove(ACTIVE_PROFILE_ID) + preferences.remove(PROFILE_LIST) + preferences.remove(BIRTH_DATE_LOCKED) + preferences.remove(GENDER_LOCKED) + preferences.remove(PERSONALITY_LOCKED) + preferences.remove(IDENTITY_LOCKED) + preferences.remove(OCCUPATION_LOCKED) + preferences.remove(AI_STYLE_LOCKED) + } + } + + private fun decodeIdList(encoded: String?): List { + return encoded?.let { Json.decodeFromString>(it) }.orEmpty() + } + + private fun createDefaultProfile(profileId: String): LegacyUserProfile { + return LegacyUserProfile( + id = profileId, + name = if (profileId == DEFAULT_PROFILE_ID) "Default" else profileId + ) + } // 主题相关Flow val themeMode: Flow = @@ -1560,236 +1712,6 @@ class UserPreferencesManager private constructor(private val context: Context) { } } - // 获取指定配置文件的用户偏好 - fun getUserPreferencesFlow(profileId: String = ""): Flow { - return context.userPreferencesDataStore.data.map { preferences -> - val targetProfileId = - if (profileId.isEmpty()) { - preferences[ACTIVE_PROFILE_ID] ?: DEFAULT_PROFILE_ID - } else { - profileId - } - - val profileKey = stringPreferencesKey("profile_$targetProfileId") - val profileJson = preferences[profileKey] - - if (profileJson != null) { - try { - Json.decodeFromString(profileJson) - } catch (e: Exception) { - createDefaultProfile(targetProfileId) - } - } else { - createDefaultProfile(targetProfileId) - } - } - } - - // 创建默认的配置文件 - private fun createDefaultProfile(profileId: String): PreferenceProfile { - return PreferenceProfile( - id = profileId, - name = if (profileId == DEFAULT_PROFILE_ID) "Default" else profileId, - birthDate = 0L, - gender = "", - occupation = "", - personality = "", - identity = "", - aiStyle = "", - isInitialized = false - ) - } - - // 获取分类锁定状态 - val categoryLockStatusFlow: Flow> = - context.userPreferencesDataStore.data.map { preferences -> - mapOf( - "birthDate" to (preferences[BIRTH_DATE_LOCKED] ?: false), - "gender" to (preferences[GENDER_LOCKED] ?: false), - "personality" to (preferences[PERSONALITY_LOCKED] ?: false), - "identity" to (preferences[IDENTITY_LOCKED] ?: false), - "occupation" to (preferences[OCCUPATION_LOCKED] ?: false), - "aiStyle" to (preferences[AI_STYLE_LOCKED] ?: false) - ) - } - - // 检查指定分类是否被锁定 - fun isCategoryLocked(category: String): Boolean { - return runBlocking { - val lockStatusMap = categoryLockStatusFlow.first() - lockStatusMap[category] ?: false - } - } - - // 设置分类锁定状态 - suspend fun setCategoryLocked(category: String, locked: Boolean) { - context.userPreferencesDataStore.edit { preferences -> - when (category) { - "birthDate" -> preferences[BIRTH_DATE_LOCKED] = locked - "gender" -> preferences[GENDER_LOCKED] = locked - "personality" -> preferences[PERSONALITY_LOCKED] = locked - "identity" -> preferences[IDENTITY_LOCKED] = locked - "occupation" -> preferences[OCCUPATION_LOCKED] = locked - "aiStyle" -> preferences[AI_STYLE_LOCKED] = locked - } - } - } - - // 同步检查偏好是否已初始化 - fun isPreferencesInitialized(): Boolean { - return runBlocking { - val activeProfile = getUserPreferencesFlow().first() - activeProfile.isInitialized - } - } - - // 创建新的配置文件 - suspend fun createProfile(name: String, isDefault: Boolean = false): String { - val profileId = - if (isDefault) DEFAULT_PROFILE_ID - else "profile_${System.currentTimeMillis()}" - val newProfile = - PreferenceProfile( - id = profileId, - name = name, - birthDate = 0L, - gender = "", - occupation = "", - personality = "", - identity = "", - aiStyle = "", - isInitialized = false - ) - - context.userPreferencesDataStore.edit { preferences -> - // 添加到配置文件列表 - val currentList = - try { - val listJson = preferences[PROFILE_LIST] ?: "[]" - Json.decodeFromString>(listJson).toMutableList() - } catch (e: Exception) { - mutableListOf() - } - - if (!currentList.contains(profileId)) { - currentList.add(profileId) - } - - preferences[PROFILE_LIST] = Json.encodeToString(currentList) - - // 保存配置文件内容 - val profileKey = stringPreferencesKey("profile_$profileId") - preferences[profileKey] = Json.encodeToString(newProfile) - - // 默认锁定出生日期 - preferences[BIRTH_DATE_LOCKED] = true - } - - return profileId - } - - // 设置激活的配置文件 - suspend fun setActiveProfile(profileId: String) { - context.userPreferencesDataStore.edit { preferences -> - preferences[ACTIVE_PROFILE_ID] = profileId - } - } - - // 更新指定配置文件 - suspend fun updateProfile(profile: PreferenceProfile) { - context.userPreferencesDataStore.edit { preferences -> - val profileKey = stringPreferencesKey("profile_${profile.id}") - preferences[profileKey] = Json.encodeToString(profile) - } - } - - // 更新配置文件中的特定分类 - suspend fun updateProfileCategory( - profileId: String = "", - birthDate: Long? = null, - gender: String? = null, - personality: String? = null, - identity: String? = null, - occupation: String? = null, - aiStyle: String? = null - ) { - val targetProfileId = - if (profileId.isEmpty()) { - context.userPreferencesDataStore.data.first()[ACTIVE_PROFILE_ID] - ?: DEFAULT_PROFILE_ID - } else { - profileId - } - - val currentProfile = getUserPreferencesFlow(targetProfileId).first() - - // 检查每个分类的锁定状态,如果锁定则不更新 - val updatedProfile = - currentProfile.copy( - birthDate = - if (birthDate != null && !isCategoryLocked("birthDate")) birthDate - else currentProfile.birthDate, - gender = - if (gender != null && !isCategoryLocked("gender")) gender - else currentProfile.gender, - personality = - if (personality != null && !isCategoryLocked("personality")) - personality - else currentProfile.personality, - identity = - if (identity != null && !isCategoryLocked("identity")) identity - else currentProfile.identity, - occupation = - if (occupation != null && !isCategoryLocked("occupation")) - occupation - else currentProfile.occupation, - aiStyle = - if (aiStyle != null && !isCategoryLocked("aiStyle")) aiStyle - else currentProfile.aiStyle, - isInitialized = true - ) - - updateProfile(updatedProfile) - } - - // 删除配置文件 - suspend fun deleteProfile(profileId: String) { - if (profileId == DEFAULT_PROFILE_ID) { - // 不允许删除默认配置 - return - } - - context.userPreferencesDataStore.edit { preferences -> - // 从列表中删除 - val currentList = - try { - val listJson = preferences[PROFILE_LIST] ?: "[]" - Json.decodeFromString>(listJson).toMutableList() - } catch (e: Exception) { - mutableListOf() - } - - currentList.remove(profileId) - preferences[PROFILE_LIST] = Json.encodeToString(currentList) - - // 删除配置文件内容 - val profileKey = stringPreferencesKey("profile_$profileId") - preferences.remove(profileKey) - - // 如果当前活动的是被删除的配置文件,则切换到默认配置 - if (preferences[ACTIVE_PROFILE_ID] == profileId) { - preferences[ACTIVE_PROFILE_ID] = DEFAULT_PROFILE_ID - } - } - // 删除对应的记忆库数据库 - ObjectBoxManager.delete(context, profileId) - } - - // 重置用户偏好 - suspend fun resetPreferences() { - context.userPreferencesDataStore.edit { preferences -> preferences.clear() } - } - // ========== 角色卡/群组主题绑定功能 ========== private fun getCharacterCardThemePrefix(characterCardId: String): String = diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/UserProfileDocumentRepository.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/UserProfileDocumentRepository.kt new file mode 100644 index 000000000..f5774b4ca --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/UserProfileDocumentRepository.kt @@ -0,0 +1,226 @@ +package com.ai.assistance.operit.data.preferences + +import android.content.Context +import android.util.AtomicFile +import com.ai.assistance.operit.data.model.LegacyUserProfile +import java.io.File +import java.io.FileOutputStream +import java.nio.charset.StandardCharsets +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * The single source of truth for the human user's stable profile. + * + * Keeping this as a normal private Markdown file makes the exact model context inspectable and + * editable. AtomicFile is required because a partially written profile would silently change the + * model's behavior on every subsequent request. + */ +class UserProfileDocumentRepository private constructor(private val context: Context) { + companion object { + const val MAX_CONTENT_CHARS = 12_000 + const val USER_FILE_NAME = "user.md" + const val LEGACY_ARCHIVE_FILE_NAME = "legacy-user-profiles.md" + + const val DEFAULT_TEMPLATE = "" + + private val LEGACY_EMPTY_TEMPLATE = """# About me + + +""" + + private const val MIGRATION_PREFERENCES = "user_profile_document" + private const val SCHEMA_VERSION_KEY = "schema_version" + private const val CURRENT_SCHEMA_VERSION = 2 + + @Volatile + private var INSTANCE: UserProfileDocumentRepository? = null + + fun getInstance(context: Context): UserProfileDocumentRepository { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: UserProfileDocumentRepository(context.applicationContext ?: context).also { + INSTANCE = it + } + } + } + } + + private val userFile = File(context.filesDir, USER_FILE_NAME) + private val archiveFile = File(context.filesDir, LEGACY_ARCHIVE_FILE_NAME) + private val migrationPreferences = + context.getSharedPreferences(MIGRATION_PREFERENCES, Context.MODE_PRIVATE) + private val initializationMutex = Mutex() + private val writeMutex = Mutex() + private val contentState = MutableStateFlow(null) + + val contentFlow: Flow = flow { + initialize() + emitAll(contentState.filterNotNull()) + } + + suspend fun initialize() { + initializationMutex.withLock { + if (contentState.value != null) return + + val schemaVersion = migrationPreferences.getInt(SCHEMA_VERSION_KEY, 0) + if (schemaVersion < CURRENT_SCHEMA_VERSION) { + migrateReleasedStructuredProfiles() + } + + // The first user.md implementation stored its editor hint inside the document, which + // made an otherwise empty profile part of every system prompt. Remove only that exact + // untouched template; real user-authored Markdown is never rewritten here. + if ( + userFile.exists() && + userFile.readText(StandardCharsets.UTF_8) == LEGACY_EMPTY_TEMPLATE + ) { + writeAtomically(userFile, DEFAULT_TEMPLATE) + } + + if (!userFile.exists()) { + writeAtomically(userFile, DEFAULT_TEMPLATE) + } + contentState.value = userFile.readText(StandardCharsets.UTF_8) + } + } + + suspend fun load(): String { + initialize() + return requireNotNull(contentState.value) + } + + suspend fun save(markdown: String) { + require(markdown.length <= MAX_CONTENT_CHARS) { + "user.md exceeds the $MAX_CONTENT_CHARS character limit" + } + initialize() + writeMutex.withLock { + writeAtomically(userFile, markdown) + contentState.value = markdown + } + } + + suspend fun resetToTemplate() { + save(DEFAULT_TEMPLATE) + } + + fun hasLegacyArchive(): Boolean = archiveFile.isFile && archiveFile.length() > 0L + + fun readLegacyArchive(): String? { + return archiveFile.takeIf { hasLegacyArchive() }?.readText(StandardCharsets.UTF_8) + } + + private suspend fun migrateReleasedStructuredProfiles() { + val manager = UserPreferencesManager.getInstance(context) + val snapshot = manager.readLegacyUserProfiles() + val activeProfile = snapshot.profiles.firstOrNull { it.id == snapshot.activeProfileId } + val migratedActiveDocument = activeProfile?.toUserMarkdown() ?: DEFAULT_TEMPLATE + val activeDocumentFits = migratedActiveDocument.length <= MAX_CONTENT_CHARS + val userDocumentAlreadyExisted = userFile.exists() + + if (!userDocumentAlreadyExisted) { + writeAtomically( + userFile, + if (activeDocumentFits) migratedActiveDocument else DEFAULT_TEMPLATE + ) + } + + val archivedProfiles = snapshot.profiles.filter { profile -> + profile.hasStructuredUserContent() && + ( + profile.id != snapshot.activeProfileId || + !activeDocumentFits || + userDocumentAlreadyExisted + ) + } + if (archivedProfiles.isNotEmpty() && !archiveFile.exists()) { + writeAtomically(archiveFile, buildLegacyArchive(archivedProfiles)) + } + + manager.migrateLegacyProfilesToMemorySpaces(snapshot) + check( + migrationPreferences.edit() + .putInt(SCHEMA_VERSION_KEY, CURRENT_SCHEMA_VERSION) + .commit() + ) { "Failed to persist user.md migration version" } + } + + private fun LegacyUserProfile.hasStructuredUserContent(): Boolean { + return birthDate > 0L || gender.isNotBlank() || personality.isNotBlank() || + identity.isNotBlank() || occupation.isNotBlank() || aiStyle.isNotBlank() + } + + private fun LegacyUserProfile.toUserMarkdown(): String { + if (!hasStructuredUserContent()) return DEFAULT_TEMPLATE + + return buildString { + appendLine("# About me") + appendLine() + appendProfileSections(this@toUserMarkdown) + }.trimEnd() + "\n" + } + + private fun buildLegacyArchive(profiles: List): String { + return buildString { + appendLine("# Archived user profiles") + appendLine() + appendLine("> These profiles were preserved during the user.md migration and are not injected automatically.") + profiles.forEach { profile -> + appendLine() + appendLine("## ${profile.name}") + appendLine() + appendProfileSections(profile) + } + }.trimEnd() + "\n" + } + + private fun StringBuilder.appendProfileSections(profile: LegacyUserProfile) { + val basicItems = buildList { + if (profile.gender.isNotBlank()) add("Gender: ${profile.gender}") + if (profile.birthDate > 0L) { + val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) + add("Birth date: ${formatter.format(Date(profile.birthDate))}") + } + if (profile.identity.isNotBlank()) add("Identity: ${profile.identity}") + if (profile.occupation.isNotBlank()) add("Occupation: ${profile.occupation}") + } + if (basicItems.isNotEmpty()) { + appendLine("## Basic information") + appendLine() + basicItems.forEach { appendLine("- $it") } + } + if (profile.personality.isNotBlank()) { + if (basicItems.isNotEmpty()) appendLine() + appendLine("## Personality") + appendLine() + appendLine(profile.personality) + } + if (profile.aiStyle.isNotBlank()) { + if (basicItems.isNotEmpty() || profile.personality.isNotBlank()) appendLine() + appendLine("## Preferred assistant style") + appendLine() + appendLine(profile.aiStyle) + } + } + + private fun writeAtomically(target: File, content: String) { + val atomicFile = AtomicFile(target) + var output: FileOutputStream? = null + try { + output = atomicFile.startWrite() + output.write(content.toByteArray(StandardCharsets.UTF_8)) + atomicFile.finishWrite(output) + } catch (error: Throwable) { + output?.let(atomicFile::failWrite) + throw error + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/integrations/http/bridge/WebChatMemorySelectorBridge.kt b/app/src/main/java/com/ai/assistance/operit/integrations/http/bridge/WebChatMemorySelectorBridge.kt index 3ff13f68f..2a5c45456 100644 --- a/app/src/main/java/com/ai/assistance/operit/integrations/http/bridge/WebChatMemorySelectorBridge.kt +++ b/app/src/main/java/com/ai/assistance/operit/integrations/http/bridge/WebChatMemorySelectorBridge.kt @@ -14,11 +14,11 @@ internal class WebChatMemorySelectorBridge( private val userPreferencesManager = UserPreferencesManager.getInstance(appContext) suspend fun resolveState(): WebMemorySelectorState { - val currentProfileId = userPreferencesManager.activeProfileIdFlow.first() - val profileIds = userPreferencesManager.profileListFlow.first() + val currentProfileId = userPreferencesManager.activeMemorySpaceIdFlow.first() + val profileIds = userPreferencesManager.memorySpaceListFlow.first() val profiles = profileIds.map { profileId -> - val profile = userPreferencesManager.getUserPreferencesFlow(profileId).first() + val profile = userPreferencesManager.getMemorySpaceFlow(profileId).first() WebMemoryProfileItem( id = profile.id, name = profile.name @@ -36,12 +36,12 @@ internal class WebChatMemorySelectorBridge( return null } - val profileIds = userPreferencesManager.profileListFlow.first() + val profileIds = userPreferencesManager.memorySpaceListFlow.first() if (!profileIds.contains(normalizedProfileId)) { return null } - userPreferencesManager.setActiveProfile(normalizedProfileId) + userPreferencesManager.setActiveMemorySpace(normalizedProfileId) return waitForSelection(normalizedProfileId) } diff --git a/app/src/main/java/com/ai/assistance/operit/provider/MemoryDocumentsProvider.kt b/app/src/main/java/com/ai/assistance/operit/provider/MemoryDocumentsProvider.kt index 83981cf06..12cf5f4ed 100644 --- a/app/src/main/java/com/ai/assistance/operit/provider/MemoryDocumentsProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/provider/MemoryDocumentsProvider.kt @@ -15,7 +15,7 @@ import android.util.Log import com.ai.assistance.operit.R import com.ai.assistance.operit.data.preferences.UserPreferencesManager import com.ai.assistance.operit.data.model.Memory -import com.ai.assistance.operit.data.model.PreferenceProfile +import com.ai.assistance.operit.data.model.MemorySpace import com.ai.assistance.operit.data.repository.MemoryRepository import com.ai.assistance.operit.util.AppLogger import kotlinx.coroutines.flow.first @@ -260,9 +260,9 @@ class MemoryDocumentsProvider : DocumentsProvider() { when (val parent = parseDocumentId(parentDocumentId)) { is DocRef.Root -> { - val profileIds = runBlocking { prefs.profileListFlow.first() } + val profileIds = runBlocking { prefs.memorySpaceListFlow.first() } profileIds.forEach { profileId -> - val profile = runBlocking { prefs.getUserPreferencesFlow(profileId).first() } + val profile = runBlocking { prefs.getMemorySpaceFlow(profileId).first() } includeProfile(result, profile) } } @@ -510,9 +510,9 @@ class MemoryDocumentsProvider : DocumentsProvider() { is DocRef.Profile -> { requireProfileExists(ref.profileId) - val profile = runBlocking { prefs.getUserPreferencesFlow(ref.profileId).first() } + val profile = runBlocking { prefs.getMemorySpaceFlow(ref.profileId).first() } runBlocking { - prefs.updateProfile(profile.copy(name = cleanName)) + prefs.updateMemorySpace(profile.copy(name = cleanName)) } buildProfileDocumentId(ref.profileId) } @@ -638,7 +638,7 @@ class MemoryDocumentsProvider : DocumentsProvider() { is DocRef.Profile -> { requireProfileExists(ref.profileId) val prefs = UserPreferencesManager.getInstance(context ?: throw IllegalStateException("Context is null")) - val profile = runBlocking { prefs.getUserPreferencesFlow(ref.profileId).first() } + val profile = runBlocking { prefs.getMemorySpaceFlow(ref.profileId).first() } val displayName = getProfileDisplayName(profile) val row = result.newRow() @@ -697,7 +697,7 @@ class MemoryDocumentsProvider : DocumentsProvider() { } } - private fun includeProfile(result: MatrixCursor, profile: PreferenceProfile) { + private fun includeProfile(result: MatrixCursor, profile: MemorySpace) { val displayName = getProfileDisplayName(profile) val row = result.newRow() row.add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, buildProfileDocumentId(profile.id)) @@ -757,9 +757,8 @@ class MemoryDocumentsProvider : DocumentsProvider() { } } - private fun getProfileDisplayName(profile: PreferenceProfile): String { + private fun getProfileDisplayName(profile: MemorySpace): String { return profile.name - .ifBlank { profile.personality } .ifBlank { profile.id } } @@ -931,10 +930,10 @@ class MemoryDocumentsProvider : DocumentsProvider() { return when (parent) { is DocRef.Root -> { val prefs = UserPreferencesManager.getInstance(requireProviderContext()) - val profileIds = runBlocking { prefs.profileListFlow.first() } + val profileIds = runBlocking { prefs.memorySpaceListFlow.first() } val profile = profileIds .asSequence() - .map { profileId -> runBlocking { prefs.getUserPreferencesFlow(profileId).first() } } + .map { profileId -> runBlocking { prefs.getMemorySpaceFlow(profileId).first() } } .firstOrNull { getProfileDisplayName(it) == displayName } ?: throw FileNotFoundException("Synthetic child not found: $originalDocumentId") DocRef.Profile(profile.id) @@ -1049,7 +1048,7 @@ class MemoryDocumentsProvider : DocumentsProvider() { private fun profileExists(profileId: String): Boolean { val prefs = UserPreferencesManager.getInstance(requireProviderContext()) - return runBlocking { prefs.profileListFlow.first() }.contains(profileId) + return runBlocking { prefs.memorySpaceListFlow.first() }.contains(profileId) } private fun requireProfileExists(profileId: String) { diff --git a/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt b/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt index 230bb5eb1..3110bc6f2 100644 --- a/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt +++ b/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt @@ -98,7 +98,7 @@ class MessageCoordinationDelegate( private var currentPromptFunctionType: PromptFunctionType = PromptFunctionType.CHAT private var currentChatModelConfigIdOverride: String? = null private var currentChatModelIndexOverride: Int? = null - private var currentPreferenceProfileIdOverride: String? = null + private var currentMemorySpaceIdOverride: String? = null private var nonFatalErrorCollectorJob: Job? = null private val characterCardManager = CharacterCardManager.getInstance(context) @@ -111,7 +111,7 @@ class MessageCoordinationDelegate( val promptFunctionType: PromptFunctionType, val chatModelConfigIdOverride: String?, val chatModelIndexOverride: Int?, - val preferenceProfileIdOverride: String?, + val memorySpaceIdOverride: String?, val roleCardIdOverride: String?, val isGroupOrchestrationTurn: Boolean, val groupParticipantNamesText: String?, @@ -174,7 +174,7 @@ class MessageCoordinationDelegate( groupParticipantNamesText: String? = null, chatModelConfigIdOverride: String? = null, chatModelIndexOverride: Int? = null, - preferenceProfileIdOverride: String? = null + memorySpaceIdOverride: String? = null ): Int { val currentChat = chatHistoryDelegate.chatHistories.value.firstOrNull { it.id == chatId } val currentRoleName = @@ -196,7 +196,7 @@ class MessageCoordinationDelegate( groupParticipantNamesText = groupParticipantNamesText, chatModelConfigIdOverride = chatModelConfigIdOverride, chatModelIndexOverride = chatModelIndexOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride, + memorySpaceIdOverride = memorySpaceIdOverride, publishEstimate = false ) } @@ -261,7 +261,7 @@ class MessageCoordinationDelegate( groupParticipantNamesText: String? = null, chatModelConfigIdOverride: String? = null, chatModelIndexOverride: Int? = null, - preferenceProfileIdOverride: String? = null + memorySpaceIdOverride: String? = null ): Int? { val targetChatId = chatId ?: chatHistoryDelegate.currentChatId.value ?: return null val service = resolveWindowEstimateService(targetChatId) ?: return null @@ -271,9 +271,9 @@ class MessageCoordinationDelegate( chatModelConfigIdOverride ?: currentChatModelConfigIdOverride val effectiveChatModelIndexOverride = chatModelIndexOverride ?: currentChatModelIndexOverride - val effectivePreferenceProfileIdOverride = - preferenceProfileIdOverride - ?: currentPreferenceProfileIdOverride + val effectiveMemorySpaceIdOverride = + memorySpaceIdOverride + ?: currentMemorySpaceIdOverride ?: effectiveRoleCardId?.let { resolveRoleCardMemoryProfileOverride(it) } val newWindowSize = @@ -286,7 +286,7 @@ class MessageCoordinationDelegate( groupParticipantNamesText = groupParticipantNamesText, chatModelConfigIdOverride = effectiveChatModelConfigIdOverride, chatModelIndexOverride = effectiveChatModelIndexOverride, - preferenceProfileIdOverride = effectivePreferenceProfileIdOverride + memorySpaceIdOverride = effectiveMemorySpaceIdOverride ) val (inputTokens, outputTokens) = tokenStatsDelegate.getCumulativeTokenCounts(targetChatId) chatHistoryDelegate.saveCurrentChat( @@ -423,7 +423,7 @@ class MessageCoordinationDelegate( val (resolvedChatModelConfigIdOverride, resolvedChatModelIndexOverride) = resolveRoleCardChatModelOverrides(roleCardId) - val resolvedPreferenceProfileIdOverride = + val resolvedMemorySpaceIdOverride = resolveRoleCardMemoryProfileOverride(roleCardId) val chatContextSettings = resolveChatContextSettingsForRequest(resolvedChatModelConfigIdOverride) @@ -448,7 +448,7 @@ class MessageCoordinationDelegate( tokenUsageThreshold = chatContextSettings.summaryTokenThreshold.toDouble(), chatModelConfigIdOverride = resolvedChatModelConfigIdOverride, chatModelIndexOverride = resolvedChatModelIndexOverride, - preferenceProfileIdOverride = resolvedPreferenceProfileIdOverride, + memorySpaceIdOverride = resolvedMemorySpaceIdOverride, groupOrchestrationMode = groupOrchestrationMode, groupParticipantNamesText = groupParticipantNamesText, onVariantPreviewStarted = { previewMessage -> @@ -476,7 +476,7 @@ class MessageCoordinationDelegate( groupParticipantNamesText = groupParticipantNamesText, chatModelConfigIdOverride = resolvedChatModelConfigIdOverride, chatModelIndexOverride = resolvedChatModelIndexOverride, - preferenceProfileIdOverride = resolvedPreferenceProfileIdOverride + memorySpaceIdOverride = resolvedMemorySpaceIdOverride ) }.onFailure { AppLogger.w(TAG, "单条重新生成后刷新上下文窗口失败", it) @@ -515,7 +515,7 @@ class MessageCoordinationDelegate( proxySenderNameOverride: String? = null, chatModelConfigIdOverride: String? = null, chatModelIndexOverride: Int? = null, - preferenceProfileIdOverride: String? = null, + memorySpaceIdOverride: String? = null, suppressUserMessageInHistory: Boolean = false, forceDisableSummary: Boolean = false, enableGroupOrchestration: Boolean = true, @@ -612,16 +612,16 @@ class MessageCoordinationDelegate( resolveRoleCardChatModelOverrides(roleCardId) } } - val resolvedPreferenceProfileIdOverride = + val resolvedMemorySpaceIdOverride = when { - !preferenceProfileIdOverride.isNullOrBlank() -> preferenceProfileIdOverride - isAutoContinuation -> currentPreferenceProfileIdOverride + !memorySpaceIdOverride.isNullOrBlank() -> memorySpaceIdOverride + isAutoContinuation -> currentMemorySpaceIdOverride else -> roleCardId?.let { resolveRoleCardMemoryProfileOverride(it) } } Triple( resolvedChatModelConfigIdOverride, resolvedChatModelIndexOverride, - resolvedPreferenceProfileIdOverride + resolvedMemorySpaceIdOverride ) } else { Triple(null, null, null) @@ -635,7 +635,7 @@ class MessageCoordinationDelegate( } val resolvedChatModelConfigIdOverride = resolvedOverrides.first val resolvedChatModelIndexOverride = resolvedOverrides.second - val resolvedPreferenceProfileIdOverride = resolvedOverrides.third + val resolvedMemorySpaceIdOverride = resolvedOverrides.third val chatContextSettings = runBlocking { resolveChatContextSettingsForRequest(resolvedChatModelConfigIdOverride) @@ -644,7 +644,7 @@ class MessageCoordinationDelegate( if (!isAutoContinuation) { currentChatModelConfigIdOverride = resolvedChatModelConfigIdOverride currentChatModelIndexOverride = resolvedChatModelIndexOverride - currentPreferenceProfileIdOverride = resolvedPreferenceProfileIdOverride + currentMemorySpaceIdOverride = resolvedMemorySpaceIdOverride } // 当前请求使用的Token使用率阈值,默认使用配置值 @@ -685,7 +685,7 @@ class MessageCoordinationDelegate( roleCardId = roleCardId, chatModelConfigIdOverride = resolvedChatModelConfigIdOverride, chatModelIndexOverride = resolvedChatModelIndexOverride, - preferenceProfileIdOverride = resolvedPreferenceProfileIdOverride + memorySpaceIdOverride = resolvedMemorySpaceIdOverride ) // 本次请求的Token阈值在原基础上增加 0.5 @@ -721,7 +721,7 @@ class MessageCoordinationDelegate( enableSummary = !forceDisableSummary && !isBackgroundSend && chatContextSettings.enableSummary, chatModelConfigIdOverride = resolvedChatModelConfigIdOverride, chatModelIndexOverride = resolvedChatModelIndexOverride, - preferenceProfileIdOverride = resolvedPreferenceProfileIdOverride, + memorySpaceIdOverride = resolvedMemorySpaceIdOverride, suppressUserMessageInHistory = suppressUserMessageInHistory, isGroupOrchestrationTurn = isGroupOrchestrationTurn, groupParticipantNamesText = groupParticipantNamesText, @@ -1273,7 +1273,7 @@ class MessageCoordinationDelegate( promptFunctionType: PromptFunctionType, chatModelConfigIdOverride: String?, chatModelIndexOverride: Int?, - preferenceProfileIdOverride: String?, + memorySpaceIdOverride: String?, roleCardIdOverride: String? = null, isGroupOrchestrationTurn: Boolean = false, groupParticipantNamesText: String? = null @@ -1286,7 +1286,7 @@ class MessageCoordinationDelegate( promptFunctionType = promptFunctionType, chatModelConfigIdOverride = chatModelConfigIdOverride, chatModelIndexOverride = chatModelIndexOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride, + memorySpaceIdOverride = memorySpaceIdOverride, roleCardIdOverride = roleCardIdOverride, isGroupOrchestrationTurn = isGroupOrchestrationTurn, groupParticipantNamesText = groupParticipantNamesText @@ -1323,7 +1323,7 @@ class MessageCoordinationDelegate( chatIdOverride = chatId, chatModelConfigIdOverride = request.chatModelConfigIdOverride, chatModelIndexOverride = request.chatModelIndexOverride, - preferenceProfileIdOverride = request.preferenceProfileIdOverride, + memorySpaceIdOverride = request.memorySpaceIdOverride, isGroupOrchestrationTurn = request.isGroupOrchestrationTurn, groupParticipantNamesText = request.groupParticipantNamesText ) @@ -1468,7 +1468,7 @@ class MessageCoordinationDelegate( ) val profileId = roleCardId?.let { resolveRoleCardMemoryProfileOverride(it) } - ?: preferencesManager.activeProfileIdFlow.first() + ?: preferencesManager.activeMemorySpaceIdFlow.first() MemoryAutoSaveCandidateRepository(context, profileId) .enqueueSelectedUserMessages( chatId = currentChatId, @@ -1524,13 +1524,13 @@ class MessageCoordinationDelegate( chatId = currentChatId, roleCardId = null ) - val preferenceProfileIdOverride = + val memorySpaceIdOverride = roleCardId?.let { resolveRoleCardMemoryProfileOverride(it) } enhancedAiService.saveConversationToMemoryAsync( conversationHistory = history, lastContent = lastMessageContent, - preferenceProfileIdOverride = preferenceProfileIdOverride, + memorySpaceIdOverride = memorySpaceIdOverride, onSuccess = { uiStateDelegate.showToast(context.getString(R.string.chat_memory_manually_updated)) _isUpdatingMemory.value = false @@ -1730,7 +1730,7 @@ class MessageCoordinationDelegate( roleCardId: String?, chatModelConfigIdOverride: String? = null, chatModelIndexOverride: Int? = null, - preferenceProfileIdOverride: String? = null + memorySpaceIdOverride: String? = null ) { if (snapshotMessages.isEmpty() || originalChatId == null) { return @@ -1785,7 +1785,7 @@ class MessageCoordinationDelegate( roleCardId = roleCardId, chatModelConfigIdOverride = chatModelConfigIdOverride, chatModelIndexOverride = chatModelIndexOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride + memorySpaceIdOverride = memorySpaceIdOverride ) } catch (e: CancellationException) { throw e @@ -1829,7 +1829,7 @@ class MessageCoordinationDelegate( chatIdOverride: String? = null, chatModelConfigIdOverride: String? = null, chatModelIndexOverride: Int? = null, - preferenceProfileIdOverride: String? = null, + memorySpaceIdOverride: String? = null, roleCardIdOverride: String? = null, isGroupChat: Boolean = false, isGroupOrchestrationTurn: Boolean = false, @@ -1853,9 +1853,9 @@ class MessageCoordinationDelegate( chatModelConfigIdOverride ?: currentChatModelConfigIdOverride val effectiveChatModelIndexOverride = chatModelIndexOverride ?: currentChatModelIndexOverride - val effectivePreferenceProfileIdOverride = - preferenceProfileIdOverride - ?: currentPreferenceProfileIdOverride + val effectiveMemorySpaceIdOverride = + memorySpaceIdOverride + ?: currentMemorySpaceIdOverride ?: roleCardIdOverride?.let { resolveRoleCardMemoryProfileOverride(it) } val effectiveIsGroupChat = isGroupChat || isGroupChatSession(currentChatId) @@ -1900,7 +1900,7 @@ class MessageCoordinationDelegate( groupParticipantNamesText = groupParticipantNamesText, chatModelConfigIdOverride = effectiveChatModelConfigIdOverride, chatModelIndexOverride = effectiveChatModelIndexOverride, - preferenceProfileIdOverride = effectivePreferenceProfileIdOverride + memorySpaceIdOverride = effectiveMemorySpaceIdOverride ) summarySuccess = true } else { @@ -1942,7 +1942,7 @@ class MessageCoordinationDelegate( promptFunctionType = continuationPromptType, chatModelConfigIdOverride = effectiveChatModelConfigIdOverride, chatModelIndexOverride = effectiveChatModelIndexOverride, - preferenceProfileIdOverride = effectivePreferenceProfileIdOverride, + memorySpaceIdOverride = effectiveMemorySpaceIdOverride, roleCardIdOverride = roleCardIdOverride, isGroupOrchestrationTurn = isGroupOrchestrationTurn, groupParticipantNamesText = groupParticipantNamesText @@ -1958,7 +1958,7 @@ class MessageCoordinationDelegate( chatIdOverride = currentChatId, chatModelConfigIdOverride = effectiveChatModelConfigIdOverride, chatModelIndexOverride = effectiveChatModelIndexOverride, - preferenceProfileIdOverride = effectivePreferenceProfileIdOverride, + memorySpaceIdOverride = effectiveMemorySpaceIdOverride, isGroupOrchestrationTurn = isGroupOrchestrationTurn, groupParticipantNamesText = groupParticipantNamesText ) diff --git a/app/src/main/java/com/ai/assistance/operit/services/core/MessageProcessingDelegate.kt b/app/src/main/java/com/ai/assistance/operit/services/core/MessageProcessingDelegate.kt index 43bc0fdf0..da095fe56 100644 --- a/app/src/main/java/com/ai/assistance/operit/services/core/MessageProcessingDelegate.kt +++ b/app/src/main/java/com/ai/assistance/operit/services/core/MessageProcessingDelegate.kt @@ -598,7 +598,7 @@ class MessageProcessingDelegate( enableSummary: Boolean = true, chatModelConfigIdOverride: String? = null, chatModelIndexOverride: Int? = null, - preferenceProfileIdOverride: String? = null, + memorySpaceIdOverride: String? = null, suppressUserMessageInHistory: Boolean = false, isGroupOrchestrationTurn: Boolean = false, groupParticipantNamesText: String? = null, @@ -873,7 +873,7 @@ class MessageProcessingDelegate( groupParticipantNamesText = groupParticipantNamesText, chatModelConfigIdOverride = chatModelConfigIdOverride, chatModelIndexOverride = chatModelIndexOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride, + memorySpaceIdOverride = memorySpaceIdOverride, publishEstimate = false ) }.onFailure { @@ -964,7 +964,7 @@ class MessageProcessingDelegate( notifyReplyOverride = turnOptions.notifyReply, chatModelConfigIdOverride = chatModelConfigIdOverride, chatModelIndexOverride = chatModelIndexOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride, + memorySpaceIdOverride = memorySpaceIdOverride, disableWarning = turnOptions.disableWarning ) logMessageTiming( @@ -1454,7 +1454,7 @@ class MessageProcessingDelegate( tokenUsageThreshold: Double, chatModelConfigIdOverride: String?, chatModelIndexOverride: Int?, - preferenceProfileIdOverride: String?, + memorySpaceIdOverride: String?, groupOrchestrationMode: Boolean, groupParticipantNamesText: String?, onVariantPreviewStarted: suspend (ChatMessage) -> Unit, @@ -1552,7 +1552,7 @@ class MessageProcessingDelegate( onToolInvocation = { incrementCurrentTurnToolInvocationCount(chatId) }, chatModelConfigIdOverride = chatModelConfigIdOverride, chatModelIndexOverride = chatModelIndexOverride, - preferenceProfileIdOverride = preferenceProfileIdOverride, + memorySpaceIdOverride = memorySpaceIdOverride, ) val sharedResponseStream = responseStream diff --git a/app/src/main/java/com/ai/assistance/operit/ui/common/NavItem.kt b/app/src/main/java/com/ai/assistance/operit/ui/common/NavItem.kt index eed69c175..88642e1e0 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/common/NavItem.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/common/NavItem.kt @@ -30,12 +30,6 @@ sealed class NavItem(val route: String, val titleResId: Int, val icon: ImageVect object Settings : NavItem("settings", R.string.nav_settings, Icons.Default.Settings) object ToolPermissions : NavItem("tool_permissions", R.string.tool_permissions, Icons.Default.Security) - object UserPreferencesGuide : - NavItem( - "user_preferences_guide", - R.string.user_preferences_guide, - Icons.Default.Person - ) object UserPreferencesSettings : NavItem( "user_preferences_settings", diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/MemoryFolderSelectionDialog.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/MemoryFolderSelectionDialog.kt index 4b8e39b33..5fe80e7f3 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/MemoryFolderSelectionDialog.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/MemoryFolderSelectionDialog.kt @@ -397,7 +397,7 @@ private fun getSubfolders(parentPath: String, allPaths: List): Set = withContext(Dispatchers.IO) { try { - val profileId = preferencesManager.activeProfileIdFlow.first() + val profileId = preferencesManager.activeMemorySpaceIdFlow.first() AppLogger.d("MemoryFolderDialog", "Loading folders for profileId: $profileId") val repository = MemoryRepository(context, profileId) val folders = repository.getAllFolderPaths() diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/style/input/agent/AgentChatInputSection.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/style/input/agent/AgentChatInputSection.kt index 3ec1431c8..38d811d4b 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/style/input/agent/AgentChatInputSection.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/style/input/agent/AgentChatInputSection.kt @@ -117,7 +117,7 @@ import com.ai.assistance.operit.data.model.CharacterCardMemoryProfileBindingMode import com.ai.assistance.operit.data.model.FunctionType import com.ai.assistance.operit.data.model.InputProcessingState import com.ai.assistance.operit.data.model.ModelConfigSummary -import com.ai.assistance.operit.data.model.PreferenceProfile +import com.ai.assistance.operit.data.model.MemorySpace import com.ai.assistance.operit.data.model.getModelByIndex import com.ai.assistance.operit.data.model.getModelList import com.ai.assistance.operit.data.model.getValidModelIndex @@ -360,8 +360,8 @@ fun AgentChatInputSection( val configMappingWithIndex by functionalConfigManager.functionConfigMappingWithIndexFlow.collectAsState(initial = emptyMap()) var configSummaries by remember { mutableStateOf>(emptyList()) } - val activeProfileId by userPreferencesManager.activeProfileIdFlow.collectAsState(initial = "default") - var preferenceProfiles by remember { mutableStateOf>(emptyList()) } + val activeProfileId by userPreferencesManager.activeMemorySpaceIdFlow.collectAsState(initial = "default") + var preferenceProfiles by remember { mutableStateOf>(emptyList()) } val currentConfigMapping = configMappingWithIndex[FunctionType.CHAT] ?: FunctionConfigMapping(FunctionalConfigManager.DEFAULT_CONFIG_ID, 0) @@ -385,9 +385,9 @@ fun AgentChatInputSection( LaunchedEffect(Unit) { configSummaries = modelConfigManager.getAllConfigSummaries() - val profileIds = userPreferencesManager.profileListFlow.first() + val profileIds = userPreferencesManager.memorySpaceListFlow.first() preferenceProfiles = - profileIds.map { profileId -> userPreferencesManager.getUserPreferencesFlow(profileId).first() } + profileIds.map { profileId -> userPreferencesManager.getMemorySpaceFlow(profileId).first() } } LaunchedEffect(showModelSelectorPopup.value) { @@ -584,7 +584,7 @@ fun AgentChatInputSection( } } else { scope.launch { - userPreferencesManager.setActiveProfile(selectedId) + userPreferencesManager.setActiveMemorySpace(selectedId) EnhancedAIService.refreshServiceForFunction(context, FunctionType.CHAT) showExtraSettingsPopup.value = false } @@ -2278,7 +2278,7 @@ private fun AgentModelSelectorItem( private fun AgentExtraSettingsPopup( visible: Boolean, popupContainerColor: Color, - preferenceProfiles: List, + preferenceProfiles: List, currentProfileId: String, onSelectMemory: (String) -> Unit, onManageMemory: () -> Unit, @@ -2526,7 +2526,7 @@ private fun AgentExtraSettingsPopup( @Composable private fun AgentMemorySelectorItem( - preferenceProfiles: List, + preferenceProfiles: List, currentProfileId: String, onSelectMemory: (String) -> Unit, expanded: Boolean, diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/style/input/classic/ClassicChatSettingsBar.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/style/input/classic/ClassicChatSettingsBar.kt index afc53e160..331e06054 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/style/input/classic/ClassicChatSettingsBar.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/style/input/classic/ClassicChatSettingsBar.kt @@ -60,7 +60,7 @@ import com.ai.assistance.operit.data.model.CharacterCardChatModelBindingMode import com.ai.assistance.operit.data.model.CharacterCardMemoryProfileBindingMode import com.ai.assistance.operit.data.model.FunctionType import com.ai.assistance.operit.data.model.ModelConfigSummary -import com.ai.assistance.operit.data.model.PreferenceProfile +import com.ai.assistance.operit.data.model.MemorySpace import com.ai.assistance.operit.data.preferences.CharacterCardManager import com.ai.assistance.operit.data.preferences.ApiPreferences import com.ai.assistance.operit.data.preferences.ActivePromptManager @@ -183,8 +183,8 @@ fun ClassicChatSettingsBar( // 新增:用户偏好(记忆)选择逻辑 val userPreferencesManager = remember { UserPreferencesManager.getInstance(context) } val activeProfileId by - userPreferencesManager.activeProfileIdFlow.collectAsState(initial = "default") - var preferenceProfiles by remember { mutableStateOf>(emptyList()) } + userPreferencesManager.activeMemorySpaceIdFlow.collectAsState(initial = "default") + var preferenceProfiles by remember { mutableStateOf>(emptyList()) } val effectiveCurrentProfileId = if (isMemorySelectionLockedByCharacterCard) { characterCardBoundMemoryProfileId ?: activeProfileId @@ -192,9 +192,9 @@ fun ClassicChatSettingsBar( activeProfileId } LaunchedEffect(Unit) { - val profileIds = userPreferencesManager.profileListFlow.first() + val profileIds = userPreferencesManager.memorySpaceListFlow.first() preferenceProfiles = - profileIds.map { id -> userPreferencesManager.getUserPreferencesFlow(id).first() } + profileIds.map { id -> userPreferencesManager.getMemorySpaceFlow(id).first() } } // 获取聊天设置按钮右边距设置 @@ -286,7 +286,7 @@ fun ClassicChatSettingsBar( } } else { scope.launch { - userPreferencesManager.setActiveProfile(selectedId) + userPreferencesManager.setActiveMemorySpace(selectedId) // 用户偏好和记忆库绑定,可能影响AI行为,所以刷新服务 EnhancedAIService.refreshServiceForFunction(context, FunctionType.CHAT) } @@ -1429,7 +1429,7 @@ private fun ThinkingSettingsItem( @Composable private fun MemorySelectorItem( - preferenceProfiles: List, + preferenceProfiles: List, currentProfileId: String, onSelectMemory: (String) -> Unit, expanded: Boolean, diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/memory/screens/FolderNavigator.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/memory/screens/FolderNavigator.kt index be10c176f..5b72b3f00 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/memory/screens/FolderNavigator.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/memory/screens/FolderNavigator.kt @@ -38,45 +38,135 @@ data class FolderExpandedState( ) /** - * 配置文件选择器 + * Memory-space selector and metadata controls. Memory contents stay in the existing ObjectBox + * database keyed by the stable space id. */ @Composable private fun ProfileSelector( profileList: List, profileNameMap: Map, selectedProfileId: String, - onProfileSelected: (String) -> Unit + onProfileSelected: (String) -> Unit, + onMemorySpaceCreate: (String) -> Unit, + onMemorySpaceRename: (String, String) -> Unit, + onMemorySpaceDelete: (String) -> Unit ) { var expanded by remember { mutableStateOf(false) } - // It's possible selectedProfileId is not in profileNameMap yet if things are loading. + var showCreateDialog by remember { mutableStateOf(false) } + var showRenameDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } + var editedName by remember { mutableStateOf("") } val selectedProfileName = profileNameMap[selectedProfileId] ?: selectedProfileId - Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - OutlinedButton( - onClick = { expanded = true }, - modifier = Modifier.fillMaxWidth() - ) { - Text(selectedProfileName, modifier = Modifier.weight(1f)) - Icon(Icons.Default.ArrowDropDown, contentDescription = "Select Profile") + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Box { + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text(selectedProfileName, modifier = Modifier.weight(1f)) + Icon(Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.memory_space_select)) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.width(218.dp) + ) { + profileList.forEach { profileId -> + val profileName = profileNameMap[profileId] ?: profileId + DropdownMenuItem( + text = { Text(profileName) }, + onClick = { + onProfileSelected(profileId) + expanded = false + } + ) + } + } + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.memory_space_create)) + } + IconButton( + onClick = { + editedName = selectedProfileName + showRenameDialog = true + }, + modifier = Modifier.size(32.dp) + ) { + Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.memory_space_rename)) + } + if (selectedProfileId != "default") { + IconButton(onClick = { showDeleteDialog = true }, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.memory_space_delete)) + } + } } + } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - // Make dropdown same width as button - modifier = Modifier.width(218.dp) // 250 - 16*2 = 218 - ) { - profileList.forEach { profileId -> - val profileName = profileNameMap[profileId] ?: profileId - DropdownMenuItem( - text = { Text(profileName) }, + if (showCreateDialog || showRenameDialog) { + val creating = showCreateDialog + AlertDialog( + onDismissRequest = { + showCreateDialog = false + showRenameDialog = false + editedName = "" + }, + title = { + Text(stringResource(if (creating) R.string.memory_space_create else R.string.memory_space_rename)) + }, + text = { + OutlinedTextField( + value = editedName, + onValueChange = { editedName = it }, + label = { Text(stringResource(R.string.memory_space_name)) }, + singleLine = true + ) + }, + confirmButton = { + TextButton( + onClick = { + val name = editedName.trim() + if (creating) onMemorySpaceCreate(name) + else onMemorySpaceRename(selectedProfileId, name) + showCreateDialog = false + showRenameDialog = false + editedName = "" + }, + enabled = editedName.isNotBlank() + ) { Text(stringResource(R.string.confirm)) } + }, + dismissButton = { + TextButton(onClick = { + showCreateDialog = false + showRenameDialog = false + editedName = "" + }) { Text(stringResource(R.string.cancel_action)) } + } + ) + } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(stringResource(R.string.memory_space_delete)) }, + text = { Text(stringResource(R.string.memory_space_delete_warning, selectedProfileName)) }, + confirmButton = { + TextButton( onClick = { - onProfileSelected(profileId) - expanded = false + onMemorySpaceDelete(selectedProfileId) + showDeleteDialog = false } - ) + ) { Text(stringResource(R.string.confirm_delete), color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(stringResource(R.string.cancel_action)) + } } - } + ) } } @@ -113,6 +203,9 @@ fun FolderNavigator( profileNameMap: Map, selectedProfileId: String, onProfileSelected: (String) -> Unit, + onMemorySpaceCreate: (String) -> Unit, + onMemorySpaceRename: (String, String) -> Unit, + onMemorySpaceDelete: (String) -> Unit, onDismissRequest: () -> Unit, modifier: Modifier = Modifier ) { @@ -184,7 +277,10 @@ fun FolderNavigator( profileList = profileList, profileNameMap = profileNameMap, selectedProfileId = selectedProfileId, - onProfileSelected = onProfileSelected + onProfileSelected = onProfileSelected, + onMemorySpaceCreate = onMemorySpaceCreate, + onMemorySpaceRename = onMemorySpaceRename, + onMemorySpaceDelete = onMemorySpaceDelete ) // 新建文件夹按钮和刷新按钮 @@ -659,4 +755,3 @@ private fun FolderDeleteDialog( } ) } - diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/memory/screens/MemoryScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/memory/screens/MemoryScreen.kt index bde937cf7..45a1e486c 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/memory/screens/MemoryScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/memory/screens/MemoryScreen.kt @@ -137,9 +137,9 @@ fun MemorySearchBar( @Composable fun MemoryScreen() { val context = LocalContext.current - val profileList by preferencesManager.profileListFlow.collectAsState(initial = emptyList()) + val profileList by preferencesManager.memorySpaceListFlow.collectAsState(initial = emptyList()) val activeProfileId by - preferencesManager.activeProfileIdFlow.collectAsState(initial = "default") + preferencesManager.activeMemorySpaceIdFlow.collectAsState(initial = "default") // 获取所有配置文件的名称映射(id -> name) val profileNameMap = remember { mutableStateMapOf() } @@ -147,7 +147,7 @@ fun MemoryScreen() { // 加载所有配置文件名称 LaunchedEffect(profileList) { profileList.forEach { profileId -> - val profile = preferencesManager.getUserPreferencesFlow(profileId).first() + val profile = preferencesManager.getMemorySpaceFlow(profileId).first() profileNameMap[profileId] = profile.name } } @@ -395,7 +395,33 @@ fun MemoryScreen() { profileList = profileList, profileNameMap = profileNameMap, selectedProfileId = selectedProfileId, - onProfileSelected = { selectedProfileId = it }, + onProfileSelected = { id -> + scope.launch { + preferencesManager.setActiveMemorySpace(id) + selectedProfileId = id + } + }, + onMemorySpaceCreate = { name -> + scope.launch { + val id = preferencesManager.createMemorySpace(name) + preferencesManager.setActiveMemorySpace(id) + selectedProfileId = id + } + }, + onMemorySpaceRename = { id, name -> + scope.launch { + val space = preferencesManager.getMemorySpaceFlow(id).first() + preferencesManager.updateMemorySpace(space.copy(name = name)) + profileNameMap[id] = name + } + }, + onMemorySpaceDelete = { id -> + scope.launch { + preferencesManager.deleteMemorySpace(id) + profileNameMap.remove(id) + selectedProfileId = "default" + } + }, onDismissRequest = { showFolderNavigator = false } ) } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/BackupDialogs.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/BackupDialogs.kt index e6805ecc5..7996eacd0 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/BackupDialogs.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/BackupDialogs.kt @@ -41,7 +41,7 @@ import com.ai.assistance.operit.R import com.ai.assistance.operit.data.converter.ChatFormat import com.ai.assistance.operit.data.converter.ExportFormat import com.ai.assistance.operit.data.model.ImportStrategy -import com.ai.assistance.operit.data.model.PreferenceProfile +import com.ai.assistance.operit.data.model.MemorySpace @Composable fun DeleteConfirmationDialog( @@ -173,7 +173,7 @@ fun StrategyOption( @Composable fun ProfileSelectionDialog( title: String, - profiles: List, + profiles: List, selectedProfileId: String, onProfileSelected: (String) -> Unit, onDismiss: () -> Unit, diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/CharacterCardDialog.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/CharacterCardDialog.kt index b99cb2e39..d7f32d771 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/CharacterCardDialog.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/CharacterCardDialog.kt @@ -38,7 +38,7 @@ import com.ai.assistance.operit.data.model.CharacterCardChatModelBindingMode import com.ai.assistance.operit.data.model.CharacterCardMemoryProfileBindingMode import com.ai.assistance.operit.data.model.CharacterCardToolAccessConfig import com.ai.assistance.operit.data.model.ModelConfigSummary -import com.ai.assistance.operit.data.model.PreferenceProfile +import com.ai.assistance.operit.data.model.MemorySpace import com.ai.assistance.operit.data.model.PromptTag import com.ai.assistance.operit.data.model.getModelByIndex import com.ai.assistance.operit.data.model.getModelList @@ -118,7 +118,7 @@ fun CharacterCardDialog( val skillRepository = remember { SkillRepository.getInstance(context) } val modelConfigManager = remember { ModelConfigManager(context) } var configSummaries by remember { mutableStateOf>(emptyList()) } - var preferenceProfiles by remember { mutableStateOf>(emptyList()) } + var preferenceProfiles by remember { mutableStateOf>(emptyList()) } var builtinToolOptions by remember(characterCard.id) { mutableStateOf>(emptyList()) } @@ -137,9 +137,9 @@ fun CharacterCardDialog( LaunchedEffect(Unit) { modelConfigManager.initializeIfNeeded() configSummaries = modelConfigManager.getAllConfigSummaries() - val profileIds = userPreferencesManager.profileListFlow.first() + val profileIds = userPreferencesManager.memorySpaceListFlow.first() preferenceProfiles = - profileIds.map { profileId -> userPreferencesManager.getUserPreferencesFlow(profileId).first() } + profileIds.map { profileId -> userPreferencesManager.getMemorySpaceFlow(profileId).first() } } LaunchedEffect(chatModelBindingMode, configSummaries) { @@ -1530,7 +1530,7 @@ private fun CharacterCardFixedModelPickerDialog( @Composable private fun CharacterCardFixedMemoryProfilePickerDialog( visible: Boolean, - preferenceProfiles: List, + preferenceProfiles: List, selectedProfileId: String, onSelect: (String) -> Unit, onDismiss: () -> Unit diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/MarkdownSyntaxHighlighting.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/MarkdownSyntaxHighlighting.kt new file mode 100644 index 000000000..84bc07633 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/MarkdownSyntaxHighlighting.kt @@ -0,0 +1,600 @@ +package com.ai.assistance.operit.ui.features.settings.components + +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle + +// Output transformations run while the field is presenting edits, so malformed delimiters must +// not trigger repeated whole-line searches. +private const val MAX_INLINE_TOKEN_LENGTH = 512 + +internal enum class MarkdownSyntaxKind { + HEADING, + EMPHASIS, + CODE, + LINK, + QUOTE, + MARKER, + COMMENT, + HTML, +} + +private data class MarkdownSyntaxColors( + val heading: Color, + val emphasis: Color, + val code: Color, + val link: Color, + val quote: Color, + val marker: Color, + val comment: Color, + val html: Color, +) { + fun colorFor(kind: MarkdownSyntaxKind): Color = + when (kind) { + MarkdownSyntaxKind.HEADING -> heading + MarkdownSyntaxKind.EMPHASIS -> emphasis + MarkdownSyntaxKind.CODE -> code + MarkdownSyntaxKind.LINK -> link + MarkdownSyntaxKind.QUOTE -> quote + MarkdownSyntaxKind.MARKER -> marker + MarkdownSyntaxKind.COMMENT -> comment + MarkdownSyntaxKind.HTML -> html + } +} + +@Composable +internal fun rememberMarkdownSyntaxOutputTransformation(): OutputTransformation { + val colorScheme = MaterialTheme.colorScheme + val colors = + remember( + colorScheme.primary, + colorScheme.secondary, + colorScheme.tertiary, + colorScheme.onSurfaceVariant, + ) { + MarkdownSyntaxColors( + heading = colorScheme.tertiary, + emphasis = colorScheme.tertiary, + code = colorScheme.primary, + link = colorScheme.primary, + quote = colorScheme.secondary, + marker = colorScheme.secondary, + comment = colorScheme.onSurfaceVariant, + html = colorScheme.secondary, + ) + } + + return remember(colors) { + OutputTransformation { + // Style annotations preserve source offsets, so selection, IME composition, and the + // field's own scroll state continue to use the same text layout. + scanMarkdownSyntax(asCharSequence()) { kind, start, end -> + addStyle(SpanStyle(color = colors.colorFor(kind)), start, end) + } + } + } +} + +internal fun scanMarkdownSyntax( + source: CharSequence, + emit: (kind: MarkdownSyntaxKind, start: Int, end: Int) -> Unit, +) { + var lineStart = 0 + var fenceMarker: Char? = null + var fenceLength = 0 + var inHtmlComment = false + + while (lineStart < source.length) { + val newline = source.findChar('\n', lineStart) + val rawLineEnd = if (newline >= 0) newline else source.length + val lineEnd = + if (rawLineEnd > lineStart && source[rawLineEnd - 1] == '\r') { + rawLineEnd - 1 + } else { + rawLineEnd + } + + if (inHtmlComment) { + val commentEnd = source.findSequence("-->", lineStart, lineEnd) + if (commentEnd < 0) { + emitRange(emit, MarkdownSyntaxKind.COMMENT, lineStart, lineEnd) + } else { + val afterComment = commentEnd + 3 + emitRange(emit, MarkdownSyntaxKind.COMMENT, lineStart, afterComment) + inHtmlComment = scanInlineSyntax(source, afterComment, lineEnd, emit) + } + lineStart = nextLineStart(newline, source.length) + continue + } + + val blockStart = source.blockStart(lineStart, lineEnd) + val activeFenceMarker = fenceMarker + if (activeFenceMarker != null) { + emitRange(emit, MarkdownSyntaxKind.CODE, lineStart, lineEnd) + if ( + blockStart >= 0 && + source.isClosingFence(blockStart, lineEnd, activeFenceMarker, fenceLength) + ) { + fenceMarker = null + fenceLength = 0 + } + lineStart = nextLineStart(newline, source.length) + continue + } + + if (blockStart >= 0) { + val openingFenceLength = source.fenceRunLength(blockStart, lineEnd) + val marker = if (blockStart < lineEnd) source[blockStart] else null + val validInfoString = + marker != '`' || !source.contains('`', blockStart + openingFenceLength, lineEnd) + if (openingFenceLength >= 3 && validInfoString) { + fenceMarker = source[blockStart] + fenceLength = openingFenceLength + emitRange(emit, MarkdownSyntaxKind.CODE, lineStart, lineEnd) + lineStart = nextLineStart(newline, source.length) + continue + } + } + + inHtmlComment = scanMarkdownLine(source, lineStart, lineEnd, blockStart, emit) + lineStart = nextLineStart(newline, source.length) + } +} + +private fun scanMarkdownLine( + source: CharSequence, + lineStart: Int, + lineEnd: Int, + blockStart: Int, + emit: (MarkdownSyntaxKind, Int, Int) -> Unit, +): Boolean { + if (blockStart < 0 || blockStart >= lineEnd) { + return scanInlineSyntax(source, lineStart, lineEnd, emit) + } + + var contentStart = blockStart + if (source[contentStart] == '>') { + val quoteStart = contentStart + do { + contentStart++ + if (contentStart < lineEnd && source[contentStart] == ' ') { + contentStart++ + } + } while (contentStart < lineEnd && source[contentStart] == '>') + emitRange(emit, MarkdownSyntaxKind.QUOTE, quoteStart, contentStart) + contentStart = source.skipWhitespace(contentStart, lineEnd) + } + + val headingEnd = source.headingMarkerEnd(contentStart, lineEnd) + if (headingEnd > contentStart) { + emitRange(emit, MarkdownSyntaxKind.MARKER, contentStart, headingEnd) + val headingTextStart = source.skipWhitespace(headingEnd, lineEnd) + emitRange(emit, MarkdownSyntaxKind.HEADING, headingTextStart, lineEnd) + return false + } + + if (source.isThematicBreak(contentStart, lineEnd)) { + emitRange(emit, MarkdownSyntaxKind.MARKER, contentStart, lineEnd) + return false + } + + val listMarkerEnd = source.listMarkerEnd(contentStart, lineEnd) + if (listMarkerEnd > contentStart) { + emitRange(emit, MarkdownSyntaxKind.MARKER, contentStart, listMarkerEnd) + contentStart = source.skipWhitespace(listMarkerEnd, lineEnd) + val taskMarkerEnd = source.taskMarkerEnd(contentStart, lineEnd) + if (taskMarkerEnd > contentStart) { + emitRange(emit, MarkdownSyntaxKind.MARKER, contentStart, taskMarkerEnd) + contentStart = source.skipWhitespace(taskMarkerEnd, lineEnd) + } + } + + return scanInlineSyntax(source, contentStart, lineEnd, emit) +} + +private fun scanInlineSyntax( + source: CharSequence, + start: Int, + end: Int, + emit: (MarkdownSyntaxKind, Int, Int) -> Unit, +): Boolean { + var index = start + while (index < end) { + when { + source[index] == '\\' -> { + index = (index + 2).coerceAtMost(end) + } + + source.hasPrefix("", index + 4, end) + if (commentEnd < 0) { + emitRange(emit, MarkdownSyntaxKind.COMMENT, index, end) + return true + } + val afterComment = commentEnd + 3 + emitRange(emit, MarkdownSyntaxKind.COMMENT, index, afterComment) + index = afterComment + } + + source[index] == '`' -> { + val markerLength = source.runLength(index, end, '`') + val searchEnd = inlineTokenEnd(index, end) + val closingStart = + source.findMatchingRun('`', markerLength, index + markerLength, searchEnd) + if (closingStart >= 0) { + val codeEnd = closingStart + markerLength + emitRange(emit, MarkdownSyntaxKind.CODE, index, codeEnd) + index = codeEnd + } else { + index += markerLength + } + } + + source[index] == '[' || + (source[index] == '!' && index + 1 < end && source[index + 1] == '[') -> { + val linkEnd = source.linkEnd(index, end) + if (linkEnd > index) { + emitRange(emit, MarkdownSyntaxKind.LINK, index, linkEnd) + index = linkEnd + } else { + index++ + } + } + + source[index] == '<' -> { + val autolinkEnd = source.autolinkEnd(index, end) + if (autolinkEnd > index) { + emitRange(emit, MarkdownSyntaxKind.LINK, index, autolinkEnd) + index = autolinkEnd + } else { + val htmlEnd = source.htmlTagEnd(index, end) + if (htmlEnd > index) { + emitRange(emit, MarkdownSyntaxKind.HTML, index, htmlEnd) + index = htmlEnd + } else { + index++ + } + } + } + + source[index] == '*' || source[index] == '_' || source[index] == '~' -> { + val marker = source[index] + val markerRunLength = source.runLength(index, end, marker) + val markerLength = emphasisMarkerLength(marker, markerRunLength) + if ( + markerLength > 0 && + source.canOpenEmphasis(index, markerLength, end) + ) { + val emphasisEnd = source.emphasisEnd(index, markerLength, end) + if (emphasisEnd > index) { + emitRange(emit, MarkdownSyntaxKind.EMPHASIS, index, emphasisEnd) + index = emphasisEnd + } else { + index += markerRunLength + } + } else { + index += markerRunLength + } + } + + source[index] == '|' -> { + emitRange(emit, MarkdownSyntaxKind.MARKER, index, index + 1) + index++ + } + + else -> index++ + } + } + return false +} + +private fun CharSequence.blockStart(start: Int, end: Int): Int { + var index = start + var spaces = 0 + while (index < end && this[index] == ' ' && spaces < 4) { + index++ + spaces++ + } + return if (spaces <= 3) index else -1 +} + +private fun CharSequence.fenceRunLength(start: Int, end: Int): Int { + if (start >= end || (this[start] != '`' && this[start] != '~')) return 0 + return runLength(start, end, this[start]) +} + +private fun CharSequence.isClosingFence( + start: Int, + end: Int, + marker: Char, + openingLength: Int, +): Boolean { + if (start >= end || this[start] != marker) return false + val runLength = runLength(start, end, marker) + if (runLength < openingLength) return false + var index = start + runLength + while (index < end) { + if (!this[index].isWhitespace()) return false + index++ + } + return true +} + +private fun CharSequence.headingMarkerEnd(start: Int, end: Int): Int { + var index = start + while (index < end && this[index] == '#' && index - start < 6) { + index++ + } + val markerLength = index - start + return if (markerLength in 1..6 && (index == end || this[index].isWhitespace())) index else -1 +} + +private fun CharSequence.isThematicBreak(start: Int, end: Int): Boolean { + if (start >= end || this[start] !in charArrayOf('*', '-', '_')) return false + val marker = this[start] + var markerCount = 0 + for (index in start until end) { + when { + this[index] == marker -> markerCount++ + this[index] == ' ' || this[index] == '\t' -> Unit + else -> return false + } + } + return markerCount >= 3 +} + +private fun CharSequence.listMarkerEnd(start: Int, end: Int): Int { + if (start >= end) return -1 + if ( + this[start] in charArrayOf('-', '+', '*') && + start + 1 < end && + this[start + 1].isWhitespace() + ) { + return start + 1 + } + + var index = start + while (index < end && this[index].isDigit() && index - start < 9) { + index++ + } + if ( + index > start && + index < end && + this[index] in charArrayOf('.', ')') && + index + 1 < end && + this[index + 1].isWhitespace() + ) { + return index + 1 + } + return -1 +} + +private fun CharSequence.taskMarkerEnd(start: Int, end: Int): Int { + if ( + start + 2 < end && + this[start] == '[' && + this[start + 1] in charArrayOf(' ', 'x', 'X') && + this[start + 2] == ']' && + (start + 3 == end || this[start + 3].isWhitespace()) + ) { + return start + 3 + } + return -1 +} + +private fun CharSequence.linkEnd(start: Int, end: Int): Int { + val searchEnd = inlineTokenEnd(start, end) + val openingBracket = if (this[start] == '!') start + 1 else start + val labelEnd = findUnescaped(']', openingBracket + 1, searchEnd) + if (labelEnd < 0 || labelEnd + 1 >= searchEnd) return -1 + + return when (this[labelEnd + 1]) { + '(' -> closingParenthesisEnd(labelEnd + 1, searchEnd) + '[' -> { + val referenceEnd = findUnescaped(']', labelEnd + 2, searchEnd) + if (referenceEnd >= 0) referenceEnd + 1 else -1 + } + else -> -1 + } +} + +private fun CharSequence.closingParenthesisEnd(start: Int, end: Int): Int { + var index = start + var depth = 0 + var quote: Char? = null + while (index < end) { + val current = this[index] + if (current == '\\') { + index += 2 + continue + } + val activeQuote = quote + if (activeQuote != null) { + if (current == activeQuote) quote = null + } else { + when (current) { + '\'', '"' -> quote = current + '(' -> depth++ + ')' -> { + depth-- + if (depth == 0) return index + 1 + } + } + } + index++ + } + return -1 +} + +private fun CharSequence.htmlTagEnd(start: Int, end: Int): Int { + val searchEnd = inlineTokenEnd(start, end) + if (start + 1 >= searchEnd) return -1 + val first = this[start + 1] + if (!(first.isLetter() || first in charArrayOf('/', '!', '?'))) return -1 + + var index = start + 2 + var quote: Char? = null + while (index < searchEnd) { + val current = this[index] + if (current == '\\') { + index += 2 + continue + } + val activeQuote = quote + if (activeQuote != null) { + if (current == activeQuote) quote = null + } else { + when (current) { + '\'', '"' -> quote = current + '>' -> return index + 1 + } + } + index++ + } + return -1 +} + +private fun CharSequence.autolinkEnd(start: Int, end: Int): Int { + val closingBracket = findUnescaped('>', start + 1, inlineTokenEnd(start, end)) + if (closingBracket < 0) return -1 + + var hasSchemeSeparator = false + var hasEmailSeparator = false + for (index in start + 1 until closingBracket) { + val current = this[index] + if (current.isWhitespace() || current == '<') return -1 + if (current == ':') hasSchemeSeparator = true + if (current == '@') hasEmailSeparator = true + } + return if (hasSchemeSeparator || hasEmailSeparator) closingBracket + 1 else -1 +} + +private fun emphasisMarkerLength(marker: Char, availableRun: Int): Int { + return when (marker) { + '~' -> if (availableRun >= 2) 2 else -1 + '*', '_' -> availableRun.coerceAtMost(3) + else -> -1 + } +} + +private fun CharSequence.canOpenEmphasis(start: Int, markerLength: Int, end: Int): Boolean { + val contentStart = start + markerLength + if (contentStart >= end || this[contentStart].isWhitespace()) return false + if (this[start] == '_' && start > 0 && this[start - 1].isLetterOrDigit()) return false + return true +} + +private fun CharSequence.emphasisEnd(start: Int, markerLength: Int, end: Int): Int { + val marker = this[start] + val contentStart = start + markerLength + val searchEnd = inlineTokenEnd(start, end) + var closingStart = findMatchingRun(marker, markerLength, contentStart, searchEnd) + while (closingStart >= 0) { + val afterClosing = closingStart + markerLength + val validClosing = + closingStart > contentStart && + !this[closingStart - 1].isWhitespace() && + (marker != '_' || afterClosing == end || !this[afterClosing].isLetterOrDigit()) + if (validClosing) return afterClosing + closingStart = findMatchingRun(marker, markerLength, afterClosing, searchEnd) + } + return -1 +} + +private fun CharSequence.findMatchingRun( + marker: Char, + length: Int, + start: Int, + end: Int, +): Int { + var index = start + while (index < end) { + if (this[index] == '\\') { + index += 2 + continue + } + if (this[index] == marker) { + val candidateLength = runLength(index, end, marker) + if (candidateLength == length) return index + index += candidateLength + } else { + index++ + } + } + return -1 +} + +private fun CharSequence.findUnescaped(target: Char, start: Int, end: Int): Int { + var index = start + while (index < end) { + if (this[index] == '\\') { + index += 2 + } else if (this[index] == target) { + return index + } else { + index++ + } + } + return -1 +} + +private fun CharSequence.contains(target: Char, start: Int, end: Int): Boolean { + for (index in start until end) { + if (this[index] == target) return true + } + return false +} + +private fun CharSequence.findChar(target: Char, start: Int): Int { + for (index in start until length) { + if (this[index] == target) return index + } + return -1 +} + +private fun CharSequence.findSequence(target: String, start: Int, end: Int): Int { + var index = start + while (index + target.length <= end) { + if (hasPrefix(target, index, end)) return index + index++ + } + return -1 +} + +private fun CharSequence.hasPrefix(target: String, start: Int, end: Int): Boolean { + if (start < 0 || start + target.length > end) return false + for (offset in target.indices) { + if (this[start + offset] != target[offset]) return false + } + return true +} + +private fun CharSequence.runLength(start: Int, end: Int, marker: Char): Int { + var index = start + while (index < end && this[index] == marker) index++ + return index - start +} + +private fun CharSequence.skipWhitespace(start: Int, end: Int): Int { + var index = start + while (index < end && this[index].isWhitespace()) index++ + return index +} + +private fun emitRange( + emit: (MarkdownSyntaxKind, Int, Int) -> Unit, + kind: MarkdownSyntaxKind, + start: Int, + end: Int, +) { + if (start < end) emit(kind, start, end) +} + +private fun nextLineStart(newline: Int, textLength: Int): Int = + if (newline >= 0) newline + 1 else textLength + +private fun inlineTokenEnd(start: Int, lineEnd: Int): Int = + (start + MAX_INLINE_TOKEN_LENGTH).coerceAtMost(lineEnd) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt index c962ab82f..786be7c18 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt @@ -68,7 +68,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.ai.assistance.operit.R import com.ai.assistance.operit.data.model.ImportStrategy -import com.ai.assistance.operit.data.model.PreferenceProfile +import com.ai.assistance.operit.data.model.MemorySpace import com.ai.assistance.operit.data.backup.OperitBackupDirs import com.ai.assistance.operit.data.backup.RawSnapshotBackupManager import com.ai.assistance.operit.data.backup.RoomDatabaseBackupManager @@ -148,7 +148,7 @@ fun ChatBackupSettingsScreen() { val userPreferencesManager = remember { UserPreferencesManager.getInstance(context) } val characterCardManager = remember { CharacterCardManager.getInstance(context) } val modelConfigManager = remember { ModelConfigManager(context) } - val activeProfileId by userPreferencesManager.activeProfileIdFlow.collectAsState(initial = "default") + val activeProfileId by userPreferencesManager.activeMemorySpaceIdFlow.collectAsState(initial = "default") var memoryRepo by remember { mutableStateOf(null) } var totalChatCount by remember { mutableStateOf(0) } @@ -202,8 +202,8 @@ fun ChatBackupSettingsScreen() { initial = RoomDatabaseBackupPreferences.DEFAULT_MAX_BACKUP_COUNT ) - val profileIds by userPreferencesManager.profileListFlow.collectAsState(initial = listOf("default")) - var allProfiles by remember { mutableStateOf>(emptyList()) } + val profileIds by userPreferencesManager.memorySpaceListFlow.collectAsState(initial = listOf("default")) + var allProfiles by remember { mutableStateOf>(emptyList()) } var selectedExportProfileId by remember { mutableStateOf(activeProfileId) } var selectedImportProfileId by remember { mutableStateOf(activeProfileId) } var showExportProfileDialog by remember { mutableStateOf(false) } @@ -227,7 +227,7 @@ fun ChatBackupSettingsScreen() { LaunchedEffect(profileIds) { val profiles = profileIds.mapNotNull { profileId -> try { - userPreferencesManager.getUserPreferencesFlow(profileId).first() + userPreferencesManager.getMemorySpaceFlow(profileId).first() } catch (_: Exception) { null } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatHistorySettingsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatHistorySettingsScreen.kt index 7a0222530..af068f99f 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatHistorySettingsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatHistorySettingsScreen.kt @@ -47,7 +47,7 @@ import com.ai.assistance.operit.data.model.CharacterCardChatStats import com.ai.assistance.operit.data.model.CharacterGroupCard import com.ai.assistance.operit.data.model.CharacterGroupChatStats import com.ai.assistance.operit.data.model.ImportStrategy -import com.ai.assistance.operit.data.model.PreferenceProfile +import com.ai.assistance.operit.data.model.MemorySpace import com.ai.assistance.operit.data.preferences.CharacterCardManager import com.ai.assistance.operit.data.preferences.CharacterGroupCardManager import com.ai.assistance.operit.data.preferences.UserPreferencesManager @@ -81,7 +81,7 @@ fun ChatHistorySettingsScreen() { val characterCardManager = remember { CharacterCardManager.getInstance(context) } val characterGroupCardManager = remember { CharacterGroupCardManager.getInstance(context) } val userPreferencesManager = remember { UserPreferencesManager.getInstance(context) } - val activeProfileId by userPreferencesManager.activeProfileIdFlow.collectAsState(initial = "default") + val activeProfileId by userPreferencesManager.activeMemorySpaceIdFlow.collectAsState(initial = "default") val characterCardStatsState by chatHistoryManager.characterCardStatsFlow .collectAsState(initial = null as List?) @@ -224,13 +224,13 @@ fun ChatHistorySettingsScreen() { } } - val profileIds by userPreferencesManager.profileListFlow.collectAsState(initial = listOf("default")) - var allProfiles by remember { mutableStateOf>(emptyList()) } + val profileIds by userPreferencesManager.memorySpaceListFlow.collectAsState(initial = listOf("default")) + var allProfiles by remember { mutableStateOf>(emptyList()) } LaunchedEffect(profileIds) { val profiles = profileIds.mapNotNull { profileId -> try { - userPreferencesManager.getUserPreferencesFlow(profileId).first() + userPreferencesManager.getMemorySpaceFlow(profileId).first() } catch (_: Exception) { null } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/FunctionalConfigScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/FunctionalConfigScreen.kt index c1b20bbe3..76a346c64 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/FunctionalConfigScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/FunctionalConfigScreen.kt @@ -639,7 +639,6 @@ fun FunctionConfigCard( duplicatesPromptPart = "", existingMemoriesPrompt = existingMemoriesPrompt, existingFoldersPrompt = existingFoldersPrompt, - currentPreferences = "", useEnglish = useEnglish ) val userPrompt = diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/UserPreferencesGuideScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/UserPreferencesGuideScreen.kt deleted file mode 100644 index fabb83805..000000000 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/UserPreferencesGuideScreen.kt +++ /dev/null @@ -1,844 +0,0 @@ -package com.ai.assistance.operit.ui.features.settings.screens - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Info -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.DisplayMode -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import com.ai.assistance.operit.ui.components.CustomScaffold -import androidx.compose.material3.rememberDatePickerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.ai.assistance.operit.R -import com.ai.assistance.operit.data.preferences.UserPreferencesManager -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Date -import java.util.Locale -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import androidx.compose.foundation.clickable -import android.content.Context - -/** 根据用户选择的关键信息生成偏好描述 */ -private fun generatePreferencesDescription( - gender: String, - occupation: String, - birthDate: Long, - context: Context -): String { - val genderDesc = when (gender) { - context.getString(R.string.user_male) -> context.getString(R.string.user_male_desc) - context.getString(R.string.user_female) -> context.getString(R.string.user_female_desc) - else -> context.getString(R.string.user_generic_desc) - } - - // 根据出生日期计算年龄 - val age = if (birthDate > 0) { - val today = Calendar.getInstance() - val birthCal = Calendar.getInstance().apply { timeInMillis = birthDate } - var age = today.get(Calendar.YEAR) - birthCal.get(Calendar.YEAR) - // 如果今年的生日还没过,年龄减一 - if (today.get(Calendar.MONTH) < birthCal.get(Calendar.MONTH) || - (today.get(Calendar.MONTH) == birthCal.get(Calendar.MONTH) && - today.get(Calendar.DAY_OF_MONTH) < - birthCal.get(Calendar.DAY_OF_MONTH)) - ) { - age-- - } - age - } else { - 0 - } - - val ageDesc = when { - age in 1..12 -> context.getString(R.string.age_child) - age in 13..17 -> context.getString(R.string.age_teenager) - age in 18..25 -> context.getString(R.string.age_young_adult) - age in 26..40 -> context.getString(R.string.age_adult) - age in 41..60 -> context.getString(R.string.age_middle_aged) - age > 60 -> context.getString(R.string.age_elderly) - else -> "" - } - - val occupationDesc = when (occupation) { - context.getString(R.string.occupation_student) -> context.getString(R.string.occupation_student_desc) - context.getString(R.string.occupation_employee) -> context.getString(R.string.occupation_employee_desc) - context.getString(R.string.occupation_freelancer) -> context.getString(R.string.occupation_freelancer_desc) - else -> context.getString(R.string.occupation_worker_desc) - } - - val interestTopics = when (occupation) { - context.getString(R.string.occupation_student) -> context.getString(R.string.interests_student) - context.getString(R.string.occupation_employee) -> context.getString(R.string.interests_employee) - context.getString(R.string.occupation_freelancer) -> context.getString(R.string.interests_freelancer) - else -> context.getString(R.string.interests_general) - } - - val preferenceStyle = when (gender) { - context.getString(R.string.user_male) -> context.getString(R.string.communication_style_male) - context.getString(R.string.user_female) -> context.getString(R.string.communication_style_female) - else -> context.getString(R.string.communication_style_general) - } - - // 组装完整描述 - val description = if (ageDesc.isNotEmpty()) { - context.getString(R.string.preferences_intro_template, genderDesc, ageDesc, occupationDesc, interestTopics, preferenceStyle) - } else { - context.getString(R.string.preferences_intro_template_no_age, genderDesc, occupationDesc, interestTopics, preferenceStyle) - } - - // 确保不超过100字 - return if (description.length > 100) description.substring(0, 97) + "..." else description -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -fun UserPreferencesGuideScreen( - profileName: String = "", - profileId: String = "", - onComplete: () -> Unit, - navigateToPermissions: () -> Unit = onComplete, - onBackPressed: () -> Unit = onComplete -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val preferencesManager = remember { UserPreferencesManager.getInstance(context) } - - var selectedGender by remember { mutableStateOf("") } - var selectedOccupation by remember { mutableStateOf("") } - var birthDate by remember { mutableStateOf(0L) } - var selectedPersonality by remember { mutableStateOf(setOf()) } - var selectedIdentity by remember { mutableStateOf(setOf()) } - var selectedAiStyleTags by remember { mutableStateOf(setOf()) } - - // 自定义标签相关状态 - var customPersonalityTags by remember { mutableStateOf(setOf()) } - var customIdentityTags by remember { mutableStateOf(setOf()) } - var customAiStyleTags by remember { mutableStateOf(setOf()) } - - // 新增标签对话框相关状态 - var showCustomTagDialog by remember { mutableStateOf(false) } - var newTagText by remember { mutableStateOf("") } - var currentTagCategory by remember { mutableStateOf("") } // personality, identity, or aiStyle - - // 日期选择器状态 - var showDatePicker by remember { mutableStateOf(false) } - val dateFormatter = SimpleDateFormat(stringResource(R.string.date_format_pattern), Locale.getDefault()) - - // 初始化日期选择器状态 - val initialSelectedDateMillis = - if (birthDate > 0) birthDate - else { - // 默认设置为1990年1月1日 - Calendar.getInstance() - .apply { - set(Calendar.YEAR, 1990) - set(Calendar.MONTH, Calendar.JANUARY) - set(Calendar.DAY_OF_MONTH, 1) - } - .timeInMillis - } - val datePickerState = - rememberDatePickerState( - initialSelectedDateMillis = initialSelectedDateMillis, - initialDisplayMode = DisplayMode.Picker - ) - - // 各种选项数据 - val genderOptions = listOf( - stringResource(R.string.user_male), - stringResource(R.string.user_female), - stringResource(R.string.user_other) - ) - val occupationOptions = listOf( - stringResource(R.string.occupation_student), - stringResource(R.string.occupation_teacher), - stringResource(R.string.occupation_doctor), - stringResource(R.string.occupation_engineer), - stringResource(R.string.occupation_designer), - stringResource(R.string.occupation_programmer), - stringResource(R.string.occupation_business_owner), - stringResource(R.string.occupation_sales), - stringResource(R.string.occupation_customer_service), - stringResource(R.string.occupation_freelancer), - stringResource(R.string.occupation_retired), - stringResource(R.string.occupation_other) - ) - val personalityOptions = listOf( - stringResource(R.string.personality_extroverted), - stringResource(R.string.personality_introverted), - stringResource(R.string.personality_sensitive), - stringResource(R.string.personality_rational), - stringResource(R.string.personality_emotional), - stringResource(R.string.personality_cautious), - stringResource(R.string.personality_adventurous), - stringResource(R.string.personality_patient), - stringResource(R.string.personality_impatient), - stringResource(R.string.personality_optimistic), - stringResource(R.string.personality_pessimistic), - stringResource(R.string.personality_curious), - stringResource(R.string.personality_conservative), - stringResource(R.string.personality_innovative), - stringResource(R.string.personality_meticulous), - stringResource(R.string.personality_rough) - ) - val identityOptions = listOf( - stringResource(R.string.identity_student), - stringResource(R.string.identity_teacher), - stringResource(R.string.identity_parent), - stringResource(R.string.identity_music_lover), - stringResource(R.string.identity_art_lover), - stringResource(R.string.identity_gamer), - stringResource(R.string.identity_athlete), - stringResource(R.string.identity_tech_enthusiast), - stringResource(R.string.identity_traveler), - stringResource(R.string.identity_foodie), - stringResource(R.string.identity_entrepreneur), - stringResource(R.string.identity_professional) - ) - val aiStyleOptions = listOf( - stringResource(R.string.ai_style_professional), - stringResource(R.string.ai_style_humorous), - stringResource(R.string.ai_style_direct), - stringResource(R.string.ai_style_patient), - stringResource(R.string.ai_style_creative), - stringResource(R.string.ai_style_technical), - stringResource(R.string.ai_style_educational), - stringResource(R.string.ai_style_supportive) - ) - - // 从配置文件加载现有数据 - LaunchedEffect(profileId) { - try { - // 检查是否存在配置文件列表 - val profiles = preferencesManager.profileListFlow.first() - - // 如果配置列表为空,创建默认配置 - if (profiles.isEmpty()) { - // 创建默认配置并设置为活动配置 - val defaultProfileId = preferencesManager.createProfile(context.getString(R.string.default_profile), isDefault = true) - preferencesManager.setActiveProfile(defaultProfileId) - // 给一点时间让数据存储更新 - delay(100) - } - - // 如果提供了profileId,加载特定配置;否则加载活动配置 - val profile = - if (profileId.isNotEmpty()) { - preferencesManager.getUserPreferencesFlow(profileId).first() - } else { - preferencesManager.getUserPreferencesFlow().first() - } - - // 填充表单字段 - selectedGender = profile.gender - selectedOccupation = profile.occupation - birthDate = profile.birthDate - - // 解析个性特点 - val personalityTags = - profile.personality.split(",").map { it.trim() }.filter { it.isNotEmpty() } - val (standard, custom) = personalityTags.partition { personalityOptions.contains(it) } - selectedPersonality = standard.toSet() - customPersonalityTags = custom.toSet() - - // 解析身份认同 - val identityTags = - profile.identity.split(",").map { it.trim() }.filter { it.isNotEmpty() } - val (standardId, customId) = identityTags.partition { identityOptions.contains(it) } - selectedIdentity = standardId.toSet() - customIdentityTags = customId.toSet() - - // 解析AI风格标签 - val styleTags = profile.aiStyle.split(",").map { it.trim() }.filter { it.isNotEmpty() } - val (standardStyle, customStyle) = styleTags.partition { aiStyleOptions.contains(it) } - selectedAiStyleTags = standardStyle.toSet() - customAiStyleTags = customStyle.toSet() - - // 如果没有已选的标签,默认选择一些并保存到配置中 - var needsUpdate = false - - if (selectedAiStyleTags.isEmpty() && customAiStyleTags.isEmpty()) { - selectedAiStyleTags = setOf( - context.getString(R.string.ai_style_professional), - context.getString(R.string.ai_style_direct) - ) - needsUpdate = true - } - - if (selectedPersonality.isEmpty() && customPersonalityTags.isEmpty()) { - selectedPersonality = setOf( - context.getString(R.string.personality_rational), - context.getString(R.string.personality_patient) - ) - needsUpdate = true - } - - // 如果需要更新默认值,保存到配置 - if (needsUpdate) { - if (profileId.isNotEmpty()) { - // 更新指定的配置文件 - preferencesManager.updateProfileCategory( - profileId = profileId, - aiStyle = selectedAiStyleTags.joinToString(", "), - personality = selectedPersonality.joinToString(", ") - ) - } else { - // 更新当前活动的配置文件 - preferencesManager.updateProfileCategory( - aiStyle = selectedAiStyleTags.joinToString(", "), - personality = selectedPersonality.joinToString(", ") - ) - } - } - } catch (e: Exception) { - // 如果获取配置失败,创建默认配置 - try { - // 创建默认配置 - val defaultProfileId = preferencesManager.createProfile(context.getString(R.string.default_profile), isDefault = true) - - // 设置默认值 - selectedAiStyleTags = setOf( - context.getString(R.string.ai_style_professional), - context.getString(R.string.ai_style_direct) - ) - selectedPersonality = setOf( - context.getString(R.string.personality_rational), - context.getString(R.string.personality_patient) - ) - - // 保存默认值到配置 - if (profileId.isNotEmpty()) { - // 如果有指定的profileId,更新指定配置 - preferencesManager.updateProfileCategory( - profileId = profileId, - aiStyle = selectedAiStyleTags.joinToString(", "), - personality = selectedPersonality.joinToString(", ") - ) - } else { - // 否则更新默认配置 - preferencesManager.updateProfileCategory( - profileId = defaultProfileId, - aiStyle = selectedAiStyleTags.joinToString(", "), - personality = selectedPersonality.joinToString(", ") - ) - } - } catch (ex: Exception) { - // 如果还是失败,至少确保UI有默认值显示 - selectedAiStyleTags = setOf( - context.getString(R.string.ai_style_professional), - context.getString(R.string.ai_style_direct) - ) - selectedPersonality = setOf( - context.getString(R.string.personality_rational), - context.getString(R.string.personality_patient) - ) - } - } - } - - // 自定义标签对话框 - if (showCustomTagDialog) { - AlertDialog( - onDismissRequest = { - showCustomTagDialog = false - newTagText = "" - }, - title = { - Text( - when (currentTagCategory) { - "personality" -> stringResource(R.string.add_custom_personality) - "identity" -> stringResource(R.string.add_custom_identity) - "aiStyle" -> stringResource(R.string.add_custom_ai_style) - else -> stringResource(R.string.add_custom_tag) - } - ) - }, - text = { - OutlinedTextField( - value = newTagText, - onValueChange = { if (it.length <= 10) newTagText = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.enter_tag_name)) }, - singleLine = true - ) - }, - confirmButton = { - TextButton( - onClick = { - if (newTagText.isNotEmpty()) { - when (currentTagCategory) { - "personality" -> { - customPersonalityTags = - customPersonalityTags + newTagText - selectedPersonality = selectedPersonality + newTagText - } - "identity" -> { - customIdentityTags = customIdentityTags + newTagText - selectedIdentity = selectedIdentity + newTagText - } - "aiStyle" -> { - customAiStyleTags = customAiStyleTags + newTagText - selectedAiStyleTags = selectedAiStyleTags + newTagText - } - } - } - newTagText = "" - showCustomTagDialog = false - }, - enabled = newTagText.isNotEmpty() - ) { Text(stringResource(R.string.add_action)) } - }, - dismissButton = { - TextButton( - onClick = { - newTagText = "" - showCustomTagDialog = false - } - ) { Text(stringResource(R.string.cancel_action)) } - } - ) - } - - // 日期选择器对话框 - if (showDatePicker) { - DatePickerDialog( - onDismissRequest = { showDatePicker = false }, - confirmButton = { - TextButton( - onClick = { - datePickerState.selectedDateMillis?.let { birthDate = it } - showDatePicker = false - } - ) { Text(stringResource(R.string.confirm_action)) } - }, - dismissButton = { TextButton(onClick = { showDatePicker = false }) { Text(stringResource(R.string.cancel_action)) } } - ) { - DatePicker( - state = datePickerState, - title = { - Text( - stringResource(R.string.select_birth_date_title), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 16.dp) - ) - } - ) - } - } - - CustomScaffold() { paddingValues -> - Column( - modifier = - Modifier.fillMaxSize() - .padding(paddingValues) - .padding(16.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Column { - Text( - text = - if (profileName.isNotEmpty()) stringResource(R.string.profile_config_title, profileName) - else stringResource(id = R.string.preferences_guide_title), - style = MaterialTheme.typography.titleMedium - ) - - // 显示当前编辑的配置ID(调试用) - if (profileId.isNotEmpty()) { - Text( - text = stringResource(R.string.editing_profile_id, profileId), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp) - ) - } - } - - // 添加说明卡片,提示所有选项都是可选的 - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - tonalElevation = 1.dp, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = stringResource(R.string.info_icon), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - stringResource(R.string.all_options_optional), - style = MaterialTheme.typography.bodyMedium - ) - } - } - - // 性别选择(标签选择) - Text(stringResource(R.string.gender_optional), style = MaterialTheme.typography.titleSmall) - FlowRow( - modifier = Modifier.fillMaxWidth(), - maxItemsInEachRow = 4, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - genderOptions.forEach { option -> - FilterChip( - selected = selectedGender == option, - onClick = { selectedGender = option }, - label = { Text(option) }, - modifier = Modifier.padding(vertical = 4.dp) - ) - } - } - - // 职业选择(标签选择) - Text( - stringResource(R.string.occupation_optional), - style = MaterialTheme.typography.titleSmall - ) - FlowRow( - modifier = Modifier.fillMaxWidth(), - maxItemsInEachRow = 4, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - occupationOptions.forEach { option -> - FilterChip( - selected = selectedOccupation == option, - onClick = { selectedOccupation = option }, - label = { Text(option) }, - modifier = Modifier.padding(vertical = 4.dp) - ) - } - } - - // 出生日期选择 - Text( - stringResource(R.string.birth_date_optional), - style = MaterialTheme.typography.titleSmall - ) - OutlinedCard( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - onClick = { showDatePicker = true } - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = - if (birthDate > 0) dateFormatter.format(Date(birthDate)) - else stringResource(R.string.select_birth_date), - style = MaterialTheme.typography.bodyLarge, - color = - if (birthDate > 0) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurfaceVariant - ) - Icon( - Icons.Default.CalendarMonth, - contentDescription = stringResource(R.string.select_date), - tint = MaterialTheme.colorScheme.primary - ) - } - } - - // 性格特点选择(多选标签) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(stringResource(R.string.personality_optional), style = MaterialTheme.typography.titleSmall) - TextButton( - onClick = { - currentTagCategory = "personality" - showCustomTagDialog = true - } - ) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_icon)) - Text(stringResource(R.string.add_custom)) - } - } - FlowRow( - modifier = Modifier.fillMaxWidth(), - maxItemsInEachRow = 4, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // 预定义标签 - personalityOptions.forEach { option -> - val isSelected = selectedPersonality.contains(option) - FilterChip( - selected = isSelected, - onClick = { - if (isSelected) { - selectedPersonality = selectedPersonality - option - } else { - selectedPersonality = selectedPersonality + option - } - }, - label = { Text(option) }, - modifier = Modifier.padding(vertical = 4.dp) - ) - } - - // 自定义标签 - customPersonalityTags.forEach { tag -> - FilterChip( - selected = selectedPersonality.contains(tag), - onClick = { - if (selectedPersonality.contains(tag)) { - selectedPersonality = selectedPersonality - tag - } else { - selectedPersonality = selectedPersonality + tag - } - }, - label = { Text(tag) }, - modifier = Modifier.padding(vertical = 4.dp), - trailingIcon = { - Icon( - Icons.Default.Close, - contentDescription = stringResource(R.string.delete_icon), - modifier = Modifier - .size(16.dp) - .clickable { - customPersonalityTags = customPersonalityTags - tag - selectedPersonality = selectedPersonality - tag - } - ) - } - ) - } - } - - // 身份认同选择(多选标签) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(stringResource(R.string.identity_optional), style = MaterialTheme.typography.titleSmall) - TextButton( - onClick = { - currentTagCategory = "identity" - showCustomTagDialog = true - } - ) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_icon)) - Text(stringResource(R.string.add_custom)) - } - } - FlowRow( - modifier = Modifier.fillMaxWidth(), - maxItemsInEachRow = 4, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // 预定义标签 - identityOptions.forEach { option -> - val isSelected = selectedIdentity.contains(option) - FilterChip( - selected = isSelected, - onClick = { - if (isSelected) { - selectedIdentity = selectedIdentity - option - } else { - selectedIdentity = selectedIdentity + option - } - }, - label = { Text(option) }, - modifier = Modifier.padding(vertical = 4.dp) - ) - } - - // 自定义标签 - customIdentityTags.forEach { tag -> - FilterChip( - selected = selectedIdentity.contains(tag), - onClick = { - if (selectedIdentity.contains(tag)) { - selectedIdentity = selectedIdentity - tag - } else { - selectedIdentity = selectedIdentity + tag - } - }, - label = { Text(tag) }, - modifier = Modifier.padding(vertical = 4.dp), - trailingIcon = { - Icon( - Icons.Default.Close, - contentDescription = stringResource(R.string.delete_icon), - modifier = Modifier - .size(16.dp) - .clickable { - customIdentityTags = customIdentityTags - tag - selectedIdentity = selectedIdentity - tag - } - ) - } - ) - } - } - - // AI风格标签选择 - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(stringResource(R.string.ai_style_optional), style = MaterialTheme.typography.titleSmall) - TextButton( - onClick = { - currentTagCategory = "aiStyle" - showCustomTagDialog = true - } - ) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_icon)) - Text(stringResource(R.string.add_custom)) - } - } - FlowRow( - modifier = Modifier.fillMaxWidth(), - maxItemsInEachRow = 4, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // 预定义标签 - aiStyleOptions.forEach { option -> - val isSelected = selectedAiStyleTags.contains(option) - FilterChip( - selected = isSelected, - onClick = { - if (isSelected) { - selectedAiStyleTags = selectedAiStyleTags - option - } else { - selectedAiStyleTags = selectedAiStyleTags + option - } - }, - label = { Text(option) }, - modifier = Modifier.padding(vertical = 4.dp) - ) - } - - // 自定义标签 - customAiStyleTags.forEach { tag -> - FilterChip( - selected = selectedAiStyleTags.contains(tag), - onClick = { - if (selectedAiStyleTags.contains(tag)) { - selectedAiStyleTags = selectedAiStyleTags - tag - } else { - selectedAiStyleTags = selectedAiStyleTags + tag - } - }, - label = { Text(tag) }, - modifier = Modifier.padding(vertical = 4.dp), - trailingIcon = { - Icon( - Icons.Default.Close, - contentDescription = stringResource(R.string.delete_icon), - modifier = Modifier - .size(16.dp) - .clickable { - customAiStyleTags = customAiStyleTags - tag - selectedAiStyleTags = selectedAiStyleTags - tag - } - ) - } - ) - } - } - - Spacer(modifier = Modifier.weight(1f)) - - // 完成按钮 - Button( - onClick = { - scope.launch { - // 将选中的标签合并为字符串(包括自定义标签) - val personalityTags = selectedPersonality.joinToString(", ") - val identityTags = selectedIdentity.joinToString(", ") - val aiStyleTags = selectedAiStyleTags.joinToString(", ") - - // 更新配置信息 - if (profileId.isNotEmpty()) { - // 如果提供了profileId,更新指定的配置文件 - preferencesManager.updateProfileCategory( - profileId = profileId, - birthDate = birthDate, - gender = selectedGender, - occupation = selectedOccupation, - personality = personalityTags, - identity = identityTags, - aiStyle = aiStyleTags - ) - } else { - // 否则更新当前活动的配置文件 - preferencesManager.updateProfileCategory( - birthDate = birthDate, - gender = selectedGender, - occupation = selectedOccupation, - personality = personalityTags, - identity = identityTags, - aiStyle = aiStyleTags - ) - } - - // 根据流程选择不同的导航目标 - if (profileName.isNotEmpty()) { - // 如果是从配置页来的,返回到配置页 - onComplete() - } else { - // 如果是首次启动应用,进入权限页 - navigateToPermissions() - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { Text(stringResource(id = R.string.complete)) } - } - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/UserPreferencesSettingsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/UserPreferencesSettingsScreen.kt index 5086f87e1..e8ce459f3 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/UserPreferencesSettingsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/UserPreferencesSettingsScreen.kt @@ -1,740 +1,333 @@ package com.ai.assistance.operit.ui.features.settings.screens -import android.app.DatePickerDialog as AndroidDatePickerDialog -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* -import androidx.compose.material3.* -import androidx.compose.material3.LocalTextStyle -import androidx.compose.runtime.* +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Restore +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.ai.assistance.operit.ui.components.CustomScaffold -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalClipboardManager 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 androidx.compose.ui.unit.sp -import androidx.compose.ui.window.PopupProperties -import com.ai.assistance.operit.data.model.PreferenceProfile -import com.ai.assistance.operit.data.preferences.preferencesManager -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Date -import java.util.Locale -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.preferences.UserProfileDocumentRepository +import com.ai.assistance.operit.ui.common.displays.MarkdownTextComposable +import com.ai.assistance.operit.ui.components.CustomScaffold +import com.ai.assistance.operit.ui.features.settings.components.rememberMarkdownSyntaxOutputTransformation +import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun UserPreferencesSettingsScreen( - onNavigateBack: () -> Unit, - onNavigateToGuide: (String, String) -> Unit -) { - val scope = rememberCoroutineScope() +fun UserPreferencesSettingsScreen(onNavigateBack: () -> Unit) { val context = LocalContext.current - - // 获取所有配置文件 - val profileList by preferencesManager.profileListFlow.collectAsState(initial = emptyList()) - val activeProfileId by - preferencesManager.activeProfileIdFlow.collectAsState(initial = "default") - - // 下拉菜单状态 - var isDropdownExpanded by remember { mutableStateOf(false) } - - // 获取所有配置文件的名称映射(id -> name) - val profileNameMap = remember { mutableStateMapOf() } - - // 获取字符串资源 - val defaultProfileName = stringResource(R.string.default_profile) - - // 确保默认配置文件存在并在列表中显示 - LaunchedEffect(Unit) { - // 检查配置列表是否为空,或者不包含默认配置 - if (profileList.isEmpty() || !profileList.contains("default")) { - // 创建默认配置 - val defaultProfileId = preferencesManager.createProfile(defaultProfileName, isDefault = true) - preferencesManager.setActiveProfile(defaultProfileId) - } - } - - // 加载所有配置文件名称 - LaunchedEffect(profileList) { - profileList.forEach { profileId -> - val profile = preferencesManager.getUserPreferencesFlow(profileId).first() - profileNameMap[profileId] = profile.name - } - } - - // 分类锁定状态 - val categoryLockStatus by - preferencesManager.categoryLockStatusFlow.collectAsState(initial = emptyMap()) - - // 对话框状态 - var showAddProfileDialog by remember { mutableStateOf(false) } - var newProfileName by remember { mutableStateOf("") } - // 新增:删除确认弹窗状态 - var showDeleteProfileDialog by remember { mutableStateOf(false) } - // 新增:重命名弹窗状态 - var showRenameProfileDialog by remember { mutableStateOf(false) } - var editingProfileName by remember { mutableStateOf("") } - - // 选中的配置文件 - var selectedProfileId by remember { mutableStateOf(activeProfileId) } - var selectedProfile by remember { mutableStateOf(null) } - - // 编辑状态 - var editMode by remember { mutableStateOf(false) } - var editBirthDate by remember { mutableStateOf(0L) } - var editGender by remember { mutableStateOf("") } - var editPersonality by remember { mutableStateOf("") } - var editIdentity by remember { mutableStateOf("") } - var editOccupation by remember { mutableStateOf("") } - var editAiStyle by remember { mutableStateOf("") } - - // 日期选择器状态 - val dateFormatter = SimpleDateFormat(stringResource(R.string.date_format_chinese), Locale.getDefault()) - - // 动画状态 - val listState = rememberLazyListState() - - // 加载选中的配置文件 - LaunchedEffect(selectedProfileId) { - preferencesManager.getUserPreferencesFlow(selectedProfileId).collect { profile -> - selectedProfile = profile - // 初始化编辑字段 - editBirthDate = profile.birthDate - editGender = profile.gender - editPersonality = profile.personality - editIdentity = profile.identity - editOccupation = profile.occupation - editAiStyle = profile.aiStyle - } - } - - // 保存用户偏好配置函数 - fun saveUserPreferences() { - scope.launch { - selectedProfile?.let { profile -> - preferencesManager.updateProfileCategory( - profileId = profile.id, - birthDate = editBirthDate, - gender = editGender.takeIf { it.isNotBlank() }, - personality = editPersonality.takeIf { it.isNotBlank() }, - identity = editIdentity.takeIf { it.isNotBlank() }, - occupation = editOccupation.takeIf { it.isNotBlank() }, - aiStyle = editAiStyle.takeIf { it.isNotBlank() } - ) - editMode = false + val repository = remember(context) { UserProfileDocumentRepository.getInstance(context) } + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + // State-based input commits a touch selection before focus-driven scrolling. The value-based + // field can instead bring the stale cursor at the document start back into view. + val draftEditorState = rememberTextFieldState() + val editorScrollState = rememberScrollState() + val markdownSyntaxOutputTransformation = rememberMarkdownSyntaxOutputTransformation() + var savedMarkdown by remember { mutableStateOf("") } + var selectedTab by remember { mutableIntStateOf(0) } + var loading by remember { mutableStateOf(true) } + var saving by remember { mutableStateOf(false) } + var showDiscardDialog by remember { mutableStateOf(false) } + var showResetDialog by remember { mutableStateOf(false) } + var showMoreMenu by remember { mutableStateOf(false) } + var archiveMarkdown by remember { mutableStateOf(null) } + var archiveSheetMarkdown by remember { mutableStateOf(null) } + + val draftMarkdown = draftEditorState.text.toString() + val hasUnsavedChanges = draftMarkdown != savedMarkdown + val exceedsLimit = draftMarkdown.length > UserProfileDocumentRepository.MAX_CONTENT_CHARS + + LaunchedEffect(repository) { + try { + val loadedMarkdown = repository.load() + savedMarkdown = loadedMarkdown + draftEditorState.edit { + replace(0, length, loadedMarkdown) + selection = TextRange(0) } + archiveMarkdown = repository.readLegacyArchive() + } catch (error: Exception) { + snackbarHostState.showSnackbar(error.message ?: error.javaClass.simpleName) + } finally { + loading = false } } - // 日期选择器函数 - val showDatePickerDialog = { - val calendar = - Calendar.getInstance().apply { - if (editBirthDate > 0) { - timeInMillis = editBirthDate - } else { - set(Calendar.YEAR, 1990) - set(Calendar.MONTH, Calendar.JANUARY) - set(Calendar.DAY_OF_MONTH, 1) - } - } - - val year = calendar.get(Calendar.YEAR) - val month = calendar.get(Calendar.MONTH) - val day = calendar.get(Calendar.DAY_OF_MONTH) - - AndroidDatePickerDialog( - context, - { _, selectedYear, selectedMonth, selectedDay -> - val selectedCalendar = - Calendar.getInstance().apply { - set(Calendar.YEAR, selectedYear) - set(Calendar.MONTH, selectedMonth) - set(Calendar.DAY_OF_MONTH, selectedDay) - } - editBirthDate = selectedCalendar.timeInMillis - }, - year, - month, - day - ) - .show() + fun navigateBackSafely() { + if (hasUnsavedChanges) showDiscardDialog = true else onNavigateBack() } + BackHandler(onBack = ::navigateBackSafely) + CustomScaffold( - floatingActionButton = { - FloatingActionButton( - onClick = { - if (editMode) { - saveUserPreferences() - } else { - editMode = true - } - }, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - shape = CircleShape, - modifier = Modifier.size(48.dp) + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Box( + modifier = + Modifier.fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 12.dp, vertical = 10.dp) + ) { + Column( + modifier = + Modifier.align(Alignment.TopCenter) + .fillMaxHeight() + .widthIn(max = 840.dp) + .imePadding(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( - if (editMode) Icons.Default.Save else Icons.Default.Edit, - contentDescription = if (editMode) stringResource(R.string.save_action) else stringResource(R.string.edit_profile) + imageVector = Icons.Default.Description, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.user_md_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } - } - ) { paddingValues -> - Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 4.dp)) { - // 配置文件选择区域 - Card( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 12.dp), - shape = RoundedCornerShape(12.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - border = - BorderStroke( - 0.7.dp, - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceContainer ) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - // 水平分隔线 - 减小垂直间距 - HorizontalDivider( - thickness = 0.5.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - modifier = Modifier.padding(vertical = 4.dp) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FilterChip( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + label = { Text(stringResource(R.string.user_md_edit_tab)) } ) - - // 配置选择器区 - 标签和新建按钮放在一行 - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // 配置文件选择标签 + FilterChip( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + label = { Text(stringResource(R.string.user_md_preview_tab)) } + ) + Spacer(modifier = Modifier.weight(1f)) + if (hasUnsavedChanges) { Text( - stringResource(R.string.select_preference_profile), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, + text = stringResource(R.string.workspace_unsaved), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary ) - - // 新建按钮 - 更小的尺寸 - OutlinedButton( - onClick = { showAddProfileDialog = true }, - shape = RoundedCornerShape(16.dp), - border = - BorderStroke(0.8.dp, MaterialTheme.colorScheme.primary), - contentPadding = - PaddingValues(horizontal = 8.dp, vertical = 4.dp), - modifier = Modifier.height(28.dp), - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.primary - ) - ) { - Icon( - Icons.Default.Add, - contentDescription = null, - modifier = Modifier.size(14.dp) - ) - Spacer(modifier = Modifier.width(2.dp)) - Text( - stringResource(R.string.new_action), - fontSize = 12.sp, - style = MaterialTheme.typography.labelSmall - ) - } } - - val selectedProfileName = profileNameMap[selectedProfileId] ?: stringResource(R.string.default_profile) - val isActive = selectedProfileId == activeProfileId - - Surface( - modifier = - Modifier.fillMaxWidth().clickable { - isDropdownExpanded = true - }, - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - tonalElevation = 0.5.dp, - ) { - Row( - modifier = - Modifier.fillMaxWidth() - .padding(vertical = 12.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // 活跃状态指示 - if (isActive) { - Box( - modifier = - Modifier.size(8.dp) - .background( - MaterialTheme.colorScheme - .primary, - CircleShape - ) + FilledTonalButton( + onClick = { + scope.launch { + saving = true + try { + repository.save(draftMarkdown) + savedMarkdown = draftMarkdown + snackbarHostState.showSnackbar( + context.getString(R.string.save_successful) ) + } catch (error: Exception) { + snackbarHostState.showSnackbar( + error.message ?: error.javaClass.simpleName + ) + } finally { + saving = false } - - Text( - text = selectedProfileName, - style = MaterialTheme.typography.bodyLarge, - fontWeight = - if (isActive) FontWeight.Medium - else FontWeight.Normal, - color = - if (isActive) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurface - ) } - - AnimatedContent( - targetState = isDropdownExpanded, - transitionSpec = { - fadeIn() + scaleIn() with fadeOut() + scaleOut() - } - ) { expanded -> - Icon( - if (expanded) Icons.Default.KeyboardArrowUp - else Icons.Default.KeyboardArrowDown, - contentDescription = stringResource(R.string.select_config), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - // 操作按钮 - Row( - modifier = Modifier.padding(top = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + }, + enabled = hasUnsavedChanges && !exceedsLimit && !saving ) { - // 激活按钮 - if (!isActive) { - TextButton( - onClick = { - scope.launch { - preferencesManager.setActiveProfile( - selectedProfileId - ) - } - }, - contentPadding = PaddingValues(horizontal = 12.dp), - modifier = Modifier.height(36.dp) - ) { - Icon( - Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text(stringResource(R.string.set_active), fontSize = 14.sp) - } - } - - // 重命名按钮 - 只有非默认配置才显示 - if (selectedProfileId != "default") { - TextButton( - onClick = { - editingProfileName = selectedProfileName - showRenameProfileDialog = true - }, - contentPadding = PaddingValues(horizontal = 12.dp), - modifier = Modifier.height(36.dp) - ) { - Icon( - Icons.Default.Edit, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text(stringResource(R.string.rename_action), fontSize = 14.sp) - } - } - - // 删除按钮 - if (selectedProfileId != "default") { - TextButton( - onClick = { - showDeleteProfileDialog = true - }, - contentPadding = PaddingValues(horizontal = 12.dp), - colors = - ButtonDefaults.textButtonColors( - contentColor = - MaterialTheme.colorScheme.error - ), - modifier = Modifier.height(36.dp) - ) { - Icon( - Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text(stringResource(R.string.delete_action), fontSize = 14.sp) - } - } - } - - // 下拉菜单 - DropdownMenu( - expanded = isDropdownExpanded, - onDismissRequest = { isDropdownExpanded = false }, - modifier = Modifier.width(280.dp), - properties = PopupProperties(focusable = true) - ) { - profileList.forEach { profileId -> - val profileName = profileNameMap[profileId] ?: stringResource(R.string.unnamed_profile) - val isCurrentActive = profileId == activeProfileId - val isSelected = profileId == selectedProfileId - - DropdownMenuItem( - text = { - Text( - text = profileName, - fontWeight = - if (isSelected) FontWeight.SemiBold - else FontWeight.Normal, - color = - when { - isSelected -> - MaterialTheme.colorScheme - .primary - isCurrentActive -> - MaterialTheme.colorScheme - .primary.copy( - alpha = 0.8f - ) - else -> - MaterialTheme.colorScheme - .onSurface - } - ) - }, - leadingIcon = - if (isCurrentActive) { - { - Icon( - Icons.Default.Check, - contentDescription = null, - tint = - MaterialTheme.colorScheme - .primary, - modifier = Modifier.size(18.dp) - ) - } - } else null, - trailingIcon = - if (isSelected) { - { - Box( - modifier = - Modifier.size(8.dp) - .background( - MaterialTheme - .colorScheme - .primary, - CircleShape - ) - ) - } - } else null, - onClick = { - selectedProfileId = profileId - isDropdownExpanded = false - editMode = false - }, - colors = - MenuDefaults.itemColors( - textColor = - if (isSelected) - MaterialTheme.colorScheme - .primary - else - MaterialTheme.colorScheme - .onSurface - ), - modifier = Modifier.padding(horizontal = 4.dp) + if (saving) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp ) - - if (profileId != profileList.last()) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 8.dp), - thickness = 0.5.dp - ) - } + } else { + Icon(Icons.Default.Save, contentDescription = null) } + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.save_action)) } } } - Spacer(modifier = Modifier.height(4.dp)) - - // 配置文件详情 - AnimatedVisibility( - visible = selectedProfile != null, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically() + Surface( + modifier = Modifier.fillMaxWidth().weight(1f), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow ) { - selectedProfile?.let { profile -> - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) - ) { - Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { - // 标题和引导按钮 - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.Settings, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "${profile.name}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - } - - // 添加引导配置按钮 - if (!editMode) { - OutlinedButton( - onClick = { - onNavigateToGuide(profile.name, profile.id) - }, - shape = RoundedCornerShape(16.dp), - border = - BorderStroke( - 1.dp, - MaterialTheme.colorScheme.primary - ), - contentPadding = - PaddingValues( - horizontal = 10.dp, - vertical = 6.dp - ), - modifier = Modifier.height(32.dp) - ) { - Icon( - Icons.Default.Assistant, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(R.string.config_wizard), fontSize = 14.sp) + if (loading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + if (selectedTab == 0) { + BasicTextField( + state = draftEditorState, + scrollState = editorScrollState, + modifier = Modifier.fillMaxSize().padding(16.dp), + textStyle = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + fontFamily = FontFamily.Monospace + ), + outputTransformation = markdownSyntaxOutputTransformation, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorator = { innerTextField -> + Box(modifier = Modifier.fillMaxSize()) { + if (draftMarkdown.isEmpty()) { + Text( + text = stringResource(R.string.user_md_editor_placeholder), + style = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + innerTextField() + } } - } - } - - // 偏好分类项 - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - // 出生日期 - item { - ModernPreferenceCategoryItem( - title = stringResource(R.string.birth_date), - value = - if (profile.birthDate > 0) - dateFormatter.format( - Date(profile.birthDate) - ) - else stringResource(R.string.not_set), - editMode = editMode, - isLocked = categoryLockStatus["birthDate"] ?: false, - onLockChange = { locked -> - scope.launch { - preferencesManager.setCategoryLocked( - "birthDate", - locked - ) - } - }, - icon = Icons.Default.Cake, - onDatePickerClick = { - if (editMode && - !(categoryLockStatus[ - "birthDate"] - ?: false) - ) { - showDatePickerDialog() - } - }, - dateValue = editBirthDate - ) - } - - // 性别 - item { - ModernPreferenceCategoryItem( - title = stringResource(R.string.gender), - value = profile.gender.ifEmpty { stringResource(R.string.not_set) }, - editValue = editGender, - onValueChange = { editGender = it }, - isLocked = categoryLockStatus["gender"] ?: false, - onLockChange = { locked -> - scope.launch { - preferencesManager.setCategoryLocked( - "gender", - locked - ) - } - }, - editMode = editMode, - icon = Icons.Default.Face + ) + } else if (draftMarkdown.isBlank()) { + Text( + text = stringResource(R.string.user_md_preview_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.Center).padding(24.dp) + ) + } else { + Box( + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + MarkdownTextComposable( + text = draftMarkdown, + textColor = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth() ) } + } + } - // 性格特点 - item { - ModernPreferenceCategoryItem( - title = stringResource(R.string.personality_traits), - value = profile.personality.ifEmpty { stringResource(R.string.not_set) }, - editValue = editPersonality, - onValueChange = { editPersonality = it }, - isLocked = categoryLockStatus["personality"] - ?: false, - onLockChange = { locked -> - scope.launch { - preferencesManager.setCategoryLocked( - "personality", - locked - ) - } - }, - editMode = editMode, - icon = Icons.Default.Psychology + Row( + modifier = + Modifier.fillMaxWidth() + .padding(start = 16.dp, end = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = + "${draftMarkdown.length} / ${UserProfileDocumentRepository.MAX_CONTENT_CHARS}", + style = MaterialTheme.typography.labelMedium, + color = + if (exceedsLimit) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.weight(1f)) + Box { + IconButton(onClick = { showMoreMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more) ) } - - // 身份认同 - item { - ModernPreferenceCategoryItem( - title = stringResource(R.string.identity), - value = profile.identity.ifEmpty { stringResource(R.string.not_set) }, - editValue = editIdentity, - onValueChange = { editIdentity = it }, - isLocked = categoryLockStatus["identity"] ?: false, - onLockChange = { locked -> - scope.launch { - preferencesManager.setCategoryLocked( - "identity", - locked - ) - } - }, - editMode = editMode, - icon = Icons.Default.Badge + DropdownMenu( + expanded = showMoreMenu, + onDismissRequest = { showMoreMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.user_md_reset)) }, + leadingIcon = { + Icon(Icons.Default.Restore, contentDescription = null) + }, + onClick = { + showMoreMenu = false + showResetDialog = true + } ) - } - - // 职业 - item { - ModernPreferenceCategoryItem( - title = stringResource(R.string.occupation), - value = profile.occupation.ifEmpty { stringResource(R.string.not_set) }, - editValue = editOccupation, - onValueChange = { editOccupation = it }, - isLocked = categoryLockStatus["occupation"] - ?: false, - onLockChange = { locked -> - scope.launch { - preferencesManager.setCategoryLocked( - "occupation", - locked - ) - } + archiveMarkdown?.let { archive -> + DropdownMenuItem( + text = { + Text(stringResource(R.string.user_md_legacy_archive)) }, - editMode = editMode, - icon = Icons.Default.Work - ) - } - - // AI风格偏好 - item { - ModernPreferenceCategoryItem( - title = stringResource(R.string.ai_style), - value = profile.aiStyle.ifEmpty { stringResource(R.string.not_set) }, - editValue = editAiStyle, - onValueChange = { editAiStyle = it }, - isLocked = categoryLockStatus["aiStyle"] ?: false, - onLockChange = { locked -> - scope.launch { - preferencesManager.setCategoryLocked( - "aiStyle", - locked - ) - } + leadingIcon = { + Icon(Icons.Default.Archive, contentDescription = null) }, - editMode = editMode, - icon = Icons.Default.SmartToy - ) - } - - // 保存按钮(编辑模式时显示) - if (editMode) { - item { - Button( - onClick = { - saveUserPreferences() - }, - modifier = - Modifier.fillMaxWidth() - .padding(top = 8.dp), - contentPadding = PaddingValues(vertical = 8.dp), - shape = RoundedCornerShape(8.dp) - ) { - Text( - stringResource(R.string.save_changes), - fontSize = 14.sp, - fontWeight = FontWeight.Bold - ) - } + onClick = { + showMoreMenu = false + archiveSheetMarkdown = archive + } + ) } } } @@ -744,511 +337,103 @@ fun UserPreferencesSettingsScreen( } } } - - // 新建配置文件对话框 - if (showAddProfileDialog) { - AlertDialog( - onDismissRequest = { - showAddProfileDialog = false - newProfileName = "" - }, - title = { - Text( - stringResource(R.string.new_preference_profile), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - }, - text = { - Column(modifier = Modifier.fillMaxWidth()) { - Text( - stringResource(R.string.create_new_profile_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = newProfileName, - onValueChange = { newProfileName = it }, - label = { Text(stringResource(R.string.profile_name), fontSize = 12.sp) }, - placeholder = { Text(stringResource(R.string.profile_name_placeholder), fontSize = 12.sp) }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = - MaterialTheme.colorScheme.primary, - unfocusedBorderColor = - MaterialTheme.colorScheme.outlineVariant - ), - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 14.sp) - ) - } - }, - confirmButton = { - Button( - onClick = { - if (newProfileName.isNotBlank()) { - scope.launch { - val newProfileId = - preferencesManager.createProfile(newProfileName) - selectedProfileId = newProfileId - showAddProfileDialog = false - - // 导航到引导页,传递配置ID和名称 - onNavigateToGuide(newProfileName, newProfileId) - } - } - }, - shape = RoundedCornerShape(8.dp) - ) { Text(stringResource(R.string.create_and_configure), fontSize = 13.sp) } - }, - dismissButton = { - TextButton( - onClick = { - showAddProfileDialog = false - newProfileName = "" - } - ) { Text(stringResource(R.string.cancel_action), fontSize = 13.sp) } - }, - shape = RoundedCornerShape(12.dp) - ) - } - // 新增:重命名配置文件弹窗 - if (showRenameProfileDialog) { - AlertDialog( - onDismissRequest = { - showRenameProfileDialog = false - editingProfileName = "" - }, - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.Edit, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(end = 8.dp).size(24.dp) - ) - Text( - stringResource(R.string.rename_profile), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - } - }, - text = { - Column(modifier = Modifier.fillMaxWidth()) { - Text( - stringResource(R.string.enter_new_profile_name), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = editingProfileName, - onValueChange = { editingProfileName = it }, - label = { Text(stringResource(R.string.profile_name), fontSize = 12.sp) }, - placeholder = { Text(stringResource(R.string.profile_name_placeholder), fontSize = 12.sp) }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant - ), - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontSize = 14.sp) - ) - } - }, - confirmButton = { - Button( - onClick = { - if (editingProfileName.isNotBlank()) { - scope.launch { - selectedProfile?.let { profile -> - val updatedProfile = profile.copy(name = editingProfileName) - preferencesManager.updateProfile(updatedProfile) - profileNameMap[selectedProfileId] = editingProfileName - } - showRenameProfileDialog = false - editingProfileName = "" - } - } - }, - shape = RoundedCornerShape(8.dp), - enabled = editingProfileName.isNotBlank() - ) { - Text(stringResource(R.string.confirm_rename), fontSize = 13.sp) - } - }, - dismissButton = { - TextButton( - onClick = { - showRenameProfileDialog = false - editingProfileName = "" - } - ) { - Text(stringResource(R.string.cancel_action), fontSize = 13.sp) - } - }, - shape = RoundedCornerShape(12.dp) - ) - } - // 新增:删除配置文件确认弹窗 - if (showDeleteProfileDialog) { - AlertDialog( - onDismissRequest = { showDeleteProfileDialog = false }, - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.Delete, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(end = 8.dp).size(24.dp) - ) - Text(stringResource(R.string.confirm_delete_profile)) - } - }, - text = { - Text(stringResource(R.string.delete_profile_warning)) - }, - confirmButton = { - TextButton( - onClick = { - showDeleteProfileDialog = false - scope.launch { - preferencesManager.deleteProfile(selectedProfileId) - selectedProfileId = activeProfileId - } - } - ) { Text(stringResource(R.string.confirm_delete)) } - }, - dismissButton = { - TextButton(onClick = { showDeleteProfileDialog = false }) { Text(stringResource(R.string.cancel_action)) } - }, - shape = RoundedCornerShape(12.dp) - ) - } } -} - -@Composable -fun ProfileItem( - profileName: String, - isActive: Boolean, - isSelected: Boolean, - onSelect: () -> Unit, - onActivate: () -> Unit, - onDelete: (() -> Unit)? = null -) { - Surface( - modifier = Modifier.fillMaxWidth().height(50.dp).clickable(onClick = onSelect), - shape = RoundedCornerShape(8.dp), - color = - when { - isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f) - isActive -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f) - else -> MaterialTheme.colorScheme.surface - }, - border = - BorderStroke( - width = if (isSelected) 1.dp else 0.dp, - color = - if (isSelected) MaterialTheme.colorScheme.primary - else Color.Transparent - ) - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = isSelected, - onClick = onSelect, - colors = - RadioButtonDefaults.colors( - selectedColor = MaterialTheme.colorScheme.primary, - unselectedColor = MaterialTheme.colorScheme.outline - ), - modifier = Modifier.size(36.dp) - ) - Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) { - Text( - text = profileName, - style = MaterialTheme.typography.bodyLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - fontSize = 16.sp, - color = - if (isSelected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - if (isActive) { - Text( - text = stringResource(R.string.currently_active), - style = MaterialTheme.typography.labelSmall, - fontSize = 12.sp, - color = MaterialTheme.colorScheme.primary - ) + if (showDiscardDialog) { + AlertDialog( + onDismissRequest = { showDiscardDialog = false }, + title = { Text(stringResource(R.string.user_md_unsaved_title)) }, + text = { Text(stringResource(R.string.user_md_unsaved_message)) }, + confirmButton = { + TextButton(onClick = onNavigateBack) { + Text(stringResource(R.string.user_md_discard)) } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - if (!isActive) { - OutlinedButton( - onClick = onActivate, - shape = RoundedCornerShape(12.dp), - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary), - modifier = Modifier.height(28.dp) - ) { Text(stringResource(R.string.activate_action), style = MaterialTheme.typography.labelMedium, fontSize = 13.sp) } + }, + dismissButton = { + TextButton(onClick = { showDiscardDialog = false }) { + Text(stringResource(R.string.cancel_action)) } + } + ) + } - if (onDelete != null) { - IconButton( - onClick = onDelete, - modifier = Modifier.size(28.dp).clip(CircleShape) - ) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(R.string.delete_action), - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(18.dp) - ) + if (showResetDialog) { + AlertDialog( + onDismissRequest = { showResetDialog = false }, + title = { Text(stringResource(R.string.user_md_reset_title)) }, + text = { Text(stringResource(R.string.user_md_reset_message)) }, + confirmButton = { + TextButton( + onClick = { + draftEditorState.edit { + replace(0, length, UserProfileDocumentRepository.DEFAULT_TEMPLATE) + selection = TextRange(0) + } + selectedTab = 0 + showResetDialog = false } + ) { + Text(stringResource(R.string.user_md_reset)) + } + }, + dismissButton = { + TextButton(onClick = { showResetDialog = false }) { + Text(stringResource(R.string.cancel_action)) } } - } + ) } -} -@Composable -fun ModernPreferenceCategoryItem( - title: String, - value: String, - editValue: String = "", - onValueChange: (String) -> Unit = {}, - isLocked: Boolean, - onLockChange: (Boolean) -> Unit, - editMode: Boolean, - isNumeric: Boolean = false, - icon: androidx.compose.ui.graphics.vector.ImageVector, - placeholder: String = stringResource(R.string.input_field_placeholder, title), - dateValue: Long = 0L, - onDatePickerClick: () -> Unit = {} -) { - val animatedElevation by - animateDpAsState( - targetValue = if (editMode && !isLocked) 2.dp else 0.dp, - label = "elevation" - ) - - Surface( - modifier = + archiveSheetMarkdown?.let { archive -> + val clipboardManager = LocalClipboardManager.current + val archiveScrollState = rememberScrollState() + ModalBottomSheet(onDismissRequest = { archiveSheetMarkdown = null }) { + Column( + modifier = Modifier.fillMaxWidth() - .shadow( - elevation = animatedElevation, - shape = RoundedCornerShape(8.dp) - ), - shape = RoundedCornerShape(8.dp), - color = - if (isLocked) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f) - else MaterialTheme.colorScheme.surface, - border = - BorderStroke( - width = if (editMode && !isLocked) 1.dp else 0.dp, - color = - if (editMode && !isLocked) MaterialTheme.colorScheme.primary - else Color.Transparent - ) - ) { - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + .fillMaxHeight(0.85f) + .padding(horizontal = 20.dp) + .padding(bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) + Text( + text = UserProfileDocumentRepository.LEGACY_ARCHIVE_FILE_NAME, + style = MaterialTheme.typography.titleLarge + ) + SelectionContainer( + modifier = + Modifier.fillMaxWidth() + .weight(1f) + .verticalScroll(archiveScrollState) + ) { Text( - text = title, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyLarge, - fontSize = 16.sp + text = archive, + style = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth() ) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End ) { - Icon( - if (isLocked) Icons.Default.Lock else Icons.Default.LockOpen, - contentDescription = if (isLocked) stringResource(R.string.locked) else stringResource(R.string.unlocked), - tint = - if (isLocked) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.outline, - modifier = Modifier.size(18.dp) - ) - - Switch( - checked = isLocked, - onCheckedChange = onLockChange, - modifier = Modifier.scale(0.8f), - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.onPrimary, - checkedTrackColor = MaterialTheme.colorScheme.primary, - checkedBorderColor = MaterialTheme.colorScheme.primary, - uncheckedThumbColor = - MaterialTheme.colorScheme.onSurfaceVariant, - uncheckedTrackColor = - MaterialTheme.colorScheme.surfaceVariant, - uncheckedBorderColor = MaterialTheme.colorScheme.outline - ) - ) - } - } - - Spacer(modifier = Modifier.height(4.dp)) - - AnimatedContent( - targetState = editMode, - transitionSpec = { - (fadeIn() + scaleIn(initialScale = 0.95f)).togetherWith(fadeOut()) - }, - label = "edit mode transition" - ) { isEditMode -> - if (isEditMode) { - if (title == stringResource(R.string.birth_date)) { - // 出生日期使用点击卡片打开日期选择器 - Card( - modifier = - Modifier.fillMaxWidth() - .height(50.dp) - .clickable( - enabled = !isLocked, - onClick = onDatePickerClick - ), - shape = RoundedCornerShape(6.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = - MaterialTheme.colorScheme.surfaceVariant - .copy(alpha = 0.8f) - ) - ) { - Row( - modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = - if (dateValue > 0) - SimpleDateFormat( - stringResource(R.string.date_format_chinese), - Locale.getDefault() - ) - .format(Date(dateValue)) - else stringResource(R.string.select_birth_date), - style = MaterialTheme.typography.bodyLarge, - color = - if (isLocked) - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.6f - ) - else MaterialTheme.colorScheme.onSurface - ) - Icon( - Icons.Default.CalendarMonth, - contentDescription = stringResource(R.string.select_date), - tint = - if (isLocked) - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.6f - ) - else MaterialTheme.colorScheme.primary - ) - } + TextButton( + onClick = { + clipboardManager.setText(AnnotatedString(archive)) + Toast.makeText( + context, + context.getString(R.string.copied_to_clipboard), + Toast.LENGTH_SHORT + ).show() } - } else { - OutlinedTextField( - value = editValue, - onValueChange = { - if (isNumeric) { - if (it.all { char -> char.isDigit() } || it.isEmpty()) { - onValueChange(it) - } - } else { - onValueChange(it) - } - }, - modifier = Modifier.fillMaxWidth().height(50.dp), - enabled = !isLocked, - textStyle = LocalTextStyle.current.copy(fontSize = 16.sp), - shape = RoundedCornerShape(6.dp), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = - MaterialTheme.colorScheme.primary, - unfocusedBorderColor = - MaterialTheme.colorScheme.outlineVariant, - disabledBorderColor = - MaterialTheme.colorScheme.outlineVariant - .copy(alpha = 0.5f), - disabledTextColor = - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.6f - ) - ), - placeholder = { - Text( - placeholder, - color = - MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = 0.6f - ), - fontSize = 16.sp - ) - } - ) + ) { + Icon(Icons.Default.ContentCopy, contentDescription = null) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.copy_content)) } - } else { - val displayText = - if (value == stringResource(R.string.not_set)) { - stringResource(R.string.not_set_field, title) - } else { - value - } - - Text( - text = displayText, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(vertical = 4.dp, horizontal = 2.dp), - color = - if (value == stringResource(R.string.not_set)) - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - else MaterialTheme.colorScheme.onSurface, - fontSize = 16.sp, - lineHeight = 22.sp - ) } } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/MainActivity.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/MainActivity.kt index 219f2f6d7..8d2d1cf8f 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/MainActivity.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/MainActivity.kt @@ -39,7 +39,6 @@ import com.ai.assistance.operit.core.application.OperitApplication import com.ai.assistance.operit.core.tools.AIToolHandler import com.ai.assistance.operit.data.preferences.AgreementPreferences import com.ai.assistance.operit.data.preferences.DisplayPreferencesManager -import com.ai.assistance.operit.data.preferences.UserPreferencesManager import com.ai.assistance.operit.data.preferences.androidPermissionPreferences import com.ai.assistance.operit.data.repository.ChatHistoryManager import com.ai.assistance.operit.data.updates.UpdateManager @@ -82,15 +81,11 @@ class MainActivity : ComponentActivity() { // ======== 工具和管理器 ======== private lateinit var toolHandler: AIToolHandler - private lateinit var preferencesManager: UserPreferencesManager private lateinit var agreementPreferences: AgreementPreferences private var updateCheckPerformed = false private lateinit var anrMonitor: AnrMonitor private lateinit var mcpRepository: MCPRepository - // ======== 导航状态 ======== - private var showPreferencesGuide by mutableStateOf(false) - // ======== MCP插件状态 ======== private val pluginLoadingState = PluginLoadingState() @@ -204,7 +199,6 @@ class MainActivity : ComponentActivity() { initializeComponents() anrMonitor.start() - setupPreferencesListener() configureDisplaySettings() // 设置上下文以便获取插件元数据 @@ -563,14 +557,6 @@ class MainActivity : ComponentActivity() { anrMonitor = AnrMonitor(this, lifecycleScope) - // 初始化用户偏好管理器并直接检查初始化状态 - preferencesManager = UserPreferencesManager.getInstance(this) - showPreferencesGuide = !preferencesManager.isPreferencesInitialized() - AppLogger.d( - TAG, - "初始化检查: 用户偏好已初始化=${!showPreferencesGuide},将${if(showPreferencesGuide) "" else "不"}显示引导界面" - ) - // 初始化协议偏好管理器 agreementPreferences = AgreementPreferences(this) @@ -619,22 +605,6 @@ class MainActivity : ComponentActivity() { ) } - // ======== 偏好监听器设置 ======== - private fun setupPreferencesListener() { - // 监听偏好变化 - lifecycleScope.launch { - preferencesManager.getUserPreferencesFlow().collect { profile -> - // 只有当状态变化时才更新UI - val newValue = !profile.isInitialized - if (showPreferencesGuide != newValue) { - AppLogger.d(TAG, "偏好变更: 从 $showPreferencesGuide 变为 $newValue") - showPreferencesGuide = newValue - setAppContent() - } - } - } - } - // ======== 显示与性能配置 ======== private fun configureDisplaySettings() { // 1. 请求持续的高性能模式 (API 31+) @@ -714,15 +684,12 @@ class MainActivity : ComponentActivity() { // 处理待处理的分享文件 processPendingSharedFiles() processPendingSharedText() - val shortcutNavItem = if (!showPreferencesGuide) pendingShortcutNavItem else null - val shortcutNavRequestId = - if (!showPreferencesGuide) pendingShortcutRequestId else 0L - val routeNavRequest = if (!showPreferencesGuide) pendingRouteId else null - val routeNavArgs = if (!showPreferencesGuide) pendingRouteArgs else emptyMap() - val routeNavRequestId = - if (!showPreferencesGuide) pendingRouteRequestId else 0L + val shortcutNavItem = pendingShortcutNavItem + val shortcutNavRequestId = pendingShortcutRequestId + val routeNavRequest = pendingRouteId + val routeNavArgs = pendingRouteArgs + val routeNavRequestId = pendingRouteRequestId val initialNavItem = when { - showPreferencesGuide -> NavItem.UserPreferencesGuide shortcutNavItem != null -> shortcutNavItem else -> currentMainNavItem } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt index ff1f9ee2a..ea41bbf0c 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt @@ -62,7 +62,6 @@ import com.ai.assistance.operit.ui.features.settings.screens.SettingsScreen import com.ai.assistance.operit.ui.features.settings.screens.SpeechServicesSettingsScreen import com.ai.assistance.operit.ui.features.settings.screens.ThemeSettingsScreen import com.ai.assistance.operit.ui.features.settings.screens.ToolPermissionSettingsScreen -import com.ai.assistance.operit.ui.features.settings.screens.UserPreferencesGuideScreen import com.ai.assistance.operit.ui.features.settings.screens.UserPreferencesSettingsScreen import com.ai.assistance.operit.ui.features.settings.screens.MnnModelDownloadScreen import com.ai.assistance.operit.ui.features.settings.screens.TokenUsageStatisticsScreen @@ -776,27 +775,6 @@ sealed class Screen( } } - data class UserPreferencesGuide(var profileName: String = "", var profileId: String = "") : - Screen(navItem = NavItem.Settings, titleRes = R.string.screen_title_user_preferences_guide) { - @Composable - override fun Content( - navController: NavController, - navigateTo: ScreenNavigationHandler, - onGoBack: () -> Unit, - hasBackgroundImage: Boolean, - onLoading: (Boolean) -> Unit, - onError: (String) -> Unit, - onGestureConsumed: (Boolean) -> Unit - ) { - UserPreferencesGuideScreen( - profileName = profileName, - profileId = profileId, - onComplete = onGoBack, - navigateToPermissions = { navigateTo(ShizukuCommands) } - ) - } - } - data object UserPreferencesSettings : Screen(navItem = NavItem.Settings, titleRes = R.string.screen_title_user_preferences_settings) { @Composable @@ -809,12 +787,7 @@ sealed class Screen( onError: (String) -> Unit, onGestureConsumed: (Boolean) -> Unit ) { - UserPreferencesSettingsScreen( - onNavigateBack = onGoBack, - onNavigateToGuide = { profileName, profileId -> - navigateTo(UserPreferencesGuide(profileName, profileId)) - } - ) + UserPreferencesSettingsScreen(onNavigateBack = onGoBack) } } @@ -1520,4 +1493,3 @@ object GestureStateHolder { var isChatScreenGestureConsumed: Boolean = false } - diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/screens/ScreenRouteRegistry.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/screens/ScreenRouteRegistry.kt index ff7246852..adc548bc4 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/screens/ScreenRouteRegistry.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/screens/ScreenRouteRegistry.kt @@ -356,11 +356,6 @@ object ScreenRouteRegistry { screen = Screen.ToolPermission, launchNavItem = NavItem.ToolPermissions ), - hostEntryDefinition( - entryId = "hidden.user_preferences_guide", - screen = Screen.UserPreferencesGuide(), - launchNavItem = NavItem.UserPreferencesGuide - ), hostEntryDefinition( entryId = "hidden.user_preferences_settings", screen = Screen.UserPreferencesSettings, diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index d243456bc..0206f176c 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -730,8 +730,8 @@ Non-streaming Tool Prompt Manager Manage Packages - Disable User Preference Description - When disabled, the system prompt no longer appends the "User preference description" section + Disable user.md + When disabled, user.md is not injected into the system prompt Pause Reading Resume Reading Stop Reading @@ -882,7 +882,7 @@ Personalization User Preferences - Configure personal information and preferences to help AI better understand you, including age, personality, identity, occupation, and expected AI style + Edit the user.md document sent to the AI as user context Configure Preferences Model Prompt Settings Customize AI assistant\'s system prompts, including self-introduction and tone style, to better meet your expectations @@ -996,7 +996,19 @@ Personalization User Preferences - Detailed User Preferences + User Profile (user.md) + The single user profile file. When enabled, this Markdown is sent to the AI as user context. + Edit + Preview + Clear document + Legacy profiles + Discard unsaved changes? + Your changes to user.md will be lost. + Discard + Clear user.md? + The current draft will be replaced with an empty document and takes effect after saving. + Describe your background, habits, and how you want the AI to communicate… + user.md is empty. Add some content to preview it here. Custom Introduction Enter your personal introduction (max 100 characters) Enter your personal introduction here... @@ -3159,7 +3171,13 @@ Prompt Select a configured prompt here, or click Manage Configuration below to create or modify prompts Memory - Memory selection includes user preferences and memory base under that preference. To create a new memory base, go to settings to create a new user preference and select it here + Select the isolated memory space used by the current conversation + Select memory space + Create memory space + Rename memory space + Delete memory space + Memory space name + Delete “%1$s” and all memories in it? This cannot be undone. Auto Save Memory When enabled, candidate content from the current conversation is queued after the reply is finalized, then written into the memory library by periodic background processing while the app process remains alive. Pending memory extraction items: %1$d, next save in %2$d minute(s) @@ -3432,7 +3450,7 @@ Long press to manage - Personal preferences and behavior settings + Edit and preview user.md Interface language switching Theme and appearance customization Layout Adjustment @@ -4013,7 +4031,7 @@ Memory Base Tool Permissions User Preferences Guide - User Preferences Settings + User Profile (user.md) Model & Parameters Configuration Speech Services Settings Persona Card Generation @@ -5410,7 +5428,7 @@ Occupation AI style , - Update user preferences: %1$s + Update user profile document: %1$s Create memory: %1$s Update memory: %1$s -> %2$s Delete memory: %1$s @@ -6991,7 +7009,7 @@ - + MMMM dd, yyyy diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 33822c52a..e5dbfcfa9 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -181,7 +181,19 @@ Configuración personalizada Preferencias de usuario - Configuración detallada de preferencias de usuario + Perfil de usuario (user.md) + El único archivo de perfil del usuario. Cuando está habilitado, este Markdown se envía a la IA como contexto. + Editar + Vista previa + Vaciar documento + Perfiles anteriores + ¿Descartar los cambios sin guardar? + Se perderán los cambios realizados en user.md. + Descartar + ¿Vaciar user.md? + El borrador actual se sustituirá por un documento vacío y se aplicará al guardarlo. + Describe tu contexto, tus hábitos y cómo quieres que se comunique la IA… + user.md está vacío. Añade contenido para previsualizarlo aquí. Descripción personalizada Ingrese su descripción personal (máximo 100 caracteres) Ingrese aquí su descripción personal... @@ -1998,8 +2010,8 @@ Gestión de herramientas Gestión de indicaciones de herramientas Gestionar paquetes - Deshabilitar descripción de preferencias del usuario - Al deshabilitarlo, ya no se adjuntará el párrafo "User preference description" en las indicaciones del sistema + Deshabilitar user.md + Al deshabilitarlo, user.md no se incluirá en el prompt del sistema Rama Chat padre: %1$s Confirmar reversión @@ -3141,7 +3153,7 @@ Ahora puede usar el modo AutoGLM en la interfaz de conversación. Prompt Seleccione un prompt ya configurado aquí, o haga clic en "Gestionar configuración" abajo para crear o modificar un prompt Memoria - La selección de memoria incluye las preferencias del usuario y la biblioteca de memoria asociada. Si desea una nueva biblioteca de memoria, vaya a configuración para crear una nueva preferencia de usuario y selecciónela aquí + Selecciona el espacio de memoria aislado usado por la conversación actual Actualización automática de memoria Al activarlo, cuando finalice la respuesta actual, el contenido valioso de esta conversación se guardará automáticamente en la biblioteca de memoria. Resumiendo memoria... @@ -3457,7 +3469,7 @@ Ahora puede usar el modo AutoGLM en la interfaz de conversación. Base de memoria Permisos de herramientas Guía de preferencias de usuario - Ajustes de preferencias de usuario + Perfil de usuario (user.md) Configuración del modelo y parámetros Ajustes de servicios de voz Generación de tarjeta de personaje @@ -5238,7 +5250,7 @@ Ahora puede usar el modo AutoGLM en la interfaz de conversación. Ocupación Estilo de IA - Actualizar preferencias de usuario: %1$s + Actualizar documento de perfil: %1$s Crear recuerdo: %1$s Actualizar recuerdo: %1$s -> %2$s Eliminar recuerdo: %1$s diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 5732f09e6..7116378ea 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -670,7 +670,19 @@ Gagal menginstal Shizuku bawaan, coba instal dari toko aplikasi Pengaturan personalisasi Preferensi pengguna - Pengaturan detail preferensi pengguna + Profil Pengguna (user.md) + Satu-satunya berkas profil pengguna. Saat diaktifkan, Markdown ini dikirim ke AI sebagai konteks pengguna. + Edit + Pratinjau + Kosongkan dokumen + Profil lama + Buang perubahan yang belum disimpan? + Perubahan pada user.md akan hilang. + Buang + Kosongkan user.md? + Draf saat ini akan diganti dengan dokumen kosong dan berlaku setelah disimpan. + Jelaskan latar belakang, kebiasaan, dan cara AI berkomunikasi dengan Anda… + user.md masih kosong. Tambahkan konten untuk melihat pratinjau. Deskripsi kustom Masukkan deskripsi pribadi Anda (maksimal 100 karakter) Masukkan deskripsi pribadi Anda di sini... @@ -1293,8 +1305,8 @@ Manajemen Alat Manajemen Prompt Alat Kelola Paket - Nonaktifkan Deskripsi Preferensi Pengguna - Setelah dinonaktifkan, paragraf "Deskripsi preferensi pengguna" tidak akan lagi ditambahkan ke prompt sistem + Nonaktifkan user.md + Jika dinonaktifkan, user.md tidak disisipkan ke prompt sistem Balas Jeda Pembacaan Lanjutkan Pembacaan @@ -2649,7 +2661,7 @@ Sekarang Anda dapat menggunakan mode AutoGLM di antarmuka percakapan. Prompt Pilih prompt yang sudah dikonfigurasi di sini, atau ketuk kelola konfigurasi di bawah untuk membuat atau mengubah prompt Memori - Pilihan memori mencakup preferensi pengguna dan bank memori di bawah preferensi tersebut. Jika ingin bank memori baru, buat preferensi pengguna baru di pengaturan dan pilih di sini + Pilih ruang memori terisolasi untuk percakapan saat ini Pembaruan Memori Otomatis Saat diaktifkan, setelah respons saat ini selesai, konten berharga dari percakapan ini akan otomatis disimpan ke bank memori. Meringkas memori... @@ -3336,7 +3348,7 @@ Sekarang Anda dapat menggunakan mode AutoGLM di antarmuka percakapan. Basis Memori Izin Alat Panduan Preferensi Pengguna - Pengaturan Preferensi Pengguna + Profil Pengguna (user.md) Konfigurasi Model & Parameter Pengaturan Layanan Ucapan Pembuatan Kartu Persona @@ -5048,7 +5060,7 @@ Sekarang Anda dapat menggunakan mode AutoGLM di antarmuka percakapan. Pekerjaan Gaya AI - Perbarui preferensi pengguna: %1$s + Perbarui dokumen profil pengguna: %1$s Buat memori: %1$s Perbarui memori: %1$s -> %2$s Hapus memori: %1$s diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 62e8a7c64..d78524126 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -863,7 +863,19 @@ 내장 Shizuku 설치에 실패했습니다. 앱 스토어에서 설치해 보세요. 개인화 설정 사용자 기본 설정 - 상세 사용자 기본 설정 + 사용자 프로필 (user.md) + 유일한 사용자 프로필 파일입니다. 활성화하면 이 Markdown이 사용자 컨텍스트로 AI에 전달됩니다. + 편집 + 미리보기 + 문서 비우기 + 이전 프로필 + 저장하지 않은 변경 사항을 버릴까요? + user.md의 변경 사항이 사라집니다. + 버리기 + user.md를 비울까요? + 현재 초안이 빈 문서로 바뀌며 저장 후 적용됩니다. + 배경, 습관, AI가 어떤 방식으로 대화하기를 원하는지 작성하세요… + user.md가 비어 있습니다. 내용을 추가하면 여기에서 미리 볼 수 있습니다. 맞춤형 소개 개인 소개를 입력하세요(최대 100자). 여기에 개인 소개를 입력하세요... @@ -1426,8 +1438,8 @@ 도구 관리 도구 프롬프트 관리자 패키지 관리 - 사용자 기본 설정 설명 비활성화 - 비활성화되면 시스템 프롬프트에 더 이상 "사용자 기본 설정 설명" 섹션이 추가되지 않습니다. + user.md 비활성화 + 비활성화하면 시스템 프롬프트에 user.md가 삽입되지 않습니다. 답글 읽기 일시 중지 읽기 재개 @@ -2751,7 +2763,7 @@ 프롬프트 여기에서 구성된 프롬프트를 선택하거나 아래의 구성 관리를 클릭하여 프롬프트를 생성하거나 수정합니다. 메모리 - 메모리 선택에는 사용자 기본 설정과 해당 기본 설정의 메모리 베이스가 포함됩니다. 새 메모리 베이스를 생성하려면 설정으로 이동하여 새 사용자 기본 설정을 생성하고 여기에서 선택하세요. + 현재 대화에서 사용할 독립 메모리 공간을 선택합니다. 메모리 자동 저장 활성화되면 현재 대화의 후보 콘텐츠는 응답이 완료된 후 대기열에 추가된 다음 앱 프로세스가 활성 상태로 유지되는 동안 주기적인 백그라운드 처리를 통해 메모리 라이브러리에 기록됩니다. 추출 대기 중인 메모리 항목: %1$d개, 다음 저장까지 %2$d분 @@ -3560,7 +3572,7 @@ 메모리 베이스 도구 권한 사용자 기본 설정 가이드 - 사용자 기본 설정 + 사용자 프로필 (user.md) 모델 및 매개변수 구성 음성 서비스 설정 페르소나 카드 생성 @@ -5123,7 +5135,7 @@ 직업 AI 스타일 , - 사용자 기본 설정 업데이트: %1$s + 사용자 프로필 문서 업데이트: %1$s 메모리 생성: %1$s 메모리 업데이트: %1$s -> %2$s 메모리 삭제: %1$s diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 4741d4c1d..25cf0d2ff 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1037,7 +1037,19 @@ Pemasangan Shizuku terbundel gagal, sila cuba pasang dari kedai aplikasi Tetapan peribadi Keutamaan pengguna - Tetapan terperinci keutamaan pengguna + Profil Pengguna (user.md) + Satu-satunya fail profil pengguna. Apabila didayakan, Markdown ini dihantar kepada AI sebagai konteks pengguna. + Edit + Pratonton + Kosongkan dokumen + Profil lama + Buang perubahan yang belum disimpan? + Perubahan pada user.md akan hilang. + Buang + Kosongkan user.md? + Draf semasa akan digantikan dengan dokumen kosong dan berkuat kuasa selepas disimpan. + Terangkan latar belakang, tabiat dan cara anda mahu AI berkomunikasi… + user.md masih kosong. Tambah kandungan untuk melihat pratonton. Perkenalan tersuai Sila masukkan perkenalan peribadi anda (maksimum 100 perkataan) Masukkan perkenalan peribadi anda di sini... @@ -1291,8 +1303,8 @@ Pengurusan Alat Pengurusan Petunjuk Alat Urus Pakej - Lumpuhkan Penerangan Keutamaan Pengguna - Apabila dilumpuhkan, perenggan "Penerangan Keutamaan Pengguna" tidak lagi dilampirkan pada petunjuk sistem + Lumpuhkan user.md + Apabila dilumpuhkan, user.md tidak disuntik ke dalam gesaan sistem Balas Jeda Bacaan Sambung Bacaan @@ -2602,7 +2614,7 @@ Kata kunci Pilih kata kunci yang telah dikonfigurasi di sini, atau klik pengurusan konfigurasi di bawah untuk mencipta atau mengubah suai kata kunci Ingatan - Pilihan ingatan termasuk keutamaan pengguna dan pangkalan ingatan di bawah keutamaan tersebut. Jika mahukan pangkalan ingatan baharu, pergi ke tetapan untuk mencipta keutamaan pengguna baharu dan pilih di sini + Pilih ruang ingatan berasingan untuk perbualan semasa Kemas Kini Ingatan Automatik Apabila dihidupkan, selepas respons semasa selesai, kandungan berharga daripada perbualan ini akan disimpan secara automatik ke pangkalan ingatan. Sedang meringkaskan ingatan... @@ -3583,7 +3595,7 @@ Kini anda boleh menggunakan mod AutoGLM di antara muka perbualan. Pangkalan Memori Kebenaran Alat Panduan Keutamaan Pengguna - Tetapan Keutamaan Pengguna + Profil Pengguna (user.md) Konfigurasi Model & Parameter Tetapan Perkhidmatan Pertuturan Penjanaan Kad Persona @@ -5047,7 +5059,7 @@ Kini anda boleh menggunakan mod AutoGLM di antara muka perbualan. Pekerjaan Gaya AI - Kemas kini keutamaan pengguna: %1$s + Kemas kini dokumen profil pengguna: %1$s Cipta ingatan: %1$s Kemas kini ingatan: %1$s -> %2$s Padam ingatan: %1$s diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ac18f3c95..eddb74173 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -853,7 +853,19 @@ A instalação do Shizuku integrado falhou. Tente instalar na App Store Personalização Preferências do usuário - Detalhes de preferência do usuário + Perfil do usuário (user.md) + O único arquivo de perfil do usuário. Quando ativado, este Markdown é enviado à IA como contexto. + Editar + Visualizar + Esvaziar documento + Perfis antigos + Descartar alterações não salvas? + As alterações em user.md serão perdidas. + Descartar + Esvaziar user.md? + O rascunho atual será substituído por um documento vazio e terá efeito após salvar. + Descreva seu contexto, hábitos e como deseja que a IA se comunique… + user.md está vazio. Adicione conteúdo para visualizá-lo aqui. Introdução personalizada Por favor, insira sua introdução pessoal (máximo de 100 palavras) Insira sua biografia aqui... @@ -1389,8 +1401,8 @@ Gerenciamento de ferramentas Gerenciamento de palavras com dicas de ferramentas pacote de gerenciamento - Desativar descrições de preferências do usuário - Após a desativação, o parágrafo "Descrição da preferência do usuário" não será mais anexado à palavra do prompt do sistema. + Desativar user.md + Quando desativado, user.md não é inserido no prompt do sistema. responder pausar leitura continuar leitura @@ -2654,7 +2666,7 @@ Agora é possível usar o modo AutoGLM na interface de diálogo. palavra alerta Selecione aqui uma palavra de prompt já configurada ou clique em Gerenciar configuração abaixo para criar ou modificar uma palavra de prompt. memória - A seleção de memória inclui as preferências do usuário e o banco de memória dessa preferência. Se você quiser um novo banco de memória, você pode ir nas configurações para criar uma nova preferência de usuário e selecioná-la aqui + Selecione o espaço de memória isolado usado pela conversa atual atualização automática de memória Quando ativado, após a resposta atual ser finalizada, o conteúdo valioso desta conversa será salvo automaticamente na biblioteca de memória. Resumindo memórias... @@ -3421,7 +3433,7 @@ Agora é possível usar o modo AutoGLM na interface de diálogo. banco de memória Permissões de ferramentas Orientação de preferência do usuário - Preferências do usuário + Perfil do usuário (user.md) Configuração de modelo e parâmetros Configurações do serviço de voz Geração de cartão de personalidade @@ -4700,7 +4712,7 @@ Agora é possível usar o modo AutoGLM na interface de diálogo. Profissão Estilo IA , - Atualizar preferências do usuário: %1$s + Atualizar documento de perfil: %1$s Criar memória: %1$s Atualizar memória: %1$s -> %2$s Excluir memória: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9bf6b40a0..cfe608f1c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -839,7 +839,7 @@ 个性化 用户偏好设置 - 配置个人信息和偏好,让AI更好地了解你,包括年龄、性格、身份、职业和期待的AI风格 + 编辑会作为用户上下文发送给 AI 的 user.md 配置用户偏好 模型提示词设置 自定义AI助手的系统提示词,包括自我介绍和语气风格,使AI更符合您的预期 @@ -953,7 +953,19 @@ 个性化设置 用户偏好设置 - 用户偏好详细设置 + 用户资料(user.md) + 唯一的用户资料文件。这里的 Markdown 会在启用时作为用户上下文发送给 AI。 + 编辑 + 预览 + 清空文档 + 旧档案 + 放弃未保存的修改? + 返回后,本次对 user.md 的修改将丢失。 + 放弃修改 + 清空 user.md? + 当前草稿会被替换为空白文档,保存后生效。 + 介绍你的背景、习惯,以及希望 AI 如何与你交流… + user.md 目前为空,填写内容后可在这里预览。 自定义介绍 请输入您的个人介绍(最多100字) 在此输入您的个人介绍... @@ -1597,8 +1609,8 @@ 非流式 工具提示词管理 管理包 - 禁用用户偏好描述 - 禁用后,系统提示词中不再附加“User preference description”段落 + 禁用 user.md + 禁用后,系统提示词中不再注入 user.md 用户资料 回复 暂停朗读 继续朗读 @@ -3070,7 +3082,13 @@ 提示词 在这里选择一个已经配置好的提示词,或者点击下方的管理配置去新建或修改提示词 记忆 - 记忆选择包括了用户偏好和该偏好下的记忆库。如果想要新的记忆库,可以去设置新建一个用户偏好并在这里选择 + 选择当前对话使用的独立记忆空间 + 选择记忆空间 + 新建记忆空间 + 重命名记忆空间 + 删除记忆空间 + 记忆空间名称 + 确定删除“%1$s”及其中的全部记忆吗?此操作无法撤销。 自动保存记忆 开启后,当前轮回复结束时会先把候选内容加入长期记忆队列,并在应用存活期间由后台定时整理写入记忆库。 待提取记忆的条数%1$d,距离下次保存%2$d分钟 @@ -3347,7 +3365,7 @@ 滚动到底部 - 个人偏好和行为设置 + 编辑和预览 user.md 界面语言切换 主题和外观定制 布局调整 @@ -3946,7 +3964,7 @@ 记忆库 工具权限 用户偏好引导 - 用户偏好设置 + 用户资料(user.md) 模型与参数配置 语音服务设置 人设卡生成 @@ -5832,7 +5850,7 @@ 职业 AI风格 - 更新用户偏好: %1$s + 更新用户资料文件:%1$s 创建记忆: %1$s 更新记忆: %1$s -> %2$s 删除记忆: %1$s @@ -7500,7 +7518,7 @@ - + yyyy年MM月dd日 diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/settings/components/MarkdownSyntaxHighlightingTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/settings/components/MarkdownSyntaxHighlightingTest.kt new file mode 100644 index 000000000..08a07bc0d --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/settings/components/MarkdownSyntaxHighlightingTest.kt @@ -0,0 +1,155 @@ +package com.ai.assistance.operit.ui.features.settings.components + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarkdownSyntaxHighlightingTest { + @Test + fun highlightsBlockMarkersAndHeadingText() { + val source = "# Heading\n> quote\n- [x] task\n---" + + assertEquals( + listOf( + HighlightedText(MarkdownSyntaxKind.MARKER, "#"), + HighlightedText(MarkdownSyntaxKind.HEADING, "Heading"), + HighlightedText(MarkdownSyntaxKind.QUOTE, "> "), + HighlightedText(MarkdownSyntaxKind.MARKER, "-"), + HighlightedText(MarkdownSyntaxKind.MARKER, "[x]"), + HighlightedText(MarkdownSyntaxKind.MARKER, "---"), + ), + source.highlightedText(), + ) + } + + @Test + fun givesCodeAndLinksPrecedenceOverNestedMarkers() { + val source = + "**bold** `*code*` [**link**](url) tag \\*plain*" + + assertEquals( + listOf( + HighlightedText(MarkdownSyntaxKind.EMPHASIS, "**bold**"), + HighlightedText(MarkdownSyntaxKind.CODE, "`*code*`"), + HighlightedText(MarkdownSyntaxKind.LINK, "[**link**](url)"), + HighlightedText(MarkdownSyntaxKind.HTML, ""), + HighlightedText(MarkdownSyntaxKind.HTML, ""), + HighlightedText(MarkdownSyntaxKind.LINK, ""), + ), + source.highlightedText(), + ) + } + + @Test + fun treatsFencedContentAsCode() { + val source = "```md\n# not heading\n**not emphasis**\n```\n# heading" + + assertEquals( + listOf( + HighlightedText(MarkdownSyntaxKind.CODE, "```md"), + HighlightedText(MarkdownSyntaxKind.CODE, "# not heading"), + HighlightedText(MarkdownSyntaxKind.CODE, "**not emphasis**"), + HighlightedText(MarkdownSyntaxKind.CODE, "```"), + HighlightedText(MarkdownSyntaxKind.MARKER, "#"), + HighlightedText(MarkdownSyntaxKind.HEADING, "heading"), + ), + source.highlightedText(), + ) + } + + @Test + fun rejectsBackticksInFenceInfoString() { + val source = "```lang`\n# heading" + + assertEquals( + listOf( + HighlightedText(MarkdownSyntaxKind.MARKER, "#"), + HighlightedText(MarkdownSyntaxKind.HEADING, "heading"), + ), + source.highlightedText(), + ) + } + + @Test + fun handlesUnclosedDelimitersWithoutProducingRanges() { + val source = "[a](".repeat(3_000) + "\na" + "*".repeat(12_000) + + assertTrue(source.highlightRanges().isEmpty()) + } + + @Test + fun continuesHighlightingAfterUnclosedLinkMarker() { + val source = "[ *bold* after" + + assertEquals( + listOf( + HighlightedText(MarkdownSyntaxKind.COMMENT, ""), + ), + source.highlightedText(), + ) + } + + @Test + fun emitsOrderedRangesForCrLfAndUnicode() { + val source = "## 你好 👋\r\n1. `值` | [链接](url)" + val ranges = source.highlightRanges() + + assertEquals( + listOf( + HighlightedText(MarkdownSyntaxKind.MARKER, "##"), + HighlightedText(MarkdownSyntaxKind.HEADING, "你好 👋"), + HighlightedText(MarkdownSyntaxKind.MARKER, "1."), + HighlightedText(MarkdownSyntaxKind.CODE, "`值`"), + HighlightedText(MarkdownSyntaxKind.MARKER, "|"), + HighlightedText(MarkdownSyntaxKind.LINK, "[链接](url)"), + ), + ranges.map { HighlightedText(it.kind, source.substring(it.start, it.end)) }, + ) + ranges.zipWithNext().forEach { (current, next) -> + assertTrue(current.end <= next.start) + } + ranges.forEach { range -> + assertTrue(range.start >= 0) + assertTrue(range.start < range.end) + assertTrue(range.end <= source.length) + } + } +} + +private data class HighlightRange( + val kind: MarkdownSyntaxKind, + val start: Int, + val end: Int, +) + +private data class HighlightedText( + val kind: MarkdownSyntaxKind, + val text: String, +) + +private fun String.highlightRanges(): List = + buildList { + scanMarkdownSyntax(this@highlightRanges) { kind, start, end -> + add(HighlightRange(kind, start, end)) + } + } + +private fun String.highlightedText(): List = + highlightRanges().map { range -> + HighlightedText(range.kind, substring(range.start, range.end)) + } diff --git a/docs/TODO/user_md_profile/1_StorageAndMigration.md b/docs/TODO/user_md_profile/1_StorageAndMigration.md new file mode 100644 index 000000000..38500a7ef --- /dev/null +++ b/docs/TODO/user_md_profile/1_StorageAndMigration.md @@ -0,0 +1,13 @@ +# Storage and migration + +Status: DONE + +The current DataStore profile payload contains both display metadata and structured user fields. Add a private UTF-8 `user.md` repository with atomic writes and a 12,000-character limit. + +For schema version 2 migration: + +- Convert the active legacy profile into `user.md` +- Export non-active structured profiles into `legacy-user-profiles.md` +- Rewrite legacy profile metadata as memory-space metadata while retaining identifiers +- Preserve the active identifier and every ObjectBox database +- Mark the migration complete only after file and DataStore writes succeed diff --git a/docs/TODO/user_md_profile/2_PromptAndTools.md b/docs/TODO/user_md_profile/2_PromptAndTools.md new file mode 100644 index 000000000..921c27541 --- /dev/null +++ b/docs/TODO/user_md_profile/2_PromptAndTools.md @@ -0,0 +1,7 @@ +# Prompt and tools + +Status: DONE + +Remove structured field formatting from conversation preparation. Inject the non-empty Markdown document inside a clearly delimited `user_profile` section when user-profile injection is enabled. + +Character-card prompts remain independent and cannot replace the global user document. Rename the preference update tool to a document-oriented user-profile tool and retain normal tool permission confirmation. Keep the released tool name as a hidden compatibility adapter for installed packages and persisted calls; it writes into `user.md` and is not exposed in new prompts. diff --git a/docs/TODO/user_md_profile/3_MarkdownSettingsUi.md b/docs/TODO/user_md_profile/3_MarkdownSettingsUi.md new file mode 100644 index 000000000..d28ffa3f5 --- /dev/null +++ b/docs/TODO/user_md_profile/3_MarkdownSettingsUi.md @@ -0,0 +1,20 @@ +# Markdown settings UI + +Status: DONE + +Replace profile selection, questionnaires, category locks, and onboarding with a single document screen. + +The screen provides: + +- A compact document toolbar for edit, preview, dirty state, and explicit save +- A low-contrast, monospace Markdown editor with localized empty-state guidance +- Theme-aware Markdown source coloring without changing the stored document +- Stable long-press selection and scrolling through coordinated text and scroll state +- Character count and limit inside the editor status bar +- Unsaved-change confirmation +- Reset-to-template confirmation +- Low-frequency reset and archive actions in an overflow menu +- Selectable raw legacy Markdown and one-click whole-archive copy in a large bottom sheet + +An untouched instructional template is normalized to an empty document so an empty profile is not +injected into the system prompt. Guidance remains presentation-only and is never stored in user.md. diff --git a/docs/TODO/user_md_profile/4_MemorySpaceCleanup.md b/docs/TODO/user_md_profile/4_MemorySpaceCleanup.md new file mode 100644 index 000000000..39c529109 --- /dev/null +++ b/docs/TODO/user_md_profile/4_MemorySpaceCleanup.md @@ -0,0 +1,7 @@ +# Memory-space cleanup + +Status: DONE + +Rename preference-profile concepts used by memory selection to memory spaces. Keep identifiers stable so ObjectBox databases require no bulk copy. + +Character-card fixed bindings continue to reference the same identifier under memory-space terminology. Remove structured preference models, lock keys, guide navigation, and profile-field update APIs after migration support is in place. diff --git a/docs/TODO/user_md_profile/index.md b/docs/TODO/user_md_profile/index.md new file mode 100644 index 000000000..9d5b51d85 --- /dev/null +++ b/docs/TODO/user_md_profile/index.md @@ -0,0 +1,32 @@ +--- +title: User Markdown Profile +fork: https://github.com/luojiaping/Operit +branch: agent/user-md-profile +status: complete +--- + +# User Markdown Profile + +## Current state + +The released application stores six structured user fields inside preference profiles. The same profile identifiers also select ObjectBox memory databases and can be bound to character cards. This couples the human user's identity, assistant roles, and memory isolation. + +## Intent + +Replace structured user preferences with one private `user.md` document. Keep multiple assistant roles and migrate the old profile identifiers into memory spaces so existing memories and character-card bindings remain valid. + +## Expected result + +- One editable and previewable `user.md` is the only user-profile source injected into prompts +- Character cards continue to define assistant personas +- Existing profile-backed memory databases become named memory spaces without moving their ObjectBox data +- The active legacy profile initializes `user.md`; other legacy profiles are preserved in a Markdown archive +- Legacy structured preference runtime code is removed after the one-time migration + +## Scope + +1. [Storage and migration](1_StorageAndMigration.md) — DONE +2. [Prompt and tools](2_PromptAndTools.md) — DONE +3. [Markdown settings UI](3_MarkdownSettingsUi.md) — DONE +4. [Memory-space cleanup](4_MemorySpaceCleanup.md) — DONE +5. Static source and call-site validation without compilation, build, or tests — DONE