Merge pull request #1631 from AstrBotDevs/feat/alkaid

[WIP] Feature: 提供 AstrBot 后端服务插件接口、试验性嵌入式知识库(Alkaid)、移除不必要的包
This commit is contained in:
Soulter
2025-05-23 16:57:04 +08:00
committed by GitHub
30 changed files with 3765 additions and 1713 deletions
+3 -8
View File
@@ -1,6 +1,6 @@
"""
Astrbot 核心生命周期管理类, 负责管理 AstrBot 的启动、停止、重启等操作。
该类负责初始化各个组件, 包括 ProviderManager、PlatformManager、KnowledgeDBManager、ConversationManager、PluginManager、PipelineScheduler、EventBus等。
该类负责初始化各个组件, 包括 ProviderManager、PlatformManager、ConversationManager、PluginManager、PipelineScheduler、EventBus等。
该类还负责加载和执行插件, 以及处理事件总线的分发。
工作流程:
@@ -28,7 +28,6 @@ from astrbot.core.db import BaseDatabase
from astrbot.core.updator import AstrBotUpdator
from astrbot.core import logger
from astrbot.core.config.default import VERSION
from astrbot.core.rag.knowledge_db_mgr import KnowledgeDBManager
from astrbot.core.conversation_mgr import ConversationManager
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star_handler import star_map
@@ -37,7 +36,7 @@ from astrbot.core.star.star_handler import star_map
class AstrBotCoreLifecycle:
"""
AstrBot 核心生命周期管理类, 负责管理 AstrBot 的启动、停止、重启等操作。
该类负责初始化各个组件, 包括 ProviderManager、PlatformManager、KnowledgeDBManager、ConversationManager、PluginManager、PipelineScheduler、
该类负责初始化各个组件, 包括 ProviderManager、PlatformManager、ConversationManager、PluginManager、PipelineScheduler、
EventBus 等。
该类还负责加载和执行插件, 以及处理事件总线的分发。
"""
@@ -54,7 +53,7 @@ class AstrBotCoreLifecycle:
async def initialize(self):
"""
初始化 AstrBot 核心生命周期管理类, 负责初始化各个组件, 包括 ProviderManager、PlatformManager、KnowledgeDBManager、ConversationManager、PluginManager、PipelineScheduler、EventBus、AstrBotUpdator等。
初始化 AstrBot 核心生命周期管理类, 负责初始化各个组件, 包括 ProviderManager、PlatformManager、ConversationManager、PluginManager、PipelineScheduler、EventBus、AstrBotUpdator等。
"""
# 初始化日志代理
@@ -73,9 +72,6 @@ class AstrBotCoreLifecycle:
# 初始化平台管理器
self.platform_manager = PlatformManager(self.astrbot_config, self.event_queue)
# 初始化知识库管理器
self.knowledge_db_manager = KnowledgeDBManager(self.astrbot_config)
# 初始化对话管理器
self.conversation_manager = ConversationManager(self.db)
@@ -87,7 +83,6 @@ class AstrBotCoreLifecycle:
self.provider_manager,
self.platform_manager,
self.conversation_manager,
self.knowledge_db_manager,
)
# 初始化插件管理器
-113
View File
@@ -1,113 +0,0 @@
import json
import aiosqlite
import os
from typing import Any
from .plugin_storage import PluginStorage
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
DBPATH = os.path.join(get_astrbot_data_path(), "plugin_data", "sqlite", "plugin_data.db")
class SQLitePluginStorage(PluginStorage):
"""插件数据的 SQLite 存储实现类。
该类提供异步方式将插件数据存储到 SQLite 数据库中,支持数据的增删改查操作。
所有数据以 (plugin, key) 作为复合主键进行索引。
"""
_instance = None # Standalone instance of the class
_db_conn = None
db_path = None
def __new__(cls):
"""
创建或获取 SQLitePluginStorage 的单例实例。
如果实例已存在,则返回现有实例;否则创建一个新实例。
数据在 `data/plugin_data/sqlite/plugin_data.db` 下。
"""
os.makedirs(os.path.dirname(DBPATH), exist_ok=True)
if cls._instance is None:
cls._instance = super(SQLitePluginStorage, cls).__new__(cls)
cls._instance.db_path = DBPATH
return cls._instance
async def _init_db(self):
"""初始化数据库连接(只执行一次)"""
if SQLitePluginStorage._db_conn is None:
SQLitePluginStorage._db_conn = await aiosqlite.connect(self.db_path)
await self._setup_db()
async def _setup_db(self):
"""
异步初始化数据库。
创建插件数据表,如果表不存在则创建,表结构包含 plugin、key 和 value 字段,
其中 plugin 和 key 组合作为主键。
"""
await self._db_conn.execute("""
CREATE TABLE IF NOT EXISTS plugin_data (
plugin TEXT,
key TEXT,
value TEXT,
PRIMARY KEY (plugin, key)
)
""")
await self._db_conn.commit()
async def set(self, plugin: str, key: str, value: Any):
"""
异步存储数据。
将指定插件的键值对存入数据库,如果键已存在则更新值。
值会被序列化为 JSON 字符串后存储。
Args:
plugin: 插件标识符
key: 数据键名
value: 要存储的数据值(任意类型,将被 JSON 序列化)
"""
await self._init_db()
await self._db_conn.execute(
"INSERT INTO plugin_data (plugin, key, value) VALUES (?, ?, ?) "
"ON CONFLICT(plugin, key) DO UPDATE SET value = excluded.value",
(plugin, key, json.dumps(value)),
)
await self._db_conn.commit()
async def get(self, plugin: str, key: str) -> Any:
"""
异步获取数据。
从数据库中获取指定插件和键名对应的值,
返回的值会从 JSON 字符串反序列化为原始数据类型。
Args:
plugin: 插件标识符
key: 数据键名
Returns:
Any: 存储的数据值,如果未找到则返回 None
"""
await self._init_db()
async with self._db_conn.execute(
"SELECT value FROM plugin_data WHERE plugin = ? AND key = ?",
(plugin, key),
) as cursor:
row = await cursor.fetchone()
return json.loads(row[0]) if row else None
async def delete(self, plugin: str, key: str):
"""
异步删除数据。
从数据库中删除指定插件和键名对应的数据项。
Args:
plugin: 插件标识符
key: 要删除的数据键名
"""
await self._init_db()
await self._db_conn.execute(
"DELETE FROM plugin_data WHERE plugin = ? AND key = ?", (plugin, key)
)
await self._db_conn.commit()
+46
View File
@@ -0,0 +1,46 @@
import abc
from dataclasses import dataclass
@dataclass
class Result:
similarity: float
data: dict
class BaseVecDB:
async def initialize(self):
"""
初始化向量数据库
"""
pass
@abc.abstractmethod
async def insert(self, content: str, metadata: dict = None, id: str = None) -> int:
"""
插入一条文本和其对应向量,自动生成 ID 并保持一致性。
"""
...
@abc.abstractmethod
async def retrieve(self, query: str, top_k: int = 5) -> list[Result]:
"""
搜索最相似的文档。
Args:
query (str): 查询文本
top_k (int): 返回的最相似文档的数量
Returns:
List[Result]: 查询结果
"""
...
@abc.abstractmethod
async def delete(self, doc_id: str) -> bool:
"""
删除指定文档。
Args:
doc_id (str): 要删除的文档 ID
Returns:
bool: 删除是否成功
"""
...
@@ -0,0 +1,3 @@
from .vec_db import FaissVecDB
__all__ = ["FaissVecDB"]
@@ -0,0 +1,121 @@
import aiosqlite
import os
class DocumentStorage:
def __init__(self, db_path: str):
self.db_path = db_path
self.connection = None
self.sqlite_init_path = os.path.join(
os.path.dirname(__file__), "sqlite_init.sql"
)
async def initialize(self):
"""Initialize the SQLite database and create the documents table if it doesn't exist."""
if not os.path.exists(self.db_path):
await self.connect()
async with self.connection.cursor() as cursor:
with open(self.sqlite_init_path, "r", encoding="utf-8") as f:
sql_script = f.read()
await cursor.executescript(sql_script)
await self.connection.commit()
else:
await self.connect()
async def connect(self):
"""Connect to the SQLite database."""
self.connection = await aiosqlite.connect(self.db_path)
async def get_documents(self, metadata_filters: dict, ids: list = None):
"""Retrieve documents by metadata filters and ids.
Args:
metadata_filters (dict): The metadata filters to apply.
Returns:
list: The list of document IDs(primary key, not doc_id) that match the filters.
"""
# metadata filter -> SQL WHERE clause
where_clauses = []
values = []
for key, val in metadata_filters.items():
where_clauses.append(f"json_extract(metadata, '$.{key}') = ?")
values.append(val)
if ids is not None and len(ids) > 0:
ids = [str(i) for i in ids if i != -1]
where_clauses.append("id IN ({})".format(",".join("?" * len(ids))))
values.extend(ids)
where_sql = " AND ".join(where_clauses) or "1=1"
result = []
async with self.connection.cursor() as cursor:
sql = "SELECT * FROM documents WHERE " + where_sql
await cursor.execute(sql, values)
for row in await cursor.fetchall():
result.append(await self.tuple_to_dict(row))
return result
async def get_document_by_doc_id(self, doc_id: str):
"""Retrieve a document by its doc_id.
Args:
doc_id (str): The doc_id of the document to retrieve.
Returns:
dict: The document data.
"""
async with self.connection.cursor() as cursor:
await cursor.execute("SELECT * FROM documents WHERE doc_id = ?", (doc_id,))
row = await cursor.fetchone()
if row:
return await self.tuple_to_dict(row)
else:
return None
async def update_document_by_doc_id(self, doc_id: str, new_text: str):
"""Retrieve a document by its doc_id.
Args:
doc_id (str): The doc_id.
new_text (str): The new text to update the document with.
"""
async with self.connection.cursor() as cursor:
await cursor.execute(
"UPDATE documents SET text = ? WHERE doc_id = ?", (new_text, doc_id)
)
await self.connection.commit()
async def get_user_ids(self) -> list[str]:
"""Retrieve all user IDs from the documents table.
Returns:
list: A list of user IDs.
"""
async with self.connection.cursor() as cursor:
await cursor.execute("SELECT DISTINCT user_id FROM documents")
rows = await cursor.fetchall()
return [row[0] for row in rows]
async def tuple_to_dict(self, row):
"""Convert a tuple to a dictionary.
Args:
row (tuple): The row to convert.
Returns:
dict: The converted dictionary.
"""
return {
"id": row[0],
"doc_id": row[1],
"text": row[2],
"metadata": row[3],
"created_at": row[4],
"updated_at": row[5],
}
async def close(self):
"""Close the connection to the SQLite database."""
if self.connection:
await self.connection.close()
self.connection = None
@@ -0,0 +1,59 @@
try:
import faiss
except ModuleNotFoundError:
raise ImportError(
"faiss 未安装。请使用 'pip install faiss-cpu''pip install faiss-gpu' 安装。"
)
import os
import numpy as np
class EmbeddingStorage:
def __init__(self, dimension: int, path: str = None):
self.dimension = dimension
self.path = path
self.index = None
if path and os.path.exists(path):
self.index = faiss.read_index(path)
else:
base_index = faiss.IndexFlatL2(dimension)
self.index = faiss.IndexIDMap(base_index)
self.storage = {}
async def insert(self, vector: np.ndarray, id: int):
"""插入向量
Args:
vector (np.ndarray): 要插入的向量
id (int): 向量的ID
Raises:
ValueError: 如果向量的维度与存储的维度不匹配
"""
if vector.shape[0] != self.dimention:
raise ValueError(
f"向量维度不匹配, 期望: {self.dimention}, 实际: {vector.shape[0]}"
)
self.index.add_with_ids(vector.reshape(1, -1), np.array([id]))
self.storage[id] = vector
await self.save_index()
async def search(self, vector: np.ndarray, k: int) -> tuple:
"""搜索最相似的向量
Args:
vector (np.ndarray): 查询向量
k (int): 返回的最相似向量的数量
Returns:
tuple: (距离, 索引)
"""
faiss.normalize_L2(vector)
distances, indices = self.index.search(vector, k)
return distances, indices
async def save_index(self):
"""保存索引
Args:
path (str): 保存索引的路径
"""
faiss.write_index(self.index, self.path)
@@ -0,0 +1,17 @@
-- 创建文档存储表,包含 faiss 中文档的 id,文档文本,create_atupdated_at
CREATE TABLE documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
doc_id TEXT NOT NULL,
text TEXT NOT NULL,
metadata TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE documents
ADD COLUMN group_id TEXT GENERATED ALWAYS AS (json_extract(metadata, '$.group_id')) STORED;
ALTER TABLE documents
ADD COLUMN user_id TEXT GENERATED ALWAYS AS (json_extract(metadata, '$.user_id')) STORED;
CREATE INDEX idx_documents_user_id ON documents(user_id);
CREATE INDEX idx_documents_group_id ON documents(group_id);
+123
View File
@@ -0,0 +1,123 @@
import uuid
import json
import numpy as np
from .document_storage import DocumentStorage
from .embedding_storage import EmbeddingStorage
from ..base import Result, BaseVecDB
from astrbot.core.provider.provider import EmbeddingProvider
class FaissVecDB(BaseVecDB):
"""
A class to represent a vector database.
"""
def __init__(
self,
doc_store_path: str,
index_store_path: str,
embedding_provider: EmbeddingProvider,
):
self.doc_store_path = doc_store_path
self.index_store_path = index_store_path
self.embedding_provider = embedding_provider
self.document_storage = DocumentStorage(doc_store_path)
self.embedding_storage = EmbeddingStorage(
embedding_provider.get_dim(), index_store_path
)
self.embedding_provider = embedding_provider
async def initialize(self):
await self.document_storage.initialize()
async def insert(
self,
content: str,
metadata: dict = None,
id: str = None,
) -> int:
"""
插入一条文本和其对应向量,自动生成 ID 并保持一致性。
"""
metadata = metadata or {}
str_id = id or str(uuid.uuid4()) # 使用 UUID 作为原始 ID
# 获取向量
vector = await self.embedding_provider.get_embedding(content)
vector = np.array(vector, dtype=np.float32)
async with self.document_storage.connection.cursor() as cursor:
await cursor.execute(
"INSERT INTO documents (doc_id, text, metadata) VALUES (?, ?, ?)",
(str_id, content, json.dumps(metadata)),
)
await self.document_storage.connection.commit()
result = await self.document_storage.get_document_by_doc_id(str_id)
int_id = result["id"]
# 插入向量到 FAISS
await self.embedding_storage.insert(vector, int_id)
return int_id
async def retrieve(
self, query: str, k: int = 5, fetch_k: int = 20, metadata_filters: dict = None
) -> list[Result]:
"""
搜索最相似的文档。
Args:
query (str): 查询文本
k (int): 返回的最相似文档的数量
fetch_k (int): 在根据 metadata 过滤前从 FAISS 中获取的数量
metadata_filters (dict): 元数据过滤器
Returns:
List[Result]: 查询结果
"""
embedding = await self.embedding_provider.get_embedding(query)
scores, indices = await self.embedding_storage.search(
vector=np.array([embedding]).astype("float32"),
k=fetch_k if metadata_filters else k,
)
# TODO: rerank
if len(indices[0]) == 0 or indices[0][0] == -1:
return []
# normalize scores
scores[0] = 1.0 - (scores[0] / 2.0)
# NOTE: maybe the size is less than k.
fetched_docs = await self.document_storage.get_documents(
metadata_filters=metadata_filters or {}, ids=indices[0]
)
if not fetched_docs:
return []
result_docs = []
idx_pos = {fetch_doc["id"]: idx for idx, fetch_doc in enumerate(fetched_docs)}
for i, indice_idx in enumerate(indices[0]):
pos = idx_pos.get(indice_idx)
if pos is None:
continue
fetch_doc = fetched_docs[pos]
score = scores[0][i]
result_docs.append(Result(similarity=float(score), data=fetch_doc))
return result_docs[:k]
async def delete(self, doc_id: int):
"""
删除一条文档
"""
await self.document_storage.connection.execute(
"DELETE FROM documents WHERE doc_id = ?", (doc_id,)
)
await self.document_storage.connection.commit()
async def close(self):
await self.document_storage.close()
async def count_documents(self) -> int:
"""
计算文档数量
"""
async with self.document_storage.connection.cursor() as cursor:
await cursor.execute("SELECT COUNT(*) FROM documents")
count = await cursor.fetchone()
return count[0] if count else 0
+17
View File
@@ -179,3 +179,20 @@ class TTSProvider(AbstractProvider):
async def get_audio(self, text: str) -> str:
"""获取文本的音频,返回音频文件路径"""
raise NotImplementedError()
class EmbeddingProvider(AbstractProvider):
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config)
self.provider_config = provider_config
self.provider_settings = provider_settings
@abc.abstractmethod
async def get_embedding(self, text: str) -> list[float]:
"""获取文本的向量"""
...
@abc.abstractmethod
def get_dim(self) -> int:
"""获取向量的维度"""
...
@@ -1,20 +0,0 @@
from typing import List
from openai import AsyncOpenAI
class SimpleOpenAIEmbedding:
def __init__(
self,
model,
api_key,
api_base=None,
) -> None:
self.client = AsyncOpenAI(api_key=api_key, base_url=api_base)
self.model = model
async def get_embedding(self, text) -> List[float]:
"""
获取文本的嵌入
"""
embedding = await self.client.embeddings.create(input=text, model=self.model)
return embedding.data[0].embedding
-95
View File
@@ -1,95 +0,0 @@
import os
from typing import List, Dict
from astrbot.core import logger
from .store import Store
from astrbot.core.config import AstrBotConfig
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
class KnowledgeDBManager:
def __init__(self, astrbot_config: AstrBotConfig) -> None:
self.db_path = os.path.join(get_astrbot_data_path(), "knowledge_db")
self.config = astrbot_config.get("knowledge_db", {})
self.astrbot_config = astrbot_config
if not os.path.exists(self.db_path):
os.makedirs(self.db_path)
self.store_insts: Dict[str, Store] = {}
for name, cfg in self.config.items():
if cfg["strategy"] == "embedding":
logger.info(f"加载 Chroma Vector Store{name}")
try:
from .store.chroma_db import ChromaVectorStore
except ImportError as ie:
logger.error(f"{ie} 可能未安装 chromadb 库。")
continue
self.store_insts[name] = ChromaVectorStore(
name, cfg["embedding_config"]
)
else:
logger.error(f"不支持的策略:{cfg['strategy']}")
async def list_knowledge_db(self) -> List[str]:
return [
f
for f in os.listdir(self.db_path)
if os.path.isfile(os.path.join(self.db_path, f))
]
async def create_knowledge_db(self, name: str, config: Dict):
"""
config 格式:
```
{
"strategy": "embedding", # 目前只支持 embedding
"chunk_method": {
"strategy": "fixed",
"chunk_size": 100,
"overlap_size": 10
},
"embedding_config": {
"strategy": "openai",
"base_url": "",
"model": "",
"api_key": ""
}
}
```
"""
if name in self.config:
raise ValueError(f"知识库已存在:{name}")
self.config[name] = config
self.astrbot_config["knowledge_db"] = self.config
self.astrbot_config.save_config()
async def insert_record(self, name: str, text: str):
if name not in self.store_insts:
raise ValueError(f"未找到知识库:{name}")
ret = []
match self.config[name]["chunk_method"]["strategy"]:
case "fixed":
chunk_size = self.config[name]["chunk_method"]["chunk_size"]
chunk_overlap = self.config[name]["chunk_method"]["overlap_size"]
ret = self._fixed_chunk(text, chunk_size, chunk_overlap)
case _:
pass
for chunk in ret:
await self.store_insts[name].save(chunk)
async def retrive_records(self, name: str, query: str, top_n: int = 3) -> List[str]:
if name not in self.store_insts:
raise ValueError(f"未找到知识库:{name}")
inst = self.store_insts[name]
return await inst.query(query, top_n)
def _fixed_chunk(self, text: str, chunk_size: int, chunk_overlap: int) -> List[str]:
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
start += chunk_size - chunk_overlap
return chunks
-9
View File
@@ -1,9 +0,0 @@
from typing import List
class Store:
async def save(self, text: str):
pass
async def query(self, query: str, top_n: int = 3) -> List[str]:
pass
-44
View File
@@ -1,44 +0,0 @@
import chromadb
import uuid
from typing import List, Dict
from astrbot.api import logger
from ..embedding.openai_source import SimpleOpenAIEmbedding
from . import Store
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
class ChromaVectorStore(Store):
def __init__(self, name: str, embedding_cfg: Dict) -> None:
import os
self.chroma_client = chromadb.PersistentClient(
path=os.path.join(get_astrbot_data_path(), "long_term_memory_chroma.db")
)
self.collection = self.chroma_client.get_or_create_collection(name=name)
self.embedding = None
if embedding_cfg["strategy"] == "openai":
self.embedding = SimpleOpenAIEmbedding(
model=embedding_cfg["model"],
api_key=embedding_cfg["api_key"],
api_base=embedding_cfg.get("base_url", None),
)
async def save(self, text: str, metadata: Dict = None):
logger.debug(f"Saving text: {text}")
embedding = await self.embedding.get_embedding(text)
self.collection.upsert(
documents=text,
metadatas=metadata,
ids=str(uuid.uuid4()),
embeddings=embedding,
)
async def query(
self, query: str, top_n=3, metadata_filter: Dict = None
) -> List[str]:
embedding = await self.embedding.get_embedding(query)
results = self.collection.query(
query_embeddings=embedding, n_results=top_n, where=metadata_filter
)
return results["documents"][0]
+5 -3
View File
@@ -16,7 +16,6 @@ from .star_handler import star_handlers_registry, StarHandlerMetadata, EventType
from .filter.command import CommandFilter
from .filter.regex import RegexFilter
from typing import Awaitable
from astrbot.core.rag.knowledge_db_mgr import KnowledgeDBManager
from astrbot.core.conversation_mgr import ConversationManager
from astrbot.core.star.filter.platform_adapter_type import (
PlatformAdapterType,
@@ -42,6 +41,8 @@ class Context:
platform_manager: PlatformManager = None
registered_web_apis: list = []
# back compatibility
_register_tasks: List[Awaitable] = []
_star_manager = None
@@ -54,14 +55,12 @@ class Context:
provider_manager: ProviderManager = None,
platform_manager: PlatformManager = None,
conversation_manager: ConversationManager = None,
knowledge_db_manager: KnowledgeDBManager = None,
):
self._event_queue = event_queue
self._config = config
self._db = db
self.provider_manager = provider_manager
self.platform_manager = platform_manager
self.knowledge_db_manager = knowledge_db_manager
self.conversation_manager = conversation_manager
def get_registered_star(self, star_name: str) -> StarMetadata:
@@ -301,3 +300,6 @@ class Context:
注册一个异步任务。
"""
self._register_tasks.append(task)
def register_web_api(self, route: str, view_handler: Awaitable, methods: list, desc: str):
self.registered_web_apis.append((route, view_handler, methods, desc))
+3
View File
@@ -102,7 +102,10 @@ class PluginRoute(Route):
async def get_plugins(self):
_plugin_resp = []
plugin_name = request.args.get("name")
for plugin in self.plugin_manager.context.get_all_stars():
if plugin_name and plugin.name != plugin_name:
continue
_t = {
"name": plugin.name,
"repo": "" if plugin.repo is None else plugin.repo,
+20
View File
@@ -15,6 +15,8 @@ from astrbot.core.db import BaseDatabase
from astrbot.core.utils.io import get_local_ip_addresses
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
APP: Quart = None
class AstrBotDashboard:
def __init__(
@@ -27,6 +29,7 @@ class AstrBotDashboard:
self.config = core_lifecycle.astrbot_config
self.data_path = os.path.abspath(os.path.join(get_astrbot_data_path(), "dist"))
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
APP = self.app # noqa
self.app.config["MAX_CONTENT_LENGTH"] = (
128 * 1024 * 1024
) # 将 Flask 允许的最大上传文件体大小设置为 128 MB
@@ -51,8 +54,25 @@ class AstrBotDashboard:
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
self.file_route = FileRoute(self.context)
self.app.add_url_rule(
"/api/plug/<path:subpath>",
view_func=self.srv_plug_route,
methods=["GET", "POST"],
)
self.shutdown_event = shutdown_event
async def srv_plug_route(self, subpath, *args, **kwargs):
"""
插件路由
"""
registered_web_apis = self.core_lifecycle.star_context.registered_web_apis
for api in registered_web_apis:
route, view_handler, methods, _ = api
if route == f"/{subpath}" and request.method in methods:
return await view_handler(*args, **kwargs)
return jsonify(Response().error("未找到该路由").__dict__)
async def auth_middleware(self):
if not request.path.startswith("/api"):
return
+1
View File
@@ -20,6 +20,7 @@
"axios": "^1.6.2",
"axios-mock-adapter": "^1.22.0",
"chance": "1.1.11",
"d3": "^7.9.0",
"date-fns": "2.30.0",
"highlight.js": "^11.11.1",
"js-md5": "^0.8.3",
@@ -166,6 +166,10 @@ function endDrag() {
</template>
</v-list>
<div style="position: absolute; bottom: 16px; width: 100%; font-size: 13px;" class="text-center">
<v-btn style="margin-bottom: 8px;" size="small" variant="primary" v-if="!customizer.mini_sidebar" to="/settings">
🔧 设置
</v-btn>
<br/>
<v-btn style="margin-bottom: 8px;" size="small" variant="plain" v-if="!customizer.mini_sidebar" @click="toggleIframe">
官方文档
</v-btn>
@@ -65,11 +65,11 @@ const sidebarItem: menu[] = [
icon: 'mdi-console',
to: '/console'
},
{
title: '设置',
icon: 'mdi-wrench',
to: '/settings'
},
// {
// title: 'Alkaid',
// icon: 'mdi-test-tube',
// to: '/alkaid'
// },
{
title: '关于',
icon: 'mdi-information',
+20 -3
View File
@@ -57,9 +57,26 @@ const MainRoutes = {
component: () => import('@/views/ConsolePage.vue')
},
{
name: 'Project ATRI',
path: '/project-atri',
component: () => import('@/views/ATRIProject.vue')
name: 'Alkaid',
path: '/alkaid',
component: () => import('@/views/AlkaidPage.vue'),
children: [
{
path: 'knowledge-base',
name: 'KnowledgeBase',
component: () => import('@/views/alkaid/KnowledgeBase.vue')
},
{
path: 'long-term-memory',
name: 'LongTermMemory',
component: () => import('@/views/alkaid/LongTermMemory.vue')
},
{
path: 'other',
name: 'OtherFeatures',
component: () => import('@/views/alkaid/Other.vue')
}
]
},
{
name: 'Chat',
-87
View File
@@ -1,87 +0,0 @@
<script setup>
</script>
<template>
<v-alert style="margin-bottom: 16px"
text="这是一个长期实验性功能,目标是实现更具人类机能的 LLM 对话。推荐使用 gpt-4o-mini 作为文本生成和视觉理解模型,成本很低。推荐使用 text-embedding-3-small 作为 Embedding 模型,成本忽略不计。"
title="💡实验性功能" type="info" variant="tonal">
</v-alert>
<v-card>
<v-card-text>
<v-container fluid>
<AstrBotConfig :metadata="project_atri_config_metadata" :iterable="project_atri_config?.project_atri"
metadataKey="project_atri">
</AstrBotConfig>
</v-container>
</v-card-text>
</v-card>
<v-btn icon="mdi-content-save" size="x-large" style="position: fixed; right: 52px; bottom: 52px;" color="darkprimary"
@click="updateConfig">
</v-btn>
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
{{ save_message }}
</v-snackbar>
<WaitingForRestart ref="wfr"></WaitingForRestart>
</template>
<script>
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
export default {
name: 'AtriProject',
components: {
AstrBotConfig,
WaitingForRestart
},
data() {
return {
project_atri_config: {},
fetched: false,
project_atri_config_metadata: {},
save_message_snack: false,
save_message: "",
save_message_success: "",
}
},
mounted() {
this.getConfig();
},
methods: {
getConfig() {
// 获取配置
axios.get('/api/config/get').then((res) => {
this.project_atri_config = res.data.data.config;
this.fetched = true
this.project_atri_config_metadata = res.data.data.metadata;
}).catch((err) => {
save_message = err;
save_message_snack = true;
save_message_success = "error";
});
},
updateConfig() {
if (!this.fetched) return;
axios.post('/api/config/astrbot/update', this.project_atri_config).then((res) => {
if (res.data.status === "ok") {
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
this.$refs.wfr.check();
} else {
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "error";
}
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
},
},
}
</script>
+80
View File
@@ -0,0 +1,80 @@
<template>
<v-card style="height: 100%; width: 100%;">
<v-card-text class="pa-4" style="height: 100%;">
<v-container fluid class="d-flex flex-column" style="height: 100%;">
<div style="margin-bottom: 32px;">
<h1 class="gradient-text">The Alkaid Project.</h1>
<small style="color: #a3a3a3;">AstrBot Alpha 项目</small>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<v-btn size="large" :variant="isActive('knowledge-base') ? 'flat' : 'tonal'"
:color="isActive('knowledge-base') ? '#9b72cb' : ''" rounded="lg"
@click="navigateTo('knowledge-base')">
<v-icon start>mdi-text-box-search</v-icon>
知识库
</v-btn>
<v-btn size="large" :variant="isActive('long-term-memory') ? 'flat' : 'tonal'"
:color="isActive('long-term-memory') ? '#9b72cb' : ''" rounded="lg"
@click="navigateTo('long-term-memory')">
<v-icon start>mdi-dots-hexagon</v-icon>
长期记忆层
</v-btn>
<v-btn size="large" :variant="isActive('other') ? 'flat' : 'tonal'"
:color="isActive('other') ? '#9b72cb' : ''" rounded="lg"
@click="navigateTo('other')">
<v-icon start>mdi-tools</v-icon>
...
</v-btn>
</div>
<div id="sub-view" class="flex-grow-1" style="max-height: 100%;">
<router-view></router-view>
</div>
</v-container>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'AlkaidPage',
components: {},
data() {
return {}
},
methods: {
navigateTo(tab) {
this.$router.push(`/alkaid/${tab}`);
},
isActive(tab) {
return this.$route.path.includes(`/alkaid/${tab}`);
}
},
mounted() {
// 如果在根路径 /alkaid,默认跳转到知识库页面
if (this.$route.path === '/alkaid') {
this.navigateTo('knowledge-base');
}
}
}
</script>
<style scoped>
.gradient-text {
background: linear-gradient(74deg, #2abfe1 0, #9b72cb 25%, #b55908 50%, #d93025 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: bold;
}
#subview {
display: flex;
flex-direction: column;
flex-grow: 1;
width: 100%;
height: 100%;
}
</style>
+432
View File
@@ -0,0 +1,432 @@
<script setup>
import Graph from "graphology";
import Sigma from "sigma";
import ForceSupervisor from "graphology-layout-force/worker";
</script>
<template>
<v-card style="height: 100%; width: 100%;">
<v-card-text class="pa-4" style="height: 100%;">
<v-container fluid class="d-flex flex-column" style="height: 100%;">
<div style="margin-bottom: 32px;">
<h1 class="gradient-text">The Alkaid Project.</h1>
<small style="color: #a3a3a3;">AstrBot 实验性项目</small>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<v-btn size="large" :variant="activeTab === 'long-term-memory' ? 'flat' : 'tonal'"
:color="activeTab === 'long-term-memory' ? '#9b72cb' : ''" rounded="lg"
@click="activeTab = 'long-term-memory'">
<v-icon start>mdi-dots-hexagon</v-icon>
长期记忆层
</v-btn>
<v-btn size="large" :variant="activeTab === 'other' ? 'flat' : 'tonal'"
:color="activeTab === 'other' ? '#9b72cb' : ''" rounded="lg" @click="activeTab = 'other'">
<v-icon start>mdi-dots-horizontal</v-icon>
其他
</v-btn>
</div>
<div v-if="activeTab === 'long-term-memory'" id="long-term-memory" class="flex-grow-1"
style="display: flex; flex-direction: row;">
<div id="graph-container" style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px;">
</div>
<div id="graph-control-panel"
style="min-width: 450px; border: 1px solid #eee; border-radius: 8px; padding: 16px; margin-left: 16px;">
<div>
<span style="color: #333333;">可视化</span>
<div style="margin-top: 8px;">
<v-autocomplete v-model="searchUserId" :items="userIdList" variant="outlined"
label="筛选用户 ID"></v-autocomplete>
<v-btn color="primary" @click="onNodeSelect" variant="tonal" style="margin-top: 8px;">
<v-icon start>mdi-magnify</v-icon>
筛选
</v-btn>
<v-btn color="secondary" @click="resetFilter" variant="tonal"
style="margin-top: 8px; margin-left: 8px;">
<v-icon start>mdi-filter-remove</v-icon>
重置筛选
</v-btn>
</div>
<div style="margin-top: 16px;">
<v-btn color="primary" @click="refreshGraph" variant="tonal">
<v-icon start>mdi-refresh</v-icon>
刷新图形
</v-btn>
</div>
</div>
<v-divider class="my-4"></v-divider>
<div v-if="selectedNode" class="mt-4">
<h3>节点详情</h3>
<v-card variant="outlined" class="mt-2 pa-3">
<div v-if="selectedNode.id">
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">ID:</span>
<span>{{ selectedNode.id }}</span>
</div>
</div>
<div v-if="selectedNode._label">
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">类型:</span>
<span>{{ selectedNode._label }}</span>
</div>
</div>
<div v-if="selectedNode.name">
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">名称:</span>
<span>{{ selectedNode.name }}</span>
</div>
</div>
<div v-if="selectedNode.user_id">
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">用户ID:</span>
<span>{{ selectedNode.user_id }}</span>
</div>
</div>
<div v-if="selectedNode.ts">
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">时间戳:</span>
<span>{{ selectedNode.ts }}</span>
</div>
</div>
<div v-if="selectedNode.type">
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">类型:</span>
<span>{{ selectedNode.type }}</span>
</div>
</div>
</v-card>
</div>
<div v-if="graphStats" class="mt-4">
<h3>图形统计</h3>
<v-card variant="outlined" class="mt-2 pa-3">
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">节点数:</span>
<span>{{ graphStats.nodeCount }}</span>
</div>
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">边数:</span>
<span>{{ graphStats.edgeCount }}</span>
</div>
</v-card>
</div>
</div>
</div>
<div v-if="activeTab === 'other'" class="flex-grow-1" style="display: flex; flex-direction: column;">
<div class="d-flex align-center justify-center"
style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px;">
<v-icon size="64" color="grey-lighten-1">mdi-tools</v-icon>
<p class="text-h6 text-grey ml-4">功能开发中</p>
</div>
</div>
</v-container>
</v-card-text>
</v-card>
</template>
<script>
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
export default {
name: 'AlkaidPage',
components: {
AstrBotConfig,
WaitingForRestart
},
data() {
return {
renderer: null,
graph: null,
layout: null,
activeTab: 'long-term-memory',
node_data: [],
edge_data: [],
searchUserId: null,
userIdList: [],
selectedNode: null,
graphStats: null,
nodeColors: {
'PhaseNode': '#4CAF50', // 绿色
'PassageNode': '#2196F3', // 蓝色
'FactNode': '#FF9800', // 橙色
'default': '#9C27B0' // 紫色作为默认
},
edgeColors: {
'_include_': '#607D8B',
'_related_': '#9E9E9E',
'default': '#BDBDBD'
},
isLoading: false
}
},
mounted() {
this.initSigma();
this.ltmGetGraph();
this.ltmGetUserIds();
},
beforeUnmount() {
if (this.renderer) {
this.renderer.kill();
}
if (this.layout) {
this.layout.stop();
}
},
watch: {
activeTab(newVal) {
if (newVal === 'long-term-memory') {
this.$nextTick(() => {
if (!this.renderer) {
this.initSigma();
}
});
} else {
if (this.renderer) {
this.renderer.kill();
this.renderer = null;
}
if (this.layout) {
this.layout.stop();
this.layout = null;
}
}
}
},
methods: {
ltmGetGraph(userId = null) {
this.isLoading = true;
const params = userId ? { user_id: userId } : {};
axios.get('/api/plug/alkaid/ltm/graph', { params })
.then(response => {
let nodes = response.data.data.nodes;
let edges = response.data.data.edges;
this.node_data = nodes;
this.edge_data = edges;
if (this.graph) {
this.graph.clear();
}
nodes.forEach(node => {
const nodeId = node[0];
const nodeData = node[1];
if (!this.graph.hasNode(nodeId)) {
const nodeType = nodeData._label || 'default';
const color = this.nodeColors[nodeType] || this.nodeColors['default'];
this.graph.addNode(nodeId, {
x: Math.random(),
y: Math.random(),
size: 5,
label: nodeData.name || nodeId.split('_')[0],
color: color,
originalData: nodeData
});
}
});
// 添加边
edges.forEach(edge => {
const sourceId = edge[0];
const targetId = edge[1];
const edgeData = edge[2];
if (this.graph.hasNode(sourceId) && this.graph.hasNode(targetId)) {
const edgeId = `${sourceId}->${targetId}`;
const relationType = edgeData.relation_type || 'default';
const color = this.edgeColors[relationType] || this.edgeColors['default'];
this.graph.addEdge(sourceId, targetId, {
size: 1,
color: color,
originalData: edgeData,
label: relationType,
type: "line"
});
} else {
console.warn(`Edge ${sourceId} -> ${targetId} has missing nodes.`);
}
});
this.updateGraphStats();
console.log('Graph initialized with', nodes.length, 'nodes and', edges.length, 'edges');
})
.catch(error => {
console.error('Error fetching graph data:', error);
})
.finally(() => {
this.isLoading = false;
});
if (this.layout) {
this.layout.start();
}
},
ltmGetUserIds() {
axios.get('/api/plug/alkaid/ltm/user_ids')
.then(response => {
this.userIdList = response.data.data;
})
.catch(error => {
console.error('Error fetching user IDs:', error);
});
},
updateGraphStats() {
if (this.graph) {
this.graphStats = {
nodeCount: this.graph.order,
edgeCount: this.graph.size
};
}
},
refreshGraph() {
this.ltmGetGraph(this.searchUserId);
},
onNodeSelect() {
console.log('Selected user ID:', this.searchUserId);
if (!this.searchUserId || !this.graph) return;
// 使用API的user_id参数筛选数据
this.ltmGetGraph(this.searchUserId);
},
resetFilter() {
this.searchUserId = null;
this.ltmGetGraph();
},
initSigma() {
const container = document.getElementById("graph-container");
if (!container) return;
if (this.renderer) {
this.renderer.kill();
this.renderer = null;
}
if (this.layout) {
this.layout.stop();
this.layout = null;
}
const graph = new Graph({
multi: true,
});
const layout = new ForceSupervisor(graph, {
isNodeFixed: (_, attr) => attr.highlighted, settings: {
gravity: 0.0001,
repulsion: 0.001
}
});
layout.start();
this.layout = layout;
this.graph = graph;
const renderer = new Sigma(graph, container, {
minCameraRatio: 0.01,
maxCameraRatio: 2,
labelRenderedSizeThreshold: 1,
renderLabels: true,
renderEdgeLabels: true,
labelSize: 14,
labelColor: "#333333",
});
this.renderer = renderer;
let draggedNode = null;
let isDragging = false;
renderer.on("downNode", (e) => {
isDragging = true;
draggedNode = e.node;
graph.setNodeAttribute(draggedNode, "highlighted", true);
if (!renderer.getCustomBBox()) renderer.setCustomBBox(renderer.getBBox());
});
renderer.on("moveBody", ({ event }) => {
if (!isDragging || !draggedNode) return;
const pos = renderer.viewportToGraph(event);
graph.setNodeAttribute(draggedNode, "x", pos.x);
graph.setNodeAttribute(draggedNode, "y", pos.y);
event.preventSigmaDefault();
event.original.preventDefault();
event.original.stopPropagation();
});
const handleUp = () => {
if (draggedNode) {
graph.removeNodeAttribute(draggedNode, "highlighted");
}
isDragging = false;
draggedNode = null;
};
renderer.on("upNode", handleUp);
renderer.on("upStage", handleUp);
renderer.on("clickNode", (e) => {
const nodeId = e.node;
const nodeAttributes = graph.getNodeAttributes(nodeId);
this.selectedNode = nodeAttributes.originalData;
});
renderer.on("clickStage", () => {
this.selectedNode = null;
});
},
getRandomColor() {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
},
}
</script>
<style scoped>
.gradient-text {
background: linear-gradient(74deg, #2abfe1 0, #9b72cb 25%, #b55908 50%, #d93025 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: bold;
}
#graph-container {
position: relative;
background-color: #f2f6f9;
overflow: hidden;
min-height: 200px;
}
#graph-container:hover {
cursor: pointer;
}
.memory-header {
padding: 0 8px;
}
</style>
+15 -13
View File
@@ -12,19 +12,22 @@ marked.setOptions({
<v-card class="chat-page-card">
<v-card-text class="chat-page-container">
<div class="chat-layout">
<!-- 左侧对话列表面板 - 优化版 -->
<div class="sidebar-panel">
<div class="sidebar-header">
<v-btn variant="elevated" rounded="lg" class="new-chat-btn" @click="newC" :disabled="!currCid"
prepend-icon="mdi-plus">
创建对话
<v-btn icon variant="plain">
<v-icon icon="mdi-menu" color="deep-purple"></v-icon>
</v-btn>
</div>
<div style="padding: 16px; padding-top: 8px;">
<v-btn variant="elevated" rounded="lg" class="new-chat-btn" @click="newC" :disabled="!currCid"
prepend-icon="mdi-plus">
创建对话
</v-btn>
</div>
<div class="conversations-container">
<div class="sidebar-section-title" v-if="conversations.length > 0">
对话历史
</div>
<v-card class="conversation-list-card" v-if="conversations.length > 0" flat>
<v-list density="compact" nav class="conversation-list"
@@ -36,7 +39,7 @@ marked.setOptions({
</template>
<v-list-item-title class="conversation-title">新对话</v-list-item-title>
<v-list-item-subtitle class="timestamp">{{ formatDate(item.updated_at)
}}</v-list-item-subtitle>
}}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
@@ -151,10 +154,10 @@ marked.setOptions({
<!-- 输入区域 -->
<div class="input-area fade-in">
<v-text-field id="input-field" variant="outlined" v-model="prompt" :label="inputFieldLabel"
placeholder="开始输入..." :loading="loadingChat" clear-icon="mdi-close-circle" clearable
@click:clear="clearMessage" class="message-input" @keydown="handleInputKeyDown"
hide-details>
<v-text-field autocomplete="off" id="input-field" variant="outlined" v-model="prompt"
:label="inputFieldLabel" placeholder="开始输入..." :loading="loadingChat"
clear-icon="mdi-close-circle" clearable @click:clear="clearMessage" class="message-input"
@keydown="handleInputKeyDown" hide-details>
<template v-slot:loader>
<v-progress-linear :active="loadingChat" height="3" color="deep-purple"
indeterminate></v-progress-linear>
@@ -701,7 +704,6 @@ export default {
.sidebar-header {
padding: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.conversations-container {
@@ -0,0 +1,750 @@
<template>
<div class="flex-grow-1" style="display: flex; flex-direction: column; height: 100%;">
<div style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px; padding: 16px">
<!-- knowledge card -->
<div v-if="!installed" class="d-flex align-center justify-center flex-column"
style="flex-grow: 1; width: 100%; height: 100%;">
<h2>还没有安装知识库插件</h2>
<v-btn style="margin-top: 16px;" variant="tonal" color="primary"
@click="installPlugin" :loading="installing">
立即安装
</v-btn>
</div>
<div v-else-if="kbCollections.length == 0" class="d-flex align-center justify-center flex-column"
style="flex-grow: 1; width: 100%; height: 100%;">
<h2>还没有知识库快创建一个吧🙂</h2>
<v-btn style="margin-top: 16px;" variant="tonal" color="primary" @click="showCreateDialog = true">
创建知识库
</v-btn>
</div>
<div v-else>
<h2 class="mb-4">知识库列表</h2>
<v-btn class="mb-4" prepend-icon="mdi-plus" variant="tonal" color="primary"
@click="showCreateDialog = true">
创建新知识库
</v-btn>
<div class="kb-grid">
<div v-for="(kb, index) in kbCollections" :key="index" class="kb-card"
@click="openKnowledgeBase(kb)">
<div class="book-spine"></div>
<div class="book-content">
<div class="emoji-container">
<span class="kb-emoji">{{ kb.emoji || '🙂' }}</span>
</div>
<div class="kb-name">{{ kb.collection_name }}</div>
<div class="kb-count">{{ kb.count || 0 }} 条知识</div>
<div class="kb-actions">
<v-btn icon variant="text" size="small" color="error" @click.stop="confirmDelete(kb)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</div>
</div>
</div>
</div>
<div style="padding: 16px; text-align: center;">
<small style="color: #a3a3a3">Tips: 在聊天页面通过 /kb 指令了解如何使用</small>
</div>
</div>
</div>
<!-- 创建知识库对话框 -->
<v-dialog v-model="showCreateDialog" max-width="500px">
<v-card>
<v-card-title class="text-h4">创建新知识库</v-card-title>
<v-card-text>
<div style="width: 100%; display: flex; align-items: center; justify-content: center;">
<span id="emoji-display" @click="showEmojiPicker = true">
{{ newKB.emoji || '🙂' }}
</span>
</div>
<v-form @submit.prevent="submitCreateForm">
<v-text-field variant="outlined" v-model="newKB.name" label="知识库名称" required></v-text-field>
<v-textarea v-model="newKB.description" label="描述" variant="outlined" placeholder="知识库的简短描述..."
rows="3"></v-textarea>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="error" variant="text" @click="showCreateDialog = false">取消</v-btn>
<v-btn color="primary" variant="text" @click="submitCreateForm">创建</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 表情选择器对话框 -->
<v-dialog v-model="showEmojiPicker" max-width="400px">
<v-card>
<v-card-title class="text-h6">选择表情</v-card-title>
<v-card-text>
<div class="emoji-picker">
<div v-for="(category, catIndex) in emojiCategories" :key="catIndex" class="mb-4">
<div class="text-subtitle-2 mb-2">{{ category.name }}</div>
<div class="emoji-grid">
<div v-for="(emoji, emojiIndex) in category.emojis" :key="emojiIndex" class="emoji-item"
@click="selectEmoji(emoji)">
{{ emoji }}
</div>
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="showEmojiPicker = false">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 知识库内容管理对话框 -->
<v-dialog v-model="showContentDialog" max-width="1000px">
<v-card>
<v-card-title class="d-flex align-center">
<div class="me-2 emoji-sm">{{ currentKB.emoji || '🙂' }}</div>
<span>{{ currentKB.collection_name }} - 知识库管理</span>
<v-spacer></v-spacer>
<v-btn variant="plain" icon @click="showContentDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-tabs v-model="activeTab">
<v-tab value="upload">上传文件</v-tab>
<v-tab value="search">搜索内容</v-tab>
</v-tabs>
<v-window v-model="activeTab" class="mt-4">
<!-- 上传文件标签页 -->
<v-window-item value="upload">
<div class="upload-container pa-4">
<div class="text-center mb-4">
<h3>上传文件到知识库</h3>
<p class="text-subtitle-1">支持 txtpdfwordexcel 等多种格式</p>
</div>
<div class="upload-zone" @dragover.prevent @drop.prevent="onFileDrop"
@click="triggerFileInput">
<input type="file" ref="fileInput" style="display: none" @change="onFileSelected" />
<v-icon size="48" color="primary">mdi-cloud-upload</v-icon>
<p class="mt-2">拖放文件到这里或点击上传</p>
</div>
<div class="selected-files mt-4" v-if="selectedFile">
<div type="info" variant="tonal" class="d-flex align-center">
<div>
<v-icon class="me-2">{{ getFileIcon(selectedFile.name) }}</v-icon>
<span style="font-weight: 1000;">{{ selectedFile.name }}</span>
</div>
<v-btn size="small" color="error" variant="text" @click="selectedFile = null">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div class="text-center mt-4">
<v-btn color="primary" variant="elevated" :loading="uploading"
:disabled="!selectedFile" @click="uploadFile">
上传到知识库
</v-btn>
</div>
</div>
<div class="upload-progress mt-4" v-if="uploading">
<v-progress-linear indeterminate color="primary"></v-progress-linear>
</div>
</div>
</v-window-item>
<!-- 搜索内容标签页 -->
<v-window-item value="search">
<div class="search-container pa-4">
<v-form @submit.prevent="searchKnowledgeBase" class="d-flex align-center">
<v-text-field v-model="searchQuery" label="搜索知识库内容" append-icon="mdi-magnify"
variant="outlined" class="flex-grow-1 me-2" @click:append="searchKnowledgeBase"
@keyup.enter="searchKnowledgeBase" placeholder="输入关键词搜索知识库内容..."
hide-details></v-text-field>
<v-select v-model="topK" :items="[3, 5, 10, 20]" label="结果数量" variant="outlined"
style="max-width: 120px;" hide-details></v-select>
</v-form>
<div class="search-results mt-4">
<div v-if="searching">
<v-progress-linear indeterminate color="primary"></v-progress-linear>
<p class="text-center mt-4">正在搜索...</p>
</div>
<div v-else-if="searchResults.length > 0">
<h3 class="mb-2">搜索结果</h3>
<v-card v-for="(result, index) in searchResults" :key="index"
class="mb-4 search-result-card" variant="outlined">
<v-card-text>
<div class="d-flex align-center mb-2">
<v-icon class="me-2" size="small"
color="primary">mdi-file-document-outline</v-icon>
<span class="text-caption text-medium-emphasis">{{
result.metadata.source }}</span>
<v-spacer></v-spacer>
<v-chip v-if="result.score" size="small" color="primary"
variant="tonal">
相关度: {{ Math.round(result.score * 100) }}%
</v-chip>
</div>
<div class="search-content">{{ result.content }}</div>
</v-card-text>
</v-card>
</div>
<div v-else-if="searchPerformed">
<v-alert type="info" variant="tonal">
没有找到匹配的内容
</v-alert>
</div>
</div>
</div>
</v-window-item>
</v-window>
</v-card-text>
</v-card>
</v-dialog>
<!-- 删除知识库确认对话框 -->
<v-dialog v-model="showDeleteDialog" max-width="400px">
<v-card>
<v-card-title class="text-h5">确认删除</v-card-title>
<v-card-text>
<p>您确定要删除知识库 <span class="font-weight-bold">{{ deleteTarget.collection_name }}</span> </p>
<p class="text-red">此操作不可逆所有知识库内容将被永久删除</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey-darken-1" variant="text" @click="showDeleteDialog = false">取消</v-btn>
<v-btn color="error" variant="text" @click="deleteKnowledgeBase" :loading="deleting">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'KnowledgeBase',
data() {
return {
installed: true,
installing: false,
kbCollections: [],
showCreateDialog: false,
showEmojiPicker: false,
newKB: {
name: '',
emoji: '🙂',
description: ''
},
snackbar: {
show: false,
text: '',
color: 'success'
},
emojiCategories: [
{
name: '笑脸和情感',
emojis: ['😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩', '😘']
},
{
name: '动物和自然',
emojis: ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵']
},
{
name: '食物和饮料',
emojis: ['🍏', '🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥']
},
{
name: '活动和物品',
emojis: ['⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉', '🎱', '🏓', '🏸', '🥅', '🏒', '🏑', '🥍']
},
{
name: '旅行和地点',
emojis: ['🚗', '🚕', '🚙', '🚌', '🚎', '🏎️', '🚓', '🚑', '🚒', '🚐', '🚚', '🚛', '🚜', '🛴', '🚲']
},
{
name: '符号和旗帜',
emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗']
}
],
showContentDialog: false,
currentKB: {
collection_name: '',
emoji: ''
},
activeTab: 'upload',
selectedFile: null,
uploading: false,
searchQuery: '',
searchResults: [],
searching: false,
searchPerformed: false,
topK: 5,
showDeleteDialog: false,
deleteTarget: {
collection_name: ''
},
deleting: false
}
},
mounted() {
this.checkPlugin();
},
methods: {
checkPlugin() {
axios.get('/api/plugin/get?name=astrbot_plugin_knowledge_base')
.then(response => {
if (response.data.status !== 'ok') {
this.showSnackbar('插件未安装或不可用', 'error');
}
if (response.data.data.length > 0) {
this.installed = true;
this.getKBCollections();
} else {
this.installed = false;
}
})
.catch(error => {
console.error('Error checking plugin:', error);
this.showSnackbar('检查插件失败', 'error');
})
},
installPlugin() {
this.installing = true;
axios.post('/api/plugin/install', {
url: "https://github.com/soulter/astrbot_plugin_knowledge_base",
proxy: localStorage.getItem('selectedGitHubProxy') || ""
})
.then(response => {
if (response.data.status === 'ok') {
this.checkPlugin();
} else {
this.showSnackbar(response.data.message || '安装失败', 'error');
}
})
.catch(error => {
console.error('Error installing plugin:', error);
this.showSnackbar('安装插件失败', 'error');
}).finally(() => {
this.installing = false;
});
},
getKBCollections() {
axios.get('/api/plug/alkaid/kb/collections')
.then(response => {
this.kbCollections = response.data.data;
})
.catch(error => {
console.error('Error fetching knowledge base collections:', error);
this.showSnackbar('获取知识库列表失败', 'error');
});
},
createCollection(name, emoji, description) {
axios.post('/api/plug/alkaid/kb/create_collection', {
collection_name: name,
emoji: emoji,
description: description
})
.then(response => {
if (response.data.status === 'ok') {
this.showSnackbar('知识库创建成功');
this.getKBCollections();
this.showCreateDialog = false;
this.resetNewKB();
} else {
this.showSnackbar(response.data.message || '创建失败', 'error');
}
})
.catch(error => {
console.error('Error creating knowledge base collection:', error);
this.showSnackbar('创建知识库失败', 'error');
});
},
submitCreateForm() {
if (!this.newKB.name) {
this.showSnackbar('请输入知识库名称', 'warning');
return;
}
this.createCollection(
this.newKB.name,
this.newKB.emoji || '🙂',
this.newKB.description
);
},
resetNewKB() {
this.newKB = {
name: '',
emoji: '🙂',
description: ''
};
},
openKnowledgeBase(kb) {
// 不再跳转路由,而是打开对话框
this.currentKB = kb;
this.showContentDialog = true;
this.resetContentDialog();
},
resetContentDialog() {
this.activeTab = 'upload';
this.selectedFile = null;
this.searchQuery = '';
this.searchResults = [];
this.searchPerformed = false;
},
triggerFileInput() {
this.$refs.fileInput.click();
},
onFileSelected(event) {
const files = event.target.files;
if (files.length > 0) {
this.selectedFile = files[0];
}
},
onFileDrop(event) {
const files = event.dataTransfer.files;
if (files.length > 0) {
this.selectedFile = files[0];
}
},
getFileIcon(filename) {
const extension = filename.split('.').pop().toLowerCase();
switch (extension) {
case 'pdf':
return 'mdi-file-pdf-box';
case 'doc':
case 'docx':
return 'mdi-file-word-box';
case 'xls':
case 'xlsx':
return 'mdi-file-excel-box';
case 'ppt':
case 'pptx':
return 'mdi-file-powerpoint-box';
case 'txt':
return 'mdi-file-document-outline';
default:
return 'mdi-file-outline';
}
},
uploadFile() {
if (!this.selectedFile) {
this.showSnackbar('请先选择文件', 'warning');
return;
}
this.uploading = true;
const formData = new FormData();
formData.append('file', this.selectedFile);
formData.append('collection_name', this.currentKB.collection_name);
axios.post('/api/plug/alkaid/kb/collection/add_file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then(response => {
if (response.data.status === 'ok') {
this.showSnackbar('文件上传成功');
this.selectedFile = null;
// 刷新知识库列表,获取更新的数量
this.getKBCollections();
} else {
this.showSnackbar(response.data.message || '上传失败', 'error');
}
})
.catch(error => {
console.error('Error uploading file:', error);
this.showSnackbar('文件上传失败', 'error');
})
.finally(() => {
this.uploading = false;
});
},
searchKnowledgeBase() {
if (!this.searchQuery.trim()) {
this.showSnackbar('请输入搜索内容', 'warning');
return;
}
this.searching = true;
this.searchPerformed = true;
axios.get(`/api/plug/alkaid/kb/collection/search`, {
params: {
collection_name: this.currentKB.collection_name,
query: this.searchQuery,
top_k: this.topK
}
})
.then(response => {
if (response.data.status === 'ok') {
this.searchResults = response.data.data || [];
if (this.searchResults.length === 0) {
this.showSnackbar('没有找到匹配的内容', 'info');
}
} else {
this.showSnackbar(response.data.message || '搜索失败', 'error');
this.searchResults = [];
}
})
.catch(error => {
console.error('Error searching knowledge base:', error);
this.showSnackbar('搜索知识库失败', 'error');
this.searchResults = [];
})
.finally(() => {
this.searching = false;
});
},
showSnackbar(text, color = 'success') {
this.snackbar.text = text;
this.snackbar.color = color;
this.snackbar.show = true;
},
selectEmoji(emoji) {
this.newKB.emoji = emoji;
this.showEmojiPicker = false;
},
confirmDelete(kb) {
this.deleteTarget = kb;
this.showDeleteDialog = true;
},
deleteKnowledgeBase() {
if (!this.deleteTarget.collection_name) {
this.showSnackbar('删除目标不存在', 'error');
return;
}
this.deleting = true;
axios.get('/api/plug/alkaid/kb/collection/delete', {
params: {
collection_name: this.deleteTarget.collection_name
}
})
.then(response => {
if (response.data.status === 'ok') {
this.showSnackbar('知识库删除成功');
this.getKBCollections(); // 刷新列表
this.showDeleteDialog = false;
} else {
this.showSnackbar(response.data.message || '删除失败', 'error');
}
})
.catch(error => {
console.error('Error deleting knowledge base:', error);
this.showSnackbar('删除知识库失败', 'error');
})
.finally(() => {
this.deleting = false;
});
},
}
}
</script>
<style scoped>
.kb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 24px;
margin-top: 16px;
}
.kb-card {
height: 280px;
border-radius: 8px;
overflow: hidden;
position: relative;
cursor: pointer;
display: flex;
background-color: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.kb-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
}
.book-spine {
width: 12px;
background-color: #5c6bc0;
height: 100%;
border-radius: 2px 0 0 2px;
}
.book-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
background: linear-gradient(145deg, #f5f7fa 0%, #e4e8f0 100%);
}
.emoji-container {
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
background-color: #ffffff;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 16px;
}
.kb-emoji {
font-size: 40px;
}
.kb-name {
font-weight: bold;
font-size: 18px;
margin-bottom: 8px;
text-align: center;
color: #333;
}
.kb-count {
font-size: 14px;
color: #666;
}
.emoji-picker {
max-height: 300px;
overflow-y: auto;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 8px;
}
.emoji-item {
font-size: 24px;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.emoji-item:hover {
background-color: rgba(0, 0, 0, 0.05);
}
#emoji-display {
font-size: 64px;
cursor: pointer;
transition: transform 0.2s ease;
}
#emoji-display:hover {
transform: scale(1.1);
}
.emoji-sm {
font-size: 24px;
}
.upload-zone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 32px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.upload-zone:hover {
border-color: #5c6bc0;
background-color: rgba(92, 107, 192, 0.05);
}
.search-container {
min-height: 300px;
}
.search-result-card {
transition: all 0.2s ease;
}
.search-result-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.search-content {
white-space: pre-line;
max-height: 200px;
overflow-y: auto;
font-size: 0.95rem;
line-height: 1.6;
padding: 8px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 4px;
}
.kb-actions {
position: absolute;
bottom: 10px;
right: 10px;
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.2s ease;
}
.kb-card {
position: relative;
}
.kb-card:hover .kb-actions {
opacity: 1;
}
</style>
@@ -0,0 +1,610 @@
<template>
<div id="long-term-memory" class="flex-grow-1" style="display: flex; flex-direction: row; ">
<div id="graph-container" style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px; max-height: calc(100% - 40px);">
</div>
<div id="graph-control-panel"
style="min-width: 450px; border: 1px solid #eee; border-radius: 8px; padding: 16px; padding-bottom: 0px; margin-left: 16px; max-height: calc(100% - 40px);">
<div>
<!-- <span style="color: #333333;">可视化</span> -->
<h3>筛选</h3>
<div style="margin-top: 8px;">
<v-autocomplete v-model="searchUserId" density="compact" :items="userIdList" variant="outlined"
label="筛选用户 ID"></v-autocomplete>
</div>
<div style="display: flex; gap: 8px;">
<v-btn color="primary" @click="onNodeSelect" variant="tonal">
<v-icon start>mdi-magnify</v-icon>
筛选
</v-btn>
<v-btn color="secondary" @click="resetFilter" variant="tonal">
<v-icon start>mdi-filter-remove</v-icon>
重置筛选
</v-btn>
<v-btn color="primary" @click="refreshGraph" variant="tonal">
<v-icon start>mdi-refresh</v-icon>
刷新图形
</v-btn>
</div>
</div>
<!-- 新增搜索记忆功能 -->
<div class="mt-4">
<h3>搜索记忆</h3>
<v-card variant="outlined" class="mt-2 pa-3">
<div >
<v-text-field
v-model="searchMemoryUserId"
label="用户 ID"
variant="outlined"
density="compact"
hide-details
class="mb-2"
></v-text-field>
<v-text-field
v-model="searchQuery"
label="输入关键词"
variant="outlined"
density="compact"
hide-details
@keyup.enter="searchMemory"
class="mb-2"
></v-text-field>
<v-btn color="info" @click="searchMemory" :loading="isSearching" variant="tonal">
<v-icon start>mdi-text-search</v-icon>
搜索
</v-btn>
</div>
<!-- 新增搜索结果展示区域 -->
<div v-if="searchResults.length > 0" class="mt-3">
<v-divider class="mb-3"></v-divider>
<div class="text-subtitle-1 mb-2">搜索结果 ({{ searchResults.length }})</div>
<v-expansion-panels variant="accordion">
<v-expansion-panel
v-for="(result, index) in searchResults"
:key="index"
>
<v-expansion-panel-title>
<div>
<span class="text-truncate d-inline-block" style="max-width: 300px;">{{ result.text.substring(0, 30) }}...</span>
<span class="ms-2 text-caption text-grey">(相关度: {{ (result.score * 100).toFixed(1) }}%)</span>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div>
<div class="mb-2 text-body-1">{{ result.text }}</div>
<div class="d-flex">
<span class="text-caption text-grey">文档ID: {{ result.doc_id }}</span>
</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
<div v-else-if="hasSearched" class="mt-3 text-center text-body-1 text-grey">
未找到相关记忆内容
</div>
</v-card>
</div>
<!-- 新增添加记忆数据的表单 -->
<div class="mt-4">
<h3>添加记忆数据</h3>
<v-card variant="outlined" class="mt-2 pa-3">
<v-form @submit.prevent="addMemoryData">
<v-textarea
v-model="newMemoryText"
label="输入文本内容"
variant="outlined"
rows="4"
hide-details
class="mb-2"
></v-textarea>
<v-text-field
v-model="newMemoryUserId"
label="用户 ID"
variant="outlined"
density="compact"
hide-details
></v-text-field>
<v-switch
v-model="needSummarize"
color="primary"
label="需要摘要"
hide-details
></v-switch>
<v-btn
color="success"
type="submit"
:loading="isSubmitting"
:disabled="!newMemoryText || !newMemoryUserId"
>
<v-icon start>mdi-plus</v-icon>
添加数据
</v-btn>
</v-form>
</v-card>
</div>
<div v-if="selectedNode" class="mt-4">
<h3>节点详情</h3>
<v-card variant="outlined" class="mt-2 pa-3">
<div v-if="selectedNode.id">
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">ID:</span>
<span>{{ selectedNode.id }}</span>
</div>
</div>
<div v-if="selectedNode._label">
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">类型:</span>
<span>{{ selectedNode._label }}</span>
</div>
</div>
<div v-if="selectedNode.name">
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">名称:</span>
<span>{{ selectedNode.name }}</span>
</div>
</div>
<div v-if="selectedNode.user_id">
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">用户ID:</span>
<span>{{ selectedNode.user_id }}</span>
</div>
</div>
<div v-if="selectedNode.ts">
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">时间戳:</span>
<span>{{ selectedNode.ts }}</span>
</div>
</div>
<div v-if="selectedNode.type">
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">类型:</span>
<span>{{ selectedNode.type }}</span>
</div>
</div>
</v-card>
</div>
<div v-if="graphStats" class="mt-4">
<h3>图形统计</h3>
<v-card variant="outlined" class="mt-2 pa-3">
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">节点数:</span>
<span>{{ graphStats.nodeCount }}</span>
</div>
<div class="d-flex justify-space-between">
<span class="text-subtitle-2">边数:</span>
<span>{{ graphStats.edgeCount }}</span>
</div>
</v-card>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import * as d3 from "d3"; // npm install d3
export default {
name: 'LongTermMemory',
data() {
return {
simulation: null,
svg: null,
zoom: null,
node_data: [],
edge_data: [],
nodes: [],
links: [],
searchUserId: null,
userIdList: [],
selectedNode: null,
graphStats: null,
nodeColors: {
'PhaseNode': '#4CAF50', // 绿色
'PassageNode': '#2196F3', // 蓝色
'FactNode': '#FF9800', // 橙色
'default': '#9C27B0' // 紫色作为默认
},
edgeColors: {
'_include_': '#607D8B',
'_related_': '#9E9E9E',
'default': '#BDBDBD'
},
isLoading: false,
// 添加新的数据属性
newMemoryText: '',
newMemoryUserId: null,
needSummarize: false,
isSubmitting: false,
// 搜索记忆相关属性
searchMemoryUserId: null,
searchQuery: '',
isSearching: false,
searchResults: [],
hasSearched: false,
}
},
mounted() {
this.initD3Graph();
this.ltmGetGraph();
this.ltmGetUserIds();
},
beforeUnmount() {
if (this.simulation) {
this.simulation.stop();
}
},
methods: {
// 添加搜索记忆方法
searchMemory() {
if (!this.searchQuery.trim()) {
this.$toast.warning('请输入搜索关键词');
return;
}
this.isSearching = true;
this.hasSearched = true;
this.searchResults = [];
// 构建查询参数
const params = {
query: this.searchQuery
};
// 如果有选择用户ID,也加入查询参数
if (this.searchMemoryUserId) {
params.user_id = this.searchMemoryUserId;
}
axios.get('/api/plug/alkaid/ltm/graph/search', { params })
.then(response => {
if (response.data.status === 'ok') {
const data = response.data.data;
// 处理返回的文档数组
this.searchResults = Object.keys(data).map(doc_id => {
return {
doc_id: doc_id,
text: data[doc_id].text || '无文本内容',
score: data[doc_id].score || 0
};
});
if (this.searchResults.length === 0) {
this.$toast.info('未找到相关记忆内容');
} else {
this.$toast.success(`找到 ${this.searchResults.length} 条相关记忆`);
}
} else {
this.$toast.error('搜索失败: ' + response.data.message);
}
})
.catch(error => {
console.error('搜索记忆数据失败:', error);
this.$toast.error('搜索失败: ' + (error.response?.data?.message || error.message));
})
.finally(() => {
this.isSearching = false;
});
},
// 添加新方法,用于提交记忆数据
addMemoryData() {
if (!this.newMemoryText || !this.newMemoryUserId) {
return;
}
this.isSubmitting = true;
// 准备提交数据
const payload = {
text: this.newMemoryText,
user_id: this.newMemoryUserId,
need_summarize: this.needSummarize
};
axios.post('/api/plug/alkaid/ltm/graph/add', payload)
.then(response => {
// 成功添加后刷新图表
this.refreshGraph();
// 重置表单
// this.newMemoryText = '';
// this.needSummarize = false;
// 显示成功消息
this.$toast.success('记忆数据添加成功!');
})
.catch(error => {
console.error('添加记忆数据失败:', error);
this.$toast.error('添加记忆数据失败: ' + (error.response?.data?.message || error.message));
})
.finally(() => {
this.isSubmitting = false;
});
},
ltmGetGraph(userId = null) {
this.isLoading = true;
const params = userId ? { user_id: userId } : {};
axios.get('/api/plug/alkaid/ltm/graph', { params })
.then(response => {
let nodesRaw = response.data.data.nodes;
let edgesRaw = response.data.data.edges;
this.node_data = nodesRaw;
this.edge_data = edgesRaw;
// 转换为D3所需的数据格式
this.nodes = nodesRaw.map(node => {
const nodeId = node[0];
const nodeData = node[1];
const nodeType = nodeData._label || 'default';
const color = this.nodeColors[nodeType] || this.nodeColors['default'];
return {
id: nodeId,
label: nodeData.name || nodeId.split('_')[0],
color: color,
originalData: nodeData
};
});
this.links = edgesRaw.map(edge => {
const sourceId = edge[0];
const targetId = edge[1];
const edgeData = edge[2];
const relationType = edgeData.relation_type || 'default';
const color = this.edgeColors[relationType] || this.edgeColors['default'];
return {
source: sourceId,
target: targetId,
color: color,
originalData: edgeData,
label: relationType
};
});
this.updateD3Graph();
this.updateGraphStats();
console.log('Graph initialized with', this.nodes.length, 'nodes and', this.links.length, 'links');
})
.catch(error => {
console.error('Error fetching graph data:', error);
})
.finally(() => {
this.isLoading = false;
});
},
ltmGetUserIds() {
axios.get('/api/plug/alkaid/ltm/user_ids')
.then(response => {
this.userIdList = response.data.data;
})
.catch(error => {
console.error('Error fetching user IDs:', error);
});
},
updateGraphStats() {
this.graphStats = {
nodeCount: this.nodes.length,
edgeCount: this.links.length
};
},
refreshGraph() {
this.ltmGetGraph(this.searchUserId);
},
onNodeSelect() {
console.log('Selected user ID:', this.searchUserId);
if (!this.searchUserId) return;
// 使用API的user_id参数筛选数据
this.ltmGetGraph(this.searchUserId);
},
resetFilter() {
this.searchUserId = null;
this.searchQuery = ''; // 重置搜索关键词
this.searchResults = []; // 清空搜索结果
this.hasSearched = false; // 重置搜索状态
this.ltmGetGraph();
},
initD3Graph() {
const container = document.getElementById("graph-container");
if (!container) return;
d3.select("#graph-container svg").remove();
const width = container.clientWidth;
const height = container.clientHeight;
const svg = d3.select("#graph-container")
.append("svg")
.attr("width", "100%")
.attr("height", "100%")
.attr("viewBox", [0, 0, width, height])
.classed("d3-graph", true);
const g = svg.append("g");
const zoom = d3.zoom()
.scaleExtent([0.1, 10])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svg.call(zoom);
const simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id).distance(100))
.force("charge", d3.forceManyBody().strength(-300))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(30));
this.svg = svg;
this.g = g;
this.zoom = zoom;
this.simulation = simulation;
this.width = width;
this.height = height;
},
updateD3Graph() {
if (!this.svg || !this.simulation) return;
const g = this.g;
g.selectAll("*").remove();
g.append("defs").append("marker")
.attr("id", "arrowhead")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 20)
.attr("refY", 0)
.attr("orient", "auto")
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", "#999");
const link = g.append("g")
.selectAll("line")
.data(this.links)
.join("line")
.attr("stroke", d => d.color)
.attr("stroke-width", 1.5)
.attr("marker-end", "url(#arrowhead)");
const edgeLabels = g.append("g")
.selectAll("text")
.data(this.links)
.join("text")
.text(d => d.label)
.attr("font-size", "8px")
.attr("text-anchor", "middle")
.attr("fill", "#666")
.attr("dy", -5);
const node = g.append("g")
.selectAll("circle")
.data(this.nodes)
.join("circle")
.attr("r", 8)
.attr("fill", d => d.color)
.style("cursor", "pointer")
.call(this.dragBehavior());
const nodeLabels = g.append("g")
.selectAll("text")
.data(this.nodes)
.join("text")
.text(d => d.label)
.attr("font-size", "10px")
.attr("text-anchor", "middle")
.attr("fill", "#333")
.attr("dy", -12);
node.on("click", (event, d) => {
event.stopPropagation();
this.selectedNode = d.originalData;
});
this.svg.on("click", () => {
this.selectedNode = null;
});
this.simulation
.nodes(this.nodes)
.on("tick", () => {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
edgeLabels
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2);
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
nodeLabels
.attr("x", d => d.x)
.attr("y", d => d.y);
});
this.simulation.force("link")
.links(this.links);
this.simulation.alpha(1).restart();
},
dragBehavior() {
return d3.drag()
.on("start", (event, d) => {
if (!event.active) this.simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event, d) => {
if (!event.active) this.simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
});
},
getRandomColor() {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
}
}
</script>
<style scoped>
#long-term-memory {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: row;
}
#graph-container {
position: relative;
background-color: #f2f6f9;
overflow: hidden;
height: 100%;
flex-grow: 1;
}
#graph-control-panel {
height: 100%;
overflow-y: auto; /* 让控制面板可滚动而不是整个页面滚动 */
min-width: 450px;
max-width: 450px;
}
#graph-container:hover {
cursor: pointer;
}
.memory-header {
padding: 0 8px;
}
#graph-container svg {
width: 100%;
height: 100%;
}
.d3-graph {
background-color: #f2f6f9;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
<template>
<div class="flex-grow-1" style="display: flex; flex-direction: column; height: 100%;">
<div class="d-flex align-center justify-center"
style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px;">
<span size="64">🌍</span>
<p class="text-h6 text-grey ml-4">前面的世界以后再来探索吧</p>
</div>
</div>
</template>
<script>
export default {
name: 'OtherFeatures'
}
</script>
-18
View File
@@ -1,18 +0,0 @@
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.star import Context, Star, register
@register("vpet", "AstrBot Team", "虚拟桌宠", "0.0.1")
class VPet(Star):
def __init__(self, context: Context):
super().__init__(context)
async def initialize(self):
"""可选择实现异步的插件初始化方法,当实例化该插件类之后会自动调用该方法。"""
@filter.llm_tool("screenshot")
async def screenshot(self, event: AstrMessageEvent):
"""Capture the screen and return the image."""
async def terminate(self):
"""可选择实现异步的插件销毁方法,当插件被卸载/停用时会调用。"""
+2
View File
@@ -8,6 +8,7 @@ dependencies = [
"aiocqhttp>=1.4.4",
"aiodocker>=0.24.0",
"aiohttp>=3.11.18",
"aiosqlite>=0.21.0",
"anthropic>=0.51.0",
"apscheduler>=3.11.0",
"beautifulsoup4>=4.13.4",
@@ -19,6 +20,7 @@ dependencies = [
"defusedxml>=0.7.1",
"dingtalk-stream>=0.22.1",
"docstring-parser>=0.16",
"faiss-cpu>=1.11.0",
"filelock>=3.18.0",
"google-genai>=1.14.0",
"googlesearch-python>=1.3.0",
Generated
+1414 -1295
View File
File diff suppressed because it is too large Load Diff