From 944b81ce09d9141f5de47cce84b12db4f7810466 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 21 Nov 2025 20:32:58 +0800 Subject: [PATCH] feat: agentic memory --- astrbot/core/core_lifecycle.py | 4 + astrbot/core/db/vec_db/base.py | 11 +- astrbot/core/memory/DESIGN.excalidraw | 822 ++++++++++++++++++ astrbot/core/memory/_README.md | 76 ++ astrbot/core/memory/entities.py | 63 ++ astrbot/core/memory/mem_db_sqlite.py | 174 ++++ astrbot/core/memory/memory_manager.py | 281 ++++++ astrbot/core/memory/tools.py | 156 ++++ .../process_stage/method/llm_request.py | 13 + astrbot/core/star/context.py | 3 + astrbot/dashboard/routes/__init__.py | 2 + astrbot/dashboard/routes/memory.py | 174 ++++ astrbot/dashboard/server.py | 1 + .../components/chat/ConversationSidebar.vue | 1 - dashboard/src/composables/useMessages.ts | 1 - .../i18n/locales/en-US/core/navigation.json | 1 + .../i18n/locales/zh-CN/core/navigation.json | 1 + .../full/vertical-sidebar/sidebarItem.ts | 5 + dashboard/src/router/MainRoutes.ts | 5 + dashboard/src/views/MemoryPage.vue | 358 ++++++++ 20 files changed, 2149 insertions(+), 3 deletions(-) create mode 100644 astrbot/core/memory/DESIGN.excalidraw create mode 100644 astrbot/core/memory/_README.md create mode 100644 astrbot/core/memory/entities.py create mode 100644 astrbot/core/memory/mem_db_sqlite.py create mode 100644 astrbot/core/memory/memory_manager.py create mode 100644 astrbot/core/memory/tools.py create mode 100644 astrbot/dashboard/routes/memory.py create mode 100644 dashboard/src/views/MemoryPage.vue diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 17fd52138..f20bf6c9e 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -24,6 +24,7 @@ from astrbot.core.db.migration.migra_45_to_46 import migrate_45_to_46 from astrbot.core.db.migration.migra_webchat_session import migrate_webchat_session from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager +from astrbot.core.memory.memory_manager import MemoryManager from astrbot.core.persona_mgr import PersonaManager from astrbot.core.pipeline.scheduler import PipelineContext, PipelineScheduler from astrbot.core.platform.manager import PlatformManager @@ -136,6 +137,8 @@ async def initialize(self) -> None: # 初始化知识库管理器 self.kb_manager = KnowledgeBaseManager(self.provider_manager) + # 初始化记忆管理器 + self.memory_manager = MemoryManager() # 初始化提供给插件的上下文 self.star_context = Context( @@ -149,6 +152,7 @@ async def initialize(self) -> None: self.persona_mgr, self.astrbot_config_mgr, self.kb_manager, + self.memory_manager, ) # 初始化插件管理器 diff --git a/astrbot/core/db/vec_db/base.py b/astrbot/core/db/vec_db/base.py index 7440b6f2a..732de2431 100644 --- a/astrbot/core/db/vec_db/base.py +++ b/astrbot/core/db/vec_db/base.py @@ -1,11 +1,20 @@ import abc from dataclasses import dataclass +from typing import TypedDict @dataclass class Result: + class ResultData(TypedDict): + id: str + doc_id: str + text: str + metadata: str + created_at: int + updated_at: int + similarity: float - data: dict + data: ResultData | dict class BaseVecDB: diff --git a/astrbot/core/memory/DESIGN.excalidraw b/astrbot/core/memory/DESIGN.excalidraw new file mode 100644 index 000000000..e98b28caa --- /dev/null +++ b/astrbot/core/memory/DESIGN.excalidraw @@ -0,0 +1,822 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor", + "elements": [ + { + "id": "l6cYurMvF69IM4Kc33Qou", + "type": "rectangle", + "x": 173.140625, + "y": -29.0234375, + "width": 92.95703125, + "height": 77.109375, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a0", + "roundness": { + "type": 3 + }, + "seed": 1409469537, + "version": 91, + "versionNonce": 307958671, + "isDeleted": false, + "boundElements": [], + "updated": 1763703733605, + "link": null, + "locked": false + }, + { + "id": "1ZvS6t8U6ihUjNU0dakgl", + "type": "arrow", + "x": 409.30859375, + "y": 9.6875, + "width": 118.2734375, + "height": 1.9609375, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a1", + "roundness": { + "type": 2 + }, + "seed": 326508865, + "version": 120, + "versionNonce": 199367023, + "isDeleted": false, + "boundElements": null, + "updated": 1763703733605, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -118.2734375, + -1.9609375 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "tfdUGiJdcMoOHGfqFHXK6", + "type": "text", + "x": 153.46875, + "y": -70.9765625, + "width": 136.4598846435547, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a2", + "roundness": null, + "seed": 688712865, + "version": 67, + "versionNonce": 300660705, + "isDeleted": false, + "boundElements": null, + "updated": 1763703743816, + "link": null, + "locked": false, + "text": "FAISS+SQLite", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "FAISS+SQLite", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "AeL3kEB9a8_TAvAXpAbpl", + "type": "text", + "x": 438.36328125, + "y": -3.78125, + "width": 116.109375, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a3", + "roundness": null, + "seed": 788579535, + "version": 33, + "versionNonce": 946602095, + "isDeleted": false, + "boundElements": null, + "updated": 1763703932431, + "link": null, + "locked": false, + "text": "FACT", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "FACT", + "autoResize": false, + "lineHeight": 1.25 + }, + { + "id": "Pe3TeMZvxQ8tRTcbD5v6P", + "type": "arrow", + "x": 297.125, + "y": 40.2578125, + "width": 120.2421875, + "height": 1.421875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a4", + "roundness": { + "type": 2 + }, + "seed": 1146229999, + "version": 44, + "versionNonce": 636917679, + "isDeleted": false, + "boundElements": null, + "updated": 1763703759050, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 120.2421875, + 1.421875 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "GhmQoadtQRK8c8aEEbYKQ", + "type": "text", + "x": 283.53515625, + "y": 64.76171875, + "width": 130.85989379882812, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a5", + "roundness": null, + "seed": 1445650959, + "version": 79, + "versionNonce": 566193167, + "isDeleted": false, + "boundElements": null, + "updated": 1763703768982, + "link": null, + "locked": false, + "text": "top-n Similary\n", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "top-n Similary\n", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "uTEFJs8cNS09WFq2pi9P7", + "type": "rectangle", + "x": 528.1586158430439, + "y": -173.43472375183552, + "width": 135.7578125, + "height": 128.73828125, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a6", + "roundness": { + "type": 3 + }, + "seed": 223409231, + "version": 44, + "versionNonce": 1066827105, + "isDeleted": false, + "boundElements": [ + { + "id": "FfWdx1_yCq6UYfXamJX9N", + "type": "arrow" + } + ], + "updated": 1763704050188, + "link": null, + "locked": false + }, + { + "id": "2SzqzpJ4C2ymVj8-8vN7H", + "type": "text", + "x": 548.1480270948795, + "y": -211, + "width": 86.43992614746094, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a7", + "roundness": null, + "seed": 1015608623, + "version": 23, + "versionNonce": 950374849, + "isDeleted": false, + "boundElements": null, + "updated": 1763704047884, + "link": null, + "locked": false, + "text": "Memories", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Memories", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "CgW6Yf9v0a9q1tsjhDl7b", + "type": "text", + "x": 568.3099317299038, + "y": -154.69469411681115, + "width": 62.099945068359375, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aA", + "roundness": null, + "seed": 452254927, + "version": 10, + "versionNonce": 972895023, + "isDeleted": false, + "boundElements": null, + "updated": 1763704057762, + "link": null, + "locked": false, + "text": "chunk1", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "chunk1", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "knvlKpaFZ8lY-73Y-e9W6", + "type": "text", + "x": 569.11328125, + "y": -116.91056665512056, + "width": 67.55995178222656, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aB", + "roundness": null, + "seed": 914644015, + "version": 90, + "versionNonce": 158135631, + "isDeleted": false, + "boundElements": null, + "updated": 1763704057762, + "link": null, + "locked": false, + "text": "chunk2", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "chunk2", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "Q7URqvTSMpvj08ye-afTT", + "type": "rectangle", + "x": 444.515625, + "y": 36.7890625, + "width": 58.859375, + "height": 29.41796875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aC", + "roundness": { + "type": 3 + }, + "seed": 1642537601, + "version": 19, + "versionNonce": 948406575, + "isDeleted": false, + "boundElements": null, + "updated": 1763703870173, + "link": null, + "locked": false + }, + { + "id": "JjxBt9cZIZXNTd6CmwyKL", + "type": "rectangle", + "x": 452.203125, + "y": 46.064453125, + "width": 58.859375, + "height": 29.41796875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aD", + "roundness": { + "type": 3 + }, + "seed": 1746916641, + "version": 40, + "versionNonce": 1650978255, + "isDeleted": false, + "boundElements": [], + "updated": 1763703871882, + "link": null, + "locked": false + }, + { + "id": "XGBCPPFnjriqsL8LvLwyQ", + "type": "rectangle", + "x": 461.56640625, + "y": 56.162109375, + "width": 58.859375, + "height": 29.41796875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aE", + "roundness": { + "type": 3 + }, + "seed": 529794575, + "version": 85, + "versionNonce": 2131900641, + "isDeleted": false, + "boundElements": [], + "updated": 1763703874182, + "link": null, + "locked": false + }, + { + "id": "FfWdx1_yCq6UYfXamJX9N", + "type": "arrow", + "x": 537.6875, + "y": 48.203125, + "width": 6.615850226297994, + "height": 75.81335873223107, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aF", + "roundness": { + "type": 2 + }, + "seed": 1982870689, + "version": 90, + "versionNonce": 25307457, + "isDeleted": false, + "boundElements": null, + "updated": 1763704050188, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 6.615850226297994, + -75.81335873223107 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": { + "elementId": "uTEFJs8cNS09WFq2pi9P7", + "focus": 0.6071885090336794, + "gap": 24.64453125 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "jgJgqGMRWcaNX_28wY4CU", + "type": "text", + "x": 570, + "y": 10, + "width": 67.11994934082031, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aG", + "roundness": null, + "seed": 1065220559, + "version": 26, + "versionNonce": 2115991521, + "isDeleted": false, + "boundElements": null, + "updated": 1763703959397, + "link": null, + "locked": false, + "text": "update", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "update", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "_5pSPPOpp9h1TpFCIc055", + "type": "text", + "x": 292.36328125, + "y": -138.5703125, + "width": 122.87992858886719, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aH", + "roundness": null, + "seed": 51461025, + "version": 26, + "versionNonce": 1647492655, + "isDeleted": false, + "boundElements": null, + "updated": 1763703925147, + "link": null, + "locked": false, + "text": "ADD Memory", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "ADD Memory", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "YG6MdL14l7lk4ypQNMZ_k", + "type": "text", + "x": 296.71885397566257, + "y": 161.399157096715, + "width": 295.27984619140625, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aJ", + "roundness": null, + "seed": 1183210273, + "version": 122, + "versionNonce": 1702733281, + "isDeleted": false, + "boundElements": [], + "updated": 1763704085083, + "link": null, + "locked": false, + "text": "RETRIEVE Memory (STATIC)", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "RETRIEVE Memory (STATIC)", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "Foa3VPJYqhj1uAX5mn3n0", + "type": "rectangle", + "x": 324.7616636099071, + "y": 248.63213980937013, + "width": 135.7578125, + "height": 128.73828125, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aL", + "roundness": { + "type": 3 + }, + "seed": 995116257, + "version": 225, + "versionNonce": 1886900225, + "isDeleted": false, + "boundElements": [], + "updated": 1763704055846, + "link": null, + "locked": false + }, + { + "id": "pe3veI_yBFKYtbaJwDKQT", + "type": "text", + "x": 344.7510748617428, + "y": 211.06686356120565, + "width": 86.43992614746094, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aM", + "roundness": null, + "seed": 26673345, + "version": 204, + "versionNonce": 1004546017, + "isDeleted": false, + "boundElements": [], + "updated": 1763704055846, + "link": null, + "locked": false, + "text": "Memories", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Memories", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "bOlhO8AaKE86_43viu5UG", + "type": "text", + "x": 365.50408375566445, + "y": 269.24725381983865, + "width": 62.099945068359375, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aN", + "roundness": null, + "seed": 1849784033, + "version": 106, + "versionNonce": 762320737, + "isDeleted": false, + "boundElements": [], + "updated": 1763704060295, + "link": null, + "locked": false, + "text": "chunk1", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "chunk1", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "V_iDW10PKwMe7vWb5S5HF", + "type": "text", + "x": 366.3074332757606, + "y": 307.03138128152926, + "width": 67.55995178222656, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aO", + "roundness": null, + "seed": 1670509249, + "version": 186, + "versionNonce": 1964540737, + "isDeleted": false, + "boundElements": [], + "updated": 1763704060295, + "link": null, + "locked": false, + "text": "chunk2", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "chunk2", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "LHKMRdSowgcl2LsKacxTz", + "type": "text", + "x": 484.9493410573871, + "y": 292.45619471187945, + "width": 273.579833984375, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aP", + "roundness": null, + "seed": 945666991, + "version": 104, + "versionNonce": 1512137505, + "isDeleted": false, + "boundElements": null, + "updated": 1763704096016, + "link": null, + "locked": false, + "text": "RANKED By DECAY SCORE,\nTOP K", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "RANKED By DECAY SCORE,\nTOP K", + "autoResize": true, + "lineHeight": 1.25 + } + ], + "appState": { + "gridSize": 20, + "gridStep": 5, + "gridModeEnabled": false, + "viewBackgroundColor": "#ffffff" + }, + "files": {} +} \ No newline at end of file diff --git a/astrbot/core/memory/_README.md b/astrbot/core/memory/_README.md new file mode 100644 index 000000000..af32ae54f --- /dev/null +++ b/astrbot/core/memory/_README.md @@ -0,0 +1,76 @@ +## Decay Score + +记忆衰减分数定义为: + +\[ +\text{decay\_score} += \alpha \cdot e^{-\lambda \cdot \Delta t \cdot \beta} + ++ (1-\alpha)\cdot (1 - e^{-\gamma \cdot c}) +\] + +其中: + ++ \(\Delta t\):自上次检索以来经过的时间(天),由 `last_retrieval_at` 计算; ++ \(c\):检索次数,对应字段 `retrieval_count`; ++ \(\alpha\):控制时间衰减和检索次数影响的权重; ++ \(\gamma\):控制检索次数影响的速率; ++ \(\lambda\):控制时间衰减的速率; ++ \(\beta\):时间衰减调节因子; + +\[ +\beta = \frac{1}{1 + a \cdot c} +\] + ++ \(a\):控制检索次数对时间衰减影响的权重。 + +## ADD MEMORY + ++ LLM 通过 `astr_add_memory` 工具调用,传入记忆内容和记忆类型。 ++ 生成 `mem_id = uuid4()`。 ++ 从上下文中获取 `owner_id = unified_message_origin`。 + +步骤: + +1. 使用 VecDB 以新记忆内容为 query,检索前 20 条相似记忆。 +2. 从中取相似度最高的前 5 条: + + 若相似度超过“合并阈值”(如 `sim >= merge_threshold`): + + 将该条记忆视为同一记忆,使用 LLM 将旧内容与新内容合并; + + 在同一个 `mem_id` 上更新 MemoryDB 和 VecDB(UPDATE,而非新建)。 + + 否则: + + 作为全新的记忆插入: + + 写入 VecDB(metadata 中包含 `mem_id`, `owner_id`); + + 写入 MemoryDB 的 `memory_chunks` 表,初始化: + + `created_at = now` + + `last_retrieval_at = now` + + `retrieval_count = 1` 等。 +3. 对 VecDB 返回的前 20 条记忆,如果相似度高于某个“赫布阈值”(`hebb_threshold`),则: + + `retrieval_count += 1` + + `last_retrieval_at = now` + +这一步体现了赫布学习:与新记忆共同被激活的旧记忆会获得一次强化。 + +## QUERY MEMORY (STATIC) + ++ LLM 通过 `astr_query_memory` 工具调用,无参数。 + +步骤: + +1. 从 MemoryDB 的 `memory_chunks` 表中查询当前用户所有活跃记忆: + + `SELECT * FROM memory_chunks WHERE owner_id = ? AND is_active = 1` +2. 对每条记忆,根据 `last_retrieval_at` 和 `retrieval_count` 计算对应的 `decay_score`。 +3. 按 `decay_score` 从高到低排序,返回前 `top_k` 条记忆内容给 LLM。 +4. 对返回的这 `top_k` 条记忆: + + `retrieval_count += 1` + + `last_retrieval_at = now` + +## QUERY MEMORY (DYNAMIC)(暂不实现) + ++ LLM 提供查询内容作为语义 query。 ++ 使用 VecDB 检索与该 query 最相似的前 `N` 条记忆(`N > top_k`)。 ++ 根据 `mem_id` 从 `memory_chunks` 中加载对应记录。 ++ 对这批候选记忆计算: + + 语义相似度(来自 VecDB) + + `decay_score` + + 最终排序分数(例如 `w1 * sim + w2 * decay_score`) ++ 按最终排序分数从高到低返回前 `top_k` 条记忆内容,并更新它们的 `retrieval_count` 和 `last_retrieval_at`。 diff --git a/astrbot/core/memory/entities.py b/astrbot/core/memory/entities.py new file mode 100644 index 000000000..0233ed0a6 --- /dev/null +++ b/astrbot/core/memory/entities.py @@ -0,0 +1,63 @@ +import uuid +from datetime import datetime, timezone + +import numpy as np +from sqlmodel import Field, MetaData, SQLModel + +MEMORY_TYPE_IMPORTANCE = {"persona": 1.3, "fact": 1.0, "ephemeral": 0.8} + + +class BaseMemoryModel(SQLModel, table=False): + metadata = MetaData() + + +class MemoryChunk(BaseMemoryModel, table=True): + """A chunk of memory stored in the system.""" + + __tablename__ = "memory_chunks" # type: ignore + + id: int | None = Field( + primary_key=True, + sa_column_kwargs={"autoincrement": True}, + default=None, + ) + mem_id: str = Field( + max_length=36, + nullable=False, + unique=True, + default_factory=lambda: str(uuid.uuid4()), + index=True, + ) + fact: str = Field(nullable=False) + """The factual content of the memory chunk.""" + owner_id: str = Field(max_length=255, nullable=False, index=True) + """The identifier of the owner (user) of the memory chunk.""" + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + """The timestamp when the memory chunk was created.""" + last_retrieval_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc) + ) + """The timestamp when the memory chunk was last retrieved.""" + retrieval_count: int = Field(default=1, nullable=False) + """The number of times the memory chunk has been retrieved.""" + memory_type: str = Field(max_length=20, nullable=False, default="fact") + """The type of memory (e.g., 'persona', 'fact', 'ephemeral').""" + is_active: bool = Field(default=True, nullable=False) + """Whether the memory chunk is active.""" + + def compute_decay_score(self, current_time: datetime) -> float: + """Compute the decay score of the memory chunk based on time and retrievals.""" + # Constants for the decay formula + alpha = 0.5 + gamma = 0.1 + lambda_ = 0.05 + a = 0.1 + + # Calculate delta_t in days + delta_t = (current_time - self.last_retrieval_at).total_seconds() / 86400 + c = self.retrieval_count + beta = 1 / (1 + a * c) + decay_score = alpha * np.exp(-lambda_ * delta_t * beta) + (1 - alpha) * ( + 1 - np.exp(-gamma * c) + ) + return decay_score * MEMORY_TYPE_IMPORTANCE.get(self.memory_type, 1.0) diff --git a/astrbot/core/memory/mem_db_sqlite.py b/astrbot/core/memory/mem_db_sqlite.py new file mode 100644 index 000000000..9766e94d1 --- /dev/null +++ b/astrbot/core/memory/mem_db_sqlite.py @@ -0,0 +1,174 @@ +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from pathlib import Path + +from sqlalchemy import select, text, update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlmodel import col + +from astrbot.core import logger + +from .entities import BaseMemoryModel, MemoryChunk + + +class MemoryDatabase: + def __init__(self, db_path: str = "data/astr_memory/memory.db") -> None: + """Initialize memory database + + Args: + db_path: Database file path, default is data/astr_memory/memory.db + + """ + self.db_path = db_path + self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}" + self.inited = False + + # Ensure directory exists + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + + # Create async engine + self.engine = create_async_engine( + self.DATABASE_URL, + echo=False, + pool_pre_ping=True, + pool_recycle=3600, + ) + + # Create session factory + self.async_session = async_sessionmaker( + self.engine, + class_=AsyncSession, + expire_on_commit=False, + ) + + @asynccontextmanager + async def get_db(self): + """Get database session + + Usage: + async with mem_db.get_db() as session: + # Perform database operations + result = await session.execute(stmt) + """ + async with self.async_session() as session: + yield session + + async def initialize(self) -> None: + """Initialize database, create tables and configure SQLite parameters""" + async with self.engine.begin() as conn: + # Create all memory related tables + await conn.run_sync(BaseMemoryModel.metadata.create_all) + + # Configure SQLite performance optimization parameters + await conn.execute(text("PRAGMA journal_mode=WAL")) + await conn.execute(text("PRAGMA synchronous=NORMAL")) + await conn.execute(text("PRAGMA cache_size=20000")) + await conn.execute(text("PRAGMA temp_store=MEMORY")) + await conn.execute(text("PRAGMA mmap_size=134217728")) + await conn.execute(text("PRAGMA optimize")) + await conn.commit() + + await self._create_indexes() + self.inited = True + logger.info(f"Memory database initialized: {self.db_path}") + + async def _create_indexes(self) -> None: + """Create indexes for memory_chunks table""" + async with self.get_db() as session: + async with session.begin(): + # Create memory chunks table indexes + await session.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_mem_mem_id " + "ON memory_chunks(mem_id)", + ), + ) + await session.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_mem_owner_id " + "ON memory_chunks(owner_id)", + ), + ) + await session.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_mem_owner_active " + "ON memory_chunks(owner_id, is_active)", + ), + ) + await session.commit() + + async def close(self) -> None: + """Close database connection""" + await self.engine.dispose() + logger.info(f"Memory database closed: {self.db_path}") + + async def insert_memory(self, memory: MemoryChunk) -> MemoryChunk: + """Insert a new memory chunk""" + async with self.get_db() as session: + session.add(memory) + await session.commit() + await session.refresh(memory) + return memory + + async def get_memory_by_id(self, mem_id: str) -> MemoryChunk | None: + """Get memory chunk by mem_id""" + async with self.get_db() as session: + stmt = select(MemoryChunk).where(col(MemoryChunk.mem_id) == mem_id) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + async def update_memory(self, memory: MemoryChunk) -> MemoryChunk: + """Update an existing memory chunk""" + async with self.get_db() as session: + session.add(memory) + await session.commit() + await session.refresh(memory) + return memory + + async def get_active_memories(self, owner_id: str) -> list[MemoryChunk]: + """Get all active memories for a user""" + async with self.get_db() as session: + stmt = select(MemoryChunk).where( + col(MemoryChunk.owner_id) == owner_id, + col(MemoryChunk.is_active) == True, # noqa: E712 + ) + result = await session.execute(stmt) + return list(result.scalars().all()) + + async def update_retrieval_stats( + self, + mem_ids: list[str], + current_time: datetime | None = None, + ) -> None: + """Update retrieval statistics for multiple memories""" + if not mem_ids: + return + + if current_time is None: + current_time = datetime.now(timezone.utc) + + async with self.get_db() as session: + async with session.begin(): + stmt = ( + update(MemoryChunk) + .where(col(MemoryChunk.mem_id).in_(mem_ids)) + .values( + retrieval_count=MemoryChunk.retrieval_count + 1, + last_retrieval_at=current_time, + ) + ) + await session.execute(stmt) + await session.commit() + + async def deactivate_memory(self, mem_id: str) -> bool: + """Deactivate a memory chunk""" + async with self.get_db() as session: + async with session.begin(): + stmt = ( + update(MemoryChunk) + .where(col(MemoryChunk.mem_id) == mem_id) + .values(is_active=False) + ) + result = await session.execute(stmt) + await session.commit() + return result.rowcount > 0 if result.rowcount else False # type: ignore diff --git a/astrbot/core/memory/memory_manager.py b/astrbot/core/memory/memory_manager.py new file mode 100644 index 000000000..b286725a5 --- /dev/null +++ b/astrbot/core/memory/memory_manager.py @@ -0,0 +1,281 @@ +import json +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from astrbot.core import logger +from astrbot.core.db.vec_db.faiss_impl import FaissVecDB +from astrbot.core.provider.provider import EmbeddingProvider +from astrbot.core.provider.provider import Provider as LLMProvider + +from .entities import MemoryChunk +from .mem_db_sqlite import MemoryDatabase + +MERGE_THRESHOLD = 0.85 +"""Similarity threshold for merging memories""" +HEBB_THRESHOLD = 0.70 +"""Similarity threshold for Hebbian learning reinforcement""" +MERGE_SYSTEM_PROMPT = """You are a memory consolidation assistant. Your task is to merge two related memory entries into a single, comprehensive memory. + +Input format: +- Old memory: [existing memory content] +- New memory: [new memory content to be integrated] + +Your output should be a single, concise memory that combines the essential information from both entries. Preserve specific details, update outdated information, and eliminate redundancy. Output only the merged memory content without any explanations or meta-commentary.""" + + +class MemoryManager: + """Manager for user long-term memory storage and retrieval""" + + def __init__(self, memory_root_dir: str = "data/astr_memory"): + self.memory_root_dir = Path(memory_root_dir) + self.memory_root_dir.mkdir(parents=True, exist_ok=True) + + self.mem_db: MemoryDatabase | None = None + self.vec_db: FaissVecDB | None = None + + self._initialized = False + + async def initialize( + self, + embedding_provider: EmbeddingProvider, + merge_llm_provider: LLMProvider, + ): + """Initialize memory database and vector database""" + # Initialize MemoryDB + db_path = self.memory_root_dir / "memory.db" + self.mem_db = MemoryDatabase(db_path.as_posix()) + await self.mem_db.initialize() + + self.embedding_provider = embedding_provider + self.merge_llm_provider = merge_llm_provider + + # Initialize VecDB + doc_store_path = self.memory_root_dir / "doc.db" + index_store_path = self.memory_root_dir / "index.faiss" + self.vec_db = FaissVecDB( + doc_store_path=doc_store_path.as_posix(), + index_store_path=index_store_path.as_posix(), + embedding_provider=self.embedding_provider, + ) + await self.vec_db.initialize() + + logger.info("Memory manager initialized") + self._initialized = True + + async def terminate(self): + """Close all database connections""" + if self.vec_db: + await self.vec_db.close() + if self.mem_db: + await self.mem_db.close() + + async def add_memory( + self, + fact: str, + owner_id: str, + memory_type: str = "fact", + ) -> MemoryChunk: + """Add a new memory with similarity check and merge logic + + Implements the ADD MEMORY workflow from _README.md: + 1. Search for similar memories using VecDB + 2. If similarity >= merge_threshold, merge with existing memory + 3. Otherwise, create new memory + 4. Apply Hebbian learning to similar memories (similarity >= hebb_threshold) + + Args: + fact: Memory content + owner_id: User identifier + memory_type: Memory type ('persona', 'fact', 'ephemeral') + + Returns: + The created or updated MemoryChunk + + """ + if not self.vec_db or not self.mem_db: + raise RuntimeError("Memory manager not initialized") + + current_time = datetime.now(timezone.utc) + + # Step 1: Search for similar memories + similar_results = await self.vec_db.retrieve( + query=fact, + k=20, + fetch_k=50, + metadata_filters={"owner_id": owner_id}, + ) + + # Step 2: Check if we should merge with existing memories (top 3 similar ones) + merge_candidates = [ + r for r in similar_results[:3] if r.similarity >= MERGE_THRESHOLD + ] + + if merge_candidates: + # Get all candidate memories from database + candidate_memories: list[tuple[str, MemoryChunk]] = [] + for candidate in merge_candidates: + mem_id = json.loads(candidate.data["metadata"])["mem_id"] + memory = await self.mem_db.get_memory_by_id(mem_id) + if memory: + candidate_memories.append((mem_id, memory)) + + if candidate_memories: + # Use the most similar memory as the base + base_mem_id, base_memory = candidate_memories[0] + + # Collect all facts to merge (existing candidates + new fact) + all_facts = [mem.fact for _, mem in candidate_memories] + [fact] + merged_fact = await self._merge_multiple_memories(all_facts) + + # Update the base memory + base_memory.fact = merged_fact + base_memory.last_retrieval_at = current_time + base_memory.retrieval_count += 1 + updated_memory = await self.mem_db.update_memory(base_memory) + + # Update VecDB for base memory + await self.vec_db.delete(base_mem_id) + await self.vec_db.insert( + content=merged_fact, + metadata={ + "mem_id": base_mem_id, + "owner_id": owner_id, + "memory_type": memory_type, + }, + id=base_mem_id, + ) + + # Deactivate and remove other merged memories + for mem_id, _ in candidate_memories[1:]: + await self.mem_db.deactivate_memory(mem_id) + await self.vec_db.delete(mem_id) + + logger.info( + f"Merged {len(candidate_memories)} memories into {base_mem_id} for user {owner_id}" + ) + return updated_memory + + # Step 3: Create new memory + mem_id = str(uuid.uuid4()) + new_memory = MemoryChunk( + mem_id=mem_id, + fact=fact, + owner_id=owner_id, + memory_type=memory_type, + created_at=current_time, + last_retrieval_at=current_time, + retrieval_count=1, + is_active=True, + ) + + # Insert into MemoryDB + created_memory = await self.mem_db.insert_memory(new_memory) + + # Insert into VecDB + await self.vec_db.insert( + content=fact, + metadata={ + "mem_id": mem_id, + "owner_id": owner_id, + "memory_type": memory_type, + }, + id=mem_id, + ) + + # Step 4: Apply Hebbian learning to similar memories + hebb_mem_ids = [ + json.loads(r.data["metadata"])["mem_id"] + for r in similar_results + if r.similarity >= HEBB_THRESHOLD + ] + if hebb_mem_ids: + await self.mem_db.update_retrieval_stats(hebb_mem_ids, current_time) + logger.debug( + f"Applied Hebbian learning to {len(hebb_mem_ids)} memories for user {owner_id}", + ) + + logger.info(f"Created new memory {mem_id} for user {owner_id}") + return created_memory + + async def query_memory( + self, + owner_id: str, + top_k: int = 5, + ) -> list[MemoryChunk]: + """Query user's memories using static retrieval with decay score ranking + + Implements the QUERY MEMORY (STATIC) workflow from _README.md: + 1. Get all active memories for user from MemoryDB + 2. Compute decay_score for each memory + 3. Sort by decay_score and return top_k + 4. Update retrieval statistics for returned memories + + Args: + owner_id: User identifier + top_k: Number of memories to return + + Returns: + List of top_k MemoryChunk sorted by decay score + """ + if not self.mem_db: + raise RuntimeError("Memory manager not initialized") + + current_time = datetime.now(timezone.utc) + + # Step 1: Get all active memories for user + all_memories = await self.mem_db.get_active_memories(owner_id) + + if not all_memories: + return [] + + # Step 2-3: Compute decay scores and sort + memories_with_scores = [ + (mem, mem.compute_decay_score(current_time)) for mem in all_memories + ] + memories_with_scores.sort(key=lambda x: x[1], reverse=True) + + # Get top_k memories + top_memories = [mem for mem, _ in memories_with_scores[:top_k]] + + # Step 4: Update retrieval statistics + mem_ids = [mem.mem_id for mem in top_memories] + await self.mem_db.update_retrieval_stats(mem_ids, current_time) + + logger.debug(f"Retrieved {len(top_memories)} memories for user {owner_id}") + return top_memories + + async def _merge_multiple_memories(self, facts: list[str]) -> str: + """Merge multiple memory facts using LLM in one call + + Args: + facts: List of memory facts to merge + + Returns: + Merged memory content + """ + if not self.merge_llm_provider: + return " ".join(facts) + + if len(facts) == 1: + return facts[0] + + try: + # Format all facts as a numbered list + facts_list = "\n".join(f"{i + 1}. {fact}" for i, fact in enumerate(facts)) + user_prompt = ( + f"Please merge the following {len(facts)} related memory entries " + "into a single, comprehensive memory:" + f"\n{facts_list}\n\nOutput only the merged memory content." + ) + response = await self.merge_llm_provider.text_chat( + prompt=user_prompt, + system_prompt=MERGE_SYSTEM_PROMPT, + ) + + merged_content = response.completion_text.strip() + return merged_content if merged_content else " ".join(facts) + + except Exception as e: + logger.warning(f"Failed to merge memories with LLM: {e}, using fallback") + return " ".join(facts) diff --git a/astrbot/core/memory/tools.py b/astrbot/core/memory/tools.py new file mode 100644 index 000000000..ffda3c863 --- /dev/null +++ b/astrbot/core/memory/tools.py @@ -0,0 +1,156 @@ +from pydantic import Field +from pydantic.dataclasses import dataclass + +from astrbot.core.agent.tool import FunctionTool, ToolExecResult +from astrbot.core.astr_agent_context import AstrAgentContext, ContextWrapper + + +@dataclass +class AddMemory(FunctionTool[AstrAgentContext]): + """Tool for adding memories to user's long-term memory storage""" + + name: str = "astr_add_memory" + description: str = ( + "Add a new memory to the user's long-term memory storage. " + "Use this tool only when the user explicitly asks you to remember something, " + "or when they share stable preferences, identity, or long-term goals that will be useful in future interactions." + ) + parameters: dict = Field( + default_factory=lambda: { + "type": "object", + "properties": { + "fact": { + "type": "string", + "description": ( + "The concrete memory content to store, such as a user preference, " + "identity detail, long-term goal, or stable profile fact." + ), + }, + "memory_type": { + "type": "string", + "enum": ["persona", "fact", "ephemeral"], + "description": ( + "The relative importance of this memory. " + "Use 'persona' for core identity or highly impactful information, " + "'fact' for normal long-term preferences, " + "and 'ephemeral' for minor or tentative facts." + ), + }, + }, + "required": ["fact", "memory_type"], + } + ) + + async def call( + self, context: ContextWrapper[AstrAgentContext], **kwargs + ) -> ToolExecResult: + """Add a memory to long-term storage + + Args: + context: Agent context + **kwargs: Must contain 'fact' and 'memory_type' + + Returns: + ToolExecResult with success message + + """ + mm = context.context.context.memory_manager + fact = kwargs.get("fact") + memory_type = kwargs.get("memory_type", "fact") + + if not fact: + return "Missing required parameter: fact" + + try: + # Get owner_id from context + owner_id = context.context.event.unified_msg_origin + + # Add memory using memory manager + memory = await mm.add_memory( + fact=fact, + owner_id=owner_id, + memory_type=memory_type, + ) + + return f"Memory added successfully (ID: {memory.mem_id})" + + except Exception as e: + return f"Failed to add memory: {str(e)}" + + +@dataclass +class QueryMemory(FunctionTool[AstrAgentContext]): + """Tool for querying user's long-term memories""" + + name: str = "astr_query_memory" + description: str = ( + "Query the user's long-term memory storage and return the most relevant memories. " + "Use this tool when you need user-specific context, preferences, or past facts " + "that are not explicitly present in the current conversation." + ) + parameters: dict = Field( + default_factory=lambda: { + "type": "object", + "properties": { + "top_k": { + "type": "integer", + "description": ( + "Maximum number of memories to retrieve after retention-based ranking. " + "Typically between 3 and 10." + ), + "default": 5, + "minimum": 1, + "maximum": 20, + }, + }, + "required": [], + } + ) + + async def call( + self, context: ContextWrapper[AstrAgentContext], **kwargs + ) -> ToolExecResult: + """Query memories from long-term storage + + Args: + context: Agent context + **kwargs: Optional 'top_k' parameter + + Returns: + ToolExecResult with formatted memory list + + """ + mm = context.context.context.memory_manager + top_k = kwargs.get("top_k", 5) + + try: + # Get owner_id from context + owner_id = context.context.event.unified_msg_origin + + # Query memories using memory manager + memories = await mm.query_memory( + owner_id=owner_id, + top_k=top_k, + ) + + if not memories: + return "No memories found for this user." + + # Format memories for output + formatted_memories = [] + for i, mem in enumerate(memories, 1): + formatted_memories.append( + f"{i}. [{mem.memory_type.upper()}] {mem.fact} " + f"(retrieved {mem.retrieval_count} times, " + f"last: {mem.last_retrieval_at.strftime('%Y-%m-%d')})" + ) + + result_text = "Retrieved memories:\n" + "\n".join(formatted_memories) + return result_text + + except Exception as e: + return f"Failed to query memories: {str(e)}" + + +ADD_MEMORY_TOOL = AddMemory() +QUERY_MEMORY_TOOL = QueryMemory() diff --git a/astrbot/core/pipeline/process_stage/method/llm_request.py b/astrbot/core/pipeline/process_stage/method/llm_request.py index bd9e4ce3b..bc6f8f09b 100644 --- a/astrbot/core/pipeline/process_stage/method/llm_request.py +++ b/astrbot/core/pipeline/process_stage/method/llm_request.py @@ -30,6 +30,7 @@ from ....astr_agent_hooks import MAIN_AGENT_HOOKS from ....astr_agent_run_util import AgentRunner, run_agent from ....astr_agent_tool_exec import FunctionToolExecutor +from ....memory.tools import ADD_MEMORY_TOOL, QUERY_MEMORY_TOOL from ...context import PipelineContext, call_event_hook from ..stage import Stage from ..utils import KNOWLEDGE_BASE_QUERY_TOOL, retrieve_knowledge_base @@ -124,6 +125,15 @@ async def _apply_kb( req.func_tool = ToolSet() req.func_tool.add_tool(KNOWLEDGE_BASE_QUERY_TOOL) + async def _apply_memory(self, req: ProviderRequest): + mm = self.ctx.plugin_manager.context.memory_manager + if not mm or not mm._initialized: + return + if req.func_tool is None: + req.func_tool = ToolSet() + req.func_tool.add_tool(ADD_MEMORY_TOOL) + req.func_tool.add_tool(QUERY_MEMORY_TOOL) + def _truncate_contexts( self, contexts: list[dict], @@ -377,6 +387,9 @@ async def process( # apply knowledge base feature await self._apply_kb(event, req) + # apply memory feature + await self._apply_memory(req) + # fix contexts json str if isinstance(req.contexts, str): req.contexts = json.loads(req.contexts) diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 21c1ad8fd..73b7276c1 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -14,6 +14,7 @@ from astrbot.core.conversation_mgr import ConversationManager from astrbot.core.db import BaseDatabase from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager +from astrbot.core.memory.memory_manager import MemoryManager from astrbot.core.message.message_event_result import MessageChain from astrbot.core.persona_mgr import PersonaManager from astrbot.core.platform import Platform @@ -65,6 +66,7 @@ def __init__( persona_manager: PersonaManager, astrbot_config_mgr: AstrBotConfigManager, knowledge_base_manager: KnowledgeBaseManager, + memory_manager: MemoryManager, ): self._event_queue = event_queue """事件队列。消息平台通过事件队列传递消息事件。""" @@ -79,6 +81,7 @@ def __init__( self.persona_manager = persona_manager self.astrbot_config_mgr = astrbot_config_mgr self.kb_manager = knowledge_base_manager + self.memory_manager = memory_manager async def llm_generate( self, diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py index b7997cf8e..2a22d8b59 100644 --- a/astrbot/dashboard/routes/__init__.py +++ b/astrbot/dashboard/routes/__init__.py @@ -5,6 +5,7 @@ from .file import FileRoute from .knowledge_base import KnowledgeBaseRoute from .log import LogRoute +from .memory import MemoryRoute from .persona import PersonaRoute from .plugin import PluginRoute from .session_management import SessionManagementRoute @@ -21,6 +22,7 @@ "FileRoute", "KnowledgeBaseRoute", "LogRoute", + "MemoryRoute", "PersonaRoute", "PluginRoute", "SessionManagementRoute", diff --git a/astrbot/dashboard/routes/memory.py b/astrbot/dashboard/routes/memory.py new file mode 100644 index 000000000..534e7b1f2 --- /dev/null +++ b/astrbot/dashboard/routes/memory.py @@ -0,0 +1,174 @@ +"""Memory management API routes""" + +from quart import jsonify, request + +from astrbot.core import logger +from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core.db import BaseDatabase + +from .route import Response, Route, RouteContext + + +class MemoryRoute(Route): + """Memory management routes""" + + def __init__( + self, + context: RouteContext, + db: BaseDatabase, + core_lifecycle: AstrBotCoreLifecycle, + ): + super().__init__(context) + self.db = db + self.core_lifecycle = core_lifecycle + self.memory_manager = core_lifecycle.memory_manager + self.provider_manager = core_lifecycle.provider_manager + self.routes = [ + ("/memory/status", ("GET", self.get_status)), + ("/memory/initialize", ("POST", self.initialize)), + ("/memory/update_merge_llm", ("POST", self.update_merge_llm)), + ] + self.register_routes() + + async def get_status(self): + """Get memory system status""" + try: + is_initialized = self.memory_manager._initialized + + status_data = { + "initialized": is_initialized, + "embedding_provider_id": None, + "merge_llm_provider_id": None, + } + + if is_initialized: + # Get embedding provider info + if self.memory_manager.embedding_provider: + status_data["embedding_provider_id"] = ( + self.memory_manager.embedding_provider.provider_config["id"] + ) + # Get merge LLM provider info + if self.memory_manager.merge_llm_provider: + status_data["merge_llm_provider_id"] = ( + self.memory_manager.merge_llm_provider.provider_config["id"] + ) + + return jsonify(Response().ok(status_data).__dict__) + except Exception as e: + logger.error(f"Failed to get memory status: {e}") + return jsonify(Response().error(str(e)).__dict__) + + async def initialize(self): + """Initialize memory system with embedding and merge LLM providers""" + try: + data = await request.get_json() + embedding_provider_id = data.get("embedding_provider_id") + merge_llm_provider_id = data.get("merge_llm_provider_id") + + if not embedding_provider_id or not merge_llm_provider_id: + return jsonify( + Response() + .error( + "embedding_provider_id and merge_llm_provider_id are required" + ) + .__dict__, + ) + + # Check if already initialized + if self.memory_manager._initialized: + return jsonify( + Response() + .error( + "Memory system already initialized. Embedding provider cannot be changed.", + ) + .__dict__, + ) + + # Get providers + embedding_provider = await self.provider_manager.get_provider_by_id( + embedding_provider_id, + ) + merge_llm_provider = await self.provider_manager.get_provider_by_id( + merge_llm_provider_id, + ) + + if not embedding_provider: + return jsonify( + Response() + .error(f"Embedding provider {embedding_provider_id} not found") + .__dict__, + ) + + if not merge_llm_provider: + return jsonify( + Response() + .error(f"Merge LLM provider {merge_llm_provider_id} not found") + .__dict__, + ) + + # Initialize memory manager + await self.memory_manager.initialize( + embedding_provider=embedding_provider, + merge_llm_provider=merge_llm_provider, + ) + + logger.info( + f"Memory system initialized with embedding: {embedding_provider_id}, " + f"merge LLM: {merge_llm_provider_id}", + ) + + return jsonify( + Response() + .ok({"message": "Memory system initialized successfully"}) + .__dict__, + ) + + except Exception as e: + logger.error(f"Failed to initialize memory system: {e}") + return jsonify(Response().error(str(e)).__dict__) + + async def update_merge_llm(self): + """Update merge LLM provider (only allowed after initialization)""" + try: + data = await request.get_json() + merge_llm_provider_id = data.get("merge_llm_provider_id") + + if not merge_llm_provider_id: + return jsonify( + Response().error("merge_llm_provider_id is required").__dict__, + ) + + # Check if initialized + if not self.memory_manager._initialized: + return jsonify( + Response() + .error("Memory system not initialized. Please initialize first.") + .__dict__, + ) + + # Get new merge LLM provider + merge_llm_provider = await self.provider_manager.get_provider_by_id( + merge_llm_provider_id, + ) + + if not merge_llm_provider: + return jsonify( + Response() + .error(f"Merge LLM provider {merge_llm_provider_id} not found") + .__dict__, + ) + + # Update merge LLM provider + self.memory_manager.merge_llm_provider = merge_llm_provider + + logger.info(f"Updated merge LLM provider to: {merge_llm_provider_id}") + + return jsonify( + Response() + .ok({"message": "Merge LLM provider updated successfully"}) + .__dict__, + ) + + except Exception as e: + logger.error(f"Failed to update merge LLM provider: {e}") + return jsonify(Response().error(str(e)).__dict__) diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 84976f2ba..504a4afe8 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -79,6 +79,7 @@ def __init__( self.persona_route = PersonaRoute(self.context, db, core_lifecycle) self.t2i_route = T2iRoute(self.context, core_lifecycle) self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle) + self.memory_route = MemoryRoute(self.context, db, core_lifecycle) self.app.add_url_rule( "/api/plug/", diff --git a/dashboard/src/components/chat/ConversationSidebar.vue b/dashboard/src/components/chat/ConversationSidebar.vue index 5abc1bed8..b2ebd3fef 100644 --- a/dashboard/src/components/chat/ConversationSidebar.vue +++ b/dashboard/src/components/chat/ConversationSidebar.vue @@ -293,4 +293,3 @@ function handleSidebarMouseLeave() { } } - diff --git a/dashboard/src/composables/useMessages.ts b/dashboard/src/composables/useMessages.ts index 5e1a6c7a1..3779576cd 100644 --- a/dashboard/src/composables/useMessages.ts +++ b/dashboard/src/composables/useMessages.ts @@ -301,4 +301,3 @@ export function useMessages( toggleStreaming }; } - diff --git a/dashboard/src/i18n/locales/en-US/core/navigation.json b/dashboard/src/i18n/locales/en-US/core/navigation.json index 9351d1da4..fbe81ab9a 100644 --- a/dashboard/src/i18n/locales/en-US/core/navigation.json +++ b/dashboard/src/i18n/locales/en-US/core/navigation.json @@ -12,6 +12,7 @@ "console": "Console", "alkaid": "Alkaid Lab", "knowledgeBase": "Knowledge Base", + "memory": "Long-term Memory", "about": "About", "settings": "Settings", "documentation": "Documentation", diff --git a/dashboard/src/i18n/locales/zh-CN/core/navigation.json b/dashboard/src/i18n/locales/zh-CN/core/navigation.json index 61f69d9ca..db89cd22e 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/navigation.json +++ b/dashboard/src/i18n/locales/zh-CN/core/navigation.json @@ -12,6 +12,7 @@ "console": "控制台", "alkaid": "Alkaid", "knowledgeBase": "知识库", + "memory": "长期记忆", "about": "关于", "settings": "设置", "documentation": "官方文档", diff --git a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts index 3203985cd..0c2415465 100644 --- a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts +++ b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts @@ -48,6 +48,11 @@ const sidebarItem: menu[] = [ icon: 'mdi-book-open-variant', to: '/knowledge-base', }, + { + title: 'core.navigation.memory', + icon: 'mdi-brain', + to: '/memory', + }, { title: 'core.navigation.chat', icon: 'mdi-chat', diff --git a/dashboard/src/router/MainRoutes.ts b/dashboard/src/router/MainRoutes.ts index 276d37444..f85bdc290 100644 --- a/dashboard/src/router/MainRoutes.ts +++ b/dashboard/src/router/MainRoutes.ts @@ -90,6 +90,11 @@ const MainRoutes = { } ] }, + { + name: 'Memory', + path: '/memory', + component: () => import('@/views/MemoryPage.vue') + }, // 旧版本的知识库路由 { diff --git a/dashboard/src/views/MemoryPage.vue b/dashboard/src/views/MemoryPage.vue new file mode 100644 index 000000000..b0a8d315e --- /dev/null +++ b/dashboard/src/views/MemoryPage.vue @@ -0,0 +1,358 @@ + + + + +