feat: agent memory management — CRUD API + Android management screen
Some checks failed
CI/CD Pipeline / Backend — Lint & Test (push) Has been cancelled
CI/CD Pipeline / Frontend — Lint & Build (push) Has been cancelled
CI/CD Pipeline / Docker — Build Check (push) Has been cancelled

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