From cffe4a52d652f53625cdbe6e087352fc2bcda088 Mon Sep 17 00:00:00 2001 From: renjianbo <263303411@qq.com> Date: Tue, 30 Jun 2026 02:23:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20agent=20memory=20management=20=E2=80=94?= =?UTF-8?q?=20CRUD=20API=20+=20Android=20management=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - New /api/v1/agents/{id}/memory endpoints: CRUD for global_knowledge, knowledge_entities, learning_patterns, vector_memories + import/export - Fix scope_id column overflow: 3 model columns expanded to hold compound keys (user_id:agent_id format, 73 chars vs old VARCHAR(36)) - Config: allow unknown env vars (extra="ignore") for optional overrides Android: - MemoryManageScreen: 4-tab UI (全局知识/知识实体/学习模式/对话记忆) with search, delete, and FAB to add new entries - Import/export via ShareSheet and file picker - AgentListScreen: long-press dropdown menu → 记忆管理 entry point - NavGraph: memory_manage/{agentId}/{agentName} route with URL encoding Co-Authored-By: Claude Opus 4.6 --- .../aiagent/data/remote/ApiService.kt | 94 +++ .../tiangong/aiagent/data/remote/SseClient.kt | 6 +- .../tiangong/aiagent/data/remote/dto/Dtos.kt | 99 +++ .../aiagent/ui/agents/AgentListScreen.kt | 33 +- .../tiangong/aiagent/ui/login/LoginScreen.kt | 64 +- .../aiagent/ui/login/LoginViewModel.kt | 35 +- .../aiagent/ui/memory/MemoryManageScreen.kt | 631 ++++++++++++++++ .../ui/memory/MemoryManageViewModel.kt | 220 ++++++ .../aiagent/ui/navigation/NavGraph.kt | 18 + backend/app/api/agent_memory.py | 680 ++++++++++++++++++ backend/app/core/config.py | 1 + backend/app/main.py | 3 +- backend/app/models/agent_learning_pattern.py | 2 +- backend/app/models/agent_vector_memory.py | 2 +- backend/app/models/persistent_user_memory.py | 2 +- 15 files changed, 1876 insertions(+), 14 deletions(-) create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/memory/MemoryManageScreen.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/memory/MemoryManageViewModel.kt create mode 100644 backend/app/api/agent_memory.py diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt index 41b7e4a..aff31bf 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt @@ -112,4 +112,98 @@ interface ApiService { @Path("agentId") agentId: String, @Query("limit") limit: Int = 50 ): SessionListResponse + + // ─── Agent Memory ─── + @GET("api/v1/agents/{agentId}/memory/global-knowledge") + suspend fun getGlobalKnowledge( + @Path("agentId") agentId: String, + @Query("skip") skip: Int = 0, + @Query("limit") limit: Int = 50, + @Query("search") search: String? = null + ): GlobalKnowledgeListResponse + + @POST("api/v1/agents/{agentId}/memory/global-knowledge") + suspend fun createGlobalKnowledge( + @Path("agentId") agentId: String, + @Body request: GlobalKnowledgeCreateRequest + ): GlobalKnowledgeDto + + @PUT("api/v1/agents/{agentId}/memory/global-knowledge/{knowledgeId}") + suspend fun updateGlobalKnowledge( + @Path("agentId") agentId: String, + @Path("knowledgeId") knowledgeId: String, + @Body request: GlobalKnowledgeCreateRequest + ): GlobalKnowledgeDto + + @DELETE("api/v1/agents/{agentId}/memory/global-knowledge/{knowledgeId}") + suspend fun deleteGlobalKnowledge( + @Path("agentId") agentId: String, + @Path("knowledgeId") knowledgeId: String + ): Response + + @GET("api/v1/agents/{agentId}/memory/knowledge-entities") + suspend fun getKnowledgeEntities( + @Path("agentId") agentId: String, + @Query("skip") skip: Int = 0, + @Query("limit") limit: Int = 50, + @Query("search") search: String? = null + ): KnowledgeEntityListResponse + + @POST("api/v1/agents/{agentId}/memory/knowledge-entities") + suspend fun createKnowledgeEntity( + @Path("agentId") agentId: String, + @Body request: KnowledgeEntityCreateRequest + ): KnowledgeEntityDto + + @PUT("api/v1/agents/{agentId}/memory/knowledge-entities/{entityId}") + suspend fun updateKnowledgeEntity( + @Path("agentId") agentId: String, + @Path("entityId") entityId: String, + @Body request: KnowledgeEntityCreateRequest + ): KnowledgeEntityDto + + @DELETE("api/v1/agents/{agentId}/memory/knowledge-entities/{entityId}") + suspend fun deleteKnowledgeEntity( + @Path("agentId") agentId: String, + @Path("entityId") entityId: String + ): Response + + @GET("api/v1/agents/{agentId}/memory/learning-patterns") + suspend fun getLearningPatterns( + @Path("agentId") agentId: String, + @Query("skip") skip: Int = 0, + @Query("limit") limit: Int = 50 + ): LearningPatternListResponse + + @DELETE("api/v1/agents/{agentId}/memory/learning-patterns/{patternId}") + suspend fun deleteLearningPattern( + @Path("agentId") agentId: String, + @Path("patternId") patternId: String + ): Response + + @POST("api/v1/agents/{agentId}/memory/export") + suspend fun exportMemory( + @Path("agentId") agentId: String + ): MemoryExportResponse + + @POST("api/v1/agents/{agentId}/memory/import") + suspend fun importMemory( + @Path("agentId") agentId: String, + @Body request: MemoryImportRequest + ): Response + + // ─── Vector Memories ─── + @GET("api/v1/agents/{agentId}/memory/vector-memories") + suspend fun getVectorMemories( + @Path("agentId") agentId: String, + @Query("skip") skip: Int = 0, + @Query("limit") limit: Int = 50, + @Query("search") search: String? = null + ): VectorMemoryListResponse + + @DELETE("api/v1/agents/{agentId}/memory/vector-memories/{memoryId}") + suspend fun deleteVectorMemory( + @Path("agentId") agentId: String, + @Path("memoryId") memoryId: String + ): Response } diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/SseClient.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/SseClient.kt index 93ca899..e5e83ee 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/SseClient.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/SseClient.kt @@ -60,11 +60,11 @@ class SseClient @Inject constructor( request: ChatRequest ): Flow = callbackFlow { // callbackFlow lambda is suspend — no runBlocking needed - val baseUrl = tokenDataStore.serverUrl.first() + val baseUrl = tokenDataStore.serverUrl.first().trimEnd('/') val url = if (agentId != null) { - "${baseUrl}api/v1/agent-chat/$agentId/stream" + "${baseUrl}/api/v1/agent-chat/$agentId/stream" } else { - "${baseUrl}api/v1/agent-chat/bare/stream" + "${baseUrl}/api/v1/agent-chat/bare/stream" } val jsonBody = gson.toJson(request) diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt index 469d461..70f099f 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt @@ -336,3 +336,102 @@ data class UploadResponse( val size: Int, @SerializedName("content_type") val contentType: String? = null ) + +// ─────────── Agent Memory ─────────── + +data class GlobalKnowledgeDto( + val id: String, + val content: String, + @SerializedName("source_agent_id") val sourceAgentId: String? = null, + val tags: List? = null, + val confidence: String = "medium", + @SerializedName("expires_at") val expiresAt: String? = null, + @SerializedName("created_at") val createdAt: String? = null +) + +data class GlobalKnowledgeListResponse( + val items: List, + val total: Int +) + +data class GlobalKnowledgeCreateRequest( + val content: String, + val tags: List? = null, + val confidence: String = "medium" +) + +data class KnowledgeEntityDto( + val id: String, + val name: String, + @SerializedName("entity_type") val entityType: String = "concept", + val description: String? = null, + val source: String = "extracted", + val confidence: String = "medium", + val tags: List? = null, + @SerializedName("created_at") val createdAt: String? = null +) + +data class KnowledgeEntityListResponse( + val items: List, + val total: Int +) + +data class KnowledgeEntityCreateRequest( + val name: String, + @SerializedName("entity_type") val entityType: String = "concept", + val description: String? = null, + val tags: List? = null, + val confidence: String = "medium" +) + +data class LearningPatternDto( + val id: String, + @SerializedName("task_category") val taskCategory: String = "", + @SerializedName("task_keywords") val taskKeywords: String? = null, + @SerializedName("suggested_tools") val suggestedTools: String? = null, + @SerializedName("effectiveness_score") val effectivenessScore: Double = 0.0, + @SerializedName("total_runs") val totalRuns: Int = 0, + @SerializedName("successful_runs") val successfulRuns: Int = 0, + @SerializedName("avg_iterations") val avgIterations: Double = 0.0, + @SerializedName("avg_tool_calls") val avgToolCalls: Double = 0.0, + @SerializedName("last_used_at") val lastUsedAt: String? = null, + @SerializedName("created_at") val createdAt: String? = null +) + +data class LearningPatternListResponse( + val items: List, + val total: Int +) + +data class MemoryExportResponse( + @SerializedName("agent_id") val agentId: String, + @SerializedName("exported_at") val exportedAt: String, + @SerializedName("global_knowledge") val globalKnowledge: List> = emptyList(), + @SerializedName("knowledge_entities") val knowledgeEntities: List> = emptyList(), + @SerializedName("knowledge_relations") val knowledgeRelations: List> = emptyList(), + @SerializedName("learning_patterns") val learningPatterns: List> = emptyList(), + @SerializedName("vector_memories") val vectorMemories: List> = emptyList() +) + +data class MemoryImportRequest( + @SerializedName("global_knowledge") val globalKnowledge: List> = emptyList(), + @SerializedName("knowledge_entities") val knowledgeEntities: List> = emptyList(), + @SerializedName("knowledge_relations") val knowledgeRelations: List> = emptyList(), + @SerializedName("learning_patterns") val learningPatterns: List> = emptyList(), + @SerializedName("vector_memories") val vectorMemories: List> = emptyList() +) + +// ─────────── Vector Memories ─────────── + +data class VectorMemoryDto( + val id: String, + @SerializedName("content_text") val contentText: String, + @SerializedName("session_key") val sessionKey: String = "", + val metadata: Map? = null, + @SerializedName("created_at") val createdAt: String? = null +) + +data class VectorMemoryListResponse( + val items: List, + val total: Int +) diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt index dd2bce7..f749a3e 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt @@ -23,6 +23,7 @@ import com.tiangong.aiagent.ui.common.SkeletonAgentList fun AgentListScreen( onBack: () -> Unit, onAgentSelected: (com.tiangong.aiagent.domain.model.Agent) -> Unit, + onManageMemory: (agentId: String, agentName: String) -> Unit = { _, _ -> }, viewModel: AgentListViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -108,12 +109,34 @@ fun AgentListScreen( contentPadding = PaddingValues(vertical = 8.dp) ) { items(uiState.agents) { agent -> + var showMenu by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } - AgentListItem( - agent = agent, - onClick = { onAgentSelected(agent) }, - onLongClick = { showDeleteDialog = true } - ) + Box { + AgentListItem( + agent = agent, + onClick = { onAgentSelected(agent) }, + onLongClick = { showMenu = true } + ) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text("记忆管理") }, + onClick = { + showMenu = false + onManageMemory(agent.id, agent.name) + } + ) + DropdownMenuItem( + text = { Text("删除", color = MaterialTheme.colorScheme.error) }, + onClick = { + showMenu = false + showDeleteDialog = true + } + ) + } + } if (showDeleteDialog) { AlertDialog( onDismissRequest = { showDeleteDialog = false }, diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt index d048816..f1eac1a 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.* @@ -33,6 +34,8 @@ fun LoginScreen( val uiState by viewModel.uiState.collectAsState() val focusManager = LocalFocusManager.current var passwordVisible by remember { mutableStateOf(false) } + var isEditingUrl by remember { mutableStateOf(false) } + var editedUrl by remember { mutableStateOf("") } LaunchedEffect(uiState.isLoggedIn) { if (uiState.isLoggedIn) { @@ -68,7 +71,66 @@ fun LoginScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(48.dp)) + Spacer(modifier = Modifier.height(36.dp)) + + // Server URL + if (isEditingUrl) { + OutlinedTextField( + value = editedUrl, + onValueChange = { editedUrl = it }, + label = { Text("服务器地址") }, + placeholder = { Text("http://101.43.95.130:8037") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + enabled = !uiState.isLoading, + supportingText = { Text("输入后点击保存,设置即时生效") } + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = { isEditingUrl = false }, + enabled = !uiState.isLoading + ) { + Text("取消") + } + Spacer(modifier = Modifier.width(8.dp)) + TextButton( + onClick = { + viewModel.saveServerUrl(editedUrl) + isEditingUrl = false + }, + enabled = !uiState.isLoading + ) { + Text("保存") + } + } + } else { + TextButton( + onClick = { + editedUrl = uiState.serverUrl + isEditingUrl = true + }, + enabled = !uiState.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = if (uiState.serverUrl.isNotBlank()) uiState.serverUrl else "点击配置服务器地址", + style = MaterialTheme.typography.bodySmall, + maxLines = 1 + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) // Username OutlinedTextField( diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginViewModel.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginViewModel.kt index c91565c..cf1f0c4 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginViewModel.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginViewModel.kt @@ -2,6 +2,8 @@ package com.tiangong.aiagent.ui.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.tiangong.aiagent.data.local.TokenDataStore +import com.tiangong.aiagent.data.remote.DynamicUrlInterceptor import com.tiangong.aiagent.data.repository.AuthRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -13,6 +15,7 @@ import javax.inject.Inject data class LoginUiState( val username: String = "", val password: String = "", + val serverUrl: String = "", val isLoading: Boolean = false, val error: String? = null, val isLoggedIn: Boolean = false @@ -20,12 +23,21 @@ data class LoginUiState( @HiltViewModel class LoginViewModel @Inject constructor( - private val authRepository: AuthRepository + private val authRepository: AuthRepository, + private val tokenDataStore: TokenDataStore, + private val dynamicUrlInterceptor: DynamicUrlInterceptor ) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) val uiState: StateFlow = _uiState.asStateFlow() + init { + viewModelScope.launch { + val url = tokenDataStore.getServerUrl() ?: "" + _uiState.value = _uiState.value.copy(serverUrl = url) + } + } + fun onUsernameChange(username: String) { _uiState.value = _uiState.value.copy(username = username, error = null) } @@ -34,6 +46,27 @@ class LoginViewModel @Inject constructor( _uiState.value = _uiState.value.copy(password = password, error = null) } + fun onServerUrlChange(url: String) { + _uiState.value = _uiState.value.copy(serverUrl = url, error = null) + } + + fun saveServerUrl(url: String) { + val trimmed = url.trim().trimEnd('/') + if (trimmed.isBlank()) { + _uiState.value = _uiState.value.copy(error = "服务器地址不能为空") + return + } + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + _uiState.value = _uiState.value.copy(error = "服务器地址必须以 http:// 或 https:// 开头") + return + } + viewModelScope.launch { + tokenDataStore.saveServerUrl(trimmed) + dynamicUrlInterceptor.updateBaseUrl(trimmed) + _uiState.value = _uiState.value.copy(serverUrl = trimmed, error = null) + } + } + fun login() { val state = _uiState.value if (state.username.isBlank() || state.password.isBlank()) { diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/memory/MemoryManageScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/memory/MemoryManageScreen.kt new file mode 100644 index 0000000..b90b68d --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/memory/MemoryManageScreen.kt @@ -0,0 +1,631 @@ +package com.tiangong.aiagent.ui.memory + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.hilt.navigation.compose.hiltViewModel +import com.tiangong.aiagent.data.remote.dto.GlobalKnowledgeDto +import com.tiangong.aiagent.data.remote.dto.KnowledgeEntityDto +import com.tiangong.aiagent.data.remote.dto.LearningPatternDto + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MemoryManageScreen( + agentId: String, + agentName: String, + onBack: () -> Unit, + viewModel: MemoryManageViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + var showDeleteDialog by remember { mutableStateOf(null) } + var showDeleteEntityDialog by remember { mutableStateOf(null) } + var showDeletePatternDialog by remember { mutableStateOf(null) } + var showDeleteVectorDialog by remember { mutableStateOf(null) } + var editContent by remember { mutableStateOf("") } + var editTags by remember { mutableStateOf("") } + var editConfidence by remember { mutableStateOf("medium") } + var showImportDialog by remember { mutableStateOf(false) } + var importJson by remember { mutableStateOf("") } + + // File picker for import + val filePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + uri?.let { + try { + val inputStream = context.contentResolver.openInputStream(it) + val text = inputStream?.bufferedReader()?.readText() ?: "" + inputStream?.close() + viewModel.importMemory(text) + } catch (_: Exception) { + } + } + } + + // Init + LaunchedEffect(agentId) { + viewModel.initialize(agentId) + } + + // Export share + LaunchedEffect(uiState.exportJson) { + uiState.exportJson?.let { json -> + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, json) + type = "application/json" + } + context.startActivity(Intent.createChooser(sendIntent, "导出记忆数据")) + viewModel.clearExportJson() + } + } + + // Error / Success snackbar + LaunchedEffect(uiState.error, uiState.successMsg) { + // auto-dismiss handled by clearMessages + } + + // Edit dialog + if (uiState.showCreateDialog) { + val item = uiState.editingItem + LaunchedEffect(item) { + editContent = item?.content ?: "" + editTags = item?.tags?.joinToString(", ") ?: "" + editConfidence = item?.confidence ?: "medium" + } + + AlertDialog( + onDismissRequest = { viewModel.dismissDialog() }, + title = { Text(if (item != null) "编辑知识" else "新增知识") }, + text = { + Column { + OutlinedTextField( + value = editContent, + onValueChange = { editContent = it }, + label = { Text("内容") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 5 + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = editTags, + onValueChange = { editTags = it }, + label = { Text("标签(逗号分隔)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(8.dp)) + // Confidence dropdown + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = editConfidence, + onValueChange = {}, + readOnly = true, + label = { Text("置信度") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + listOf("low", "medium", "high").forEach { c -> + DropdownMenuItem( + text = { Text(c) }, + onClick = { + editConfidence = c + expanded = false + } + ) + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + val tags = editTags.split(",").map { it.trim() }.filter { it.isNotEmpty() } + viewModel.saveKnowledge(editContent, tags.ifEmpty { null }, editConfidence) + }, + enabled = editContent.isNotBlank() + ) { + Text("保存") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissDialog() }) { Text("取消") } + } + ) + } + + // Import dialog + if (showImportDialog) { + AlertDialog( + onDismissRequest = { showImportDialog = false }, + title = { Text("导入记忆") }, + text = { + Column { + Text("粘贴 JSON 数据或选择文件", style = MaterialTheme.typography.bodySmall) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = importJson, + onValueChange = { importJson = it }, + label = { Text("JSON 数据") }, + modifier = Modifier.fillMaxWidth().height(150.dp), + maxLines = 8 + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = { filePicker.launch("application/json") }) { + Icon(Icons.Default.FileOpen, contentDescription = null) + Spacer(modifier = Modifier.width(4.dp)) + Text("选择文件") + } + } + }, + confirmButton = { + TextButton( + onClick = { + if (importJson.isNotBlank()) { + viewModel.importMemory(importJson) + } + showImportDialog = false + }, + enabled = importJson.isNotBlank() + ) { Text("导入") } + }, + dismissButton = { + TextButton(onClick = { showImportDialog = false }) { Text("取消") } + } + ) + } + + // Delete confirmation + showDeleteDialog?.let { id -> + AlertDialog( + onDismissRequest = { showDeleteDialog = null }, + title = { Text("确认删除") }, + text = { Text("确定要删除这条知识吗?") }, + confirmButton = { + TextButton(onClick = { viewModel.deleteKnowledge(id); showDeleteDialog = null }) { + Text("删除", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = null }) { Text("取消") } + } + ) + } + + showDeleteEntityDialog?.let { id -> + AlertDialog( + onDismissRequest = { showDeleteEntityDialog = null }, + title = { Text("确认删除") }, + text = { Text("确定要删除这个知识实体吗?") }, + confirmButton = { + TextButton(onClick = { viewModel.deleteEntity(id); showDeleteEntityDialog = null }) { + Text("删除", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteEntityDialog = null }) { Text("取消") } + } + ) + } + + showDeletePatternDialog?.let { id -> + AlertDialog( + onDismissRequest = { showDeletePatternDialog = null }, + title = { Text("确认删除") }, + text = { Text("确定要删除这个学习模式吗?") }, + confirmButton = { + TextButton(onClick = { viewModel.deletePattern(id); showDeletePatternDialog = null }) { + Text("删除", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeletePatternDialog = null }) { Text("取消") } + } + ) + } + + showDeleteVectorDialog?.let { id -> + AlertDialog( + onDismissRequest = { showDeleteVectorDialog = null }, + title = { Text("确认删除") }, + text = { Text("确定要删除这条向量记忆吗?") }, + confirmButton = { + TextButton(onClick = { viewModel.deleteVectorMemory(id); showDeleteVectorDialog = null }) { + Text("删除", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteVectorDialog = null }) { Text("取消") } + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("${agentName} 记忆管理") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") + } + }, + actions = { + // Import + IconButton(onClick = { showImportDialog = true }) { + Icon(Icons.Default.FileOpen, contentDescription = "导入") + } + // Export + IconButton( + onClick = { viewModel.exportMemory() }, + enabled = !uiState.isExporting + ) { + if (uiState.isExporting) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Default.Share, contentDescription = "导出") + } + } + } + ) + } + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + Column(modifier = Modifier.fillMaxSize()) { + // Tabs + TabRow(selectedTabIndex = uiState.selectedTab) { + Tab( + selected = uiState.selectedTab == 0, + onClick = { viewModel.onTabSelected(0) }, + text = { Text("全局知识 (${uiState.totalKnowledge})") } + ) + Tab( + selected = uiState.selectedTab == 1, + onClick = { viewModel.onTabSelected(1) }, + text = { Text("知识实体 (${uiState.knowledgeEntities.size})") } + ) + Tab( + selected = uiState.selectedTab == 2, + onClick = { viewModel.onTabSelected(2) }, + text = { Text("学习模式 (${uiState.totalPatterns})") } + ) + Tab( + selected = uiState.selectedTab == 3, + onClick = { viewModel.onTabSelected(3) }, + text = { Text("对话记忆 (${uiState.totalVectorMemories})") } + ) + } + + // Search + OutlinedTextField( + value = uiState.searchQuery, + onValueChange = viewModel::onSearchChange, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + placeholder = { Text("搜索...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true + ) + + // Error & success + uiState.error?.let { err -> + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Text(err, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + TextButton(onClick = { viewModel.clearMessages() }) { Text("关闭") } + } + } + } + + uiState.successMsg?.let { msg -> + LaunchedEffect(msg) { + kotlinx.coroutines.delay(2000) + viewModel.clearMessages() + } + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) + ) { + Text(msg, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodySmall) + } + } + + // Content + if (uiState.isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + when (uiState.selectedTab) { + 0 -> GlobalKnowledgeTab( + items = uiState.globalKnowledge, + onEdit = { viewModel.showEditDialog(it) }, + onDelete = { showDeleteDialog = it } + ) + 1 -> KnowledgeEntitiesTab( + items = uiState.knowledgeEntities, + onDelete = { showDeleteEntityDialog = it } + ) + 2 -> LearningPatternsTab( + items = uiState.learningPatterns, + onDelete = { showDeletePatternDialog = it } + ) + 3 -> VectorMemoriesTab( + items = uiState.vectorMemories, + onDelete = { showDeleteVectorDialog = it } + ) + } + } + } // end Column + + // FAB for add knowledge + if (uiState.selectedTab == 0) { + FloatingActionButton( + onClick = { viewModel.showCreateDialog() }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + containerColor = MaterialTheme.colorScheme.primaryContainer + ) { + Icon(Icons.Default.Add, contentDescription = "新增知识") + } + } + } // end Box + } +} + +// ─── Tab Content ─── + +@Composable +private fun GlobalKnowledgeTab( + items: List, + onEdit: (GlobalKnowledgeDto) -> Unit, + onDelete: (String) -> Unit +) { + if (items.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("暂无全局知识", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + items(items, key = { it.id }) { item -> + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = item.content, + style = MaterialTheme.typography.bodyMedium, + maxLines = 4, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + item.tags?.take(3)?.forEach { tag -> + SuggestionChip( + onClick = {}, + label = { Text(tag, style = MaterialTheme.typography.labelSmall) } + ) + } + Text( + text = "置信度: ${item.confidence}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Row { + IconButton(onClick = { onEdit(item) }, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Edit, contentDescription = "编辑", modifier = Modifier.size(16.dp)) + } + IconButton(onClick = { onDelete(item.id) }, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Delete, contentDescription = "删除", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error) + } + } + } + } + } + } + } + } +} + +@Composable +private fun KnowledgeEntitiesTab( + items: List, + onDelete: (String) -> Unit +) { + if (items.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("暂无知识实体", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + items(items, key = { it.id }) { item -> + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "${item.entityType} · ${item.source} · ${item.confidence}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + item.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + IconButton(onClick = { onDelete(item.id) }) { + Icon(Icons.Default.Delete, contentDescription = "删除", tint = MaterialTheme.colorScheme.error) + } + } + } + } + } + } +} + +@Composable +private fun LearningPatternsTab( + items: List, + onDelete: (String) -> Unit +) { + if (items.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("暂无学习模式", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + items(items, key = { it.id }) { item -> + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = item.taskCategory, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "成功率: ${(item.effectivenessScore * 100).toInt()}% · " + + "运行: ${item.totalRuns}次 · " + + "平均${item.avgIterations}轮/${item.avgToolCalls}工具", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + item.taskKeywords?.let { + Text(text = "关键词: $it", style = MaterialTheme.typography.bodySmall) + } + item.suggestedTools?.let { + Text( + text = "工具: $it", + style = MaterialTheme.typography.labelSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.primary + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + IconButton(onClick = { onDelete(item.id) }) { + Icon(Icons.Default.Delete, contentDescription = "删除", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error) + } + } + } + } + } + } + } +} + +@Composable +private fun VectorMemoriesTab( + items: List, + onDelete: (String) -> Unit +) { + if (items.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("暂无对话记忆", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + items(items, key = { it.id }) { item -> + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = item.contentText, + style = MaterialTheme.typography.bodySmall, + maxLines = 5, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item.metadata?.get("memory_type")?.let { type -> + SuggestionChip( + onClick = {}, + label = { Text(type.toString(), style = MaterialTheme.typography.labelSmall) } + ) + } + item.createdAt?.let { date -> + Text( + text = date.take(16).replace("T", " "), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + IconButton(onClick = { onDelete(item.id) }, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Delete, contentDescription = "删除", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error) + } + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/memory/MemoryManageViewModel.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/memory/MemoryManageViewModel.kt new file mode 100644 index 0000000..e67b421 --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/memory/MemoryManageViewModel.kt @@ -0,0 +1,220 @@ +package com.tiangong.aiagent.ui.memory + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tiangong.aiagent.data.remote.ApiService +import com.tiangong.aiagent.data.remote.dto.* +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class MemoryUiState( + val isLoading: Boolean = false, + val globalKnowledge: List = emptyList(), + val knowledgeEntities: List = emptyList(), + val learningPatterns: List = emptyList(), + val vectorMemories: List = emptyList(), + val totalKnowledge: Int = 0, + val totalPatterns: Int = 0, + val totalVectorMemories: Int = 0, + val searchQuery: String = "", + val selectedTab: Int = 0, + val isExporting: Boolean = false, + val exportJson: String? = null, + val error: String? = null, + val successMsg: String? = null, + val editingItem: GlobalKnowledgeDto? = null, + val showCreateDialog: Boolean = false +) + +@HiltViewModel +class MemoryManageViewModel @Inject constructor( + private val apiService: ApiService +) : ViewModel() { + + private val _uiState = MutableStateFlow(MemoryUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var agentId: String = "" + + fun initialize(agentId: String) { + if (this.agentId == agentId) return + this.agentId = agentId + loadAll() + } + + fun loadAll() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + try { + val gk = apiService.getGlobalKnowledge(agentId) + val ke = apiService.getKnowledgeEntities(agentId) + val lp = apiService.getLearningPatterns(agentId) + val vm = apiService.getVectorMemories(agentId) + _uiState.value = _uiState.value.copy( + globalKnowledge = gk.items, + knowledgeEntities = ke.items, + learningPatterns = lp.items, + vectorMemories = vm.items, + totalKnowledge = gk.total, + totalPatterns = lp.total, + totalVectorMemories = vm.total, + isLoading = false + ) + } catch (e: Exception) { + Log.w("MemoryVM", "Failed to load memory", e) + _uiState.value = _uiState.value.copy(isLoading = false, error = "加载失败: ${e.message}") + } + } + } + + fun onSearchChange(query: String) { + _uiState.value = _uiState.value.copy(searchQuery = query) + if (agentId.isEmpty()) return + viewModelScope.launch { + try { + val gk = apiService.getGlobalKnowledge(agentId, search = query.ifBlank { null }) + _uiState.value = _uiState.value.copy( + globalKnowledge = gk.items, + totalKnowledge = gk.total + ) + } catch (e: Exception) { + Log.w("MemoryVM", "Search failed", e) + } + } + } + + fun onTabSelected(index: Int) { + _uiState.value = _uiState.value.copy(selectedTab = index) + } + + // ─── Global Knowledge CRUD ─── + + fun showCreateDialog() { + _uiState.value = _uiState.value.copy(showCreateDialog = true, editingItem = null) + } + + fun showEditDialog(item: GlobalKnowledgeDto) { + _uiState.value = _uiState.value.copy(showCreateDialog = true, editingItem = item) + } + + fun dismissDialog() { + _uiState.value = _uiState.value.copy(showCreateDialog = false, editingItem = null) + } + + fun saveKnowledge(content: String, tags: List?, confidence: String) { + viewModelScope.launch { + try { + val editing = _uiState.value.editingItem + if (editing != null) { + apiService.updateGlobalKnowledge(agentId, editing.id, + GlobalKnowledgeCreateRequest(content, tags, confidence)) + } else { + apiService.createGlobalKnowledge(agentId, + GlobalKnowledgeCreateRequest(content, tags, confidence)) + } + _uiState.value = _uiState.value.copy(showCreateDialog = false, editingItem = null, successMsg = "保存成功") + loadAll() + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(error = "保存失败: ${e.message}") + } + } + } + + fun deleteKnowledge(id: String) { + viewModelScope.launch { + try { + apiService.deleteGlobalKnowledge(agentId, id) + _uiState.value = _uiState.value.copy(successMsg = "已删除") + loadAll() + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(error = "删除失败: ${e.message}") + } + } + } + + fun deleteEntity(id: String) { + viewModelScope.launch { + try { + apiService.deleteKnowledgeEntity(agentId, id) + _uiState.value = _uiState.value.copy(successMsg = "已删除") + loadAll() + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(error = "删除失败: ${e.message}") + } + } + } + + fun deletePattern(id: String) { + viewModelScope.launch { + try { + apiService.deleteLearningPattern(agentId, id) + _uiState.value = _uiState.value.copy(successMsg = "已删除") + loadAll() + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(error = "删除失败: ${e.message}") + } + } + } + + fun deleteVectorMemory(id: String) { + viewModelScope.launch { + try { + apiService.deleteVectorMemory(agentId, id) + _uiState.value = _uiState.value.copy(successMsg = "已删除") + loadAll() + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(error = "删除失败: ${e.message}") + } + } + } + + // ─── Import / Export ─── + + fun exportMemory() { + viewModelScope.launch { + try { + _uiState.value = _uiState.value.copy(isExporting = true) + val response = apiService.exportMemory(agentId) + val gson = com.google.gson.GsonBuilder().setPrettyPrinting().create() + val json = gson.toJson(response) + _uiState.value = _uiState.value.copy(isExporting = false, exportJson = json, successMsg = "导出成功") + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(isExporting = false, error = "导出失败: ${e.message}") + } + } + } + + fun importMemory(json: String) { + viewModelScope.launch { + try { + val gson = com.google.gson.Gson() + val response = gson.fromJson(json, MemoryExportResponse::class.java) + val request = MemoryImportRequest( + globalKnowledge = response.globalKnowledge, + knowledgeEntities = response.knowledgeEntities, + knowledgeRelations = response.knowledgeRelations, + learningPatterns = response.learningPatterns, + vectorMemories = response.vectorMemories + ) + apiService.importMemory(agentId, request) + _uiState.value = _uiState.value.copy(successMsg = "导入成功") + loadAll() + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(error = "导入失败: ${e.message}") + } + } + } + + fun clearExportJson() { + _uiState.value = _uiState.value.copy(exportJson = null) + } + + fun clearMessages() { + _uiState.value = _uiState.value.copy(error = null, successMsg = null) + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt index 43cb7cb..a615619 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt @@ -16,6 +16,7 @@ import com.tiangong.aiagent.ui.chat.ChatScreen import com.tiangong.aiagent.ui.chat.ChatViewModel import com.tiangong.aiagent.ui.history.ConversationListScreen import com.tiangong.aiagent.ui.login.LoginScreen +import com.tiangong.aiagent.ui.memory.MemoryManageScreen import com.tiangong.aiagent.ui.notifications.NotificationDetailScreen import com.tiangong.aiagent.ui.notifications.NotificationsScreen import com.tiangong.aiagent.ui.register.RegisterScreen @@ -32,6 +33,7 @@ object Routes { const val ABOUT = "about" const val NOTIFICATIONS = "notifications" const val NOTIFICATION_DETAIL = "notifications/{notificationId}" + const val MEMORY_MANAGE = "memory_manage/{agentId}/{agentName}" } @Composable @@ -104,6 +106,10 @@ fun NavGraph( onAgentSelected = { agent: Agent -> chatViewModel.switchAgent(agent) navController.popBackStack() + }, + onManageMemory = { agentId, agentName -> + val encoded = java.net.URLEncoder.encode(agentName, "UTF-8") + navController.navigate("memory_manage/$agentId/$encoded") } ) } @@ -155,5 +161,17 @@ fun NavGraph( navController = navController ) } + + composable(Routes.MEMORY_MANAGE) { backStackEntry -> + val agentId = backStackEntry.arguments?.getString("agentId") ?: return@composable + val agentName = java.net.URLDecoder.decode( + backStackEntry.arguments?.getString("agentName") ?: "", "UTF-8" + ) + MemoryManageScreen( + agentId = agentId, + agentName = agentName, + onBack = { navController.popBackStack() } + ) + } } } diff --git a/backend/app/api/agent_memory.py b/backend/app/api/agent_memory.py new file mode 100644 index 0000000..6b49617 --- /dev/null +++ b/backend/app/api/agent_memory.py @@ -0,0 +1,680 @@ +""" +Agent 记忆管理 API — 全局知识 / 知识实体 / 学习模式的 CRUD + 导入导出 +""" +from __future__ import annotations + +import logging +import uuid +from datetime import datetime +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session +from sqlalchemy import or_ + +from app.core.database import get_db +from app.api.auth import get_current_user +from app.models.user import User +from app.models.agent import Agent, GlobalKnowledge, KnowledgeEntity, KnowledgeRelation +from app.models.agent_learning_pattern import AgentLearningPattern +from app.models.agent_vector_memory import AgentVectorMemory + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/v1/agents", + tags=["agent-memory"], +) + +SCOPE_AGENT = "agent" + + +def _check_agent(db: Session, agent_id: str, user: User): + agent = db.query(Agent).filter(Agent.id == agent_id).first() + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + if agent.user_id and agent.user_id != user.id and user.role != "admin": + raise HTTPException(status_code=403, detail="无权访问该 Agent") + return agent + + +# ─────────── Pydantic models ─────────── + + +class GlobalKnowledgeItem(BaseModel): + id: str + content: str + source_agent_id: Optional[str] = None + source_user_id: Optional[str] = None + tags: Optional[list] = None + confidence: str = "medium" + scope_kind: str = "agent" + scope_id: str = "" + expires_at: Optional[str] = None + created_at: Optional[str] = None + + +class GlobalKnowledgeCreate(BaseModel): + content: str + tags: Optional[list] = None + confidence: str = "medium" + + +class GlobalKnowledgeUpdate(BaseModel): + content: Optional[str] = None + tags: Optional[list] = None + confidence: Optional[str] = None + + +class GlobalKnowledgeList(BaseModel): + items: List[GlobalKnowledgeItem] + total: int + + +class KnowledgeEntityItem(BaseModel): + id: str + name: str + entity_type: str = "concept" + description: Optional[str] = None + source: str = "extracted" + confidence: str = "medium" + tags: Optional[list] = None + scope_kind: str = "agent" + scope_id: str = "" + created_at: Optional[str] = None + + +class KnowledgeEntityCreate(BaseModel): + name: str + entity_type: str = "concept" + description: Optional[str] = None + tags: Optional[list] = None + confidence: str = "medium" + + +class KnowledgeEntityUpdate(BaseModel): + name: Optional[str] = None + entity_type: Optional[str] = None + description: Optional[str] = None + tags: Optional[list] = None + confidence: Optional[str] = None + + +class KnowledgeEntityList(BaseModel): + items: List[KnowledgeEntityItem] + total: int + + +class LearningPatternItem(BaseModel): + id: str + scope_kind: str + scope_id: str + task_category: str + task_keywords: Optional[str] = None + suggested_tools: Optional[str] = None + effectiveness_score: float = 0.0 + total_runs: int = 0 + successful_runs: int = 0 + avg_iterations: float = 0.0 + avg_tool_calls: float = 0.0 + last_used_at: Optional[str] = None + created_at: Optional[str] = None + + +class LearningPatternList(BaseModel): + items: List[LearningPatternItem] + total: int + + +class VectorMemoryItem(BaseModel): + id: str + scope_kind: str = "agent" + scope_id: str = "" + session_key: str = "" + content_text: str + metadata: Optional[dict] = None + created_at: Optional[str] = None + + +class VectorMemoryList(BaseModel): + items: List[VectorMemoryItem] + total: int + + +class MemoryExportResponse(BaseModel): + agent_id: str + exported_at: str + global_knowledge: List[dict] = [] + knowledge_entities: List[dict] = [] + knowledge_relations: List[dict] = [] + learning_patterns: List[dict] = [] + vector_memories: List[dict] = [] + + +class MemoryImportRequest(BaseModel): + global_knowledge: List[dict] = [] + knowledge_entities: List[dict] = [] + knowledge_relations: List[dict] = [] + learning_patterns: List[dict] = [] + vector_memories: List[dict] = [] + + +# ─────────── 辅助 ─────────── + + +def _gk_to_item(gk: GlobalKnowledge) -> GlobalKnowledgeItem: + return GlobalKnowledgeItem( + id=gk.id, + content=gk.content, + source_agent_id=gk.source_agent_id, + source_user_id=gk.source_user_id, + tags=gk.tags, + confidence=gk.confidence or "medium", + scope_kind=gk.scope_kind or SCOPE_AGENT, + scope_id=gk.scope_id or "", + expires_at=gk.expires_at.isoformat() if gk.expires_at else None, + created_at=gk.created_at.isoformat() if gk.created_at else None, + ) + + +def _ke_to_item(ke: KnowledgeEntity) -> KnowledgeEntityItem: + return KnowledgeEntityItem( + id=ke.id, + name=ke.name, + entity_type=ke.entity_type or "concept", + description=getattr(ke, "description", None), + source=ke.source or "extracted", + confidence=ke.confidence or "medium", + tags=getattr(ke, "metadata_", None), + scope_kind=ke.scope_kind or SCOPE_AGENT, + scope_id=ke.scope_id or "", + created_at=ke.created_at.isoformat() if ke.created_at else None, + ) + + +def _vm_to_item(vm: AgentVectorMemory) -> VectorMemoryItem: + return VectorMemoryItem( + id=vm.id, + scope_kind=vm.scope_kind or SCOPE_AGENT, + scope_id=vm.scope_id or "", + session_key=vm.session_key or "", + content_text=vm.content_text or "", + metadata=vm.metadata_ or {}, + created_at=vm.created_at.isoformat() if vm.created_at else None, + ) + + +# ─────────── Global Knowledge CRUD ─────────── + + +@router.get("/{agent_id}/memory/global-knowledge", response_model=GlobalKnowledgeList) +def list_global_knowledge( + agent_id: str, + skip: int = 0, + limit: int = 50, + search: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _check_agent(db, agent_id, current_user) + q = db.query(GlobalKnowledge).filter( + GlobalKnowledge.scope_kind == SCOPE_AGENT, + GlobalKnowledge.scope_id == agent_id, + ) + if search: + q = q.filter(GlobalKnowledge.content.contains(search)) + total = q.count() + items = q.order_by(GlobalKnowledge.created_at.desc()).offset(skip).limit(limit).all() + return GlobalKnowledgeList(items=[_gk_to_item(it) for it in items], total=total) + + +@router.post("/{agent_id}/memory/global-knowledge", response_model=GlobalKnowledgeItem) +def create_global_knowledge( + agent_id: str, + body: GlobalKnowledgeCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _check_agent(db, agent_id, current_user) + gk = GlobalKnowledge( + id=str(uuid.uuid4()), + content=body.content, + tags=body.tags, + confidence=body.confidence, + scope_kind=SCOPE_AGENT, + scope_id=agent_id, + source_user_id=current_user.id, + ) + db.add(gk) + db.commit() + db.refresh(gk) + return _gk_to_item(gk) + + +@router.put("/{agent_id}/memory/global-knowledge/{knowledge_id}", response_model=GlobalKnowledgeItem) +def update_global_knowledge( + agent_id: str, + knowledge_id: str, + body: GlobalKnowledgeUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _check_agent(db, agent_id, current_user) + gk = db.query(GlobalKnowledge).filter( + GlobalKnowledge.id == knowledge_id, + GlobalKnowledge.scope_kind == SCOPE_AGENT, + GlobalKnowledge.scope_id == agent_id, + ).first() + if not gk: + raise HTTPException(status_code=404, detail="知识条目不存在") + if body.content is not None: + gk.content = body.content + if body.tags is not None: + gk.tags = body.tags + if body.confidence is not None: + gk.confidence = body.confidence + db.commit() + db.refresh(gk) + return _gk_to_item(gk) + + +@router.delete("/{agent_id}/memory/global-knowledge/{knowledge_id}") +def delete_global_knowledge( + agent_id: str, + knowledge_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _check_agent(db, agent_id, current_user) + gk = db.query(GlobalKnowledge).filter( + GlobalKnowledge.id == knowledge_id, + GlobalKnowledge.scope_kind == SCOPE_AGENT, + GlobalKnowledge.scope_id == agent_id, + ).first() + if not gk: + raise HTTPException(status_code=404, detail="知识条目不存在") + db.delete(gk) + db.commit() + return {"ok": True} + + +# ─────────── Knowledge Entities CRUD ─────────── + + +@router.get("/{agent_id}/memory/knowledge-entities", response_model=KnowledgeEntityList) +def list_knowledge_entities( + agent_id: str, + skip: int = 0, + limit: int = 50, + search: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _check_agent(db, agent_id, current_user) + q = db.query(KnowledgeEntity).filter( + KnowledgeEntity.scope_kind == SCOPE_AGENT, + KnowledgeEntity.scope_id == agent_id, + ) + if search: + q = q.filter( + (KnowledgeEntity.name.contains(search)) + | (KnowledgeEntity.description.contains(search)) + ) + total = q.count() + items = q.order_by(KnowledgeEntity.created_at.desc()).offset(skip).limit(limit).all() + return KnowledgeEntityList(items=[_ke_to_item(it) for it in items], total=total) + + +@router.post("/{agent_id}/memory/knowledge-entities", response_model=KnowledgeEntityItem) +def create_knowledge_entity( + agent_id: str, + body: KnowledgeEntityCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _check_agent(db, agent_id, current_user) + ke = KnowledgeEntity( + id=str(uuid.uuid4()), + name=body.name, + entity_type=body.entity_type, + description=body.description, + source="manual", + confidence=body.confidence, + metadata_=body.tags, + scope_kind=SCOPE_AGENT, + scope_id=agent_id, + user_id=current_user.id, + ) + db.add(ke) + db.commit() + db.refresh(ke) + return _ke_to_item(ke) + + +@router.put("/{agent_id}/memory/knowledge-entities/{entity_id}", response_model=KnowledgeEntityItem) +def update_knowledge_entity( + agent_id: str, + entity_id: str, + body: KnowledgeEntityUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _check_agent(db, agent_id, current_user) + ke = db.query(KnowledgeEntity).filter( + KnowledgeEntity.id == entity_id, + KnowledgeEntity.scope_kind == SCOPE_AGENT, + KnowledgeEntity.scope_id == agent_id, + ).first() + if not ke: + raise HTTPException(status_code=404, detail="实体不存在") + if body.name is not None: + ke.name = body.name + if body.entity_type is not None: + ke.entity_type = body.entity_type + if body.description is not None: + ke.description = body.description + if body.tags is not None: + ke.metadata_ = body.tags + if body.confidence is not None: + ke.confidence = body.confidence + db.commit() + db.refresh(ke) + return _ke_to_item(ke) + + +@router.delete("/{agent_id}/memory/knowledge-entities/{entity_id}") +def delete_knowledge_entity( + agent_id: str, + entity_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _check_agent(db, agent_id, current_user) + ke = db.query(KnowledgeEntity).filter( + KnowledgeEntity.id == entity_id, + KnowledgeEntity.scope_kind == SCOPE_AGENT, + KnowledgeEntity.scope_id == agent_id, + ).first() + if not ke: + raise HTTPException(status_code=404, detail="实体不存在") + # Also remove related relations + db.query(KnowledgeRelation).filter( + (KnowledgeRelation.source_entity_id == entity_id) + | (KnowledgeRelation.target_entity_id == entity_id) + ).delete() + db.delete(ke) + db.commit() + return {"ok": True} + + +# ─────────── Learning Patterns ─────────── + + +@router.get("/{agent_id}/memory/learning-patterns", response_model=LearningPatternList) +def list_learning_patterns( + agent_id: str, + skip: int = 0, + limit: int = 50, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _check_agent(db, agent_id, current_user) + q = db.query(AgentLearningPattern).filter( + AgentLearningPattern.scope_kind == SCOPE_AGENT, + AgentLearningPattern.scope_id == agent_id, + ) + total = q.count() + items = q.order_by(AgentLearningPattern.updated_at.desc()).offset(skip).limit(limit).all() + return LearningPatternList( + items=[ + LearningPatternItem( + id=lp.id, + scope_kind=lp.scope_kind or SCOPE_AGENT, + scope_id=lp.scope_id or "", + task_category=lp.task_category or "", + task_keywords=lp.task_keywords, + suggested_tools=lp.suggested_tools, + effectiveness_score=lp.effectiveness_score or 0.0, + total_runs=lp.total_runs or 0, + successful_runs=lp.successful_runs or 0, + avg_iterations=lp.avg_iterations or 0.0, + avg_tool_calls=lp.avg_tool_calls or 0.0, + last_used_at=lp.last_used_at.isoformat() if lp.last_used_at else None, + created_at=lp.created_at.isoformat() if lp.created_at else None, + ) + for lp in items + ], + total=total, + ) + + +@router.delete("/{agent_id}/memory/learning-patterns/{pattern_id}") +def delete_learning_pattern( + agent_id: str, + pattern_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _check_agent(db, agent_id, current_user) + lp = db.query(AgentLearningPattern).filter( + AgentLearningPattern.id == pattern_id, + AgentLearningPattern.scope_kind == SCOPE_AGENT, + AgentLearningPattern.scope_id == agent_id, + ).first() + if not lp: + raise HTTPException(status_code=404, detail="学习模式不存在") + db.delete(lp) + db.commit() + return {"ok": True} + + +# ─────────── Vector Memories ─────────── + + +@router.get("/{agent_id}/memory/vector-memories", response_model=VectorMemoryList) +def list_vector_memories( + agent_id: str, + skip: int = 0, + limit: int = 50, + search: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _check_agent(db, agent_id, current_user) + uid = current_user.id + # scope_id may be: agent_id, or {user_id}:{agent_id}, or {user_id}:{agent_id}:{session_id} + q = db.query(AgentVectorMemory).filter( + AgentVectorMemory.scope_kind == SCOPE_AGENT, + or_( + AgentVectorMemory.scope_id == agent_id, + AgentVectorMemory.scope_id.like(f"%:{agent_id}"), + AgentVectorMemory.scope_id.like(f"{uid}:%"), + ), + ) + if search: + q = q.filter(AgentVectorMemory.content_text.contains(search)) + total = q.count() + items = q.order_by(AgentVectorMemory.created_at.desc()).offset(skip).limit(limit).all() + return VectorMemoryList(items=[_vm_to_item(it) for it in items], total=total) + + +@router.delete("/{agent_id}/memory/vector-memories/{memory_id}") +def delete_vector_memory( + agent_id: str, + memory_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _check_agent(db, agent_id, current_user) + uid = current_user.id + vm = ( + db.query(AgentVectorMemory) + .filter( + AgentVectorMemory.id == memory_id, + AgentVectorMemory.scope_kind == SCOPE_AGENT, + or_( + AgentVectorMemory.scope_id == agent_id, + AgentVectorMemory.scope_id.like(f"%:{agent_id}"), + AgentVectorMemory.scope_id.like(f"{uid}:%"), + ), + ) + .first() + ) + if not vm: + raise HTTPException(status_code=404, detail="向量记忆不存在") + db.delete(vm) + db.commit() + return {"ok": True} + + +# ─────────── Import / Export ─────────── + + +def _serialize(row): + """Serialize a SQLAlchemy row to dict, handling datetime.""" + d = {} + for c in row.__table__.columns: + val = getattr(row, c.name) + if isinstance(val, datetime): + val = val.isoformat() + d[c.name] = val + return d + + +@router.post("/{agent_id}/memory/export", response_model=MemoryExportResponse) +def export_memory( + agent_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _check_agent(db, agent_id, current_user) + gk_list = ( + db.query(GlobalKnowledge) + .filter(GlobalKnowledge.scope_kind == SCOPE_AGENT, GlobalKnowledge.scope_id == agent_id) + .all() + ) + ke_list = ( + db.query(KnowledgeEntity) + .filter(KnowledgeEntity.scope_kind == SCOPE_AGENT, KnowledgeEntity.scope_id == agent_id) + .all() + ) + kr_list = ( + db.query(KnowledgeRelation) + .filter(KnowledgeRelation.scope_kind == SCOPE_AGENT, KnowledgeRelation.scope_id == agent_id) + .all() + ) + lp_list = ( + db.query(AgentLearningPattern) + .filter(AgentLearningPattern.scope_kind == SCOPE_AGENT, AgentLearningPattern.scope_id == agent_id) + .all() + ) + vm_list = ( + db.query(AgentVectorMemory) + .filter( + AgentVectorMemory.scope_kind == SCOPE_AGENT, + or_( + AgentVectorMemory.scope_id == agent_id, + AgentVectorMemory.scope_id.like(f"%:{agent_id}"), + AgentVectorMemory.scope_id.like(f"{current_user.id}:%"), + ), + ) + .all() + ) + return MemoryExportResponse( + agent_id=agent_id, + exported_at=datetime.utcnow().isoformat(), + global_knowledge=[_serialize(gk) for gk in gk_list], + knowledge_entities=[_serialize(ke) for ke in ke_list], + knowledge_relations=[_serialize(kr) for kr in kr_list], + learning_patterns=[_serialize(lp) for lp in lp_list], + vector_memories=[_serialize(vm) for vm in vm_list], + ) + + +@router.post("/{agent_id}/memory/import") +def import_memory( + agent_id: str, + body: MemoryImportRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _check_agent(db, agent_id, current_user) + imported = {"global_knowledge": 0, "knowledge_entities": 0, "knowledge_relations": 0, "learning_patterns": 0} + + for item in body.global_knowledge: + gk = GlobalKnowledge( + id=str(uuid.uuid4()), + content=item.get("content", ""), + tags=item.get("tags"), + confidence=item.get("confidence", "medium"), + scope_kind=SCOPE_AGENT, + scope_id=agent_id, + source_agent_id=item.get("source_agent_id"), + source_user_id=current_user.id, + ) + db.add(gk) + imported["global_knowledge"] += 1 + + for item in body.knowledge_entities: + ke = KnowledgeEntity( + id=str(uuid.uuid4()), + name=item.get("name", ""), + entity_type=item.get("entity_type", "concept"), + description=item.get("description"), + source="imported", + confidence=item.get("confidence", "medium"), + metadata_=item.get("metadata") or item.get("tags"), + scope_kind=SCOPE_AGENT, + scope_id=agent_id, + user_id=current_user.id, + ) + db.add(ke) + imported["knowledge_entities"] += 1 + + for item in body.knowledge_relations: + kr = KnowledgeRelation( + id=str(uuid.uuid4()), + source_entity_id=item.get("source_entity_id", ""), + target_entity_id=item.get("target_entity_id", ""), + relation_type=item.get("relation_type", "related_to"), + description=item.get("description"), + weight=item.get("weight", "1.0"), + scope_kind=SCOPE_AGENT, + scope_id=agent_id, + ) + db.add(kr) + imported["knowledge_relations"] += 1 + + for item in body.learning_patterns: + lp = AgentLearningPattern( + id=str(uuid.uuid4()), + scope_kind=SCOPE_AGENT, + scope_id=agent_id, + task_category=item.get("task_category", "general"), + task_keywords=item.get("task_keywords", ""), + suggested_tools=item.get("suggested_tools", "[]"), + effectiveness_score=item.get("effectiveness_score", 0.0), + total_runs=item.get("total_runs", 1), + successful_runs=item.get("successful_runs", 1), + avg_iterations=item.get("avg_iterations", 1.0), + avg_tool_calls=item.get("avg_tool_calls", 1.0), + ) + db.add(lp) + imported["learning_patterns"] += 1 + + imported["vector_memories"] = 0 + for item in body.vector_memories: + vm = AgentVectorMemory( + id=str(uuid.uuid4()), + scope_kind=SCOPE_AGENT, + scope_id=agent_id, + session_key=item.get("session_key", ""), + content_text=item.get("content_text", ""), + metadata_=item.get("metadata"), + ) + db.add(vm) + imported["vector_memories"] += 1 + + db.commit() + return {"ok": True, "imported": imported} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a4dad85..806b835 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -151,6 +151,7 @@ class Settings(BaseSettings): class Config: env_file = str(_ENV_PATH) case_sensitive = True + extra = "ignore" settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index d93a8a5..4a09eb0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -510,7 +510,7 @@ async def startup_event(): logger.error(f"定时任务调度器启动失败: {e}") # 注册路由 -from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams +from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams, agent_memory app.include_router(auth.router) app.include_router(workspaces.router) @@ -556,6 +556,7 @@ app.include_router(fcm.router) app.include_router(knowledge_dashboard.router) app.include_router(scene_contracts.router) app.include_router(teams.router) +app.include_router(agent_memory.router) if __name__ == "__main__": import uvicorn diff --git a/backend/app/models/agent_learning_pattern.py b/backend/app/models/agent_learning_pattern.py index 58f0162..e01aaed 100644 --- a/backend/app/models/agent_learning_pattern.py +++ b/backend/app/models/agent_learning_pattern.py @@ -11,7 +11,7 @@ class AgentLearningPattern(Base): id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) scope_kind = Column(String(16), nullable=False, index=True, comment="作用域类型: agent/bare") - scope_id = Column(String(64), nullable=False, index=True, comment="作用域 ID: agent_id/user_id") + scope_id = Column(String(255), nullable=False, index=True, comment="作用域 ID: agent_id/user_id") task_category = Column(String(64), nullable=False, default="general", comment="任务分类") task_keywords = Column(String(256), default="", comment="任务关键词") suggested_tools = Column(Text, nullable=False, comment="推荐工具序列 (JSON array)") diff --git a/backend/app/models/agent_vector_memory.py b/backend/app/models/agent_vector_memory.py index 35da35a..8d5cbdd 100644 --- a/backend/app/models/agent_vector_memory.py +++ b/backend/app/models/agent_vector_memory.py @@ -21,7 +21,7 @@ class AgentVectorMemory(Base): id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) scope_kind = Column(String(16), nullable=False, index=True, comment="作用域类型: agent / bare") - scope_id = Column(String(36), nullable=False, index=True, comment="作用域 ID: agent_id / user_id") + scope_id = Column(String(255), nullable=False, index=True, comment="作用域 ID: agent_id / user_id") session_key = Column(String(128), nullable=False, default="", comment="会话标识") content_text = Column(Text, nullable=False, comment="原始对话文本") embedding = Column(Text, nullable=True, comment="JSON 序列化的 embedding 向量") diff --git a/backend/app/models/persistent_user_memory.py b/backend/app/models/persistent_user_memory.py index 4aa9b0b..ec76a1d 100644 --- a/backend/app/models/persistent_user_memory.py +++ b/backend/app/models/persistent_user_memory.py @@ -19,7 +19,7 @@ class PersistentUserMemory(Base): id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="主键") scope_kind = Column(String(16), nullable=False, comment="agent 或 workflow") - scope_id = Column(CHAR(36), nullable=False, comment="Agent ID 或 Workflow ID") + scope_id = Column(String(128), nullable=False, comment="Agent ID 或 Workflow ID") session_key = Column(String(512), nullable=False, comment="调用方传入的 user_id 等会话键") payload = Column(JSON, nullable=False, comment="与 Redis 中 user_memory_* 结构一致的记忆 JSON") updated_at = Column(