Compare commits

..

7 Commits

Author SHA1 Message Date
Junyan Qin
318c6b6c66 refactor: switch RetrievalResultEntry to langbot_plugin pkg ones 2025-11-24 20:36:20 +08:00
Junyan Qin
00a7e86875 perf: margin-top for kb page 2025-11-16 23:12:56 +08:00
copilot-swe-agent[bot]
abe97957b4 Update knowledge base tab list styling to match plugins page
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-16 15:04:47 +00:00
copilot-swe-agent[bot]
1a07ed6e99 Add i18n translations for all languages (Traditional Chinese and Japanese)
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-16 14:52:05 +00:00
copilot-swe-agent[bot]
64fd4b55c7 Add frontend support for external knowledge bases with tabs UI
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-16 14:50:07 +00:00
copilot-swe-agent[bot]
5e81306d7c Add backend support for external knowledge bases
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-16 14:44:03 +00:00
copilot-swe-agent[bot]
6c5105b61f Initial plan 2025-11-16 14:36:21 +00:00
32 changed files with 1093 additions and 393 deletions

View File

@@ -2,17 +2,6 @@
> 请在此部分填写你实现/解决/优化的内容:
> Summary of what you implemented/solved/optimized:
>
### 更改前后对比截图 / Screenshots
> 请在此部分粘贴更改前后对比截图(可以是界面截图、控制台输出、对话截图等):
> Please paste the screenshots of changes before and after here (can be interface screenshots, console output, conversation screenshots, etc.):
>
> 修改前 / Before:
>
> 修改后 / After:
>
## 检查清单 / Checklist

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.5.3"
version = "4.5.2"
description = "Easy-to-use global IM bot platform designed for LLM era"
readme = "README.md"
license-files = ["LICENSE"]
@@ -63,7 +63,7 @@ dependencies = [
"langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.1.11",
"langbot-plugin==0.1.12",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",

View File

@@ -1,3 +1,3 @@
"""LangBot - Easy-to-use global IM bot platform designed for LLM era"""
__version__ = '4.5.3'
__version__ = '4.5.2'

View File

@@ -109,13 +109,14 @@ class WecomClient:
async def send_image(self, user_id: str, agent_id: int, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/message/send?access_token=' + self.access_token
url = self.base_url + '/media/upload?access_token=' + self.access_token
async with httpx.AsyncClient() as client:
params = {
'touser': user_id,
'msgtype': 'image',
'toparty': '',
'totag': '',
'agentid': agent_id,
'msgtype': 'image',
'image': {
'media_id': media_id,
},
@@ -124,13 +125,19 @@ class WecomClient:
'enable_duplicate_check': 0,
'duplicate_check_interval': 1800,
}
response = await client.post(url, json=params)
data = response.json()
try:
response = await client.post(url, json=params)
data = response.json()
except Exception as e:
await self.logger.error(f'发送图片失败:{data}')
raise Exception('Failed to send image: ' + str(e))
# 企业微信错误码40014和42001代表accesstoken问题
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_image(user_id, agent_id, media_id)
if data['errcode'] != 0:
await self.logger.error(f'发送图片失败:{data}')
raise Exception('Failed to send image: ' + str(data))
async def send_private_msg(self, user_id: str, agent_id: int, content: str):

View File

@@ -0,0 +1 @@
from . import base, external

View File

@@ -0,0 +1,55 @@
import quart
from ... import group
@group.group_class('external_knowledge_base', '/api/v1/knowledge/external-bases')
class ExternalKnowledgeBaseRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['POST', 'GET'])
async def handle_external_knowledge_bases() -> quart.Response:
if quart.request.method == 'GET':
external_kbs = await self.ap.knowledge_service.get_external_knowledge_bases()
return self.success(data={'bases': external_kbs})
elif quart.request.method == 'POST':
json_data = await quart.request.json
kb_uuid = await self.ap.knowledge_service.create_external_knowledge_base(json_data)
return self.success(data={'uuid': kb_uuid})
return self.http_status(405, -1, 'Method not allowed')
@self.route(
'/<kb_uuid>',
methods=['GET', 'DELETE', 'PUT'],
)
async def handle_specific_external_knowledge_base(kb_uuid: str) -> quart.Response:
if quart.request.method == 'GET':
external_kb = await self.ap.knowledge_service.get_external_knowledge_base(kb_uuid)
if external_kb is None:
return self.http_status(404, -1, 'external knowledge base not found')
return self.success(
data={
'base': external_kb,
}
)
elif quart.request.method == 'PUT':
json_data = await quart.request.json
await self.ap.knowledge_service.update_external_knowledge_base(kb_uuid, json_data)
return self.success({})
elif quart.request.method == 'DELETE':
await self.ap.knowledge_service.delete_external_knowledge_base(kb_uuid)
return self.success({})
@self.route(
'/<kb_uuid>/retrieve',
methods=['POST'],
)
async def retrieve_external_knowledge_base(kb_uuid: str) -> str:
json_data = await quart.request.json
query = json_data.get('query')
results = await self.ap.knowledge_service.retrieve_knowledge_base(kb_uuid, query)
return self.success(data={'results': results})

View File

@@ -71,6 +71,9 @@ class KnowledgeService:
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
if runtime_kb is None:
raise Exception('Knowledge base not found')
# Only internal KBs support file storage
if runtime_kb.get_type() != 'internal':
raise Exception('Only internal knowledge bases support file storage')
return await runtime_kb.store_file(file_id)
async def retrieve_knowledge_base(self, kb_uuid: str, query: str) -> list[dict]:
@@ -78,9 +81,16 @@ class KnowledgeService:
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
if runtime_kb is None:
raise Exception('Knowledge base not found')
return [
result.model_dump() for result in await runtime_kb.retrieve(query, runtime_kb.knowledge_base_entity.top_k)
]
# Get top_k based on KB type
if runtime_kb.get_type() == 'internal':
top_k = runtime_kb.knowledge_base_entity.top_k
elif runtime_kb.get_type() == 'external':
top_k = runtime_kb.external_kb_entity.top_k
else:
top_k = 5 # default fallback
return [result.model_dump() for result in await runtime_kb.retrieve(query, top_k)]
async def get_files_by_knowledge_base(self, kb_uuid: str) -> list[dict]:
"""获取知识库文件"""
@@ -95,6 +105,9 @@ class KnowledgeService:
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
if runtime_kb is None:
raise Exception('Knowledge base not found')
# Only internal KBs support file deletion
if runtime_kb.get_type() != 'internal':
raise Exception('Only internal knowledge bases support file deletion')
await runtime_kb.delete_file(file_id)
async def delete_knowledge_base(self, kb_uuid: str) -> None:
@@ -118,3 +131,66 @@ class KnowledgeService:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_rag.File).where(persistence_rag.File.uuid == file.uuid)
)
# External Knowledge Base methods
async def get_external_knowledge_bases(self) -> list[dict]:
"""获取所有外部知识库"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_rag.ExternalKnowledgeBase)
)
external_kbs = result.all()
return [
self.ap.persistence_mgr.serialize_model(persistence_rag.ExternalKnowledgeBase, external_kb)
for external_kb in external_kbs
]
async def get_external_knowledge_base(self, kb_uuid: str) -> dict | None:
"""获取外部知识库"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_rag.ExternalKnowledgeBase).where(
persistence_rag.ExternalKnowledgeBase.uuid == kb_uuid
)
)
external_kb = result.first()
if external_kb is None:
return None
return self.ap.persistence_mgr.serialize_model(persistence_rag.ExternalKnowledgeBase, external_kb)
async def create_external_knowledge_base(self, kb_data: dict) -> str:
"""创建外部知识库"""
kb_data['uuid'] = str(uuid.uuid4())
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_rag.ExternalKnowledgeBase).values(kb_data)
)
kb = await self.get_external_knowledge_base(kb_data['uuid'])
await self.ap.rag_mgr.load_external_knowledge_base(kb)
return kb_data['uuid']
async def update_external_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
"""更新外部知识库"""
if 'uuid' in kb_data:
del kb_data['uuid']
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_rag.ExternalKnowledgeBase)
.values(kb_data)
.where(persistence_rag.ExternalKnowledgeBase.uuid == kb_uuid)
)
await self.ap.rag_mgr.remove_knowledge_base_from_runtime(kb_uuid)
kb = await self.get_external_knowledge_base(kb_uuid)
await self.ap.rag_mgr.load_external_knowledge_base(kb)
async def delete_external_knowledge_base(self, kb_uuid: str) -> None:
"""删除外部知识库"""
await self.ap.rag_mgr.delete_knowledge_base(kb_uuid)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_rag.ExternalKnowledgeBase).where(
persistence_rag.ExternalKnowledgeBase.uuid == kb_uuid
)
)

View File

@@ -43,6 +43,17 @@ class Chunk(Base):
text = sqlalchemy.Column(sqlalchemy.Text)
class ExternalKnowledgeBase(Base):
__tablename__ = 'external_knowledge_bases'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String, index=True)
description = sqlalchemy.Column(sqlalchemy.Text)
api_url = sqlalchemy.Column(sqlalchemy.String, nullable=False)
api_key = sqlalchemy.Column(sqlalchemy.String, nullable=True)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now())
top_k = sqlalchemy.Column(sqlalchemy.Integer, default=5)
# class Vector(Base):
# __tablename__ = 'knowledge_base_vectors'
# uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)

View File

@@ -1,13 +0,0 @@
from __future__ import annotations
import pydantic
from typing import Any
class RetrieveResultEntry(pydantic.BaseModel):
id: str
metadata: dict[str, Any]
distance: float

View File

@@ -73,7 +73,15 @@ class LocalAgentRunner(runner.RequestRunner):
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
continue
result = await kb.retrieve(user_message_text, kb.knowledge_base_entity.top_k)
# Get top_k based on KB type
if kb.get_type() == 'internal':
top_k = kb.knowledge_base_entity.top_k
elif kb.get_type() == 'external':
top_k = kb.external_kb_entity.top_k
else:
top_k = 5 # default fallback
result = await kb.retrieve(user_message_text, top_k)
if result:
all_results.extend(result)

View File

@@ -0,0 +1,55 @@
"""Base classes and interfaces for knowledge bases"""
from __future__ import annotations
import abc
from langbot.pkg.core import app
from langbot_plugin.api.entities.rag import context as rag_context
class KnowledgeBaseInterface(metaclass=abc.ABCMeta):
"""Abstract interface for all knowledge base types"""
ap: app.Application
def __init__(self, ap: app.Application):
self.ap = ap
@abc.abstractmethod
async def initialize(self):
"""Initialize the knowledge base"""
pass
@abc.abstractmethod
async def retrieve(self, query: str, top_k: int) -> list[rag_context.RetrievalResultEntry]:
"""Retrieve relevant documents from the knowledge base
Args:
query: The query string
top_k: Number of top results to return
Returns:
List of retrieve result entries
"""
pass
@abc.abstractmethod
def get_uuid(self) -> str:
"""Get the UUID of the knowledge base"""
pass
@abc.abstractmethod
def get_name(self) -> str:
"""Get the name of the knowledge base"""
pass
@abc.abstractmethod
def get_type(self) -> str:
"""Get the type of knowledge base (internal/external)"""
pass
@abc.abstractmethod
async def dispose(self):
"""Clean up resources"""
pass

View File

@@ -0,0 +1,123 @@
"""External knowledge base implementation"""
from __future__ import annotations
import aiohttp
from langbot.pkg.core import app
from langbot.pkg.entity.persistence import rag as persistence_rag
from langbot_plugin.api.entities.rag import context as rag_context
from .base import KnowledgeBaseInterface
class ExternalKnowledgeBase(KnowledgeBaseInterface):
"""External knowledge base that queries via HTTP API"""
external_kb_entity: persistence_rag.ExternalKnowledgeBase
def __init__(self, ap: app.Application, external_kb_entity: persistence_rag.ExternalKnowledgeBase):
super().__init__(ap)
self.external_kb_entity = external_kb_entity
async def initialize(self):
"""Initialize the external knowledge base"""
pass
async def retrieve(self, query: str, top_k: int) -> list[rag_context.RetrievalResultEntry]:
"""Retrieve documents from external knowledge base via HTTP API
The API should follow this format:
POST {api_url}
Content-Type: application/json
Authorization: Bearer {api_key} (if api_key is provided)
Request body:
{
"query": "user query text",
"top_k": 5
}
Response format:
{
"records": [
{
"content": "document text content",
"score": 0.95,
"title": "optional document title",
"metadata": {}
}
]
}
"""
try:
headers = {'Content-Type': 'application/json'}
if self.external_kb_entity.api_key:
headers['Authorization'] = f'Bearer {self.external_kb_entity.api_key}'
request_data = {'query': query, 'top_k': top_k}
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(
self.external_kb_entity.api_url, json=request_data, headers=headers
) as response:
if response.status != 200:
error_text = await response.text()
self.ap.logger.error(f'External KB API error: status={response.status}, body={error_text}')
return []
response_data = await response.json()
# Parse response
records = response_data.get('records', [])
results = []
for record in records:
content = record.get('content', '')
score = record.get('score', 0.0)
title = record.get('title', '')
metadata = record.get('metadata', {})
# Build metadata for result
result_metadata = {
'text': content,
'score': score,
'source': 'external_kb',
'kb_uuid': self.external_kb_entity.uuid,
'kb_name': self.external_kb_entity.name,
}
if title:
result_metadata['title'] = title
# Merge additional metadata
result_metadata.update(metadata)
results.append(rag_context.RetrievalResultEntry(score=score, metadata=result_metadata))
return results
except aiohttp.ClientError as e:
self.ap.logger.error(f'External KB HTTP error: {e}')
return []
except Exception as e:
self.ap.logger.error(f'External KB retrieval error: {e}')
return []
def get_uuid(self) -> str:
"""Get the UUID of the external knowledge base"""
return self.external_kb_entity.uuid
def get_name(self) -> str:
"""Get the name of the external knowledge base"""
return self.external_kb_entity.name
def get_type(self) -> str:
"""Get the type of knowledge base"""
return 'external'
async def dispose(self):
"""Clean up resources - no cleanup needed for external KB"""
pass

View File

@@ -10,10 +10,12 @@ from langbot.pkg.rag.knowledge.services.retriever import Retriever
import sqlalchemy
from langbot.pkg.entity.persistence import rag as persistence_rag
from langbot.pkg.core import taskmgr
from langbot.pkg.entity.rag import retriever as retriever_entities
from langbot_plugin.api.entities.rag import context as rag_context
from .base import KnowledgeBaseInterface
from .external import ExternalKnowledgeBase
class RuntimeKnowledgeBase:
class RuntimeKnowledgeBase(KnowledgeBaseInterface):
ap: app.Application
knowledge_base_entity: persistence_rag.KnowledgeBase
@@ -27,7 +29,7 @@ class RuntimeKnowledgeBase:
retriever: Retriever
def __init__(self, ap: app.Application, knowledge_base_entity: persistence_rag.KnowledgeBase):
self.ap = ap
super().__init__(ap)
self.knowledge_base_entity = knowledge_base_entity
self.parser = parser.FileParser(ap=self.ap)
self.chunker = chunker.Chunker(ap=self.ap)
@@ -187,7 +189,7 @@ class RuntimeKnowledgeBase:
return stored_file_tasks[0] if stored_file_tasks else ''
async def retrieve(self, query: str, top_k: int) -> list[retriever_entities.RetrieveResultEntry]:
async def retrieve(self, query: str, top_k: int) -> list[rag_context.RetrievalResultEntry]:
embedding_model = await self.ap.model_mgr.get_embedding_model_by_uuid(
self.knowledge_base_entity.embedding_model_uuid
)
@@ -206,6 +208,18 @@ class RuntimeKnowledgeBase:
sqlalchemy.delete(persistence_rag.File).where(persistence_rag.File.uuid == file_id)
)
def get_uuid(self) -> str:
"""Get the UUID of the knowledge base"""
return self.knowledge_base_entity.uuid
def get_name(self) -> str:
"""Get the name of the knowledge base"""
return self.knowledge_base_entity.name
def get_type(self) -> str:
"""Get the type of knowledge base"""
return 'internal'
async def dispose(self):
await self.ap.vector_db_mgr.vector_db.delete_collection(self.knowledge_base_entity.uuid)
@@ -213,7 +227,7 @@ class RuntimeKnowledgeBase:
class RAGManager:
ap: app.Application
knowledge_bases: list[RuntimeKnowledgeBase]
knowledge_bases: list[KnowledgeBaseInterface]
def __init__(self, ap: app.Application):
self.ap = ap
@@ -227,8 +241,8 @@ class RAGManager:
self.knowledge_bases = []
# Load internal knowledge bases
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_rag.KnowledgeBase))
knowledge_bases = result.all()
for knowledge_base in knowledge_bases:
@@ -239,6 +253,20 @@ class RAGManager:
f'Error loading knowledge base {knowledge_base.uuid}: {e}\n{traceback.format_exc()}'
)
# Load external knowledge bases
external_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_rag.ExternalKnowledgeBase)
)
external_kbs = external_result.all()
for external_kb in external_kbs:
try:
await self.load_external_knowledge_base(external_kb)
except Exception as e:
self.ap.logger.error(
f'Error loading external knowledge base {external_kb.uuid}: {e}\n{traceback.format_exc()}'
)
async def load_knowledge_base(
self,
knowledge_base_entity: persistence_rag.KnowledgeBase | sqlalchemy.Row | dict,
@@ -256,21 +284,39 @@ class RAGManager:
return runtime_knowledge_base
async def get_knowledge_base_by_uuid(self, kb_uuid: str) -> RuntimeKnowledgeBase | None:
async def load_external_knowledge_base(
self,
external_kb_entity: persistence_rag.ExternalKnowledgeBase | sqlalchemy.Row | dict,
) -> ExternalKnowledgeBase:
"""Load external knowledge base into runtime"""
if isinstance(external_kb_entity, sqlalchemy.Row):
external_kb_entity = persistence_rag.ExternalKnowledgeBase(**external_kb_entity._mapping)
elif isinstance(external_kb_entity, dict):
external_kb_entity = persistence_rag.ExternalKnowledgeBase(**external_kb_entity)
external_kb = ExternalKnowledgeBase(ap=self.ap, external_kb_entity=external_kb_entity)
await external_kb.initialize()
self.knowledge_bases.append(external_kb)
return external_kb
async def get_knowledge_base_by_uuid(self, kb_uuid: str) -> KnowledgeBaseInterface | None:
for kb in self.knowledge_bases:
if kb.knowledge_base_entity.uuid == kb_uuid:
if kb.get_uuid() == kb_uuid:
return kb
return None
async def remove_knowledge_base_from_runtime(self, kb_uuid: str):
for kb in self.knowledge_bases:
if kb.knowledge_base_entity.uuid == kb_uuid:
if kb.get_uuid() == kb_uuid:
self.knowledge_bases.remove(kb)
return
async def delete_knowledge_base(self, kb_uuid: str):
for kb in self.knowledge_bases:
if kb.knowledge_base_entity.uuid == kb_uuid:
if kb.get_uuid() == kb_uuid:
await kb.dispose()
self.knowledge_bases.remove(kb)
return

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from . import base_service
from ....core import app
from ....provider.modelmgr.requester import RuntimeEmbeddingModel
from ....entity.rag import retriever as retriever_entities
from langbot_plugin.api.entities.rag import context as rag_context
class Retriever(base_service.BaseService):
@@ -13,7 +13,7 @@ class Retriever(base_service.BaseService):
async def retrieve(
self, kb_id: str, query: str, embedding_model: RuntimeEmbeddingModel, k: int = 5
) -> list[retriever_entities.RetrieveResultEntry]:
) -> list[rag_context.RetrievalResultEntry]:
self.ap.logger.info(
f"Retrieving for query: '{query[:10]}' with k={k} using {embedding_model.model_entity.uuid}"
)
@@ -35,10 +35,10 @@ class Retriever(base_service.BaseService):
self.ap.logger.info('No relevant chunks found in vector database.')
return []
result: list[retriever_entities.RetrieveResultEntry] = []
result: list[rag_context.RetrievalResultEntry] = []
for i, id in enumerate(matched_vector_ids):
entry = retriever_entities.RetrieveResultEntry(
entry = rag_context.RetrievalResultEntry(
id=id,
metadata=vector_metadatas[i],
distance=distances[i],

View File

@@ -1,6 +1,4 @@
import langbot
semantic_version = f'v{langbot.__version__}'
semantic_version = 'v4.5.2'
required_database_version = 11
"""Tag the version of the database schema, used to check if the database needs to be migrated"""

View File

@@ -188,6 +188,40 @@ export default function HomeSidebar({
</div>
)}
<SidebarChild
onClick={() => {
// open docs.langbot.app
const language = localStorage.getItem('langbot_language');
if (language === 'zh-Hans') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else if (language === 'zh-Hant') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else {
window.open(
'https://docs.langbot.app/en/insight/guide.html',
'_blank',
);
}
}}
isSelected={false}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM13 13.3551V14H11V12.5C11 11.9477 11.4477 11.5 12 11.5C12.8284 11.5 13.5 10.8284 13.5 10C13.5 9.17157 12.8284 8.5 12 8.5C11.2723 8.5 10.6656 9.01823 10.5288 9.70577L8.56731 9.31346C8.88637 7.70919 10.302 6.5 12 6.5C13.933 6.5 15.5 8.067 15.5 10C15.5 11.5855 14.4457 12.9248 13 13.3551Z"></path>
</svg>
}
name={t('common.helpDocs')}
/>
<SidebarChild
onClick={() => {
setApiKeyDialogOpen(true);
@@ -268,41 +302,6 @@ export default function HomeSidebar({
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-medium">{t('common.account')}</span>
<Button
variant="ghost"
className="w-full justify-start font-normal"
onClick={() => {
// open docs.langbot.app
const language = localStorage.getItem('langbot_language');
if (language === 'zh-Hans') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else if (language === 'zh-Hant') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else {
window.open(
'https://docs.langbot.app/en/insight/guide.html',
'_blank',
);
}
setPopoverOpen(false);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4 mr-2"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM13 13.3551V14H11V12.5C11 11.9477 11.4477 11.5 12 11.5C12.8284 11.5 13.5 10.8284 13.5 10C13.5 9.17157 12.8284 8.5 12 8.5C11.2723 8.5 10.6656 9.01823 10.5288 9.70577L8.56731 9.31346C8.88637 7.70919 10.302 6.5 12 6.5C13.933 6.5 15.5 8.067 15.5 10C15.5 11.5855 14.4457 12.9248 13 13.3551Z"></path>
</svg>
{t('common.helpDocs')}
</Button>
<Button
variant="ghost"
className="w-full justify-start font-normal"

View File

@@ -0,0 +1,40 @@
import { ExternalKBCardVO } from '@/app/home/knowledge/components/external-kb-card/ExternalKBCardVO';
import { useTranslation } from 'react-i18next';
import styles from '../kb-card/KBCard.module.css';
export default function ExternalKBCard({
kbCardVO,
}: {
kbCardVO: ExternalKBCardVO;
}) {
const { t } = useTranslation();
return (
<div className={`${styles.cardContainer}`}>
<div className={`${styles.basicInfoContainer}`}>
<div className={`${styles.basicInfoNameContainer}`}>
<div className={`${styles.basicInfoNameText} ${styles.bigText}`}>
{kbCardVO.name}
</div>
<div className={`${styles.basicInfoDescriptionText}`}>
{kbCardVO.description}
</div>
</div>
<div className={`${styles.basicInfoLastUpdatedTimeContainer}`}>
<svg
className={`${styles.basicInfoUpdateTimeIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM13 12H17V14H11V7H13V12Z"></path>
</svg>
<div className={`${styles.basicInfoUpdateTimeText}`}>
{t('knowledge.updateTime')}
{kbCardVO.lastUpdatedTimeAgo}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
export class ExternalKBCardVO {
id: string;
name: string;
description: string;
apiUrl: string;
top_k: number;
lastUpdatedTimeAgo: string;
constructor({
id,
name,
description,
apiUrl,
top_k,
lastUpdatedTimeAgo,
}: {
id: string;
name: string;
description: string;
apiUrl: string;
top_k: number;
lastUpdatedTimeAgo: string;
}) {
this.id = id;
this.name = name;
this.description = description;
this.apiUrl = apiUrl;
this.top_k = top_k;
this.lastUpdatedTimeAgo = lastUpdatedTimeAgo;
}
}

View File

@@ -5,6 +5,7 @@
.knowledgeListContainer {
width: 100%;
margin-top: 2rem;
padding-left: 0.8rem;
padding-right: 0.8rem;
display: grid;

View File

@@ -5,21 +5,48 @@ import styles from './knowledgeBase.module.css';
import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react';
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
import { ExternalKBCardVO } from '@/app/home/knowledge/components/external-kb-card/ExternalKBCardVO';
import KBCard from '@/app/home/knowledge/components/kb-card/KBCard';
import ExternalKBCard from '@/app/home/knowledge/components/external-kb-card/ExternalKBCard';
import KBDetailDialog from '@/app/home/knowledge/KBDetailDialog';
import { httpClient } from '@/app/infra/http/HttpClient';
import { KnowledgeBase } from '@/app/infra/entities/api';
import { KnowledgeBase, ExternalKnowledgeBase } from '@/app/infra/entities/api';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';
export default function KnowledgePage() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('builtin');
const [knowledgeBaseList, setKnowledgeBaseList] = useState<KnowledgeBaseVO[]>(
[],
);
const [externalKBList, setExternalKBList] = useState<ExternalKBCardVO[]>([]);
const [selectedKbId, setSelectedKbId] = useState<string>('');
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [externalKBDialogOpen, setExternalKBDialogOpen] = useState(false);
const [editingExternalKB, setEditingExternalKB] =
useState<ExternalKnowledgeBase | null>(null);
const [externalKBForm, setExternalKBForm] = useState({
name: '',
description: '',
api_url: '',
api_key: '',
top_k: 5,
});
useEffect(() => {
getKnowledgeBaseList();
getExternalKBList();
}, []);
async function getKnowledgeBaseList() {
@@ -53,6 +80,41 @@ export default function KnowledgePage() {
);
}
async function getExternalKBList() {
try {
const resp = await httpClient.getExternalKnowledgeBases();
setExternalKBList(
resp.bases.map((kb: ExternalKnowledgeBase) => {
const currentTime = new Date();
const lastUpdatedTimeAgo = Math.floor(
(currentTime.getTime() -
new Date(kb.created_at ?? currentTime.getTime()).getTime()) /
1000 /
60 /
60 /
24,
);
const lastUpdatedTimeAgoText =
lastUpdatedTimeAgo > 0
? ` ${lastUpdatedTimeAgo} ${t('knowledge.daysAgo')}`
: t('knowledge.today');
return new ExternalKBCardVO({
id: kb.uuid || '',
name: kb.name,
description: kb.description,
apiUrl: kb.api_url,
top_k: kb.top_k ?? 5,
lastUpdatedTimeAgo: lastUpdatedTimeAgoText,
});
}),
);
} catch (error) {
console.error('Failed to load external knowledge bases:', error);
}
}
const handleKBCardClick = (kbId: string) => {
setSelectedKbId(kbId);
setDetailDialogOpen(true);
@@ -82,6 +144,77 @@ export default function KnowledgePage() {
getKnowledgeBaseList();
};
const handleExternalKBCardClick = (kbId: string) => {
const kb = externalKBList.find((kb) => kb.id === kbId);
if (kb) {
// Load full data
httpClient.getExternalKnowledgeBase(kbId).then((resp) => {
setEditingExternalKB(resp.base);
setExternalKBForm({
name: resp.base.name,
description: resp.base.description,
api_url: resp.base.api_url,
api_key: resp.base.api_key || '',
top_k: resp.base.top_k,
});
setExternalKBDialogOpen(true);
});
}
};
const handleCreateExternalKB = () => {
setEditingExternalKB(null);
setExternalKBForm({
name: '',
description: '',
api_url: '',
api_key: '',
top_k: 5,
});
setExternalKBDialogOpen(true);
};
const handleSaveExternalKB = async () => {
if (!externalKBForm.name || !externalKBForm.api_url) {
toast.error(t('knowledge.externalApiUrlRequired'));
return;
}
try {
if (editingExternalKB) {
await httpClient.updateExternalKnowledgeBase(
editingExternalKB.uuid!,
externalKBForm as ExternalKnowledgeBase,
);
toast.success(t('knowledge.updateExternalSuccess'));
} else {
await httpClient.createExternalKnowledgeBase(
externalKBForm as ExternalKnowledgeBase,
);
toast.success(t('knowledge.createExternalSuccess'));
}
setExternalKBDialogOpen(false);
getExternalKBList();
} catch (error) {
toast.error('Failed to save external knowledge base');
console.error(error);
}
};
const handleDeleteExternalKB = async () => {
if (!editingExternalKB) return;
try {
await httpClient.deleteExternalKnowledgeBase(editingExternalKB.uuid!);
toast.success(t('knowledge.deleteExternalSuccess'));
setExternalKBDialogOpen(false);
getExternalKBList();
} catch (error) {
toast.error('Failed to delete external knowledge base');
console.error(error);
}
};
return (
<div>
<KBDetailDialog
@@ -94,22 +227,159 @@ export default function KnowledgePage() {
onKbUpdated={handleKbUpdated}
/>
<div className={styles.knowledgeListContainer}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={handleCreateKBClick}
/>
{knowledgeBaseList.map((kb) => {
return (
<div key={kb.id} onClick={() => handleKBCardClick(kb.id)}>
<KBCard kbCardVO={kb} />
<Dialog open={externalKBDialogOpen} onOpenChange={setExternalKBDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{editingExternalKB
? t('knowledge.editKnowledgeBase')
: t('knowledge.addExternal')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
{t('knowledge.kbName')}
</label>
<Input
value={externalKBForm.name}
onChange={(e) =>
setExternalKBForm({ ...externalKBForm, name: e.target.value })
}
placeholder={t('knowledge.kbName')}
/>
</div>
);
})}
</div>
<div>
<label className="text-sm font-medium">
{t('knowledge.kbDescription')}
</label>
<Textarea
value={externalKBForm.description}
onChange={(e) =>
setExternalKBForm({
...externalKBForm,
description: e.target.value,
})
}
placeholder={t('knowledge.kbDescription')}
/>
</div>
<div>
<label className="text-sm font-medium">
{t('knowledge.externalApiUrl')}
</label>
<Input
value={externalKBForm.api_url}
onChange={(e) =>
setExternalKBForm({
...externalKBForm,
api_url: e.target.value,
})
}
placeholder={t('knowledge.externalApiUrlPlaceholder')}
/>
</div>
<div>
<label className="text-sm font-medium">
{t('knowledge.externalApiKey')}
</label>
<Input
value={externalKBForm.api_key}
onChange={(e) =>
setExternalKBForm({
...externalKBForm,
api_key: e.target.value,
})
}
placeholder={t('knowledge.externalApiKeyPlaceholder')}
type="password"
/>
</div>
<div>
<label className="text-sm font-medium">
{t('knowledge.topK')}
</label>
<Input
type="number"
min={1}
max={30}
value={externalKBForm.top_k}
onChange={(e) =>
setExternalKBForm({
...externalKBForm,
top_k: parseInt(e.target.value) || 5,
})
}
/>
</div>
</div>
<DialogFooter>
{editingExternalKB && (
<Button variant="destructive" onClick={handleDeleteExternalKB}>
{t('common.delete')}
</Button>
)}
<Button variant="outline" onClick={() => setExternalKBDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleSaveExternalKB}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<div className="flex flex-row justify-between items-center px-[0.8rem]">
<TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger value="builtin" className="px-6 py-4 cursor-pointer">
{t('knowledge.builtIn')}
</TabsTrigger>
<TabsTrigger value="external" className="px-6 py-4 cursor-pointer">
{t('knowledge.external')}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="builtin">
<div className={styles.knowledgeListContainer}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={handleCreateKBClick}
/>
{knowledgeBaseList.map((kb) => {
return (
<div key={kb.id} onClick={() => handleKBCardClick(kb.id)}>
<KBCard kbCardVO={kb} />
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="external">
<div className={styles.knowledgeListContainer}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={handleCreateExternalKB}
/>
{externalKBList.map((kb) => {
return (
<div
key={kb.id}
onClick={() => handleExternalKBCardClick(kb.id)}
>
<ExternalKBCard kbCardVO={kb} />
</div>
);
})}
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -146,26 +146,6 @@ export default function PipelineExtension({
);
};
const handleToggleAllPlugins = () => {
if (tempSelectedPluginIds.length === allPlugins.length) {
// Deselect all
setTempSelectedPluginIds([]);
} else {
// Select all
setTempSelectedPluginIds(allPlugins.map((p) => getPluginId(p)));
}
};
const handleToggleAllMCPServers = () => {
if (tempSelectedMCPIds.length === allMCPServers.length) {
// Deselect all
setTempSelectedMCPIds([]);
} else {
// Select all
setTempSelectedMCPIds(allMCPServers.map((s) => s.uuid || ''));
}
};
const handleConfirmPluginSelection = async () => {
const newSelected = allPlugins.filter((p) =>
tempSelectedPluginIds.includes(getPluginId(p)),
@@ -350,74 +330,49 @@ export default function PipelineExtension({
<DialogHeader>
<DialogTitle>{t('pipelines.extensions.selectPlugins')}</DialogTitle>
</DialogHeader>
{allPlugins.length > 0 && (
<div
className="flex items-center gap-3 px-1 py-2 border-b cursor-pointer"
onClick={handleToggleAllPlugins}
>
<Checkbox
checked={
tempSelectedPluginIds.length === allPlugins.length &&
allPlugins.length > 0
}
onCheckedChange={handleToggleAllPlugins}
/>
<span className="text-sm font-medium">
{t('pipelines.extensions.selectAll')}
</span>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{allPlugins.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noPluginsInstalled')}
</p>
</div>
) : (
allPlugins.map((plugin) => {
const pluginId = getPluginId(plugin);
const metadata = plugin.manifest.manifest.metadata;
const isSelected = tempSelectedPluginIds.includes(pluginId);
return (
<div
key={pluginId}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleTogglePlugin(pluginId)}
>
<Checkbox checked={isSelected} />
<img
src={backendClient.getPluginIconURL(
metadata.author || '',
metadata.name,
)}
alt={metadata.name}
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
/>
<div className="flex-1">
<div className="font-medium">{metadata.name}</div>
<div className="text-sm text-muted-foreground">
{metadata.author} v{metadata.version}
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={plugin.components}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
</div>
{!plugin.enabled && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
{allPlugins.map((plugin) => {
const pluginId = getPluginId(plugin);
const metadata = plugin.manifest.manifest.metadata;
const isSelected = tempSelectedPluginIds.includes(pluginId);
return (
<div
key={pluginId}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleTogglePlugin(pluginId)}
>
<Checkbox checked={isSelected} />
<img
src={backendClient.getPluginIconURL(
metadata.author || '',
metadata.name,
)}
alt={metadata.name}
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
/>
<div className="flex-1">
<div className="font-medium">{metadata.name}</div>
<div className="text-sm text-muted-foreground">
{metadata.author} v{metadata.version}
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={plugin.components}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
</div>
);
})
)}
{!plugin.enabled && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
</div>
);
})}
</div>
<DialogFooter>
<Button
@@ -441,74 +396,47 @@ export default function PipelineExtension({
{t('pipelines.extensions.selectMCPServers')}
</DialogTitle>
</DialogHeader>
{allMCPServers.length > 0 && (
<div
className="flex items-center gap-3 px-1 py-2 border-b cursor-pointer"
onClick={handleToggleAllMCPServers}
>
<Checkbox
checked={
tempSelectedMCPIds.length === allMCPServers.length &&
allMCPServers.length > 0
}
onCheckedChange={handleToggleAllMCPServers}
/>
<span className="text-sm font-medium">
{t('pipelines.extensions.selectAll')}
</span>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{allMCPServers.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noMCPServersConfigured')}
</p>
</div>
) : (
allMCPServers.map((server) => {
const isSelected = tempSelectedMCPIds.includes(
server.uuid || '',
);
return (
<div
key={server.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleToggleMCPServer(server.uuid || '')}
>
<Checkbox checked={isSelected} />
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Server className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium">{server.name}</div>
<div className="text-sm text-muted-foreground">
{server.mode}
</div>
{server.runtime_info &&
server.runtime_info.status === 'connected' && (
<Badge
variant="outline"
className="flex items-center gap-1 mt-1"
>
<Wrench className="h-3 w-3 text-black dark:text-white" />
<span className="text-xs text-black dark:text-white">
{t('pipelines.extensions.toolCount', {
count: server.runtime_info.tool_count || 0,
})}
</span>
</Badge>
)}
</div>
{!server.enable && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
{allMCPServers.map((server) => {
const isSelected = tempSelectedMCPIds.includes(server.uuid || '');
return (
<div
key={server.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleToggleMCPServer(server.uuid || '')}
>
<Checkbox checked={isSelected} />
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Server className="h-5 w-5 text-muted-foreground" />
</div>
);
})
)}
<div className="flex-1">
<div className="font-medium">{server.name}</div>
<div className="text-sm text-muted-foreground">
{server.mode}
</div>
{server.runtime_info &&
server.runtime_info.status === 'connected' && (
<Badge
variant="outline"
className="flex items-center gap-1 mt-1"
>
<Wrench className="h-3 w-3 text-black dark:text-white" />
<span className="text-xs text-black dark:text-white">
{t('pipelines.extensions.toolCount', {
count: server.runtime_info.tool_count || 0,
})}
</span>
</Badge>
)}
</div>
{!server.enable && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
</div>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setMcpDialogOpen(false)}>

View File

@@ -274,7 +274,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
</Dialog>
{pluginList.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"

View File

@@ -57,7 +57,6 @@ function MarketPageContent({
const pageSize = 16; // 每页16个4行x4列
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
// 排序选项
const sortOptions: SortOption[] = [
@@ -263,21 +262,19 @@ function MarketPageContent({
}
}, [currentPage, isLoadingMore, hasMore, fetchPlugins, searchQuery]);
// Listen to scroll events on the scroll container
// 监听滚动事件
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
// Load more when scrolled to within 100px of the bottom
if (scrollTop + clientHeight >= scrollHeight - 100) {
if (
window.innerHeight + document.documentElement.scrollTop >=
document.documentElement.offsetHeight - 100
) {
loadMore();
}
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => scrollContainer.removeEventListener('scroll', handleScroll);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [loadMore]);
// 安装插件
@@ -286,109 +283,99 @@ function MarketPageContent({
// };
return (
<div className="h-full flex flex-col">
{/* Fixed header with search and sort controls */}
<div className="flex-shrink-0 space-y-4 px-3 sm:px-4 py-4 sm:py-6">
{/* Search box */}
<div className="flex items-center justify-center">
<div className="relative w-full max-w-2xl">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder={t('market.searchPlaceholder')}
value={searchQuery}
onChange={(e) => handleSearchInputChange(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
// Immediately search, clear debounce timer
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
handleSearch(searchQuery);
<div className="container mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-4 sm:space-y-6">
{/* 搜索框 */}
<div className="flex items-center justify-center">
<div className="relative w-full max-w-2xl">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder={t('market.searchPlaceholder')}
value={searchQuery}
onChange={(e) => handleSearchInputChange(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
// 立即搜索,清除防抖定时器
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
}}
className="pl-10 pr-4 text-sm sm:text-base"
/>
</div>
handleSearch(searchQuery);
}
}}
className="pl-10 pr-4 text-sm sm:text-base"
/>
</div>
{/* Sort dropdown */}
<div className="flex items-center justify-center">
<div className="w-full max-w-2xl flex items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.sortBy')}:
</span>
<Select value={sortOption} onValueChange={handleSortChange}>
<SelectTrigger className="w-40 sm:w-48 text-xs sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Search results stats */}
{total > 0 && (
<div className="text-center text-muted-foreground text-sm">
{searchQuery
? t('market.searchResults', { count: total })
: t('market.totalPlugins', { count: total })}
</div>
)}
</div>
{/* Scrollable content area */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto px-3 sm:px-4"
>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">{t('market.loading')}</span>
</div>
) : plugins.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">
{searchQuery ? t('market.noResults') : t('market.noPlugins')}
</div>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6 pb-6">
{plugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.pluginId}
cardVO={plugin}
onPluginClick={handlePluginClick}
/>
{/* 排序下拉框 */}
<div className="flex items-center justify-center">
<div className="w-full max-w-2xl flex items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.sortBy')}:
</span>
<Select value={sortOption} onValueChange={handleSortChange}>
<SelectTrigger className="w-40 sm:w-48 text-xs sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</div>
{/* Loading more indicator */}
{isLoadingMore && (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="ml-2">{t('market.loadingMore')}</span>
</div>
)}
{/* No more data hint */}
{!hasMore && plugins.length > 0 && (
<div className="text-center text-muted-foreground py-6">
{t('market.allLoaded')}
</div>
)}
</>
)}
</SelectContent>
</Select>
</div>
</div>
{/* Plugin detail dialog */}
{/* 搜索结果统计 */}
{total > 0 && (
<div className="text-center text-muted-foreground text-sm">
{searchQuery
? t('market.searchResults', { count: total })
: t('market.totalPlugins', { count: total })}
</div>
)}
{/* 插件列表 */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">{t('market.loading')}</span>
</div>
) : plugins.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">
{searchQuery ? t('market.noResults') : t('market.noPlugins')}
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6">
{plugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.pluginId}
cardVO={plugin}
onPluginClick={handlePluginClick}
/>
))}
</div>
)}
{/* 加载更多指示器 */}
{isLoadingMore && (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="ml-2">{t('market.loadingMore')}</span>
</div>
)}
{/* 没有更多数据提示 */}
{!hasMore && plugins.length > 0 && (
<div className="text-center text-muted-foreground py-6">
{t('market.allLoaded')}
</div>
)}
{/* 插件详情对话框 */}
<PluginDetailDialog
open={dialogOpen}
onOpenChange={handleDialogClose}

View File

@@ -73,14 +73,14 @@ export default function MCPComponent({
return (
<div className="w-full h-full">
{/* Server list */}
<div className="w-full h-full px-[0.8rem] pt-[0rem]">
{/* 已安装的服务器列表 */}
<div className="w-full px-[0.8rem] pt-[0rem] gap-4">
{loading ? (
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
{t('mcp.loading')}
</div>
) : installedServers.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
@@ -92,7 +92,7 @@ export default function MCPComponent({
<div className="text-lg mb-2">{t('mcp.noServerInstalled')}</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pt-[2rem] pb-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pt-[2rem]">
{installedServers.map((server, index) => (
<div key={`${server.name}-${index}`}>
<MCPCardComponent

View File

@@ -431,7 +431,7 @@ export default function PluginConfigPage() {
return (
<div
className={`${styles.pageContainer} h-full flex flex-col ${isDragOver ? 'bg-blue-50' : ''}`}
className={`${styles.pageContainer} ${isDragOver ? 'bg-blue-50' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -443,12 +443,8 @@ export default function PluginConfigPage() {
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full h-full flex flex-col"
>
<div className="flex flex-row justify-between items-center px-[0.8rem] flex-shrink-0">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<div className="flex flex-row justify-between items-center px-[0.8rem]">
<TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger value="installed" className="px-6 py-4 cursor-pointer">
{t('plugins.installed')}
@@ -526,10 +522,10 @@ export default function PluginConfigPage() {
</DropdownMenu>
</div>
</div>
<TabsContent value="installed" className="flex-1 overflow-y-auto mt-0">
<TabsContent value="installed">
<PluginInstalledComponent ref={pluginInstalledRef} />
</TabsContent>
<TabsContent value="market" className="flex-1 overflow-y-auto mt-0">
<TabsContent value="market">
<MarketPage
installPlugin={(plugin: PluginV4) => {
setInstallSource('marketplace');
@@ -543,10 +539,7 @@ export default function PluginConfigPage() {
}}
/>
</TabsContent>
<TabsContent
value="mcp-servers"
className="flex-1 overflow-y-auto mt-0"
>
<TabsContent value="mcp-servers">
<MCPServerComponent
key={refreshKey}
onEditServer={(serverName) => {

View File

@@ -162,6 +162,24 @@ export interface KnowledgeBase {
updated_at?: string;
}
export interface ExternalKnowledgeBase {
uuid?: string;
name: string;
description: string;
api_url: string;
api_key?: string;
top_k: number;
created_at?: string;
}
export interface ApiRespExternalKnowledgeBases {
bases: ExternalKnowledgeBase[];
}
export interface ApiRespExternalKnowledgeBase {
base: ExternalKnowledgeBase;
}
export interface ApiRespKnowledgeBaseFiles {
files: KnowledgeBaseFile[];
}

View File

@@ -36,6 +36,9 @@ import {
ApiRespMCPServers,
ApiRespMCPServer,
MCPServer,
ExternalKnowledgeBase,
ApiRespExternalKnowledgeBases,
ApiRespExternalKnowledgeBase,
} from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
@@ -439,6 +442,43 @@ export class BackendClient extends BaseHttpClient {
return this.post(`/api/v1/knowledge/bases/${uuid}/retrieve`, { query });
}
// ============ External Knowledge Base API ============
public getExternalKnowledgeBases(): Promise<ApiRespExternalKnowledgeBases> {
return this.get('/api/v1/knowledge/external-bases');
}
public getExternalKnowledgeBase(
uuid: string,
): Promise<ApiRespExternalKnowledgeBase> {
return this.get(`/api/v1/knowledge/external-bases/${uuid}`);
}
public createExternalKnowledgeBase(
base: ExternalKnowledgeBase,
): Promise<{ uuid: string }> {
return this.post('/api/v1/knowledge/external-bases', base);
}
public updateExternalKnowledgeBase(
uuid: string,
base: ExternalKnowledgeBase,
): Promise<{ uuid: string }> {
return this.put(`/api/v1/knowledge/external-bases/${uuid}`, base);
}
public deleteExternalKnowledgeBase(uuid: string): Promise<object> {
return this.delete(`/api/v1/knowledge/external-bases/${uuid}`);
}
public retrieveExternalKnowledgeBase(
uuid: string,
query: string,
): Promise<ApiRespKnowledgeBaseRetrieve> {
return this.post(`/api/v1/knowledge/external-bases/${uuid}/retrieve`, {
query,
});
}
// ============ Plugins API ============
public getPlugins(): Promise<ApiRespPlugins> {
return this.get('/api/v1/plugins');

View File

@@ -482,9 +482,6 @@ const enUS = {
addMCPServer: 'Add MCP Server',
selectMCPServers: 'Select MCP Servers',
toolCount: '{{count}} tools',
noPluginsInstalled: 'No installed plugins',
noMCPServersConfigured: 'No configured MCP servers',
selectAll: 'Select All',
},
debugDialog: {
title: 'Pipeline Chat',
@@ -574,6 +571,19 @@ const enUS = {
fileName: 'File Name',
noResults: 'No results',
retrieveError: 'Retrieve failed',
builtIn: 'Built-in',
external: 'External',
addExternal: 'Add External Knowledge Base',
externalApiUrl: 'API URL',
externalApiUrlPlaceholder: 'Enter external knowledge base API URL',
externalApiUrlRequired: 'API URL cannot be empty',
externalApiKey: 'API Key (Optional)',
externalApiKeyPlaceholder: 'Enter API key if required',
externalKbDescription:
'External knowledge bases retrieve documents via HTTP API',
createExternalSuccess: 'External knowledge base created successfully',
updateExternalSuccess: 'External knowledge base updated successfully',
deleteExternalSuccess: 'External knowledge base deleted successfully',
},
register: {
title: 'Initialize LangBot 👋',

View File

@@ -485,9 +485,6 @@ const jaJP = {
addMCPServer: 'MCPサーバーを追加',
selectMCPServers: 'MCPサーバーを選択',
toolCount: '{{count}}個のツール',
noPluginsInstalled: 'インストールされているプラグインがありません',
noMCPServersConfigured: '設定されているMCPサーバーがありません',
selectAll: 'すべて選択',
},
debugDialog: {
title: 'パイプラインのチャット',
@@ -578,6 +575,18 @@ const jaJP = {
fileName: 'ファイル名',
noResults: '検索結果がありません',
retrieveError: '検索に失敗しました',
builtIn: '内蔵',
external: '外部ナレッジベース',
addExternal: '外部ナレッジベースを追加',
externalApiUrl: 'API URL',
externalApiUrlPlaceholder: '外部ナレッジベースのAPI URLを入力',
externalApiUrlRequired: 'API URLは空にできません',
externalApiKey: 'API キー(オプション)',
externalApiKeyPlaceholder: '必要に応じてAPIキーを入力してください',
externalKbDescription: '外部ナレッジベースはHTTP APIを介してドキュメントを取得します',
createExternalSuccess: '外部ナレッジベースが正常に作成されました',
updateExternalSuccess: '外部ナレッジベースが正常に更新されました',
deleteExternalSuccess: '外部ナレッジベースが正常に削除されました',
},
register: {
title: 'LangBot を初期化 👋',

View File

@@ -464,9 +464,6 @@ const zhHans = {
addMCPServer: '添加 MCP 服务器',
selectMCPServers: '选择 MCP 服务器',
toolCount: '{{count}} 个工具',
noPluginsInstalled: '无已安装的插件',
noMCPServersConfigured: '无已配置的 MCP 服务器',
selectAll: '全选',
},
debugDialog: {
title: '流水线对话',
@@ -551,6 +548,18 @@ const zhHans = {
fileName: '文件名',
noResults: '暂无结果',
retrieveError: '检索失败',
builtIn: '内置',
external: '外部知识库',
addExternal: '添加外部知识库',
externalApiUrl: 'API 地址',
externalApiUrlPlaceholder: '输入外部知识库 API 地址',
externalApiUrlRequired: 'API 地址不能为空',
externalApiKey: 'API 密钥(可选)',
externalApiKeyPlaceholder: '如需要请输入 API 密钥',
externalKbDescription: '外部知识库通过 HTTP API 检索文档',
createExternalSuccess: '外部知识库创建成功',
updateExternalSuccess: '外部知识库更新成功',
deleteExternalSuccess: '外部知识库删除成功',
},
register: {
title: '初始化 LangBot 👋',

View File

@@ -462,9 +462,6 @@ const zhHant = {
addMCPServer: '新增 MCP 伺服器',
selectMCPServers: '選擇 MCP 伺服器',
toolCount: '{{count}} 個工具',
noPluginsInstalled: '無已安裝的插件',
noMCPServersConfigured: '無已配置的 MCP 伺服器',
selectAll: '全選',
},
debugDialog: {
title: '流程線對話',
@@ -548,6 +545,18 @@ const zhHant = {
fileName: '文檔名稱',
noResults: '暫無結果',
retrieveError: '檢索失敗',
builtIn: '內置',
external: '外部知識庫',
addExternal: '添加外部知識庫',
externalApiUrl: 'API 地址',
externalApiUrlPlaceholder: '輸入外部知識庫 API 地址',
externalApiUrlRequired: 'API 地址不能為空',
externalApiKey: 'API 密鑰(可選)',
externalApiKeyPlaceholder: '如需要請輸入 API 密鑰',
externalKbDescription: '外部知識庫通過 HTTP API 檢索文檔',
createExternalSuccess: '外部知識庫創建成功',
updateExternalSuccess: '外部知識庫更新成功',
deleteExternalSuccess: '外部知識庫刪除成功',
},
register: {
title: '初始化 LangBot 👋',