feat: agent memory management — CRUD API + Android management screen
Some checks are pending
CI/CD Pipeline / Backend — Lint & Test (push) Waiting to run
CI/CD Pipeline / Frontend — Lint & Build (push) Waiting to run
CI/CD Pipeline / Docker — Build Check (push) Blocked by required conditions

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 02:23:45 +08:00
parent 43d5347458
commit cffe4a52d6
15 changed files with 1876 additions and 14 deletions

View File

@@ -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<Unit>
@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<Unit>
@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<Unit>
@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<Unit>
// ─── 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<Unit>
}

View File

@@ -60,11 +60,11 @@ class SseClient @Inject constructor(
request: ChatRequest
): Flow<SseEvent> = 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)

View File

@@ -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<String>? = 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<GlobalKnowledgeDto>,
val total: Int
)
data class GlobalKnowledgeCreateRequest(
val content: String,
val tags: List<String>? = 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<String>? = null,
@SerializedName("created_at") val createdAt: String? = null
)
data class KnowledgeEntityListResponse(
val items: List<KnowledgeEntityDto>,
val total: Int
)
data class KnowledgeEntityCreateRequest(
val name: String,
@SerializedName("entity_type") val entityType: String = "concept",
val description: String? = null,
val tags: List<String>? = 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<LearningPatternDto>,
val total: Int
)
data class MemoryExportResponse(
@SerializedName("agent_id") val agentId: String,
@SerializedName("exported_at") val exportedAt: String,
@SerializedName("global_knowledge") val globalKnowledge: List<Map<String, Any?>> = emptyList(),
@SerializedName("knowledge_entities") val knowledgeEntities: List<Map<String, Any?>> = emptyList(),
@SerializedName("knowledge_relations") val knowledgeRelations: List<Map<String, Any?>> = emptyList(),
@SerializedName("learning_patterns") val learningPatterns: List<Map<String, Any?>> = emptyList(),
@SerializedName("vector_memories") val vectorMemories: List<Map<String, Any?>> = emptyList()
)
data class MemoryImportRequest(
@SerializedName("global_knowledge") val globalKnowledge: List<Map<String, Any?>> = emptyList(),
@SerializedName("knowledge_entities") val knowledgeEntities: List<Map<String, Any?>> = emptyList(),
@SerializedName("knowledge_relations") val knowledgeRelations: List<Map<String, Any?>> = emptyList(),
@SerializedName("learning_patterns") val learningPatterns: List<Map<String, Any?>> = emptyList(),
@SerializedName("vector_memories") val vectorMemories: List<Map<String, Any?>> = emptyList()
)
// ─────────── Vector Memories ───────────
data class VectorMemoryDto(
val id: String,
@SerializedName("content_text") val contentText: String,
@SerializedName("session_key") val sessionKey: String = "",
val metadata: Map<String, Any?>? = null,
@SerializedName("created_at") val createdAt: String? = null
)
data class VectorMemoryListResponse(
val items: List<VectorMemoryDto>,
val total: Int
)

View File

@@ -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 },

View File

@@ -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(

View File

@@ -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<LoginUiState> = _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()) {

View File

@@ -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<String?>(null) }
var showDeleteEntityDialog by remember { mutableStateOf<String?>(null) }
var showDeletePatternDialog by remember { mutableStateOf<String?>(null) }
var showDeleteVectorDialog by remember { mutableStateOf<String?>(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<GlobalKnowledgeDto>,
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<KnowledgeEntityDto>,
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<LearningPatternDto>,
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<com.tiangong.aiagent.data.remote.dto.VectorMemoryDto>,
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)
}
}
}
}
}
}
}
}

View File

@@ -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<GlobalKnowledgeDto> = emptyList(),
val knowledgeEntities: List<KnowledgeEntityDto> = emptyList(),
val learningPatterns: List<LearningPatternDto> = emptyList(),
val vectorMemories: List<VectorMemoryDto> = 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<MemoryUiState> = _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<String>?, 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)
}
}

View File

@@ -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() }
)
}
}
}

View File

@@ -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}

View File

@@ -151,6 +151,7 @@ class Settings(BaseSettings):
class Config:
env_file = str(_ENV_PATH)
case_sensitive = True
extra = "ignore"
settings = Settings()

View File

@@ -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

View File

@@ -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)")

View File

@@ -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 向量")

View File

@@ -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(