refactor: Implement upload progress tracking in KnowledgeBaseList component and enhance error handling for file uploads
This commit is contained in:
@@ -678,6 +678,35 @@ const handleDocUploadClick = async () => {
|
||||
docUploadInput.value?.click()
|
||||
}
|
||||
|
||||
const FAILED_FILES_PREVIEW_LIMIT = 10
|
||||
|
||||
const summarizeFailedFiles = (failedFiles: Array<{ name: string; reason: string }>) => {
|
||||
const duplicateLabel = t('knowledgeBase.fileExists')
|
||||
let duplicateCount = 0
|
||||
const nonDuplicate: Array<{ name: string; reason: string }> = []
|
||||
failedFiles.forEach((file) => {
|
||||
if (file.reason === duplicateLabel) {
|
||||
duplicateCount++
|
||||
} else {
|
||||
nonDuplicate.push(file)
|
||||
}
|
||||
})
|
||||
|
||||
const previewList = nonDuplicate.slice(0, FAILED_FILES_PREVIEW_LIMIT).map(f => `• ${f.name}: ${f.reason}`)
|
||||
let nonDuplicateText = ''
|
||||
if (previewList.length) {
|
||||
nonDuplicateText = previewList.join('\n')
|
||||
if (nonDuplicate.length > FAILED_FILES_PREVIEW_LIMIT) {
|
||||
nonDuplicateText += `\n${t('knowledgeBase.andMoreFiles', { count: nonDuplicate.length - FAILED_FILES_PREVIEW_LIMIT })}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
duplicateCount,
|
||||
nonDuplicateText,
|
||||
}
|
||||
}
|
||||
|
||||
const handleDocFileChange = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input?.files
|
||||
@@ -736,8 +765,7 @@ const handleDocFileChange = async (event: Event) => {
|
||||
detail: {
|
||||
kbId,
|
||||
uploadId,
|
||||
fileName: file.name,
|
||||
file
|
||||
fileName: file.name
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -809,24 +837,35 @@ const handleDocFileChange = async (event: Event) => {
|
||||
if (failCount === 0) {
|
||||
MessagePlugin.success(t('knowledgeBase.uploadAllSuccess', { count: successCount }))
|
||||
} else if (successCount > 0) {
|
||||
// 部分成功,显示详细失败信息
|
||||
const failedList = failedFiles.map(f => `• ${f.name}: ${f.reason}`).join('\n')
|
||||
const { duplicateCount, nonDuplicateText } = summarizeFailedFiles(failedFiles)
|
||||
const extraSections: string[] = []
|
||||
if (duplicateCount > 0) {
|
||||
extraSections.push(t('knowledgeBase.duplicateFilesSkipped', { count: duplicateCount }))
|
||||
}
|
||||
if (nonDuplicateText) {
|
||||
extraSections.push(t('knowledgeBase.failedFilesList') + '\n' + nonDuplicateText)
|
||||
}
|
||||
const extraContent = extraSections.length ? '\n\n' + extraSections.join('\n\n') : ''
|
||||
MessagePlugin.warning({
|
||||
content: t('knowledgeBase.uploadPartialSuccess', {
|
||||
success: successCount,
|
||||
fail: failCount
|
||||
}) + '\n\n' + t('knowledgeBase.failedFilesList') + '\n' + failedList,
|
||||
}) + extraContent,
|
||||
duration: 8000,
|
||||
closeBtn: true
|
||||
})
|
||||
} else {
|
||||
// 全部失败,显示详细失败信息
|
||||
const failedList = failedFiles.slice(0, 5).map(f => `• ${f.name}: ${f.reason}`).join('\n')
|
||||
const moreCount = failedFiles.length > 5 ? failedFiles.length - 5 : 0
|
||||
const { duplicateCount, nonDuplicateText } = summarizeFailedFiles(failedFiles)
|
||||
const extraSections: string[] = []
|
||||
if (duplicateCount > 0) {
|
||||
extraSections.push(t('knowledgeBase.duplicateFilesSkipped', { count: duplicateCount }))
|
||||
}
|
||||
if (nonDuplicateText) {
|
||||
extraSections.push(t('knowledgeBase.failedFilesList') + '\n' + nonDuplicateText)
|
||||
}
|
||||
const extraContent = extraSections.length ? '\n\n' + extraSections.join('\n\n') : ''
|
||||
MessagePlugin.error({
|
||||
content: t('knowledgeBase.uploadAllFailed') + '\n\n' +
|
||||
t('knowledgeBase.failedFilesList') + '\n' + failedList +
|
||||
(moreCount > 0 ? `\n${t('knowledgeBase.andMoreFiles', { count: moreCount })}` : ''),
|
||||
content: t('knowledgeBase.uploadAllFailed') + extraContent,
|
||||
duration: 8000,
|
||||
closeBtn: true
|
||||
})
|
||||
@@ -950,32 +989,69 @@ const handleDocFolderChange = async (event: Event) => {
|
||||
const failedFiles: Array<{ name: string; reason: string }> = []
|
||||
|
||||
for (const file of validFiles) {
|
||||
try {
|
||||
// 获取文件的相对路径(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}`
|
||||
}
|
||||
// 获取文件的相对路径(webkitRelativePath),用于保留子目录结构
|
||||
const relativePath = (file as any).webkitRelativePath
|
||||
let fileName = file.name
|
||||
if (relativePath) {
|
||||
const pathParts = relativePath.split('/')
|
||||
if (pathParts.length > 2) {
|
||||
const subPath = pathParts.slice(1, -1).join('/')
|
||||
fileName = `${subPath}/${file.name}`
|
||||
}
|
||||
|
||||
await uploadKnowledgeFile(kbId, { file, fileName })
|
||||
}
|
||||
|
||||
const uploadId = `${file.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
let progress = 0
|
||||
let status: 'uploading' | 'success' | 'error' = 'uploading'
|
||||
let errorReason: string | undefined
|
||||
|
||||
window.dispatchEvent(new CustomEvent('knowledgeFileUploadStart', {
|
||||
detail: {
|
||||
kbId,
|
||||
uploadId,
|
||||
fileName
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
await uploadKnowledgeFile(
|
||||
kbId,
|
||||
{ file, fileName },
|
||||
(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')
|
||||
errorReason = error?.error?.message || error?.message || t('knowledgeBase.uploadFailed')
|
||||
if (error?.code === 'duplicate_file' || error?.error?.code === 'duplicate_file') {
|
||||
errorReason = t('knowledgeBase.fileExists')
|
||||
}
|
||||
failedFiles.push({ name: file.name, reason: errorReason })
|
||||
failedFiles.push({ name: fileName, reason: errorReason })
|
||||
status = 'error'
|
||||
} finally {
|
||||
window.dispatchEvent(new CustomEvent('knowledgeFileUploadComplete', {
|
||||
detail: {
|
||||
kbId,
|
||||
uploadId,
|
||||
status,
|
||||
progress,
|
||||
error: errorReason,
|
||||
fileName
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -988,24 +1064,35 @@ const handleDocFolderChange = async (event: Event) => {
|
||||
if (failCount === 0) {
|
||||
MessagePlugin.success(t('knowledgeBase.uploadAllSuccess', { count: successCount }))
|
||||
} else if (successCount > 0) {
|
||||
// 部分成功,显示详细失败信息
|
||||
const failedList = failedFiles.map(f => `• ${f.name}: ${f.reason}`).join('\n')
|
||||
const { duplicateCount, nonDuplicateText } = summarizeFailedFiles(failedFiles)
|
||||
const extraSections: string[] = []
|
||||
if (duplicateCount > 0) {
|
||||
extraSections.push(t('knowledgeBase.duplicateFilesSkipped', { count: duplicateCount }))
|
||||
}
|
||||
if (nonDuplicateText) {
|
||||
extraSections.push(t('knowledgeBase.failedFilesList') + '\n' + nonDuplicateText)
|
||||
}
|
||||
const extraContent = extraSections.length ? '\n\n' + extraSections.join('\n\n') : ''
|
||||
MessagePlugin.warning({
|
||||
content: t('knowledgeBase.uploadPartialSuccess', {
|
||||
success: successCount,
|
||||
fail: failCount
|
||||
}) + '\n\n' + t('knowledgeBase.failedFilesList') + '\n' + failedList,
|
||||
}) + extraContent,
|
||||
duration: 8000,
|
||||
closeBtn: true
|
||||
})
|
||||
} else {
|
||||
// 全部失败,显示详细失败信息
|
||||
const failedList = failedFiles.slice(0, 5).map(f => `• ${f.name}: ${f.reason}`).join('\n')
|
||||
const moreCount = failedFiles.length > 5 ? failedFiles.length - 5 : 0
|
||||
const { duplicateCount, nonDuplicateText } = summarizeFailedFiles(failedFiles)
|
||||
const extraSections: string[] = []
|
||||
if (duplicateCount > 0) {
|
||||
extraSections.push(t('knowledgeBase.duplicateFilesSkipped', { count: duplicateCount }))
|
||||
}
|
||||
if (nonDuplicateText) {
|
||||
extraSections.push(t('knowledgeBase.failedFilesList') + '\n' + nonDuplicateText)
|
||||
}
|
||||
const extraContent = extraSections.length ? '\n\n' + extraSections.join('\n\n') : ''
|
||||
MessagePlugin.error({
|
||||
content: t('knowledgeBase.uploadAllFailed') + '\n\n' +
|
||||
t('knowledgeBase.failedFilesList') + '\n' + failedList +
|
||||
(moreCount > 0 ? `\n${t('knowledgeBase.andMoreFiles', { count: moreCount })}` : ''),
|
||||
content: t('knowledgeBase.uploadAllFailed') + extraContent,
|
||||
duration: 8000,
|
||||
closeBtn: true
|
||||
})
|
||||
|
||||
@@ -65,6 +65,7 @@ export default {
|
||||
unsupportedFileType: 'Unsupported file type',
|
||||
failedFilesList: 'Failed files:',
|
||||
andMoreFiles: '...and {count} more files',
|
||||
duplicateFilesSkipped: '{count} duplicate files skipped',
|
||||
uploadFile: 'Upload File',
|
||||
uploadFileDesc: 'Supports PDF, Word, TXT, etc.',
|
||||
importURL: 'Import from URL',
|
||||
@@ -812,6 +813,16 @@ export default {
|
||||
documents: 'Document Count',
|
||||
faqEntries: 'FAQ Entries',
|
||||
chunks: 'Chunk Count'
|
||||
},
|
||||
uploadProgress: {
|
||||
uploadingTitle: 'Uploading folder documents to "{name}"',
|
||||
detail: '{completed}/{total} files finished',
|
||||
keepPageOpen: 'Please keep this page open while files upload.',
|
||||
completedTitle: 'Upload finished for "{name}"',
|
||||
completedDetail: 'All {total} files uploaded. Refreshing list to show parsing status...',
|
||||
refreshing: 'Refreshing list to show parsing status...',
|
||||
errorTip: 'Some files failed to upload. Please check the notifications.',
|
||||
unknownKb: 'Knowledge Base {id}'
|
||||
}
|
||||
},
|
||||
knowledgeEditor: {
|
||||
|
||||
@@ -65,6 +65,7 @@ export default {
|
||||
unsupportedFileType: 'Неподдерживаемый тип файла',
|
||||
failedFilesList: 'Неудавшиеся файлы:',
|
||||
andMoreFiles: '...и ещё {count} файлов',
|
||||
duplicateFilesSkipped: 'Пропущено {count} повторяющихся файлов',
|
||||
uploadFile: 'Загрузить файл',
|
||||
uploadFileDesc: 'Поддерживает PDF, Word, TXT и т.д.',
|
||||
importURL: 'Импорт из URL',
|
||||
@@ -900,6 +901,16 @@ export default {
|
||||
documents: 'Количество документов',
|
||||
faqEntries: 'FAQ записи',
|
||||
chunks: 'Количество фрагментов'
|
||||
},
|
||||
uploadProgress: {
|
||||
uploadingTitle: 'Загрузка документов папки в «{name}»',
|
||||
detail: 'Готово {completed} из {total} файлов',
|
||||
keepPageOpen: 'Пожалуйста, не закрывайте страницу, пока идет загрузка.',
|
||||
completedTitle: 'Загрузка для «{name}» завершена',
|
||||
completedDetail: 'Загружено {total} файлов. Обновляем список, чтобы показать статус разбора...',
|
||||
refreshing: 'Обновляем список и статусы разбора...',
|
||||
errorTip: 'Часть файлов загрузить не удалось. Проверьте уведомления.',
|
||||
unknownKb: 'База знаний {id}'
|
||||
}
|
||||
},
|
||||
knowledgeEditor: {
|
||||
|
||||
@@ -64,6 +64,7 @@ export default {
|
||||
unsupportedFileType: "不支持的文件格式",
|
||||
failedFilesList: "失败文件列表:",
|
||||
andMoreFiles: "...及其他 {count} 个文件",
|
||||
duplicateFilesSkipped: "已忽略 {count} 个重复文件",
|
||||
uploadFile: "上传文件",
|
||||
uploadFileDesc: "支持 PDF、Word、TXT 等",
|
||||
importURL: "导入网页",
|
||||
@@ -1136,6 +1137,16 @@ export default {
|
||||
faqEntries: "问答条目",
|
||||
chunks: "分块数量",
|
||||
},
|
||||
uploadProgress: {
|
||||
uploadingTitle: "正在向「{name}」上传文件夹中的文档",
|
||||
detail: "已完成 {completed}/{total} 个文件",
|
||||
keepPageOpen: "请保持页面打开,上传完成后会自动刷新解析状态。",
|
||||
completedTitle: "「{name}」的上传已完成",
|
||||
completedDetail: "共上传 {total} 个文件,正在刷新列表查看解析状态...",
|
||||
refreshing: "正在刷新列表并获取最新解析状态...",
|
||||
errorTip: "部分文件上传失败,请查看右上角通知详情。",
|
||||
unknownKb: "知识库 {id}",
|
||||
},
|
||||
},
|
||||
knowledgeEditor: {
|
||||
titleCreate: "新建知识库",
|
||||
|
||||
@@ -15,6 +15,48 @@
|
||||
<span>{{ $t('knowledgeList.uninitializedBanner') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 上传进度提示 -->
|
||||
<div v-if="uploadSummaries.length" class="upload-progress-panel">
|
||||
<div
|
||||
v-for="summary in uploadSummaries"
|
||||
:key="summary.kbId"
|
||||
class="upload-progress-item"
|
||||
>
|
||||
<div class="upload-progress-icon">
|
||||
<t-icon :name="summary.completed === summary.total ? 'check-circle-filled' : 'upload'" size="20px" />
|
||||
</div>
|
||||
<div class="upload-progress-content">
|
||||
<div class="progress-title">
|
||||
{{
|
||||
summary.completed === summary.total
|
||||
? $t('knowledgeList.uploadProgress.completedTitle', { name: summary.kbName })
|
||||
: $t('knowledgeList.uploadProgress.uploadingTitle', { name: summary.kbName })
|
||||
}}
|
||||
</div>
|
||||
<div class="progress-subtitle">
|
||||
{{
|
||||
summary.completed === summary.total
|
||||
? $t('knowledgeList.uploadProgress.completedDetail', { total: summary.total })
|
||||
: $t('knowledgeList.uploadProgress.detail', { completed: summary.completed, total: summary.total })
|
||||
}}
|
||||
</div>
|
||||
<div class="progress-subtitle secondary">
|
||||
{{
|
||||
summary.completed === summary.total
|
||||
? $t('knowledgeList.uploadProgress.refreshing')
|
||||
: $t('knowledgeList.uploadProgress.keepPageOpen')
|
||||
}}
|
||||
</div>
|
||||
<div v-if="summary.hasError" class="progress-subtitle error">
|
||||
{{ $t('knowledgeList.uploadProgress.errorTip') }}
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-inner" :style="{ width: summary.progress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片网格 -->
|
||||
<div v-if="kbs.length > 0" class="kb-card-wrap">
|
||||
<div
|
||||
@@ -155,7 +197,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed, watch, nextTick } from 'vue'
|
||||
import { onMounted, onUnmounted, 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'
|
||||
@@ -195,6 +237,28 @@ const deletingKb = ref<KB | null>(null)
|
||||
const currentMoreIndex = ref<number>(-1)
|
||||
const highlightedKbId = ref<string | null>(null)
|
||||
const highlightedCardRef = ref<HTMLElement | null>(null)
|
||||
const uploadTasks = ref<UploadTaskState[]>([])
|
||||
const uploadCleanupTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
let uploadRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const UPLOAD_CLEANUP_DELAY = 10000
|
||||
|
||||
interface UploadTaskState {
|
||||
uploadId: string
|
||||
kbId: string
|
||||
fileName?: string
|
||||
progress: number
|
||||
status: 'uploading' | 'success' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface UploadSummary {
|
||||
kbId: string
|
||||
kbName: string
|
||||
total: number
|
||||
completed: number
|
||||
progress: number
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
const fetchList = () => {
|
||||
loading.value = true
|
||||
@@ -222,6 +286,25 @@ onMounted(() => {
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('knowledgeFileUploadStart', handleUploadStartEvent as EventListener)
|
||||
window.addEventListener('knowledgeFileUploadProgress', handleUploadProgressEvent as EventListener)
|
||||
window.addEventListener('knowledgeFileUploadComplete', handleUploadCompleteEvent as EventListener)
|
||||
window.addEventListener('knowledgeFileUploaded', handleUploadFinishedEvent as EventListener)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('knowledgeFileUploadStart', handleUploadStartEvent as EventListener)
|
||||
window.removeEventListener('knowledgeFileUploadProgress', handleUploadProgressEvent as EventListener)
|
||||
window.removeEventListener('knowledgeFileUploadComplete', handleUploadCompleteEvent as EventListener)
|
||||
window.removeEventListener('knowledgeFileUploaded', handleUploadFinishedEvent as EventListener)
|
||||
|
||||
uploadCleanupTimers.forEach(timer => clearTimeout(timer))
|
||||
uploadCleanupTimers.clear()
|
||||
if (uploadRefreshTimer) {
|
||||
clearTimeout(uploadRefreshTimer)
|
||||
uploadRefreshTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// 监听路由变化,处理从其他页面跳转过来的高亮需求
|
||||
@@ -285,6 +368,101 @@ const hasUninitializedKbs = computed(() => {
|
||||
return kbs.value.some(kb => !isInitialized(kb))
|
||||
})
|
||||
|
||||
const getKbDisplayName = (kbId: string) => {
|
||||
const target = kbs.value.find(kb => kb.id === kbId)
|
||||
if (target?.name) return target.name
|
||||
return t('knowledgeList.uploadProgress.unknownKb', { id: kbId }) as string
|
||||
}
|
||||
|
||||
const uploadSummaries = computed<UploadSummary[]>(() => {
|
||||
if (!uploadTasks.value.length) return []
|
||||
const grouped: Record<string, UploadTaskState[]> = {}
|
||||
uploadTasks.value.forEach(task => {
|
||||
const kbKey = String(task.kbId)
|
||||
if (!grouped[kbKey]) grouped[kbKey] = []
|
||||
grouped[kbKey].push(task)
|
||||
})
|
||||
return Object.entries(grouped).map(([kbId, tasks]) => {
|
||||
const total = tasks.length
|
||||
const completed = tasks.filter(task => task.status !== 'uploading').length
|
||||
const progressSum = tasks.reduce((sum, task) => sum + (task.progress ?? 0), 0)
|
||||
const avgProgress = total === 0 ? 0 : Math.min(100, Math.max(0, Math.round(progressSum / total)))
|
||||
const hasError = tasks.some(task => task.status === 'error')
|
||||
return {
|
||||
kbId,
|
||||
kbName: getKbDisplayName(kbId),
|
||||
total,
|
||||
completed,
|
||||
progress: avgProgress,
|
||||
hasError
|
||||
}
|
||||
}).sort((a, b) => a.kbName.localeCompare(b.kbName))
|
||||
})
|
||||
|
||||
const clampProgress = (value: number) => Math.min(100, Math.max(0, Math.round(value)))
|
||||
|
||||
const addUploadTask = (task: UploadTaskState) => {
|
||||
uploadTasks.value = [
|
||||
...uploadTasks.value.filter(item => item.uploadId !== task.uploadId),
|
||||
task,
|
||||
]
|
||||
}
|
||||
|
||||
const patchUploadTask = (uploadId: string, patch: Partial<UploadTaskState>) => {
|
||||
const index = uploadTasks.value.findIndex(task => task.uploadId === uploadId)
|
||||
if (index === -1) return
|
||||
const nextTasks = [...uploadTasks.value]
|
||||
nextTasks[index] = { ...nextTasks[index], ...patch }
|
||||
uploadTasks.value = nextTasks
|
||||
}
|
||||
|
||||
const removeUploadTask = (uploadId: string) => {
|
||||
uploadTasks.value = uploadTasks.value.filter(task => task.uploadId !== uploadId)
|
||||
const timer = uploadCleanupTimers.get(uploadId)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
uploadCleanupTimers.delete(uploadId)
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleUploadTaskCleanup = (uploadId: string) => {
|
||||
const existing = uploadCleanupTimers.get(uploadId)
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
removeUploadTask(uploadId)
|
||||
}, UPLOAD_CLEANUP_DELAY)
|
||||
uploadCleanupTimers.set(uploadId, timer)
|
||||
}
|
||||
|
||||
type UploadEventDetail = {
|
||||
uploadId: string
|
||||
kbId?: string | number
|
||||
fileName?: string
|
||||
progress?: number
|
||||
status?: UploadTaskState['status']
|
||||
error?: string
|
||||
}
|
||||
|
||||
const ensureUploadTaskEntry = (detail?: UploadEventDetail) => {
|
||||
if (!detail?.uploadId) return null
|
||||
const existing = uploadTasks.value.find(task => task.uploadId === detail.uploadId)
|
||||
if (existing) return existing
|
||||
if (!detail.kbId) return null
|
||||
const initialProgress = typeof detail.progress === 'number' ? clampProgress(detail.progress) : 0
|
||||
const newTask: UploadTaskState = {
|
||||
uploadId: detail.uploadId,
|
||||
kbId: String(detail.kbId),
|
||||
fileName: detail.fileName,
|
||||
progress: initialProgress,
|
||||
status: detail.status || 'uploading',
|
||||
error: detail.error
|
||||
}
|
||||
addUploadTask(newTask)
|
||||
return newTask
|
||||
}
|
||||
|
||||
const handleCardClick = (kb: KB) => {
|
||||
if (isInitialized(kb)) {
|
||||
goDetail(kb.id)
|
||||
@@ -332,6 +510,54 @@ const triggerHighlightFlash = (kbId: string) => {
|
||||
}, 3000)
|
||||
})
|
||||
}
|
||||
|
||||
const handleUploadStartEvent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<UploadEventDetail>).detail
|
||||
if (!detail?.uploadId || !detail?.kbId) return
|
||||
addUploadTask({
|
||||
uploadId: detail.uploadId,
|
||||
kbId: String(detail.kbId),
|
||||
fileName: detail.fileName,
|
||||
progress: typeof detail.progress === 'number' ? clampProgress(detail.progress) : 0,
|
||||
status: 'uploading'
|
||||
})
|
||||
}
|
||||
|
||||
const handleUploadProgressEvent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<UploadEventDetail>).detail
|
||||
if (!detail?.uploadId || typeof detail.progress !== 'number') return
|
||||
if (!ensureUploadTaskEntry(detail)) return
|
||||
patchUploadTask(detail.uploadId, {
|
||||
progress: clampProgress(detail.progress)
|
||||
})
|
||||
}
|
||||
|
||||
const handleUploadCompleteEvent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<UploadEventDetail>).detail
|
||||
if (!detail?.uploadId) return
|
||||
const progress = typeof detail.progress === 'number'
|
||||
? clampProgress(detail.progress)
|
||||
: 100
|
||||
if (!ensureUploadTaskEntry({ ...detail, progress })) return
|
||||
patchUploadTask(detail.uploadId, {
|
||||
status: detail.status || 'success',
|
||||
progress,
|
||||
error: detail.error
|
||||
})
|
||||
scheduleUploadTaskCleanup(detail.uploadId)
|
||||
}
|
||||
|
||||
const handleUploadFinishedEvent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ kbId?: string | number }>).detail
|
||||
if (!detail?.kbId) return
|
||||
if (uploadRefreshTimer) {
|
||||
clearTimeout(uploadRefreshTimer)
|
||||
}
|
||||
uploadRefreshTimer = setTimeout(() => {
|
||||
fetchList()
|
||||
uploadRefreshTimer = null
|
||||
}, 800)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -401,6 +627,75 @@ const triggerHighlightFlash = (kbId: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
.upload-progress-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.upload-progress-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e7ebf0;
|
||||
border-radius: 8px;
|
||||
background: #f7fbff;
|
||||
}
|
||||
|
||||
.upload-progress-icon {
|
||||
color: #07c05f;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upload-progress-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.progress-title {
|
||||
color: #0f172a;
|
||||
font-family: "PingFang SC";
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.progress-subtitle {
|
||||
color: #475467;
|
||||
font-family: "PingFang SC";
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.progress-subtitle.secondary {
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.progress-subtitle.error {
|
||||
color: #d92d20;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #e7eef5;
|
||||
margin-top: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-inner {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #05a04f 0%, #07c05f 100%);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.kb-card-wrap {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
|
||||
-1
Submodule graphiti deleted from d2654003ff
@@ -302,7 +302,8 @@ For every retrieval attempt (Phase 1 or Phase 3), follow this exact chain:
|
||||
|
||||
### Final Output Standards
|
||||
* **Definitive:** Based strictly on the "Deep Read" content.
|
||||
* **Sourced:** Cite sources immediately after claims: <kb doc="..." chunk_id="..." /> or <web url="..." title="..." />.
|
||||
* **Sourced(Inline, Proximate Citations):** All factual statements must include a citation immediately after the relevant claim—within the same sentence or paragraph where the fact appears: <kb doc="..." chunk_id="..." /> or <web url="..." title="..." />.
|
||||
Citations may not be placed at the end of the answer. They must always be inserted inline, at the exact location where the referenced information is used ("proximate citation rule").
|
||||
* **Structured:** Clear hierarchy and logic.
|
||||
|
||||
### System Status
|
||||
@@ -383,7 +384,8 @@ For every information seeking step, strictly follow this 3-step atomic unit:
|
||||
|
||||
### Final Output Standards
|
||||
1. **Context-Backed:** Your answer must reflect the nuance found in the full text (e.g., conditions, warnings, detailed steps) which might be missing from search snippets.
|
||||
2. **Sourced:** Cite sources immediately after claims: <kb doc="..." chunk_id="..." /> .
|
||||
2 **Sourced(Inline, Proximate Citations):** All factual statements must include a citation immediately after the relevant claim—within the same sentence or paragraph where the fact appears: <kb doc="..." chunk_id="..." />.
|
||||
Citations may not be placed at the end of the answer. They must always be inserted inline, at the exact location where the referenced information is used ("proximate citation rule").
|
||||
3. **Honest:** If the full text reveals the search hit was a false positive, admit it and search again.
|
||||
|
||||
### System Status
|
||||
|
||||
@@ -255,6 +255,7 @@ type chunkWithTitle struct {
|
||||
KnowledgeTitle string `json:"knowledge_title" gorm:"column:knowledge_title"`
|
||||
MatchScore float64 `json:"match_score" gorm:"column:match_score"` // Score based on match count and position
|
||||
MatchedPatterns int `json:"matched_patterns"` // Number of unique patterns matched
|
||||
TotalChunkCount int `json:"total_chunk_count" gorm:"column:total_chunk_count"`
|
||||
}
|
||||
|
||||
// searchChunks performs the database search with pattern matching
|
||||
@@ -265,7 +266,7 @@ func (t *GrepChunksTool) searchChunks(
|
||||
) ([]chunkWithTitle, int64, error) {
|
||||
// Build base query
|
||||
query := t.db.Debug().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").
|
||||
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, COUNT(*) OVER (PARTITION BY chunks.knowledge_id) AS total_chunk_count").
|
||||
Joins("LEFT JOIN knowledges ON chunks.knowledge_id = knowledges.id").
|
||||
Where("chunks.tenant_id = ?", t.tenantID).
|
||||
Where("chunks.is_enabled = ?", true).
|
||||
@@ -333,11 +334,6 @@ func (t *GrepChunksTool) formatOutput(
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -348,11 +344,12 @@ func (t *GrepChunksTool) formatOutput(
|
||||
patternSummaries = append(patternSummaries, fmt.Sprintf("%s=%d", pattern, count))
|
||||
}
|
||||
|
||||
output.WriteString(fmt.Sprintf("%d) knowledge_id=%s | title=%s | chunk_hits=%d | pattern_hits=[%s]\n",
|
||||
output.WriteString(fmt.Sprintf("%d) knowledge_id=%s | title=%s | chunk_hits=%d | chunk_total=%d | pattern_hits=[%s]\n",
|
||||
idx+1,
|
||||
result.KnowledgeID,
|
||||
result.KnowledgeTitle,
|
||||
result.ChunkHitCount,
|
||||
result.TotalChunkCount,
|
||||
strings.Join(patternSummaries, ", "),
|
||||
))
|
||||
}
|
||||
@@ -364,6 +361,7 @@ type knowledgeAggregation struct {
|
||||
KnowledgeBaseID string `json:"knowledge_base_id"`
|
||||
KnowledgeTitle string `json:"knowledge_title"`
|
||||
ChunkHitCount int `json:"chunk_hit_count"`
|
||||
TotalChunkCount int `json:"total_chunk_count"`
|
||||
PatternCounts map[string]int `json:"pattern_counts"`
|
||||
TotalPatternHits int `json:"total_pattern_hits"`
|
||||
DistinctPatterns int `json:"distinct_patterns"`
|
||||
@@ -398,6 +396,7 @@ func (t *GrepChunksTool) aggregateByKnowledge(results []chunkWithTitle, patterns
|
||||
KnowledgeID: knowledgeID,
|
||||
KnowledgeBaseID: chunk.KnowledgeBaseID,
|
||||
KnowledgeTitle: title,
|
||||
TotalChunkCount: chunk.TotalChunkCount,
|
||||
PatternCounts: make(map[string]int, len(patternKeys)),
|
||||
}
|
||||
for _, pKey := range patternKeys {
|
||||
|
||||
Reference in New Issue
Block a user