Переглянути джерело

feat: 添加智能体记忆功能

- ApplicationMemory 模型支持长期记忆存储
- 记忆 CRUD API
- 前端记忆配置对话框

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
mengboxin137-blip 6 днів тому
батько
коміт
385a39490b

+ 46 - 0
apps/application/models/application_memory.py

@@ -0,0 +1,46 @@
+# coding=utf-8
+"""
+智能体记忆模型
+存储智能体的长期记忆,支持对话过程中积累和检索
+"""
+import uuid_utils.compat as uuid
+from django.db import models
+
+from common.mixins.app_model_mixin import AppModelMixin
+
+
+class ApplicationMemoryType(models.TextChoices):
+    """记忆类型"""
+    DIALOGUE = 'DIALOGUE', '对话记忆'
+    KEYWORD = 'KEYWORD', '关键词记忆'
+    SUMMARY = 'SUMMARY', '摘要记忆'
+    CUSTOM = 'CUSTOM', '自定义记忆'
+
+
+class ApplicationMemory(AppModelMixin):
+    """智能体记忆"""
+    id = models.UUIDField(primary_key=True, max_length=128, default=uuid.uuid7, editable=False, verbose_name="主键id")
+    application = models.ForeignKey(
+        'application.Application',
+        on_delete=models.CASCADE,
+        db_constraint=False,
+        verbose_name="应用",
+        db_index=True
+    )
+    memory_type = models.CharField(
+        max_length=20,
+        choices=ApplicationMemoryType.choices,
+        default=ApplicationMemoryType.DIALOGUE,
+        verbose_name="记忆类型"
+    )
+    content = models.TextField(verbose_name="记忆内容")
+    metadata = models.JSONField(default=dict, verbose_name="元数据")
+    is_enabled = models.BooleanField(default=True, verbose_name="是否启用")
+    relevance_score = models.FloatField(default=0.0, verbose_name="相关度分数")
+
+    class Meta:
+        db_table = "application_memory"
+        ordering = ['-relevance_score', '-create_time']
+
+    def __str__(self):
+        return f"{self.application_id} - {self.memory_type}: {self.content[:50]}"

+ 126 - 0
apps/application/services/application_memory_service.py

@@ -0,0 +1,126 @@
+# coding=utf-8
+"""
+智能体记忆服务
+提供记忆的 CRUD 操作和检索功能
+"""
+from typing import List, Optional
+from django.db.models import QuerySet
+from application.models.application_memory import ApplicationMemory, ApplicationMemoryType
+from common.utils.logger import maxkb_logger
+
+
+class ApplicationMemoryService:
+    """智能体记忆服务"""
+
+    @staticmethod
+    def get_memories(application_id: str, memory_type: str = None,
+                     is_enabled: bool = True, limit: int = 100) -> QuerySet:
+        """获取应用的记忆列表"""
+        queryset = ApplicationMemory.objects.filter(
+            application_id=application_id,
+            is_enabled=is_enabled
+        )
+        if memory_type:
+            queryset = queryset.filter(memory_type=memory_type)
+        return queryset.order_by('-relevance_score', '-create_time')[:limit]
+
+    @staticmethod
+    def get_memory(memory_id: str) -> Optional[ApplicationMemory]:
+        """获取单条记忆"""
+        try:
+            return ApplicationMemory.objects.get(id=memory_id)
+        except ApplicationMemory.DoesNotExist:
+            return None
+
+    @staticmethod
+    def create_memory(application_id: str, content: str,
+                      memory_type: str = ApplicationMemoryType.DIALOGUE,
+                      metadata: dict = None) -> ApplicationMemory:
+        """创建记忆"""
+        return ApplicationMemory.objects.create(
+            application_id=application_id,
+            content=content,
+            memory_type=memory_type,
+            metadata=metadata or {}
+        )
+
+    @staticmethod
+    def update_memory(memory_id: str, content: str = None,
+                      memory_type: str = None, is_enabled: bool = None,
+                      relevance_score: float = None,
+                      metadata: dict = None) -> Optional[ApplicationMemory]:
+        """更新记忆"""
+        memory = ApplicationMemoryService.get_memory(memory_id)
+        if not memory:
+            return None
+
+        if content is not None:
+            memory.content = content
+        if memory_type is not None:
+            memory.memory_type = memory_type
+        if is_enabled is not None:
+            memory.is_enabled = is_enabled
+        if relevance_score is not None:
+            memory.relevance_score = relevance_score
+        if metadata is not None:
+            memory.metadata = metadata
+
+        memory.save()
+        return memory
+
+    @staticmethod
+    def delete_memory(memory_id: str) -> bool:
+        """删除记忆"""
+        memory = ApplicationMemoryService.get_memory(memory_id)
+        if not memory:
+            return False
+        memory.delete()
+        return True
+
+    @staticmethod
+    def batch_delete_memories(memory_ids: List[str]) -> int:
+        """批量删除记忆"""
+        return ApplicationMemory.objects.filter(id__in=memory_ids).delete()[0]
+
+    @staticmethod
+    def search_memories(application_id: str, query: str,
+                        limit: int = 10) -> List[ApplicationMemory]:
+        """搜索记忆(简单文本匹配)"""
+        return list(
+            ApplicationMemory.objects.filter(
+                application_id=application_id,
+                is_enabled=True,
+                content__icontains=query
+            ).order_by('-relevance_score', '-create_time')[:limit]
+        )
+
+    @staticmethod
+    def get_memory_context(application_id: str, query: str = None,
+                           max_tokens: int = 2000) -> str:
+        """获取记忆上下文(用于注入到对话 prompt)"""
+        memories = ApplicationMemoryService.get_memories(
+            application_id=application_id,
+            is_enabled=True
+        )
+
+        if query:
+            # 如果有查询,优先返回相关记忆
+            relevant = ApplicationMemoryService.search_memories(
+                application_id=application_id,
+                query=query
+            )
+            if relevant:
+                memories = relevant
+
+        context_parts = []
+        current_tokens = 0
+
+        for memory in memories:
+            # 简单估算 token 数(中文约 1.5 字符/token)
+            estimated_tokens = len(memory.content) / 1.5
+            if current_tokens + estimated_tokens > max_tokens:
+                break
+            context_parts.append(memory.content)
+            current_tokens += estimated_tokens
+
+        return "\n".join(context_parts)

+ 155 - 0
apps/application/views/application_memory.py

@@ -0,0 +1,155 @@
+# coding=utf-8
+"""
+智能体记忆视图
+提供记忆的 CRUD API
+"""
+from django.utils.translation import gettext as _
+from rest_framework.request import Request
+from rest_framework.views import APIView
+
+from common.auth import TokenAuth
+from common.exception.app_exception import AppApiException
+from common.result import result
+from application.services.application_memory_service import ApplicationMemoryService
+
+
+class ApplicationMemoryView(APIView):
+    """智能体记忆管理"""
+    authentication_classes = [TokenAuth]
+
+    class List(APIView):
+        """获取记忆列表"""
+        authentication_classes = [TokenAuth]
+
+        def get(self, request: Request, application_id: str):
+            memory_type = request.query_params.get('memory_type')
+            is_enabled = request.query_params.get('is_enabled', 'true') == 'true'
+            limit = int(request.query_params.get('limit', 100))
+
+            memories = ApplicationMemoryService.get_memories(
+                application_id=application_id,
+                memory_type=memory_type,
+                is_enabled=is_enabled,
+                limit=limit
+            )
+
+            data = [{
+                'id': str(m.id),
+                'memory_type': m.memory_type,
+                'content': m.content,
+                'metadata': m.metadata,
+                'is_enabled': m.is_enabled,
+                'relevance_score': m.relevance_score,
+                'create_time': m.create_time.isoformat() if m.create_time else None,
+                'update_time': m.update_time.isoformat() if m.update_time else None,
+            } for m in memories]
+
+            return result.success(data)
+
+    class Create(APIView):
+        """创建记忆"""
+        authentication_classes = [TokenAuth]
+
+        def post(self, request: Request, application_id: str):
+            content = request.data.get('content', '')
+            memory_type = request.data.get('memory_type', 'DIALOGUE')
+            metadata = request.data.get('metadata', {})
+
+            if not content:
+                raise AppApiException(400, _('记忆内容不能为空'))
+
+            memory = ApplicationMemoryService.create_memory(
+                application_id=application_id,
+                content=content,
+                memory_type=memory_type,
+                metadata=metadata
+            )
+
+            return result.success({
+                'id': str(memory.id),
+                'memory_type': memory.memory_type,
+                'content': memory.content,
+                'metadata': memory.metadata,
+                'is_enabled': memory.is_enabled,
+                'relevance_score': memory.relevance_score,
+                'create_time': memory.create_time.isoformat() if memory.create_time else None,
+            })
+
+    class Operate(APIView):
+        """记忆操作(更新/删除)"""
+        authentication_classes = [TokenAuth]
+
+        def put(self, request: Request, application_id: str, memory_id: str):
+            content = request.data.get('content')
+            memory_type = request.data.get('memory_type')
+            is_enabled = request.data.get('is_enabled')
+            relevance_score = request.data.get('relevance_score')
+            metadata = request.data.get('metadata')
+
+            memory = ApplicationMemoryService.update_memory(
+                memory_id=memory_id,
+                content=content,
+                memory_type=memory_type,
+                is_enabled=is_enabled,
+                relevance_score=relevance_score,
+                metadata=metadata
+            )
+
+            if not memory:
+                raise AppApiException(404, _('记忆不存在'))
+
+            return result.success({
+                'id': str(memory.id),
+                'memory_type': memory.memory_type,
+                'content': memory.content,
+                'metadata': memory.metadata,
+                'is_enabled': memory.is_enabled,
+                'relevance_score': memory.relevance_score,
+                'update_time': memory.update_time.isoformat() if memory.update_time else None,
+            })
+
+        def delete(self, request: Request, application_id: str, memory_id: str):
+            success = ApplicationMemoryService.delete_memory(memory_id)
+            if not success:
+                raise AppApiException(404, _('记忆不存在'))
+            return result.success(True)
+
+    class BatchDelete(APIView):
+        """批量删除记忆"""
+        authentication_classes = [TokenAuth]
+
+        def post(self, request: Request, application_id: str):
+            memory_ids = request.data.get('memory_ids', [])
+            if not memory_ids:
+                raise AppApiException(400, _('请选择要删除的记忆'))
+
+            count = ApplicationMemoryService.batch_delete_memories(memory_ids)
+            return result.success({'deleted_count': count})
+
+    class Search(APIView):
+        """搜索记忆"""
+        authentication_classes = [TokenAuth]
+
+        def get(self, request: Request, application_id: str):
+            query = request.query_params.get('query', '')
+            limit = int(request.query_params.get('limit', 10))
+
+            if not query:
+                raise AppApiException(400, _('搜索关键词不能为空'))
+
+            memories = ApplicationMemoryService.search_memories(
+                application_id=application_id,
+                query=query,
+                limit=limit
+            )
+
+            data = [{
+                'id': str(m.id),
+                'memory_type': m.memory_type,
+                'content': m.content,
+                'metadata': m.metadata,
+                'relevance_score': m.relevance_score,
+                'create_time': m.create_time.isoformat() if m.create_time else None,
+            } for m in memories]
+
+            return result.success(data)

+ 85 - 0
ui/src/api/application/memory.ts

@@ -0,0 +1,85 @@
+import { get, post, put, del } from '@/request/index'
+import { type Ref } from 'vue'
+import type { Result } from '@/request/Result'
+
+export interface ApplicationMemory {
+  id: string
+  memory_type: 'DIALOGUE' | 'KEYWORD' | 'SUMMARY' | 'CUSTOM'
+  content: string
+  metadata: Record<string, any>
+  is_enabled: boolean
+  relevance_score: number
+  create_time: string
+  update_time: string
+}
+
+export interface CreateMemoryParams {
+  content: string
+  memory_type?: string
+  metadata?: Record<string, any>
+}
+
+export interface UpdateMemoryParams {
+  content?: string
+  memory_type?: string
+  is_enabled?: boolean
+  relevance_score?: number
+  metadata?: Record<string, any>
+}
+
+const prefix = 'workspace/default/application'
+
+// 获取记忆列表
+export function getMemoryList(
+  applicationId: string,
+  params?: { memory_type?: string; is_enabled?: boolean; limit?: number },
+  loading?: Ref<boolean>,
+): Promise<Result<ApplicationMemory[]>> {
+  return get(`${prefix}/${applicationId}/memory`, params, loading)
+}
+
+// 创建记忆
+export function createMemory(
+  applicationId: string,
+  data: CreateMemoryParams,
+  loading?: Ref<boolean>,
+): Promise<Result<ApplicationMemory>> {
+  return post(`${prefix}/${applicationId}/memory/create`, data, loading)
+}
+
+// 更新记忆
+export function updateMemory(
+  applicationId: string,
+  memoryId: string,
+  data: UpdateMemoryParams,
+  loading?: Ref<boolean>,
+): Promise<Result<ApplicationMemory>> {
+  return put(`${prefix}/${applicationId}/memory/${memoryId}`, data, loading)
+}
+
+// 删除记忆
+export function deleteMemory(
+  applicationId: string,
+  memoryId: string,
+  loading?: Ref<boolean>,
+): Promise<Result<boolean>> {
+  return del(`${prefix}/${applicationId}/memory/${memoryId}`, undefined, loading)
+}
+
+// 批量删除记忆
+export function batchDeleteMemories(
+  applicationId: string,
+  memoryIds: string[],
+  loading?: Ref<boolean>,
+): Promise<Result<{ deleted_count: number }>> {
+  return post(`${prefix}/${applicationId}/memory/batch_delete`, { memory_ids: memoryIds }, loading)
+}
+
+// 搜索记忆
+export function searchMemories(
+  applicationId: string,
+  params: { query: string; limit?: number },
+  loading?: Ref<boolean>,
+): Promise<Result<ApplicationMemory[]>> {
+  return get(`${prefix}/${applicationId}/memory/search`, params, loading)
+}

+ 271 - 0
ui/src/views/application/component/MemorySettingDialog.vue

@@ -0,0 +1,271 @@
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    :title="$t('views.application.memory.title')"
+    width="700px"
+    :close-on-click-modal="false"
+  >
+    <div class="memory-container">
+      <!-- 记忆开关 -->
+      <div class="mb-16">
+        <el-switch
+          v-model="memoryEnable"
+          :active-text="$t('views.application.memory.enable')"
+          @change="handleEnableChange"
+        />
+      </div>
+
+      <!-- 记忆列表 -->
+      <div v-if="memoryEnable" class="memory-list">
+        <div class="flex-between mb-16">
+          <el-input
+            v-model="searchQuery"
+            :placeholder="$t('views.application.memory.searchPlaceholder')"
+            clearable
+            @keyup.enter="handleSearch"
+            style="width: 300px"
+          >
+            <template #prefix>
+              <el-icon><Search /></el-icon>
+            </template>
+          </el-input>
+          <el-button type="primary" @click="handleCreate">
+            {{ $t('views.application.memory.add') }}
+          </el-button>
+        </div>
+
+        <el-table :data="memoryList" v-loading="loading" style="width: 100%">
+          <el-table-column prop="content" :label="$t('views.application.memory.content')" show-overflow-tooltip />
+          <el-table-column prop="memory_type" :label="$t('views.application.memory.type')" width="120">
+            <template #default="{ row }">
+              <el-tag :type="getMemoryTypeTag(row.memory_type)">
+                {{ getMemoryTypeLabel(row.memory_type) }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="is_enabled" :label="$t('views.application.memory.status')" width="100">
+            <template #default="{ row }">
+              <el-switch v-model="row.is_enabled" @change="handleStatusChange(row)" />
+            </template>
+          </el-table-column>
+          <el-table-column :label="$t('common.operation')" width="150">
+            <template #default="{ row }">
+              <el-button type="primary" link @click="handleEdit(row)">
+                {{ $t('common.edit') }}
+              </el-button>
+              <el-button type="danger" link @click="handleDelete(row)">
+                {{ $t('common.delete') }}
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </div>
+
+    <template #footer>
+      <el-button @click="dialogVisible = false">
+        {{ $t('common.close') }}
+      </el-button>
+    </template>
+  </el-dialog>
+
+  <!-- 创建/编辑对话框 -->
+  <el-dialog
+    v-model="editDialogVisible"
+    :title="editingMemory ? $t('views.application.memory.edit') : $t('views.application.memory.add')"
+    width="500px"
+  >
+    <el-form :model="editForm" label-position="top">
+      <el-form-item :label="$t('views.application.memory.type')">
+        <el-select v-model="editForm.memory_type" style="width: 100%">
+          <el-option label="对话记忆" value="DIALOGUE" />
+          <el-option label="关键词记忆" value="KEYWORD" />
+          <el-option label="摘要记忆" value="SUMMARY" />
+          <el-option label="自定义记忆" value="CUSTOM" />
+        </el-select>
+      </el-form-item>
+      <el-form-item :label="$t('views.application.memory.content')">
+        <el-input
+          v-model="editForm.content"
+          type="textarea"
+          :rows="4"
+          :placeholder="$t('views.application.memory.contentPlaceholder')"
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="editDialogVisible = false">
+        {{ $t('common.cancel') }}
+      </el-button>
+      <el-button type="primary" @click="handleSave" :loading="saving">
+        {{ $t('common.save') }}
+      </el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { Search } from '@element-plus/icons-vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import {
+  getMemoryList,
+  createMemory,
+  updateMemory,
+  deleteMemory,
+  searchMemories,
+} from '@/api/application/memory'
+import type { ApplicationMemory } from '@/api/application/memory'
+
+const props = defineProps<{
+  applicationId: string
+}>()
+
+const dialogVisible = ref(false)
+const editDialogVisible = ref(false)
+const loading = ref(false)
+const saving = ref(false)
+const memoryEnable = ref(false)
+const searchQuery = ref('')
+const memoryList = ref<ApplicationMemory[]>([])
+const editingMemory = ref<ApplicationMemory | null>(null)
+
+const editForm = ref({
+  memory_type: 'DIALOGUE',
+  content: '',
+})
+
+const getMemoryTypeTag = (type: string) => {
+  const map: Record<string, string> = {
+    DIALOGUE: '',
+    KEYWORD: 'success',
+    SUMMARY: 'warning',
+    CUSTOM: 'info',
+  }
+  return map[type] || ''
+}
+
+const getMemoryTypeLabel = (type: string) => {
+  const map: Record<string, string> = {
+    DIALOGUE: '对话记忆',
+    KEYWORD: '关键词记忆',
+    SUMMARY: '摘要记忆',
+    CUSTOM: '自定义记忆',
+  }
+  return map[type] || type
+}
+
+const loadMemoryList = async () => {
+  loading.value = true
+  try {
+    const res = await getMemoryList(props.applicationId)
+    memoryList.value = res.data || []
+  } catch (error) {
+    console.error('Failed to load memory list:', error)
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleEnableChange = (value: boolean) => {
+  // TODO: 保存记忆开关配置到应用设置
+  if (value) {
+    loadMemoryList()
+  }
+}
+
+const handleSearch = async () => {
+  if (!searchQuery.value) {
+    loadMemoryList()
+    return
+  }
+  loading.value = true
+  try {
+    const res = await searchMemories(props.applicationId, { query: searchQuery.value })
+    memoryList.value = res.data || []
+  } catch (error) {
+    console.error('Failed to search memories:', error)
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleCreate = () => {
+  editingMemory.value = null
+  editForm.value = { memory_type: 'DIALOGUE', content: '' }
+  editDialogVisible.value = true
+}
+
+const handleEdit = (memory: ApplicationMemory) => {
+  editingMemory.value = memory
+  editForm.value = {
+    memory_type: memory.memory_type,
+    content: memory.content,
+  }
+  editDialogVisible.value = true
+}
+
+const handleSave = async () => {
+  if (!editForm.value.content.trim()) {
+    ElMessage.warning('请输入记忆内容')
+    return
+  }
+
+  saving.value = true
+  try {
+    if (editingMemory.value) {
+      await updateMemory(props.applicationId, editingMemory.value.id, editForm.value)
+      ElMessage.success('更新成功')
+    } else {
+      await createMemory(props.applicationId, editForm.value)
+      ElMessage.success('创建成功')
+    }
+    editDialogVisible.value = false
+    loadMemoryList()
+  } catch (error) {
+    console.error('Failed to save memory:', error)
+  } finally {
+    saving.value = false
+  }
+}
+
+const handleStatusChange = async (memory: ApplicationMemory) => {
+  try {
+    await updateMemory(props.applicationId, memory.id, { is_enabled: memory.is_enabled })
+  } catch (error) {
+    console.error('Failed to update memory status:', error)
+    memory.is_enabled = !memory.is_enabled
+  }
+}
+
+const handleDelete = async (memory: ApplicationMemory) => {
+  try {
+    await ElMessageBox.confirm('确定要删除这条记忆吗?', '提示', {
+      type: 'warning',
+    })
+    await deleteMemory(props.applicationId, memory.id)
+    ElMessage.success('删除成功')
+    loadMemoryList()
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error('Failed to delete memory:', error)
+    }
+  }
+}
+
+const open = () => {
+  dialogVisible.value = true
+  loadMemoryList()
+}
+
+defineExpose({ open })
+</script>
+
+<style scoped>
+.memory-container {
+  min-height: 400px;
+}
+.memory-list {
+  margin-top: 16px;
+}
+</style>