Merge pull request #3143 from lxfight/feature/knowledge-base

This commit is contained in:
Soulter authored and GitHub committed 2025-10-25 18:19:15 +08:00
commit 6cfed9a39d
63 files changed
+8624 -270

No files matched your search

+23 -4
View File
@@ -134,8 +134,11 @@ DEFAULT_CONFIG = {
"persona": [], # deprecated
"timezone": "Asia/Shanghai",
"callback_api_base": "",
"default_kb_collection": "", # 默认知识库名称
"default_kb_collection": "", # 默认知识库名称, 已经过时
"plugin_set": ["*"], # "*" 表示使用所有可用的插件, 空列表表示不使用任何插件
"kb_names": [], # 默认知识库名称列表
"kb_fusion_top_k": 20, # 知识库检索融合阶段返回结果数量
"kb_final_top_k": 5, # 知识库检索最终返回结果数量
}
@@ -1414,6 +1417,7 @@ CONFIG_METADATA_2 = {
"description": "嵌入维度",
"type": "int",
"hint": "嵌入向量的维度。根据模型不同,可能需要调整,请参考具体模型的文档。此配置项请务必填写正确,否则将导致向量数据库无法正常工作。",
"_special": "get_embedding_dim",
},
"embedding_model": {
"description": "嵌入模型",
@@ -2061,6 +2065,9 @@ CONFIG_METADATA_2 = {
"default_kb_collection": {
"type": "string",
},
"kb_names": {"type": "list", "items": {"type": "string"}},
"kb_fusion_top_k": {"type": "int", "default": 20},
"kb_final_top_k": {"type": "int", "default": 5},
},
},
}
@@ -2139,10 +2146,22 @@ CONFIG_METADATA_3 = {
"description": "知识库",
"type": "object",
"items": {
"default_kb_collection": {
"description": "默认使用的知识库",
"type": "string",
"kb_names": {
"description": "知识库列表",
"type": "list",
"items": {"type": "string"},
"_special": "select_knowledgebase",
"hint": "支持多选",
},
"kb_fusion_top_k": {
"description": "融合检索结果数",
"type": "int",
"hint": "多个知识库检索结果融合后的返回结果数量",
},
"kb_final_top_k": {
"description": "最终返回结果数",
"type": "int",
"hint": "从知识库中检索到的结果数量,越大可能获得越多相关信息,但也可能引入噪音。建议根据实际需求调整",
},
},
},
+36 -1
View File
@@ -7,7 +7,7 @@ AstrBot 会话-对话管理器, 维护两个本地存储, 其中一个是 json
import json
from astrbot.core import sp
from typing import Dict, List
from typing import Dict, List, Callable, Awaitable
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import Conversation, ConversationV2
@@ -20,6 +20,38 @@ class ConversationManager:
self.db = db_helper
self.save_interval = 60 # 每 60 秒保存一次
# 会话删除回调函数列表(用于级联清理,如知识库配置)
self._on_session_deleted_callbacks: List[Callable[[str], Awaitable[None]]] = []
def register_on_session_deleted(
self, callback: Callable[[str], Awaitable[None]]
) -> None:
"""注册会话删除回调函数
其他模块可以注册回调来响应会话删除事件,实现级联清理。
例如:知识库模块可以注册回调来清理会话的知识库配置。
Args:
callback: 回调函数,接收会话ID (unified_msg_origin) 作为参数
"""
self._on_session_deleted_callbacks.append(callback)
async def _trigger_session_deleted(self, unified_msg_origin: str) -> None:
"""触发会话删除回调
Args:
unified_msg_origin: 会话ID
"""
for callback in self._on_session_deleted_callbacks:
try:
await callback(unified_msg_origin)
except Exception as e:
from astrbot.core import logger
logger.error(
f"会话删除回调执行失败 (session: {unified_msg_origin}): {e}"
)
def _convert_conv_from_v2_to_v1(self, conv_v2: ConversationV2) -> Conversation:
"""将 ConversationV2 对象转换为 Conversation 对象"""
created_at = int(conv_v2.created_at.timestamp())
@@ -106,6 +138,9 @@ class ConversationManager:
self.session_conversations.pop(unified_msg_origin, None)
await sp.session_remove(unified_msg_origin, "sel_conv_id")
# 触发会话删除回调(级联清理)
await self._trigger_session_deleted(unified_msg_origin)
async def get_curr_conversation_id(self, unified_msg_origin: str) -> str | None:
"""获取会话当前的对话 ID
+11 -4
View File
@@ -17,7 +17,6 @@ import os
from .event_bus import EventBus
from . import astrbot_config, html_renderer
from asyncio import Queue
from typing import List
from astrbot.core.pipeline.scheduler import PipelineScheduler, PipelineContext
from astrbot.core.star import PluginManager
from astrbot.core.platform.manager import PlatformManager
@@ -36,6 +35,7 @@ from astrbot.core.umop_config_router import UmopConfigRouter
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star_handler import star_map
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
class AstrBotCoreLifecycle:
@@ -122,6 +122,9 @@ class AstrBotCoreLifecycle:
# 初始化平台消息历史管理器
self.platform_message_history_manager = PlatformMessageHistoryManager(self.db)
# 初始化知识库管理器
self.kb_manager = KnowledgeBaseManager(self.provider_manager)
# 初始化提供给插件的上下文
self.star_context = Context(
self.event_queue,
@@ -133,6 +136,7 @@ class AstrBotCoreLifecycle:
self.platform_message_history_manager,
self.persona_mgr,
self.astrbot_config_mgr,
self.kb_manager,
)
# 初始化插件管理器
@@ -144,8 +148,9 @@ class AstrBotCoreLifecycle:
# 根据配置实例化各个 Provider
await self.provider_manager.initialize()
# 初始化消息事件流水线调度器
await self.kb_manager.initialize()
# 初始化消息事件流水线调度器
self.pipeline_scheduler_mapping = await self.load_pipeline_scheduler()
# 初始化更新器
@@ -160,7 +165,7 @@ class AstrBotCoreLifecycle:
self.start_time = int(time.time())
# 初始化当前任务列表
self.curr_tasks: List[asyncio.Task] = []
self.curr_tasks: list[asyncio.Task] = []
# 根据配置实例化各个平台适配器
await self.platform_manager.initialize()
@@ -245,6 +250,7 @@ class AstrBotCoreLifecycle:
await self.provider_manager.terminate()
await self.platform_manager.terminate()
await self.kb_manager.terminate()
self.dashboard_shutdown_event.set()
# 再次遍历curr_tasks等待每个任务真正结束
@@ -260,12 +266,13 @@ class AstrBotCoreLifecycle:
"""重启 AstrBot 核心生命周期管理类, 终止各个管理器并重新加载平台实例"""
await self.provider_manager.terminate()
await self.platform_manager.terminate()
await self.kb_manager.terminate()
self.dashboard_shutdown_event.set()
threading.Thread(
target=self.astrbot_updator._reboot, name="restart", daemon=True
).start()
def load_platform(self) -> List[asyncio.Task]:
def load_platform(self) -> list[asyncio.Task]:
"""加载平台实例并返回所有平台实例的异步任务列表"""
tasks = []
platform_insts = self.platform_manager.get_insts()
+33 -2
View File
@@ -16,14 +16,42 @@ class BaseVecDB:
pass
@abc.abstractmethod
async def insert(self, content: str, metadata: dict = None, id: str = None) -> int:
async def insert(
self, content: str, metadata: dict | None = None, id: str | None = None
) -> int:
"""
插入一条文本和其对应向量,自动生成 ID 并保持一致性。
"""
...
@abc.abstractmethod
async def retrieve(self, query: str, top_k: int = 5) -> list[Result]:
async def insert_batch(
self,
contents: list[str],
metadatas: list[dict] | None = None,
ids: list[str] | None = None,
batch_size: int = 32,
tasks_limit: int = 3,
max_retries: int = 3,
progress_callback=None,
) -> int:
"""
批量插入文本和其对应向量,自动生成 ID 并保持一致性。
Args:
progress_callback: 进度回调函数,接收参数 (current, total)
"""
...
@abc.abstractmethod
async def retrieve(
self,
query: str,
top_k: int = 5,
fetch_k: int = 20,
rerank: bool = False,
metadata_filters: dict | None = None,
) -> list[Result]:
"""
搜索最相似的文档。
Args:
@@ -44,3 +72,6 @@ class BaseVecDB:
bool: 删除是否成功
"""
...
@abc.abstractmethod
async def close(self): ...
@@ -1,59 +1,224 @@
import aiosqlite
import os
import json
from datetime import datetime
from contextlib import asynccontextmanager
from sqlalchemy import Text, Column
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlmodel import Field, SQLModel, select, col, func, text, MetaData
from astrbot.core import logger
class BaseDocModel(SQLModel, table=False):
metadata = MetaData()
class Document(BaseDocModel, table=True):
"""SQLModel for documents table."""
__tablename__ = "documents" # type: ignore
id: int | None = Field(
default=None, primary_key=True, sa_column_kwargs={"autoincrement": True}
)
doc_id: str = Field(nullable=False)
text: str = Field(nullable=False)
metadata_: str | None = Field(default=None, sa_column=Column("metadata", Text))
created_at: datetime | None = Field(default=None)
updated_at: datetime | None = Field(default=None)
class DocumentStorage:
def __init__(self, db_path: str):
self.db_path = db_path
self.connection = None
self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}"
self.engine: AsyncEngine | None = None
self.async_session_maker: sessionmaker | None = 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()
await self.connect()
async with self.engine.begin() as conn: # type: ignore
# Create tables using SQLModel
await conn.run_sync(BaseDocModel.metadata.create_all)
try:
await conn.execute(
text(
"ALTER TABLE documents ADD COLUMN kb_doc_id TEXT "
"GENERATED ALWAYS AS (json_extract(metadata, '$.kb_doc_id')) STORED"
)
)
await conn.execute(
text(
"ALTER TABLE documents ADD COLUMN user_id TEXT "
"GENERATED ALWAYS AS (json_extract(metadata, '$.user_id')) STORED"
)
)
# Create indexes
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_documents_kb_doc_id ON documents(kb_doc_id)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_documents_user_id ON documents(user_id)"
)
)
except BaseException:
pass
await conn.commit()
async def connect(self):
"""Connect to the SQLite database."""
self.connection = await aiosqlite.connect(self.db_path)
if self.engine is None:
self.engine = create_async_engine(
self.DATABASE_URL,
echo=False,
future=True,
)
self.async_session_maker = sessionmaker(
self.engine, # type: ignore
class_=AsyncSession,
expire_on_commit=False,
) # type: ignore
async def get_documents(self, metadata_filters: dict, ids: list = None):
@asynccontextmanager
async def get_session(self):
"""Context manager for database sessions."""
async with self.async_session_maker() as session: # type: ignore
yield session
async def get_documents(
self,
metadata_filters: dict,
ids: list | None = None,
offset: int | None = 0,
limit: int | None = 100,
) -> list[dict]:
"""Retrieve documents by metadata filters and ids.
Args:
metadata_filters (dict): The metadata filters to apply.
ids (list | None): Optional list of document IDs to filter.
offset (int | None): Offset for pagination.
limit (int | None): Limit for pagination.
Returns:
list: The list of document IDs(primary key, not doc_id) that match the filters.
list: The list of documents 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"
if self.engine is None:
logger.warning(
"Database connection is not initialized, returning empty result"
)
return []
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 with self.get_session() as session:
query = select(Document)
for key, val in metadata_filters.items():
query = query.where(
text(f"json_extract(metadata, '$.{key}') = :filter_{key}")
).params(**{f"filter_{key}": val})
if ids is not None and len(ids) > 0:
valid_ids = [int(i) for i in ids if i != -1]
if valid_ids:
query = query.where(col(Document.id).in_(valid_ids))
if limit is not None:
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
result = await session.execute(query)
documents = result.scalars().all()
return [self._document_to_dict(doc) for doc in documents]
async def insert_document(self, doc_id: str, text: str, metadata: dict) -> int:
"""Insert a single document and return its integer ID.
Args:
doc_id (str): The document ID (UUID string).
text (str): The document text.
metadata (dict): The document metadata.
Returns:
int: The integer ID of the inserted document.
"""
assert self.engine is not None, "Database connection is not initialized."
async with self.get_session() as session:
async with session.begin():
document = Document(
doc_id=doc_id,
text=text,
metadata_=json.dumps(metadata),
created_at=datetime.now(),
updated_at=datetime.now(),
)
session.add(document)
await session.flush() # Flush to get the ID
return document.id # type: ignore
async def insert_documents_batch(
self, doc_ids: list[str], texts: list[str], metadatas: list[dict]
) -> list[int]:
"""Batch insert documents and return their integer IDs.
Args:
doc_ids (list[str]): List of document IDs (UUID strings).
texts (list[str]): List of document texts.
metadatas (list[dict]): List of document metadata.
Returns:
list[int]: List of integer IDs of the inserted documents.
"""
assert self.engine is not None, "Database connection is not initialized."
async with self.get_session() as session:
async with session.begin():
import json
documents = []
for doc_id, text, metadata in zip(doc_ids, texts, metadatas):
document = Document(
doc_id=doc_id,
text=text,
metadata_=json.dumps(metadata),
created_at=datetime.now(),
updated_at=datetime.now(),
)
documents.append(document)
session.add(document)
await session.flush() # Flush to get all IDs
return [doc.id for doc in documents] # type: ignore
async def delete_document_by_doc_id(self, doc_id: str):
"""Delete a document by its doc_id.
Args:
doc_id (str): The doc_id of the document to delete.
"""
assert self.engine is not None, "Database connection is not initialized."
async with self.get_session() as session:
async with session.begin():
query = select(Document).where(col(Document.doc_id) == doc_id)
result = await session.execute(query)
document = result.scalar_one_or_none()
if document:
await session.delete(document)
async def get_document_by_doc_id(self, doc_id: str):
"""Retrieve a document by its doc_id.
@@ -62,28 +227,91 @@ class DocumentStorage:
doc_id (str): The doc_id of the document to retrieve.
Returns:
dict: The document data.
dict: The document data or None if not found.
"""
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
assert self.engine is not None, "Database connection is not initialized."
async with self.get_session() as session:
query = select(Document).where(col(Document.doc_id) == doc_id)
result = await session.execute(query)
document = result.scalar_one_or_none()
if document:
return self._document_to_dict(document)
return None
async def update_document_by_doc_id(self, doc_id: str, new_text: str):
"""Retrieve a document by its doc_id.
"""Update 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)
assert self.engine is not None, "Database connection is not initialized."
async with self.get_session() as session:
async with session.begin():
query = select(Document).where(col(Document.doc_id) == doc_id)
result = await session.execute(query)
document = result.scalar_one_or_none()
if document:
document.text = new_text
document.updated_at = datetime.now()
session.add(document)
async def delete_documents(self, metadata_filters: dict):
"""Delete documents by their metadata filters.
Args:
metadata_filters (dict): The metadata filters to apply.
"""
if self.engine is None:
logger.warning(
"Database connection is not initialized, skipping delete operation"
)
await self.connection.commit()
return
async with self.get_session() as session:
async with session.begin():
query = select(Document)
for key, val in metadata_filters.items():
query = query.where(
text(f"json_extract(metadata, '$.{key}') = :filter_{key}")
).params(**{f"filter_{key}": val})
result = await session.execute(query)
documents = result.scalars().all()
for doc in documents:
await session.delete(doc)
async def count_documents(self, metadata_filters: dict | None = None) -> int:
"""Count documents in the database.
Args:
metadata_filters (dict | None): Metadata filters to apply.
Returns:
int: The count of documents.
"""
if self.engine is None:
logger.warning("Database connection is not initialized, returning 0")
return 0
async with self.get_session() as session:
query = select(func.count(col(Document.id)))
if metadata_filters:
for key, val in metadata_filters.items():
query = query.where(
text(f"json_extract(metadata, '$.{key}') = :filter_{key}")
).params(**{f"filter_{key}": val})
result = await session.execute(query)
count = result.scalar_one_or_none()
return count if count is not None else 0
async def get_user_ids(self) -> list[str]:
"""Retrieve all user IDs from the documents table.
@@ -91,11 +319,38 @@ class DocumentStorage:
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()
assert self.engine is not None, "Database connection is not initialized."
async with self.get_session() as session:
query = text(
"SELECT DISTINCT user_id FROM documents WHERE user_id IS NOT NULL"
)
result = await session.execute(query)
rows = result.fetchall()
return [row[0] for row in rows]
def _document_to_dict(self, document: Document) -> dict:
"""Convert a Document model to a dictionary.
Args:
document (Document): The document to convert.
Returns:
dict: The converted dictionary.
"""
return {
"id": document.id,
"doc_id": document.doc_id,
"text": document.text,
"metadata": document.metadata_,
"created_at": document.created_at.isoformat()
if isinstance(document.created_at, datetime)
else document.created_at,
"updated_at": document.updated_at.isoformat()
if isinstance(document.updated_at, datetime)
else document.updated_at,
}
async def tuple_to_dict(self, row):
"""Convert a tuple to a dictionary.
@@ -104,6 +359,8 @@ class DocumentStorage:
Returns:
dict: The converted dictionary.
Note: This method is kept for backward compatibility but is no longer used internally.
"""
return {
"id": row[0],
@@ -116,6 +373,7 @@ class DocumentStorage:
async def close(self):
"""Close the connection to the SQLite database."""
if self.connection:
await self.connection.close()
self.connection = None
if self.engine:
await self.engine.dispose()
self.engine = None
self.async_session_maker = None
@@ -9,7 +9,7 @@ import numpy as np
class EmbeddingStorage:
def __init__(self, dimension: int, path: str = None):
def __init__(self, dimension: int, path: str | None = None):
self.dimension = dimension
self.path = path
self.index = None
@@ -18,7 +18,6 @@ class EmbeddingStorage:
else:
base_index = faiss.IndexFlatL2(dimension)
self.index = faiss.IndexIDMap(base_index)
self.storage = {}
async def insert(self, vector: np.ndarray, id: int):
"""插入向量
@@ -29,12 +28,29 @@ class EmbeddingStorage:
Raises:
ValueError: 如果向量的维度与存储的维度不匹配
"""
assert self.index is not None, "FAISS index is not initialized."
if vector.shape[0] != self.dimension:
raise ValueError(
f"向量维度不匹配, 期望: {self.dimension}, 实际: {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 insert_batch(self, vectors: np.ndarray, ids: list[int]):
"""批量插入向量
Args:
vectors (np.ndarray): 要插入的向量数组
ids (list[int]): 向量的ID列表
Raises:
ValueError: 如果向量的维度与存储的维度不匹配
"""
assert self.index is not None, "FAISS index is not initialized."
if vectors.shape[1] != self.dimension:
raise ValueError(
f"向量维度不匹配, 期望: {self.dimension}, 实际: {vectors.shape[1]}"
)
self.index.add_with_ids(vectors, np.array(ids))
await self.save_index()
async def search(self, vector: np.ndarray, k: int) -> tuple:
@@ -46,10 +62,22 @@ class EmbeddingStorage:
Returns:
tuple: (距离, 索引)
"""
assert self.index is not None, "FAISS index is not initialized."
faiss.normalize_L2(vector)
distances, indices = self.index.search(vector, k)
return distances, indices
async def delete(self, ids: list[int]):
"""删除向量
Args:
ids (list[int]): 要删除的向量ID列表
"""
assert self.index is not None, "FAISS index is not initialized."
id_array = np.array(ids, dtype=np.int64)
self.index.remove_ids(id_array)
await self.save_index()
async def save_index(self):
"""保存索引
+81 -23
View File
@@ -1,11 +1,12 @@
import uuid
import json
import time
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
from astrbot.core.provider.provider import RerankProvider
from astrbot import logger
class FaissVecDB(BaseVecDB):
@@ -44,18 +45,56 @@ class FaissVecDB(BaseVecDB):
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
# 使用 DocumentStorage 的方法插入文档
int_id = await self.document_storage.insert_document(str_id, content, metadata)
# 插入向量到 FAISS
await self.embedding_storage.insert(vector, int_id)
return int_id
async def insert_batch(
self,
contents: list[str],
metadatas: list[dict] | None = None,
ids: list[str] | None = None,
batch_size: int = 32,
tasks_limit: int = 3,
max_retries: int = 3,
progress_callback=None,
) -> list[int]:
"""
批量插入文本和其对应向量,自动生成 ID 并保持一致性。
Args:
progress_callback: 进度回调函数,接收参数 (current, total)
"""
metadatas = metadatas or [{} for _ in contents]
ids = ids or [str(uuid.uuid4()) for _ in contents]
start = time.time()
logger.debug(f"Generating embeddings for {len(contents)} contents...")
vectors = await self.embedding_provider.get_embeddings_batch(
contents,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
progress_callback=progress_callback,
)
end = time.time()
logger.debug(
f"Generated embeddings for {len(contents)} contents in {end - start:.2f} seconds."
)
# 使用 DocumentStorage 的批量插入方法
int_ids = await self.document_storage.insert_documents_batch(
ids, contents, metadatas
)
# 批量插入向量到 FAISS
vectors_array = np.array(vectors).astype("float32")
await self.embedding_storage.insert_batch(vectors_array, int_ids)
return int_ids
async def retrieve(
self,
@@ -119,23 +158,42 @@ class FaissVecDB(BaseVecDB):
return top_k_results
async def delete(self, doc_id: int):
async def delete(self, doc_id: str):
"""
删除一条文档
删除一条文档块(chunk)
"""
await self.document_storage.connection.execute(
"DELETE FROM documents WHERE doc_id = ?", (doc_id,)
)
await self.document_storage.connection.commit()
# 获得对应的 int id
result = await self.document_storage.get_document_by_doc_id(doc_id)
int_id = result["id"] if result else None
if int_id is None:
return
# 使用 DocumentStorage 的删除方法
await self.document_storage.delete_document_by_doc_id(doc_id)
await self.embedding_storage.delete([int_id])
async def close(self):
await self.document_storage.close()
async def count_documents(self) -> int:
async def count_documents(self, metadata_filter: dict | None = None) -> int:
"""
计算文档数量
Args:
metadata_filter (dict | None): 元数据过滤器
"""
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
count = await self.document_storage.count_documents(
metadata_filters=metadata_filter or {}
)
return count
async def delete_documents(self, metadata_filters: dict):
"""
根据元数据过滤器删除文档
"""
docs = await self.document_storage.get_documents(
metadata_filters=metadata_filters, offset=None, limit=None
)
doc_ids: list[int] = [doc["id"] for doc in docs]
await self.embedding_storage.delete(doc_ids)
await self.document_storage.delete_documents(metadata_filters=metadata_filters)
@@ -0,0 +1,11 @@
"""
文档分块模块
"""
from .base import BaseChunker
from .fixed_size import FixedSizeChunker
__all__ = [
"BaseChunker",
"FixedSizeChunker",
]
@@ -0,0 +1,24 @@
"""文档分块器基类
定义了文档分块处理的抽象接口。
"""
from abc import ABC, abstractmethod
class BaseChunker(ABC):
"""分块器基类
所有分块器都应该继承此类并实现 chunk 方法。
"""
@abstractmethod
async def chunk(self, text: str, **kwargs) -> list[str]:
"""将文本分块
Args:
text: 输入文本
Returns:
list[str]: 分块后的文本列表
"""
@@ -0,0 +1,57 @@
"""固定大小分块器
按照固定的字符数将文本分块,支持重叠区域。
"""
from .base import BaseChunker
class FixedSizeChunker(BaseChunker):
"""固定大小分块器
按照固定的字符数分块,并支持块之间的重叠。
"""
def __init__(self, chunk_size: int = 512, chunk_overlap: int = 50):
"""初始化分块器
Args:
chunk_size: 块的大小(字符数)
chunk_overlap: 块之间的重叠字符数
"""
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
async def chunk(self, text: str, **kwargs) -> list[str]:
"""固定大小分块
Args:
text: 输入文本
chunk_size: 每个文本块的最大大小
chunk_overlap: 每个文本块之间的重叠部分大小
Returns:
list[str]: 分块后的文本列表
"""
chunk_size = kwargs.get("chunk_size", self.chunk_size)
chunk_overlap = kwargs.get("chunk_overlap", self.chunk_overlap)
chunks = []
start = 0
text_len = len(text)
while start < text_len:
end = start + chunk_size
chunk = text[start:end]
if chunk:
chunks.append(chunk)
# 移动窗口,保留重叠部分
start = end - chunk_overlap
# 防止无限循环: 如果重叠过大,直接移到end
if start >= end or chunk_overlap >= chunk_size:
start = end
return chunks
@@ -0,0 +1,155 @@
from collections.abc import Callable
from .base import BaseChunker
class RecursiveCharacterChunker(BaseChunker):
def __init__(
self,
chunk_size: int = 500,
chunk_overlap: int = 100,
length_function: Callable[[str], int] = len,
is_separator_regex: bool = False,
separators: list[str] | None = None,
):
"""
初始化递归字符文本分割器
Args:
chunk_size: 每个文本块的最大大小
chunk_overlap: 每个文本块之间的重叠部分大小
length_function: 计算文本长度的函数
is_separator_regex: 分隔符是否为正则表达式
separators: 用于分割文本的分隔符列表,按优先级排序
"""
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
self.length_function = length_function
self.is_separator_regex = is_separator_regex
# 默认分隔符列表,按优先级从高到低
self.separators = separators or [
"\n\n", # 段落
"\n", # 换行
"。", # 中文句子
",", # 中文逗号
". ", # 句子
", ", # 逗号分隔
" ", # 单词
"", # 字符
]
async def chunk(self, text: str, **kwargs) -> list[str]:
"""
递归地将文本分割成块
Args:
text: 要分割的文本
chunk_size: 每个文本块的最大大小
chunk_overlap: 每个文本块之间的重叠部分大小
Returns:
分割后的文本块列表
"""
if not text:
return []
overlap = kwargs.get("chunk_overlap", self.chunk_overlap)
chunk_size = kwargs.get("chunk_size", self.chunk_size)
text_length = self.length_function(text)
if text_length <= chunk_size:
return [text]
for separator in self.separators:
if separator == "":
return self._split_by_character(text, chunk_size, overlap)
if separator in text:
splits = text.split(separator)
# 重新添加分隔符(除了最后一个片段)
splits = [s + separator for s in splits[:-1]] + [splits[-1]]
splits = [s for s in splits if s]
if len(splits) == 1:
continue
# 递归合并分割后的文本块
final_chunks = []
current_chunk = []
current_chunk_length = 0
for split in splits:
split_length = self.length_function(split)
# 如果单个分割部分已经超过了chunk_size,需要递归分割
if split_length > chunk_size:
# 先处理当前积累的块
if current_chunk:
combined_text = "".join(current_chunk)
final_chunks.extend(
await self.chunk(
combined_text,
chunk_size=chunk_size,
chunk_overlap=overlap,
)
)
current_chunk = []
current_chunk_length = 0
# 递归分割过大的部分
final_chunks.extend(
await self.chunk(
split, chunk_size=chunk_size, chunk_overlap=overlap
)
)
# 如果添加这部分会使当前块超过chunk_size
elif current_chunk_length + split_length > chunk_size:
# 合并当前块并添加到结果中
combined_text = "".join(current_chunk)
final_chunks.append(combined_text)
# 处理重叠部分
overlap_start = max(0, len(combined_text) - overlap)
if overlap_start > 0:
overlap_text = combined_text[overlap_start:]
current_chunk = [overlap_text, split]
current_chunk_length = (
self.length_function(overlap_text) + split_length
)
else:
current_chunk = [split]
current_chunk_length = split_length
else:
# 添加到当前块
current_chunk.append(split)
current_chunk_length += split_length
# 处理剩余的块
if current_chunk:
final_chunks.append("".join(current_chunk))
return final_chunks
return [text]
def _split_by_character(
self, text: str, chunk_size: int | None = None, overlap: int | None = None
) -> list[str]:
"""
按字符级别分割文本
Args:
text: 要分割的文本
Returns:
分割后的文本块列表
"""
chunk_size = chunk_size or self.chunk_size
overlap = overlap or self.chunk_overlap
result = []
for i in range(0, len(text), chunk_size - overlap):
end = min(i + chunk_size, len(text))
result.append(text[i:end])
if end == len(text):
break
return result
+299
View File
@@ -0,0 +1,299 @@
from contextlib import asynccontextmanager
from pathlib import Path
from sqlmodel import col, desc
from sqlalchemy import text, func, select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from astrbot.core import logger
from astrbot.core.knowledge_base.models import (
BaseKBModel,
KBDocument,
KBMedia,
KnowledgeBase,
)
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
class KBSQLiteDatabase:
def __init__(self, db_path: str = "data/knowledge_base/kb.db") -> None:
"""初始化知识库数据库
Args:
db_path: 数据库文件路径, 默认为 data/knowledge_base/kb.db
"""
self.db_path = db_path
self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}"
self.inited = False
# 确保目录存在
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
# 创建异步引擎
self.engine = create_async_engine(
self.DATABASE_URL,
echo=False,
pool_pre_ping=True,
pool_recycle=3600,
)
# 创建会话工厂
self.async_session = async_sessionmaker(
self.engine,
class_=AsyncSession,
expire_on_commit=False,
)
@asynccontextmanager
async def get_db(self):
"""获取数据库会话
用法:
async with kb_db.get_db() as session:
# 执行数据库操作
result = await session.execute(stmt)
"""
async with self.async_session() as session:
yield session
async def initialize(self) -> None:
"""初始化数据库,创建表并配置 SQLite 参数"""
async with self.engine.begin() as conn:
# 创建所有知识库相关表
await conn.run_sync(BaseKBModel.metadata.create_all)
# 配置 SQLite 性能优化参数
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()
self.inited = True
async def migrate_to_v1(self) -> None:
"""执行知识库数据库 v1 迁移
创建所有必要的索引以优化查询性能
"""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
# 创建知识库表索引
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_kb_kb_id "
"ON knowledge_bases(kb_id)"
)
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_kb_name "
"ON knowledge_bases(kb_name)"
)
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_kb_created_at "
"ON knowledge_bases(created_at)"
)
)
# 创建文档表索引
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_doc_doc_id "
"ON kb_documents(doc_id)"
)
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_doc_kb_id "
"ON kb_documents(kb_id)"
)
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_doc_name "
"ON kb_documents(doc_name)"
)
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_doc_type "
"ON kb_documents(file_type)"
)
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_doc_created_at "
"ON kb_documents(created_at)"
)
)
# 创建多媒体表索引
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_media_media_id "
"ON kb_media(media_id)"
)
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_media_doc_id "
"ON kb_media(doc_id)"
)
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_media_kb_id ON kb_media(kb_id)"
)
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_media_type "
"ON kb_media(media_type)"
)
)
await session.commit()
async def close(self) -> None:
"""关闭数据库连接"""
await self.engine.dispose()
logger.info(f"知识库数据库已关闭: {self.db_path}")
async def get_kb_by_id(self, kb_id: str) -> KnowledgeBase | None:
"""根据 ID 获取知识库"""
async with self.get_db() as session:
stmt = select(KnowledgeBase).where(col(KnowledgeBase.kb_id) == kb_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_kb_by_name(self, kb_name: str) -> KnowledgeBase | None:
"""根据名称获取知识库"""
async with self.get_db() as session:
stmt = select(KnowledgeBase).where(col(KnowledgeBase.kb_name) == kb_name)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def list_kbs(self, offset: int = 0, limit: int = 100) -> list[KnowledgeBase]:
"""列出所有知识库"""
async with self.get_db() as session:
stmt = (
select(KnowledgeBase)
.offset(offset)
.limit(limit)
.order_by(desc(KnowledgeBase.created_at))
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def count_kbs(self) -> int:
"""统计知识库数量"""
async with self.get_db() as session:
stmt = select(func.count(col(KnowledgeBase.id)))
result = await session.execute(stmt)
return result.scalar() or 0
# ===== 文档查询 =====
async def get_document_by_id(self, doc_id: str) -> KBDocument | None:
"""根据 ID 获取文档"""
async with self.get_db() as session:
stmt = select(KBDocument).where(col(KBDocument.doc_id) == doc_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def list_documents_by_kb(
self, kb_id: str, offset: int = 0, limit: int = 100
) -> list[KBDocument]:
"""列出知识库的所有文档"""
async with self.get_db() as session:
stmt = (
select(KBDocument)
.where(col(KBDocument.kb_id) == kb_id)
.offset(offset)
.limit(limit)
.order_by(desc(KBDocument.created_at))
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def count_documents_by_kb(self, kb_id: str) -> int:
"""统计知识库的文档数量"""
async with self.get_db() as session:
stmt = select(func.count(col(KBDocument.id))).where(
col(KBDocument.kb_id) == kb_id
)
result = await session.execute(stmt)
return result.scalar() or 0
async def get_document_with_metadata(self, doc_id: str) -> dict | None:
async with self.get_db() as session:
stmt = (
select(KBDocument, KnowledgeBase)
.join(KnowledgeBase, col(KBDocument.kb_id) == col(KnowledgeBase.kb_id))
.where(col(KBDocument.doc_id) == doc_id)
)
result = await session.execute(stmt)
row = result.first()
if not row:
return None
return {
"document": row[0],
"knowledge_base": row[1],
}
async def delete_document_by_id(self, doc_id: str, vec_db: FaissVecDB):
"""删除单个文档及其相关数据"""
# 在知识库表中删除
async with self.get_db() as session:
async with session.begin():
# 删除文档记录
delete_stmt = delete(KBDocument).where(col(KBDocument.doc_id) == doc_id)
await session.execute(delete_stmt)
await session.commit()
# 在 vec db 中删除相关向量
await vec_db.delete_documents(metadata_filters={"kb_doc_id": doc_id})
# ===== 多媒体查询 =====
async def list_media_by_doc(self, doc_id: str) -> list[KBMedia]:
"""列出文档的所有多媒体资源"""
async with self.get_db() as session:
stmt = select(KBMedia).where(col(KBMedia.doc_id) == doc_id)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_media_by_id(self, media_id: str) -> KBMedia | None:
"""根据 ID 获取多媒体资源"""
async with self.get_db() as session:
stmt = select(KBMedia).where(col(KBMedia.media_id) == media_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def update_kb_stats(self, kb_id: str, vec_db: FaissVecDB) -> None:
"""更新知识库统计信息"""
chunk_cnt = await vec_db.count_documents()
async with self.get_db() as session:
async with session.begin():
update_stmt = (
update(KnowledgeBase)
.where(col(KnowledgeBase.kb_id) == kb_id)
.values(
doc_count=select(func.count(col(KBDocument.id)))
.where(col(KBDocument.kb_id) == kb_id)
.scalar_subquery(),
chunk_count=chunk_cnt,
)
)
await session.execute(update_stmt)
await session.commit()
+352
View File
@@ -0,0 +1,352 @@
import uuid
import aiofiles
import json
from pathlib import Path
from .models import KnowledgeBase, KBDocument, KBMedia
from .kb_db_sqlite import KBSQLiteDatabase
from astrbot.core.db.vec_db.base import BaseVecDB
from astrbot.core.db.vec_db.faiss_impl.vec_db import FaissVecDB
from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider
from astrbot.core.provider.manager import ProviderManager
from .parsers.base import BaseParser
from .chunking.base import BaseChunker
from astrbot.core import logger
class KBHelper:
vec_db: BaseVecDB
kb: KnowledgeBase
def __init__(
self,
kb_db: KBSQLiteDatabase,
kb: KnowledgeBase,
provider_manager: ProviderManager,
kb_root_dir: str,
chunker: BaseChunker,
parsers: dict[str, BaseParser],
):
self.kb_db = kb_db
self.kb = kb
self.prov_mgr = provider_manager
self.kb_root_dir = kb_root_dir
self.parsers = parsers
self.chunker = chunker
self.kb_dir = Path(self.kb_root_dir) / self.kb.kb_id
self.kb_medias_dir = Path(self.kb_dir) / "medias" / self.kb.kb_id
self.kb_files_dir = Path(self.kb_dir) / "files" / self.kb.kb_id
self.kb_medias_dir.mkdir(parents=True, exist_ok=True)
self.kb_files_dir.mkdir(parents=True, exist_ok=True)
async def initialize(self):
await self._ensure_vec_db()
async def get_ep(self) -> EmbeddingProvider:
if not self.kb.embedding_provider_id:
raise ValueError(f"知识库 {self.kb.kb_name} 未配置 Embedding Provider")
ep: EmbeddingProvider = await self.prov_mgr.get_provider_by_id(
self.kb.embedding_provider_id
) # type: ignore
if not ep:
raise ValueError(
f"无法找到 ID 为 {self.kb.embedding_provider_id} 的 Embedding Provider"
)
return ep
async def get_rp(self) -> RerankProvider | None:
if not self.kb.rerank_provider_id:
return None
rp: RerankProvider = await self.prov_mgr.get_provider_by_id(
self.kb.rerank_provider_id
) # type: ignore
if not rp:
raise ValueError(
f"无法找到 ID 为 {self.kb.rerank_provider_id} 的 Rerank Provider"
)
return rp
async def _ensure_vec_db(self) -> FaissVecDB:
if not self.kb.embedding_provider_id:
raise ValueError(f"知识库 {self.kb.kb_name} 未配置 Embedding Provider")
ep = await self.get_ep()
rp = await self.get_rp()
vec_db = FaissVecDB(
doc_store_path=str(self.kb_dir / "doc.db"),
index_store_path=str(self.kb_dir / "index.faiss"),
embedding_provider=ep,
rerank_provider=rp,
)
await vec_db.initialize()
self.vec_db = vec_db
return vec_db
async def delete_vec_db(self):
"""删除知识库的向量数据库和所有相关文件"""
import shutil
await self.terminate()
if self.kb_dir.exists():
shutil.rmtree(self.kb_dir)
async def terminate(self):
if self.vec_db:
await self.vec_db.close()
async def upload_document(
self,
file_name: str,
file_content: bytes,
file_type: str,
chunk_size: int = 512,
chunk_overlap: int = 50,
batch_size: int = 32,
tasks_limit: int = 3,
max_retries: int = 3,
progress_callback=None,
) -> KBDocument:
"""上传并处理文档(带原子性保证和失败清理)
流程:
1. 保存原始文件
2. 解析文档内容
3. 提取多媒体资源
4. 分块处理
5. 生成向量并存储
6. 保存元数据(事务)
7. 更新统计
Args:
progress_callback: 进度回调函数,接收参数 (stage, current, total)
- stage: 当前阶段 ('parsing', 'chunking', 'embedding')
- current: 当前进度
- total: 总数
"""
await self._ensure_vec_db()
doc_id = str(uuid.uuid4())
media_paths: list[Path] = []
# file_path = self.kb_files_dir / f"{doc_id}.{file_type}"
# async with aiofiles.open(file_path, "wb") as f:
# await f.write(file_content)
try:
# 阶段1: 解析文档
if progress_callback:
await progress_callback("parsing", 0, 100)
parser = self.parsers.get(file_type)
if not parser:
raise ValueError(f"不支持的文件类型: {file_type}")
parse_result = await parser.parse(file_content, file_name)
text_content = parse_result.text
media_items = parse_result.media
if progress_callback:
await progress_callback("parsing", 100, 100)
# 保存媒体文件
saved_media = []
for media_item in media_items:
media = await self._save_media(
doc_id=doc_id,
media_type=media_item.media_type,
file_name=media_item.file_name,
content=media_item.content,
mime_type=media_item.mime_type,
)
saved_media.append(media)
media_paths.append(Path(media.file_path))
# 阶段2: 分块
if progress_callback:
await progress_callback("chunking", 0, 100)
chunks_text = await self.chunker.chunk(
text_content, chunk_size=chunk_size, chunk_overlap=chunk_overlap
)
contents = []
metadatas = []
for idx, chunk_text in enumerate(chunks_text):
contents.append(chunk_text)
metadatas.append(
{
"kb_id": self.kb.kb_id,
"kb_doc_id": doc_id,
"chunk_index": idx,
}
)
if progress_callback:
await progress_callback("chunking", 100, 100)
# 阶段3: 生成向量(带进度回调)
async def embedding_progress_callback(current, total):
if progress_callback:
await progress_callback("embedding", current, total)
await self.vec_db.insert_batch(
contents=contents,
metadatas=metadatas,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
progress_callback=embedding_progress_callback,
)
# 保存文档的元数据
doc = KBDocument(
doc_id=doc_id,
kb_id=self.kb.kb_id,
doc_name=file_name,
file_type=file_type,
file_size=len(file_content),
# file_path=str(file_path),
file_path="",
chunk_count=len(chunks_text),
media_count=0,
)
async with self.kb_db.get_db() as session:
async with session.begin():
session.add(doc)
for media in saved_media:
session.add(media)
await session.commit()
await session.refresh(doc)
vec_db: FaissVecDB = self.vec_db # type: ignore
await self.kb_db.update_kb_stats(kb_id=self.kb.kb_id, vec_db=vec_db)
await self.refresh_kb()
await self.refresh_document(doc_id)
return doc
except Exception as e:
logger.error(f"上传文档失败: {e}")
# if file_path.exists():
# file_path.unlink()
for media_path in media_paths:
try:
if media_path.exists():
media_path.unlink()
except Exception as me:
logger.warning(f"清理多媒体文件失败 {media_path}: {me}")
raise e
async def list_documents(
self, offset: int = 0, limit: int = 100
) -> list[KBDocument]:
"""列出知识库的所有文档"""
docs = await self.kb_db.list_documents_by_kb(self.kb.kb_id, offset, limit)
return docs
async def get_document(self, doc_id: str) -> KBDocument | None:
"""获取单个文档"""
doc = await self.kb_db.get_document_by_id(doc_id)
return doc
async def delete_document(self, doc_id: str):
"""删除单个文档及其相关数据"""
await self.kb_db.delete_document_by_id(
doc_id=doc_id,
vec_db=self.vec_db, # type: ignore
)
await self.kb_db.update_kb_stats(
kb_id=self.kb.kb_id,
vec_db=self.vec_db, # type: ignore
)
await self.refresh_kb()
async def delete_chunk(self, chunk_id: str, doc_id: str):
"""删除单个文本块及其相关数据"""
vec_db: FaissVecDB = self.vec_db # type: ignore
await vec_db.delete(chunk_id)
await self.kb_db.update_kb_stats(
kb_id=self.kb.kb_id,
vec_db=self.vec_db, # type: ignore
)
await self.refresh_kb()
await self.refresh_document(doc_id)
async def refresh_kb(self):
if self.kb:
kb = await self.kb_db.get_kb_by_id(self.kb.kb_id)
if kb:
self.kb = kb
async def refresh_document(self, doc_id: str) -> None:
"""更新文档的元数据"""
doc = await self.get_document(doc_id)
if not doc:
raise ValueError(f"无法找到 ID 为 {doc_id} 的文档")
chunk_count = await self.get_chunk_count_by_doc_id(doc_id)
doc.chunk_count = chunk_count
async with self.kb_db.get_db() as session:
async with session.begin():
session.add(doc)
await session.commit()
await session.refresh(doc)
async def get_chunks_by_doc_id(
self, doc_id: str, offset: int = 0, limit: int = 100
) -> list[dict]:
"""获取文档的所有块及其元数据"""
vec_db: FaissVecDB = self.vec_db # type: ignore
chunks = await vec_db.document_storage.get_documents(
metadata_filters={"kb_doc_id": doc_id}, offset=offset, limit=limit
)
result = []
for chunk in chunks:
chunk_md = json.loads(chunk["metadata"])
result.append(
{
"chunk_id": chunk["doc_id"],
"doc_id": chunk_md["kb_doc_id"],
"kb_id": chunk_md["kb_id"],
"chunk_index": chunk_md["chunk_index"],
"content": chunk["text"],
"char_count": len(chunk["text"]),
}
)
return result
async def get_chunk_count_by_doc_id(self, doc_id: str) -> int:
"""获取文档的块数量"""
vec_db: FaissVecDB = self.vec_db # type: ignore
count = await vec_db.count_documents(metadata_filter={"kb_doc_id": doc_id})
return count
async def _save_media(
self,
doc_id: str,
media_type: str,
file_name: str,
content: bytes,
mime_type: str,
) -> KBMedia:
"""保存多媒体资源"""
media_id = str(uuid.uuid4())
ext = Path(file_name).suffix
# 保存文件
file_path = self.kb_medias_dir / doc_id / f"{media_id}{ext}"
file_path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(file_path, "wb") as f:
await f.write(content)
media = KBMedia(
media_id=media_id,
doc_id=doc_id,
kb_id=self.kb.kb_id,
media_type=media_type,
file_name=file_name,
file_path=str(file_path),
file_size=len(content),
mime_type=mime_type,
)
return media
+298
View File
@@ -0,0 +1,298 @@
import traceback
from pathlib import Path
from astrbot.core import logger
from astrbot.core.provider.manager import ProviderManager
from .retrieval.manager import RetrievalManager, RetrievalResult
from .retrieval.sparse_retriever import SparseRetriever
from .retrieval.rank_fusion import RankFusion
from .kb_db_sqlite import KBSQLiteDatabase
from .parsers.text_parser import TextParser
from .parsers.pdf_parser import PDFParser
# from .chunking.fixed_size import FixedSizeChunker
from .chunking.recursive import RecursiveCharacterChunker
from .kb_helper import KBHelper
from .models import KnowledgeBase
FILES_PATH = "data/knowledge_base"
DB_PATH = Path(FILES_PATH) / "kb.db"
"""Knowledge Base storage root directory"""
PARSERS = {
"txt": TextParser(),
"md": TextParser(),
"markdown": TextParser(),
"pdf": PDFParser(),
}
CHUNKER = RecursiveCharacterChunker()
class KnowledgeBaseManager:
kb_db: KBSQLiteDatabase
retrieval_manager: RetrievalManager
def __init__(
self,
provider_manager: ProviderManager,
):
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
self.provider_manager = provider_manager
self._session_deleted_callback_registered = False
self.kb_insts: dict[str, KBHelper] = {}
async def initialize(self):
"""初始化知识库模块"""
try:
logger.info("正在初始化知识库模块...")
# 初始化数据库
await self._init_kb_database()
# 初始化检索管理器
sparse_retriever = SparseRetriever(self.kb_db)
rank_fusion = RankFusion(self.kb_db)
self.retrieval_manager = RetrievalManager(
sparse_retriever=sparse_retriever,
rank_fusion=rank_fusion,
kb_db=self.kb_db,
)
await self.load_kbs()
except ImportError as e:
logger.error(f"知识库模块导入失败: {e}")
logger.warning("请确保已安装所需依赖: pypdf, aiofiles, Pillow, rank-bm25")
except Exception as e:
logger.error(f"知识库模块初始化失败: {e}")
logger.error(traceback.format_exc())
async def _init_kb_database(self):
self.kb_db = KBSQLiteDatabase(DB_PATH.as_posix())
await self.kb_db.initialize()
await self.kb_db.migrate_to_v1()
logger.info(f"KnowledgeBase database initialized: {DB_PATH}")
async def load_kbs(self):
"""加载所有知识库实例"""
kb_records = await self.kb_db.list_kbs()
for record in kb_records:
kb_helper = KBHelper(
kb_db=self.kb_db,
kb=record,
provider_manager=self.provider_manager,
kb_root_dir=FILES_PATH,
chunker=CHUNKER,
parsers=PARSERS,
)
await kb_helper.initialize()
self.kb_insts[record.kb_id] = kb_helper
async def create_kb(
self,
kb_name: str,
description: str | None = None,
emoji: str | None = None,
embedding_provider_id: str | None = None,
rerank_provider_id: str | None = None,
chunk_size: int | None = None,
chunk_overlap: int | None = None,
top_k_dense: int | None = None,
top_k_sparse: int | None = None,
top_m_final: int | None = None,
) -> KBHelper:
"""创建新的知识库实例"""
kb = KnowledgeBase(
kb_name=kb_name,
description=description,
emoji=emoji or "📚",
embedding_provider_id=embedding_provider_id,
rerank_provider_id=rerank_provider_id,
chunk_size=chunk_size if chunk_size is not None else 512,
chunk_overlap=chunk_overlap if chunk_overlap is not None else 50,
top_k_dense=top_k_dense if top_k_dense is not None else 50,
top_k_sparse=top_k_sparse if top_k_sparse is not None else 50,
top_m_final=top_m_final if top_m_final is not None else 5,
)
async with self.kb_db.get_db() as session:
session.add(kb)
await session.commit()
await session.refresh(kb)
kb_helper = KBHelper(
kb_db=self.kb_db,
kb=kb,
provider_manager=self.provider_manager,
kb_root_dir=FILES_PATH,
chunker=CHUNKER,
parsers=PARSERS,
)
await kb_helper.initialize()
self.kb_insts[kb.kb_id] = kb_helper
return kb_helper
async def get_kb(self, kb_id: str) -> KBHelper | None:
"""获取知识库实例"""
if kb_id in self.kb_insts:
return self.kb_insts[kb_id]
async def get_kb_by_name(self, kb_name: str) -> KBHelper | None:
"""通过名称获取知识库实例"""
for kb_helper in self.kb_insts.values():
if kb_helper.kb.kb_name == kb_name:
return kb_helper
return None
async def delete_kb(self, kb_id: str) -> bool:
"""删除知识库实例"""
kb_helper = await self.get_kb(kb_id)
if not kb_helper:
return False
await kb_helper.delete_vec_db()
async with self.kb_db.get_db() as session:
await session.delete(kb_helper.kb)
await session.commit()
self.kb_insts.pop(kb_id, None)
return True
async def list_kbs(self) -> list[KnowledgeBase]:
"""列出所有知识库实例"""
kbs = [kb_helper.kb for kb_helper in self.kb_insts.values()]
return kbs
async def update_kb(
self,
kb_id: str,
kb_name: str,
description: str | None = None,
emoji: str | None = None,
embedding_provider_id: str | None = None,
rerank_provider_id: str | None = None,
chunk_size: int | None = None,
chunk_overlap: int | None = None,
top_k_dense: int | None = None,
top_k_sparse: int | None = None,
top_m_final: int | None = None,
) -> KBHelper | None:
"""更新知识库实例"""
kb_helper = await self.get_kb(kb_id)
if not kb_helper:
return None
kb = kb_helper.kb
if kb_name is not None:
kb.kb_name = kb_name
if description is not None:
kb.description = description
if emoji is not None:
kb.emoji = emoji
if embedding_provider_id is not None:
kb.embedding_provider_id = embedding_provider_id
kb.rerank_provider_id = rerank_provider_id # 允许设置为 None
if chunk_size is not None:
kb.chunk_size = chunk_size
if chunk_overlap is not None:
kb.chunk_overlap = chunk_overlap
if top_k_dense is not None:
kb.top_k_dense = top_k_dense
if top_k_sparse is not None:
kb.top_k_sparse = top_k_sparse
if top_m_final is not None:
kb.top_m_final = top_m_final
async with self.kb_db.get_db() as session:
session.add(kb)
await session.commit()
await session.refresh(kb)
return kb_helper
async def retrieve(
self,
query: str,
kb_names: list[str],
top_k_fusion: int = 20,
top_m_final: int = 5,
) -> dict | None:
"""从指定知识库中检索相关内容"""
kb_ids = []
kb_id_helper_map = {}
for kb_name in kb_names:
if kb_helper := await self.get_kb_by_name(kb_name):
kb_ids.append(kb_helper.kb.kb_id)
kb_id_helper_map[kb_helper.kb.kb_id] = kb_helper
if not kb_ids:
return {}
results = await self.retrieval_manager.retrieve(
query=query,
kb_ids=kb_ids,
kb_id_helper_map=kb_id_helper_map,
top_k_fusion=top_k_fusion,
top_m_final=top_m_final,
)
if not results:
return None
context_text = self._format_context(results)
results_dict = [
{
"chunk_id": r.chunk_id,
"doc_id": r.doc_id,
"kb_id": r.kb_id,
"kb_name": r.kb_name,
"doc_name": r.doc_name,
"chunk_index": r.metadata.get("chunk_index", 0),
"content": r.content,
"score": r.score,
"char_count": r.metadata.get("char_count", 0),
}
for r in results
]
return {
"context_text": context_text,
"results": results_dict,
}
def _format_context(self, results: list[RetrievalResult]) -> str:
"""格式化知识上下文
Args:
results: 检索结果列表
Returns:
str: 格式化的上下文文本
"""
lines = ["以下是相关的知识库内容,请参考这些信息回答用户的问题:\n"]
for i, result in enumerate(results, 1):
lines.append(f"【知识 {i}】")
lines.append(f"来源: {result.kb_name} / {result.doc_name}")
lines.append(f"内容: {result.content}")
lines.append(f"相关度: {result.score:.2f}")
lines.append("")
return "\n".join(lines)
async def terminate(self):
"""终止所有知识库实例,关闭数据库连接"""
for kb_id, kb_helper in self.kb_insts.items():
try:
await kb_helper.terminate()
except Exception as e:
logger.error(f"关闭知识库 {kb_id} 失败: {e}")
self.kb_insts.clear()
# 关闭元数据数据库
if hasattr(self, "kb_db") and self.kb_db:
try:
await self.kb_db.close()
except Exception as e:
logger.error(f"关闭知识库元数据数据库失败: {e}")
+114
View File
@@ -0,0 +1,114 @@
import uuid
from datetime import datetime, timezone
from sqlmodel import Field, SQLModel, Text, UniqueConstraint, MetaData
class BaseKBModel(SQLModel, table=False):
metadata = MetaData()
class KnowledgeBase(BaseKBModel, table=True):
"""知识库表
存储知识库的基本信息和统计数据。
"""
__tablename__ = "knowledge_bases" # type: ignore
id: int | None = Field(
primary_key=True, sa_column_kwargs={"autoincrement": True}, default=None
)
kb_id: str = Field(
max_length=36,
nullable=False,
unique=True,
default_factory=lambda: str(uuid.uuid4()),
index=True,
)
kb_name: str = Field(max_length=100, nullable=False)
description: str | None = Field(default=None, sa_type=Text)
emoji: str | None = Field(default="📚", max_length=10)
embedding_provider_id: str | None = Field(default=None, max_length=100)
rerank_provider_id: str | None = Field(default=None, max_length=100)
# 分块配置参数
chunk_size: int | None = Field(default=512, nullable=True)
chunk_overlap: int | None = Field(default=50, nullable=True)
# 检索配置参数
top_k_dense: int | None = Field(default=50, nullable=True)
top_k_sparse: int | None = Field(default=50, nullable=True)
top_m_final: int | None = Field(default=5, nullable=True)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
doc_count: int = Field(default=0, nullable=False)
chunk_count: int = Field(default=0, nullable=False)
__table_args__ = (
UniqueConstraint(
"kb_name",
name="uix_kb_name",
),
)
class KBDocument(BaseKBModel, table=True):
"""文档表
存储上传到知识库的文档元数据。
"""
__tablename__ = "kb_documents" # type: ignore
id: int | None = Field(
primary_key=True, sa_column_kwargs={"autoincrement": True}, default=None
)
doc_id: str = Field(
max_length=36,
nullable=False,
unique=True,
default_factory=lambda: str(uuid.uuid4()),
index=True,
)
kb_id: str = Field(max_length=36, nullable=False, index=True)
doc_name: str = Field(max_length=255, nullable=False)
file_type: str = Field(max_length=20, nullable=False)
file_size: int = Field(nullable=False)
file_path: str = Field(max_length=512, nullable=False)
chunk_count: int = Field(default=0, nullable=False)
media_count: int = Field(default=0, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
class KBMedia(BaseKBModel, table=True):
"""多媒体资源表
存储从文档中提取的图片、视频等多媒体资源。
"""
__tablename__ = "kb_media" # type: ignore
id: int | None = Field(
primary_key=True, sa_column_kwargs={"autoincrement": True}, default=None
)
media_id: str = Field(
max_length=36,
nullable=False,
unique=True,
default_factory=lambda: str(uuid.uuid4()),
index=True,
)
doc_id: str = Field(max_length=36, nullable=False, index=True)
kb_id: str = Field(max_length=36, nullable=False, index=True)
media_type: str = Field(max_length=20, nullable=False)
file_name: str = Field(max_length=255, nullable=False)
file_path: str = Field(max_length=512, nullable=False)
file_size: int = Field(nullable=False)
mime_type: str = Field(max_length=100, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@@ -0,0 +1,15 @@
"""
文档解析器模块
"""
from .base import BaseParser, MediaItem, ParseResult
from .text_parser import TextParser
from .pdf_parser import PDFParser
__all__ = [
"BaseParser",
"MediaItem",
"ParseResult",
"TextParser",
"PDFParser",
]
@@ -0,0 +1,50 @@
"""文档解析器基类和数据结构
定义了文档解析器的抽象接口和相关数据类。
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class MediaItem:
"""多媒体项
表示从文档中提取的多媒体资源。
"""
media_type: str # image, video
file_name: str
content: bytes
mime_type: str
@dataclass
class ParseResult:
"""解析结果
包含解析后的文本内容和提取的多媒体资源。
"""
text: str
media: list[MediaItem]
class BaseParser(ABC):
"""文档解析器基类
所有文档解析器都应该继承此类并实现 parse 方法。
"""
@abstractmethod
async def parse(self, file_content: bytes, file_name: str) -> ParseResult:
"""解析文档
Args:
file_content: 文件内容
file_name: 文件名
Returns:
ParseResult: 解析结果
"""
@@ -0,0 +1,100 @@
"""PDF 文件解析器
支持解析 PDF 文件中的文本和图片资源。
"""
import io
from pypdf import PdfReader
from astrbot.core.knowledge_base.parsers.base import (
BaseParser,
MediaItem,
ParseResult,
)
class PDFParser(BaseParser):
"""PDF 文档解析器
提取 PDF 中的文本内容和嵌入的图片资源。
"""
async def parse(self, file_content: bytes, file_name: str) -> ParseResult:
"""解析 PDF 文件
Args:
file_content: 文件内容
file_name: 文件名
Returns:
ParseResult: 包含文本和图片的解析结果
"""
pdf_file = io.BytesIO(file_content)
reader = PdfReader(pdf_file)
text_parts = []
media_items = []
# 提取文本
for page in reader.pages:
text = page.extract_text()
if text:
text_parts.append(text)
# 提取图片
image_counter = 0
for page_num, page in enumerate(reader.pages):
try:
# 安全检查 Resources
if "/Resources" not in page:
continue
resources = page["/Resources"]
if not resources or "/XObject" not in resources: # type: ignore
continue
xobjects = resources["/XObject"].get_object() # type: ignore
if not xobjects:
continue
for obj_name in xobjects:
try:
obj = xobjects[obj_name]
if obj.get("/Subtype") != "/Image":
continue
# 提取图片数据
image_data = obj.get_data()
# 确定格式
filter_type = obj.get("/Filter", "")
if filter_type == "/DCTDecode":
ext = "jpg"
mime_type = "image/jpeg"
elif filter_type == "/FlateDecode":
ext = "png"
mime_type = "image/png"
else:
ext = "png"
mime_type = "image/png"
image_counter += 1
media_items.append(
MediaItem(
media_type="image",
file_name=f"page_{page_num}_img_{image_counter}.{ext}",
content=image_data,
mime_type=mime_type,
)
)
except Exception:
# 单个图片提取失败不影响整体
continue
except Exception:
# 页面处理失败不影响其他页面
continue
full_text = "\n\n".join(text_parts)
return ParseResult(text=full_text, media=media_items)
@@ -0,0 +1,41 @@
"""文本文件解析器
支持解析 TXT 和 Markdown 文件。
"""
from astrbot.core.knowledge_base.parsers.base import BaseParser, ParseResult
class TextParser(BaseParser):
"""TXT/MD 文本解析器
支持多种字符编码的自动检测。
"""
async def parse(self, file_content: bytes, file_name: str) -> ParseResult:
"""解析文本文件
尝试使用多种编码解析文件内容。
Args:
file_content: 文件内容
file_name: 文件名
Returns:
ParseResult: 解析结果,不包含多媒体资源
Raises:
ValueError: 如果无法解码文件
"""
# 尝试多种编码
for encoding in ["utf-8", "gbk", "gb2312", "gb18030"]:
try:
text = file_content.decode(encoding)
break
except UnicodeDecodeError:
continue
else:
raise ValueError(f"无法解码文件: {file_name}")
# 文本文件无多媒体资源
return ParseResult(text=text, media=[])
@@ -0,0 +1,16 @@
"""
检索模块
"""
from .manager import RetrievalManager, RetrievalResult
from .sparse_retriever import SparseRetriever, SparseResult
from .rank_fusion import RankFusion, FusedResult
__all__ = [
"RetrievalManager",
"RetrievalResult",
"SparseRetriever",
"SparseResult",
"RankFusion",
"FusedResult",
]
@@ -0,0 +1,767 @@
———
》),
)÷(1-
”,
)、
=(
:
→
℃
&
*
一一
~~~~
’
.
『
.一
./
--
』
=″
【
[*]
}>
[⑤]]
[①D]
c]
ng昉
*
//
[
]
[②e]
[②g]
={
}
,也
‘
A
[①⑥]
[②B]
[①a]
[④a]
[①③]
[③h]
③]
1.
--
[②b]
’‘
×××
[①⑧]
0:2
=[
[⑤b]
[②c]
[④b]
[②③]
[③a]
[④c]
[①⑤]
[①⑦]
[①g]
∈[
[①⑨]
[①④]
[①c]
[②f]
[②⑧]
[②①]
[①C]
[③c]
[③g]
[②⑤]
[②②]
一.
[①h]
.数
[]
[①B]
数/
[①i]
[③e]
[①①]
[④d]
[④e]
[③b]
[⑤a]
[①A]
[②⑧]
[②⑦]
[①d]
[②j]
〕〔
][
://
′∈
[②④
[⑤e]
12%
b]
...
...................
…………………………………………………③
ZXFITL
[③F]
」
[①o]
]∧′=[
∪φ∈
′|
{-
②c
}
[③①]
R.L.
[①E]
Ψ
-[*]-
↑
.日
[②d]
[②
[②⑦]
[②②]
[③e]
[①i]
[①B]
[①h]
[①d]
[①g]
[①②]
[②a]
f]
[⑩]
a]
[①e]
[②h]
[②⑥]
[③d]
[②⑩]
e]
〉
】
元/吨
[②⑩]
2.3%
5:0
[①]
::
[②]
[③]
[④]
[⑤]
[⑥]
[⑦]
[⑧]
[⑨]
……
——
?
、
。
“
”
《
》
!
,
:
;
?
.
,
.
'
?
·
———
──
?
—
<
>
(
)
〔
〕
[
]
(
)
-
+
~
×
/
/
①
②
③
④
⑤
⑥
⑦
⑧
⑨
⑩
Ⅲ
В
"
;
#
@
γ
μ
φ
φ.
×
Δ
■
▲
sub
exp
sup
sub
Lex
#
%
&
'
+
+ξ
++
-
-β
<
<±
<Δ
<λ
<φ
<<
=
=
=☆
=-
>
>λ
_
~±
~+
[⑤f]
[⑤d]
[②i]
≈
[②G]
[①f]
LI
㈧
[-
......
〉
[③⑩]
第二
一番
一直
一个
一些
许多
种
有的是
也就是说
末##末
啊
阿
哎
哎呀
哎哟
唉
俺
俺们
按
按照
吧
吧哒
把
罢了
被
本
本着
比
比方
比如
鄙人
彼
彼此
边
别
别的
别说
并
并且
不比
不成
不单
不但
不独
不管
不光
不过
不仅
不拘
不论
不怕
不然
不如
不特
不惟
不问
不只
朝
朝着
趁
趁着
乘
冲
除
除此之外
除非
除了
此
此间
此外
从
从而
打
待
但
但是
当
当着
到
得
的
的话
等
等等
地
第
叮咚
对
对于
多
多少
而
而况
而且
而是
而外
而言
而已
尔后
反过来
反过来说
反之
非但
非徒
否则
嘎
嘎登
该
赶
个
各
各个
各位
各种
各自
给
根据
跟
故
故此
固然
关于
管
归
果然
果真
过
哈
哈哈
呵
和
何
何处
何况
何时
嘿
哼
哼唷
呼哧
乎
哗
还是
还有
换句话说
换言之
或
或是
或者
极了
及
及其
及至
即
即便
即或
即令
即若
即使
几
几时
己
既
既然
既是
继而
加之
假如
假若
假使
鉴于
将
较
较之
叫
接着
结果
借
紧接着
进而
尽
尽管
经
经过
就
就是
就是说
据
具体地说
具体说来
开始
开外
靠
咳
可
可见
可是
可以
况且
啦
来
来着
离
例如
哩
连
连同
两者
了
临
另
另外
另一方面
论
嘛
吗
慢说
漫说
冒
么
每
每当
们
莫若
某
某个
某些
拿
哪
哪边
哪儿
哪个
哪里
哪年
哪怕
哪天
哪些
哪样
那
那边
那儿
那个
那会儿
那里
那么
那么些
那么样
那时
那些
那样
乃
乃至
呢
能
你
你们
您
宁
宁可
宁肯
宁愿
哦
呕
啪达
旁人
呸
凭
凭借
其
其次
其二
其他
其它
其一
其余
其中
起
起见
起见
岂但
恰恰相反
前后
前者
且
然而
然后
然则
让
人家
任
任何
任凭
如
如此
如果
如何
如其
如若
如上所述
若
若非
若是
啥
上下
尚且
设若
设使
甚而
甚么
甚至
省得
时候
什么
什么样
使得
是
是的
首先
谁
谁知
顺
顺着
似的
虽
虽然
虽说
虽则
随
随着
所
所以
他
他们
他人
它
它们
她
她们
倘
倘或
倘然
倘若
倘使
腾
替
通过
同
同时
哇
万一
往
望
为
为何
为了
为什么
为着
喂
嗡嗡
我
我们
呜
呜呼
乌乎
无论
无宁
毋宁
嘻
吓
相对而言
像
向
向着
嘘
呀
焉
沿
沿着
要
要不
要不然
要不是
要么
要是
也
也罢
也好
一
一般
一旦
一方面
一来
一切
一样
一则
依
依照
矣
以
以便
以及
以免
以至
以至于
以致
抑或
因
因此
因而
因为
哟
用
由
由此可见
由于
有
有的
有关
有些
又
于
于是
于是乎
与
与此同时
与否
与其
越是
云云
哉
再说
再者
在
在下
咱
咱们
则
怎
怎么
怎么办
怎么样
怎样
咋
照
照着
者
这
这边
这儿
这个
这会儿
这就是说
这里
这么
这么点儿
这么些
这么样
这时
这些
这样
正如
吱
之
之类
之所以
之一
只是
只限
只要
只有
至
至于
诸位
着
着呢
自
自从
自个儿
自各儿
自己
自家
自身
综上所述
总的来看
总的来说
总的说来
总而言之
总之
纵
纵令
纵然
纵使
遵照
作为
兮
呃
呗
咚
咦
喏
啐
喔唷
嗬
嗯
嗳
@@ -0,0 +1,273 @@
"""检索管理器
协调稠密检索、稀疏检索和 Rerank,提供统一的检索接口
"""
import time
from dataclasses import dataclass
from typing import List
from astrbot.core.knowledge_base.kb_db_sqlite import KBSQLiteDatabase
from astrbot.core.knowledge_base.retrieval.rank_fusion import RankFusion
from astrbot.core.knowledge_base.retrieval.sparse_retriever import SparseRetriever
from astrbot.core.provider.provider import RerankProvider
from astrbot.core.db.vec_db.base import Result
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
from ..kb_helper import KBHelper
from astrbot import logger
@dataclass
class RetrievalResult:
"""检索结果"""
chunk_id: str
doc_id: str
doc_name: str
kb_id: str
kb_name: str
content: str
score: float
metadata: dict
class RetrievalManager:
"""检索管理器
职责:
- 协调稠密检索、稀疏检索和 Rerank
- 结果融合和排序
"""
def __init__(
self,
sparse_retriever: SparseRetriever,
rank_fusion: RankFusion,
kb_db: KBSQLiteDatabase,
):
"""初始化检索管理器
Args:
vec_db_factory: 向量数据库工厂
sparse_retriever: 稀疏检索器
rank_fusion: 结果融合器
kb_db: 知识库数据库实例
"""
self.sparse_retriever = sparse_retriever
self.rank_fusion = rank_fusion
self.kb_db = kb_db
async def retrieve(
self,
query: str,
kb_ids: List[str],
kb_id_helper_map: dict[str, KBHelper],
top_k_fusion: int = 20,
top_m_final: int = 5,
) -> List[RetrievalResult]:
"""混合检索
流程:
1. 稠密检索 (向量相似度)
2. 稀疏检索 (BM25)
3. 结果融合 (RRF)
4. Rerank 重排序
Args:
query: 查询文本
kb_ids: 知识库 ID 列表
top_m_final: 最终返回数量
enable_rerank: 是否启用 Rerank
Returns:
List[RetrievalResult]: 检索结果列表
"""
if not kb_ids:
return []
kb_options: dict = {}
new_kb_ids = []
for kb_id in kb_ids:
kb_helper = kb_id_helper_map.get(kb_id)
if kb_helper:
kb = kb_helper.kb
kb_options[kb_id] = {
"top_k_dense": kb.top_k_dense or 50,
"top_k_sparse": kb.top_k_sparse or 50,
"top_m_final": kb.top_m_final or 5,
"vec_db": kb_helper.vec_db,
"rerank_provider_id": kb.rerank_provider_id,
}
new_kb_ids.append(kb_id)
else:
logger.warning(f"知识库 ID {kb_id} 实例未找到, 已跳过该知识库的检索")
kb_ids = new_kb_ids
# 1. 稠密检索
time_start = time.time()
dense_results = await self._dense_retrieve(
query=query,
kb_ids=kb_ids,
kb_options=kb_options,
)
time_end = time.time()
logger.debug(
f"Dense retrieval across {len(kb_ids)} bases took {time_end - time_start:.2f}s and returned {len(dense_results)} results."
)
# 2. 稀疏检索
time_start = time.time()
sparse_results = await self.sparse_retriever.retrieve(
query=query,
kb_ids=kb_ids,
kb_options=kb_options,
)
time_end = time.time()
logger.debug(
f"Sparse retrieval across {len(kb_ids)} bases took {time_end - time_start:.2f}s and returned {len(sparse_results)} results."
)
# 3. 结果融合
time_start = time.time()
fused_results = await self.rank_fusion.fuse(
dense_results=dense_results,
sparse_results=sparse_results,
top_k=top_k_fusion,
)
time_end = time.time()
logger.debug(
f"Rank fusion took {time_end - time_start:.2f}s and returned {len(fused_results)} results."
)
# 4. 转换为 RetrievalResult (获取元数据)
retrieval_results = []
for fr in fused_results:
metadata_dict = await self.kb_db.get_document_with_metadata(fr.doc_id)
if metadata_dict:
retrieval_results.append(
RetrievalResult(
chunk_id=fr.chunk_id,
doc_id=fr.doc_id,
doc_name=metadata_dict["document"].doc_name,
kb_id=fr.kb_id,
kb_name=metadata_dict["knowledge_base"].kb_name,
content=fr.content,
score=fr.score,
metadata={
"chunk_index": fr.chunk_index,
"char_count": len(fr.content),
},
)
)
# 5. Rerank
first_rerank = None
for kb_id in kb_ids:
vec_db: FaissVecDB = kb_options[kb_id]["vec_db"]
rerank_pi = kb_options[kb_id]["rerank_provider_id"]
if (
vec_db
and vec_db.rerank_provider
and rerank_pi
and rerank_pi == vec_db.rerank_provider.meta().id
):
first_rerank = vec_db.rerank_provider
break
if first_rerank and retrieval_results:
retrieval_results = await self._rerank(
query=query,
results=retrieval_results,
top_k=top_m_final,
rerank_provider=first_rerank,
)
return retrieval_results[:top_m_final]
async def _dense_retrieve(
self,
query: str,
kb_ids: List[str],
kb_options: dict,
):
"""稠密检索 (向量相似度)
为每个知识库使用独立的向量数据库进行检索,然后合并结果。
Args:
query: 查询文本
kb_ids: 知识库 ID 列表
top_k: 返回结果数量
Returns:
List[Result]: 检索结果列表
"""
all_results: list[Result] = []
for kb_id in kb_ids:
if kb_id not in kb_options:
continue
try:
vec_db: FaissVecDB = kb_options[kb_id]["vec_db"]
dense_k = int(kb_options[kb_id]["top_k_dense"])
vec_results = await vec_db.retrieve(
query=query,
k=dense_k,
fetch_k=dense_k * 2,
rerank=False, # 稠密检索阶段不进行 rerank
metadata_filters={"kb_id": kb_id},
)
all_results.extend(vec_results)
except Exception as e:
from astrbot.core import logger
logger.warning(f"知识库 {kb_id} 稠密检索失败: {e}")
continue
# 按相似度排序并返回 top_k
all_results.sort(key=lambda x: x.similarity, reverse=True)
# return all_results[: len(all_results) // len(kb_ids)]
return all_results
async def _rerank(
self,
query: str,
results: List[RetrievalResult],
top_k: int,
rerank_provider: RerankProvider,
) -> List[RetrievalResult]:
"""Rerank 重排序
Args:
query: 查询文本
results: 检索结果列表
top_k: 返回结果数量
Returns:
List[RetrievalResult]: 重排序后的结果列表
"""
if not results:
return []
# 准备文档列表
docs = [r.content for r in results]
# 调用 Rerank Provider
rerank_results = await rerank_provider.rerank(
query=query,
documents=docs,
)
# 更新分数并重新排序
reranked_list = []
for rerank_result in rerank_results:
idx = rerank_result.index
if idx < len(results):
result = results[idx]
result.score = rerank_result.relevance_score
reranked_list.append(result)
reranked_list.sort(key=lambda x: x.score, reverse=True)
return reranked_list[:top_k]
@@ -0,0 +1,138 @@
"""检索结果融合器
使用 Reciprocal Rank Fusion (RRF) 算法融合稠密检索和稀疏检索的结果
"""
import json
from dataclasses import dataclass
from astrbot.core.db.vec_db.base import Result
from astrbot.core.knowledge_base.kb_db_sqlite import KBSQLiteDatabase
from astrbot.core.knowledge_base.retrieval.sparse_retriever import SparseResult
@dataclass
class FusedResult:
"""融合后的检索结果"""
chunk_id: str
chunk_index: int
doc_id: str
kb_id: str
content: str
score: float
class RankFusion:
"""检索结果融合器
职责:
- 融合稠密检索和稀疏检索的结果
- 使用 Reciprocal Rank Fusion (RRF) 算法
"""
def __init__(self, kb_db: KBSQLiteDatabase, k: int = 60):
"""初始化结果融合器
Args:
kb_db: 知识库数据库实例
k: RRF 参数,用于平滑排名
"""
self.kb_db = kb_db
self.k = k
async def fuse(
self,
dense_results: list[Result],
sparse_results: list[SparseResult],
top_k: int = 20,
) -> list[FusedResult]:
"""融合稠密和稀疏检索结果
RRF 公式:
score(doc) = sum(1 / (k + rank_i))
Args:
dense_results: 稠密检索结果
sparse_results: 稀疏检索结果
top_k: 返回结果数量
Returns:
List[FusedResult]: 融合后的结果列表
"""
# 1. 构建排名映射
dense_ranks = {
r.data["doc_id"]: (idx + 1) for idx, r in enumerate(dense_results)
} # 这里的 doc_id 实际上是 chunk_id
sparse_ranks = {r.chunk_id: (idx + 1) for idx, r in enumerate(sparse_results)}
# 2. 收集所有唯一的 ID
# 需要统一为 chunk_id
all_chunk_ids = set()
vec_doc_id_to_dense: dict[str, Result] = {} # vec_doc_id -> Result
chunk_id_to_sparse: dict[str, SparseResult] = {} # chunk_id -> SparseResult
# 处理稀疏检索结果
for r in sparse_results:
all_chunk_ids.add(r.chunk_id)
chunk_id_to_sparse[r.chunk_id] = r
# 处理稠密检索结果 (需要转换 vec_doc_id 到 chunk_id)
for r in dense_results:
vec_doc_id = r.data["doc_id"]
all_chunk_ids.add(vec_doc_id)
vec_doc_id_to_dense[vec_doc_id] = r
# 3. 计算 RRF 分数
rrf_scores: dict[str, float] = {}
for identifier in all_chunk_ids:
score = 0.0
# 来自稠密检索的贡献
if identifier in dense_ranks:
score += 1.0 / (self.k + dense_ranks[identifier])
# 来自稀疏检索的贡献
if identifier in sparse_ranks:
score += 1.0 / (self.k + sparse_ranks[identifier])
rrf_scores[identifier] = score
# 4. 排序
sorted_ids = sorted(
rrf_scores.keys(), key=lambda cid: rrf_scores[cid], reverse=True
)[:top_k]
# 5. 构建融合结果
fused_results = []
for identifier in sorted_ids:
# 优先从稀疏检索获取完整信息
if identifier in chunk_id_to_sparse:
sr = chunk_id_to_sparse[identifier]
fused_results.append(
FusedResult(
chunk_id=sr.chunk_id,
chunk_index=sr.chunk_index,
doc_id=sr.doc_id,
kb_id=sr.kb_id,
content=sr.content,
score=rrf_scores[identifier],
)
)
elif identifier in vec_doc_id_to_dense:
# 从向量检索获取信息,需要从数据库获取块的详细信息
vec_result = vec_doc_id_to_dense[identifier]
chunk_md = json.loads(vec_result.data["metadata"])
fused_results.append(
FusedResult(
chunk_id=identifier,
chunk_index=chunk_md["chunk_index"],
doc_id=chunk_md["kb_doc_id"],
kb_id=chunk_md["kb_id"],
content=vec_result.data["text"],
score=rrf_scores[identifier],
)
)
return fused_results
@@ -0,0 +1,130 @@
"""稀疏检索器
使用 BM25 算法进行基于关键词的文档检索
"""
import jieba
import os
import json
from dataclasses import dataclass
from rank_bm25 import BM25Okapi
from astrbot.core.knowledge_base.kb_db_sqlite import KBSQLiteDatabase
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
@dataclass
class SparseResult:
"""稀疏检索结果"""
chunk_index: int
chunk_id: str
doc_id: str
kb_id: str
content: str
score: float
class SparseRetriever:
"""BM25 稀疏检索器
职责:
- 基于关键词的文档检索
- 使用 BM25 算法计算相关度
"""
def __init__(self, kb_db: KBSQLiteDatabase):
"""初始化稀疏检索器
Args:
kb_db: 知识库数据库实例
"""
self.kb_db = kb_db
self._index_cache = {} # 缓存 BM25 索引
with open(
os.path.join(os.path.dirname(__file__), "hit_stopwords.txt"),
encoding="utf-8",
) as f:
self.hit_stopwords = {
word.strip() for word in set(f.read().splitlines()) if word.strip()
}
async def retrieve(
self,
query: str,
kb_ids: list[str],
kb_options: dict,
) -> list[SparseResult]:
"""执行稀疏检索
Args:
query: 查询文本
kb_ids: 知识库 ID 列表
kb_options: 每个知识库的检索选项
Returns:
List[SparseResult]: 检索结果列表
"""
# 1. 获取所有相关块
top_k_sparse = 0
chunks = []
for kb_id in kb_ids:
vec_db: FaissVecDB = kb_options.get(kb_id, {}).get("vec_db")
if not vec_db:
continue
result = await vec_db.document_storage.get_documents(
metadata_filters={}, limit=None, offset=None
)
chunk_mds = [json.loads(doc["metadata"]) for doc in result]
result = [
{
"chunk_id": doc["doc_id"],
"chunk_index": chunk_md["chunk_index"],
"doc_id": chunk_md["kb_doc_id"],
"kb_id": kb_id,
"text": doc["text"],
}
for doc, chunk_md in zip(result, chunk_mds)
]
chunks.extend(result)
top_k_sparse += kb_options.get(kb_id, {}).get("top_k_sparse", 50)
if not chunks:
return []
# 2. 准备文档和索引
corpus = [chunk["text"] for chunk in chunks]
tokenized_corpus = [list(jieba.cut(doc)) for doc in corpus]
tokenized_corpus = [
[word for word in doc if word not in self.hit_stopwords]
for doc in tokenized_corpus
]
# 3. 构建 BM25 索引
bm25 = BM25Okapi(tokenized_corpus)
# 4. 执行检索
tokenized_query = list(jieba.cut(query))
tokenized_query = [
word for word in tokenized_query if word not in self.hit_stopwords
]
scores = bm25.get_scores(tokenized_query)
# 5. 排序并返回 Top-K
results = []
for idx, score in enumerate(scores):
chunk = chunks[idx]
results.append(
SparseResult(
chunk_id=chunk["chunk_id"],
chunk_index=chunk["chunk_index"],
doc_id=chunk["doc_id"],
kb_id=chunk["kb_id"],
content=chunk["text"],
score=float(score),
)
)
results.sort(key=lambda x: x.score, reverse=True)
# return results[: len(results) // len(kb_ids)]
return results[:top_k_sparse]
@@ -7,7 +7,7 @@ import copy
import json
import traceback
from datetime import timedelta
from typing import AsyncGenerator, Union
from collections.abc import AsyncGenerator
from astrbot.core.conversation_mgr import Conversation
from astrbot.core import logger
from astrbot.core.message.components import Image
@@ -33,6 +33,7 @@ from astrbot.core.star.star_handler import EventType
from astrbot.core.utils.metrics import Metric
from ...context import PipelineContext, call_event_hook, call_handler
from ..stage import Stage
from ..utils import inject_kb_context
from astrbot.core.provider.register import llm_tools
from astrbot.core.star.star_handler import star_map
from astrbot.core.astr_agent_context import AstrAgentContext
@@ -367,7 +368,7 @@ class LLMRequestSubStage(Stage):
async def process(
self, event: AstrMessageEvent, _nested: bool = False
) -> Union[None, AsyncGenerator[None, None]]:
) -> None | AsyncGenerator[None, None]:
req: ProviderRequest | None = None
if not self.ctx.astrbot_config["provider_settings"]["enable"]:
@@ -419,6 +420,14 @@ class LLMRequestSubStage(Stage):
if not req.prompt and not req.image_urls:
return
# 应用知识库
try:
await inject_kb_context(
umo=event.unified_msg_origin, p_ctx=self.ctx, req=req
)
except Exception as e:
logger.error(f"调用知识库时遇到问题: {e}")
# 执行请求 LLM 前事件钩子。
if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
return
@@ -0,0 +1,80 @@
from ..context import PipelineContext
from astrbot.core.provider.entities import ProviderRequest
from astrbot.api import logger, sp
async def inject_kb_context(
umo: str,
p_ctx: PipelineContext,
req: ProviderRequest,
) -> None:
"""inject knowledge base context into the provider request
Args:
umo: Unique message object (session ID)
p_ctx: Pipeline context
req: Provider request
"""
kb_mgr = p_ctx.plugin_manager.context.kb_manager
# 1. 优先读取会话级配置
session_config = await sp.session_get(umo, "kb_config", default={})
if session_config and "kb_ids" in session_config:
# 会话级配置
kb_ids = session_config.get("kb_ids", [])
# 如果配置为空列表,明确表示不使用知识库
if not kb_ids:
logger.info(f"[知识库] 会话 {umo} 已被配置为不使用知识库")
return
top_k = session_config.get("top_k", 5)
# 将 kb_ids 转换为 kb_names
kb_names = []
invalid_kb_ids = []
for kb_id in kb_ids:
kb_helper = await kb_mgr.get_kb(kb_id)
if kb_helper:
kb_names.append(kb_helper.kb.kb_name)
else:
logger.warning(f"[知识库] 知识库不存在或未加载: {kb_id}")
invalid_kb_ids.append(kb_id)
if invalid_kb_ids:
logger.warning(
f"[知识库] 会话 {umo} 配置的以下知识库无效: {invalid_kb_ids}"
)
if not kb_names:
return
logger.debug(f"[知识库] 使用会话级配置,知识库数量: {len(kb_names)}")
else:
kb_names = p_ctx.astrbot_config.get("kb_names", [])
top_k = p_ctx.astrbot_config.get("kb_final_top_k", 5)
logger.debug(f"[知识库] 使用全局配置,知识库数量: {len(kb_names)}")
top_k_fusion = p_ctx.astrbot_config.get("kb_fusion_top_k", 20)
if not kb_names:
return
logger.debug(f"[知识库] 开始检索知识库,数量: {len(kb_names)}, top_k={top_k}")
kb_context = await kb_mgr.retrieve(
query=req.prompt,
kb_names=kb_names,
top_k_fusion=top_k_fusion,
top_m_final=top_k,
)
if not kb_context:
return
formatted = kb_context.get("context_text", "")
if formatted:
results = kb_context.get("results", [])
logger.debug(f"[知识库] 为会话 {umo} 注入了 {len(results)} 条相关知识块")
req.system_prompt = f"{formatted}\n\n{req.system_prompt or ''}"
+14 -9
View File
@@ -1,6 +1,5 @@
import asyncio
import traceback
from typing import List
from astrbot.core import logger, sp
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
@@ -28,7 +27,7 @@ class ProviderManager:
self.persona_mgr = persona_mgr
self.acm = acm
config = acm.confs["default"]
self.providers_config: List = config["provider"]
self.providers_config: list = config["provider"]
self.provider_settings: dict = config["provider_settings"]
self.provider_stt_settings: dict = config.get("provider_stt_settings", {})
self.provider_tts_settings: dict = config.get("provider_tts_settings", {})
@@ -36,15 +35,15 @@ class ProviderManager:
# 人格相关属性,v4.0.0 版本后被废弃,推荐使用 PersonaManager
self.default_persona_name = persona_mgr.default_persona
self.provider_insts: List[Provider] = []
self.provider_insts: list[Provider] = []
"""加载的 Provider 的实例"""
self.stt_provider_insts: List[STTProvider] = []
self.stt_provider_insts: list[STTProvider] = []
"""加载的 Speech To Text Provider 的实例"""
self.tts_provider_insts: List[TTSProvider] = []
self.tts_provider_insts: list[TTSProvider] = []
"""加载的 Text To Speech Provider 的实例"""
self.embedding_provider_insts: List[EmbeddingProvider] = []
self.embedding_provider_insts: list[EmbeddingProvider] = []
"""加载的 Embedding Provider 的实例"""
self.rerank_provider_insts: List[RerankProvider] = []
self.rerank_provider_insts: list[RerankProvider] = []
"""加载的 Rerank Provider 的实例"""
self.inst_map: dict[
str,
@@ -175,7 +174,11 @@ class ProviderManager:
async def initialize(self):
# 逐个初始化提供商
for provider_config in self.providers_config:
await self.load_provider(provider_config)
try:
await self.load_provider(provider_config)
except Exception as e:
logger.error(traceback.format_exc())
logger.error(e)
# 设置默认提供商
selected_provider_id = sp.get(
@@ -404,10 +407,12 @@ class ProviderManager:
self.inst_map[provider_config["id"]] = inst
except Exception as e:
logger.error(traceback.format_exc())
logger.error(
f"实例化 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}"
)
raise Exception(
f"实例化 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}"
)
async def reload(self, provider_config: dict):
await self.terminate_provider(provider_config["id"])
+67
View File
@@ -1,4 +1,5 @@
import abc
import asyncio
from typing import List
from typing import AsyncGenerator
from astrbot.core.agent.tool import ToolSet
@@ -203,6 +204,72 @@ class EmbeddingProvider(AbstractProvider):
"""获取向量的维度"""
...
async def get_embeddings_batch(
self,
texts: list[str],
batch_size: int = 16,
tasks_limit: int = 3,
max_retries: int = 3,
progress_callback=None,
) -> list[list[float]]:
"""批量获取文本的向量,分批处理以节省内存
Args:
texts: 文本列表
batch_size: 每批处理的文本数量
tasks_limit: 并发任务数量限制
max_retries: 失败时的最大重试次数
progress_callback: 进度回调函数,接收参数 (current, total)
Returns:
向量列表
"""
semaphore = asyncio.Semaphore(tasks_limit)
all_embeddings: list[list[float]] = []
failed_batches: list[tuple[int, list[str]]] = []
completed_count = 0
total_count = len(texts)
async def process_batch(batch_idx: int, batch_texts: list[str]):
nonlocal completed_count
async with semaphore:
for attempt in range(max_retries):
try:
batch_embeddings = await self.get_embeddings(batch_texts)
all_embeddings.extend(batch_embeddings)
completed_count += len(batch_texts)
if progress_callback:
await progress_callback(completed_count, total_count)
return
except Exception as e:
if attempt == max_retries - 1:
# 最后一次重试失败,记录失败的批次
failed_batches.append((batch_idx, batch_texts))
raise Exception(
f"批次 {batch_idx} 处理失败,已重试 {max_retries} 次: {str(e)}"
)
# 等待一段时间后重试,使用指数退避
await asyncio.sleep(2**attempt)
tasks = []
for i in range(0, len(texts), batch_size):
batch_texts = texts[i : i + batch_size]
batch_idx = i // batch_size
tasks.append(process_batch(batch_idx, batch_texts))
# 收集所有任务的结果,包括失败的任务
results = await asyncio.gather(*tasks, return_exceptions=True)
# 检查是否有失败的任务
errors = [r for r in results if isinstance(r, Exception)]
if errors:
error_msg = (
f"有 {len(errors)} 个批次处理失败: {'; '.join(str(e) for e in errors)}"
)
raise Exception(error_msg)
return all_embeddings
class RerankProvider(AbstractProvider):
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
@@ -32,7 +32,6 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
self.model = provider_config.get(
"embedding_model", "gemini-embedding-exp-03-07"
)
self.dimension = provider_config.get("embedding_dimensions", 768)
async def get_embedding(self, text: str) -> list[float]:
"""
@@ -60,4 +59,4 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
def get_dim(self) -> int:
"""获取向量的维度"""
return self.dimension
return self.provider_config.get("embedding_dimensions", 768)
@@ -22,7 +22,6 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
timeout=int(provider_config.get("timeout", 20)),
)
self.model = provider_config.get("embedding_model", "text-embedding-3-small")
self.dimension = provider_config.get("embedding_dimensions", 1024)
async def get_embedding(self, text: str) -> list[float]:
"""
@@ -40,4 +39,4 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
def get_dim(self) -> int:
"""获取向量的维度"""
return self.dimension
return self.provider_config.get("embedding_dimensions", 1024)
+3
View File
@@ -19,6 +19,7 @@ from astrbot.core.platform import Platform
from astrbot.core.platform.manager import PlatformManager
from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.persona_mgr import PersonaManager
from .star import star_registry, StarMetadata, star_map
from .star_handler import star_handlers_registry, StarHandlerMetadata, EventType
@@ -55,6 +56,7 @@ class Context:
message_history_manager: PlatformMessageHistoryManager,
persona_manager: PersonaManager,
astrbot_config_mgr: AstrBotConfigManager,
knowledge_base_manager: KnowledgeBaseManager,
):
self._event_queue = event_queue
"""事件队列。消息平台通过事件队列传递消息事件。"""
@@ -68,6 +70,7 @@ class Context:
self.message_history_manager = message_history_manager
self.persona_manager = persona_manager
self.astrbot_config_mgr = astrbot_config_mgr
self.kb_manager = knowledge_base_manager
def get_registered_star(self, star_name: str) -> StarMetadata | None:
"""根据插件名获取插件的 Metadata"""
+2
View File
@@ -11,6 +11,7 @@ from .conversation import ConversationRoute
from .file import FileRoute
from .session_management import SessionManagementRoute
from .persona import PersonaRoute
from .knowledge_base import KnowledgeBaseRoute
__all__ = [
"AuthRoute",
@@ -26,4 +27,5 @@ __all__ = [
"FileRoute",
"SessionManagementRoute",
"PersonaRoute",
"KnowledgeBaseRoute",
]
+58 -5
View File
@@ -1,4 +1,3 @@
import typing
import traceback
import os
import inspect
@@ -45,9 +44,7 @@ def try_cast(value: str, type_: str):
return None
def validate_config(
data, schema: dict, is_core: bool
) -> typing.Tuple[typing.List[str], typing.Dict]:
def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]:
errors = []
def validate(data: dict, metadata: dict = schema, path=""):
@@ -178,6 +175,7 @@ class ConfigRoute(Route):
"/config/provider/check_one": ("GET", self.check_one_provider_status),
"/config/provider/list": ("GET", self.get_provider_config_list),
"/config/provider/model_list": ("GET", self.get_provider_model_list),
"/config/provider/get_embedding_dim": ("POST", self.get_embedding_dim),
}
self.register_routes()
@@ -601,6 +599,61 @@ class ConfigRoute(Route):
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def get_embedding_dim(self):
"""获取嵌入模型的维度"""
post_data = await request.json
provider_config = post_data.get("provider_config", None)
if not provider_config:
return Response().error("缺少参数 provider_config").__dict__
try:
# 动态导入 EmbeddingProvider
from astrbot.core.provider.provider import EmbeddingProvider
from astrbot.core.provider.register import provider_cls_map
# 获取 provider 类型
provider_type = provider_config.get("type", None)
if not provider_type:
return Response().error("provider_config 缺少 type 字段").__dict__
# 获取对应的 provider 类
if provider_type not in provider_cls_map:
return (
Response()
.error(f"未找到适用于 {provider_type} 的提供商适配器")
.__dict__
)
provider_metadata = provider_cls_map[provider_type]
cls_type = provider_metadata.cls_type
if not cls_type:
return Response().error(f"无法找到 {provider_type} 的类").__dict__
# 实例化 provider
inst = cls_type(provider_config, {})
# 检查是否是 EmbeddingProvider
if not isinstance(inst, EmbeddingProvider):
return Response().error("提供商不是 EmbeddingProvider 类型").__dict__
# 初始化
if getattr(inst, "initialize", None):
await inst.initialize()
# 获取嵌入向量维度
vec = await inst.get_embedding("echo")
dim = len(vec)
logger.info(
f"检测到 {provider_config.get('id', 'unknown')} 的嵌入向量维度为 {dim}"
)
return Response().ok({"embedding_dimensions": dim}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"获取嵌入维度失败: {str(e)}").__dict__
async def get_platform_list(self):
"""获取所有平台的列表"""
platform_list = []
@@ -797,7 +850,7 @@ class ConfigRoute(Route):
logger.warning(
f"Failed to import required modules for platform {platform.name}: {e}"
)
except (OSError, IOError) as e:
except OSError as e:
logger.warning(f"File system error for platform {platform.name} logo: {e}")
except Exception as e:
logger.warning(
File diff suppressed because it is too large. Load diff
+1
View File
@@ -72,6 +72,7 @@ class AstrBotDashboard:
)
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.app.add_url_rule(
"/api/plug/<path:subpath>",
+161
View File
@@ -0,0 +1,161 @@
import base64
import os
import traceback
from io import BytesIO
from astrbot.api import logger
from astrbot.core.knowledge_base.kb_helper import KBHelper
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
async def generate_tsne_visualization(
query: str, kb_names: list[str], kb_manager: KnowledgeBaseManager
) -> str | None:
"""生成 t-SNE 可视化图片
Args:
query: 查询文本
kb_names: 知识库名称列表
kb_manager: 知识库管理器
Returns:
图片路径或 None
"""
try:
import faiss
import numpy as np
import matplotlib
matplotlib.use("Agg") # 使用非交互式后端
import matplotlib.pyplot as plt
from sklearn.manifold import TSNE
except ImportError as e:
raise Exception(
"缺少必要的库以生成 t-SNE 可视化。请安装 matplotlib 和 scikit-learn: {e}"
) from e
try:
# 获取第一个知识库的向量数据
kb_helper: KBHelper | None = None
for kb_name in kb_names:
kb_helper = await kb_manager.get_kb_by_name(kb_name)
if kb_helper:
break
if not kb_helper:
logger.warning("未找到知识库")
return None
kb = kb_helper.kb
index_path = f"data/knowledge_base/{kb.kb_id}/index.faiss"
# 读取 FAISS 索引
if not os.path.exists(index_path):
logger.warning(f"FAISS 索引不存在: {index_path}")
return None
index = faiss.read_index(index_path)
if index.ntotal == 0:
logger.warning("索引为空")
return None
# 提取所有向量
logger.info(f"提取 {index.ntotal} 个向量用于可视化...")
if isinstance(index, faiss.IndexIDMap):
base_index = faiss.downcast_index(index.index)
if hasattr(base_index, "reconstruct_n"):
vectors = base_index.reconstruct_n(0, index.ntotal)
else:
vectors = np.zeros((index.ntotal, index.d), dtype=np.float32)
for i in range(index.ntotal):
base_index.reconstruct(i, vectors[i])
elif hasattr(index, "reconstruct_n"):
vectors = index.reconstruct_n(0, index.ntotal)
else:
vectors = np.zeros((index.ntotal, index.d), dtype=np.float32)
for i in range(index.ntotal):
index.reconstruct(i, vectors[i])
# 获取查询向量
vec_db: FaissVecDB = kb_helper.vec_db # type: ignore
embedding_provider = vec_db.embedding_provider
query_embedding = await embedding_provider.get_embedding(query)
query_vector = np.array([query_embedding], dtype=np.float32)
# 合并所有向量和查询向量
all_vectors = np.vstack([vectors, query_vector])
# t-SNE 降维
logger.info("开始 t-SNE 降维...")
perplexity = min(30, all_vectors.shape[0] - 1)
tsne = TSNE(n_components=2, random_state=42, perplexity=perplexity)
vectors_2d = tsne.fit_transform(all_vectors)
# 分离知识库向量和查询向量
kb_vectors_2d = vectors_2d[:-1]
query_vector_2d = vectors_2d[-1]
# 可视化
logger.info("生成可视化图表...")
plt.figure(figsize=(14, 10))
# 绘制知识库向量
scatter = plt.scatter(
kb_vectors_2d[:, 0],
kb_vectors_2d[:, 1],
alpha=0.5,
s=40,
c=range(len(kb_vectors_2d)),
cmap="viridis",
label="Knowledge Base Vectors",
)
# 绘制查询向量(红色 X)
plt.scatter(
query_vector_2d[0],
query_vector_2d[1],
c="red",
s=300,
marker="X",
edgecolors="black",
linewidths=2,
label="Query",
zorder=5,
)
# 添加查询文本标注
plt.annotate(
"Query",
(query_vector_2d[0], query_vector_2d[1]),
xytext=(10, 10),
textcoords="offset points",
fontsize=10,
bbox={"boxstyle": "round,pad=0.5", "fc": "yellow", "alpha": 0.7},
arrowprops={"arrowstyle": "->", "connectionstyle": "arc3,rad=0"},
)
plt.colorbar(scatter, label="Vector Index")
plt.title(
f"t-SNE Visualization: Query in Knowledge Base\n"
f"({index.ntotal} vectors, {index.d} dimensions, KB: {kb.kb_name})",
fontsize=14,
pad=20,
)
plt.xlabel("t-SNE Dimension 1", fontsize=12)
plt.ylabel("t-SNE Dimension 2", fontsize=12)
plt.grid(True, alpha=0.3)
plt.legend(fontsize=10, loc="upper right")
# base64 编码图片返回
buffer = BytesIO()
plt.savefig(buffer, format="png", dpi=150, bbox_inches="tight")
plt.close()
buffer.seek(0)
img_base64 = base64.b64encode(buffer.read()).decode("utf-8")
return img_base64
except Exception as e:
logger.error(f"生成 t-SNE 可视化时出错: {e}")
logger.error(traceback.format_exc())
return None
@@ -7,6 +7,8 @@ import ProviderSelector from './ProviderSelector.vue'
import PersonaSelector from './PersonaSelector.vue'
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
import { useI18n } from '@/i18n/composables'
import axios from 'axios'
import { useToast } from '@/utils/toast'
const props = defineProps({
metadata: {
@@ -40,6 +42,7 @@ const currentEditingKey = ref('')
const currentEditingLanguage = ref('json')
const currentEditingTheme = ref('vs-light')
let currentEditingKeyIterable = null
const loadingEmbeddingDim = ref(false)
function openEditorDialog(key, value, theme, language) {
currentEditingKey.value = key
@@ -49,10 +52,34 @@ function openEditorDialog(key, value, theme, language) {
dialog.value = true
}
function saveEditedContent() {
dialog.value = false
}
async function getEmbeddingDimensions(providerConfig) {
if (loadingEmbeddingDim.value) return
loadingEmbeddingDim.value = true
try {
const response = await axios.post('/api/config/provider/get_embedding_dim', {
provider_config: providerConfig
})
if (response.data.status != "error" && response.data.data?.embedding_dimensions) {
console.log(response.data.data.embedding_dimensions)
providerConfig.embedding_dimensions = response.data.data.embedding_dimensions
useToast().success("获取成功: " + response.data.data.embedding_dimensions)
} else {
useToast().error(response.data.message)
}
} catch (error) {
console.error('Error getting embedding dimensions:', error)
} finally {
loadingEmbeddingDim.value = false
}
}
function getValueBySelector(obj, selector) {
const keys = selector.split('.')
let current = obj
@@ -184,6 +211,29 @@ function hasVisibleItemsAfter(items, currentIndex) {
v-model="iterable[key]"
/>
</div>
<!-- Numeric input with get_embedding_dim button -->
<div v-else-if="metadata[metadataKey].items[key]?._special === 'get_embedding_dim'"
class="d-flex align-center gap-2">
<v-text-field
v-model="iterable[key]"
density="compact"
variant="outlined"
class="config-field"
type="number"
hide-details
></v-text-field>
<v-btn
color="primary"
variant="tonal"
size="small"
@click="getEmbeddingDimensions(iterable)"
:loading="loadingEmbeddingDim"
class="ml-2"
>
自动检测
</v-btn>
</div>
<!-- List item with options-->
<div v-else-if="metadata[metadataKey].items[key]?.type === 'list' && metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible && metadata[metadataKey].items[key]?.render_type === 'checkbox'"
class="d-flex flex-wrap gap-20">
@@ -1,11 +1,21 @@
<template>
<div class="d-flex align-center justify-space-between">
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
<span v-if="!modelValue || (Array.isArray(modelValue) && modelValue.length === 0)"
style="color: rgb(var(--v-theme-primaryText));">
未选择
</span>
<span v-else>
{{ modelValue }}
</span>
<div v-else class="d-flex flex-wrap gap-1">
<v-chip
v-for="name in modelValue"
:key="name"
size="small"
color="primary"
variant="tonal"
closable
@click:close="removeKnowledgeBase(name)">
{{ name }}
</v-chip>
</div>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
{{ buttonText }}
</v-btn>
@@ -21,86 +31,57 @@
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<!-- 插件未安装提示 -->
<div v-if="!loading && !pluginInstalled" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-puzzle-outline</v-icon>
<p class="text-grey mt-4 mb-4">知识库插件未安装</p>
<v-btn color="primary" variant="tonal" @click="goToKnowledgeBasePage">
前往知识库页面
</v-btn>
</div>
<!-- 知识库列表 -->
<v-list v-else-if="!loading && pluginInstalled" density="compact">
<!-- 不使用选项 -->
<v-list-item
:value="''"
@click="selectKnowledgeBase({ collection_name: '' })"
:active="selectedKnowledgeBase === ''"
rounded="md"
class="ma-1">
<template v-slot:prepend>
<v-icon color="grey-lighten-1">mdi-close-circle-outline</v-icon>
</template>
<v-list-item-title>不使用</v-list-item-title>
<v-list-item-subtitle>不使用任何知识库</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedKnowledgeBase === ''" color="primary">mdi-check-circle</v-icon>
</template>
</v-list-item>
<v-divider v-if="knowledgeBaseList.length > 0" class="my-2"></v-divider>
<v-list v-if="!loading" density="compact">
<!-- 知识库选项 -->
<v-list-item
v-for="kb in knowledgeBaseList"
:key="kb.collection_name"
:value="kb.collection_name"
@click="selectKnowledgeBase(kb)"
:active="selectedKnowledgeBase === kb.collection_name"
:key="kb.kb_id"
:value="kb.kb_name"
@click="selectKnowledgeBase(kb.kb_name)"
:active="isSelected(kb.kb_name)"
rounded="md"
class="ma-1">
<template v-slot:prepend>
<span class="emoji-icon">{{ kb.emoji || '🙂' }}</span>
<span class="emoji-icon">{{ kb.emoji || '📚' }}</span>
</template>
<v-list-item-title>{{ kb.collection_name }}</v-list-item-title>
<v-list-item-title>{{ kb.kb_name }}</v-list-item-title>
<v-list-item-subtitle>
{{ kb.description || '无描述' }}
<span v-if="kb.count !== undefined"> - {{ kb.count }} 项知识</span>
<span v-if="kb.doc_count !== undefined"> - {{ kb.doc_count }} 个文档</span>
<span v-if="kb.chunk_count !== undefined"> - {{ kb.chunk_count }} 个块</span>
</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedKnowledgeBase === kb.collection_name" color="primary">mdi-check-circle</v-icon>
<v-icon v-if="isSelected(kb.kb_name)" color="primary">
mdi-checkbox-marked
</v-icon>
<v-icon v-else color="grey-lighten-1">
mdi-checkbox-blank-outline
</v-icon>
</template>
</v-list-item>
<!-- 当没有知识库时显示创建提示 -->
<div v-if="knowledgeBaseList.length === 0" class="text-center py-4">
<p class="text-grey mb-4">暂无知识库</p>
<v-btn color="primary" variant="tonal" size="small" @click="goToKnowledgeBasePage">
<div v-if="knowledgeBaseList.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-database-off</v-icon>
<p class="text-grey mt-4 mb-4">暂无知识库</p>
<v-btn color="primary" variant="tonal" @click="goToKnowledgeBasePage">
创建知识库
</v-btn>
</div>
</v-list>
<!-- 空状态(插件未安装时保留原有逻辑) -->
<div v-else-if="!loading && !pluginInstalled && knowledgeBaseList.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-database-off</v-icon>
<p class="text-grey mt-4 mb-4">暂无知识库</p>
<v-btn color="primary" variant="tonal" @click="goToKnowledgeBasePage">
创建知识库
</v-btn>
</div>
</v-card-text>
<v-card-actions class="pa-4">
<div v-if="selectedKnowledgeBases.length > 0" class="text-caption text-grey">
已选择 {{ selectedKnowledgeBases.length }} 个知识库
</div>
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelSelection">取消</v-btn>
<v-btn
color="primary"
@click="confirmSelection"
:disabled="selectedKnowledgeBase === null || selectedKnowledgeBase === undefined">
@click="confirmSelection">
确认选择
</v-btn>
</v-card-actions>
@@ -115,8 +96,8 @@ import { useRouter } from 'vue-router'
const props = defineProps({
modelValue: {
type: String,
default: ''
type: Array,
default: () => []
},
buttonText: {
type: String,
@@ -130,74 +111,87 @@ const router = useRouter()
const dialog = ref(false)
const knowledgeBaseList = ref([])
const loading = ref(false)
const selectedKnowledgeBase = ref('')
const pluginInstalled = ref(false)
const selectedKnowledgeBases = ref([])
// 监听 modelValue 变化,同步到 selectedKnowledgeBase
// 监听 modelValue 变化,同步到 selectedKnowledgeBases
watch(() => props.modelValue, (newValue) => {
selectedKnowledgeBase.value = newValue || ''
selectedKnowledgeBases.value = Array.isArray(newValue) ? [...newValue] : []
}, { immediate: true })
async function openDialog() {
selectedKnowledgeBase.value = props.modelValue || ''
// 初始化选中状态
selectedKnowledgeBases.value = Array.isArray(props.modelValue)
? [...props.modelValue]
: []
dialog.value = true
await checkPluginAndLoadKnowledgeBases()
await loadKnowledgeBases()
}
async function checkPluginAndLoadKnowledgeBases() {
async function loadKnowledgeBases() {
loading.value = true
try {
// 首先检查插件是否安装
const pluginResponse = await axios.get('/api/plugin/get?name=astrbot_plugin_knowledge_base')
const response = await axios.get('/api/kb/list', {
params: {
page: 1,
page_size: 100
}
})
if (pluginResponse.data.status === 'ok' && pluginResponse.data.data.length > 0) {
pluginInstalled.value = true
// 插件已安装,获取知识库列表
await loadKnowledgeBases()
if (response.data.status === 'ok') {
knowledgeBaseList.value = response.data.data.items || []
} else {
pluginInstalled.value = false
console.error('加载知识库列表失败:', response.data.message)
knowledgeBaseList.value = []
}
} catch (error) {
console.error('检查知识库插件失败:', error)
pluginInstalled.value = false
console.error('加载知识库列表失败:', error)
knowledgeBaseList.value = []
} finally {
loading.value = false
}
}
async function loadKnowledgeBases() {
try {
const response = await axios.get('/api/plug/alkaid/kb/collections')
if (response.data.status === 'ok') {
knowledgeBaseList.value = response.data.data || []
} else {
knowledgeBaseList.value = []
}
} catch (error) {
console.error('加载知识库列表失败:', error)
knowledgeBaseList.value = []
function isSelected(kbName) {
return selectedKnowledgeBases.value.includes(kbName)
}
function selectKnowledgeBase(kbName) {
// 多选模式:切换选中状态
const index = selectedKnowledgeBases.value.indexOf(kbName)
if (index > -1) {
selectedKnowledgeBases.value.splice(index, 1)
} else {
selectedKnowledgeBases.value.push(kbName)
}
}
function selectKnowledgeBase(kb) {
selectedKnowledgeBase.value = kb.collection_name
function removeKnowledgeBase(kbName) {
const index = selectedKnowledgeBases.value.indexOf(kbName)
if (index > -1) {
selectedKnowledgeBases.value.splice(index, 1)
}
// 立即更新父组件
emit('update:modelValue', [...selectedKnowledgeBases.value])
}
function confirmSelection() {
emit('update:modelValue', selectedKnowledgeBase.value)
emit('update:modelValue', [...selectedKnowledgeBases.value])
dialog.value = false
}
function cancelSelection() {
selectedKnowledgeBase.value = props.modelValue || ''
// 恢复到原始值
selectedKnowledgeBases.value = Array.isArray(props.modelValue)
? [...props.modelValue]
: []
dialog.value = false
}
function goToKnowledgeBasePage() {
dialog.value = false
router.push('/alkaid/knowledge-base')
router.push('/knowledge-base')
}
</script>
@@ -222,4 +216,8 @@ function goToKnowledgeBasePage() {
align-items: center;
justify-content: center;
}
.gap-1 {
gap: 4px;
}
</style>
@@ -6,7 +6,7 @@
"title": "The Alkaid Project.",
"subtitle": "AstrBot Alpha Project",
"navigation": {
"knowledgeBase": "Knowledge Base",
"knowledgeBase": "Knowledge Base (Plugin)",
"longTermMemory": "Long-term Memory",
"other": "..."
}
@@ -0,0 +1,83 @@
{
"title": "Knowledge Base Details",
"backToList": "Back to List",
"tabs": {
"overview": "Overview",
"documents": "Documents",
"sessions": "Sessions",
"settings": "Settings"
},
"overview": {
"title": "Basic Information",
"name": "Name",
"description": "Description",
"emoji": "Icon",
"createdAt": "Created At",
"updatedAt": "Updated At",
"stats": "Statistics",
"docCount": "Documents",
"chunkCount": "Chunks",
"embeddingModel": "Embedding Model",
"rerankModel": "Rerank Model",
"notSet": "Not Set"
},
"documents": {
"title": "Documents",
"upload": "Upload Document",
"empty": "No documents",
"name": "Name",
"type": "Type",
"size": "Size",
"chunks": "Chunks",
"createdAt": "Uploaded At",
"actions": "Actions",
"view": "View",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete document '{name}'?",
"deleteWarning": "This will delete the document and all its chunks. This action cannot be undone.",
"uploading": "Uploading...",
"uploadSuccess": "Document uploaded successfully",
"uploadFailed": "Failed to upload document",
"deleteSuccess": "Document deleted successfully",
"deleteFailed": "Failed to delete document"
},
"upload": {
"title": "Upload Document",
"selectFile": "Select File",
"dropzone": "Drop files here or click to select",
"supportedFormats": "Supported formats: TXT, PDF, Markdown",
"maxSize": "Max file size: 128MB",
"chunkSettings": "Chunk Settings",
"batchSettings": "Batch Settings",
"chunkSize": "Chunk Size",
"chunkSizeHint": "Number of characters per chunk (default: 512)",
"chunkOverlap": "Chunk Overlap",
"chunkOverlapHint": "Overlapping characters between chunks (default: 50)",
"batchSize": "Batch Size",
"batchSizeHint": "Number of chunks to process in each batch (default: 32)",
"tasksLimit": "Concurrent Tasks Limit",
"tasksLimitHint": "Maximum number of concurrent upload tasks (default: 3)",
"maxRetries": "Max Retries",
"maxRetriesHint": "Number of times to retry a failed upload task (default: 3)",
"cancel": "Cancel",
"submit": "Upload",
"fileRequired": "Please select a file to upload"
},
"settings": {
"title": "Knowledge Base Settings",
"basic": "Basic Settings",
"retrieval": "Retrieval Settings",
"chunkSize": "Chunk Size",
"chunkOverlap": "Chunk Overlap",
"topKDense": "Dense Retrieval Count",
"topKSparse": "Sparse Retrieval Count",
"topMFinal": "Final Result Count",
"enableRerank": "Enable Rerank",
"embeddingProvider": "Embedding Provider",
"rerankProvider": "Rerank Provider",
"save": "Save Settings",
"saveSuccess": "Settings saved successfully",
"saveFailed": "Failed to save settings",
"tips": "Tip: Modifying retrieval settings will affect subsequent knowledge base queries."
}
}
@@ -0,0 +1,52 @@
{
"title": "Document Details",
"backToKB": "Back to Knowledge Base",
"info": {
"title": "Document Information",
"name": "Document Name",
"type": "File Type",
"size": "File Size",
"chunkCount": "Chunk Count",
"createdAt": "Uploaded At"
},
"chunks": {
"title": "Chunks",
"empty": "No chunks",
"index": "Index",
"content": "Content",
"charCount": "Characters",
"actions": "Actions",
"view": "View",
"edit": "Edit",
"delete": "Delete",
"preview": "Preview",
"search": "Search Chunks",
"searchPlaceholder": "Enter keywords to search chunks...",
"showing": "Showing"
},
"edit": {
"title": "Edit Chunk",
"content": "Chunk Content",
"cancel": "Cancel",
"save": "Save",
"saveSuccess": "Chunk saved successfully",
"saveFailed": "Failed to save chunk"
},
"delete": {
"title": "Delete Chunk",
"confirmText": "Are you sure you want to delete this chunk?",
"warning": "This action cannot be undone and may affect knowledge base retrieval performance.",
"cancel": "Cancel",
"confirm": "Delete",
"deleteSuccess": "Chunk deleted successfully",
"deleteFailed": "Failed to delete chunk"
},
"view": {
"title": "Chunk Details",
"index": "Index",
"content": "Content",
"charCount": "Characters",
"vecDocId": "Vector ID",
"close": "Close"
}
}
@@ -0,0 +1,67 @@
{
"title": "Knowledge Base Management",
"subtitle": "Manage and query knowledge base contents",
"list": {
"title": "My Knowledge Bases",
"subtitle": "Manage all your knowledge base collections",
"create": "Create Knowledge Base",
"refresh": "Refresh List",
"empty": "No knowledge bases",
"loading": "Loading...",
"documents": "Documents",
"chunks": "Chunks",
"sessionConfig": "Session Config"
},
"card": {
"edit": "Edit",
"delete": "Delete",
"open": "Open",
"docCount": "{count} Documents",
"chunkCount": "{count} Chunks"
},
"create": {
"title": "Create Knowledge Base",
"nameLabel": "Name",
"namePlaceholder": "Enter knowledge base name",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Describe the purpose of this knowledge base...",
"emojiLabel": "Icon",
"embeddingModelLabel": "Embedding Model",
"rerankModelLabel": "Rerank Model (Optional)",
"providerInfo": "Provider: {id} | Dimensions: {dimensions}",
"rerankProviderInfo": "Provider: {id}",
"cancel": "Cancel",
"submit": "Create",
"nameRequired": "Please enter knowledge base name"
},
"edit": {
"title": "Edit Knowledge Base",
"submit": "Save"
},
"delete": {
"title": "Delete Knowledge Base",
"confirmText": "Are you sure you want to delete knowledge base '{name}'?",
"warning": "This action is irreversible. All documents, chunks, and associated configurations will be permanently deleted.",
"cancel": "Cancel",
"confirm": "Delete"
},
"emoji": {
"title": "Select Icon",
"close": "Close",
"categories": {
"books": "Books & Documents",
"emotions": "Emotions & Faces",
"objects": "Objects & Tools",
"symbols": "Symbols & Signs"
}
},
"messages": {
"createSuccess": "Knowledge base created successfully",
"createFailed": "Failed to create",
"updateSuccess": "Knowledge base updated successfully",
"updateFailed": "Failed to update",
"deleteSuccess": "Knowledge base deleted successfully",
"deleteFailed": "Failed to delete",
"loadError": "Failed to load knowledge base list"
}
}
@@ -30,6 +30,7 @@
"ttsProvider": "TTS Provider",
"llmStatus": "LLM Status",
"ttsStatus": "TTS Status",
"knowledgeBase": "Knowledge Base",
"pluginManagement": "Plugin Management",
"actions": "Actions"
}
@@ -67,6 +68,34 @@
"fullSessionId": "Full Session ID",
"hint": "Custom names help you easily identify sessions. The small information icon (!) will show the actual UMO when hovering."
},
"knowledgeBase": {
"title": "Knowledge Base Configuration",
"configure": "Configure",
"selectKB": "Select Knowledge Bases",
"selectMultiple": "You can select multiple knowledge bases",
"noKBAvailable": "No knowledge bases available",
"noKBDesc": "No knowledge bases have been created yet",
"createKB": "Create Knowledge Base",
"advancedSettings": "Advanced Settings",
"topK": "Result Count",
"topKHint": "Number of results to retrieve from knowledge base",
"enableRerank": "Enable Reranking",
"enableRerankHint": "Use reranking model to improve retrieval quality",
"clearConfig": "Clear Configuration",
"save": "Save",
"cancel": "Cancel",
"loading": "Loading knowledge base configuration...",
"description": "Configure knowledge bases for this session. The session will use configured knowledge bases to enhance conversation context.",
"saveSuccess": "Knowledge base configuration saved successfully",
"saveFailed": "Failed to save knowledge base configuration",
"loadFailed": "Failed to load knowledge base configuration",
"clearSuccess": "Knowledge base configuration cleared",
"clearFailed": "Failed to clear knowledge base configuration",
"clearConfirm": "Are you sure you want to clear the knowledge base configuration for this session?"
},
"list": {
"documents": "documents"
},
"deleteConfirm": {
"message": "Are you sure you want to delete session {sessionName}?",
"warning": "This action will permanently delete all chat history and preference settings for this session (except for data linked via plugins), and this cannot be undone. Continue?"
@@ -6,7 +6,7 @@
"title": "The Alkaid Project.",
"subtitle": "AstrBot Alpha 项目",
"navigation": {
"knowledgeBase": "知识库",
"knowledgeBase": "知识库 (插件)",
"longTermMemory": "长期记忆层",
"other": "..."
}
@@ -0,0 +1,108 @@
{
"title": "知识库详情",
"backToList": "返回列表",
"tabs": {
"overview": "概览",
"documents": "文档管理",
"retrieval": "知识库检索",
"sessions": "使用会话",
"settings": "设置"
},
"overview": {
"title": "基本信息",
"name": "名称",
"description": "描述",
"emoji": "图标",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"stats": "统计信息",
"docCount": "文档数量",
"chunkCount": "分块数量",
"embeddingModel": "嵌入模型",
"rerankModel": "重排序模型",
"notSet": "未设置"
},
"documents": {
"title": "文档列表",
"upload": "上传文档",
"empty": "暂无文档",
"name": "文档名称",
"type": "类型",
"size": "大小",
"chunks": "分块数",
"createdAt": "上传时间",
"actions": "操作",
"view": "查看",
"delete": "删除",
"deleteConfirm": "确定要删除文档「{name}」吗?",
"deleteWarning": "此操作将删除文档及其所有分块,不可恢复。",
"uploading": "正在上传...",
"uploadSuccess": "文档上传成功",
"uploadFailed": "文档上传失败",
"deleteSuccess": "文档删除成功",
"deleteFailed": "文档删除失败"
},
"upload": {
"title": "上传文档",
"selectFile": "选择文件",
"dropzone": "拖放文件到这里或点击选择",
"supportedFormats": "支持的格式: TXT, PDF, Markdown",
"maxSize": "最大文件大小: 128MB",
"chunkSettings": "分块设置",
"batchSettings": "批处理设置",
"chunkSize": "分块大小",
"chunkSizeHint": "每个文本块的字符数 (默认: 512)",
"chunkOverlap": "分块重叠",
"chunkOverlapHint": "相邻文本块之间的重叠字符数 (默认: 50)",
"batchSize": "批处理大小",
"batchSizeHint": "每批处理的文本块数量 (默认: 32)",
"tasksLimit": "并发任务限制",
"tasksLimitHint": "最大并发上传任务数 (默认: 3)",
"maxRetries": "最大重试次数",
"maxRetriesHint": "上传失败任务的重试次数 (默认: 3)",
"cancel": "取消",
"submit": "上传",
"fileRequired": "请选择要上传的文件"
},
"retrieval": {
"title": "知识库检索",
"subtitle": "使用稠密检索和稀疏检索测试知识库内容",
"query": "检索查询",
"queryPlaceholder": "输入要检索的内容...",
"search": "检索",
"searching": "检索中...",
"results": "检索结果",
"noResults": "没有找到相关内容",
"tryDifferentQuery": "尝试使用不同的查询词",
"settings": "检索设置",
"topK": "返回结果数量",
"topKHint": "最多返回多少条检索结果",
"enableRerank": "启用重排序",
"enableRerankHint": "使用重排序模型提高检索质量",
"score": "相关度分数",
"document": "所属文档",
"chunk": "文本块 #{index}",
"content": "内容",
"charCount": "{count} 字符",
"searchSuccess": "检索完成,找到 {count} 条结果",
"searchFailed": "检索失败",
"queryRequired": "请输入检索查询"
},
"settings": {
"title": "知识库设置",
"basic": "基本设置",
"retrieval": "检索设置",
"chunkSize": "分块大小",
"chunkOverlap": "分块重叠",
"topKDense": "稠密检索数量",
"topKSparse": "稀疏检索数量",
"topMFinal": "最终返回数量",
"enableRerank": "启用重排序",
"embeddingProvider": "嵌入模型提供商",
"rerankProvider": "重排序模型提供商",
"save": "保存设置",
"saveSuccess": "设置保存成功",
"saveFailed": "设置保存失败",
"tips": "提示: 修改检索设置后,将影响后续的知识库查询效果。"
}
}
@@ -0,0 +1,55 @@
{
"title": "文档详情",
"backToKB": "返回知识库",
"info": {
"title": "文档信息",
"name": "文档名称",
"type": "文件类型",
"size": "文件大小",
"chunkCount": "分块数量",
"createdAt": "上传时间"
},
"chunks": {
"title": "分块列表",
"empty": "暂无分块",
"index": "序号",
"content": "内容",
"charCount": "字符数",
"actions": "操作",
"view": "查看",
"edit": "编辑",
"delete": "删除",
"preview": "预览",
"search": "搜索分块",
"searchPlaceholder": "输入关键词搜索分块内容...",
"showing": "显示",
"deleteConfirm": "确定要删除该文本块吗?",
"deleteSuccess": "文本块删除成功",
"deleteFailed": "文本块删除失败"
},
"edit": {
"title": "编辑分块",
"content": "分块内容",
"cancel": "取消",
"save": "保存",
"saveSuccess": "分块保存成功",
"saveFailed": "分块保存失败"
},
"delete": {
"title": "删除分块",
"confirmText": "确定要删除此分块吗?",
"warning": "删除后将无法恢复,可能影响知识库检索效果。",
"cancel": "取消",
"confirm": "删除",
"deleteSuccess": "分块删除成功",
"deleteFailed": "分块删除失败"
},
"view": {
"title": "分块详情",
"index": "序号",
"content": "内容",
"charCount": "字符数",
"vecDocId": "向量ID",
"close": "关闭"
}
}
@@ -0,0 +1,67 @@
{
"title": "知识库管理",
"subtitle": "统一管理和查询知识库内容",
"list": {
"title": "我的知识库",
"subtitle": "管理您的所有知识库集合",
"create": "创建知识库",
"refresh": "刷新列表",
"empty": "暂无知识库",
"loading": "正在加载...",
"documents": "文档",
"chunks": "分块",
"sessionConfig": "会话配置"
},
"card": {
"edit": "编辑",
"delete": "删除",
"open": "打开",
"docCount": "{count} 个文档",
"chunkCount": "{count} 个分块"
},
"create": {
"title": "创建知识库",
"nameLabel": "知识库名称",
"namePlaceholder": "为知识库起个名字",
"descriptionLabel": "描述",
"descriptionPlaceholder": "简单描述这个知识库的用途...",
"emojiLabel": "图标",
"embeddingModelLabel": "嵌入模型 (Embedding Model)",
"rerankModelLabel": "重排序模型 (Rerank Model, 可选)",
"providerInfo": "提供商: {id} | 维度: {dimensions}",
"rerankProviderInfo": "提供商: {id}",
"cancel": "取消",
"submit": "创建",
"nameRequired": "请输入知识库名称"
},
"edit": {
"title": "编辑知识库",
"submit": "保存"
},
"delete": {
"title": "删除知识库",
"confirmText": "确定要删除知识库「{name}」吗?",
"warning": "此操作不可逆,所有文档、分块和关联配置都将被永久删除。",
"cancel": "取消",
"confirm": "删除"
},
"emoji": {
"title": "选择图标",
"close": "关闭",
"categories": {
"books": "书籍与文档",
"emotions": "表情与情感",
"objects": "物品与工具",
"symbols": "符号与标志"
}
},
"messages": {
"createSuccess": "知识库创建成功",
"createFailed": "创建失败",
"updateSuccess": "知识库更新成功",
"updateFailed": "更新失败",
"deleteSuccess": "知识库删除成功",
"deleteFailed": "删除失败",
"loadError": "加载知识库列表失败"
}
}
@@ -30,6 +30,7 @@
"ttsProvider": "语音合成模型",
"llmStatus": "启用 LLM",
"ttsStatus": "启用 TTS",
"knowledgeBase": "知识库配置",
"pluginManagement": "插件管理",
"actions": "操作"
}
@@ -67,6 +68,34 @@
"fullSessionId": "完整会话ID",
"hint": "自定义名称帮助您轻松识别会话。当设置了自定义名称时,会显示一个小感叹号标识(!),鼠标悬停时会显示实际的UMO。"
},
"knowledgeBase": {
"title": "知识库配置",
"configure": "配置",
"selectKB": "选择知识库",
"selectMultiple": "可以选择多个知识库",
"noKBAvailable": "暂无可用的知识库",
"noKBDesc": "目前没有创建任何知识库",
"createKB": "创建知识库",
"advancedSettings": "高级配置",
"topK": "返回结果数量",
"topKHint": "从知识库检索的结果数量",
"enableRerank": "启用重排序",
"enableRerankHint": "使用重排序模型提高检索质量",
"clearConfig": "清除配置",
"save": "保存",
"cancel": "取消",
"loading": "加载知识库配置中...",
"description": "为此会话配置使用的知识库。会话将使用配置的知识库来增强对话上下文。",
"saveSuccess": "知识库配置保存成功",
"saveFailed": "保存知识库配置失败",
"loadFailed": "加载知识库配置失败",
"clearSuccess": "知识库配置已清除",
"clearFailed": "清除知识库配置失败",
"clearConfirm": "确定要清除此会话的知识库配置吗?"
},
"list": {
"documents": "篇文档"
},
"deleteConfirm": {
"message": "确定要删除会话 {sessionName} 吗?",
"warning": "此操作将永久删除本次会话的「全部对话记录」与「偏好设置」(插件对会话的关联数据除外),且无法恢复。确认继续?"
+16
View File
@@ -25,6 +25,9 @@ import zhCNDashboard from './locales/zh-CN/features/dashboard.json';
import zhCNAlkaidIndex from './locales/zh-CN/features/alkaid/index.json';
import zhCNAlkaidKnowledgeBase from './locales/zh-CN/features/alkaid/knowledge-base.json';
import zhCNAlkaidMemory from './locales/zh-CN/features/alkaid/memory.json';
import zhCNKnowledgeBaseIndex from './locales/zh-CN/features/knowledge-base/index.json';
import zhCNKnowledgeBaseDetail from './locales/zh-CN/features/knowledge-base/detail.json';
import zhCNKnowledgeBaseDocument from './locales/zh-CN/features/knowledge-base/document.json';
import zhCNPersona from './locales/zh-CN/features/persona.json';
import zhCNMigration from './locales/zh-CN/features/migration.json';
@@ -56,6 +59,9 @@ import enUSDashboard from './locales/en-US/features/dashboard.json';
import enUSAlkaidIndex from './locales/en-US/features/alkaid/index.json';
import enUSAlkaidKnowledgeBase from './locales/en-US/features/alkaid/knowledge-base.json';
import enUSAlkaidMemory from './locales/en-US/features/alkaid/memory.json';
import enUSKnowledgeBaseIndex from './locales/en-US/features/knowledge-base/index.json';
import enUSKnowledgeBaseDetail from './locales/en-US/features/knowledge-base/detail.json';
import enUSKnowledgeBaseDocument from './locales/en-US/features/knowledge-base/document.json';
import enUSPersona from './locales/en-US/features/persona.json';
import enUSMigration from './locales/en-US/features/migration.json';
@@ -93,6 +99,11 @@ export const translations = {
'knowledge-base': zhCNAlkaidKnowledgeBase,
memory: zhCNAlkaidMemory
},
'knowledge-base': {
index: zhCNKnowledgeBaseIndex,
detail: zhCNKnowledgeBaseDetail,
document: zhCNKnowledgeBaseDocument
},
persona: zhCNPersona,
migration: zhCNMigration
},
@@ -130,6 +141,11 @@ export const translations = {
'knowledge-base': enUSAlkaidKnowledgeBase,
memory: enUSAlkaidMemory
},
'knowledge-base': {
index: enUSKnowledgeBaseIndex,
detail: enUSKnowledgeBaseDetail,
document: enUSKnowledgeBaseDocument
},
persona: enUSPersona,
migration: enUSMigration
},
@@ -45,8 +45,8 @@ const sidebarItem: menu[] = [
},
{
title: 'core.navigation.knowledgeBase',
icon: 'mdi-text-box-search',
to: '/alkaid/knowledge-base',
icon: 'mdi-book-open-variant',
to: '/knowledge-base',
},
{
title: 'core.navigation.chat',
+31 -5
View File
@@ -66,6 +66,37 @@ const MainRoutes = {
path: '/console',
component: () => import('@/views/ConsolePage.vue')
},
{
name: 'NativeKnowledgeBase',
path: '/knowledge-base',
component: () => import('@/views/knowledge-base/index.vue'),
children: [
{
path: '',
name: 'NativeKBList',
component: () => import('@/views/knowledge-base/KBList.vue')
},
{
path: ':kbId',
name: 'NativeKBDetail',
component: () => import('@/views/knowledge-base/KBDetail.vue'),
props: true
},
{
path: ':kbId/document/:docId',
name: 'NativeDocumentDetail',
component: () => import('@/views/knowledge-base/DocumentDetail.vue'),
props: true
}
]
},
// 旧版本的知识库路由
{
name: 'KnowledgeBase',
path: '/alkaid/knowledge-base',
component: () => import('@/views/alkaid/KnowledgeBase.vue'),
},
// {
// name: 'Alkaid',
// path: '/alkaid',
@@ -88,11 +119,6 @@ const MainRoutes = {
// }
// ]
// },
{
name: 'KnowledgeBase',
path: '/alkaid/knowledge-base',
component: () => import('@/views/alkaid/KnowledgeBase.vue')
},
{
name: 'Chat',
path: '/chat',
+3 -3
View File
@@ -7,9 +7,9 @@
<small style="color: #a3a3a3;">{{ tm('page.subtitle') }}</small>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<div style="display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap;">
<v-btn size="large" :variant="isActive('knowledge-base') ? 'flat' : 'tonal'"
:color="isActive('knowledge-base') ? '#9b72cb' : ''" rounded="lg"
:color="isActive('knowledge-base') ? '#9b72cb' : ''" rounded="lg"
@click="navigateTo('knowledge-base')">
<v-icon start>mdi-text-box-search</v-icon>
{{ tm('page.navigation.knowledgeBase') }}
@@ -21,7 +21,7 @@
{{ tm('page.navigation.longTermMemory') }}
</v-btn>
<v-btn size="large" :variant="isActive('other') ? 'flat' : 'tonal'"
:color="isActive('other') ? '#9b72cb' : ''" rounded="lg"
:color="isActive('other') ? '#9b72cb' : ''" rounded="lg"
@click="navigateTo('other')">
<v-icon start>mdi-tools</v-icon>
{{ tm('page.navigation.other') }}
+41 -52
View File
@@ -12,7 +12,8 @@
</p>
</div>
<div>
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true" rounded="xl" size="x-large">
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true"
rounded="xl" size="x-large">
{{ tm('providers.addProvider') }}
</v-btn>
</div>
@@ -56,30 +57,16 @@
<v-row v-else>
<v-col v-for="(provider, index) in filteredProviders" :key="index" cols="12" md="6" lg="4" xl="3">
<item-card
:item="provider"
title-field="id"
enabled-field="enable"
:loading="isProviderTesting(provider.id)"
@toggle-enabled="providerStatusChange"
:bglogo="getProviderIcon(provider.provider)"
@delete="deleteProvider"
@edit="configExistingProvider"
@copy="copyProvider"
:show-copy-button="true">
<template #actions="{ item }">
<v-btn
style="z-index: 100000;"
variant="tonal"
color="info"
rounded="xl"
size="small"
:loading="isProviderTesting(item.id)"
@click="testSingleProvider(item)"
>
{{ tm('availability.test') }}
</v-btn>
</template>
<item-card :item="provider" title-field="id" enabled-field="enable"
:loading="isProviderTesting(provider.id)" @toggle-enabled="providerStatusChange"
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider"
@copy="copyProvider" :show-copy-button="true">
<template #actions="{ item }">
<v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small"
:loading="isProviderTesting(item.id)" @click="testSingleProvider(item)">
{{ tm('availability.test') }}
</v-btn>
</template>
<template v-slot:details="{ item }">
</template>
</item-card>
@@ -115,16 +102,12 @@
<v-col v-for="status in providerStatuses" :key="status.id" cols="12" sm="6" md="4">
<v-card variant="outlined" class="status-card" :class="`status-${status.status}`">
<v-card-item>
<v-icon v-if="status.status === 'available'" color="success" class="me-2">mdi-check-circle</v-icon>
<v-icon v-else-if="status.status === 'unavailable'" color="error" class="me-2">mdi-alert-circle</v-icon>
<v-progress-circular
v-else-if="status.status === 'pending'"
indeterminate
color="primary"
size="20"
width="2"
class="me-2"
></v-progress-circular>
<v-icon v-if="status.status === 'available'" color="success"
class="me-2">mdi-check-circle</v-icon>
<v-icon v-else-if="status.status === 'unavailable'" color="error"
class="me-2">mdi-alert-circle</v-icon>
<v-progress-circular v-else-if="status.status === 'pending'" indeterminate color="primary"
size="20" width="2" class="me-2"></v-progress-circular>
<span class="font-weight-bold">{{ status.id }}</span>
@@ -165,22 +148,16 @@
</v-container>
<!-- 添加提供商对话框 -->
<AddNewProvider
v-model:show="showAddProviderDialog"
:metadata="metadata"
@select-template="selectProviderTemplate"
/>
<AddNewProvider v-model:show="showAddProviderDialog" :metadata="metadata"
@select-template="selectProviderTemplate" />
<!-- 配置对话框 -->
<v-dialog v-model="showProviderCfg" width="900" persistent>
<v-card :title="updatingMode ? tm('dialogs.config.editTitle') : tm('dialogs.config.addTitle') + ` ${newSelectedProviderName} ` + tm('dialogs.config.provider')">
<v-card
:title="updatingMode ? tm('dialogs.config.editTitle') : tm('dialogs.config.addTitle') + ` ${newSelectedProviderName} ` + tm('dialogs.config.provider')">
<v-card-text class="py-4">
<AstrBotConfig
:iterable="newSelectedProviderConfig"
:metadata="metadata['provider_group']?.metadata"
metadataKey="provider"
:is-editing="updatingMode"
/>
<AstrBotConfig :iterable="newSelectedProviderConfig" :metadata="metadata['provider_group']?.metadata"
metadataKey="provider" :is-editing="updatingMode" />
</v-card-text>
<v-divider></v-divider>
@@ -467,7 +444,7 @@ export default {
for (let key in source) {
if (source.hasOwnProperty(key)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = Array.isArray(source[key]) ? [...source[key]] : {...source[key]};
target[key] = Array.isArray(source[key]) ? [...source[key]] : { ...source[key] };
} else {
target[key] = source[key];
}
@@ -528,7 +505,14 @@ export default {
id: this.newSelectedProviderName,
config: this.newSelectedProviderConfig
});
if (res.data.status === 'error') {
this.showError(res.data.message || "更新失败!");
return
}
this.showSuccess(res.data.message || "更新成功!");
if (wasUpdating) {
this.updatingMode = false;
}
} else {
// 检查 ID 是否已存在
const existingProvider = this.config_data.provider?.find(p => p.id === this.newSelectedProviderConfig.id);
@@ -541,17 +525,18 @@ export default {
}
const res = await axios.post('/api/config/provider/new', this.newSelectedProviderConfig);
if (res.data.status === 'error') {
this.showError(res.data.message || "添加失败!");
return
}
this.showSuccess(res.data.message || "添加成功!");
}
this.showProviderCfg = false;
this.getConfig();
} catch (err) {
this.showError(err.response?.data?.message || err.message);
} finally {
this.loading = false;
if (wasUpdating) {
this.updatingMode = false;
}
this.getConfig();
}
},
@@ -607,6 +592,10 @@ export default {
id: provider.id,
config: provider
}).then((res) => {
if (res.data.status === 'error') {
this.showError(res.data.message)
return
}
this.getConfig();
this.showSuccess(res.data.message || this.messages.success.statusUpdate);
}).catch((err) => {
+321 -2
View File
@@ -142,6 +142,14 @@
</v-checkbox>
</template>
<!-- 知识库配置 -->
<template v-slot:item.knowledge_base="{ item }">
<v-btn size="x-small" variant="tonal" color="info" @click="openKBManager(item)"
:loading="item.loadingKB" :disabled="!item.session_enabled">
{{ tm('knowledgeBase.configure') }}
</v-btn>
</template>
<!-- 插件管理 -->
<template v-slot:item.plugins="{ item }">
<v-btn size="x-small" variant="tonal" color="primary" @click="openPluginManager(item)"
@@ -335,6 +343,125 @@
</v-card>
</v-dialog>
<!-- 知识库配置对话框 -->
<v-dialog v-model="kbDialog" max-width="800" min-height="60%" @update:model-value="(val) => { if (!val) closeKBDialog(); }">
<v-card v-if="selectedSessionForKB">
<v-card-title class="bg-primary text-white py-3 px-4" style="display: flex; align-items: center;">
<span>{{ tm('knowledgeBase.title') }} - {{ selectedSessionForKB.session_name }}</span>
<v-spacer></v-spacer>
<v-btn icon variant="text" color="white" @click="closeKBDialog()">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text v-if="!loadingKBConfig">
<div style="padding: 16px;">
<v-alert type="info" variant="tonal" class="mb-4">
{{ tm('knowledgeBase.description') }}
</v-alert>
<!-- 知识库选择区域 -->
<div class="mb-4">
<div class="text-subtitle-2 mb-2">{{ tm('knowledgeBase.selectKB') }}</div>
<v-card variant="outlined" class="pa-3">
<div v-if="availableKBs.length === 0" class="text-body-2 text-medium-emphasis">
{{ tm('knowledgeBase.noKBAvailable') || '暂无可用知识库' }}
</div>
<div v-else class="kb-selector-list">
<v-checkbox
v-for="kb in availableKBs"
:key="kb.kb_id"
:value="kb.kb_id"
v-model="sessionKBConfig.kb_ids"
hide-details
density="compact"
class="mb-1"
>
<template v-slot:label>
<div class="d-flex align-center">
<span style="font-size: 18px; margin-right: 8px;">{{ kb.emoji }}</span>
<div>
<div class="text-body-2">{{ kb.kb_name }}</div>
<div class="text-caption text-medium-emphasis">
{{ kb.description || tm('knowledgeBase.noKBDesc') }} - {{ kb.doc_count }} {{ tm('list.documents', { count: kb.doc_count }) }}
</div>
</div>
</div>
</template>
</v-checkbox>
</div>
</v-card>
<div class="text-caption text-medium-emphasis mt-2">
{{ tm('knowledgeBase.selectMultiple') }}
</div>
</div>
<!-- 高级配置 -->
<v-expansion-panels class="mb-4">
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon class="mr-2">mdi-cog</v-icon>
{{ tm('knowledgeBase.advancedSettings') }}
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model.number="sessionKBConfig.top_k"
:label="tm('knowledgeBase.topK')"
type="number"
variant="outlined"
density="comfortable"
:hint="tm('knowledgeBase.topKHint')"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-checkbox
v-model="sessionKBConfig.enable_rerank"
:label="tm('knowledgeBase.enableRerank')"
color="primary"
:hint="tm('knowledgeBase.enableRerankHint')"
persistent-hint
/>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<div v-if="availableKBs.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-2">mdi-database-off</v-icon>
<p class="mt-4 text-medium-emphasis">{{ tm('knowledgeBase.noKBAvailable') }}</p>
<v-btn color="primary" variant="tonal" class="mt-2" @click="goToKBPage">
{{ tm('knowledgeBase.createKB') }}
</v-btn>
</div>
</div>
</v-card-text>
<v-card-text v-else class="text-center py-8">
<v-progress-circular indeterminate color="primary" size="48"></v-progress-circular>
<div class="text-body-1 mt-4">{{ tm('knowledgeBase.loading') }}</div>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-btn variant="text" @click="clearKBConfig" :disabled="savingKBConfig || loadingKBConfig">
{{ tm('knowledgeBase.clearConfig') }}
</v-btn>
<v-spacer />
<v-btn variant="text" @click="closeKBDialog()" :disabled="savingKBConfig">
{{ tm('knowledgeBase.cancel') }}
</v-btn>
<v-btn color="primary" variant="tonal" @click="saveKBConfig" :loading="savingKBConfig">
{{ tm('knowledgeBase.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 提示信息 -->
<v-snackbar v-model="snackbar" :timeout="3000" elevation="24" :color="snackbarColor" location="top">
{{ snackbarText }}
@@ -399,6 +526,18 @@ export default {
newSessionName: '',
nameEditLoading: false,
// 知识库管理
kbDialog: false,
selectedSessionForKB: null,
sessionKBConfig: {
kb_ids: [],
top_k: 5,
enable_rerank: true
},
availableKBs: [],
loadingKBConfig: false,
savingKBConfig: false,
// 提示信息
snackbar: false,
snackbarText: '',
@@ -432,6 +571,7 @@ export default {
{ title: this.tm('table.headers.ttsProvider'), key: 'tts_provider', sortable: false, minWidth: '200px' },
{ title: this.tm('table.headers.llmStatus'), key: 'llm_enabled', sortable: false, minWidth: '120px' },
{ title: this.tm('table.headers.ttsStatus'), key: 'tts_enabled', sortable: false, minWidth: '120px' },
{ title: this.tm('table.headers.knowledgeBase'), key: 'knowledge_base', sortable: false, minWidth: '150px' },
{ title: this.tm('table.headers.pluginManagement'), key: 'plugins', sortable: false, minWidth: '120px' },
{ title: this.tm('table.headers.actions'), key: 'actions', sortable: false, minWidth: '100px' },
]
@@ -503,7 +643,8 @@ export default {
...session,
updating: false, // 添加更新状态标志
loadingPlugins: false, // 添加插件加载状态标志
deleting: false // 添加删除状态标志
deleting: false, // 添加删除状态标志
loadingKB: false // 添加知识库加载状态标志
}));
this.availablePersonas = data.available_personas;
this.availableChatProviders = data.available_chat_providers;
@@ -964,15 +1105,193 @@ export default {
this.currentPage = 1; // 重置到第一页
this.loadSessions();
},
// 知识库配置相关方法
async openKBManager(session) {
this.selectedSessionForKB = session;
// ✅ 先重置配置为默认值,避免数据残留
this.sessionKBConfig = {
kb_ids: [],
top_k: 5,
enable_rerank: true
};
this.kbDialog = true;
this.loadingKBConfig = true;
try {
// 加载可用的知识库列表
const kbListResponse = await axios.get('/api/kb/list');
if (kbListResponse.data.status === 'ok') {
this.availableKBs = kbListResponse.data.data.items;
}
// 加载当前会话的知识库配置
const configResponse = await axios.get('/api/kb/session/config/get', {
params: { session_id: session.session_id }
});
if (configResponse.data.status === 'ok') {
const config = configResponse.data.data;
// 初始化配置
this.sessionKBConfig = {
kb_ids: [],
top_k: config.top_k || 5,
enable_rerank: config.enable_rerank !== false
};
// 如果有配置的 kb_ids,进行清理后赋值
if (config.kb_ids && Array.isArray(config.kb_ids)) {
this.sessionKBConfig.kb_ids = config.kb_ids.filter(id => id != null && id !== '');
}
} else {
// 保持默认值(已在开头设置)
this.sessionKBConfig = {
kb_ids: [],
top_k: 5,
enable_rerank: true
};
}
} catch (error) {
console.error('加载知识库配置失败:', error);
this.showError(this.tm('knowledgeBase.loadFailed'));
} finally {
this.loadingKBConfig = false;
}
},
async saveKBConfig() {
if (!this.selectedSessionForKB) return;
// 确保 kb_ids 是一个干净的数组
const cleanKbIds = Array.isArray(this.sessionKBConfig.kb_ids)
? this.sessionKBConfig.kb_ids.filter(id => id != null && id !== '')
: [];
this.savingKBConfig = true;
try {
const payload = {
scope: 'session',
scope_id: this.selectedSessionForKB.session_id,
kb_ids: cleanKbIds, // 使用清理后的数组
top_k: this.sessionKBConfig.top_k,
enable_rerank: this.sessionKBConfig.enable_rerank
};
const response = await axios.post('/api/kb/session/config/set', payload);
if (response.data.status === 'ok') {
this.showSuccess(this.tm('knowledgeBase.saveSuccess'));
this.kbDialog = false;
// ✅ 保存成功后清理数据
this.sessionKBConfig = {
kb_ids: [],
top_k: 5,
enable_rerank: true
};
this.selectedSessionForKB = null;
} else {
this.showError(response.data.message || this.tm('knowledgeBase.saveFailed'));
}
} catch (error) {
console.error('保存知识库配置失败:', error);
this.showError(error.response?.data?.message || this.tm('knowledgeBase.saveFailed'));
} finally {
this.savingKBConfig = false;
}
},
// 关闭知识库配置对话框并清理数据
closeKBDialog() {
this.kbDialog = false;
// 清理数据,避免下次打开时出现旧数据
this.sessionKBConfig = {
kb_ids: [],
top_k: 5,
enable_rerank: true
};
this.selectedSessionForKB = null;
this.availableKBs = [];
},
async clearKBConfig() {
if (!this.selectedSessionForKB) return;
if (!confirm(this.tm('knowledgeBase.clearConfirm'))) {
return;
}
this.savingKBConfig = true;
try {
const response = await axios.post('/api/kb/session/config/delete', {
scope: 'session',
scope_id: this.selectedSessionForKB.session_id
});
if (response.data.status === 'ok') {
this.showSuccess(this.tm('knowledgeBase.clearSuccess'));
this.sessionKBConfig = {
kb_ids: [],
top_k: 5,
enable_rerank: true
};
} else {
this.showError(response.data.message || this.tm('knowledgeBase.clearFailed'));
}
} catch (error) {
console.error('清除知识库配置失败:', error);
this.showError(error.response?.data?.message || this.tm('knowledgeBase.clearFailed'));
} finally {
this.savingKBConfig = false;
}
},
goToKBPage() {
this.$router.push('/knowledge-base');
},
},
}
</script>
<style scoped>
.v-data-table>>>.v-data-table__td {
.v-data-table :deep(.v-data-table__td) {
padding: 8px 16px !important;
vertical-align: middle !important;
}
/* 知识库选择列表滚动条样式 */
.kb-selector-list {
max-height: 150px;
overflow-y: auto;
padding-right: 8px;
}
/* 自定义滚动条样式 - Webkit浏览器 */
.kb-selector-list::-webkit-scrollbar {
width: 8px;
}
.kb-selector-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.kb-selector-list::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.kb-selector-list::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Firefox滚动条样式 */
.kb-selector-list {
scrollbar-width: thin;
scrollbar-color: #888 #f1f1f1;
}
</style>
+11 -6
View File
@@ -1,6 +1,11 @@
<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">
<v-banner lines="one">
<template v-slot:text>
建议您更换使用新版知识库功能。
</template>
</v-banner>
<!-- knowledge card -->
<div v-if="!installed" class="d-flex align-center justify-center flex-column"
style="flex-grow: 1; width: 100%; height: 100%;">
@@ -105,9 +110,9 @@
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="error" variant="text" @click="showCreateDialog = false">{{ tm('createDialog.cancel')
}}</v-btn>
}}</v-btn>
<v-btn color="primary" variant="text" @click="submitCreateForm">{{ tm('createDialog.create')
}}</v-btn>
}}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -132,7 +137,7 @@
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="showEmojiPicker = false">{{ tm('emojiPicker.close')
}}</v-btn>
}}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -159,8 +164,8 @@
<v-chip v-if="currentKB.rerank_provider_id" color="tertiary" variant="tonal" size="small"
rounded="sm">
<v-icon start size="small">mdi-sort-variant</v-icon>
重排序模型: {{ rerankProviderConfigs.
find(provider => provider.id === currentKB.rerank_provider_id)?.rerank_model || '未设置' }}
重排序模型: {{rerankProviderConfigs.
find(provider => provider.id === currentKB.rerank_provider_id)?.rerank_model || '未设置'}}
</v-chip>
<small style="margin-left: 8px;">💡 使用方式: 在聊天页中输入 "/kb use {{ currentKB.collection_name }}"</small>
</div>
@@ -411,7 +416,7 @@
<v-spacer></v-spacer>
<v-btn color="grey-darken-1" variant="text" @click="showDeleteDialog = false">{{
tm('deleteDialog.cancel')
}}</v-btn>
}}</v-btn>
<v-btn color="error" variant="text" @click="deleteKnowledgeBase" :loading="deleting">{{
tm('deleteDialog.delete') }}</v-btn>
</v-card-actions>
@@ -0,0 +1,512 @@
<template>
<div class="document-detail-page">
<!-- 页面头部 -->
<div class="page-header">
<v-btn
icon="mdi-arrow-left"
variant="text"
@click="$router.push({ name: 'NativeKBDetail', params: { kbId } })"
/>
<div class="header-content">
<h1 class="text-h4">{{ document.doc_name }}</h1>
<p class="text-subtitle-1 text-medium-emphasis mt-2">{{ t('title') }}</p>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<v-progress-circular indeterminate color="primary" size="64" />
</div>
<!-- 主内容 -->
<div v-else class="document-content">
<!-- 文档信息卡片 -->
<v-card elevation="2" class="mb-6">
<v-card-title>{{ t('info.title') }}</v-card-title>
<v-divider />
<v-card-text>
<v-row>
<v-col cols="12" md="3">
<div class="info-item">
<v-icon start>mdi-label</v-icon>
<div>
<div class="text-caption text-medium-emphasis">{{ t('info.name') }}</div>
<div class="text-body-1">{{ document.doc_name }}</div>
</div>
</div>
</v-col>
<v-col cols="12" md="2">
<div class="info-item">
<v-icon start :color="getFileColor(document.file_type)">
{{ getFileIcon(document.file_type) }}
</v-icon>
<div>
<div class="text-caption text-medium-emphasis">{{ t('info.type') }}</div>
<div class="text-body-1">{{ document.file_type || '-' }}</div>
</div>
</div>
</v-col>
<v-col cols="12" md="2">
<div class="info-item">
<v-icon start>mdi-file-chart</v-icon>
<div>
<div class="text-caption text-medium-emphasis">{{ t('info.size') }}</div>
<div class="text-body-1">{{ formatFileSize(document.file_size) }}</div>
</div>
</div>
</v-col>
<v-col cols="12" md="2">
<div class="info-item">
<v-icon start>mdi-text-box</v-icon>
<div>
<div class="text-caption text-medium-emphasis">{{ t('info.chunkCount') }}</div>
<div class="text-body-1">{{ document.chunk_count || 0 }}</div>
</div>
</div>
</v-col>
<v-col cols="12" md="3">
<div class="info-item">
<v-icon start>mdi-calendar</v-icon>
<div>
<div class="text-caption text-medium-emphasis">{{ t('info.createdAt') }}</div>
<div class="text-body-1">{{ formatDate(document.created_at) }}</div>
</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- 分块列表 -->
<v-card elevation="2">
<v-card-title class="d-flex align-center pa-4">
<span>{{ t('chunks.title') }}</span>
<v-chip class="ml-2" size="small" variant="tonal">
{{ totalChunks }} {{ t('chunks.title') }}
</v-chip>
<v-spacer />
<!-- <v-text-field
v-model="searchQuery"
prepend-inner-icon="mdi-magnify"
:placeholder="t('chunks.searchPlaceholder')"
variant="outlined"
density="compact"
hide-details
clearable
style="max-width: 300px"
/> -->
</v-card-title>
<v-divider />
<v-card-text class="pa-0">
<v-data-table
:headers="headers"
:items="filteredChunks"
:loading="loadingChunks"
:items-per-page="pageSize"
hide-default-footer
>
<template #item.chunk_index="{ item }">
<v-chip size="small" variant="tonal" color="primary">
#{{ item.chunk_index + 1 }}
</v-chip>
</template>
<template #item.content="{ item }">
<div class="chunk-content-preview">
{{ item.content }}
</div>
</template>
<template #item.char_count="{ item }">
<v-chip size="small" variant="outlined">
{{ item.char_count }} 字符
</v-chip>
</template>
<template #item.actions="{ item }">
<v-btn
icon="mdi-eye"
variant="text"
size="small"
color="info"
@click="viewChunk(item)"
/>
<!-- 删除 -->
<v-btn
icon="mdi-delete"
variant="text"
size="small"
color="error"
@click="deleteChunk(item)"
/>
</template>
<template #no-data>
<div class="text-center py-8">
<v-icon size="64" color="grey-lighten-2">mdi-text-box-outline</v-icon>
<p class="mt-4 text-medium-emphasis">{{ t('chunks.empty') }}</p>
</div>
</template>
</v-data-table>
<!-- 自定义分页器 -->
<div v-if="!searchQuery && totalChunks > 0" class="pa-4 d-flex align-center justify-space-between">
<div class="text-caption text-medium-emphasis">
{{ t('chunks.showing') }} {{ (page - 1) * pageSize + 1 }} - {{ Math.min(page * pageSize, totalChunks) }} / {{ totalChunks }}
</div>
<div class="d-flex align-center gap-2">
<v-select
v-model="pageSize"
:items="[10, 25, 50, 100]"
density="compact"
variant="outlined"
hide-details
style="width: 100px"
@update:model-value="handlePageSizeChange"
/>
<v-pagination
v-model="page"
:length="Math.ceil(totalChunks / pageSize)"
:total-visible="5"
@update:model-value="handlePageChange"
/>
</div>
</div>
</v-card-text>
</v-card>
</div>
<!-- 查看分块对话框 -->
<v-dialog v-model="showViewDialog" max-width="800px" scrollable>
<v-card>
<v-card-title class="pa-4">
<span>{{ t('view.title') }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="showViewDialog = false" />
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<v-list density="comfortable">
<v-list-item>
<template #prepend>
<v-icon>mdi-pound</v-icon>
</template>
<v-list-item-title>{{ t('view.index') }}</v-list-item-title>
<v-list-item-subtitle>#{{ (selectedChunk?.chunk_index || 0) + 1 }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-text</v-icon>
</template>
<v-list-item-title>{{ t('view.charCount') }}</v-list-item-title>
<v-list-item-subtitle>{{ selectedChunk?.char_count || 0 }} 字符</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-key</v-icon>
</template>
<v-list-item-title>{{ t('view.vecDocId') }}</v-list-item-title>
<v-list-item-subtitle>{{ selectedChunk?.chunk_id || '-' }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-divider class="my-4" />
<div class="text-caption text-medium-emphasis mb-2">{{ t('view.content') }}</div>
<div class="chunk-content-view">
{{ selectedChunk?.content }}
</div>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="showViewDialog = false">
{{ t('view.close') }}
</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 setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const { tm: t } = useModuleI18n('features/knowledge-base/document')
const route = useRoute()
const kbId = ref(route.params.kbId as string)
const docId = ref(route.params.docId as string)
// 状态
const loading = ref(true)
const loadingChunks = ref(false)
const document = ref<any>({})
const chunks = ref<any[]>([])
const searchQuery = ref('')
const showViewDialog = ref(false)
const selectedChunk = ref<any>(null)
// 分页状态
const page = ref(1)
const pageSize = ref(10)
const totalChunks = ref(0)
const snackbar = ref({
show: false,
text: '',
color: 'success'
})
const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.text = text
snackbar.value.color = color
snackbar.value.show = true
}
// 表格列
const headers = [
{ title: t('chunks.index'), key: 'chunk_index', width: 100 },
{ title: t('chunks.content'), key: 'content', sortable: false },
{ title: t('chunks.charCount'), key: 'char_count', width: 150 },
{ title: t('chunks.actions'), key: 'actions', sortable: false, width: 150 }
]
// 过滤分块
const filteredChunks = computed(() => {
if (!searchQuery.value) return chunks.value
const query = searchQuery.value.toLowerCase()
return chunks.value.filter(chunk =>
chunk.content.toLowerCase().includes(query)
)
})
// 加载文档详情
const loadDocument = async () => {
loading.value = true
try {
const response = await axios.get('/api/kb/document/get', {
params: { doc_id: docId.value, kb_id: kbId.value }
})
if (response.data.status === 'ok') {
document.value = response.data.data
}
} catch (error) {
console.error('Failed to load document:', error)
showSnackbar('加载文档详情失败', 'error')
} finally {
loading.value = false
}
}
// 加载分块列表
const loadChunks = async () => {
loadingChunks.value = true
try {
const response = await axios.get('/api/kb/chunk/list', {
params: {
doc_id: docId.value,
kb_id: kbId.value,
page: page.value,
page_size: pageSize.value
}
})
if (response.data.status === 'ok') {
chunks.value = response.data.data.items || []
totalChunks.value = response.data.data.total || 0
}
} catch (error) {
console.error('Failed to load chunks:', error)
showSnackbar('加载分块列表失败', 'error')
} finally {
loadingChunks.value = false
}
}
// 处理分页变化
const handlePageChange = (newPage: number) => {
page.value = newPage
loadChunks()
}
const handlePageSizeChange = (newPageSize: number) => {
pageSize.value = newPageSize
page.value = 1
loadChunks()
}
// 查看分块
const viewChunk = (chunk: any) => {
selectedChunk.value = chunk
showViewDialog.value = true
}
// 删除分块
const deleteChunk = async (chunk: any) => {
if (!confirm(t('chunks.deleteConfirm'))) return
try {
const response = await axios.post('/api/kb/chunk/delete', {
chunk_id: chunk.chunk_id,
doc_id: docId.value,
kb_id: kbId.value
})
if (response.data.status === 'ok') {
showSnackbar(t('chunks.deleteSuccess'))
loadChunks()
} else {
showSnackbar(t('chunks.deleteFailed'), 'error')
}
} catch (error) {
console.error('Failed to delete chunk:', error)
showSnackbar(t('chunks.deleteFailed'), 'error')
}
}
// 工具函数
const getFileIcon = (fileType: string) => {
const type = fileType?.toLowerCase() || ''
if (type.includes('pdf')) return 'mdi-file-pdf-box'
if (type.includes('md')) return 'mdi-language-markdown'
if (type.includes('txt')) return 'mdi-file-document-outline'
return 'mdi-file'
}
const getFileColor = (fileType: string) => {
const type = fileType?.toLowerCase() || ''
if (type.includes('pdf')) return 'error'
if (type.includes('md')) return 'info'
if (type.includes('txt')) return 'success'
return 'grey'
}
const formatFileSize = (bytes: number) => {
if (!bytes) return '-'
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(2)} ${units[unitIndex]}`
}
const formatDate = (dateStr: string) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadDocument()
loadChunks()
})
</script>
<style scoped>
.document-detail-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.page-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 32px;
}
.header-content {
flex: 1;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
}
.document-content {
animation: slideUp 0.4s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.info-item {
display: flex;
gap: 12px;
align-items: flex-start;
}
.chunk-content-preview {
max-width: 400px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.875rem;
line-height: 1.5;
}
.chunk-content-view {
padding: 16px;
background: rgba(var(--v-theme-surface-variant), 0.3);
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
font-family: 'Consolas', 'Monaco', monospace;
}
.gap-2 {
gap: 8px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.document-detail-page {
padding: 16px;
}
}
</style>
@@ -0,0 +1,351 @@
<template>
<div class="kb-detail-page">
<!-- 页面头部 -->
<div class="page-header">
<v-btn
icon="mdi-arrow-left"
variant="text"
@click="$router.push({ name: 'NativeKBList' })"
/>
<div class="header-content">
<div class="kb-title">
<span class="kb-emoji">{{ kb.emoji || '📚' }}</span>
<h1 class="text-h4">{{ kb.kb_name }}</h1>
</div>
<p v-if="kb.description" class="text-subtitle-1 text-medium-emphasis mt-2">
{{ kb.description }}
</p>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<v-progress-circular indeterminate color="primary" size="64" />
</div>
<!-- 主内容 -->
<div v-else class="kb-content">
<!-- 标签页 -->
<v-tabs v-model="activeTab" class="mb-6" color="primary">
<v-tab value="overview">
<v-icon start>mdi-information-outline</v-icon>
{{ t('tabs.overview') }}
</v-tab>
<v-tab value="documents">
<v-icon start>mdi-file-document-multiple</v-icon>
{{ t('tabs.documents') }}
<v-chip class="ml-2" size="small" variant="tonal">{{ kb.doc_count || 0 }}</v-chip>
</v-tab>
<v-tab value="retrieval">
<v-icon start>mdi-magnify</v-icon>
{{ t('tabs.retrieval') }}
</v-tab>
<v-tab value="settings">
<v-icon start>mdi-cog</v-icon>
{{ t('tabs.settings') }}
</v-tab>
</v-tabs>
<!-- 标签页内容 -->
<v-window v-model="activeTab" style="padding: 8px;">
<!-- 概览 -->
<v-window-item value="overview">
<v-row>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title>{{ t('overview.title') }}</v-card-title>
<v-divider />
<v-card-text>
<v-list density="comfortable">
<v-list-item>
<template #prepend>
<v-icon>mdi-label</v-icon>
</template>
<v-list-item-title>{{ t('overview.name') }}</v-list-item-title>
<v-list-item-subtitle>{{ kb.kb_name }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="kb.description">
<template #prepend>
<v-icon>mdi-text</v-icon>
</template>
<v-list-item-title>{{ t('overview.description') }}</v-list-item-title>
<v-list-item-subtitle>{{ kb.description }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-emoticon</v-icon>
</template>
<v-list-item-title>{{ t('overview.emoji') }}</v-list-item-title>
<v-list-item-subtitle>{{ kb.emoji || '📚' }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-calendar-plus</v-icon>
</template>
<v-list-item-title>{{ t('overview.createdAt') }}</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(kb.created_at) }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-calendar-edit</v-icon>
</template>
<v-list-item-title>{{ t('overview.updatedAt') }}</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(kb.updated_at) }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2" class="mb-4">
<v-card-title>{{ t('overview.stats') }}</v-card-title>
<v-divider />
<v-card-text>
<v-row>
<v-col cols="6">
<div class="stat-box">
<v-icon size="48" color="primary">mdi-file-document</v-icon>
<div class="stat-value">{{ kb.doc_count || 0 }}</div>
<div class="stat-label">{{ t('overview.docCount') }}</div>
</div>
</v-col>
<v-col cols="6">
<div class="stat-box">
<v-icon size="48" color="secondary">mdi-text-box</v-icon>
<div class="stat-value">{{ kb.chunk_count || 0 }}</div>
<div class="stat-label">{{ t('overview.chunkCount') }}</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
<v-card elevation="2">
<v-card-title>{{ t('overview.embeddingModel') }}</v-card-title>
<v-divider />
<v-card-text>
<v-list density="comfortable">
<v-list-item>
<template #prepend>
<v-icon>mdi-vector-point</v-icon>
</template>
<v-list-item-title>{{ t('overview.embeddingModel') }}</v-list-item-title>
<v-list-item-subtitle>{{ kb.embedding_provider_id || t('overview.notSet') }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-sort-ascending</v-icon>
</template>
<v-list-item-title>{{ t('overview.rerankModel') }}</v-list-item-title>
<v-list-item-subtitle>{{ kb.rerank_provider_id || t('overview.notSet') }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-window-item>
<!-- 文档管理 -->
<v-window-item value="documents">
<DocumentsTab :kb-id="kbId" :kb="kb" @refresh="loadKB" />
</v-window-item>
<!-- 知识库检索 -->
<v-window-item value="retrieval">
<RetrievalTab :kb-id="kbId" :kb-name="kb.kb_name"/>
</v-window-item>
<!-- 设置 -->
<v-window-item value="settings">
<SettingsTab :kb="kb" @updated="loadKB" />
</v-window-item>
</v-window>
</div>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
import DocumentsTab from './components/DocumentsTab.vue'
import RetrievalTab from './components/RetrievalTab.vue'
import SettingsTab from './components/SettingsTab.vue'
const { tm: t } = useModuleI18n('features/knowledge-base/detail')
const route = useRoute()
const kbId = ref(route.params.kbId as string)
const loading = ref(true)
const activeTab = ref('overview')
const kb = ref<any>({})
const snackbar = ref({
show: false,
text: '',
color: 'success'
})
const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.text = text
snackbar.value.color = color
snackbar.value.show = true
}
// 加载知识库详情
const loadKB = async () => {
loading.value = true
try {
const response = await axios.get('/api/kb/get', {
params: { kb_id: kbId.value }
})
if (response.data.status === 'ok') {
kb.value = response.data.data
} else {
showSnackbar(response.data.message || '加载失败', 'error')
}
} catch (error) {
console.error('Failed to load knowledge base:', error)
showSnackbar('加载知识库详情失败', 'error')
} finally {
loading.value = false
}
}
// 格式化日期
const formatDate = (dateStr: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadKB()
})
</script>
<style scoped>
.kb-detail-page {
max-width: 1400px;
margin: 0 auto;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.page-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 32px;
}
.header-content {
flex: 1;
}
.kb-title {
display: flex;
align-items: center;
gap: 16px;
}
.kb-emoji {
font-size: 48px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
}
.kb-content {
animation: slideUp 0.4s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stat-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px;
text-align: center;
border-radius: 12px;
background: rgba(var(--v-theme-surface-variant), 0.1);
transition: all 0.3s ease;
}
.stat-box:hover {
background: rgba(var(--v-theme-surface-variant), 0.5);
}
.stat-value {
font-size: 2rem;
font-weight: 600;
margin-top: 8px;
}
.stat-label {
font-size: 0.875rem;
margin-top: 4px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.kb-title {
flex-direction: column;
align-items: flex-start;
}
.kb-emoji {
font-size: 36px;
}
}
</style>
@@ -0,0 +1,615 @@
<template>
<div class="kb-list-page">
<!-- 页面标题 -->
<div class="page-header">
<div>
<h1 class="text-h4 mb-2">{{ t('list.title') }}</h1>
<p class="text-subtitle-1 text-medium-emphasis">{{ t('list.subtitle') }}</p>
</div>
<v-btn icon="mdi-information-outline" variant="text" size="small" color="grey"
href="https://astrbot.app/use/knowledge-base.html" target="_blank" />
</div>
<!-- 操作按钮栏 -->
<div class="action-bar mb-6">
<v-btn prepend-icon="mdi-plus" color="primary" variant="elevated" @click="showCreateDialog = true">
{{ t('list.create') }}
</v-btn>
<v-btn prepend-icon="mdi-refresh" variant="tonal" @click="loadKnowledgeBases" :loading="loading">
{{ t('list.refresh') }}
</v-btn>
</div>
<!-- 知识库网格 -->
<div v-if="loading && kbList.length === 0" class="loading-container">
<v-progress-circular indeterminate color="primary" size="64" />
<p class="mt-4 text-medium-emphasis">{{ t('list.loading') }}</p>
</div>
<div v-else-if="kbList.length > 0" class="kb-grid">
<v-card v-for="kb in kbList" :key="kb.kb_id" class="kb-card" elevation="2" hover
@click="navigateToDetail(kb.kb_id)">
<div class="kb-card-content">
<div class="kb-emoji">{{ kb.emoji || '📚' }}</div>
<h3 class="kb-name">{{ kb.kb_name }}</h3>
<p class="kb-description text-medium-emphasis">{{ kb.description || '暂无描述' }}</p>
<div class="kb-stats mt-4">
<div class="stat-item">
<v-icon size="small" color="primary">mdi-file-document</v-icon>
<span>{{ kb.doc_count || 0 }} {{ t('list.documents') }}</span>
</div>
<div class="stat-item">
<v-icon size="small" color="secondary">mdi-text-box</v-icon>
<span>{{ kb.chunk_count || 0 }} {{ t('list.chunks') }}</span>
</div>
</div>
<div class="kb-actions">
<v-btn icon="mdi-pencil" size="small" variant="text" color="info" @click.stop="editKB(kb)" />
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="confirmDelete(kb)" />
</div>
</div>
</v-card>
</div>
<!-- 空状态 -->
<div v-else class="empty-state">
<v-icon size="100" color="grey-lighten-2">mdi-book-open-variant</v-icon>
<h2 class="mt-4">{{ t('list.empty') }}</h2>
<v-btn class="mt-6" prepend-icon="mdi-plus" color="primary" variant="elevated" size="large"
@click="showCreateDialog = true">
{{ t('list.create') }}
</v-btn>
</div>
<!-- 创建/编辑对话框 -->
<v-dialog v-model="showCreateDialog" max-width="600px" persistent>
<v-card>
<v-card-title class="d-flex align-center">
<span class="text-h5">{{ editingKB ? t('edit.title') : t('create.title') }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="closeCreateDialog" />
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<!-- Emoji 选择器 -->
<div class="text-center mb-6">
<div class="emoji-display" @click="showEmojiPicker = true">
{{ formData.emoji }}
</div>
<p class="text-caption text-medium-emphasis mt-2">{{ t('create.emojiLabel') }}</p>
</div>
<!-- 表单 -->
<v-form ref="formRef" @submit.prevent="submitForm">
<v-text-field v-model="formData.kb_name" :label="t('create.nameLabel')"
:placeholder="t('create.namePlaceholder')" variant="outlined"
:rules="[v => !!v || t('create.nameRequired')]" required class="mb-4" hint="后续如修改知识库名称,需重新在配置文件更新。" persistent-hint />
<v-textarea v-model="formData.description" :label="t('create.descriptionLabel')"
:placeholder="t('create.descriptionPlaceholder')" variant="outlined" rows="3" class="mb-4" />
<v-select v-model="formData.embedding_provider_id" :items="embeddingProviders"
:item-title="item => item.embedding_model || item.id" :item-value="'id'"
:label="t('create.embeddingModelLabel')" variant="outlined" class="mb-4" :disabled="editingKB !== null" hint="嵌入模型选择后无法修改,如需更换请创建新的知识库。" persistent-hint>
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #subtitle>
{{ t('create.providerInfo', {
id: item.raw.id,
dimensions: item.raw.embedding_dimensions || 'N/A'
}) }}
</template>
</v-list-item>
</template>
</v-select>
<v-select v-model="formData.rerank_provider_id" :items="rerankProviders"
:item-title="item => item.rerank_model || item.id" :item-value="'id'"
:label="t('create.rerankModelLabel')" variant="outlined" clearable class="mb-2">
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #subtitle>
{{ t('create.rerankProviderInfo', { id: item.raw.id }) }}
</template>
</v-list-item>
</template>
</v-select>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="closeCreateDialog">
{{ t('create.cancel') }}
</v-btn>
<v-btn color="primary" variant="elevated" @click="submitForm" :loading="saving">
{{ editingKB ? t('edit.submit') : t('create.submit') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Emoji 选择器对话框 -->
<v-dialog v-model="showEmojiPicker" max-width="500px">
<v-card>
<v-card-title class="pa-4">{{ t('emoji.title') }}</v-card-title>
<v-divider />
<v-card-text class="pa-4">
<div v-for="category in emojiCategories" :key="category.key" class="mb-4">
<p class="text-subtitle-2 mb-2">{{ t(`emoji.categories.${category.key}`) }}</p>
<div class="emoji-grid">
<div v-for="emoji in category.emojis" :key="emoji" class="emoji-item" @click="selectEmoji(emoji)">
{{ emoji }}
</div>
</div>
</div>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="showEmojiPicker = false">
{{ t('emoji.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="showDeleteDialog" max-width="450px" persistent>
<v-card>
<v-card-title class="pa-4 text-h6">{{ t('delete.title') }}</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<p>{{ t('delete.confirmText', { name: deleteTarget?.kb_name || '' }) }}</p>
<v-alert type="error" variant="tonal" density="compact" class="mt-4">
{{ t('delete.warning') }}
</v-alert>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="cancelDelete">
{{ t('delete.cancel') }}
</v-btn>
<v-btn color="error" variant="elevated" @click="deleteKB" :loading="deleting">
{{ t('delete.confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
{{ snackbar.text }}
</v-snackbar>
<div class="position-absolute" style="bottom: 0px; right: 16px;">
<small @click="router.push('/alkaid/knowledge-base')"><a style="text-decoration: underline; cursor: pointer;">切换到旧版知识库</a></small>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const { tm: t } = useModuleI18n('features/knowledge-base/index')
const router = useRouter()
// 状态
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const kbList = ref<any[]>([])
const embeddingProviders = ref<any[]>([])
const rerankProviders = ref<any[]>([])
const originalEmbeddingProvider = ref<string | null>(null)
const showEmbeddingWarning = ref(false)
const embeddingChangeDialog = ref(false)
const pendingEmbeddingProvider = ref<string | null>(null)
// 对话框
const showCreateDialog = ref(false)
const showEmojiPicker = ref(false)
const showDeleteDialog = ref(false)
// Snackbar 通知
const snackbar = ref({
show: false,
text: '',
color: 'success'
})
// 表单
const formRef = ref()
const editingKB = ref<any>(null)
const deleteTarget = ref<any>(null)
const formData = ref({
kb_name: '',
description: '',
emoji: '📚',
embedding_provider_id: null,
rerank_provider_id: null
})
// Emoji 分类
const emojiCategories = [
{
key: 'books',
emojis: ['📚', '📖', '📕', '📗', '📘', '📙', '📓', '📔', '📒', '📑', '🗂️', '📂', '📁', '🗃️', '🗄️']
},
{
key: 'emotions',
emojis: ['😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍']
},
{
key: 'objects',
emojis: ['💡', '🔬', '🔭', '🗿', '🏆', '🎯', '🎓', '🔑', '🔒', '🔓', '🔔', '🔕', '🔨', '🛠️', '⚙️']
},
{
key: 'symbols',
emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '⭐', '🌟', '✨', '💫', '⚡', '🔥']
}
]
// 加载知识库列表
const loadKnowledgeBases = async (refreshStats = false) => {
loading.value = true
try {
const params: any = {}
if (refreshStats) {
params.refresh_stats = 'true'
}
const response = await axios.get('/api/kb/list', { params })
if (response.data.status === 'ok') {
kbList.value = response.data.data.items || []
} else {
showSnackbar(response.data.message || t('messages.loadError'), 'error')
}
} catch (error) {
console.error('Failed to load knowledge bases:', error)
showSnackbar(t('messages.loadError'), 'error')
} finally {
loading.value = false
}
}
// 加载提供商配置
const loadProviders = async () => {
try {
const response = await axios.get('/api/config/provider/list', {
params: { provider_type: 'embedding,rerank' }
})
if (response.data.status === 'ok') {
embeddingProviders.value = response.data.data.filter(
(p: any) => p.provider_type === 'embedding'
)
rerankProviders.value = response.data.data.filter(
(p: any) => p.provider_type === 'rerank'
)
}
} catch (error) {
console.error('Failed to load providers:', error)
}
}
// 导航到详情页
const navigateToDetail = (kbId: string) => {
router.push({ name: 'NativeKBDetail', params: { kbId } })
}
// 编辑知识库
const editKB = (kb: any) => {
editingKB.value = kb
originalEmbeddingProvider.value = kb.embedding_provider_id
formData.value = {
kb_name: kb.kb_name,
description: kb.description || '',
emoji: kb.emoji || '📚',
embedding_provider_id: kb.embedding_provider_id,
rerank_provider_id: kb.rerank_provider_id
}
showCreateDialog.value = true
}
// 确认删除
const confirmDelete = (kb: any) => {
deleteTarget.value = kb
showDeleteDialog.value = true
}
// 取消删除
const cancelDelete = () => {
showDeleteDialog.value = false
deleteTarget.value = null
}
// 删除知识库
const deleteKB = async () => {
if (!deleteTarget.value) return
deleting.value = true
try {
const response = await axios.post('/api/kb/delete', {
kb_id: deleteTarget.value.kb_id
})
console.log('Delete response:', response.data) // 调试日志
if (response.data.status === 'ok') {
showSnackbar(t('messages.deleteSuccess'))
// 先刷新列表,再关闭对话框
await loadKnowledgeBases()
showDeleteDialog.value = false
deleteTarget.value = null
} else {
showSnackbar(response.data.message || t('messages.deleteFailed'), 'error')
}
} catch (error) {
console.error('Failed to delete knowledge base:', error)
showSnackbar(t('messages.deleteFailed'), 'error')
} finally {
deleting.value = false
}
}
// 提交表单
const submitForm = async () => {
const { valid } = await formRef.value.validate()
if (!valid) return
saving.value = true
try {
const payload = {
kb_name: formData.value.kb_name,
description: formData.value.description,
emoji: formData.value.emoji,
embedding_provider_id: formData.value.embedding_provider_id,
rerank_provider_id: formData.value.rerank_provider_id
}
let response
if (editingKB.value) {
response = await axios.post('/api/kb/update', {
kb_id: editingKB.value.kb_id,
...payload
})
} else {
response = await axios.post('/api/kb/create', payload)
}
if (response.data.status === 'ok') {
showSnackbar(editingKB.value ? t('messages.updateSuccess') : t('messages.createSuccess'))
closeCreateDialog()
await loadKnowledgeBases()
} else {
showSnackbar(response.data.message || (editingKB.value ? t('messages.updateFailed') : t('messages.createFailed')), 'error')
}
} catch (error) {
console.error('Failed to save knowledge base:', error)
showSnackbar(editingKB.value ? t('messages.updateFailed') : t('messages.createFailed'), 'error')
} finally {
saving.value = false
}
}
// 关闭创建对话框
const closeCreateDialog = () => {
showCreateDialog.value = false
editingKB.value = null
originalEmbeddingProvider.value = null
showEmbeddingWarning.value = false
pendingEmbeddingProvider.value = null
formData.value = {
kb_name: '',
description: '',
emoji: '📚',
embedding_provider_id: null,
rerank_provider_id: null
}
formRef.value?.reset()
}
// 选择 emoji
const selectEmoji = (emoji: string) => {
formData.value.emoji = emoji
showEmojiPicker.value = false
}
// 显示通知
const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.text = text
snackbar.value.color = color
snackbar.value.show = true
}
onMounted(() => {
loadKnowledgeBases(true) // 首次加载时刷新统计信息
loadProviders()
})
</script>
<style scoped>
.kb-list-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
}
.action-bar {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* 知识库网格 */
.kb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.kb-card {
position: relative;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.kb-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15) !important;
}
.kb-card-content {
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-height: 260px;
position: relative;
}
.kb-emoji {
font-size: 56px;
margin-bottom: 8px;
}
.kb-name {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 8px;
color: rgb(var(--v-theme-on-surface));
}
.kb-description {
font-size: 0.875rem;
line-height: 1.5;
max-height: 3em;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.kb-stats {
display: flex;
gap: 16px;
width: 100%;
justify-content: center;
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.875rem;
color: rgb(var(--v-theme-on-surface));
font-weight: 500;
}
.kb-actions {
position: absolute;
bottom: 16px;
right: 16px;
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.2s ease;
}
.kb-card:hover .kb-actions {
opacity: 1;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
text-align: center;
}
/* 加载状态 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
}
/* Emoji 显示和选择器 */
.emoji-display {
font-size: 72px;
cursor: pointer;
transition: transform 0.2s ease;
display: inline-block;
padding: 0px 16px;
border-radius: 12px;
background: rgba(var(--v-theme-primary), 0.05);
}
.emoji-display:hover {
transform: scale(1.1);
background: rgba(var(--v-theme-primary), 0.1);
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 8px;
}
.emoji-item {
font-size: 32px;
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
}
.emoji-item:hover {
background: rgba(var(--v-theme-primary), 0.1);
transform: scale(1.2);
}
/* 响应式设计 */
@media (max-width: 768px) {
.kb-list-page {
padding: 16px;
}
.kb-grid {
grid-template-columns: 1fr;
}
.emoji-grid {
grid-template-columns: repeat(6, 1fr);
}
}
</style>
@@ -0,0 +1,658 @@
<template>
<div class="documents-tab">
<!-- 操作栏 -->
<div class="action-bar mb-4">
<v-btn prepend-icon="mdi-upload" color="primary" variant="elevated" @click="showUploadDialog = true">
{{ t('documents.upload') }}
</v-btn>
<v-text-field v-model="searchQuery" prepend-inner-icon="mdi-magnify" :placeholder="'搜索文档...'" variant="outlined"
density="compact" hide-details clearable style="max-width: 300px" />
</div>
<!-- 文档列表 -->
<v-card elevation="2">
<v-data-table :headers="headers" :items="documents" :loading="loading" :search="searchQuery" :items-per-page="10">
<template #item.doc_name="{ item }">
<div class="d-flex align-center gap-2">
<v-icon :color="getFileColor(item.file_type)" class="mr-2">
{{ getFileIcon(item.file_type) }}
</v-icon>
<div class="flex-grow-1" style="padding: 4px 0px;">
<span class="font-weight-medium">{{ item.doc_name }}</span>
<!-- 上传进度 -->
<div v-if="item.uploading" class="mt-1">
<div class="text-caption text-medium-emphasis mb-1">
{{ getStageText(item.uploadProgress?.stage || 'waiting') }}
<span v-if="item.uploadProgress?.current">
({{ item.uploadProgress.current }} / {{ item.uploadProgress.total }})
</span>
</div>
<v-progress-linear :model-value="getUploadPercentage(item)" color="primary" height="4" rounded
striped />
</div>
</div>
</div>
</template>
<template #item.file_size="{ item }">
{{ formatFileSize(item.file_size) }}
</template>
<template #item.created_at="{ item }">
{{ formatDate(item.created_at) }}
</template>
<template #item.actions="{ item }">
<v-btn icon="mdi-eye" variant="text" size="small" color="info" @click="viewDocument(item)" />
<v-btn icon="mdi-delete" variant="text" size="small" color="error" @click="confirmDelete(item)" />
</template>
<template #no-data>
<div class="text-center py-8">
<v-icon size="64" color="grey-lighten-2">mdi-file-document-outline</v-icon>
<p class="mt-4 text-medium-emphasis">{{ t('documents.empty') }}</p>
</div>
</template>
</v-data-table>
</v-card>
<!-- 上传对话框 -->
<v-dialog v-model="showUploadDialog" max-width="600px" persistent @after-enter="initUploadSettings">
<v-card>
<v-card-title class="pa-4 d-flex align-center">
<span class="text-h5">{{ t('upload.title') }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="closeUploadDialog" />
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<!-- 文件选择 -->
<div class="upload-dropzone" :class="{ 'dragover': isDragging }" @drop.prevent="handleDrop"
@dragover.prevent="isDragging = true" @dragleave="isDragging = false" @click="fileInput?.click()">
<v-icon size="64" color="primary">mdi-cloud-upload</v-icon>
<p class="mt-4 text-h6">{{ t('upload.dropzone') }}</p>
<p class="text-caption text-medium-emphasis mt-2">{{ t('upload.supportedFormats') }}</p>
<p class="text-caption text-medium-emphasis">{{ t('upload.maxSize') }}</p>
<p class="text-caption text-medium-emphasis">最多可上传 10 个文件</p>
<input ref="fileInput" type="file" multiple hidden accept=".txt,.md,.pdf" @change="handleFileSelect" />
</div>
<div v-if="selectedFiles.length > 0" class="mt-4">
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-subtitle-2">已选择 {{ selectedFiles.length }} 个文件</span>
<v-btn variant="text" size="small" @click="selectedFiles = []">清空</v-btn>
</div>
<div class="files-list">
<div v-for="(file, index) in selectedFiles" :key="index"
class="file-item pa-3 mb-2 rounded bg-surface-variant">
<div class="d-flex align-center justify-space-between">
<div class="d-flex align-center gap-2">
<v-icon>{{ getFileIcon(file.name) }}</v-icon>
<div>
<div class="font-weight-medium">{{ file.name }}</div>
<div class="text-caption">{{ formatFileSize(file.size) }}</div>
</div>
</div>
<v-btn icon="mdi-close" variant="text" size="small" @click="removeFile(index)" />
</div>
</div>
</div>
</div>
<!-- 分块设置 -->
<div class="mt-6">
<div class="d-flex align-center mb-4">
<h3 class="text-h6">{{ t('upload.chunkSettings') }}</h3>
</div>
<v-row>
<v-col cols="12" sm="6">
<v-text-field v-model.number="uploadSettings.chunk_size" :label="t('upload.chunkSize')"
:hint="t('upload.chunkSizeHint')" persistent-hint type="number" variant="outlined" density="compact"
:placeholder="props.kb?.chunk_size?.toString() || '512'" />
</v-col>
<v-col cols="12" sm="6">
<v-text-field v-model.number="uploadSettings.chunk_overlap" :label="t('upload.chunkOverlap')"
:hint="t('upload.chunkOverlapHint')" persistent-hint type="number" variant="outlined"
density="compact" :placeholder="props.kb?.chunk_overlap?.toString() || '50'" />
</v-col>
</v-row>
</div>
<div class="mt-2">
<h3 class="text-h6 mb-4">{{ t('upload.batchSettings') }}</h3>
<v-row>
<v-col cols="12" sm="4">
<v-text-field v-model.number="uploadSettings.batch_size" :label="t('upload.batchSize')" hint="每批处理的文本数量"
persistent-hint type="number" variant="outlined" density="compact" />
</v-col>
<v-col cols="12" sm="4">
<v-text-field v-model.number="uploadSettings.tasks_limit" :label="t('upload.tasksLimit')"
hint="并发任务数量限制" persistent-hint type="number" variant="outlined" density="compact" />
</v-col>
<v-col cols="12" sm="4">
<v-text-field v-model.number="uploadSettings.max_retries" :label="t('upload.maxRetries')"
hint="失败时的最大重试次数" persistent-hint type="number" variant="outlined" density="compact" />
</v-col>
</v-row>
</div>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="closeUploadDialog" :disabled="uploading">
{{ t('upload.cancel') }}
</v-btn>
<v-btn color="primary" variant="elevated" @click="uploadDocument" :loading="uploading"
:disabled="selectedFiles.length === 0">
{{ t('upload.submit') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="showDeleteDialog" max-width="450px">
<v-card>
<v-card-title class="pa-4 text-h6">{{ t('documents.delete') }}</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<p>{{ t('documents.deleteConfirm', { name: deleteTarget?.doc_name || '' }) }}</p>
<v-alert type="error" variant="tonal" density="compact" class="mt-4">
{{ t('documents.deleteWarning') }}
</v-alert>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">取消</v-btn>
<v-btn color="error" variant="elevated" @click="deleteDocument" :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 setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const { tm: t } = useModuleI18n('features/knowledge-base/detail')
const router = useRouter()
const props = defineProps<{
kbId: string
kb: any
}>()
const emit = defineEmits(['refresh'])
// 状态
const loading = ref(false)
const uploading = ref(false)
const deleting = ref(false)
const documents = ref<any[]>([])
const searchQuery = ref('')
const showUploadDialog = ref(false)
const showDeleteDialog = ref(false)
const selectedFiles = ref<File[]>([])
const deleteTarget = ref<any>(null)
const isDragging = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
// 上传进度 - 用于轮询多个任务
const uploadingTasks = ref<Map<string, any>>(new Map())
const progressPollingInterval = ref<number | null>(null)
const snackbar = ref({
show: false,
text: '',
color: 'success'
})
const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.text = text
snackbar.value.color = color
snackbar.value.show = true
}
// 上传设置
const uploadSettings = ref({
chunk_size: null as number | null,
chunk_overlap: null as number | null,
batch_size: 32,
tasks_limit: 3,
max_retries: 3
})
// 初始化上传设置
const initUploadSettings = () => {
uploadSettings.value = {
chunk_size: props.kb?.chunk_size || null,
chunk_overlap: props.kb?.chunk_overlap || null,
batch_size: 32,
tasks_limit: 3,
max_retries: 3
}
}
// 表格列
const headers = [
{ title: t('documents.name'), key: 'doc_name', sortable: true },
{ title: t('documents.type'), key: 'file_type', sortable: true },
{ title: t('documents.size'), key: 'file_size', sortable: true },
{ title: t('documents.chunks'), key: 'chunk_count', sortable: true },
{ title: t('documents.createdAt'), key: 'created_at', sortable: true },
{ title: t('documents.actions'), key: 'actions', sortable: false, align: 'end' as const }
]
// 加载文档列表
const loadDocuments = async () => {
loading.value = true
try {
const response = await axios.get('/api/kb/document/list', {
params: { kb_id: props.kbId }
})
if (response.data.status === 'ok') {
documents.value = response.data.data.items || []
}
} catch (error) {
console.error('Failed to load documents:', error)
showSnackbar('加载文档列表失败', 'error')
} finally {
loading.value = false
}
}
// 文件选择
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files.length > 0) {
const newFiles = Array.from(target.files)
addFiles(newFiles)
}
}
// 添加文件(检查数量限制)
const addFiles = (files: File[]) => {
const totalFiles = selectedFiles.value.length + files.length
if (totalFiles > 10) {
showSnackbar('最多只能选择 10 个文件', 'warning')
return
}
selectedFiles.value.push(...files)
}
// 移除文件
const removeFile = (index: number) => {
selectedFiles.value.splice(index, 1)
}
// 拖放上传
const handleDrop = (event: DragEvent) => {
isDragging.value = false
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
const newFiles = Array.from(event.dataTransfer.files)
addFiles(newFiles)
}
}
// 上传文档
const uploadDocument = async () => {
if (selectedFiles.value.length === 0) {
showSnackbar(t('upload.fileRequired'), 'warning')
return
}
uploading.value = true
try {
const formData = new FormData()
// 添加所有文件
selectedFiles.value.forEach((file, index) => {
formData.append(`file${index}`, file)
})
formData.append('kb_id', props.kbId)
if (uploadSettings.value.chunk_size) {
formData.append('chunk_size', uploadSettings.value.chunk_size.toString())
}
if (uploadSettings.value.chunk_overlap) {
formData.append('chunk_overlap', uploadSettings.value.chunk_overlap.toString())
}
formData.append('batch_size', uploadSettings.value.batch_size.toString())
formData.append('tasks_limit', uploadSettings.value.tasks_limit.toString())
formData.append('max_retries', uploadSettings.value.max_retries.toString())
const response = await axios.post('/api/kb/document/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
if (response.data.status === 'ok') {
const result = response.data.data
const taskId = result.task_id
showSnackbar(`正在后台上传 ${result.file_count} 个文件...`, 'info')
// 为每个文件添加占位条目到文档列表
const uploadingDocs = selectedFiles.value.map((file, index) => ({
doc_id: `uploading_${taskId}_${index}`,
doc_name: file.name,
file_type: file.name.split('.').pop() || '',
file_size: file.size,
chunk_count: 0,
created_at: new Date().toISOString(),
uploading: true,
taskId: taskId,
uploadProgress: {
stage: 'waiting',
current: 0,
total: 100
}
}))
// 添加到文档列表顶部
documents.value = [...uploadingDocs, ...documents.value]
// 关闭对话框
closeUploadDialog()
// 开始轮询进度
if (taskId) {
startProgressPolling(taskId)
}
} else {
showSnackbar(response.data.message || t('documents.uploadFailed'), 'error')
}
} catch (error) {
console.error('Failed to upload document:', error)
showSnackbar(t('documents.uploadFailed'), 'error')
} finally {
uploading.value = false
}
}
// 开始轮询进度
const startProgressPolling = (taskId: string) => {
// 如果已经在轮询,先停止
if (progressPollingInterval.value) {
stopProgressPolling()
}
progressPollingInterval.value = window.setInterval(async () => {
try {
const response = await axios.get('/api/kb/document/upload/progress', {
params: { task_id: taskId }
})
if (response.data.status === 'ok') {
const data = response.data.data
const status = data.status
if (status === 'processing' && data.progress) {
// 更新进度
const progress = data.progress
const fileIndex = progress.file_index || 0
// 更新对应文件的进度
documents.value = documents.value.map(doc => {
if (doc.taskId === taskId) {
const docIndex = parseInt(doc.doc_id.split('_').pop() || '0')
if (docIndex === fileIndex) {
return {
...doc,
uploadProgress: {
stage: progress.stage || 'waiting',
current: progress.current || 0,
total: progress.total || 100
}
}
}
}
return doc
})
} else if (status === 'completed') {
// 任务完成
stopProgressPolling()
const result = data.result
const successCount = result?.success_count || 0
const failedCount = result?.failed_count || 0
// 移除上传中的占位文档
documents.value = documents.value.filter(doc => doc.taskId !== taskId)
// 重新加载文档列表
await loadDocuments()
emit('refresh')
if (failedCount === 0) {
showSnackbar(`成功上传 ${successCount} 个文档`)
} else {
showSnackbar(`上传完成: ${successCount} 个成功, ${failedCount} 个失败`, 'warning')
}
} else if (status === 'failed') {
// 任务失败
stopProgressPolling()
// 移除上传中的占位文档
documents.value = documents.value.filter(doc => doc.taskId !== taskId)
showSnackbar(`上传失败: ${data.error || '未知错误'}`, 'error')
}
} else {
// 任务不存在,停止轮询
stopProgressPolling()
documents.value = documents.value.filter(doc => doc.taskId !== taskId)
}
} catch (error) {
console.error('Failed to fetch progress:', error)
// 不立即停止,允许重试
}
}, 500) // 每500ms轮询一次
}
// 停止轮询进度
const stopProgressPolling = () => {
if (progressPollingInterval.value) {
clearInterval(progressPollingInterval.value)
progressPollingInterval.value = null
}
}
// 获取上传百分比
const getUploadPercentage = (item: any) => {
if (!item.uploadProgress) return 0
const { current, total } = item.uploadProgress
if (!total || total === 0) return 0
return (current / total) * 100
}
// 获取阶段文本
const getStageText = (stage: string) => {
const stageMap: Record<string, string> = {
'waiting': '等待中...',
'parsing': '解析文档...',
'chunking': '文本分块...',
'embedding': '生成向量...'
}
return stageMap[stage] || stage
}
// 关闭上传对话框
const closeUploadDialog = () => {
showUploadDialog.value = false
selectedFiles.value = []
initUploadSettings()
}
// 查看文档
const viewDocument = (doc: any) => {
router.push({
name: 'NativeDocumentDetail',
params: { kbId: props.kbId, docId: doc.doc_id }
})
}
// 确认删除
const confirmDelete = (doc: any) => {
deleteTarget.value = doc
showDeleteDialog.value = true
}
// 删除文档
const deleteDocument = async () => {
if (!deleteTarget.value) return
deleting.value = true
try {
const response = await axios.post('/api/kb/document/delete', {
doc_id: deleteTarget.value.doc_id,
kb_id: props.kbId
})
if (response.data.status === 'ok') {
showSnackbar(t('documents.deleteSuccess'))
showDeleteDialog.value = false
await loadDocuments()
emit('refresh')
} else {
showSnackbar(response.data.message || t('documents.deleteFailed'), 'error')
}
} catch (error) {
console.error('Failed to delete document:', error)
showSnackbar(t('documents.deleteFailed'), 'error')
} finally {
deleting.value = false
}
}
// 工具函数
const getFileIcon = (fileType: string) => {
const type = fileType?.toLowerCase() || ''
if (type.includes('pdf')) return 'mdi-file-pdf-box'
if (type.includes('md') || type.includes('markdown')) return 'mdi-language-markdown'
if (type.includes('txt')) return 'mdi-file-document-outline'
return 'mdi-file'
}
const getFileColor = (fileType: string) => {
const type = fileType?.toLowerCase() || ''
if (type.includes('pdf')) return 'error'
if (type.includes('md')) return 'info'
if (type.includes('txt')) return 'success'
return 'grey'
}
const formatFileSize = (bytes: number) => {
if (!bytes) return '-'
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(2)} ${units[unitIndex]}`
}
const formatDate = (dateStr: string) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadDocuments()
})
onUnmounted(() => {
stopProgressPolling()
})
</script>
<style scoped>
.documents-tab {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.upload-dropzone {
border: 2px dashed rgba(var(--v-theme-primary), 0.3);
border-radius: 12px;
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(var(--v-theme-surface-variant), 0.3);
}
.upload-dropzone:hover,
.upload-dropzone.dragover {
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.05);
transform: scale(1.02);
}
.files-list {
max-height: 300px;
overflow-y: auto;
}
.file-item {
transition: all 0.2s ease;
}
.file-item:hover {
background: rgba(var(--v-theme-surface-variant), 0.8) !important;
}
@media (max-width: 768px) {
.action-bar {
flex-direction: column;
align-items: stretch;
}
.action-bar>* {
width: 100%;
}
}
</style>
@@ -0,0 +1,248 @@
<template>
<div class="retrieval-tab">
<v-card elevation="2">
<v-card-title class="pa-4 pb-0">{{ t('retrieval.title') }}</v-card-title>
<v-card-subtitle class="pb-4 pt-2">
{{ t('retrieval.subtitle') }}
</v-card-subtitle>
<v-divider />
<v-progress-linear v-if="loading" indeterminate color="primary" height="2" />
<v-card-text class="pa-6">
<!-- 查询输入区域 -->
<v-row class="mb-4">
<v-col cols="12" md="8">
<v-textarea v-model="query" :label="t('retrieval.query')" :placeholder="t('retrieval.queryPlaceholder')"
variant="outlined" rows="3" auto-grow clearable />
<!-- debug -->
<div v-if="debugVisualize" class="mt-2">
<v-card variant="outlined">
<v-img :src="`data:image/png;base64,${debugVisualize}`" :alt="t('retrieval.tsneVisualization')" cover>
<template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular indeterminate color="primary" />
</div>
</template>
</v-img>
</v-card>
</div>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" class="pa-4">
<h4 class="text-subtitle-2 mb-3">{{ t('retrieval.settings') }}</h4>
<v-text-field v-model.number="topK" :label="t('retrieval.topK')" :hint="t('retrieval.topKHint')"
type="number" variant="outlined" density="compact" persistent-hint class="mb-3" />
<v-switch v-model="debugMode" :label="t('retrieval.debugMode')" color="primary" density="compact"
hide-details>
<template v-slot:label>
<span class="text-caption">
<v-icon size="small" class="mr-1">mdi-bug</v-icon>
Debug (t-SNE)
</span>
</template>
</v-switch>
</v-card>
</v-col>
</v-row>
<div class="d-flex justify-end mb-4">
<v-btn prepend-icon="mdi-magnify" color="primary" variant="elevated" @click="performRetrieval"
:loading="loading" :disabled="!query || query.trim() === ''">
{{ loading ? t('retrieval.searching') : t('retrieval.search') }}
</v-btn>
</div>
<!-- 检索结果 -->
<div v-if="hasSearched" class="results-section">
<v-divider class="mb-4" />
<div class="d-flex align-center mb-4">
<h3 class="text-h6">{{ t('retrieval.results') }}</h3>
<v-chip class="ml-3" color="primary" variant="tonal" size="small">
{{ results.length }} {{ t('retrieval.results') }}
</v-chip>
</div>
<!-- 结果列表 -->
<div v-if="results.length > 0" class="results-list">
<v-card v-for="(result, index) in results" :key="result.chunk_id" variant="outlined" class="mb-4">
<v-card-title class="d-flex align-center pa-2">
<v-chip size="x-small" color="primary" class="mr-2">
#{{ index + 1 }}
</v-chip>
<span class="text-subtitle-1">
{{ t('retrieval.chunk', { index: result.chunk_index }) }}
</span>
<div class="ml-4">
<v-chip size="x-small" variant="tonal" class="mr-2">
<v-icon start size="small">mdi-file-document</v-icon>
{{ result.doc_name }}
</v-chip>
<v-chip size="x-small" variant="tonal">
<v-icon start size="small">mdi-text</v-icon>
{{ t('retrieval.charCount', { count: result.char_count }) }}
</v-chip>
</div>
<v-spacer />
<v-chip size="x-small" :color="getScoreColor(result.score)">
{{ t('retrieval.score') }}: {{ result.score.toFixed(4) }}
</v-chip>
</v-card-title>
<v-divider />
<v-card-text class="pa-4">
<div class="content-box">
{{ result.content }}
</div>
</v-card-text>
</v-card>
</div>
<!-- 空结果 -->
<div v-else class="text-center py-12">
<v-icon size="80" color="grey-lighten-2">mdi-text-box-search-outline</v-icon>
<p class="text-h6 mt-4 text-medium-emphasis">{{ t('retrieval.noResults') }}</p>
<p class="text-body-2 text-medium-emphasis">{{ t('retrieval.tryDifferentQuery') }}</p>
</div>
</div>
</v-card-text>
</v-card>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const { tm: t } = useModuleI18n('features/knowledge-base/detail')
const props = defineProps<{
kbId: string,
kbName: string,
}>()
// 状态
const loading = ref(false)
const query = ref('')
const topK = ref(5)
const debugMode = ref(false)
const results = ref<any[]>([])
const hasSearched = ref(false)
const debugVisualize = ref<string | null>(null)
const snackbar = ref({
show: false,
text: '',
color: 'success'
})
const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.text = text
snackbar.value.color = color
snackbar.value.show = true
}
// 执行检索
const performRetrieval = async () => {
if (!query.value || query.value.trim() === '') {
showSnackbar(t('retrieval.queryRequired'), 'warning')
return
}
loading.value = true
hasSearched.value = false
debugVisualize.value = null
try {
const response = await axios.post('/api/kb/retrieve', {
query: query.value,
kb_names: [props.kbName],
top_k: topK.value,
debug: debugMode.value
})
if (response.data.status === 'ok') {
results.value = response.data.data.results || []
hasSearched.value = true
if (debugMode.value && response.data.data.visualization) {
debugVisualize.value = response.data.data.visualization
}
showSnackbar(t('retrieval.searchSuccess', { count: results.value.length }))
} else {
showSnackbar(response.data.message || t('retrieval.searchFailed'), 'error')
}
} catch (error) {
console.error('Retrieval failed:', error)
showSnackbar(t('retrieval.searchFailed'), 'error')
} finally {
loading.value = false
}
}
// 根据分数获取颜色
const getScoreColor = (score: number) => {
if (score >= 0.8) return 'success'
if (score >= 0.6) return 'info'
if (score >= 0.4) return 'warning'
return 'error'
}
</script>
<style scoped>
.retrieval-tab {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.results-section {
animation: slideUp 0.4s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.content-box {
background: rgba(var(--v-theme-surface-variant), 0.1);
border-radius: 8px;
padding: 16px;
white-space: pre-wrap;
word-break: break-word;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.9rem;
line-height: 1.6;
height: 120px;
overflow-y: auto;
font-size: 13px;
}
</style>
@@ -0,0 +1,316 @@
<template>
<div class="settings-tab">
<v-card elevation="2">
<v-card-title class="pa-4">{{ t('settings.title') }}</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<v-form ref="formRef">
<!-- 基本设置 -->
<h3 class="text-h6 mb-4">{{ t('settings.basic') }}</h3>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model.number="formData.chunk_size"
:label="t('settings.chunkSize')"
type="number"
variant="outlined"
density="comfortable"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="formData.chunk_overlap"
:label="t('settings.chunkOverlap')"
type="number"
variant="outlined"
density="comfortable"
/>
</v-col>
</v-row>
<!-- 检索设置 -->
<h3 class="text-h6 mb-4 mt-6">{{ t('settings.retrieval') }}</h3>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model.number="formData.top_k_dense"
:label="t('settings.topKDense')"
type="number"
variant="outlined"
density="comfortable"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="formData.top_k_sparse"
:label="t('settings.topKSparse')"
type="number"
variant="outlined"
density="comfortable"
/>
</v-col>
<!-- <v-col cols="12" md="4">
<v-text-field
v-model.number="formData.top_m_final"
:label="t('settings.topMFinal')"
type="number"
variant="outlined"
density="comfortable"
/>
</v-col> -->
</v-row>
<!-- 模型设置 -->
<h3 class="text-h6 mb-4 mt-6">{{ t('settings.embeddingProvider') }}</h3>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="formData.embedding_provider_id"
:items="embeddingProviders"
:item-title="item => item.embedding_model || item.id"
:item-value="'id'"
:label="t('settings.embeddingProvider')"
variant="outlined"
density="comfortable"
@update:model-value="handleEmbeddingProviderChange"
:disabled="true"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="formData.rerank_provider_id"
:items="rerankProviders"
:item-title="item => item.rerank_model || item.id"
:item-value="'id'"
:label="t('settings.rerankProvider')"
variant="outlined"
density="comfortable"
clearable
/>
</v-col>
</v-row>
<v-alert type="info" variant="tonal" class="mt-4">
{{ t('settings.tips') }}
</v-alert>
<v-alert type="warning" variant="tonal" class="mt-4" v-if="showEmbeddingWarning">
<strong>注意:</strong> 修改嵌入模型会导致现有的向量数据失效,建议重新上传文档。不同的嵌入模型生成的向量不兼容,可能导致检索结果不准确。
</v-alert>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn
color="primary"
variant="elevated"
prepend-icon="mdi-content-save"
@click="saveSettings"
:loading="saving"
>
{{ t('settings.save') }}
</v-btn>
</v-card-actions>
</v-card>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
{{ snackbar.text }}
</v-snackbar>
<!-- Embedding Provider修改确认对话框 -->
<v-dialog v-model="embeddingChangeDialog" max-width="500px" persistent>
<v-card>
<v-card-title class="bg-warning text-white">
<v-icon class="mr-2">mdi-alert</v-icon>
确认修改嵌入模型
</v-card-title>
<v-card-text class="pa-6">
<v-alert type="warning" variant="tonal" class="mb-4">
<strong>警告:</strong> 修改嵌入模型将导致以下影响:
</v-alert>
<ul class="text-body-2">
<li>现有的向量数据将失效</li>
<li>检索功能可能无法正常工作</li>
<li>建议删除现有文档后重新上传</li>
<li>不同嵌入模型生成的向量不兼容</li>
</ul>
<div class="mt-4 text-body-2">
您确定要将嵌入模型从 <strong>{{ originalEmbeddingProvider }}</strong> 修改为 <strong>{{ pendingEmbeddingProvider }}</strong> 吗?
</div>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="cancelEmbeddingChange">
取消
</v-btn>
<v-btn color="warning" variant="elevated" @click="confirmEmbeddingChange">
确认修改
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const { tm: t } = useModuleI18n('features/knowledge-base/detail')
const props = defineProps<{
kb: any
}>()
const emit = defineEmits(['updated'])
// 状态
const saving = ref(false)
const formRef = ref()
const embeddingProviders = ref<any[]>([])
const rerankProviders = ref<any[]>([])
const originalEmbeddingProvider = ref('')
const showEmbeddingWarning = ref(false)
const embeddingChangeDialog = ref(false)
const pendingEmbeddingProvider = ref('')
const snackbar = ref({
show: false,
text: '',
color: 'success'
})
const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.text = text
snackbar.value.color = color
snackbar.value.show = true
}
// 表单数据
const formData = ref({
chunk_size: 512,
chunk_overlap: 50,
top_k_dense: 50,
top_k_sparse: 50,
embedding_provider_id: '',
rerank_provider_id: ''
})
// 监听 kb 变化,更新表单
watch(() => props.kb, (kb) => {
if (kb) {
formData.value = {
chunk_size: kb.chunk_size || 512,
chunk_overlap: kb.chunk_overlap || 50,
top_k_dense: kb.top_k_dense || 50,
top_k_sparse: kb.top_k_sparse || 50,
// top_m_final: kb.top_m_final || 5,
embedding_provider_id: kb.embedding_provider_id || '',
rerank_provider_id: kb.rerank_provider_id || ''
}
// 保存原始的embedding provider
originalEmbeddingProvider.value = kb.embedding_provider_id || ''
}
}, { immediate: true })
// 加载提供商列表
const loadProviders = async () => {
try {
const response = await axios.get('/api/config/provider/list', {
params: { provider_type: 'embedding,rerank' }
})
if (response.data.status === 'ok') {
embeddingProviders.value = response.data.data.filter(
(p: any) => p.provider_type === 'embedding'
)
rerankProviders.value = response.data.data.filter(
(p: any) => p.provider_type === 'rerank'
)
}
} catch (error) {
console.error('Failed to load providers:', error)
}
}
// 处理embedding provider变更
const handleEmbeddingProviderChange = (newValue: string) => {
if (newValue && newValue !== originalEmbeddingProvider.value) {
// 显示警告并需要确认
showEmbeddingWarning.value = true
pendingEmbeddingProvider.value = newValue
embeddingChangeDialog.value = true
} else {
showEmbeddingWarning.value = false
}
}
// 确认修改embedding provider
const confirmEmbeddingChange = () => {
formData.value.embedding_provider_id = pendingEmbeddingProvider.value
embeddingChangeDialog.value = false
showEmbeddingWarning.value = true
}
// 取消修改embedding provider
const cancelEmbeddingChange = () => {
formData.value.embedding_provider_id = originalEmbeddingProvider.value
embeddingChangeDialog.value = false
showEmbeddingWarning.value = false
pendingEmbeddingProvider.value = ''
}
// 保存设置
const saveSettings = async () => {
const { valid } = await formRef.value.validate()
if (!valid) return
saving.value = true
try {
const response = await axios.post('/api/kb/update', {
kb_id: props.kb.kb_id,
chunk_size: formData.value.chunk_size,
chunk_overlap: formData.value.chunk_overlap,
top_k_dense: formData.value.top_k_dense,
top_k_sparse: formData.value.top_k_sparse,
// top_m_final: formData.value.top_m_final,
rerank_provider_id: formData.value.rerank_provider_id
})
if (response.data.status === 'ok') {
showSnackbar(t('settings.saveSuccess'))
emit('updated')
} else {
showSnackbar(response.data.message || t('settings.saveFailed'), 'error')
}
} catch (error) {
console.error('Failed to save settings:', error)
showSnackbar(t('settings.saveFailed'), 'error')
} finally {
saving.value = false
}
}
onMounted(() => {
loadProviders()
})
</script>
<style scoped>
.settings-tab {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
@@ -0,0 +1,37 @@
<template>
<div class="kb-container">
<router-view v-slot="{ Component }">
<transition name="kb-fade" mode="out-in">
<component :is="Component" :key="$route.fullPath" />
</transition>
</router-view>
</div>
</template>
<script setup lang="ts">
// 主容器组件,提供路由出口和页面切换动画
</script>
<style scoped>
.kb-container {
width: 100%;
height: 100%;
position: relative;
}
/* 页面切换动画 */
.kb-fade-enter-active,
.kb-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.kb-fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.kb-fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>
+4
View File
@@ -51,6 +51,10 @@ dependencies = [
"wechatpy>=1.8.18",
"audioop-lts ; python_full_version >= '3.13'",
"click>=8.2.1",
"pypdf>=6.1.1",
"aiofiles>=25.1.0",
"rank-bm25>=0.2.2",
"jieba>=0.42.1",
]
[project.scripts]