Merge pull request #3143 from lxfight/feature/knowledge-base
This commit is contained in:
63 files changed
+8624
-270
No files matched your search
@@ -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": "从知识库中检索到的结果数量,越大可能获得越多相关信息,但也可能引入噪音。建议根据实际需求调整",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
"""保存索引
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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 ''}"
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
@@ -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"""
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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>",
|
||||
|
||||
@@ -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": "此操作将永久删除本次会话的「全部对话记录」与「偏好设置」(插件对会话的关联数据除外),且无法恢复。确认继续?"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user