Merge remote-tracking branch 'wizard/master'
This commit is contained in:
Generated
+17
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user