Merge remote-tracking branch 'wizard/master'

This commit is contained in:
wizardchen
2025-11-24 10:55:12 +08:00
55 changed files with 4321 additions and 1523 deletions
+17
View File
@@ -10,10 +10,12 @@
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@types/dompurify": "^3.0.5",
"@types/papaparse": "^5.5.0",
"axios": "^1.8.4",
"dompurify": "^3.2.6",
"marked": "^5.1.2",
"pagefind": "^1.1.1",
"papaparse": "^5.5.3",
"pinia": "^3.0.1",
"swiper": "^12.0.3",
"tdesign-icons-vue-next": "^0.4.1",
@@ -1394,6 +1396,15 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/papaparse": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.0.tgz",
"integrity": "sha512-GVs5iMQmUr54BAZYYkByv8zPofFxmyxUpISPb2oh8sayR3+1zbxasrOvoKiHJ/nnoq/uULuPsu1Lze1EkagVFg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/sortablejs": {
"version": "1.15.8",
"resolved": "https://mirrors.tencent.com/npm/@types/sortablejs/-/sortablejs-1.15.8.tgz",
@@ -3046,6 +3057,12 @@
"@pagefind/windows-x64": "1.3.0"
}
},
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
"license": "MIT"
},
"node_modules/parse-node-version": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/parse-node-version/-/parse-node-version-1.0.1.tgz",
+2
View File
@@ -14,10 +14,12 @@
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@types/dompurify": "^3.0.5",
"@types/papaparse": "^5.5.0",
"axios": "^1.8.4",
"dompurify": "^3.2.6",
"marked": "^5.1.2",
"pagefind": "^1.1.1",
"papaparse": "^5.5.3",
"pinia": "^3.0.1",
"swiper": "^12.0.3",
"tdesign-icons-vue-next": "^0.4.1",
+2 -2
View File
@@ -40,8 +40,8 @@ export function copyKnowledgeBase(data: { source_id: string; target_id?: string
}
// 知识文件 API(基于具体知识库)
export function uploadKnowledgeFile(kbId: string, data = {}) {
return postUpload(`/api/v1/knowledge-bases/${kbId}/knowledge/file`, data);
export function uploadKnowledgeFile(kbId: string, data = {}, onProgress?: (progressEvent: any) => void) {
return postUpload(`/api/v1/knowledge-bases/${kbId}/knowledge/file`, data, onProgress);
}
export function createKnowledgeFromURL(kbId: string, data: { url: string; enable_multimodel?: boolean }) {
+269 -38
View File
@@ -85,6 +85,37 @@
<span class="kb-action-title">{{ t('knowledgeEditor.faqImport.importButton') }}</span>
</div>
</div>
<div class="menu_item kb-action-item" @click.stop="handleFaqSearchTestFromMenu">
<div class="kb-action-icon-wrapper">
<svg class="kb-action-icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M8.25 15C11.9779 15 15 11.9779 15 8.25C15 4.52208 11.9779 1.5 8.25 1.5C4.52208 1.5 1.5 4.52208 1.5 8.25C1.5 11.9779 4.52208 15 8.25 15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16.5 16.5L12.4875 12.4875" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="kb-action-content">
<span class="kb-action-title">{{ t('knowledgeEditor.faq.searchTest') }}</span>
</div>
</div>
<t-dropdown
v-if="selectedFaqCount > 0"
:options="faqBatchActionOptions"
trigger="hover"
placement="right"
@click="handleFaqBatchActionFromMenu"
>
<div class="menu_item kb-action-item">
<div class="kb-action-icon-wrapper">
<svg class="kb-action-icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M3.75 9H14.25M9 3.75V14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.75 3.75H14.25V14.25H3.75V3.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="kb-action-content">
<span class="kb-action-title">{{ t('knowledgeEditor.faq.batchOperations') }}</span>
<span class="kb-action-count">({{ selectedFaqCount }})</span>
</div>
</div>
</t-dropdown>
</template>
</div>
</div>
@@ -153,7 +184,7 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { onMounted, watch, computed, ref, reactive } from 'vue';
import { onMounted, onUnmounted, watch, computed, ref, reactive } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getSessionsList, delSession } from "@/api/chat/index";
import { getKnowledgeBaseById, uploadKnowledgeFile, createKnowledgeFromURL } from '@/api/knowledge-base';
@@ -197,6 +228,11 @@ const isInKnowledgeBaseList = computed<boolean>(() => {
return route.name === 'knowledgeBaseList';
});
// 是否在创建聊天页面
const isInCreatChat = computed<boolean>(() => {
return route.name === 'globalCreatChat' || route.name === 'kbCreatChat';
});
// 统一的菜单项激活状态判断
const isMenuItemActive = (itemPath: string): boolean => {
const currentRoute = route.name;
@@ -234,7 +270,7 @@ const getIconActiveState = (itemPath: string) => {
// 分离上下两部分菜单
const topMenuItems = computed<MenuItem[]>(() => {
return (menuArr.value as unknown as MenuItem[]).filter((item: MenuItem) =>
item.path === 'creatChat'
item.path === 'knowledge-bases' || item.path === 'creatChat'
);
});
@@ -253,12 +289,23 @@ const currentKbInfo = ref<any>(null)
const docUploadInput = ref<HTMLInputElement | null>(null)
const docFolderInput = ref<HTMLInputElement | null>(null)
const pendingUploadKbId = ref<string | null>(null)
const selectedFaqCount = ref<number>(0)
const selectedFaqEnabledCount = ref<number>(0)
const selectedFaqDisabledCount = ref<number>(0)
const showKbActions = computed(() => (isInKnowledgeBase.value && !!currentKbInfo.value) || isInKnowledgeBaseList.value)
// 监听FAQ选中数量变化
const handleFaqSelectionChanged = ((event: CustomEvent<{ count: number; enabledCount?: number; disabledCount?: number }>) => {
const count = event.detail?.count || 0
selectedFaqCount.value = count
selectedFaqEnabledCount.value = event.detail?.enabledCount || 0
selectedFaqDisabledCount.value = event.detail?.disabledCount || 0
}) as EventListener
const showKbActions = computed(() => (isInKnowledgeBase.value && !!currentKbInfo.value) || isInKnowledgeBaseList.value || isInCreatChat.value)
const currentKbType = computed(() => currentKbInfo.value?.type || 'document')
const showDocActions = computed(() => showKbActions.value && isInKnowledgeBase.value && currentKbType.value !== 'faq')
const showFaqActions = computed(() => showKbActions.value && isInKnowledgeBase.value && currentKbType.value === 'faq')
const showCreateKbAction = computed(() => showKbActions.value && isInKnowledgeBaseList.value)
const showCreateKbAction = computed(() => showKbActions.value && (isInKnowledgeBaseList.value || isInCreatChat.value))
// 时间分组函数
const getTimeCategory = (dateStr: string): string => {
@@ -438,9 +485,20 @@ onMounted(async () => {
// 加载对话列表
getMessageList();
// 监听FAQ选中数量变化
window.addEventListener('faqSelectionChanged', handleFaqSelectionChanged)
});
onUnmounted(() => {
window.removeEventListener('faqSelectionChanged', handleFaqSelectionChanged)
})
watch([() => route.name, () => route.params], (newvalue, oldvalue) => {
// 切换知识库时重置选中数量
if (newvalue[1].kbId !== oldvalue?.[1]?.kbId) {
selectedFaqCount.value = 0
}
const nameStr = typeof newvalue[0] === 'string' ? (newvalue[0] as string) : (newvalue[0] ? String(newvalue[0]) : '')
currentpath.value = nameStr;
if (newvalue[1].chatid) {
@@ -558,13 +616,11 @@ const gotopage = async (path: string) => {
return;
} else {
if (path === 'creatChat') {
// 尝试获取当前知识库ID
const kbId = await getCurrentKbId()
if (kbId) {
// 如果在知识库内部,进入该知识库的对话页
router.push(`/platform/knowledge-bases/${kbId}/creatChat`)
// 如果在知识库详情页,跳转到全局对话创建页
if (isInKnowledgeBase.value) {
router.push('/platform/creatChat')
} else {
// 如果不在知识库内,也进入对话创建页,让用户通过 @ 按钮选择知识库
// 如果不在知识库内,进入对话创建页
router.push(`/platform/creatChat`)
}
} else {
@@ -660,38 +716,74 @@ const handleDocFileChange = async (event: Event) => {
const totalCount = validFiles.length
const failedFiles: Array<{ name: string; reason: string }> = []
// 显示上传提示
if (totalCount > 1) {
if (invalidCount > 0) {
MessagePlugin.info(t('knowledgeBase.uploadingValidFiles', {
valid: totalCount,
total: files.length
}))
} else {
MessagePlugin.info(t('knowledgeBase.uploadingMultiple', { total: totalCount }))
}
}
// 为每个文件创建上传任务并发送事件通知
const uploadPromises = validFiles.map(async (file) => {
const uploadId = `${file.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
let progress = 0
let status: 'uploading' | 'success' | 'error' = 'uploading'
let error: string | undefined
// 发送开始上传事件
window.dispatchEvent(new CustomEvent('knowledgeFileUploadStart', {
detail: {
kbId,
uploadId,
fileName: file.name,
file
}
}))
for (const file of validFiles) {
try {
await uploadKnowledgeFile(kbId, { file })
await uploadKnowledgeFile(
kbId,
{ file },
(progressEvent: any) => {
if (progressEvent.total) {
progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
// 发送进度更新事件
window.dispatchEvent(new CustomEvent('knowledgeFileUploadProgress', {
detail: {
kbId,
uploadId,
progress
}
}))
}
}
)
successCount++
status = 'success'
progress = 100
} catch (error: any) {
failCount++
let errorReason = error?.error?.message || error?.message || t('knowledgeBase.uploadFailed')
if (error?.code === 'duplicate_file' || error?.error?.code === 'duplicate_file') {
errorReason = t('knowledgeBase.fileExists')
}
status = 'error'
error = errorReason
failedFiles.push({ name: file.name, reason: errorReason })
// 只在单文件上传时显示详细错误
if (totalCount === 1) {
MessagePlugin.error(errorReason)
} else {
// 多文件上传时记录失败信息
failedFiles.push({ name: file.name, reason: errorReason })
}
} finally {
// 发送上传完成事件
window.dispatchEvent(new CustomEvent('knowledgeFileUploadComplete', {
detail: {
kbId,
uploadId,
status,
progress,
error
}
}))
}
}
})
// 等待所有上传完成
await Promise.allSettled(uploadPromises)
// 显示上传结果
if (successCount > 0) {
@@ -758,13 +850,39 @@ const handleDocFolderChange = async (event: Event) => {
return
}
// 检查是否启用了VLM
const vlmEnabled = currentKbInfo.value?.vlm_config?.enabled || false
// 过滤有效文件(文件夹上传始终使用静默模式)
const validFiles: File[] = []
let invalidCount = 0
let hiddenFileCount = 0
let imageFilteredCount = 0
for (let i = 0; i < files.length; i++) {
const file = files[i]
// 文件夹上传时始终静默过滤
const relativePath = (file as any).webkitRelativePath || file.name
// 1. 过滤隐藏文件和隐藏文件夹
// 检查路径中是否包含以 . 开头的文件或文件夹
const pathParts = relativePath.split('/')
const hasHiddenComponent = pathParts.some((part: string) => part.startsWith('.'))
if (hasHiddenComponent) {
hiddenFileCount++
continue
}
// 2. 如果未启用VLM,过滤图片文件
if (!vlmEnabled) {
const fileExt = file.name.substring(file.name.lastIndexOf('.') + 1).toLowerCase()
const imageTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']
if (imageTypes.includes(fileExt)) {
imageFilteredCount++
continue
}
}
// 3. 文件类型验证(文件夹上传时始终静默过滤)
if (kbFileTypeVerification(file, true)) {
invalidCount++
} else {
@@ -774,8 +892,19 @@ const handleDocFolderChange = async (event: Event) => {
// 如果没有有效文件,直接返回
if (validFiles.length === 0) {
if (invalidCount > 0) {
MessagePlugin.error(t('knowledgeBase.noValidFilesInFolder', { total: files.length }))
const totalFiltered = invalidCount + hiddenFileCount + imageFilteredCount
if (totalFiltered > 0) {
let filterReasons = []
if (hiddenFileCount > 0) {
filterReasons.push(t('knowledgeBase.hiddenFilesFiltered', { count: hiddenFileCount }))
}
if (imageFilteredCount > 0) {
filterReasons.push(t('knowledgeBase.imagesFilteredNoVLM', { count: imageFilteredCount }))
}
if (invalidCount > 0) {
filterReasons.push(t('knowledgeBase.invalidFilesFiltered', { count: invalidCount }))
}
MessagePlugin.warning(t('knowledgeBase.noValidFilesInFolder', { total: files.length }) + '\n' + filterReasons.join('\n'))
} else {
MessagePlugin.error(t('knowledgeBase.noValidFiles'))
}
@@ -785,11 +914,24 @@ const handleDocFolderChange = async (event: Event) => {
// 显示过滤后的上传提示
const totalCount = validFiles.length
if (invalidCount > 0) {
MessagePlugin.info(t('knowledgeBase.uploadingValidFiles', {
valid: totalCount,
total: files.length
}))
const totalFiltered = invalidCount + hiddenFileCount + imageFilteredCount
if (totalFiltered > 0) {
let filterInfo = []
if (hiddenFileCount > 0) {
filterInfo.push(t('knowledgeBase.hiddenFilesFiltered', { count: hiddenFileCount }))
}
if (imageFilteredCount > 0) {
filterInfo.push(t('knowledgeBase.imagesFilteredNoVLM', { count: imageFilteredCount }))
}
if (invalidCount > 0) {
filterInfo.push(t('knowledgeBase.invalidFilesFiltered', { count: invalidCount }))
}
MessagePlugin.info(
t('knowledgeBase.uploadingValidFiles', {
valid: totalCount,
total: files.length
}) + '\n' + filterInfo.join(', ')
)
} else {
MessagePlugin.info(t('knowledgeBase.uploadingFolder', { total: totalCount }))
}
@@ -801,7 +943,23 @@ const handleDocFolderChange = async (event: Event) => {
for (const file of validFiles) {
try {
await uploadKnowledgeFile(kbId, { file })
// 获取文件的相对路径(webkitRelativePath)
const relativePath = (file as any).webkitRelativePath
let fileName = file.name
// 如果存在相对路径,提取子文件夹路径并拼接到文件名前
if (relativePath) {
// webkitRelativePath 格式: "文件夹名/子文件夹/文件名.ext"
// 我们需要去掉第一层文件夹名,保留子路径
const pathParts = relativePath.split('/')
if (pathParts.length > 2) {
// 有子文件夹,拼接子路径到文件名
const subPath = pathParts.slice(1, -1).join('/') // 去掉顶层文件夹和文件名本身
fileName = `${subPath}/${file.name}`
}
}
await uploadKnowledgeFile(kbId, { file, fileName })
successCount++
} catch (error: any) {
failCount++
@@ -872,7 +1030,7 @@ const handleDocURLImport = async () => {
}))
}
const dispatchFaqMenuAction = (action: 'create' | 'import', kbId: string) => {
const dispatchFaqMenuAction = (action: 'create' | 'import' | 'search' | 'batch' | 'batchTag' | 'batchEnable' | 'batchDisable' | 'batchDelete', kbId: string) => {
window.dispatchEvent(new CustomEvent('faqMenuAction', {
detail: { action, kbId }
}))
@@ -896,6 +1054,65 @@ const handleFaqImportFromMenu = async () => {
dispatchFaqMenuAction('import', kbId)
}
const handleFaqSearchTestFromMenu = async () => {
const kbId = await getCurrentKbId()
if (!kbId) {
MessagePlugin.warning(t('knowledgeEditor.messages.missingId'))
return
}
dispatchFaqMenuAction('search', kbId)
}
const faqBatchActionOptions = computed(() => {
if (selectedFaqCount.value === 0) {
return []
}
const options = [
{
content: `${t('knowledgeEditor.faq.batchUpdateTag')} (${selectedFaqCount.value})`,
value: 'batchTag',
icon: 'folder'
}
]
// 根据选中条目的状态显示批量启用或禁用
if (selectedFaqDisabledCount.value > 0) {
options.push({
content: `${t('knowledgeEditor.faq.batchEnable')} (${selectedFaqDisabledCount.value})`,
value: 'batchEnable',
icon: 'check-circle',
})
}
if (selectedFaqEnabledCount.value > 0) {
options.push({
content: `${t('knowledgeEditor.faq.batchDisable')} (${selectedFaqEnabledCount.value})`,
value: 'batchDisable',
icon: 'close-circle',
})
}
options.push({
content: `${t('knowledgeEditor.faqImport.deleteSelected')} (${selectedFaqCount.value})`,
value: 'batchDelete',
icon: 'delete',
})
return options
})
const handleFaqBatchActionFromMenu = async (data: { value: string }) => {
const kbId = await getCurrentKbId()
if (!kbId) {
MessagePlugin.warning(t('knowledgeEditor.messages.missingId'))
return
}
if (selectedFaqCount.value === 0) {
MessagePlugin.warning(t('knowledgeEditor.faq.selectEntriesFirst') || '请先选中要操作的FAQ条目')
return
}
dispatchFaqMenuAction(data.value as 'batchTag' | 'batchEnable' | 'batchDisable' | 'batchDelete', kbId)
}
const handleCreateKnowledgeBase = () => {
uiStore.openCreateKB()
}
@@ -1031,6 +1248,9 @@ const handleCreateKnowledgeBase = () => {
.kb-action-content {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
white-space: nowrap;
}
.kb-action-title {
@@ -1038,10 +1258,21 @@ const handleCreateKnowledgeBase = () => {
font-weight: 500;
color: #0f172a;
transition: color 0.08s ease;
display: block;
display: inline;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
min-width: 0;
}
.kb-action-count {
font-size: 12px;
color: #10b981;
font-weight: 600;
margin-left: 4px;
flex-shrink: 0;
white-space: nowrap;
}
.menu_box {
+7 -1
View File
@@ -59,6 +59,9 @@ export default {
noValidFiles: 'No valid files',
noValidFilesInFolder: 'All {total} files in folder are unsupported',
noValidFilesSelected: 'All selected files are unsupported',
hiddenFilesFiltered: 'Filtered {count} hidden files',
imagesFilteredNoVLM: 'Filtered {count} image files (VLM not enabled)',
invalidFilesFiltered: 'Filtered {count} unsupported files',
unsupportedFileType: 'Unsupported file type',
failedFilesList: 'Failed files:',
andMoreFiles: '...and {count} more files',
@@ -751,6 +754,8 @@ export default {
knowledgeGraph: 'Knowledge Graph Enabled',
multimodal: 'Multimodal Enabled'
},
processing: 'Processing import task',
processingDocuments: 'Processing {count} documents',
stats: {
documents: 'Document Count',
faqEntries: 'FAQ Entries',
@@ -860,6 +865,7 @@ export default {
statusEnableSuccess: 'FAQ entry enabled',
statusDisableSuccess: 'FAQ entry disabled',
statusUpdateFailed: 'Failed to update status',
batchOperations: 'Batch Operations',
batchUpdateTag: 'Batch Update Category',
batchUpdateTagTip: 'Set category for {count} selected entries',
batchEnable: 'Batch Enable',
@@ -871,7 +877,7 @@ export default {
appendMode: 'Append',
replaceMode: 'Replace existing entries',
fileLabel: 'Select File',
fileTip: 'Supports JSON / CSV / Excel with fields standard_question, answers, similar_questions, negative_questions',
fileTip: 'Supports JSON / CSV / Excel. CSV/Excel headers: 分类(必填), 问题(必填), 相似问题(选填-多个用##分隔), 反例问题(选填-多个用##分隔), 机器人回答(必填-多个用##分隔), 是否全部回复(选填-默认FALSE), 是否停用(选填-默认FALSE), 是否禁止被推荐(选填-默认False 可被推荐). Also supports old format: standard_question, answers, similar_questions, negative_questions',
clickToUpload: 'Click to upload file',
dragDropTip: 'or drag and drop file here',
importButton: 'Import FAQ',
+7 -1
View File
@@ -59,6 +59,9 @@ export default {
noValidFiles: 'Нет действительных файлов',
noValidFilesInFolder: 'Все {total} файлов в папке не поддерживаются',
noValidFilesSelected: 'Все выбранные файлы не поддерживаются',
hiddenFilesFiltered: 'Отфильтровано {count} скрытых файлов',
imagesFilteredNoVLM: 'Отфильтровано {count} изображений (VLM не включен)',
invalidFilesFiltered: 'Отфильтровано {count} неподдерживаемых файлов',
unsupportedFileType: 'Неподдерживаемый тип файла',
failedFilesList: 'Неудавшиеся файлы:',
andMoreFiles: '...и ещё {count} файлов',
@@ -839,6 +842,8 @@ export default {
knowledgeGraph: 'Граф знаний включен',
multimodal: 'Мультимодальность включена'
},
processing: 'Обработка задачи импорта',
processingDocuments: 'Обработка {count} документов',
stats: {
documents: 'Количество документов',
faqEntries: 'FAQ записи',
@@ -948,6 +953,7 @@ export default {
statusEnableSuccess: 'Запись FAQ включена',
statusDisableSuccess: 'Запись FAQ отключена',
statusUpdateFailed: 'Не удалось обновить статус',
batchOperations: 'Пакетные операции',
batchUpdateTag: 'Пакетное обновление категории',
batchUpdateTagTip: 'Установить категорию для {count} выбранных записей',
batchEnable: 'Пакетное включение',
@@ -959,7 +965,7 @@ export default {
appendMode: 'Добавить',
replaceMode: 'Заменить существующие записи',
fileLabel: 'Выберите файл',
fileTip: 'Поддерживаются JSON / CSV / Excel. Поля: standard_question, answers, similar_questions, negative_questions',
fileTip: 'Поддерживаются JSON / CSV / Excel. Заголовки CSV/Excel: 分类(必填), 问题(必填), 相似问题(选填-多个用##分隔), 反例问题(选填-多个用##分隔), 机器人回答(必填-多个用##分隔), 是否全部回复(选填-默认FALSE), 是否停用(选填-默认FALSE), 是否禁止被推荐(选填-默认False 可被推荐). Также поддерживается старый формат: standard_question, answers, similar_questions, negative_questions',
clickToUpload: 'Нажмите для загрузки файла',
dragDropTip: 'или перетащите файл сюда',
importButton: 'Импортировать FAQ',
+7 -1
View File
@@ -58,6 +58,9 @@ export default {
noValidFiles: "没有有效的文件",
noValidFilesInFolder: "文件夹中的 {total} 个文件均不支持",
noValidFilesSelected: "选中的文件均不支持",
hiddenFilesFiltered: "已过滤 {count} 个隐藏文件",
imagesFilteredNoVLM: "已过滤 {count} 个图片文件(未启用VLM)",
invalidFilesFiltered: "已过滤 {count} 个不支持的文件",
unsupportedFileType: "不支持的文件格式",
failedFilesList: "失败文件列表:",
andMoreFiles: "...及其他 {count} 个文件",
@@ -1074,6 +1077,8 @@ export default {
knowledgeGraph: "已启用知识图谱",
multimodal: "已启用多模态",
},
processing: "正在处理导入任务",
processingDocuments: "正在处理 {count} 个文档",
stats: {
documents: "文档数量",
faqEntries: "问答条目",
@@ -1187,6 +1192,7 @@ export default {
statusEnableSuccess: "FAQ 条目已启用",
statusDisableSuccess: "FAQ 条目已禁用",
statusUpdateFailed: "更新状态失败",
batchOperations: "批量操作",
batchUpdateTag: "批量分类",
batchUpdateTagTip: "将为 {count} 个选中的条目设置分类",
batchEnable: "批量启用",
@@ -1198,7 +1204,7 @@ export default {
appendMode: "追加导入",
replaceMode: "替换现有条目",
fileLabel: "选择文件",
fileTip: "支持 JSON / CSV / Excel,字段包含 standard_question、answers、similar_questions、negative_questions",
fileTip: "支持 JSON / CSV / Excel。CSV/Excel 表头:分类(必填)、问题(必填)、相似问题(选填-多个用##分隔)、反例问题(选填-多个用##分隔)、机器人回答(必填-多个用##分隔)、是否全部回复(选填-默认FALSE)、是否停用(选填-默认FALSE)、是否禁止被推荐(选填-默认False 可被推荐)。也支持旧格式:standard_question、answers、similar_questions、negative_questions",
clickToUpload: "点击上传文件",
dragDropTip: "或拖拽文件到此处",
importButton: "导入 FAQ",
+29 -2
View File
@@ -18,7 +18,8 @@ export type DisplayType =
| 'plan'
| 'database_query'
| 'web_search_results'
| 'web_fetch_results';
| 'web_fetch_results'
| 'grep_results';
// Search result item
export interface SearchResultItem {
@@ -192,6 +193,31 @@ export interface WebFetchResultsData {
count?: number;
}
// Grep chunk result item
export interface GrepChunkItem {
id: string;
content: string;
chunk_index: number;
knowledge_id: string;
knowledge_base_id: string;
chunk_type?: string;
knowledge_title?: string;
}
// Grep results data
export interface GrepResultsData {
display_type: 'grep_results';
patterns: string[];
case_sensitive: boolean;
use_regex: boolean;
results: GrepChunkItem[];
result_count: number;
total_matches: number;
knowledge_base_ids?: string[];
knowledge_ids?: string[];
limit: number;
}
// Union type for all tool result data
export type ToolResultData =
| SearchResultsData
@@ -204,7 +230,8 @@ export type ToolResultData =
| PlanData
| DatabaseQueryData
| WebSearchResultsData
| WebFetchResultsData;
| WebFetchResultsData
| GrepResultsData;
// Action data (from index.vue)
export interface ActionData {
+2 -1
View File
@@ -167,12 +167,13 @@ export async function getDown(url: string) {
return res
}
export function postUpload(url: string, data = {}) {
export function postUpload(url: string, data = {}, onUploadProgress?: (progressEvent: any) => void) {
return instance.post(url, data, {
headers: {
"Content-Type": "multipart/form-data",
"X-Request-ID": `${generateRandomString(12)}`,
},
onUploadProgress,
});
}
+2
View File
@@ -7,6 +7,7 @@
export const toolIcons: Record<string, string> = {
multi_kb_search: '🔍',
knowledge_search: '📚',
grep_chunks: '🔎',
get_chunk_detail: '📄',
list_knowledge_bases: '📂',
list_knowledge_chunks: '🧩',
@@ -42,6 +43,7 @@ export function getToolDisplayName(toolName: string): string {
const displayNames: Record<string, string> = {
multi_kb_search: '跨库搜索',
knowledge_search: '知识库搜索',
grep_chunks: '文本模式搜索',
get_chunk_detail: '获取片段详情',
list_knowledge_chunks: '查看知识分块',
list_knowledge_bases: '列出知识库',
@@ -76,7 +76,7 @@
'action-error': event.success === false
}"
>
<div class="action-header" @click="toggleEvent(event.tool_call_id)">
<div class="action-header" @click="handleActionHeaderClick(event)" :class="{ 'no-results': !hasResults(event) }">
<div class="action-title">
<img v-if="event.tool_name && !isBookIcon(event.tool_name)" class="action-title-icon" :src="getToolIcon(event.tool_name)" alt="" />
<t-icon v-if="event.tool_name && isBookIcon(event.tool_name)" class="action-title-icon" name="book" />
@@ -91,7 +91,7 @@
<span class="action-name">{{ getToolTitle(event) }}</span>
</t-tooltip>
</div>
<div v-if="!event.pending" class="action-show-icon">
<div v-if="!event.pending && hasResults(event)" class="action-show-icon">
<t-icon :name="isEventExpanded(event.tool_call_id) ? 'chevron-up' : 'chevron-down'" />
</div>
</div>
@@ -109,7 +109,7 @@
<!-- Search Results Summary (Fixed, always visible, outside action-details) -->
<div v-if="!event.pending && (event.tool_name === 'search_knowledge' || event.tool_name === 'knowledge_search') && event.tool_data" class="search-results-summary-fixed">
<div class="results-summary-text" v-html="getSearchResultsSummary(event.tool_data)"></div>
<div class="results-summary-text" v-html="getSearchResultsSummary(event)"></div>
</div>
<!-- Web Search Results Summary (Fixed, always visible, outside action-details) -->
@@ -117,7 +117,12 @@
<div class="results-summary-text" v-html="t('agent.webSearchFound', { count: getResultsCount(event.tool_data) })"></div>
</div>
<div v-if="isEventExpanded(event.tool_call_id) && !event.pending" class="action-details">
<!-- Grep Results Summary (Fixed, always visible, outside action-details) -->
<div v-if="!event.pending && event.tool_name === 'grep_chunks' && event.tool_data" class="search-results-summary-fixed grep-summary">
<div class="results-summary-text" v-html="getGrepResultsSummary(event.tool_data)"></div>
</div>
<div v-if="isEventExpanded(event.tool_call_id) && !event.pending && hasResults(event)" class="action-details">
<!-- Thinking tool: only render markdown thought content -->
<template v-if="event.tool_name === 'thinking' && event.tool_data?.thought">
<div class="thinking-thought-content">
@@ -223,6 +228,7 @@ const { t } = useI18n();
const TOOL_NAME_I18N: Record<string, string> = {
search_knowledge: '知识库检索',
knowledge_search: '知识库检索',
grep_chunks: '文本模式搜索',
web_search: '网络搜索',
web_fetch: '网页抓取',
get_document_info: '获取文档信息',
@@ -670,10 +676,45 @@ const toggleEvent = (eventId: string) => {
}
};
const handleActionHeaderClick = (event: any) => {
if (hasResults(event) && event.tool_call_id) {
toggleEvent(event.tool_call_id);
}
};
const isEventExpanded = (eventId: string): boolean => {
return expandedEvents.value.has(eventId);
};
// Check if search/grep tools have results
const hasResults = (event: any): boolean => {
if (!event || !event.tool_data) return true; // Default to true for other tools
const toolName = event.tool_name;
// For knowledge search tools
if (toolName === 'search_knowledge' || toolName === 'knowledge_search') {
const count = event.tool_data.results?.length || event.tool_data.count || 0;
return count > 0;
}
// For web search tools
if (toolName === 'web_search') {
const count = event.tool_data.results?.length || event.tool_data.count || 0;
return count > 0;
}
// For grep tools
if (toolName === 'grep_chunks') {
const totalMatches = event.tool_data.total_matches || 0;
const resultCount = event.tool_data.result_count || 0;
return totalMatches > 0 || resultCount > 0;
}
// For other tools, always allow expansion
return true;
};
// Delegated handlers for span-based citation clicks/keyboard
const handleCitationActivate = (el: HTMLElement) => {
const url = el.getAttribute('data-url');
@@ -1195,6 +1236,8 @@ const getToolIcon = (toolName: string): string => {
return thinkingIcon;
} else if (toolName === 'search_knowledge' || toolName === 'knowledge_search') {
return knowledgeIcon;
} else if (toolName === 'grep_chunks') {
return knowledgeIcon; // Use same icon as knowledge_search for consistency
} else if (toolName === 'web_search') {
return webSearchGlobeGreenIcon;
} else if (toolName === 'get_document_info' || toolName === 'list_knowledge_chunks') {
@@ -1207,17 +1250,22 @@ const getToolIcon = (toolName: string): string => {
};
// Get search results summary text (returns HTML with colored numbers)
const getSearchResultsSummary = (toolData: any): string => {
if (!toolData) return '';
const getSearchResultsSummary = (event: any): string => {
if (!event || !event.tool_data) return '';
const toolData = event.tool_data;
const count = toolData.results?.length || toolData.count || 0;
if (count === 0) return '';
if (count === 0) return `未找到匹配的内容`;
// Build summary text
let summary = '';
const kbCount = toolData.kb_counts ? Object.keys(toolData.kb_counts).length : 0;
if (kbCount > 0) {
return `找到 <strong>${count}</strong> 个结果,来自 <strong>${kbCount}</strong> 个知识库`;
summary = `找到 <strong>${count}</strong> 个结果,来自 <strong>${kbCount}</strong> 个文件`;
} else {
summary = `找到 <strong>${count}</strong> 个结果`;
}
return `找到 <strong>${count}</strong> 个结果`;
return summary;
};
// Get web search results summary text
@@ -1236,6 +1284,25 @@ const getResultsCount = (toolData: any): number => {
return toolData.results?.length || toolData.count || 0;
};
// Get grep results summary text (returns HTML with colored numbers)
const getGrepResultsSummary = (toolData: any): string => {
if (!toolData) return '';
const totalMatches = toolData.total_matches || 0;
const resultCount = toolData.result_count || 0;
if (totalMatches === 0) {
return '未找到匹配的内容';
}
let summary = `找到 <strong>${totalMatches}</strong> 处匹配`;
if (totalMatches > resultCount) {
summary += `(显示 <strong>${resultCount}</strong> 个)`;
}
return summary;
};
// Extract and format query parameters from args
const getQueryText = (args: any): string => {
if (!args) return '';
@@ -1260,28 +1327,15 @@ const getQueryText = (args: any): string => {
}
// Add vector_queries if exists
if (Array.isArray(parsedArgs.vector_queries) && parsedArgs.vector_queries.length > 0) {
const vectorQueries = parsedArgs.vector_queries
if (Array.isArray(parsedArgs.queries) && parsedArgs.queries.length > 0) {
queries.push(...parsedArgs.queries
.filter((q: any) => q && typeof q === 'string')
.join(' ');
if (vectorQueries) {
queries.push(vectorQueries);
}
);
}
// Add keyword_queries if exists
if (Array.isArray(parsedArgs.keyword_queries) && parsedArgs.keyword_queries.length > 0) {
const keywordQueries = parsedArgs.keyword_queries
.filter((q: any) => q && typeof q === 'string')
.join(' ');
if (keywordQueries) {
queries.push(keywordQueries);
}
}
// Join all queries with space and remove duplicates
// Join all queries with comma and remove duplicates
const uniqueQueries = Array.from(new Set(queries));
return uniqueQueries.join(' ');
return uniqueQueries.join(',');
};
// Get tool title - prefer summary over description, add query for search tools
@@ -1294,6 +1348,7 @@ const getToolTitle = (event: any): string => {
const toolName = event.tool_name;
const isSearchTool = toolName === 'search_knowledge' || toolName === 'knowledge_search';
const isWebSearchTool = toolName === 'web_search';
const isGrepTool = toolName === 'grep_chunks';
// For search tools, use description with query text
if (isSearchTool) {
@@ -1313,9 +1368,21 @@ const getToolTitle = (event: any): string => {
// Try to get query from arguments or tool_data
let queryText = '';
if (event.arguments && typeof event.arguments === 'object' && event.arguments.query) {
queryText = event.arguments.query;
const query = event.arguments.query;
// Handle both string and array formats
if (Array.isArray(query)) {
queryText = query.filter((q: any) => q && typeof q === 'string').join(',');
} else if (typeof query === 'string') {
queryText = query;
}
} else if (event.tool_data && event.tool_data.query) {
queryText = event.tool_data.query;
const query = event.tool_data.query;
// Handle both string and array formats
if (Array.isArray(query)) {
queryText = query.filter((q: any) => q && typeof q === 'string').join(',');
} else if (typeof query === 'string') {
queryText = query;
}
}
if (queryText) {
return `${baseTitle}:「${queryText}」`;
@@ -1323,6 +1390,34 @@ const getToolTitle = (event: any): string => {
return baseTitle;
}
// For grep tools, use description with patterns
if (isGrepTool) {
const baseTitle = getToolDescription(event);
// Try to get patterns from arguments or tool_data
let patterns: string[] = [];
if (event.arguments && typeof event.arguments === 'object') {
if (Array.isArray(event.arguments.patterns)) {
patterns = event.arguments.patterns;
} else if (event.arguments.pattern) {
patterns = [event.arguments.pattern];
}
} else if (event.tool_data) {
if (Array.isArray(event.tool_data.patterns)) {
patterns = event.tool_data.patterns;
} else if (event.tool_data.pattern) {
patterns = [event.tool_data.pattern];
}
}
if (patterns.length > 0) {
// Show up to 2 patterns in title
const displayPatterns = patterns.slice(0, 2);
const patternText = displayPatterns.join('、');
const moreText = patterns.length > 2 ? ` +${patterns.length - 2}` : '';
return `${baseTitle}:「${patternText}${moreText}」`;
}
return baseTitle;
}
// Use tool summary if available
const summary = getToolSummary(event);
return summary || getToolDescription(event);
@@ -1486,80 +1581,93 @@ const handleAddToKnowledge = (answerEvent: any) => {
// 时间轴连线容器
.event-item {
position: relative;
padding-left: 28px;
padding-left: 32px;
margin-bottom: 12px;
// 时间轴垂直线
&::before {
content: '';
position: absolute;
left: 8px;
left: 10px;
top: 0;
bottom: -12px;
width: 2px;
background: linear-gradient(to bottom, #e5e7eb 0%, #e5e7eb 100%);
width: 1.5px;
background: linear-gradient(
to bottom,
rgba(7, 192, 95, 0.1) 0%,
rgba(7, 192, 95, 0.15) 50%,
rgba(7, 192, 95, 0.1) 100%
);
z-index: 0;
}
// 第一个事件的连线从节点开始
&:first-child::before {
top: 12px;
top: 14px;
}
// 最后一个事件不显示底部连线
&.event-last::before {
bottom: auto;
height: 20px;
height: 22px;
}
// 时间轴节点(圆点)
&::after {
content: '';
position: absolute;
left: 4px;
top: 12px;
width: 10px;
height: 10px;
left: 6.25px; // 线条中心 10.75px - 圆点半径 4.5px = 6.25px (box-sizing: border-box)
top: 14px;
width: 9px;
height: 9px;
border-radius: 50%;
background: #ffffff;
border: 2px solid #e5e7eb;
border: 2px solid rgba(7, 192, 95, 0.3);
z-index: 1;
transition: all 0.2s ease;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
box-sizing: border-box; // 确保 border 包含在尺寸内
}
// 不同事件类型的节点颜色
&:has(.thinking-event)::after {
border-color: #9ca3af;
border-color: rgba(156, 163, 175, 0.4);
background: #f9fafb;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
&:has(.answer-event)::after {
border-color: #07c05f;
background: #07c05f;
box-shadow: 0 0 0 3px rgba(7, 192, 95, 0.15);
box-shadow: 0 0 0 2px rgba(7, 192, 95, 0.12), 0 2px 4px rgba(7, 192, 95, 0.2);
transform: scale(1.1);
}
&:has(.tool-event)::after {
border-color: #07c05f;
background: #ffffff;
box-shadow: 0 1px 3px rgba(7, 192, 95, 0.15);
}
&:has(.tool-event .action-pending)::after {
border-color: #07c05f;
background: rgba(7, 192, 95, 0.2);
background: rgba(7, 192, 95, 0.15);
box-shadow: 0 0 0 2px rgba(7, 192, 95, 0.1);
animation: pulseNode 2s ease-in-out infinite;
}
&:has(.tool-event .action-error)::after {
border-color: #e34d59;
background: #e34d59;
box-shadow: 0 0 0 2px rgba(227, 77, 89, 0.15), 0 2px 4px rgba(227, 77, 89, 0.2);
}
&:has(.plan-task-change-event)::after {
border-color: #07c05f;
background: #07c05f;
transform: rotate(45deg);
transform: rotate(45deg) scale(0.9);
border-radius: 2px;
box-shadow: 0 1px 3px rgba(7, 192, 95, 0.2);
}
}
@@ -1578,21 +1686,6 @@ const handleAddToKnowledge = (answerEvent: any) => {
margin-bottom: 16px;
position: relative;
// 添加时间轴起点标记
&::before {
content: '';
position: absolute;
left: -20px;
top: 12px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #07c05f;
border: 2px solid #ffffff;
box-shadow: 0 0 0 2px #07c05f, 0 0 0 4px rgba(7, 192, 95, 0.1);
z-index: 2;
}
.intermediate-steps-header {
display: flex;
justify-content: space-between;
@@ -1602,6 +1695,10 @@ const handleAddToKnowledge = (answerEvent: any) => {
font-weight: 500;
cursor: pointer;
background: linear-gradient(to right, rgba(7, 192, 95, 0.03), transparent);
&:hover {
background: linear-gradient(to right, rgba(7, 192, 95, 0.05), rgba(7, 192, 95, 0.01));
}
}
.intermediate-steps-title {
@@ -1630,10 +1727,6 @@ const handleAddToKnowledge = (answerEvent: any) => {
padding: 0 2px 1px 2px;
color: #07c05f;
}
.intermediate-steps-header:hover {
background: linear-gradient(to right, rgba(7, 192, 95, 0.05), rgba(7, 192, 95, 0.01));
}
}
// Thinking Event
@@ -1672,7 +1765,7 @@ const handleAddToKnowledge = (answerEvent: any) => {
&.markdown-content {
:deep(p) {
margin: 6px 0;
margin: 0 0;
line-height: 1.6;
}
@@ -2051,6 +2144,14 @@ const handleAddToKnowledge = (answerEvent: any) => {
&:hover {
background-color: rgba(7, 192, 95, 0.03);
}
&.no-results {
cursor: default;
&:hover {
background-color: transparent;
}
}
}
.action-title {
@@ -2123,11 +2224,15 @@ const handleAddToKnowledge = (answerEvent: any) => {
@keyframes pulseNode {
0%, 100% {
border-color: #07c05f;
box-shadow: 0 0 0 0 rgba(7, 192, 95, 0.4);
background: rgba(7, 192, 95, 0.15);
box-shadow: 0 0 0 2px rgba(7, 192, 95, 0.1);
transform: scale(1);
}
50% {
border-color: #0ae06f;
box-shadow: 0 0 0 4px rgba(7, 192, 95, 0.1);
background: rgba(7, 192, 95, 0.25);
box-shadow: 0 0 0 3px rgba(7, 192, 95, 0.15);
transform: scale(1.05);
}
}
@@ -67,6 +67,12 @@
:data="toolData as WebFetchResultsData"
/>
<!-- Grep Results Display -->
<GrepResults
v-else-if="displayType === 'grep_results'"
:data="toolData as GrepResultsData"
/>
<!-- Fallback: Display raw output -->
<div v-else class="fallback-output">
<div class="fallback-header">
@@ -93,7 +99,8 @@ import type {
PlanData,
DatabaseQueryData,
WebSearchResultsData,
WebFetchResultsData
WebFetchResultsData,
GrepResultsData
} from '@/types/tool-results';
import SearchResults from './tool-results/SearchResults.vue';
@@ -107,6 +114,7 @@ import PlanDisplay from './tool-results/PlanDisplay.vue';
import DatabaseQuery from './tool-results/DatabaseQuery.vue';
import WebSearchResults from './tool-results/WebSearchResults.vue';
import WebFetchResults from './tool-results/WebFetchResults.vue';
import GrepResults from './tool-results/GrepResults.vue';
interface Props {
displayType?: DisplayType;
@@ -0,0 +1,185 @@
<template>
<div class="grep-results">
<!-- Results List -->
<div v-if="results && results.length > 0" class="results-list">
<div
v-for="(result, index) in results"
:key="result.id"
class="result-item"
>
<t-popup
:overlayClassName="`grep-popup-${result.id}`"
placement="bottom-left"
width="400"
:showArrow="false"
trigger="click"
destroy-on-close
>
<template #content>
<ContentPopup
:content="result.content"
:chunk-id="result.id"
:knowledge-id="result.knowledge_id"
/>
</template>
<div class="result-header">
<div class="result-title">
<span class="result-index">#{{ index + 1 }}</span>
<span class="knowledge-title">{{ result.knowledge_title || 'Untitled' }}</span>
</div>
</div>
</t-popup>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state">
未找到匹配的内容
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { GrepResultsData } from '@/types/tool-results';
import ContentPopup from './ContentPopup.vue';
const props = defineProps<{
data: GrepResultsData;
}>();
const maxDisplayPatterns = 2;
const displayPatterns = computed(() => {
if (!props.data.patterns || props.data.patterns.length === 0) {
return [];
}
return props.data.patterns.slice(0, maxDisplayPatterns);
});
const results = computed(() => props.data.results || []);
</script>
<style lang="less" scoped>
@import './tool-results.less';
.grep-results {
display: flex;
flex-direction: column;
gap: 3px;
padding: 0 0 0 12px;
}
.results-list {
display: flex;
flex-direction: column;
gap: 3px;
}
.result-item {
background: transparent;
border: none;
border-radius: 0;
overflow: visible;
}
.result-header {
padding: 2px 0;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 6px;
transition: color 0.15s ease;
&:hover {
color: #07c05f;
}
}
.result-title {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
font-size: 12px;
line-height: 1.4;
}
.result-index {
font-size: 11px;
color: #9ca3af;
font-weight: 600;
flex-shrink: 0;
}
.pattern-badge {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
.pattern-text {
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 10px;
background: #f3f4f6;
color: #6b7280;
padding: 2px 5px;
border-radius: 3px;
white-space: nowrap;
font-weight: 500;
}
.more-patterns {
font-size: 10px;
color: #9ca3af;
padding: 2px 4px;
background: #f3f4f6;
border-radius: 3px;
}
}
.knowledge-title {
font-size: 12px;
color: #374151;
flex: 1;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.chunk-info {
font-size: 10px;
color: #9ca3af;
background: #f3f4f6;
padding: 2px 5px;
border-radius: 3px;
flex-shrink: 0;
}
.empty-state {
padding: 20px;
text-align: center;
color: #9ca3af;
font-size: 12px;
font-style: italic;
}
// Popup overlay styles
:deep([class*="grep-popup-"]) {
.t-popup__content {
max-height: 400px;
max-width: 500px;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
word-wrap: break-word;
word-break: break-word;
}
}
</style>
@@ -25,9 +25,6 @@
<div class="result-header">
<div class="result-title">
<span class="result-index">#{{ result.result_index }}</span>
<span class="relevance-badge" :class="getRelevanceClass(result.relevance_level)">
{{ getRelevanceLabel(result.relevance_level) }}
</span>
<span class="knowledge-title">{{ result.knowledge_title }}</span>
</div>
</div>
@@ -7,6 +7,16 @@
<InputField @send-msg="sendMsg"></InputField>
</div>
</div>
<!-- 知识库编辑器(创建/编辑统一组件) -->
<KnowledgeBaseEditorModal
:visible="uiStore.showKBEditorModal"
:mode="uiStore.kbEditorMode"
:kb-id="uiStore.currentKBId || undefined"
:initial-type="uiStore.kbEditorType"
@update:visible="(val) => val ? null : uiStore.closeKBEditor()"
@success="handleKBEditorSuccess"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
@@ -14,14 +24,17 @@ import InputField from '@/components/Input-field.vue';
import { createSessions } from "@/api/chat/index";
import { useMenuStore } from '@/stores/menu';
import { useSettingsStore } from '@/stores/settings';
import { useUIStore } from '@/stores/ui';
import { useRoute, useRouter } from 'vue-router';
import { MessagePlugin } from 'tdesign-vue-next';
import { useI18n } from 'vue-i18n';
import KnowledgeBaseEditorModal from '@/views/knowledge/KnowledgeBaseEditorModal.vue';
const router = useRouter();
const route = useRoute();
const usemenuStore = useMenuStore();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const { t } = useI18n();
const sendMsg = (value: string) => {
@@ -79,6 +92,15 @@ const navigateToSession = async (sessionId: string, value: string) => {
router.push(`/platform/chat/${sessionId}`);
}
const handleKBEditorSuccess = (kbId: string) => {
console.log('[creatChat] knowledge base created successfully:', kbId)
// 创建成功后跳转到知识库列表页面,并高亮新创建的知识库
router.push({
path: '/platform/knowledge-bases',
query: { highlightKbId: kbId }
})
}
</script>
<style lang="less" scoped>
.dialogue-wrap {
+85 -33
View File
@@ -45,6 +45,7 @@ let knowledgeIndex = ref(-1)
let knowledgeScroll = ref()
let page = 1;
let pageSize = 35;
const selectedTagId = ref<string>("");
const tagList = ref<any[]>([]);
const tagLoading = ref(false);
@@ -399,6 +400,7 @@ const handleFileUploaded = (event: CustomEvent) => {
}
};
// 监听从菜单触发的URL导入事件
const handleOpenURLImportDialog = (event: CustomEvent) => {
const eventKbId = event.detail.kbId;
@@ -435,6 +437,7 @@ watch(() => cardList.value, (newValue) => {
if (analyzeList.length) {
updateStatus(analyzeList)
}
}, { deep: true })
type KnowledgeCard = {
id: string;
@@ -477,6 +480,9 @@ const updateStatus = (analyzeList: KnowledgeCard[]) => {
}, 1500);
};
// 恢复文档处理状态(用于刷新后恢复)
const closeDoc = () => {
isCardDetails.value = false;
};
@@ -520,6 +526,7 @@ const ensureDocumentKbReady = () => {
return true;
};
const handleDocumentUploadClick = () => {
if (!ensureDocumentKbReady()) return;
uploadInputRef.value?.click();
@@ -533,50 +540,92 @@ const resetUploadInput = () => {
const handleDocumentUpload = async (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input?.files?.[0];
if (!file) return;
if (kbFileTypeVerification(file)) {
resetUploadInput();
return;
}
const files = input?.files;
if (!files || files.length === 0) return;
if (!kbId.value) {
MessagePlugin.error("缺少知识库ID");
resetUploadInput();
return;
}
uploading.value = true;
try {
const responseData: any = await uploadKnowledgeFile(kbId.value, { file });
// 过滤有效文件
const validFiles: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!kbFileTypeVerification(file, files.length > 1)) {
validFiles.push(file);
}
}
if (validFiles.length === 0) {
resetUploadInput();
return;
}
// 批量上传
let successCount = 0;
let failCount = 0;
const totalCount = validFiles.length;
for (const file of validFiles) {
try {
const responseData: any = await uploadKnowledgeFile(kbId.value, { file });
const isSuccess = responseData?.success || responseData?.code === 200 || responseData?.status === 'success' || (!responseData?.error && responseData);
if (isSuccess) {
successCount++;
} else {
failCount++;
let errorMessage = "上传失败!";
if (responseData?.error?.message) {
errorMessage = responseData.error.message;
} else if (responseData?.message) {
errorMessage = responseData.message;
}
if (responseData?.code === 'duplicate_file' || responseData?.error?.code === 'duplicate_file') {
errorMessage = "文件已存在";
}
if (totalCount === 1) {
MessagePlugin.error(errorMessage);
}
}
} catch (error: any) {
failCount++;
let errorMessage = error?.error?.message || error?.message || "上传失败!";
if (error?.code === 'duplicate_file') {
errorMessage = "文件已存在";
}
if (totalCount === 1) {
MessagePlugin.error(errorMessage);
}
}
}
// 显示上传结果
if (successCount > 0) {
window.dispatchEvent(new CustomEvent('knowledgeFileUploaded', {
detail: { kbId: kbId.value }
}));
const isSuccess = responseData?.success || responseData?.code === 200 || responseData?.status === 'success' || (!responseData?.error && responseData);
if (isSuccess) {
MessagePlugin.info("上传成功!");
} else {
let errorMessage = "上传失败!";
if (responseData?.error?.message) {
errorMessage = responseData.error.message;
} else if (responseData?.message) {
errorMessage = responseData.message;
}
if (responseData?.code === 'duplicate_file' || responseData?.error?.code === 'duplicate_file') {
errorMessage = "文件已存在";
}
MessagePlugin.error(errorMessage);
}
} catch (error: any) {
let errorMessage = error?.error?.message || error?.message || "上传失败!";
if (error?.code === 'duplicate_file') {
errorMessage = "文件已存在";
}
MessagePlugin.error(errorMessage);
} finally {
uploading.value = false;
resetUploadInput();
}
if (totalCount === 1) {
if (successCount === 1) {
MessagePlugin.success("上传成功!");
}
} else {
if (failCount === 0) {
MessagePlugin.success(`所有文件上传成功(${successCount}个)`);
} else if (successCount > 0) {
MessagePlugin.warning(`部分文件上传成功(成功:${successCount},失败:${failCount})`);
} else {
MessagePlugin.error(`所有文件上传失败(${failCount}个)`);
}
}
resetUploadInput();
};
const handleManualCreate = () => {
if (!ensureDocumentKbReady()) return;
uiStore.openManualEditor({
@@ -818,11 +867,13 @@ async function createNewSession(value: string): Promise<void> {
<p class="document-subtitle">{{ $t('knowledgeEditor.document.subtitle') }}</p>
</div>
</div>
<input
ref="uploadInputRef"
type="file"
class="document-upload-input"
accept=".pdf,.docx,.doc,.txt,.md,.jpg,.jpeg,.png"
multiple
@change="handleDocumentUpload"
/>
<div class="knowledge-main">
@@ -1872,6 +1923,7 @@ async function createNewSession(value: string): Promise<void> {
min-height: 100%;
}
:deep(.del-knowledge) {
padding: 0px !important;
border-radius: 6px !important;
@@ -24,8 +24,10 @@
:class="{
'uninitialized': !isInitialized(kb),
'kb-type-document': (kb.type || 'document') === 'document',
'kb-type-faq': kb.type === 'faq'
'kb-type-faq': kb.type === 'faq',
'highlight-flash': highlightedKbId !== null && highlightedKbId === kb.id
}"
:ref="el => { if (highlightedKbId !== null && highlightedKbId === kb.id && el) highlightedCardRef = el as HTMLElement }"
@click="handleCardClick(kb)"
>
<!-- 卡片头部 -->
@@ -78,6 +80,15 @@
{{ kb.type === 'faq' ? $t('knowledgeEditor.basic.typeFAQ') : $t('knowledgeEditor.basic.typeDocument') }}
({{ kb.type === 'faq' ? (kb.chunk_count || 0) : (kb.knowledge_count || 0) }})
</span>
<t-tooltip
v-if="kb.isProcessing"
:content="kb.type === 'document' && (kb.processing_count || 0) > 0
? $t('knowledgeList.processingDocuments', { count: kb.processing_count || 0 })
: $t('knowledgeList.processing')"
placement="top"
>
<t-icon name="loading" size="14px" class="processing-icon" />
</t-tooltip>
</div>
<div class="feature-badges">
<t-tooltip v-if="kb.extract_config?.enabled" :content="$t('knowledgeList.features.knowledgeGraph')" placement="top">
@@ -144,9 +155,9 @@
</template>
<script setup lang="ts">
import { onMounted, ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { MessagePlugin } from 'tdesign-vue-next'
import { onMounted, ref, computed, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { MessagePlugin, Icon as TIcon } from 'tdesign-vue-next'
import { listKnowledgeBases, deleteKnowledgeBase } from '@/api/knowledge-base'
import { formatStringDate } from '@/utils/index'
import { useUIStore } from '@/stores/ui'
@@ -155,6 +166,7 @@ import Settings from '@/views/settings/Settings.vue'
import { useI18n } from 'vue-i18n'
const router = useRouter()
const route = useRoute()
const uiStore = useUIStore()
const { t } = useI18n()
@@ -172,6 +184,8 @@ interface KB {
cos_config?: { provider?: string; bucket_name?: string };
knowledge_count?: number;
chunk_count?: number;
isProcessing?: boolean; // 是否有正在处理的导入任务
processing_count?: number; // 正在处理的文档数量(仅文档类型)
}
const kbs = ref<KB[]>([])
@@ -179,22 +193,43 @@ const loading = ref(false)
const deleteVisible = ref(false)
const deletingKb = ref<KB | null>(null)
const currentMoreIndex = ref<number>(-1)
const highlightedKbId = ref<string | null>(null)
const highlightedCardRef = ref<HTMLElement | null>(null)
const fetchList = () => {
loading.value = true
listKnowledgeBases().then((res: any) => {
return listKnowledgeBases().then((res: any) => {
const data = res.data || []
// 格式化时间,并初始化 showMore 状态
kbs.value = data.map((kb: KB) => ({
// is_processing 字段由后端返回
kbs.value = data.map((kb: any) => ({
...kb,
updated_at: kb.updated_at ? formatStringDate(new Date(kb.updated_at)) : '',
showMore: false
showMore: false,
isProcessing: kb.is_processing || false,
processing_count: kb.processing_count || 0
}))
}).finally(() => loading.value = false)
}
onMounted(() => {
fetchList()
fetchList().then(() => {
// 检查路由参数中是否有需要高亮的知识库ID
const highlightKbId = route.query.highlightKbId as string
if (highlightKbId) {
triggerHighlightFlash(highlightKbId)
// 清除 URL 中的查询参数
router.replace({ query: {} })
}
})
})
// 监听路由变化,处理从其他页面跳转过来的高亮需求
watch(() => route.query.highlightKbId, (newKbId) => {
if (newKbId && typeof newKbId === 'string' && kbs.value.length > 0) {
triggerHighlightFlash(newKbId)
router.replace({ query: {} })
}
})
const openMore = (index: number) => {
@@ -270,7 +305,32 @@ const goSettings = (id: string) => {
// 知识库编辑器成功回调(创建或编辑成功)
const handleKBEditorSuccess = (kbId: string) => {
console.log('[KnowledgeBaseList] knowledge operation success:', kbId)
fetchList()
fetchList().then(() => {
// 如果是从路由参数中获取的高亮ID,触发闪烁效果
if (route.query.highlightKbId === kbId) {
triggerHighlightFlash(kbId)
// 清除 URL 中的查询参数
router.replace({ query: {} })
}
})
}
// 触发高亮闪烁效果
const triggerHighlightFlash = (kbId: string) => {
highlightedKbId.value = kbId
nextTick(() => {
if (highlightedCardRef.value) {
// 滚动到高亮的卡片
highlightedCardRef.value.scrollIntoView({
behavior: 'smooth',
block: 'center'
})
}
// 3秒后清除高亮
setTimeout(() => {
highlightedKbId.value = null
}, 3000)
})
}
</script>
@@ -533,6 +593,19 @@ const handleKBEditorSuccess = (kbId: string) => {
color: #0052d9;
border: 1px solid rgba(0, 82, 217, 0.2);
}
.processing-icon {
animation: spin 1s linear infinite;
margin-left: 4px;
}
&.document .processing-icon {
color: #059669;
}
&.faq .processing-icon {
color: #0052d9;
}
}
.feature-badges {
@@ -569,6 +642,40 @@ const handleKBEditorSuccess = (kbId: string) => {
background: rgba(255, 152, 0, 0.15);
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes highlightFlash {
0% {
border-color: #07c05f;
box-shadow: 0 0 0 0 rgba(7, 192, 95, 0.4);
transform: scale(1);
}
50% {
border-color: #07c05f;
box-shadow: 0 0 0 8px rgba(7, 192, 95, 0);
transform: scale(1.02);
}
100% {
border-color: #07c05f;
box-shadow: 0 0 0 0 rgba(7, 192, 95, 0);
transform: scale(1);
}
}
.kb-card.highlight-flash {
animation: highlightFlash 0.6s ease-in-out 3;
border-color: #07c05f !important;
box-shadow: 0 0 12px rgba(7, 192, 95, 0.3) !important;
}
.card-time {
File diff suppressed because it is too large Load Diff
+401 -740
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -13,8 +13,9 @@ func AvailableToolDefinitions() []AvailableTool {
return []AvailableTool{
{Name: "thinking", Label: "思考", Description: "动态和反思性的问题解决思考工具"},
{Name: "todo_write", Label: "制定计划", Description: "创建结构化的研究计划"},
{Name: "knowledge_search", Label: "知识搜索", Description: "在知识库中搜索相关信息"},
{Name: "list_knowledge_chunks", Label: "查看知识分块", Description: "按 knowledge_id 拉取文档分块列表"},
{Name: "grep_chunks", Label: "关键词搜索", Description: "快速定位包含特定关键词的文档和分块"},
{Name: "knowledge_search", Label: "语义搜索", Description: "理解问题并查找语义相关内容"},
{Name: "list_knowledge_chunks", Label: "查看文档分块", Description: "获取文档完整分块内容"},
{Name: "query_knowledge_graph", Label: "查询知识图谱", Description: "从知识图谱中查询关系"},
{Name: "get_document_info", Label: "获取文档信息", Description: "查看文档元数据"},
{Name: "database_query", Label: "查询数据库", Description: "查询数据库中的信息"},
@@ -27,6 +28,7 @@ func DefaultAllowedTools() []string {
"thinking",
"todo_write",
"knowledge_search",
"grep_chunks",
"list_knowledge_chunks",
"query_knowledge_graph",
"get_document_info",
+479
View File
@@ -0,0 +1,479 @@
package tools
import (
"context"
"fmt"
"strings"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"gorm.io/gorm"
)
// GrepChunksTool performs text pattern matching in knowledge base chunks
// Similar to grep command in Unix-like systems, but operates on knowledge base content
type GrepChunksTool struct {
BaseTool
db *gorm.DB
tenantID uint
knowledgeBaseIDs []string
}
// NewGrepChunksTool creates a new grep chunks tool
func NewGrepChunksTool(db *gorm.DB, tenantID uint, knowledgeBaseIDs []string) *GrepChunksTool {
description := `Unix-style text pattern matching tool for knowledge base chunks.
Searches for text patterns in chunk content, similar to the Unix grep command.
## Core Function
Performs **exact text pattern matching** (NOT semantic search). Finds chunks containing any of the specified patterns using literal text matching (fixed string). Supports multiple patterns with OR logic.
## CRITICAL - Keyword Granularity
**MUST use SHORT keywords (1-3 words), NOT long phrases.** Break down long phrases into smaller keywords for better match rate.
**Why**: Long phrases like "中国饮食文化" may not match if the document contains "中国" and "饮食" and "文化" separately but not the exact phrase.
**Guidelines**:
- ❌ **Bad**: ["中国饮食文化", "日本饮食文化"] - too long, may not match
- ✅ **Good**: ["中国", "饮食", "文化", "日本", "料理", "和食"] - short keywords, higher match rate
- For comparisons: Extract keywords for each entity separately
- Use single words or 2-word phrases, avoid 3+ word phrases
**Examples**:
- "中国饮食文化" → ["中国", "饮食", "文化", "中餐", "中华"]
- "日本饮食文化" → ["日本", "饮食", "文化", "料理", "和食", "日式"]
- "对比中国和日本" → Search separately: ["中国", "中华"] then ["日本", "日式"]
## Usage
grep_chunks searches through all enabled chunks in the knowledge base(s) and displays matching chunks with context. When multiple patterns are provided, results match any pattern (OR logic).
Default behavior: case-insensitive matching, shows chunk indices, displays context around matches.
## When to Use
- Finding specific entities: "FAISS", "Redis", "404", "RAG"
- Exact keyword lookup
- Quick text search before semantic search
## Examples
- Single pattern: pattern=["FAISS"]
- Multiple patterns: pattern=["向量", "vector", "embedding"]
- Search synonyms: pattern=["RAG", "检索增强生成"]
- Short keywords: pattern=["中国", "饮食", "文化"] (NOT ["中国饮食文化"])
`
return &GrepChunksTool{
BaseTool: NewBaseTool("grep_chunks", description),
db: db,
tenantID: tenantID,
knowledgeBaseIDs: knowledgeBaseIDs,
}
}
// Parameters returns the JSON schema for the tool's parameters
func (t *GrepChunksTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"pattern": map[string]interface{}{
"type": "array",
"description": "REQUIRED: Text patterns to search for. Can be a single pattern or multiple patterns. Treated as literal text (fixed string matching). Results match any of the patterns (OR logic).",
"items": map[string]interface{}{
"type": "string",
},
"minItems": 1,
},
"knowledge_base_ids": map[string]interface{}{
"type": "array",
"description": "Filter by knowledge base IDs. If empty, searches all allowed KBs.",
"items": map[string]interface{}{
"type": "string",
},
},
"knowledge_ids": map[string]interface{}{
"type": "array",
"description": "Filter by document/knowledge IDs. If empty, searches all documents.",
"items": map[string]interface{}{
"type": "string",
},
},
"max_results": map[string]interface{}{
"type": "integer",
"description": "Maximum number of matching chunks to return (default: 50, max: 200)",
"default": 50,
"minimum": 1,
"maximum": 200,
},
},
"required": []string{"pattern"},
}
}
// Execute executes the grep chunks tool
func (t *GrepChunksTool) Execute(ctx context.Context, args map[string]interface{}) (*types.ToolResult, error) {
logger.Infof(ctx, "[Tool][GrepChunks] Execute started")
// Parse pattern parameter (required) - support multiple patterns
var patterns []string
if patternsRaw, ok := args["pattern"].([]interface{}); ok && len(patternsRaw) > 0 {
for _, p := range patternsRaw {
if pStr, ok := p.(string); ok && strings.TrimSpace(pStr) != "" {
patterns = append(patterns, strings.TrimSpace(pStr))
}
}
}
// Also support single string for backward compatibility
if len(patterns) == 0 {
if patternStr, ok := args["pattern"].(string); ok && strings.TrimSpace(patternStr) != "" {
patterns = append(patterns, strings.TrimSpace(patternStr))
}
}
if len(patterns) == 0 {
logger.Errorf(ctx, "[Tool][GrepChunks] Missing or invalid pattern parameter")
return &types.ToolResult{
Success: false,
Error: "pattern parameter is required and must contain at least one non-empty pattern",
}, fmt.Errorf("missing pattern parameter")
}
// Use default values for all options
contextLines := 50 // default: 50 context characters
countOnly := false // default: show results
showLineNumbers := true // default: show chunk indices
maxResults := 50
if mr, ok := args["max_results"].(float64); ok {
maxResults = int(mr)
if maxResults < 1 {
maxResults = 1
} else if maxResults > 200 {
maxResults = 200
}
}
// Parse knowledge_base_ids filter
var kbIDs []string
if kbIDsRaw, ok := args["knowledge_base_ids"].([]interface{}); ok {
for _, id := range kbIDsRaw {
if idStr, ok := id.(string); ok && idStr != "" {
kbIDs = append(kbIDs, idStr)
}
}
}
if len(kbIDs) == 0 {
kbIDs = t.knowledgeBaseIDs
}
// Parse knowledge_ids filter
var knowledgeIDs []string
if knowledgeIDsRaw, ok := args["knowledge_ids"].([]interface{}); ok {
for _, id := range knowledgeIDsRaw {
if idStr, ok := id.(string); ok && idStr != "" {
knowledgeIDs = append(knowledgeIDs, idStr)
}
}
}
logger.Infof(ctx, "[Tool][GrepChunks] Patterns: %v, MaxResults: %d",
patterns, maxResults)
// Build and execute query
results, totalCount, err := t.searchChunks(ctx, patterns, kbIDs, knowledgeIDs, maxResults)
if err != nil {
logger.Errorf(ctx, "[Tool][GrepChunks] Search failed: %v", err)
return &types.ToolResult{
Success: false,
Error: fmt.Sprintf("Search failed: %v", err),
}, err
}
logger.Infof(ctx, "[Tool][GrepChunks] Found %d matching chunks", len(results))
// Format output
output := t.formatOutput(ctx, results, totalCount, patterns, contextLines, countOnly, showLineNumbers, kbIDs, knowledgeIDs)
return &types.ToolResult{
Success: true,
Output: output,
Data: map[string]interface{}{
"patterns": patterns,
"results": results,
"result_count": len(results),
"total_matches": totalCount,
"knowledge_base_ids": kbIDs,
"knowledge_ids": knowledgeIDs,
"max_results": maxResults,
"display_type": "grep_results",
},
}, nil
}
// searchChunks performs the database search with pattern matching
func (t *GrepChunksTool) searchChunks(
ctx context.Context,
patterns []string,
kbIDs []string,
knowledgeIDs []string,
maxResults int,
) ([]map[string]interface{}, int64, error) {
// Build base query
query := t.db.WithContext(ctx).Table("chunks").
Select("chunks.id, chunks.content, chunks.chunk_index, chunks.knowledge_id, chunks.knowledge_base_id, chunks.chunk_type, chunks.created_at, knowledges.title as knowledge_title").
Joins("LEFT JOIN knowledges ON chunks.knowledge_id = knowledges.id").
Where("chunks.tenant_id = ?", t.tenantID).
Where("chunks.is_enabled = ?", true).
Where("chunks.deleted_at IS NULL").
Where("knowledges.deleted_at IS NULL")
// Apply knowledge base filter
if len(kbIDs) > 0 {
query = query.Where("chunks.knowledge_base_id IN ?", kbIDs)
}
// Apply knowledge filter
if len(knowledgeIDs) > 0 {
query = query.Where("chunks.knowledge_id IN ?", knowledgeIDs)
}
// Apply pattern matching (case-insensitive fixed string matching, OR logic for multiple patterns)
if len(patterns) == 1 {
query = query.Where("chunks.content ILIKE ?", "%"+patterns[0]+"%")
} else {
// Multiple patterns: use OR logic
var conditions []string
var args []interface{}
for _, pattern := range patterns {
conditions = append(conditions, "chunks.content ILIKE ?")
args = append(args, "%"+pattern+"%")
}
query = query.Where("("+strings.Join(conditions, " OR ")+")", args...)
}
// Count total matches first (for count_only mode)
var totalCount int64
if err := query.Count(&totalCount).Error; err != nil {
logger.Warnf(ctx, "[Tool][GrepChunks] Failed to count matches: %v", err)
}
// Fetch results
var results []map[string]interface{}
if err := query.Limit(maxResults).Order("chunks.created_at DESC").Find(&results).Error; err != nil {
logger.Errorf(ctx, "[Tool][GrepChunks] Failed to fetch results: %v", err)
return nil, 0, err
}
return results, totalCount, nil
}
// formatOutput formats the search results for display (grep-style output)
func (t *GrepChunksTool) formatOutput(
ctx context.Context,
results []map[string]interface{},
totalCount int64,
patterns []string,
contextLines int,
countOnly bool,
showLineNumbers bool,
kbIDs []string,
knowledgeIDs []string,
) string {
var output strings.Builder
// If count_only mode, just return the count
if countOnly {
output.WriteString(fmt.Sprintf("%d\n", totalCount))
return output.String()
}
// Show search info
if len(patterns) == 1 {
output.WriteString(fmt.Sprintf("Pattern: '%s' (case-insensitive)\n", patterns[0]))
} else {
output.WriteString(fmt.Sprintf("Patterns (%d): %v (case-insensitive, OR logic)\n", len(patterns), patterns))
}
output.WriteString(fmt.Sprintf("Matches: %d chunk(s)\n\n", len(results)))
if len(results) == 0 {
output.WriteString("No matches found.\n")
output.WriteString("\n=== ⚠️ CRITICAL - Next Steps ===\n")
output.WriteString("- ❌ DO NOT use training data or general knowledge to answer\n")
output.WriteString("- ✅ Try knowledge_search for semantic search\n")
output.WriteString("- ✅ If KB search fails and web_search is enabled: You MUST use web_search\n")
output.WriteString("- NEVER fabricate or infer answers - ONLY use retrieved content\n")
return output.String()
}
// Group by document (like grep showing filename)
docGroups := make(map[string]struct {
title string
chunks []map[string]interface{}
})
for _, result := range results {
knowledgeID := fmt.Sprintf("%v", result["knowledge_id"])
title := "Untitled"
if t := result["knowledge_title"]; t != nil {
title = fmt.Sprintf("%v", t)
}
group := docGroups[knowledgeID]
group.title = title
group.chunks = append(group.chunks, result)
docGroups[knowledgeID] = group
}
// Display results in grep-style format
for docID, group := range docGroups {
// Show document header (like grep showing filename)
if len(docGroups) > 1 {
output.WriteString(fmt.Sprintf("\n--- %s (knowledge_id: %s) ---\n", group.title, docID))
}
// Show each matching chunk
for _, chunk := range group.chunks {
chunkIndex := 0
if idx, ok := chunk["chunk_index"]; ok {
switch v := idx.(type) {
case int:
chunkIndex = v
case int64:
chunkIndex = int(v)
case float64:
chunkIndex = int(v)
}
}
content := ""
if c, ok := chunk["content"]; ok && c != nil {
content = fmt.Sprintf("%v", c)
}
// Extract preview with context around match (case-insensitive)
// Try each pattern and use the first match
preview := extractPreviewMultiPattern(content, patterns, false, contextLines)
// Format like grep: [filename:]line:content
if showLineNumbers {
if len(docGroups) > 1 {
output.WriteString(fmt.Sprintf("%s:chunk[%d]:%s\n", group.title, chunkIndex, preview))
} else {
output.WriteString(fmt.Sprintf("chunk[%d]:%s\n", chunkIndex, preview))
}
} else {
if len(docGroups) > 1 {
output.WriteString(fmt.Sprintf("%s:%s\n", group.title, preview))
} else {
output.WriteString(fmt.Sprintf("%s\n", preview))
}
}
}
}
// Add guidance for next steps
output.WriteString("\n=== Next Steps ===\n")
if len(results) > 0 {
output.WriteString("- Found matching documents. Use knowledge_search with semantic queries to understand the context.\n")
output.WriteString("- Filter knowledge_search by knowledge_ids from above results for better relevance.\n")
} else {
output.WriteString("- No matches found. Try different keywords or use knowledge_search for semantic search.\n")
output.WriteString("- Consider using synonyms or related terms in your search patterns.\n")
}
return output.String()
}
// extractPreview extracts a preview of content around the matched pattern
func extractPreview(content, pattern string, caseSensitive bool, contextLen int) string {
if content == "" {
return ""
}
// Find the pattern in content
searchContent := content
searchPattern := pattern
if !caseSensitive {
searchContent = strings.ToLower(content)
searchPattern = strings.ToLower(pattern)
}
pos := strings.Index(searchContent, searchPattern)
if pos == -1 {
// If pattern not found (might be regex), just return first 60 chars
runes := []rune(content)
if len(runes) <= contextLen*2 {
return string(runes)
}
return string(runes[:contextLen*2])
}
// Extract context around the match
runes := []rune(content)
start := pos
// Convert to rune positions for proper unicode handling
runePos := 0
for i, r := range runes {
if runePos >= start {
start = i
break
}
runePos += len(string(r))
}
// Calculate preview bounds
previewStart := start - contextLen
if previewStart < 0 {
previewStart = 0
}
previewEnd := start + len([]rune(pattern)) + contextLen
if previewEnd > len(runes) {
previewEnd = len(runes)
}
preview := string(runes[previewStart:previewEnd])
// Clean up whitespace
preview = strings.ReplaceAll(preview, "\n", " ")
preview = strings.ReplaceAll(preview, "\t", " ")
// Collapse multiple spaces
for strings.Contains(preview, " ") {
preview = strings.ReplaceAll(preview, " ", " ")
}
return strings.TrimSpace(preview)
}
// extractPreviewMultiPattern extracts a preview around the first matched pattern
func extractPreviewMultiPattern(content string, patterns []string, caseSensitive bool, contextLen int) string {
if content == "" || len(patterns) == 0 {
return ""
}
// Try each pattern and find the first match
for _, pattern := range patterns {
preview := extractPreview(content, pattern, caseSensitive, contextLen)
// Check if this pattern found a match (not just truncated content)
searchContent := content
searchPattern := pattern
if !caseSensitive {
searchContent = strings.ToLower(content)
searchPattern = strings.ToLower(pattern)
}
if strings.Contains(searchContent, searchPattern) {
return preview
}
}
// If no pattern matched, return preview from first pattern
if len(patterns) > 0 {
return extractPreview(content, patterns[0], caseSensitive, contextLen)
}
// Fallback: return first N chars
runes := []rune(content)
if len(runes) <= contextLen*2 {
return string(runes)
}
return string(runes[:contextLen*2])
}
+161 -109
View File
@@ -48,48 +48,31 @@ func NewKnowledgeSearchTool(
chatModel chat.Chat,
cfg *config.Config,
) *KnowledgeSearchTool {
description := `Search within knowledge bases. Unified tool that supports both targeted and broad searches.
description := `Semantic/vector search for understanding questions and concepts.
## Features
- Multi-KB search: Search across multiple knowledge bases concurrently
## Core Function
Finds content by MEANING using embeddings (NOT exact text matching).
## Usage
## CRITICAL: Always Check for Entities First
**If query has ANY entities → grep_chunks FIRST, then use this tool**
**Use when**:
- You know which knowledge bases to target (specify knowledge_base_ids)
- You're unsure which KB contains the info (omit knowledge_base_ids to search all allowed KBs)
- Want to search with multiple queries to get comprehensive results
- Want to filter results from specific documents (use knowledge_ids)
## Use When:
- Questions: "What is...", "How...", "Why...", "Explain..."
- Conceptual understanding
- AFTER grep_chunks pre-retrieval (hybrid approach)
**Returns**: Merged and deduplicated search results from KBs
## Hybrid Workflow (Strongly Recommended):
1. grep_chunks(["entity", "synonym", "变体"]) → pre-retrieve documents containing entity
2. knowledge_search(["concept query"]) → deep understanding
## Examples
This ensures entity-related content is not missed!
` + "`" + `
# Simple search in specific KBs
{
"knowledge_base_ids": ["kb1", "kb2"],
"queries": ["什么是向量数据库"]
}
## Parameters
- queries (required): 1-5 semantic questions (NOT just keywords)
- knowledge_base_ids (optional): Limit to specific KBs
# Search all allowed KBs with multiple queries
{
"queries": ["什么是向量数据库", "向量数据库的应用场景"]
}
# Search specific documents
{
"knowledge_base_ids": ["kb1"],
"queries": ["彗星的起源"],
"knowledge_ids": ["doc1", "doc2"]
}
` + "`" + `
## Tips
- Concurrent search across multiple KBs
- Results are automatically reranked to unify scores from different sources
- Results are merged, deduplicated and sorted by relevance`
## Output
Semantically relevant chunks with scores, auto-reranked.`
return &KnowledgeSearchTool{
BaseTool: NewBaseTool("knowledge_search", description),
@@ -110,7 +93,7 @@ func (t *KnowledgeSearchTool) Parameters() map[string]interface{} {
"properties": map[string]interface{}{
"queries": map[string]interface{}{
"type": "array",
"description": "Array of search queries",
"description": "REQUIRED: 1-5 semantic questions/topics (e.g., ['What is RAG?', 'RAG benefits'])",
"items": map[string]interface{}{
"type": "string",
},
@@ -119,22 +102,13 @@ func (t *KnowledgeSearchTool) Parameters() map[string]interface{} {
},
"knowledge_base_ids": map[string]interface{}{
"type": "array",
"description": "Array of knowledge base IDs to search in (optional, if omitted searches all allowed KBs)",
"description": "Optional: KB IDs to search",
"items": map[string]interface{}{
"type": "string",
},
"minItems": 0,
"maxItems": 10,
},
"knowledge_ids": map[string]interface{}{
"type": "array",
"description": "Optional array of document IDs to filter results (only return results from these specific documents)",
"items": map[string]interface{}{
"type": "string",
},
"minItems": 0,
"maxItems": 50,
},
},
"required": []string{"queries"},
}
@@ -243,17 +217,6 @@ func (t *KnowledgeSearchTool) Execute(ctx context.Context, args map[string]inter
logger.Infof(ctx, "[Tool][KnowledgeSearch] Search params: top_k=%d, vector_threshold=%.2f, keyword_threshold=%.2f, min_score=%.2f",
topK, vectorThreshold, keywordThreshold, minScore)
// Extract knowledge_ids filter if provided
var knowledgeIDsFilter map[string]bool
if knowledgeIDsRaw, ok := args["knowledge_ids"].([]interface{}); ok && len(knowledgeIDsRaw) > 0 {
knowledgeIDsFilter = make(map[string]bool)
for _, id := range knowledgeIDsRaw {
if idStr, ok := id.(string); ok && idStr != "" {
knowledgeIDsFilter[idStr] = true
}
}
}
// Execute concurrent search (hybrid search handles both vector and keyword)
logger.Infof(ctx, "[Tool][KnowledgeSearch] Starting concurrent search across %d KBs", len(kbIDs))
kbTypeMap := t.getKnowledgeBaseTypes(ctx, kbIDs)
@@ -262,29 +225,10 @@ func (t *KnowledgeSearchTool) Execute(ctx context.Context, args map[string]inter
topK, vectorThreshold, keywordThreshold, kbTypeMap)
logger.Infof(ctx, "[Tool][KnowledgeSearch] Concurrent search completed: %d raw results", len(allResults))
// Filter by knowledge_ids if provided
if len(knowledgeIDsFilter) > 0 {
logger.Infof(ctx, "[Tool][KnowledgeSearch] Filtering by %d knowledge IDs", len(knowledgeIDsFilter))
filtered := make([]*searchResultWithMeta, 0)
for _, r := range allResults {
if knowledgeIDsFilter[r.KnowledgeID] {
filtered = append(filtered, r)
}
}
logger.Infof(ctx, "[Tool][KnowledgeSearch] After knowledge_id filter: %d results (from %d)",
len(filtered), len(allResults))
allResults = filtered
}
// Filter by threshold first
filteredResults := t.filterByThreshold(allResults, vectorThreshold, keywordThreshold)
logger.Infof(ctx, "[Tool][KnowledgeSearch] After threshold filter: %d results (from %d)",
len(filteredResults), len(allResults))
// Deduplicate before reranking to reduce processing overhead
deduplicatedBeforeRerank := t.deduplicateResults(filteredResults)
logger.Infof(ctx, "[Tool][KnowledgeSearch] After deduplication before rerank: %d results (from %d)",
len(deduplicatedBeforeRerank), len(filteredResults))
// Apply ReRank if model is configured
// Prefer chatModel (LLM-based reranking) over rerankModel if both are available
@@ -358,11 +302,12 @@ func (t *KnowledgeSearchTool) Execute(ctx context.Context, args map[string]inter
// Build output
logger.Infof(ctx, "[Tool][KnowledgeSearch] Formatting output with %d final results", len(deduplicatedResults))
result, err := t.formatOutput(ctx, deduplicatedResults, kbIDs, len(allResults), knowledgeIDsFilter, queries)
result, err := t.formatOutput(ctx, deduplicatedResults, kbIDs, len(allResults), queries)
if err != nil {
logger.Errorf(ctx, "[Tool][KnowledgeSearch] Failed to format output: %v", err)
return result, err
}
logger.Infof(ctx, "[Tool][KnowledgeSearch] Output: %s", result.Output)
return result, nil
}
@@ -859,7 +804,6 @@ func (t *KnowledgeSearchTool) formatOutput(
results []*searchResultWithMeta,
kbsToSearch []string,
totalBeforeFilter int,
knowledgeIDsFilter map[string]bool,
queries []string,
) (*types.ToolResult, error) {
if len(results) == 0 {
@@ -868,39 +812,25 @@ func (t *KnowledgeSearchTool) formatOutput(
"results": []interface{}{},
"count": 0,
}
if len(knowledgeIDsFilter) > 0 {
filterList := make([]string, 0, len(knowledgeIDsFilter))
for id := range knowledgeIDsFilter {
filterList = append(filterList, id)
}
data["knowledge_ids"] = filterList
}
if len(queries) > 0 {
data["queries"] = queries
}
output := fmt.Sprintf("No relevant content found in %d knowledge base(s).\n\n", len(kbsToSearch))
output += "=== ⚠️ CRITICAL - Next Steps ===\n"
output += "- ❌ DO NOT use training data or general knowledge to answer\n"
output += "- ✅ If web_search is enabled: You MUST use web_search to find information\n"
output += "- ✅ If web_search is disabled: State 'I couldn't find relevant information in the knowledge base'\n"
output += "- NEVER fabricate or infer answers - ONLY use retrieved content\n"
return &types.ToolResult{
Success: true,
Output: fmt.Sprintf("No relevant content found in %d knowledge base(s).", len(kbsToSearch)),
Output: output,
Data: data,
}, nil
}
// Build output header
output := "=== Search Results ===\n"
output += fmt.Sprintf("Knowledge Bases: %v\n", kbsToSearch)
if len(knowledgeIDsFilter) > 0 {
filterList := make([]string, 0, len(knowledgeIDsFilter))
for id := range knowledgeIDsFilter {
filterList = append(filterList, id)
}
output += fmt.Sprintf("Document Filter: %v\n", filterList)
}
if len(queries) == 1 {
output += fmt.Sprintf("Query: %s\n", queries[0])
} else if len(queries) > 1 {
output += fmt.Sprintf("Queries (%d): %v\n", len(queries), queries)
}
output += fmt.Sprintf("Found %d relevant results", len(results))
if totalBeforeFilter > len(results) {
output += fmt.Sprintf(" (filtered from %d)", totalBeforeFilter)
@@ -925,6 +855,11 @@ func (t *KnowledgeSearchTool) formatOutput(
faqMetadataCache := make(map[string]*types.FAQChunkMetadata)
// Track chunks per knowledge for statistics
knowledgeChunkMap := make(map[string]map[int]bool) // knowledge_id -> set of chunk_index
knowledgeTotalMap := make(map[string]int64) // knowledge_id -> total chunks
knowledgeTitleMap := make(map[string]string) // knowledge_id -> title
for i, result := range results {
var faqMeta *types.FAQChunkMetadata
if result.KnowledgeBaseType == types.KnowledgeBaseTypeFAQ {
@@ -936,6 +871,13 @@ func (t *KnowledgeSearchTool) formatOutput(
}
}
// Track chunk indices per knowledge
if knowledgeChunkMap[result.KnowledgeID] == nil {
knowledgeChunkMap[result.KnowledgeID] = make(map[int]bool)
}
knowledgeChunkMap[result.KnowledgeID][result.ChunkIndex] = true
knowledgeTitleMap[result.KnowledgeID] = result.KnowledgeTitle
// Group by knowledge base
if result.KnowledgeID != currentKB {
currentKB = result.KnowledgeID
@@ -943,6 +885,20 @@ func (t *KnowledgeSearchTool) formatOutput(
output += "\n"
}
output += fmt.Sprintf("[Source Document: %s]\n", result.KnowledgeTitle)
// Get total chunk count for this knowledge (cache it)
if _, exists := knowledgeTotalMap[result.KnowledgeID]; !exists {
_, total, err := t.chunkService.GetRepository().ListPagedChunksByKnowledgeID(ctx,
t.tenantID, result.KnowledgeID,
&types.Pagination{Page: 1, PageSize: 1},
[]types.ChunkType{types.ChunkTypeText}, "")
if err != nil {
logger.Warnf(ctx, "[Tool][KnowledgeSearch] Failed to get total chunks for knowledge %s: %v", result.KnowledgeID, err)
knowledgeTotalMap[result.KnowledgeID] = 0
} else {
knowledgeTotalMap[result.KnowledgeID] = total
}
}
}
// relevanceLevel := GetRelevanceLevel(result.Score)
@@ -958,8 +914,8 @@ func (t *KnowledgeSearchTool) formatOutput(
}
if len(faqMeta.Answers) > 0 {
output += " FAQ Answers:\n"
for _, ans := range faqMeta.Answers {
output += fmt.Sprintf(" Answer Choice %d: %s\n", i+1, ans)
for ansIdx, ans := range faqMeta.Answers {
output += fmt.Sprintf(" Answer Choice %d: %s\n", ansIdx+1, ans)
}
}
}
@@ -992,6 +948,59 @@ func (t *KnowledgeSearchTool) formatOutput(
}
}
// Add statistics and recommendations for each knowledge
output += "\n=== 检索统计与建议 ===\n\n"
for knowledgeID, retrievedChunks := range knowledgeChunkMap {
totalChunks := knowledgeTotalMap[knowledgeID]
retrievedCount := len(retrievedChunks)
title := knowledgeTitleMap[knowledgeID]
if totalChunks > 0 {
percentage := float64(retrievedCount) / float64(totalChunks) * 100
remaining := totalChunks - int64(retrievedCount)
output += fmt.Sprintf("文档: %s (%s)\n", title, knowledgeID)
output += fmt.Sprintf(" 总 Chunk 数: %d\n", totalChunks)
output += fmt.Sprintf(" 已召回: %d 个 (%.1f%%)\n", retrievedCount, percentage)
output += fmt.Sprintf(" 未召回: %d 个\n", remaining)
if remaining > 0 {
output += " 建议: 使用 list_knowledge_chunks 工具获取完整内容\n"
// Find missing chunk ranges (gaps in retrieved chunks)
missingRanges := t.findMissingChunkRanges(retrievedChunks, int(totalChunks))
if len(missingRanges) == 0 {
// No gaps found (shouldn't happen if remaining > 0, but handle it)
output += fmt.Sprintf(" - 获取全部内容: list_knowledge_chunks(knowledge_id=\"%s\", offset=0, limit=%d)\n", knowledgeID, totalChunks)
} else if len(missingRanges) == 1 && missingRanges[0].start == 0 && missingRanges[0].end == int(totalChunks)-1 {
// All chunks are missing (shouldn't happen, but handle it)
output += fmt.Sprintf(" - 获取全部内容: list_knowledge_chunks(knowledge_id=\"%s\", offset=0, limit=%d)\n", knowledgeID, totalChunks)
} else {
// Suggest getting each missing range
for idx, r := range missingRanges {
rangeSize := r.end - r.start + 1
if rangeSize <= 100 {
// Small range, get all at once
output += fmt.Sprintf(" - 区间 %d: chunk_index %d-%d (%d 个) → list_knowledge_chunks(knowledge_id=\"%s\", offset=%d, limit=%d)\n",
idx+1, r.start, r.end, rangeSize, knowledgeID, r.start, rangeSize)
} else {
// Large range, suggest getting in batches
output += fmt.Sprintf(" - 区间 %d: chunk_index %d-%d (%d 个,建议分批获取):\n",
idx+1, r.start, r.end, rangeSize)
output += fmt.Sprintf(" 首次: list_knowledge_chunks(knowledge_id=\"%s\", offset=%d, limit=100)\n",
knowledgeID, r.start)
if rangeSize > 100 {
output += " 继续: 根据返回结果调整 offset 继续获取剩余内容\n"
}
}
}
}
}
output += "\n"
}
}
// // Add usage guidance
// output += "\n\n=== Usage Guidelines ===\n"
// output += "- High relevance (>=0.8): directly usable for answering\n"
@@ -1011,13 +1020,7 @@ func (t *KnowledgeSearchTool) formatOutput(
"kb_counts": kbCounts,
"display_type": "search_results",
}
if len(knowledgeIDsFilter) > 0 {
filterList := make([]string, 0, len(knowledgeIDsFilter))
for id := range knowledgeIDsFilter {
filterList = append(filterList, id)
}
data["knowledge_ids"] = filterList
}
if len(queries) > 0 {
data["queries"] = queries
}
@@ -1032,3 +1035,52 @@ func (t *KnowledgeSearchTool) formatOutput(
Data: data,
}, nil
}
// chunkRange represents a continuous range of chunk indices
type chunkRange struct {
start int
end int
}
// findMissingChunkRanges finds all continuous ranges of missing chunks
// retrievedChunks is a set of retrieved chunk indices
// totalChunks is the total number of chunks
func (t *KnowledgeSearchTool) findMissingChunkRanges(retrievedChunks map[int]bool, totalChunks int) []chunkRange {
if totalChunks <= 0 {
return nil
}
var ranges []chunkRange
var currentStart int = -1
// Iterate through all possible chunk indices (0 to totalChunks-1)
for i := 0; i < totalChunks; i++ {
if !retrievedChunks[i] {
// This chunk is missing
if currentStart == -1 {
// Start of a new missing range
currentStart = i
}
} else {
// This chunk is retrieved
if currentStart != -1 {
// End of a missing range
ranges = append(ranges, chunkRange{
start: currentStart,
end: i - 1,
})
currentStart = -1
}
}
}
// Handle case where missing chunks extend to the end
if currentStart != -1 {
ranges = append(ranges, chunkRange{
start: currentStart,
end: totalChunks - 1,
})
}
return ranges
}
+20 -20
View File
@@ -23,23 +23,24 @@ func NewListKnowledgeChunksTool(
knowledgeService interfaces.KnowledgeService,
chunkService interfaces.ChunkService,
) *ListKnowledgeChunksTool {
description := `Retrieve paged chunks for a document (knowledge) by knowledge_id.
description := `Retrieve full chunk content for a document by knowledge_id.
## When to Use
## Use After grep_chunks or knowledge_search:
1. grep_chunks(["keyword", "变体"]) → get knowledge_id
2. list_knowledge_chunks(knowledge_id) → read full content
- Need deterministic chunk previews for a known document
- Want to quickly confirm how many chunks a document contains
- Require surrounding context around a chunk_index returned by search results
- Need content snippets without running an additional search query
## When to Use:
- Need full content of chunks from a known document
- Want to see context around specific chunks
- Check how many chunks a document has
Avoid when:
- You don't know the knowledge_id (use knowledge_search first)
## Parameters:
- knowledge_id (required): Document ID
- limit (optional): Chunks per page (default 20, max 100)
- offset (optional): Start position (default 0)
## Parameters
- knowledge_id (required): Target document/knowledge ID
- limit (optional): Number of chunks to fetch (default 20, max 100).
- offset (optional): Offset to start fetching chunks from (default 0).`
## Output:
Full chunk content with chunk_id, chunk_index, and content text.`
return &ListKnowledgeChunksTool{
BaseTool: NewBaseTool("list_knowledge_chunks", description),
@@ -56,18 +57,18 @@ func (t *ListKnowledgeChunksTool) Parameters() map[string]interface{} {
"properties": map[string]interface{}{
"knowledge_id": map[string]interface{}{
"type": "string",
"description": "Knowledge/document ID to inspect",
"description": "Document ID to retrieve chunks from",
},
"limit": map[string]interface{}{
"type": "integer",
"description": "Number of chunks to fetch (default 20, max 100)",
"description": "Chunks per page (default 20, max 100)",
"default": 20,
"minimum": 1,
"maximum": 100,
},
"offset": map[string]interface{}{
"type": "integer",
"description": "Offset to start fetching chunks from (default 0)",
"description": "Start position (default 0)",
"default": 0,
"minimum": 0,
},
@@ -115,7 +116,7 @@ func (t *ListKnowledgeChunksTool) Execute(ctx context.Context, args map[string]i
}
chunks, total, err := t.chunkService.GetRepository().ListPagedChunksByKnowledgeID(ctx,
t.tenantID, knowledgeID, pagination, []types.ChunkType{types.ChunkTypeText}, "")
t.tenantID, knowledgeID, pagination, []types.ChunkType{types.ChunkTypeText, types.ChunkTypeFAQ}, "")
if err != nil {
return &types.ToolResult{
Success: false,
@@ -134,7 +135,7 @@ func (t *ListKnowledgeChunksTool) Execute(ctx context.Context, args map[string]i
knowledgeTitle := t.lookupKnowledgeTitle(ctx, knowledgeID)
output := t.buildOutput(knowledgeID, knowledgeTitle, totalChunks, fetched, chunkLimit, chunks)
output := t.buildOutput(knowledgeID, knowledgeTitle, totalChunks, fetched, chunks)
formattedChunks := make([]map[string]interface{}, 0, len(chunks))
for idx, c := range chunks {
@@ -183,7 +184,6 @@ func (t *ListKnowledgeChunksTool) buildOutput(
knowledgeTitle string,
total int64,
fetched int,
chunkLimit int,
chunks []*types.Chunk,
) string {
builder := &strings.Builder{}
@@ -195,7 +195,6 @@ func (t *ListKnowledgeChunksTool) buildOutput(
builder.WriteString(fmt.Sprintf("文档 ID: %s\n", knowledgeID))
}
builder.WriteString(fmt.Sprintf("总分块数: %d\n", total))
builder.WriteString(fmt.Sprintf("本次拉取: %d 条(offset=%d)\n\n", fetched, chunkLimit))
if fetched == 0 {
builder.WriteString("未找到任何分块,请确认文档是否已完成解析。\n")
@@ -204,6 +203,7 @@ func (t *ListKnowledgeChunksTool) buildOutput(
}
return builder.String()
}
builder.WriteString(fmt.Sprintf("本次拉取: %d 条, 检索范围: %d - %d\n\n", fetched, chunks[0].ChunkIndex, chunks[len(chunks)-1].ChunkIndex))
builder.WriteString("=== 分块内容预览 ===\n\n")
for idx, c := range chunks {
+29 -48
View File
@@ -18,62 +18,43 @@ type QueryKnowledgeGraphTool struct {
// NewQueryKnowledgeGraphTool creates a new query knowledge graph tool
func NewQueryKnowledgeGraphTool(knowledgeService interfaces.KnowledgeBaseService) *QueryKnowledgeGraphTool {
description := `查询知识图谱,探索实体关系和知识网络。
description := `Query knowledge graph to explore entity relationships and knowledge networks.
## 何时使用
## Core Function
Explores relationships between entities in knowledge bases that have graph extraction configured.
**适用场景**:
- ✅ 需要了解实体之间的关系(如"Docker和Kubernetes的关系")
- ✅ 探索知识网络和概念关联
- ✅ 查找特定实体的相关信息
- ✅ 理解技术架构和系统关系
## When to Use
✅ **Use for**:
- Understanding relationships between entities (e.g., "relationship between Docker and Kubernetes")
- Exploring knowledge networks and concept associations
- Finding related information about specific entities
- Understanding technical architecture and system relationships
**不适用**:
- ❌ 普通文本搜索(用 knowledge_search 更合适)
- ❌ 知识库未配置图谱抽取
- ❌ 需要精确的文档内容(用 knowledge_search)
❌ **Don't use for**:
- General text search → use knowledge_search
- Knowledge base without graph extraction configured
- Need exact document content → use knowledge_search
## 参数说明
## Parameters
- **knowledge_base_ids** (required): Array of knowledge base IDs (1-10). Only KBs with graph extraction configured will be effective.
- **query** (required): Query content - can be entity name, relationship query, or concept search.
**knowledge_base_ids** (required): 要查询的知识库ID数组(1-10个)
- 只有配置了图谱抽取的知识库才会有效
- 支持批量并发查询多个知识库
- 示例: ["kb_tech", "kb_arch"]
## Graph Configuration
Knowledge graph must be pre-configured in knowledge bases:
- **Entity types** (Nodes): e.g., "Technology", "Tool", "Concept"
- **Relationship types** (Relations): e.g., "depends_on", "uses", "contains"
**query** (required): 查询内容
- 可以是实体名称(如"Docker")
- 可以是关系查询(如"容器编排")
- 可以是概念搜索(如"微服务架构")
If KB is not configured with graph, tool will return regular search results.
## 图谱配置
## Workflow
1. **Relationship exploration**: query_knowledge_graph → list_knowledge_chunks (for detailed content)
2. **Network analysis**: query_knowledge_graph → knowledge_search (for comprehensive understanding)
3. **Topic research**: knowledge_search → query_knowledge_graph (for deep entity relationships)
知识图谱需要在知识库中预先配置:
- **实体类型**(Nodes):如"技术"、"工具"、"概念"
- **关系类型**(Relations):如"依赖"、"使用"、"包含"
如果知识库未配置图谱,工具会提示并返回普通搜索结果。
## 配合使用
1. **关系探索**: query_knowledge_graph → get_chunk_detail(查看详细内容)
2. **网络分析**: query_knowledge_graph → list_knowledge_chunks(扩展上下文)
3. **主题研究**: knowledge_search → query_knowledge_graph(深入实体关系)
## 当前状态
⚠️ **注意**: 完整的图数据库集成正在开发中。当前版本:
- ✓ 支持图谱配置查询
- ✓ 返回图谱相关的文档片段
- ✓ 显示实体和关系配置信息
- ⏳ 完整的图查询语言(Cypher)支持开发中
- ⏳ 可视化图数据结构开发中
## Tips
- 结果会标注图谱配置状态
- 返回的Data字段包含结构化图信息供前端展示
- 跨知识库结果自动去重
- 按相关度排序`
## Notes
- Results indicate graph configuration status
- Cross-KB results are automatically deduplicated
- Results are sorted by relevance`
return &QueryKnowledgeGraphTool{
BaseTool: NewBaseTool("query_knowledge_graph", description),
+55 -17
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/Tencent/WeKnora/internal/types"
)
@@ -169,13 +168,13 @@ func (t *TodoWriteTool) Parameters() map[string]interface{} {
"type": "string",
"description": "Clear description of what to investigate or accomplish in this step",
},
"tools_to_use": map[string]interface{}{
"type": "array",
"description": "Suggested tools for this step (e.g., ['knowledge_search', 'list_knowledge_chunks'])",
"items": map[string]interface{}{
"type": "string",
},
},
// "tools_to_use": map[string]interface{}{
// "type": "array",
// "description": "Suggested tools for this step (e.g., ['knowledge_search', 'list_knowledge_chunks'])",
// "items": map[string]interface{}{
// "type": "string",
// },
// },
"status": map[string]interface{}{
"type": "string",
"enum": []string{"pending", "in_progress", "completed"},
@@ -274,6 +273,23 @@ func generatePlanOutput(task string, steps []PlanStep) string {
return output
}
// Count task statuses
pendingCount := 0
inProgressCount := 0
completedCount := 0
for _, step := range steps {
switch step.Status {
case "pending":
pendingCount++
case "in_progress":
inProgressCount++
case "completed":
completedCount++
}
}
totalCount := len(steps)
remainingCount := pendingCount + inProgressCount
output += "**计划步骤**:\n\n"
// Display all steps in order
@@ -281,12 +297,34 @@ func generatePlanOutput(task string, steps []PlanStep) string {
output += formatPlanStep(i+1, step)
}
output += "\n**执行指南**:\n"
output += "- 每步执行前标记为 in_progress,完成后标记为 completed\n"
output += "- 根据搜索结果灵活调整计划,可跳过不必要的步骤\n"
output += "- 在关键决策点使用 think 工具深入分析\n"
output += "- 如果某一步骤已获得足够信息,可跳过后续步骤\n\n"
output += "注意:计划是指导而非硬性要求,保持灵活应对。"
// Add summary and emphasis on remaining tasks
output += "\n=== 任务进度 ===\n"
output += fmt.Sprintf("总计: %d 个任务\n", totalCount)
output += fmt.Sprintf("✅ 已完成: %d 个\n", completedCount)
output += fmt.Sprintf("🔄 进行中: %d 个\n", inProgressCount)
output += fmt.Sprintf("⏳ 待处理: %d 个\n", pendingCount)
output += "\n=== ⚠️ 重要提醒 ===\n"
if remainingCount > 0 {
output += fmt.Sprintf("**还有 %d 个任务未完成!**\n\n", remainingCount)
output += "**必须完成所有任务后才能总结或得出结论。**\n\n"
output += "下一步操作:\n"
if inProgressCount > 0 {
output += "- 继续完成当前进行中的任务\n"
}
if pendingCount > 0 {
output += fmt.Sprintf("- 开始处理 %d 个待处理任务\n", pendingCount)
output += "- 按顺序完成每个任务,不要跳过\n"
}
output += "- 完成每个任务后,更新 todo_write 标记为 completed\n"
output += "- 只有在所有任务完成后,才能生成最终总结\n"
} else {
output += "✅ **所有任务已完成!**\n\n"
output += "现在可以:\n"
output += "- 综合所有任务的发现\n"
output += "- 生成完整的最终答案或报告\n"
output += "- 确保所有方面都已充分研究\n"
}
return output
}
@@ -307,9 +345,9 @@ func formatPlanStep(index int, step PlanStep) string {
output := fmt.Sprintf(" %d. %s [%s] %s\n", index, emoji, step.Status, step.Description)
if len(step.ToolsToUse) > 0 {
output += fmt.Sprintf(" 工具: %s\n", strings.Join(step.ToolsToUse, ", "))
}
// if len(step.ToolsToUse) > 0 {
// output += fmt.Sprintf(" 工具: %s\n", strings.Join(step.ToolsToUse, ", "))
// }
return output
}
+25 -12
View File
@@ -43,20 +43,17 @@ type WebFetchTool struct {
// NewWebFetchTool 创建 web_fetch 工具实例
func NewWebFetchTool(chatModel chat.Chat) *WebFetchTool {
description := `Fetch web content from previously discovered URLs and analyze it with an LLM.
description := `Fetch detailed web content from previously discovered URLs and analyze it with an LLM.
## 流程
- 接收一个或多个 {url, prompt} 组合
- 抓取网页内容并转换为 Markdown 文本
- 使用提示词调用小模型进行分析与总结(若模型可用)
- 返回总结结果与原始内容片段
## Usage
- Receive one or more {url, prompt} combinations
- Fetch web page content and convert to Markdown text
- Use prompt to call small model for analysis and summary (if model is available)
- Return summary result and original content fragment
## 使用场景
- 对 web_search 返回的链接做进一步深入阅读
- 从网页中提取结构化信息或关键信息
## 参数
- items (必填): 数组,元素为 { "url": "...", "prompt": "..." }
## When to Use
- **MANDATORY**: After web_search returns results, if content is truncated or incomplete, use web_fetch to get full page content
- When web_search snippet is insufficient for answering the question
`
return &WebFetchTool{
@@ -192,6 +189,22 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{})
}
}
// Add guidance for next steps
builder.WriteString("\n=== Next Steps ===\n")
if len(aggregated) > 0 {
builder.WriteString("- ✅ Full page content has been fetched and analyzed.\n")
builder.WriteString("- Evaluate if the content is sufficient to answer the question completely.\n")
builder.WriteString("- Synthesize information from all fetched pages for comprehensive answers.\n")
if !success {
builder.WriteString("- ⚠️ Some URLs failed to fetch. Use available content or try alternative sources.\n")
}
} else {
builder.WriteString("- ❌ No content was successfully fetched. Consider:\n")
builder.WriteString(" - Verify URLs are accessible\n")
builder.WriteString(" - Try alternative sources from web_search results\n")
builder.WriteString(" - Check if information can be found in knowledge base instead\n")
}
data := map[string]interface{}{
"results": aggregated,
"count": len(aggregated),
+23 -1
View File
@@ -33,6 +33,12 @@ func NewWebSearchTool(
) *WebSearchTool {
description := `Search the web for current information and news. This tool searches the internet to find up-to-date information that may not be in the knowledge base.
## CRITICAL - KB First Rule
**ABSOLUTE RULE**: You MUST complete KB retrieval (grep_chunks AND knowledge_search) FIRST before using this tool.
- NEVER use web_search without first trying grep_chunks and knowledge_search
- ONLY use web_search if BOTH grep_chunks AND knowledge_search return insufficient/no results
- KB retrieval is MANDATORY - you CANNOT skip it
## Features
- Real-time web search: Search the internet for current information
- RAG compression: Automatically compresses and extracts relevant content from search results
@@ -41,6 +47,8 @@ func NewWebSearchTool(
## Usage
**Use when**:
- **ONLY after** completing grep_chunks AND knowledge_search
- KB retrieval returned insufficient or no results
- Need current or real-time information (news, events, recent updates)
- Information is not available in knowledge bases
- Need to verify or supplement information from knowledge bases
@@ -70,7 +78,8 @@ func NewWebSearchTool(
- Results are automatically compressed using RAG to extract relevant content
- Search results are stored in a temporary knowledge base for the session
- Use this tool when knowledge bases don't have the information you need
- Results include URL, title, snippet, and full content when available
- Results include URL, title, snippet, and content snippet (may be truncated)
- **CRITICAL**: If content is truncated or you need full details, use **web_fetch** to fetch complete page content
- Maximum ` + fmt.Sprintf("%d", maxResults) + ` results will be returned per search`
return &WebSearchTool{
@@ -232,6 +241,19 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}
formattedResults = append(formattedResults, resultData)
}
// Add guidance for next steps
output += "\n=== Next Steps ===\n"
if len(webResults) > 0 {
output += "- ⚠️ Content may be truncated (showing first 500 chars). Use web_fetch to get full page content.\n"
output += "- Extract URLs from results above and use web_fetch with appropriate prompts to get detailed information.\n"
output += "- Synthesize information from multiple sources for comprehensive answers.\n"
} else {
output += "- No web search results found. Consider:\n"
output += " - Try different search queries or keywords\n"
output += " - Check if question can be answered from knowledge base instead\n"
output += " - Verify if the topic requires real-time information\n"
}
return &types.ToolResult{
Success: true,
Output: output,
+28 -1
View File
@@ -25,7 +25,7 @@ func (r *chunkRepository) CreateChunks(ctx context.Context, chunks []*types.Chun
for _, chunk := range chunks {
chunk.Content = common.CleanInvalidUTF8(chunk.Content)
}
return r.db.Debug().WithContext(ctx).CreateInBatches(chunks, 100).Error
return r.db.WithContext(ctx).CreateInBatches(chunks, 100).Error
}
// GetChunkByID retrieves a chunk by its ID and tenant ID
@@ -141,6 +141,14 @@ func (r *chunkRepository) DeleteChunk(ctx context.Context, tenantID uint, id str
return r.db.WithContext(ctx).Where("tenant_id = ? AND id = ?", tenantID, id).Delete(&types.Chunk{}).Error
}
// DeleteChunks deletes chunks by IDs in batch
func (r *chunkRepository) DeleteChunks(ctx context.Context, tenantID uint, ids []string) error {
if len(ids) == 0 {
return nil
}
return r.db.WithContext(ctx).Where("tenant_id = ? AND id IN ?", tenantID, ids).Delete(&types.Chunk{}).Error
}
// DeleteChunksByKnowledgeID deletes all chunks for a knowledge ID
func (r *chunkRepository) DeleteChunksByKnowledgeID(ctx context.Context, tenantID uint, knowledgeID string) error {
return r.db.WithContext(ctx).Where(
@@ -163,3 +171,22 @@ func (r *chunkRepository) CountChunksByKnowledgeBaseID(ctx context.Context, tena
Count(&count).Error
return count, err
}
func (r *chunkRepository) DeleteChunksByChunkIndexRange(ctx context.Context, tenantID uint, knowledgeID string, startChunkIndex int, endChunkIndex int) ([]*types.Chunk, error) {
var chunks []*types.Chunk
// 先查询要删除的chunks
if err := r.db.WithContext(ctx).
Where("tenant_id = ? AND knowledge_id = ? AND chunk_index >= ? AND chunk_index <= ? AND deleted_at IS NULL", tenantID, knowledgeID, startChunkIndex, endChunkIndex).
Find(&chunks).Error; err != nil {
return nil, err
}
// 然后删除它们
if len(chunks) > 0 {
if err := r.db.WithContext(ctx).
Where("tenant_id = ? AND knowledge_id = ? AND chunk_index >= ? AND chunk_index <= ? AND deleted_at IS NULL", tenantID, knowledgeID, startChunkIndex, endChunkIndex).
Delete(&types.Chunk{}).Error; err != nil {
return nil, err
}
}
return chunks, nil
}
@@ -229,3 +229,26 @@ func (r *knowledgeRepository) CountKnowledgeByKnowledgeBaseID(ctx context.Contex
Count(&count).Error
return count, err
}
// CountKnowledgeByStatus counts the number of knowledge items with the specified parse status
func (r *knowledgeRepository) CountKnowledgeByStatus(
ctx context.Context,
tenantID uint,
kbID string,
parseStatuses []string,
) (int64, error) {
if len(parseStatuses) == 0 {
return 0, nil
}
var count int64
query := r.db.WithContext(ctx).Model(&types.Knowledge{}).
Where("tenant_id = ? AND knowledge_base_id = ?", tenantID, kbID).
Where("parse_status IN ?", parseStatuses)
if err := query.Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"github.com/Tencent/WeKnora/internal/common"
"github.com/Tencent/WeKnora/internal/logger"
@@ -151,8 +152,10 @@ func (g *pgRepository) KeywordsRetrieve(ctx context.Context,
conds := make([]clause.Expression, 0)
if len(params.KnowledgeBaseIDs) > 0 {
logger.GetLogger(ctx).Debugf("[Postgres] Filtering by knowledge base IDs: %v", params.KnowledgeBaseIDs)
conds = append(conds, clause.Expr{
SQL: fmt.Sprintf("knowledge_base_id @@@ 'in (%s)'", common.StringSliceJoin(params.KnowledgeBaseIDs)),
// Use standard SQL IN clause instead of @@@ operator for better performance with B-tree index
conds = append(conds, clause.IN{
Column: "knowledge_base_id",
Values: common.ToInterfaceSlice(params.KnowledgeBaseIDs),
})
}
conds = append(conds, clause.Expr{
@@ -210,52 +213,96 @@ func (g *pgRepository) KeywordsRetrieve(ctx context.Context,
}
// VectorRetrieve performs vector similarity search using pgvector
// Optimized to use HNSW index efficiently and avoid recalculating vector distance
func (g *pgRepository) VectorRetrieve(ctx context.Context,
params types.RetrieveParams,
) ([]*types.RetrieveResult, error) {
logger.GetLogger(ctx).Infof("[Postgres] Vector retrieval: dim=%d, topK=%d, threshold=%.4f",
len(params.Embedding), params.TopK, params.Threshold)
conds := make([]clause.Expression, 0)
dimension := len(params.Embedding)
queryVector := pgvector.NewHalfVector(params.Embedding)
// Build WHERE conditions for filtering
whereParts := make([]string, 0)
allVars := make([]interface{}, 0)
// Add query vector first (used in ORDER BY for HNSW index)
allVars = append(allVars, queryVector)
// Dimension filter (required for HNSW index WHERE clause)
whereParts = append(whereParts, fmt.Sprintf("dimension = $%d", len(allVars)+1))
allVars = append(allVars, dimension)
// Knowledge base filter
if len(params.KnowledgeBaseIDs) > 0 {
logger.GetLogger(ctx).Debugf(
"[Postgres] Filtering vector search by knowledge base IDs: %v",
params.KnowledgeBaseIDs,
)
conds = append(conds, clause.IN{
Column: "knowledge_base_id",
Values: common.ToInterfaceSlice(params.KnowledgeBaseIDs),
})
// Build IN clause with proper placeholders
placeholders := make([]string, len(params.KnowledgeBaseIDs))
paramStart := len(allVars) + 1
for i := range params.KnowledgeBaseIDs {
placeholders[i] = fmt.Sprintf("$%d", paramStart+i)
allVars = append(allVars, params.KnowledgeBaseIDs[i])
}
whereParts = append(whereParts, fmt.Sprintf("knowledge_base_id IN (%s)",
strings.Join(placeholders, ", ")))
}
// <=> Cosine similarity operator
// <-> L2 distance operator
// <#> Inner product operator
dimension := len(params.Embedding)
conds = append(conds, clause.Expr{SQL: "dimension = ?", Vars: []interface{}{dimension}})
// Filter by is_enabled = true or NULL (NULL means enabled for historical data)
conds = append(conds, clause.Expr{
SQL: "(is_enabled IS NULL OR is_enabled = ?)",
Vars: []interface{}{true},
})
conds = append(conds, clause.Expr{
SQL: fmt.Sprintf("embedding::halfvec(%d) <=> ?::halfvec < ?", dimension),
Vars: []interface{}{pgvector.NewHalfVector(params.Embedding), 1 - params.Threshold},
})
conds = append(conds, clause.OrderBy{Expression: clause.Expr{
SQL: fmt.Sprintf("embedding::halfvec(%d) <=> ?::halfvec", dimension),
Vars: []interface{}{pgvector.NewHalfVector(params.Embedding)},
}})
// is_enabled filter
whereParts = append(whereParts, fmt.Sprintf("(is_enabled IS NULL OR is_enabled = $%d)", len(allVars)+1))
allVars = append(allVars, true)
// Build WHERE clause string
whereClause := ""
if len(whereParts) > 0 {
whereClause = "WHERE " + strings.Join(whereParts, " AND ")
}
// Expand TopK to get more candidates before threshold filtering
expandedTopK := params.TopK * 2
if expandedTopK < 100 {
expandedTopK = 100 // Minimum 100 candidates
}
if expandedTopK > 1000 {
expandedTopK = 1000 // Maximum 1000 candidates
}
// Optimized query: Use subquery to calculate distance once
// Strategy: Use ORDER BY with vector distance to leverage HNSW index,
// then filter by threshold in outer query
// This allows PostgreSQL to use HNSW index efficiently
subqueryLimitParam := len(allVars) + 1
thresholdParam := len(allVars) + 2
finalLimitParam := len(allVars) + 3
querySQL := fmt.Sprintf(`
SELECT
id, content, source_id, source_type, chunk_id, knowledge_id, knowledge_base_id,
(1 - distance) as score
FROM (
SELECT
id, content, source_id, source_type, chunk_id, knowledge_id, knowledge_base_id,
embedding::halfvec(%d) <=> $1::halfvec as distance
FROM embeddings
%s
ORDER BY embedding::halfvec(%d) <=> $1::halfvec
LIMIT $%d
) AS candidates
WHERE distance < $%d
ORDER BY distance ASC
LIMIT $%d
`, dimension, whereClause, dimension, subqueryLimitParam, thresholdParam, finalLimitParam)
allVars = append(allVars, expandedTopK) // LIMIT in subquery
allVars = append(allVars, 1-params.Threshold) // Distance threshold
allVars = append(allVars, params.TopK) // Final LIMIT
var embeddingDBList []pgVectorWithScore
err := g.db.WithContext(ctx).Clauses(conds...).
Select(fmt.Sprintf(
"id, content, source_id, source_type, chunk_id, knowledge_id, knowledge_base_id, "+
"(1 - (embedding::halfvec(%d) <=> ?::halfvec)) as score",
dimension,
), pgvector.NewHalfVector(params.Embedding)).
Limit(int(params.TopK)).
Find(&embeddingDBList).Error
err := g.db.WithContext(ctx).Raw(querySQL, allVars...).Scan(&embeddingDBList).Error
if err == gorm.ErrRecordNotFound {
logger.GetLogger(ctx).Warnf("[Postgres] No vector matches found that meet threshold %.4f", params.Threshold)
@@ -266,6 +313,11 @@ func (g *pgRepository) VectorRetrieve(ctx context.Context,
return nil, err
}
// Apply final TopK limit (in case we got more results than needed)
if len(embeddingDBList) > int(params.TopK) {
embeddingDBList = embeddingDBList[:params.TopK]
}
logger.GetLogger(ctx).Infof("[Postgres] Vector retrieval found %d results", len(embeddingDBList))
results := make([]*types.IndexWithScore, len(embeddingDBList))
for i := range embeddingDBList {
@@ -171,10 +171,7 @@ func (s *agentService) registerTools(
sessionService interfaces.SessionService,
) error {
// If no specific tools allowed, register default tools
allowedTools := config.AllowedTools
if len(allowedTools) == 0 {
allowedTools = tools.DefaultAllowedTools()
}
allowedTools := tools.DefaultAllowedTools()
// If web search is enabled, add web_search to allowedTools
if config.WebSearchEnabled {
allowedTools = append(allowedTools, "web_search")
@@ -206,6 +203,9 @@ func (s *agentService) registerTools(
chatModel,
s.cfg,
))
case "grep_chunks":
registry.RegisterTool(tools.NewGrepChunksTool(s.db, tenantID, config.KnowledgeBases))
logger.Infof(ctx, "Registered grep_chunks tool for tenant: %d", tenantID)
case "list_knowledge_chunks":
registry.RegisterTool(tools.NewListKnowledgeChunksTool(tenantID, s.knowledgeService, s.chunkService))
case "query_knowledge_graph":
+32 -3
View File
@@ -58,8 +58,6 @@ func (s *chunkService) GetRepository() interfaces.ChunkRepository {
// Returns:
// - error: Any error encountered during chunk creation
func (s *chunkService) CreateChunks(ctx context.Context, chunks []*types.Chunk) error {
logger.Info(ctx, "Start creating chunks")
logger.Infof(ctx, "Creating %d chunks", len(chunks))
err := s.chunkRepository.CreateChunks(ctx, chunks)
if err != nil {
@@ -69,7 +67,7 @@ func (s *chunkService) CreateChunks(ctx context.Context, chunks []*types.Chunk)
return err
}
logger.Info(ctx, "Chunks created successfully")
logger.Infof(ctx, "Add %d chunks successfully", len(chunks))
return nil
}
@@ -216,6 +214,37 @@ func (s *chunkService) DeleteChunk(ctx context.Context, id string) error {
return nil
}
// DeleteChunks deletes chunks by IDs in batch
// This method removes multiple chunks from the repository in a single operation
// Parameters:
// - ctx: Context with authentication and request information
// - ids: Slice of chunk IDs to delete
//
// Returns:
// - error: Any error encountered during batch deletion
func (s *chunkService) DeleteChunks(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
logger.Info(ctx, "Start deleting chunks in batch")
logger.Infof(ctx, "Deleting %d chunks", len(ids))
tenantID := ctx.Value(types.TenantIDContextKey).(uint)
logger.Infof(ctx, "Tenant ID: %d", tenantID)
err := s.chunkRepository.DeleteChunks(ctx, tenantID, ids)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"chunk_ids": ids,
"tenant_id": tenantID,
})
return err
}
logger.Infof(ctx, "Successfully deleted %d chunks", len(ids))
return nil
}
// DeleteChunksByKnowledgeID deletes all chunks for a knowledge ID
// This method removes all chunks belonging to a specific knowledge document
// Parameters:
File diff suppressed because it is too large Load Diff
+130 -26
View File
@@ -27,6 +27,9 @@ type knowledgeBaseService struct {
chunkRepo interfaces.ChunkRepository
modelService interfaces.ModelService
retrieveEngine interfaces.RetrieveEngineRegistry
tenantRepo interfaces.TenantRepository
fileSvc interfaces.FileService
graphEngine interfaces.RetrieveGraphRepository
}
// NewKnowledgeBaseService creates a new knowledge base service
@@ -35,6 +38,9 @@ func NewKnowledgeBaseService(repo interfaces.KnowledgeBaseRepository,
chunkRepo interfaces.ChunkRepository,
modelService interfaces.ModelService,
retrieveEngine interfaces.RetrieveEngineRegistry,
tenantRepo interfaces.TenantRepository,
fileSvc interfaces.FileService,
graphEngine interfaces.RetrieveGraphRepository,
) interfaces.KnowledgeBaseService {
return &knowledgeBaseService{
repo: repo,
@@ -42,6 +48,9 @@ func NewKnowledgeBaseService(repo interfaces.KnowledgeBaseRepository,
chunkRepo: chunkRepo,
modelService: modelService,
retrieveEngine: retrieveEngine,
tenantRepo: tenantRepo,
fileSvc: fileSvc,
graphEngine: graphEngine,
}
}
@@ -89,8 +98,6 @@ func (s *knowledgeBaseService) GetKnowledgeBaseByID(ctx context.Context, id stri
return nil, errors.New("knowledge base ID cannot be empty")
}
logger.Infof(ctx, "Retrieving knowledge base, ID: %s", id)
kb, err := s.repo.GetKnowledgeBaseByID(ctx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
@@ -100,15 +107,12 @@ func (s *knowledgeBaseService) GetKnowledgeBaseByID(ctx context.Context, id stri
}
kb.EnsureDefaults()
logger.Infof(ctx, "Knowledge base retrieved successfully, ID: %s, name: %s", kb.ID, kb.Name)
return kb, nil
}
// ListKnowledgeBases returns all knowledge bases for a tenant
func (s *knowledgeBaseService) ListKnowledgeBases(ctx context.Context) ([]*types.KnowledgeBase, error) {
tenantID := ctx.Value(types.TenantIDContextKey).(uint)
logger.Infof(ctx, "Retrieving knowledge base list for tenant, tenant ID: %d", tenantID)
kbs, err := s.repo.ListKnowledgeBasesByTenantID(ctx, tenantID)
if err != nil {
@@ -125,7 +129,7 @@ func (s *knowledgeBaseService) ListKnowledgeBases(ctx context.Context) ([]*types
// Query knowledge count and chunk count for each knowledge base
for _, kb := range kbs {
kb.EnsureDefaults()
// Get knowledge count
if kb.Type == types.KnowledgeBaseTypeDocument {
knowledgeCount, err := s.kgRepo.CountKnowledgeByKnowledgeBaseID(ctx, tenantID, kb.ID)
@@ -143,14 +147,21 @@ func (s *knowledgeBaseService) ListKnowledgeBases(ctx context.Context) ([]*types
kb.ChunkCount = chunkCount
}
}
}
logger.Infof(
ctx,
"Knowledge base list retrieved successfully, tenant ID: %d, knowledge base count: %d",
tenantID,
len(kbs),
)
// Check if there is a processing import task
processingCount, err := s.kgRepo.CountKnowledgeByStatus(
ctx,
tenantID,
kb.ID,
[]string{"pending", "processing"},
)
if err != nil {
logger.Warnf(ctx, "Failed to check processing status for knowledge base %s: %v", kb.ID, err)
} else {
kb.IsProcessing = processingCount > 0
kb.ProcessingCount = processingCount
}
}
return kbs, nil
}
@@ -210,7 +221,107 @@ func (s *knowledgeBaseService) DeleteKnowledgeBase(ctx context.Context, id strin
logger.Infof(ctx, "Deleting knowledge base, ID: %s", id)
err := s.repo.DeleteKnowledgeBase(ctx, id)
// Get tenant ID from context
tenantID := ctx.Value(types.TenantIDContextKey).(uint)
// Step 1: Get all knowledge entries in this knowledge base
logger.Infof(ctx, "Fetching all knowledge entries in knowledge base, ID: %s", id)
knowledgeList, err := s.kgRepo.ListKnowledgeByKnowledgeBaseID(ctx, tenantID, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": id,
})
return err
}
logger.Infof(ctx, "Found %d knowledge entries to delete", len(knowledgeList))
// Step 2: Delete all knowledge entries and their resources
if len(knowledgeList) > 0 {
knowledgeIDs := make([]string, 0, len(knowledgeList))
for _, knowledge := range knowledgeList {
knowledgeIDs = append(knowledgeIDs, knowledge.ID)
}
logger.Infof(ctx, "Deleting all knowledge entries and their resources")
// Delete embeddings from vector store
logger.Infof(ctx, "Deleting embeddings from vector store")
tenantInfo := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
retrieveEngine, err := retriever.NewCompositeRetrieveEngine(s.retrieveEngine, tenantInfo.RetrieverEngines.Engines)
if err != nil {
logger.Warnf(ctx, "Failed to create retrieve engine: %v", err)
} else {
// Group knowledge by embedding model
embeddingGroups := make(map[string][]string)
for _, knowledge := range knowledgeList {
embeddingGroups[knowledge.EmbeddingModelID] = append(embeddingGroups[knowledge.EmbeddingModelID], knowledge.ID)
}
for embeddingModelID, knowledgeGroup := range embeddingGroups {
embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, embeddingModelID)
if err != nil {
logger.Warnf(ctx, "Failed to get embedding model %s: %v", embeddingModelID, err)
continue
}
if err := retrieveEngine.DeleteByKnowledgeIDList(ctx, knowledgeGroup, embeddingModel.GetDimensions()); err != nil {
logger.Warnf(ctx, "Failed to delete embeddings for model %s: %v", embeddingModelID, err)
}
}
}
// Delete all chunks
logger.Infof(ctx, "Deleting all chunks in knowledge base")
for _, knowledgeID := range knowledgeIDs {
if err := s.chunkRepo.DeleteChunksByKnowledgeID(ctx, tenantID, knowledgeID); err != nil {
logger.Warnf(ctx, "Failed to delete chunks for knowledge %s: %v", knowledgeID, err)
}
}
// Delete physical files and adjust storage
logger.Infof(ctx, "Deleting physical files")
storageAdjust := int64(0)
for _, knowledge := range knowledgeList {
if knowledge.FilePath != "" {
if err := s.fileSvc.DeleteFile(ctx, knowledge.FilePath); err != nil {
logger.Warnf(ctx, "Failed to delete file %s: %v", knowledge.FilePath, err)
}
}
storageAdjust -= knowledge.StorageSize
}
if storageAdjust != 0 {
if err := s.tenantRepo.AdjustStorageUsed(ctx, tenantID, storageAdjust); err != nil {
logger.Warnf(ctx, "Failed to adjust tenant storage: %v", err)
}
}
// Delete knowledge graph data
logger.Infof(ctx, "Deleting knowledge graph data")
namespaces := make([]types.NameSpace, 0, len(knowledgeList))
for _, knowledge := range knowledgeList {
namespaces = append(namespaces, types.NameSpace{
KnowledgeBase: knowledge.KnowledgeBaseID,
Knowledge: knowledge.ID,
})
}
if s.graphEngine != nil && len(namespaces) > 0 {
if err := s.graphEngine.DelGraph(ctx, namespaces); err != nil {
logger.Warnf(ctx, "Failed to delete knowledge graph: %v", err)
}
}
// Delete all knowledge entries from database
logger.Infof(ctx, "Deleting knowledge entries from database")
if err := s.kgRepo.DeleteKnowledgeList(ctx, tenantID, knowledgeIDs); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": id,
})
return err
}
}
// Step 3: Delete the knowledge base itself
logger.Infof(ctx, "Deleting knowledge base from database")
err = s.repo.DeleteKnowledgeBase(ctx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": id,
@@ -343,6 +454,8 @@ func (s *knowledgeBaseService) HybridSearch(ctx context.Context,
return nil, err
}
matchCount := params.MatchCount * 3
// Add vector retrieval params if supported
if retrieveEngine.SupportRetriever(types.VectorRetrieverType) && !params.DisableVectorMatch {
logger.Info(ctx, "Vector retrieval supported, preparing vector retrieval parameters")
@@ -368,7 +481,7 @@ func (s *knowledgeBaseService) HybridSearch(ctx context.Context,
Query: params.QueryText,
Embedding: queryEmbedding,
KnowledgeBaseIDs: []string{id},
TopK: params.MatchCount,
TopK: matchCount,
Threshold: params.VectorThreshold,
RetrieverType: types.VectorRetrieverType,
})
@@ -381,7 +494,7 @@ func (s *knowledgeBaseService) HybridSearch(ctx context.Context,
retrieveParams = append(retrieveParams, types.RetrieveParams{
Query: params.QueryText,
KnowledgeBaseIDs: []string{id},
TopK: params.MatchCount,
TopK: matchCount,
Threshold: params.KeywordThreshold,
RetrieverType: types.KeywordsRetrieverType,
})
@@ -423,23 +536,16 @@ func (s *knowledgeBaseService) HybridSearch(ctx context.Context,
}
logger.Infof(ctx, "Result count before deduplication: %d", len(matchResults))
for _, chunk := range matchResults {
logger.Infof(ctx, "Before deduplication: Chunk: %s, Content: %s, Score: %f, MatchType: %d", chunk.ChunkID, chunk.Content, chunk.Score, chunk.MatchType)
}
// First, try standard deduplication
deduplicatedChunks := common.DeduplicateWithScore(func(r *types.IndexWithScore) string { return r.ChunkID }, matchResults...)
logger.Infof(ctx, "Result count after deduplication: %d", len(deduplicatedChunks))
for _, chunk := range deduplicatedChunks {
logger.Infof(ctx, "After deduplication: Chunk: %s, Content: %s, Score: %f, MatchType: %d", chunk.ChunkID, chunk.Content, chunk.Score, chunk.MatchType)
}
kb.EnsureDefaults()
// Check if we need iterative retrieval for FAQ with separate indexing
// Only use iterative retrieval if we don't have enough unique chunks after first deduplication
needsIterativeRetrieval := len(deduplicatedChunks) < params.MatchCount &&
kb.Type == types.KnowledgeBaseTypeFAQ && len(matchResults) >= params.MatchCount
kb.Type == types.KnowledgeBaseTypeFAQ && len(matchResults) == matchCount
if needsIterativeRetrieval {
logger.Infof(ctx, "Not enough unique chunks (%d < %d), using iterative retrieval for FAQ",
len(deduplicatedChunks), params.MatchCount)
@@ -473,8 +579,6 @@ func (s *knowledgeBaseService) iterativeRetrieveWithDeduplication(ctx context.Co
uniqueChunks := make(map[string]*types.IndexWithScore)
for i := 0; i < maxIterations; i++ {
logger.Infof(ctx, "Iterative retrieval iteration %d, TopK: %d", i+1, currentTopK)
// Update TopK in retrieve params
updatedParams := make([]types.RetrieveParams, len(retrieveParams))
for j := range retrieveParams {
@@ -77,13 +77,15 @@ func (v *KeywordsVectorHybridRetrieveEngineService) BatchIndex(ctx context.Conte
embeddings, err = embedder.BatchEmbedWithPool(ctx, embedder, contentList)
if err == nil {
break
} else {
logger.Errorf(ctx, "BatchEmbedWithPool failed: %v", err)
time.Sleep(100 * time.Millisecond)
}
time.Sleep(100 * time.Millisecond)
}
if err != nil {
return err
}
batchSize := 20
batchSize := 40
for i, indexChunk := range utils.ChunkSlice(indexInfoList, batchSize) {
embeddingMap := make(map[string][]float32)
for j, indexInfo := range indexChunk {
+1 -32
View File
@@ -125,8 +125,6 @@ func (s *sessionService) GetSession(ctx context.Context, id string) (*types.Sess
// GetSessionsByTenant retrieves all sessions for the current tenant
func (s *sessionService) GetSessionsByTenant(ctx context.Context) ([]*types.Session, error) {
logger.Info(ctx, "Start retrieving all sessions for tenant")
// Get tenant ID from context
tenantID := ctx.Value(types.TenantIDContextKey).(uint)
logger.Infof(ctx, "Retrieving all sessions for tenant, tenant ID: %d", tenantID)
@@ -150,13 +148,8 @@ func (s *sessionService) GetSessionsByTenant(ctx context.Context) ([]*types.Sess
func (s *sessionService) GetPagedSessionsByTenant(ctx context.Context,
pagination *types.Pagination,
) (*types.PageResult, error) {
logger.Info(ctx, "Start retrieving paged sessions for tenant")
// Get tenant ID from context
tenantID := ctx.Value(types.TenantIDContextKey).(uint)
logger.Infof(ctx, "Retrieving paged sessions for tenant, tenant ID: %d, page: %d, page size: %d",
tenantID, pagination.Page, pagination.PageSize)
// Get paged sessions from repository
sessions, total, err := s.sessionRepo.GetPagedByTenantID(ctx, tenantID, pagination)
if err != nil {
@@ -168,22 +161,17 @@ func (s *sessionService) GetPagedSessionsByTenant(ctx context.Context,
return nil, err
}
logger.Infof(ctx, "Tenant paged sessions retrieved successfully, tenant ID: %d, total: %d", tenantID, total)
return types.NewPageResult(total, pagination, sessions), nil
}
// UpdateSession updates an existing session's properties
func (s *sessionService) UpdateSession(ctx context.Context, session *types.Session) error {
logger.Info(ctx, "Start updating session")
// Validate session ID
if session.ID == "" {
logger.Error(ctx, "Failed to update session: session ID cannot be empty")
return errors.New("session id is required")
}
logger.Infof(ctx, "Updating session, ID: %s, tenant ID: %d", session.ID, session.TenantID)
// Update session in repository
err := s.sessionRepo.Update(ctx, session)
if err != nil {
@@ -200,8 +188,6 @@ func (s *sessionService) UpdateSession(ctx context.Context, session *types.Sessi
// DeleteSession removes a session by its ID
func (s *sessionService) DeleteSession(ctx context.Context, id string) error {
logger.Info(ctx, "Start deleting session")
// Validate session ID
if id == "" {
logger.Error(ctx, "Failed to delete session: session ID cannot be empty")
@@ -210,7 +196,6 @@ func (s *sessionService) DeleteSession(ctx context.Context, id string) error {
// Get tenant ID from context
tenantID := ctx.Value(types.TenantIDContextKey).(uint)
logger.Infof(ctx, "Deleting session, ID: %s, tenant ID: %d", id, tenantID)
// Cleanup temporary KB stored in Redis for this session
if err := s.DeleteWebSearchTempKBState(ctx, id); err != nil {
@@ -227,7 +212,6 @@ func (s *sessionService) DeleteSession(ctx context.Context, id string) error {
return err
}
logger.Infof(ctx, "Session deleted successfully, ID: %s", id)
return nil
}
@@ -235,8 +219,6 @@ func (s *sessionService) DeleteSession(ctx context.Context, id string) error {
func (s *sessionService) GenerateTitle(ctx context.Context,
session *types.Session, messages []types.Message,
) (string, error) {
logger.Info(ctx, "Start generating session title")
if session == nil {
logger.Error(ctx, "Failed to generate title: session cannot be empty")
return "", errors.New("session cannot be empty")
@@ -244,14 +226,12 @@ func (s *sessionService) GenerateTitle(ctx context.Context,
// Skip if title already exists
if session.Title != "" {
logger.Infof(ctx, "Session already has a title, session ID: %s, title: %s", session.ID, session.Title)
return session.Title, nil
}
var err error
// Get the first user message, either from provided messages or repository
var message *types.Message
if len(messages) == 0 {
logger.Info(ctx, "Message list is empty, getting the first user message")
message, err = s.messageRepo.GetFirstMessageOfUser(ctx, session.ID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
@@ -260,7 +240,6 @@ func (s *sessionService) GenerateTitle(ctx context.Context,
return "", err
}
} else {
logger.Info(ctx, "Searching for user message in message list")
for _, m := range messages {
if m.Role == "user" {
message = &m
@@ -278,7 +257,6 @@ func (s *sessionService) GenerateTitle(ctx context.Context,
// Get chat model, use default if SummaryModelID is empty
modelID := session.SummaryModelID
if modelID == "" {
logger.Info(ctx, "Session SummaryModelID is empty, trying to get default chat model")
// Try to get an available KnowledgeQA model
models, err := s.modelService.ListModels(ctx)
if err != nil {
@@ -299,7 +277,6 @@ func (s *sessionService) GenerateTitle(ctx context.Context,
}
}
logger.Infof(ctx, "Getting chat model, model ID: %s", modelID)
chatModel, err := s.modelService.GetChatModel(ctx, modelID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
@@ -309,7 +286,6 @@ func (s *sessionService) GenerateTitle(ctx context.Context,
}
// Prepare messages for title generation
logger.Info(ctx, "Preparing to generate session title")
var chatMessages []chat.Message
chatMessages = append(chatMessages,
chat.Message{Role: "system", Content: s.cfg.Conversation.GenerateSessionTitlePrompt},
@@ -320,7 +296,6 @@ func (s *sessionService) GenerateTitle(ctx context.Context,
// Call model to generate title
thinking := false
logger.Info(ctx, "Calling model to generate title")
response, err := chatModel.Chat(ctx, chatMessages, &chat.ChatOptions{
Temperature: 0.3,
Thinking: &thinking,
@@ -332,17 +307,14 @@ func (s *sessionService) GenerateTitle(ctx context.Context,
// Process and store the generated title
session.Title = strings.TrimPrefix(response.Content, "<think>\n\n</think>")
logger.Infof(ctx, "Title generated successfully: %s", session.Title)
// Update session with new title
logger.Info(ctx, "Updating session title")
err = s.sessionRepo.Update(ctx, session)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
return "", err
}
logger.Infof(ctx, "Session title updated successfully, ID: %s, title: %s", session.ID, session.Title)
return session.Title, nil
}
@@ -363,11 +335,8 @@ func (s *sessionService) GenerateTitleAsync(ctx context.Context, session *types.
bgCtx = context.WithValue(bgCtx, types.RequestIDContextKey, requestID)
}
logger.Info(bgCtx, "Starting async title generation")
// Skip if title already exists
if session.Title != "" {
logger.Infof(bgCtx, "Session already has a title, skipping generation, session ID: %s", session.ID)
return
}
@@ -879,7 +848,7 @@ func (s *sessionService) AgentQA(ctx context.Context, session *types.Session, qu
agentConfig := &types.AgentConfig{
MaxIterations: tenantInfo.AgentConfig.MaxIterations,
ReflectionEnabled: tenantInfo.AgentConfig.ReflectionEnabled,
AllowedTools: tenantInfo.AgentConfig.AllowedTools,
AllowedTools: tools.DefaultAllowedTools(),
Temperature: tenantInfo.AgentConfig.Temperature,
KnowledgeBases: session.AgentConfig.KnowledgeBases, // Use session's knowledge bases
WebSearchEnabled: session.AgentConfig.WebSearchEnabled, // Web search enabled from session config
-9
View File
@@ -81,15 +81,11 @@ func (s *tenantService) CreateTenant(ctx context.Context, tenant *types.Tenant)
// GetTenantByID retrieves a tenant by their ID
func (s *tenantService) GetTenantByID(ctx context.Context, id uint) (*types.Tenant, error) {
logger.Info(ctx, "Start retrieving tenant")
if id == 0 {
logger.Error(ctx, "Tenant ID cannot be 0")
return nil, errors.New("tenant ID cannot be 0")
}
logger.Infof(ctx, "Retrieving tenant, ID: %d", id)
tenant, err := s.repo.GetTenantByID(ctx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
@@ -98,14 +94,11 @@ func (s *tenantService) GetTenantByID(ctx context.Context, id uint) (*types.Tena
return nil, err
}
logger.Infof(ctx, "Tenant retrieved successfully, ID: %d, name: %s", tenant.ID, tenant.Name)
return tenant, nil
}
// ListTenants retrieves a list of all tenants
func (s *tenantService) ListTenants(ctx context.Context) ([]*types.Tenant, error) {
logger.Info(ctx, "Start retrieving tenant list")
tenants, err := s.repo.ListTenants(ctx)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
@@ -192,8 +185,6 @@ func (s *tenantService) UpdateAPIKey(ctx context.Context, id uint) (string, erro
return "", errors.New("tenant ID cannot be 0")
}
logger.Infof(ctx, "Retrieving tenant information, ID: %d", id)
tenant, err := s.repo.GetTenantByID(ctx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
-5
View File
@@ -217,8 +217,6 @@ func (h *AuthHandler) RefreshToken(c *gin.Context) {
func (h *AuthHandler) GetCurrentUser(c *gin.Context) {
ctx := c.Request.Context()
logger.Debugf(ctx, "Get current user info")
// Get current user from service (which extracts from context)
user, err := h.userService.GetCurrentUser(ctx)
if err != nil {
@@ -235,12 +233,9 @@ func (h *AuthHandler) GetCurrentUser(c *gin.Context) {
if err != nil {
logger.Warnf(ctx, "Failed to get tenant info for user %s, tenant ID %d: %v", user.Email, user.TenantID, err)
// Don't fail the request if tenant info is not available
} else {
logger.Debugf(ctx, "Retrieved tenant info for user %s: %s", user.Email, tenant.Name)
}
}
logger.Debugf(ctx, "Retrieved current user info: %s", user.Email)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
+6 -2
View File
@@ -46,7 +46,7 @@ func (h *FAQHandler) ListEntries(c *gin.Context) {
})
}
// UpsertEntries appends or replaces FAQ entries in batch.
// UpsertEntries appends or replaces FAQ entries in batch asynchronously.
func (h *FAQHandler) UpsertEntries(c *gin.Context) {
ctx := c.Request.Context()
var req types.FAQBatchUpsertPayload
@@ -56,7 +56,8 @@ func (h *FAQHandler) UpsertEntries(c *gin.Context) {
return
}
if err := h.knowledgeService.UpsertFAQEntries(ctx, c.Param("id"), &req); err != nil {
taskID, err := h.knowledgeService.UpsertFAQEntries(ctx, c.Param("id"), &req)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
@@ -64,6 +65,9 @@ func (h *FAQHandler) UpsertEntries(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"task_id": taskID,
},
})
}
+11 -3
View File
@@ -101,8 +101,16 @@ func (h *KnowledgeHandler) CreateKnowledgeFromFile(c *gin.Context) {
return
}
logger.Infof(ctx, "File upload successful, filename: %s, size: %.2f KB", file.Filename, float64(file.Size)/1024)
logger.Infof(ctx, "Creating knowledge, knowledge base ID: %s, filename: %s", kbID, file.Filename)
// Get custom filename if provided (for folder uploads with path)
customFileName := c.PostForm("fileName")
displayFileName := file.Filename
if customFileName != "" {
displayFileName = customFileName
logger.Infof(ctx, "Using custom filename: %s (original: %s)", customFileName, file.Filename)
}
logger.Infof(ctx, "File upload successful, filename: %s, size: %.2f KB", displayFileName, float64(file.Size)/1024)
logger.Infof(ctx, "Creating knowledge, knowledge base ID: %s, filename: %s", kbID, displayFileName)
// Parse metadata if provided
var metadata map[string]string
@@ -129,7 +137,7 @@ func (h *KnowledgeHandler) CreateKnowledgeFromFile(c *gin.Context) {
}
// Create knowledge entry from the file
knowledge, err := h.kgService.CreateKnowledgeFromFile(ctx, kbID, file, metadata, enableMultimodel)
knowledge, err := h.kgService.CreateKnowledgeFromFile(ctx, kbID, file, metadata, enableMultimodel, customFileName)
// Check for duplicate knowledge error
if err != nil {
if h.handleDuplicateKnowledgeError(c, err, knowledge, "file") {
+1 -30
View File
@@ -113,8 +113,6 @@ func (h *KnowledgeBaseHandler) validateAndGetKnowledgeBase(c *gin.Context) (*typ
return nil, "", errors.NewBadRequestError("Knowledge base ID cannot be empty")
}
logger.Infof(ctx, "Retrieving knowledge base, ID: %s", id)
// Verify tenant has permission to access this knowledge base
kb, err := h.service.GetKnowledgeBaseByID(ctx, id)
if err != nil {
@@ -138,17 +136,12 @@ func (h *KnowledgeBaseHandler) validateAndGetKnowledgeBase(c *gin.Context) (*typ
// GetKnowledgeBase handles requests to retrieve a knowledge base by ID
func (h *KnowledgeBaseHandler) GetKnowledgeBase(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving knowledge base")
// Validate and get the knowledge base
kb, id, err := h.validateAndGetKnowledgeBase(c)
kb, _, err := h.validateAndGetKnowledgeBase(c)
if err != nil {
c.Error(err)
return
}
logger.Infof(ctx, "Retrieved knowledge base successfully, ID: %s, name: %s", id, kb.Name)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": kb,
@@ -159,18 +152,6 @@ func (h *KnowledgeBaseHandler) GetKnowledgeBase(c *gin.Context) {
func (h *KnowledgeBaseHandler) ListKnowledgeBases(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving knowledge base list")
// Get tenant ID from context
tenantID, exists := c.Get(types.TenantIDContextKey.String())
if !exists {
logger.Error(ctx, "Failed to get tenant ID")
c.Error(errors.NewUnauthorizedError("Unauthorized"))
return
}
logger.Infof(ctx, "Retrieving knowledge base list for tenant, tenant ID: %d", tenantID.(uint))
// Get all knowledge bases for this tenant
kbs, err := h.service.ListKnowledgeBases(ctx)
if err != nil {
@@ -179,11 +160,6 @@ func (h *KnowledgeBaseHandler) ListKnowledgeBases(c *gin.Context) {
return
}
logger.Infof(
ctx,
"Retrieved knowledge base list successfully, tenant ID: %d, total: %d knowledge bases",
tenantID.(uint), len(kbs),
)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": kbs,
@@ -269,8 +245,6 @@ type CopyKnowledgeBaseRequest struct {
func (h *KnowledgeBaseHandler) CopyKnowledgeBase(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start copy knowledge base")
var req CopyKnowledgeBaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
@@ -278,8 +252,6 @@ func (h *KnowledgeBaseHandler) CopyKnowledgeBase(c *gin.Context) {
return
}
logger.Infof(ctx, "Copy knowledge base, ID: %s to ID: %s", req.SourceID, req.TargetID)
go func(ctx context.Context) {
err := h.knowledgeService.CloneKnowledgeBase(ctx, req.SourceID, req.TargetID)
if err != nil {
@@ -289,7 +261,6 @@ func (h *KnowledgeBaseHandler) CopyKnowledgeBase(c *gin.Context) {
logger.Infof(ctx, "Knowledge base copy from ID: %s to ID: %s successfully", req.SourceID, req.TargetID)
}(logger.CloneContext(ctx))
logger.Infof(ctx, "Knowledge base start copy from ID: %s to ID: %s", req.SourceID, req.TargetID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Knowledge base copy successfully",
-13
View File
@@ -226,8 +226,6 @@ func (h *Handler) GetSession(c *gin.Context) {
func (h *Handler) GetSessionsByTenant(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving all sessions for tenant")
// Parse pagination parameters from query
var pagination types.Pagination
if err := c.ShouldBindQuery(&pagination); err != nil {
@@ -236,8 +234,6 @@ func (h *Handler) GetSessionsByTenant(c *gin.Context) {
return
}
logger.Debugf(ctx, "Using pagination parameters: page=%d, page_size=%d", pagination.Page, pagination.PageSize)
// Use paginated query to get sessions
result, err := h.sessionService.GetPagedSessionsByTenant(ctx, &pagination)
if err != nil {
@@ -247,7 +243,6 @@ func (h *Handler) GetSessionsByTenant(c *gin.Context) {
}
// Return sessions with pagination data
logger.Infof(ctx, "Successfully retrieved tenant sessions, total: %d", result.Total)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result.Data,
@@ -261,8 +256,6 @@ func (h *Handler) GetSessionsByTenant(c *gin.Context) {
func (h *Handler) UpdateSession(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start updating session")
// Get session ID from URL parameter
id := c.Param("id")
if id == "" {
@@ -287,8 +280,6 @@ func (h *Handler) UpdateSession(c *gin.Context) {
return
}
// Set session ID and tenant ID
logger.Infof(ctx, "Updating session, ID: %s, tenant ID: %d", id, tenantID.(uint))
session.ID = id
session.TenantID = tenantID.(uint)
@@ -316,8 +307,6 @@ func (h *Handler) UpdateSession(c *gin.Context) {
func (h *Handler) DeleteSession(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start deleting session")
// Get session ID from URL parameter
id := c.Param("id")
if id == "" {
@@ -327,7 +316,6 @@ func (h *Handler) DeleteSession(c *gin.Context) {
}
// Call service to delete session
logger.Infof(ctx, "Deleting session, ID: %s", id)
if err := h.sessionService.DeleteSession(ctx, id); err != nil {
if err == errors.ErrSessionNotFound {
logger.Warnf(ctx, "Session not found, ID: %s", id)
@@ -340,7 +328,6 @@ func (h *Handler) DeleteSession(c *gin.Context) {
}
// Return success message
logger.Infof(ctx, "Session deleted successfully, ID: %s", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Session deleted successfully",
+2 -14
View File
@@ -84,8 +84,6 @@ func (h *TenantHandler) CreateTenant(c *gin.Context) {
func (h *TenantHandler) GetTenant(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving tenant")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
logger.Errorf(ctx, "Invalid tenant ID: %s", c.Param("id"))
@@ -93,8 +91,6 @@ func (h *TenantHandler) GetTenant(c *gin.Context) {
return
}
logger.Infof(ctx, "Retrieving tenant, ID: %d", id)
tenant, err := h.service.GetTenantByID(ctx, uint(id))
if err != nil {
// Check if this is an application-specific error
@@ -108,7 +104,6 @@ func (h *TenantHandler) GetTenant(c *gin.Context) {
return
}
logger.Infof(ctx, "Retrieved tenant successfully, ID: %d, Name: %s", tenant.ID, tenant.Name)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": tenant,
@@ -207,8 +202,6 @@ func (h *TenantHandler) DeleteTenant(c *gin.Context) {
func (h *TenantHandler) ListTenants(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving tenant list")
tenants, err := h.service.ListTenants(ctx)
if err != nil {
// Check if this is an application-specific error
@@ -222,7 +215,6 @@ func (h *TenantHandler) ListTenants(c *gin.Context) {
return
}
logger.Infof(ctx, "Retrieved tenant list successfully, Total: %d tenants", len(tenants))
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
@@ -246,8 +238,6 @@ type AgentConfigRequest struct {
// This is the global agent configuration that applies to all sessions by default
func (h *TenantHandler) GetTenantAgentConfig(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving tenant agent config")
tenant := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
@@ -312,7 +302,7 @@ func (h *TenantHandler) GetTenantAgentConfig(c *gin.Context) {
"data": gin.H{
"max_iterations": tenant.AgentConfig.MaxIterations,
"reflection_enabled": tenant.AgentConfig.ReflectionEnabled,
"allowed_tools": tenant.AgentConfig.AllowedTools,
"allowed_tools": agenttools.DefaultAllowedTools(),
"temperature": tenant.AgentConfig.Temperature,
"system_prompt_web_enabled": systemPromptWithWeb,
"system_prompt_web_disabled": systemPromptWithoutWeb,
@@ -359,7 +349,7 @@ func (h *TenantHandler) updateTenantAgentConfigInternal(c *gin.Context) {
tenant.AgentConfig = &types.AgentConfig{
MaxIterations: req.MaxIterations,
ReflectionEnabled: req.ReflectionEnabled,
AllowedTools: req.AllowedTools,
AllowedTools: agenttools.DefaultAllowedTools(),
Temperature: req.Temperature,
SystemPromptWebEnabled: req.SystemPromptWebEnabled,
SystemPromptWebDisabled: req.SystemPromptWebDisabled,
@@ -558,8 +548,6 @@ func validateConversationConfig(req *types.ConversationConfig) error {
// This is the global conversation configuration that applies to normal mode sessions by default
func (h *TenantHandler) GetTenantConversationConfig(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving tenant conversation config")
tenant := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
+142 -11
View File
@@ -1,7 +1,11 @@
package middleware
import (
"bytes"
"context"
"io"
"regexp"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/logger"
@@ -10,6 +14,88 @@ import (
"github.com/google/uuid"
)
const (
maxBodySize = 1024 * 10 // 最大记录10KB的body内容
)
// loggerResponseBodyWriter 自定义ResponseWriter用于捕获响应内容(用于logger中间件)
type loggerResponseBodyWriter struct {
gin.ResponseWriter
body *bytes.Buffer
}
// Write 重写Write方法,同时写入buffer和原始writer
func (r loggerResponseBodyWriter) Write(b []byte) (int, error) {
r.body.Write(b)
return r.ResponseWriter.Write(b)
}
// sanitizeBody 清理敏感信息
func sanitizeBody(body string) string {
result := body
// 替换常见的敏感字段(JSON格式)
sensitivePatterns := []struct {
pattern string
replacement string
}{
{`"password"\s*:\s*"[^"]*"`, `"password":"***"`},
{`"token"\s*:\s*"[^"]*"`, `"token":"***"`},
{`"access_token"\s*:\s*"[^"]*"`, `"access_token":"***"`},
{`"refresh_token"\s*:\s*"[^"]*"`, `"refresh_token":"***"`},
{`"authorization"\s*:\s*"[^"]*"`, `"authorization":"***"`},
{`"api_key"\s*:\s*"[^"]*"`, `"api_key":"***"`},
{`"secret"\s*:\s*"[^"]*"`, `"secret":"***"`},
{`"apikey"\s*:\s*"[^"]*"`, `"apikey":"***"`},
{`"apisecret"\s*:\s*"[^"]*"`, `"apisecret":"***"`},
}
for _, p := range sensitivePatterns {
re := regexp.MustCompile(p.pattern)
result = re.ReplaceAllString(result, p.replacement)
}
return result
}
// readRequestBody 读取请求体(限制大小用于日志,但完整读取用于重置)
func readRequestBody(c *gin.Context) string {
if c.Request.Body == nil {
return ""
}
// 检查Content-Type,只记录JSON类型
contentType := c.GetHeader("Content-Type")
if !strings.Contains(contentType, "application/json") &&
!strings.Contains(contentType, "application/x-www-form-urlencoded") &&
!strings.Contains(contentType, "text/") {
return "[非文本类型,已跳过]"
}
// 完整读取body内容(不限制大小),因为需要完整重置给后续handler使用
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return "[读取请求体失败]"
}
// 重置request body,使用完整内容,确保后续handler能读取到完整数据
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// 用于日志的body(限制大小)
var logBodyBytes []byte
if len(bodyBytes) > maxBodySize {
logBodyBytes = bodyBytes[:maxBodySize]
} else {
logBodyBytes = bodyBytes
}
bodyStr := string(logBodyBytes)
if len(bodyBytes) > maxBodySize {
bodyStr += "... [内容过长,已截断]"
}
return sanitizeBody(bodyStr)
}
// RequestID middleware adds a unique request ID to the context
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -41,13 +127,27 @@ func RequestID() gin.HandlerFunc {
}
}
// Logger middleware logs request details with request ID
// Logger middleware logs request details with request ID, input and output
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
// 读取请求体(在Next之前读取,因为Next会消费body)
var requestBody string
if c.Request.Method == "POST" || c.Request.Method == "PUT" || c.Request.Method == "PATCH" {
requestBody = readRequestBody(c)
}
// 创建响应体捕获器
responseBody := &bytes.Buffer{}
responseWriter := &loggerResponseBodyWriter{
ResponseWriter: c.Writer,
body: responseBody,
}
c.Writer = responseWriter
// Process request
c.Next()
@@ -69,15 +169,46 @@ func Logger() gin.HandlerFunc {
path = path + "?" + raw
}
// Log with request ID
logger.GetLogger(c).Infof("[%s] %d | %3d | %13v | %15s | %s %s",
requestID,
statusCode,
c.Writer.Size(),
latency,
clientIP,
method,
path,
)
// 读取响应体
responseBodyStr := ""
if responseBody.Len() > 0 {
// 检查Content-Type,只记录JSON类型
contentType := c.Writer.Header().Get("Content-Type")
if strings.Contains(contentType, "application/json") ||
strings.Contains(contentType, "text/") {
bodyBytes := responseBody.Bytes()
if len(bodyBytes) > maxBodySize {
responseBodyStr = string(bodyBytes[:maxBodySize]) + "... [内容过长,已截断]"
} else {
responseBodyStr = string(bodyBytes)
}
responseBodyStr = sanitizeBody(responseBodyStr)
} else {
responseBodyStr = "[非文本类型,已跳过]"
}
}
// 构建日志消息
logMsg := logger.GetLogger(c)
logMsg = logMsg.WithFields(map[string]interface{}{
"request_id": requestID,
"method": method,
"path": path,
"status_code": statusCode,
"size": c.Writer.Size(),
"latency": latency.String(),
"client_ip": clientIP,
})
// 添加请求体(如果有)
if requestBody != "" {
logMsg = logMsg.WithField("request_body", requestBody)
}
// 添加响应体(如果有)
if responseBodyStr != "" {
logMsg = logMsg.WithField("response_body", responseBodyStr)
}
logMsg.Info()
}
}
+5 -5
View File
@@ -200,11 +200,11 @@ func (c *RemoteAPIChat) buildChatCompletionRequest(messages []Message,
}
// print req
jsonData, err := json.Marshal(req)
if err != nil {
logger.Error(context.Background(), "marshal request: %w", err)
}
logger.Infof(context.Background(), "llm request: %s", string(jsonData))
// jsonData, err := json.Marshal(req)
// if err != nil {
// logger.Error(context.Background(), "marshal request: %w", err)
// }
// logger.Infof(context.Background(), "llm request: %s", string(jsonData))
return req
}
+10 -1
View File
@@ -2,6 +2,8 @@ package embedding
import (
"context"
"os"
"strconv"
"sync"
"github.com/Tencent/WeKnora/internal/models/utils"
@@ -26,7 +28,14 @@ func (e *batchEmbedder) BatchEmbedWithPool(ctx context.Context, model Embedder,
var wg sync.WaitGroup
var mu sync.Mutex // For synchronizing access to error
var firstErr error // Record the first error that occurs
batchSize := 5
batchSizeStr := os.Getenv("BATCH_EMBED_SIZE")
if batchSizeStr == "" {
batchSizeStr = "5"
}
batchSize, err := strconv.Atoi(batchSizeStr)
if err != nil {
return nil, err
}
textEmbeddings := utils.MapSlice(texts, func(text string) *textEmbedding {
return &textEmbedding{text: text}
})
+9 -2
View File
@@ -14,8 +14,9 @@ import (
type AsynqTaskParams struct {
dig.In
Server *asynq.Server
Extracter interfaces.Extracter
Server *asynq.Server
Extracter interfaces.Extracter
KnowledgeService interfaces.KnowledgeService
}
func getAsynqRedisClientOpt() *asynq.RedisClientOpt {
@@ -60,6 +61,12 @@ func RunAsynqServer(params AsynqTaskParams) *asynq.ServeMux {
mux.HandleFunc(types.TypeChunkExtract, params.Extracter.Extract)
// Register document processing handler
mux.HandleFunc(types.TypeDocumentProcess, params.KnowledgeService.ProcessDocument)
// Register FAQ import handler
mux.HandleFunc(types.TypeFAQImport, params.KnowledgeService.ProcessFAQImport)
go func() {
// Start the server
if err := params.Server.Run(mux); err != nil {
+1 -1
View File
@@ -111,7 +111,7 @@ func (r *RedisStreamManager) GetEvents(ctx context.Context, sessionID, messageID
continue
}
events = append(events, event)
}
}
// Calculate next offset
nextOffset := fromOffset + len(results)
+29 -1
View File
@@ -1,7 +1,9 @@
package types
const (
TypeChunkExtract = "chunk:extract"
TypeChunkExtract = "chunk:extract"
TypeDocumentProcess = "document:process" // 文档处理任务
TypeFAQImport = "faq:import" // FAQ导入任务
)
type ExtractChunkPayload struct {
@@ -10,6 +12,32 @@ type ExtractChunkPayload struct {
ModelID string `json:"model_id"`
}
// DocumentProcessPayload 文档处理任务payload
type DocumentProcessPayload struct {
RequestId string `json:"request_id"`
TenantID uint `json:"tenant_id"`
KnowledgeID string `json:"knowledge_id"`
KnowledgeBaseID string `json:"knowledge_base_id"`
FilePath string `json:"file_path,omitempty"` // 文件路径(文件导入时使用)
FileName string `json:"file_name,omitempty"` // 文件名(文件导入时使用)
FileType string `json:"file_type,omitempty"` // 文件类型(文件导入时使用)
URL string `json:"url,omitempty"` // URL(URL导入时使用)
Passages []string `json:"passages,omitempty"` // 文本段落(文本导入时使用)
EnableMultimodel bool `json:"enable_multimodel"`
}
// FAQImportPayload FAQ导入任务payload
type FAQImportPayload struct {
TenantID uint `json:"tenant_id"`
TaskID string `json:"task_id"`
KBID string `json:"kb_id"`
KnowledgeID string `json:"knowledge_id"`
Entries []FAQEntryPayload `json:"entries"`
Mode string `json:"mode"`
StartChunkIndex int `json:"start_chunk_index"`
EndChunkIndex int `json:"end_chunk_index"`
}
type PromptTemplateStructured struct {
Description string `json:"description"`
Tags []string `json:"tags"`
+42
View File
@@ -110,6 +110,48 @@ type FAQSearchRequest struct {
MatchCount int `json:"match_count"`
}
// FAQImportTaskStatus 导入任务状态
type FAQImportTaskStatus string
const (
FAQImportStatusPending FAQImportTaskStatus = "pending"
FAQImportStatusProcessing FAQImportTaskStatus = "processing"
FAQImportStatusCompleted FAQImportTaskStatus = "completed"
FAQImportStatusFailed FAQImportTaskStatus = "failed"
)
// FAQImportMetadata 存储在Knowledge.Metadata中的FAQ导入任务信息
type FAQImportMetadata struct {
ImportProgress int `json:"import_progress"` // 0-100
ImportTotal int `json:"import_total"`
ImportProcessed int `json:"import_processed"`
NextChunkIndex int `json:"next_chunk_index,omitempty"` // 下一个要分配的ChunkIndex(自增计数器)
}
// ToJSON converts the metadata to JSON type.
func (m *FAQImportMetadata) ToJSON() (JSON, error) {
if m == nil {
return nil, nil
}
bytes, err := json.Marshal(m)
if err != nil {
return nil, err
}
return JSON(bytes), nil
}
// ParseFAQImportMetadata parses FAQ import metadata from Knowledge.
func ParseFAQImportMetadata(k *Knowledge) (*FAQImportMetadata, error) {
if k == nil || len(k.Metadata) == 0 {
return nil, nil
}
var metadata FAQImportMetadata
if err := json.Unmarshal(k.Metadata, &metadata); err != nil {
return nil, err
}
return &metadata, nil
}
func normalizeStrings(values []string) []string {
if len(values) == 0 {
return nil
+5
View File
@@ -33,12 +33,15 @@ type ChunkRepository interface {
UpdateChunks(ctx context.Context, chunks []*types.Chunk) error
// DeleteChunk deletes a chunk
DeleteChunk(ctx context.Context, tenantID uint, id string) error
// DeleteChunks deletes chunks by IDs in batch
DeleteChunks(ctx context.Context, tenantID uint, ids []string) error
// DeleteChunksByKnowledgeID deletes chunks by knowledge id
DeleteChunksByKnowledgeID(ctx context.Context, tenantID uint, knowledgeID string) error
// DeleteByKnowledgeList deletes all chunks for a knowledge list
DeleteByKnowledgeList(ctx context.Context, tenantID uint, knowledgeIDs []string) error
// CountChunksByKnowledgeBaseID counts the number of chunks in a knowledge base.
CountChunksByKnowledgeBaseID(ctx context.Context, tenantID uint, kbID string) (int64, error)
DeleteChunksByChunkIndexRange(ctx context.Context, tenantID uint, knowledgeID string, startChunkIndex int, endChunkIndex int) ([]*types.Chunk, error)
}
// ChunkService defines the interface for chunk service operations
@@ -60,6 +63,8 @@ type ChunkService interface {
UpdateChunk(ctx context.Context, chunk *types.Chunk) error
// DeleteChunk deletes a chunk
DeleteChunk(ctx context.Context, id string) error
// DeleteChunks deletes chunks by IDs in batch
DeleteChunks(ctx context.Context, ids []string) error
// DeleteChunksByKnowledgeID deletes chunks by knowledge id
DeleteChunksByKnowledgeID(ctx context.Context, knowledgeID string) error
// DeleteByKnowledgeList deletes all chunks for a knowledge list
+11 -2
View File
@@ -6,6 +6,7 @@ import (
"mime/multipart"
"github.com/Tencent/WeKnora/internal/types"
"github.com/hibiken/asynq"
)
// KnowledgeService defines the interface for knowledge services.
@@ -17,6 +18,7 @@ type KnowledgeService interface {
file *multipart.FileHeader,
metadata map[string]string,
enableMultimodel *bool,
customFileName string,
) (*types.Knowledge, error)
// CreateKnowledgeFromURL creates knowledge from a URL.
CreateKnowledgeFromURL(ctx context.Context, kbID string, url string, enableMultimodel *bool) (*types.Knowledge, error)
@@ -55,8 +57,9 @@ type KnowledgeService interface {
// ListFAQEntries lists FAQ entries under a FAQ knowledge base.
// When tagID is non-empty, results are filtered by tag_id on FAQ chunks.
ListFAQEntries(ctx context.Context, kbID string, page *types.Pagination, tagID string) (*types.PageResult, error)
// UpsertFAQEntries imports or appends FAQ entries.
UpsertFAQEntries(ctx context.Context, kbID string, payload *types.FAQBatchUpsertPayload) error
// UpsertFAQEntries imports or appends FAQ entries asynchronously.
// Returns task ID (Knowledge ID) for tracking import progress.
UpsertFAQEntries(ctx context.Context, kbID string, payload *types.FAQBatchUpsertPayload) (string, error)
// UpdateFAQEntry updates a single FAQ entry.
UpdateFAQEntry(ctx context.Context, kbID string, entryID string, payload *types.FAQEntryPayload) error
// UpdateFAQEntryStatusBatch updates enable status for FAQ entries in batch.
@@ -71,6 +74,10 @@ type KnowledgeService interface {
UpdateFAQEntryTagBatch(ctx context.Context, kbID string, updates map[string]*string) error
// GetRepository gets the knowledge repository
GetRepository() KnowledgeRepository
// ProcessDocument handles Asynq document processing tasks
ProcessDocument(ctx context.Context, t *asynq.Task) error
// ProcessFAQImport handles Asynq FAQ import tasks
ProcessFAQImport(ctx context.Context, t *asynq.Task) error
}
// KnowledgeRepository defines the interface for knowledge repositories.
@@ -104,4 +111,6 @@ type KnowledgeRepository interface {
UpdateKnowledgeColumn(ctx context.Context, id string, column string, value interface{}) error
// CountKnowledgeByKnowledgeBaseID counts the number of knowledge items in a knowledge base.
CountKnowledgeByKnowledgeBaseID(ctx context.Context, tenantID uint, kbID string) (int64, error)
// CountKnowledgeByStatus counts the number of knowledge items with the specified parse status.
CountKnowledgeByStatus(ctx context.Context, tenantID uint, kbID string, parseStatuses []string) (int64, error)
}
+4
View File
@@ -73,6 +73,10 @@ type KnowledgeBase struct {
KnowledgeCount int64 `yaml:"knowledge_count" json:"knowledge_count" gorm:"-"`
// Chunk count (not stored in database, calculated on query)
ChunkCount int64 `yaml:"chunk_count" json:"chunk_count" gorm:"-"`
// IsProcessing indicates if there is a processing import task (for FAQ type knowledge bases)
IsProcessing bool `yaml:"is_processing" json:"is_processing" gorm:"-"`
// ProcessingCount indicates the number of knowledge items being processed (for document type knowledge bases)
ProcessingCount int64 `yaml:"processing_count" json:"processing_count" gorm:"-"`
}
// KnowledgeBaseConfig represents the knowledge base configuration
@@ -0,0 +1,13 @@
-- 000012_add_embeddings_kb_id_index.down.sql
-- Remove B-tree index on knowledge_base_id from embeddings table
BEGIN;
DROP INDEX IF EXISTS idx_embeddings_knowledge_base_id;
COMMIT;
@@ -0,0 +1,15 @@
-- 000012_add_embeddings_kb_id_index.up.sql
-- Add B-tree index on knowledge_base_id for embeddings table to improve query performance
BEGIN;
-- Create index for knowledge_base_id to optimize filtering queries
CREATE INDEX IF NOT EXISTS idx_embeddings_knowledge_base_id
ON embeddings(knowledge_base_id);
COMMIT;