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
22 changed files with 859 additions and 48 deletions

View File

@@ -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

@@ -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

@@ -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

@@ -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

@@ -571,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

@@ -575,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

@@ -548,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

@@ -545,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 👋',