mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-01 01:10:21 +08:00
fix: paginate knowledge base dashboard lists (#9055)
* fix: paginate knowledge base dashboard lists * fix: preserve knowledge document search pagination
This commit is contained in:
@@ -219,25 +219,45 @@ class KBSQLiteDatabase:
|
||||
kb_id: str,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
search: str | None = None,
|
||||
) -> list[KBDocument]:
|
||||
"""列出知识库的所有文档"""
|
||||
"""List documents in a knowledge base.
|
||||
|
||||
Args:
|
||||
kb_id: Knowledge base ID.
|
||||
offset: Number of documents to skip.
|
||||
limit: Maximum number of documents to return.
|
||||
search: Optional partial match on document name; disabled when None or empty.
|
||||
|
||||
Returns:
|
||||
List of matching KBDocument rows.
|
||||
"""
|
||||
async with self.get_db() as session:
|
||||
stmt = select(KBDocument).where(col(KBDocument.kb_id) == kb_id)
|
||||
if search:
|
||||
stmt = stmt.where(col(KBDocument.doc_name).contains(search))
|
||||
stmt = (
|
||||
select(KBDocument)
|
||||
.where(col(KBDocument.kb_id) == kb_id)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.order_by(desc(KBDocument.created_at))
|
||||
stmt.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 def count_documents_by_kb(self, kb_id: str, search: str | None = None) -> int:
|
||||
"""Count documents in a knowledge base.
|
||||
|
||||
Args:
|
||||
kb_id: Knowledge base ID.
|
||||
search: Optional partial match on document name; disabled when None or empty.
|
||||
|
||||
Returns:
|
||||
Total number of matching documents.
|
||||
"""
|
||||
async with self.get_db() as session:
|
||||
stmt = select(func.count(col(KBDocument.id))).where(
|
||||
col(KBDocument.kb_id) == kb_id,
|
||||
)
|
||||
if search:
|
||||
stmt = stmt.where(col(KBDocument.doc_name).contains(search))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
@@ -469,11 +469,37 @@ class KBHelper:
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
search: str | None = None,
|
||||
) -> list[KBDocument]:
|
||||
"""列出知识库的所有文档"""
|
||||
docs = await self.kb_db.list_documents_by_kb(self.kb.kb_id, offset, limit)
|
||||
"""List documents in the knowledge base.
|
||||
|
||||
Args:
|
||||
offset: Number of documents to skip.
|
||||
limit: Maximum number of documents to return.
|
||||
search: Optional partial match on document name; disabled when None or empty.
|
||||
|
||||
Returns:
|
||||
List of matching KBDocument rows.
|
||||
"""
|
||||
docs = await self.kb_db.list_documents_by_kb(
|
||||
self.kb.kb_id,
|
||||
offset,
|
||||
limit,
|
||||
search=search,
|
||||
)
|
||||
return docs
|
||||
|
||||
async def count_documents(self, search: str | None = None) -> int:
|
||||
"""Count documents in the knowledge base.
|
||||
|
||||
Args:
|
||||
search: Optional partial match on document name; disabled when None or empty.
|
||||
|
||||
Returns:
|
||||
Total number of matching documents.
|
||||
"""
|
||||
return await self.kb_db.count_documents_by_kb(self.kb.kb_id, search=search)
|
||||
|
||||
async def get_document(self, doc_id: str) -> KBDocument | None:
|
||||
"""获取单个文档"""
|
||||
doc = await self.kb_db.get_document_by_id(doc_id)
|
||||
|
||||
@@ -182,6 +182,7 @@ async def list_knowledge_base_documents(
|
||||
kb_id=kb_id,
|
||||
page=_to_int(request.query_params.get("page"), 1),
|
||||
page_size=_to_int(request.query_params.get("page_size"), 100),
|
||||
search=request.query_params.get("search"),
|
||||
),
|
||||
prefix="获取文档列表失败",
|
||||
)
|
||||
@@ -390,6 +391,7 @@ async def dashboard_list_documents(
|
||||
kb_id=request.query_params.get("kb_id"),
|
||||
page=_to_int(request.query_params.get("page"), 1),
|
||||
page_size=_to_int(request.query_params.get("page_size"), 100),
|
||||
search=request.query_params.get("search"),
|
||||
),
|
||||
prefix="获取文档列表失败",
|
||||
)
|
||||
|
||||
@@ -266,16 +266,24 @@ class KnowledgeBaseService:
|
||||
async def list_kbs(self, *, page: int, page_size: int) -> dict[str, Any]:
|
||||
kb_manager = self.get_kb_manager()
|
||||
kbs = await kb_manager.list_kbs()
|
||||
total = len(kbs)
|
||||
|
||||
# Clamp page and page_size to at least 1 before calculating offsets/slices.
|
||||
page = max(page, 1)
|
||||
page_size = max(page_size, 1)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
paged_kbs = kbs[start:end]
|
||||
|
||||
kb_list = []
|
||||
for kb in kbs:
|
||||
for kb in paged_kbs:
|
||||
kb_dict = kb.model_dump()
|
||||
kb_helper = await kb_manager.get_kb(kb.kb_id)
|
||||
if kb_helper and kb_helper.init_error:
|
||||
kb_dict["init_error"] = kb_helper.init_error
|
||||
kb_list.append(kb_dict)
|
||||
|
||||
return {"items": kb_list, "page": page, "page_size": page_size}
|
||||
return {"items": kb_list, "page": page, "page_size": page_size, "total": total}
|
||||
|
||||
async def list_kbs_from_dashboard_query(self, *, page, page_size) -> dict[str, Any]:
|
||||
return await self.list_kbs(
|
||||
@@ -437,6 +445,7 @@ class KnowledgeBaseService:
|
||||
kb_id: str | None,
|
||||
page: int,
|
||||
page_size: int,
|
||||
search: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not kb_id:
|
||||
raise KnowledgeBaseServiceError("缺少参数 kb_id")
|
||||
@@ -444,12 +453,25 @@ class KnowledgeBaseService:
|
||||
if not kb_helper:
|
||||
raise KnowledgeBaseServiceError("知识库不存在")
|
||||
|
||||
if search is not None:
|
||||
search = search.strip()
|
||||
if not search:
|
||||
search = None
|
||||
|
||||
page = max(page, 1)
|
||||
page_size = max(page_size, 1)
|
||||
offset = (page - 1) * page_size
|
||||
doc_list = await kb_helper.list_documents(offset=offset, limit=page_size)
|
||||
doc_list = await kb_helper.list_documents(
|
||||
offset=offset,
|
||||
limit=page_size,
|
||||
search=search,
|
||||
)
|
||||
total = await kb_helper.count_documents(search=search)
|
||||
return {
|
||||
"items": [doc.model_dump() for doc in doc_list],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
async def list_documents_from_dashboard_query(
|
||||
@@ -458,11 +480,13 @@ class KnowledgeBaseService:
|
||||
kb_id: str | None,
|
||||
page,
|
||||
page_size,
|
||||
search: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self.list_documents(
|
||||
kb_id=kb_id,
|
||||
page=self._to_int(page, 1),
|
||||
page_size=self._to_int(page_size, 100),
|
||||
search=search,
|
||||
)
|
||||
|
||||
async def upload_document(
|
||||
|
||||
@@ -2661,6 +2661,10 @@ export type ListKnowledgeDocumentsData = {
|
||||
query?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
/**
|
||||
* Filter documents by name (case-insensitive partial match).
|
||||
*/
|
||||
search?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1384,7 +1384,7 @@ export const knowledgeApi = {
|
||||
openApiV1.deleteKnowledgeBase({ path: { kb_id: kbId } }),
|
||||
);
|
||||
},
|
||||
documents(kbId: string, params?: { page?: number; page_size?: number }) {
|
||||
documents(kbId: string, params?: { page?: number; page_size?: number; search?: string }) {
|
||||
return typed<any>(
|
||||
openApiV1.listKnowledgeDocuments({
|
||||
path: { kb_id: kbId },
|
||||
|
||||
@@ -79,6 +79,15 @@
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</OutlinedActionListItem>
|
||||
|
||||
<v-pagination
|
||||
v-if="total > pageSize"
|
||||
v-model="page"
|
||||
:length="Math.ceil(total / pageSize)"
|
||||
:total-visible="7"
|
||||
class="mt-4"
|
||||
@update:model-value="loadKnowledgeBases()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
@@ -269,6 +278,9 @@ const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const kbList = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const embeddingProviders = ref<any[]>([])
|
||||
const rerankProviders = ref<any[]>([])
|
||||
const originalEmbeddingProvider = ref<string | null>(null)
|
||||
@@ -324,18 +336,18 @@ const emojiCategories = [
|
||||
const loadKnowledgeBases = async (refreshStats = false) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (refreshStats) {
|
||||
params.refresh_stats = 'true'
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
const response = await knowledgeApi.list({
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
refresh_stats: params.refresh_stats === 'true'
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
refresh_stats: refreshStats
|
||||
})
|
||||
if (response.data.status === 'ok') {
|
||||
kbList.value = response.data.data.items || []
|
||||
const data = response.data.data
|
||||
kbList.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} else {
|
||||
showSnackbar(response.data.message || t('messages.loadError'), 'error')
|
||||
}
|
||||
@@ -407,7 +419,9 @@ const deleteKB = async () => {
|
||||
|
||||
if (response.data.status === 'ok') {
|
||||
showSnackbar(t('messages.deleteSuccess'))
|
||||
// 先刷新列表,再关闭对话框
|
||||
if (kbList.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1
|
||||
}
|
||||
await loadKnowledgeBases()
|
||||
showDeleteDialog.value = false
|
||||
deleteTarget.value = null
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
|
||||
<!-- 文档列表 -->
|
||||
<v-card variant="outlined">
|
||||
<v-data-table :headers="headers" :items="documents" :loading="loading" :search="searchQuery" :items-per-page="10">
|
||||
<v-data-table-server :headers="headers" :items="documents" :loading="loading"
|
||||
:items-per-page="pageSize" :page="page" :items-length="total"
|
||||
@update:page="onPageChange" @update:items-per-page="onItemsPerPageChange">
|
||||
<template #item.doc_name="{ item }">
|
||||
<div class="d-flex align-center gap-2">
|
||||
<v-icon :color="getFileColor(item.file_type)" class="mr-2">
|
||||
@@ -53,7 +55,7 @@
|
||||
<p class="mt-4 text-medium-emphasis">{{ t('documents.empty') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-data-table-server>
|
||||
</v-card>
|
||||
|
||||
<!-- 上传对话框 -->
|
||||
@@ -236,7 +238,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import TavilyKeyDialog from './TavilyKeyDialog.vue'
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { configProfileApi, knowledgeApi, providerApi } from '@/api/v1'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
@@ -256,6 +258,9 @@ const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const deleting = ref(false)
|
||||
const documents = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const searchQuery = ref('')
|
||||
const showUploadDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
@@ -340,9 +345,15 @@ const headers = [
|
||||
const loadDocuments = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await knowledgeApi.documents(props.kbId)
|
||||
const response = await knowledgeApi.documents(props.kbId, {
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
search: searchQuery.value.trim() || undefined,
|
||||
})
|
||||
if (response.data.status === 'ok') {
|
||||
documents.value = response.data.data.items || []
|
||||
const data = response.data.data
|
||||
documents.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load documents:', error)
|
||||
@@ -352,6 +363,18 @@ const loadDocuments = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle pagination
|
||||
const onPageChange = (newPage: number) => {
|
||||
page.value = newPage
|
||||
loadDocuments()
|
||||
}
|
||||
|
||||
const onItemsPerPageChange = (newSize: number) => {
|
||||
pageSize.value = newSize
|
||||
page.value = 1
|
||||
loadDocuments()
|
||||
}
|
||||
|
||||
// 文件选择
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
@@ -591,7 +614,7 @@ const startProgressPolling = (taskId: string) => {
|
||||
// 移除上传中的占位文档
|
||||
documents.value = documents.value.filter(doc => doc.taskId !== taskId)
|
||||
|
||||
// 重新加载文档列表
|
||||
// Reload current page
|
||||
await loadDocuments()
|
||||
emit('refresh')
|
||||
|
||||
@@ -684,6 +707,10 @@ const deleteDocument = async () => {
|
||||
if (response.data.status === 'ok') {
|
||||
showSnackbar(t('documents.deleteSuccess'))
|
||||
showDeleteDialog.value = false
|
||||
// If current page becomes empty after delete and is not the first page, go back one page
|
||||
if (documents.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1
|
||||
}
|
||||
await loadDocuments()
|
||||
emit('refresh')
|
||||
} else {
|
||||
@@ -782,6 +809,12 @@ const onTavilyKeySet = () => {
|
||||
checkTavilyConfig()
|
||||
}
|
||||
|
||||
// Reset to page 1 and reload when search text changes
|
||||
watch(searchQuery, () => {
|
||||
page.value = 1
|
||||
loadDocuments()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadDocuments()
|
||||
loadLlmProviders()
|
||||
|
||||
@@ -3446,6 +3446,12 @@ paths:
|
||||
- $ref: "#/components/parameters/KbId"
|
||||
- $ref: "#/components/parameters/Page"
|
||||
- $ref: "#/components/parameters/PageSize"
|
||||
- name: search
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Filter documents by name (case-insensitive partial match).
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Ok"
|
||||
|
||||
@@ -293,3 +293,67 @@ async def test_import_documents_invalid_input(
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "error"
|
||||
assert "chunks 必须是非空字符串列表" in data["message"]
|
||||
|
||||
|
||||
def _make_service_with_mock_kb_helper():
|
||||
"""Create a KnowledgeBaseService whose kb_manager returns a mock kb_helper.
|
||||
|
||||
Returns:
|
||||
Tuple of (service, kb_helper).
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
kb_helper = AsyncMock()
|
||||
kb_helper.list_documents = AsyncMock()
|
||||
kb_helper.count_documents = AsyncMock()
|
||||
|
||||
kb_manager = MagicMock()
|
||||
kb_manager.get_kb = AsyncMock(return_value=kb_helper)
|
||||
|
||||
service = KnowledgeBaseService.__new__(KnowledgeBaseService)
|
||||
service.core_lifecycle = MagicMock()
|
||||
service.core_lifecycle.kb_manager = kb_manager
|
||||
service.upload_progress = {}
|
||||
service.upload_tasks = {}
|
||||
return service, kb_helper
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_documents_clamps_page_and_page_size_below_one():
|
||||
"""page and page_size below 1 are clamped to 1 before calling kb_helper."""
|
||||
service, kb_helper = _make_service_with_mock_kb_helper()
|
||||
kb_helper.list_documents.return_value = []
|
||||
kb_helper.count_documents.return_value = 0
|
||||
|
||||
await service.list_documents(kb_id="kb1", page=0, page_size=-5)
|
||||
|
||||
kb_helper.list_documents.assert_awaited_once_with(offset=0, limit=1, search=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_documents_trims_search_and_turns_empty_to_none():
|
||||
"""search is stripped; whitespace-only search becomes None."""
|
||||
service, kb_helper = _make_service_with_mock_kb_helper()
|
||||
kb_helper.list_documents.return_value = []
|
||||
kb_helper.count_documents.return_value = 0
|
||||
|
||||
await service.list_documents(kb_id="kb1", page=1, page_size=10, search=" ")
|
||||
|
||||
kb_helper.list_documents.assert_awaited_once_with(
|
||||
offset=0, limit=10, search=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_documents_total_comes_from_count_documents():
|
||||
"""total uses count_documents(search=normalized_search), not stale kb.doc_count."""
|
||||
service, kb_helper = _make_service_with_mock_kb_helper()
|
||||
kb_helper.list_documents.return_value = []
|
||||
kb_helper.count_documents.return_value = 42
|
||||
|
||||
result = await service.list_documents(
|
||||
kb_id="kb1", page=1, page_size=10, search=" foo ",
|
||||
)
|
||||
|
||||
assert result["total"] == 42
|
||||
kb_helper.count_documents.assert_awaited_once_with(search="foo")
|
||||
|
||||
Reference in New Issue
Block a user