Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/src/stores/uploadConfirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,22 @@ export interface UploadConfirmManualSource {
tagIds?: string[]
}

export interface UploadConfirmReparseDoc {
knowledgeId: string
fileName?: string
fileType?: string
processOverrides?: KnowledgeProcessOverrides | null
}

export interface UploadConfirmReparseSource {
knowledgeId: string
fileName?: string
fileType?: string
processOverrides?: KnowledgeProcessOverrides | null
/** Batch reparse: per-document entries edited independently in the dialog. */
docs?: UploadConfirmReparseDoc[]
/** Batch reparse confirm output: per-document configs keyed by knowledge ID. */
processConfigs?: Record<string, KnowledgeProcessOverrides>
}

export interface UploadConfirmResult {
Expand Down
73 changes: 62 additions & 11 deletions frontend/src/views/knowledge/KnowledgeBase.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import {
reparseKnowledge,
cancelKnowledgeParse,
batchDeleteKnowledge,
batchReparseKnowledge,
getKnowledgeSpans,
getKnowledgeDetails,
listKnowledgeFolders,
Expand Down Expand Up @@ -491,6 +490,11 @@ const awaitBatchReparseReflection = async (ids: string[]) => {
applyOptimisticBatchReparse(Array.from(pendingReparseAck.value));
await new Promise<void>((r) => setTimeout(r, delayMs));
}
// Docs that finished between polls were never seen in-flight; refresh once
// more so the server's terminal status replaces the optimistic one.
if (pendingReparseAck.value.size > 0) {
await loadKnowledgeFiles(kbId.value);
}
pendingReparseAck.value.clear();
};

Expand All @@ -509,18 +513,64 @@ const confirmBatchReparse = async () => {
if (skipped > 0) {
MessagePlugin.warning(t('knowledgeBase.batchReparseSkippedInFlight', { count: skipped }));
}

if (ids.length === 1) {
const idx = cardList.value.findIndex((c) => c.id === ids[0]);
const item = cardList.value[idx];
if (item && await confirmRebuildKnowledge(idx, item)) {
clearSelection();
batchMode.value = false;
}
return;
}

let processConfigs: Record<string, KnowledgeProcessOverrides> | undefined;
if (kbInfo.value) {
const docs = ids.map((id) => {
const item = cardList.value.find((c) => c.id === id);
return {
knowledgeId: id,
fileName: item?.file_name || item?.title || '',
fileType: item?.file_type || '',
processOverrides: (item?.metadata?.process_overrides ?? null) as KnowledgeProcessOverrides | null,
};
});
try {
const result = await uploadConfirmStore.open({
mode: 'reparse',
kbInfo: kbInfo.value,
reparse: { knowledgeId: '', docs },
});
processConfigs = result.reparse?.processConfigs;
} catch {
return;
}
}

batchReparsing.value = true;
try {
const res: any = await batchReparseKnowledge(kbId.value, ids);
if (res?.success) {
MessagePlugin.success(t('knowledgeBase.batchReparseSuccess', { count: ids.length }));
applyOptimisticBatchReparse(ids);
const rebuildIds = processConfigs ? Object.keys(processConfigs) : ids;
if (rebuildIds.length === 0) return;
const CHUNK_SIZE = 10;
const okIds: string[] = [];
let failedCount = 0;
for (let i = 0; i < rebuildIds.length; i += CHUNK_SIZE) {
const chunk = rebuildIds.slice(i, i + CHUNK_SIZE);
const results = await Promise.allSettled(chunk.map((id) =>
reparseKnowledge(id, processConfigs ? { process_config: processConfigs[id] } : undefined),
));
results.forEach((r, j) => (r.status === 'fulfilled' ? okIds.push(chunk[j]) : failedCount++));
}
if (okIds.length > 0) {
MessagePlugin.success(t('knowledgeBase.batchReparseSuccess', { count: okIds.length }));
applyOptimisticBatchReparse(okIds);
clearSelection();
batchMode.value = false;
scheduleWikiStatusProbes();
void awaitBatchReparseReflection(ids);
} else {
MessagePlugin.error(res?.message || t('knowledgeBase.batchReparseFailed'));
void awaitBatchReparseReflection(okIds);
}
if (failedCount > 0) {
MessagePlugin.error(t('knowledgeBase.batchReparseFailed'));
}
} catch (e: any) {
MessagePlugin.error(e?.message || t('knowledgeBase.batchReparseFailed'));
Expand Down Expand Up @@ -1911,8 +1961,7 @@ const confirmRebuildKnowledge = async (index: number, item: KnowledgeCard) => {
// No KB context to seed the dialog defaults — fall back to a direct reparse
// that reuses the overrides stored at upload time.
if (!kbInfo.value) {
await submitReparse(item.id);
return;
return submitReparse(item.id);
}

// Prefill the confirm dialog with the overrides this doc was last parsed with.
Expand All @@ -1937,7 +1986,7 @@ const confirmRebuildKnowledge = async (index: number, item: KnowledgeCard) => {
reparse: { knowledgeId: item.id, fileName, fileType, processOverrides },
});
if (result.mode === 'reparse' && result.reparse) {
await submitReparse(result.reparse.knowledgeId, result.processConfig);
return submitReparse(result.reparse.knowledgeId, result.processConfig);
}
} catch {
// cancelled
Expand All @@ -1953,8 +2002,10 @@ const submitReparse = async (id: string, processConfig?: KnowledgeProcessOverrid
resetPage();
loadKnowledgeFiles(kbId.value);
scheduleWikiStatusProbes();
return true;
} catch (error: any) {
MessagePlugin.error(error?.message || t('knowledgeBase.rebuildFailed'));
return false;
}
};

Expand Down
162 changes: 149 additions & 13 deletions frontend/src/views/knowledge/components/UploadConfirmDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,41 @@
{{ t('uploadConfirm.manualCharCount', { count: manualCharCount }) }}
</p>
</div>
<div v-else-if="mode === 'reparse' && reparsePreview" class="manual-source-panel">
<div v-else-if="mode === 'reparse' && !isBatchReparse && reparsePreview" class="manual-source-panel">
<p class="manual-source-title" :title="reparsePreview.fileName">
{{ reparsePreview.fileName || t('uploadConfirm.reparseSource') }}
</p>
<p class="manual-source-meta">{{ t('uploadConfirm.reparseHint') }}</p>
</div>
<ul v-else-if="isBatchReparse && reparseDocs.length > 0" class="files-list">
<li
v-for="doc in reparseDocs"
:key="doc.knowledgeId"
class="file-item file-item--doc"
:class="{ 'file-item--active': doc.knowledgeId === activeDocId }"
role="button"
:aria-pressed="doc.knowledgeId === activeDocId"
@click="selectReparseDoc(doc.knowledgeId)"
>
<span class="file-icon-wrap">
<t-icon :name="getFileIcon(doc.fileName || '')" class="file-icon" />
</span>
<div class="file-meta">
<span class="file-name" :title="doc.fileName">
{{ doc.fileName || t('uploadConfirm.reparseSource') }}
</span>
<span v-if="doc.fileType" class="file-size">{{ doc.fileType }}</span>
</div>
<button
type="button"
class="file-remove"
:aria-label="t('common.remove')"
@click.stop="removeReparseDoc(doc.knowledgeId)"
>
<t-icon name="close" />
</button>
</li>
</ul>
<ul v-else-if="mode === 'file' && batchItemCount > 0" class="files-list">
<li v-for="(url, index) in localUrls" :key="`url-${url}-${index}`" class="file-item">
<span class="file-icon-wrap">
Expand Down Expand Up @@ -779,6 +808,32 @@ function inferMediaExtsFromMarkdown(content: string): string[] {
const manualCharCount = computed(() => props.manualPreview?.content?.length ?? 0)
const batchItemCount = computed(() => localFiles.value.length + localUrls.value.length)

const isBatchReparse = computed(() => props.mode === 'reparse' && (props.reparsePreview?.docs?.length || 0) > 0)

const reparseDocs = ref<Array<{ knowledgeId: string; fileName: string; fileType: string }>>([])
const docStates = new Map<string, UploadUIState>()
const activeDocId = ref('')

const activeDoc = computed(() => reparseDocs.value.find((doc) => doc.knowledgeId === activeDocId.value))

function selectReparseDoc(id: string) {
const state = docStates.get(id)
if (!state) return
activeDocId.value = id
uiState.value = state
}

function removeReparseDoc(id: string) {
reparseDocs.value = reparseDocs.value.filter((doc) => doc.knowledgeId !== id)
docStates.delete(id)
if (activeDocId.value === id) {
activeDocId.value = reparseDocs.value[0]?.knowledgeId || ''
if (activeDocId.value) {
uiState.value = docStates.get(activeDocId.value)!
}
}
}

const dialogTitle = computed(() => {
if (props.mode === 'manual') return t('uploadConfirm.titleManual')
if (props.mode === 'reparse') return t('uploadConfirm.titleReparse')
Expand All @@ -799,7 +854,8 @@ const batchFileExts = computed(() => {
}
}
if (props.mode === 'reparse') {
const ext = (props.reparsePreview?.fileType || '').toLowerCase()
const source = isBatchReparse.value ? activeDoc.value : props.reparsePreview
const ext = (source?.fileType || '').toLowerCase()
if (ext) set.add(ext)
}
for (const url of localUrls.value) {
Expand Down Expand Up @@ -1085,13 +1141,12 @@ function createDefaultUIState(): UploadUIState {
}
}

function initFromKbInfo(kb: any) {
function buildKbState(kb: any): UploadUIState {
if (!kb) {
uiState.value = createDefaultUIState()
return
return createDefaultUIState()
}

uiState.value = {
return {
chunkingConfig: {
chunkSize: kb.chunking_config?.chunk_size || 512,
chunkOverlap: kb.chunking_config?.chunk_overlap || 80,
Expand Down Expand Up @@ -1137,8 +1192,7 @@ function initFromKbInfo(kb: any) {
}
}

function buildProcessOverrides(): KnowledgeProcessOverrides {
const state = uiState.value
function buildProcessOverrides(state: UploadUIState = uiState.value): KnowledgeProcessOverrides {
const chunking = state.chunkingConfig

const overrides: KnowledgeProcessOverrides = {
Expand Down Expand Up @@ -1192,9 +1246,9 @@ function buildProcessOverrides(): KnowledgeProcessOverrides {
return overrides
}

function applyOverridesToState(o?: KnowledgeProcessOverrides | null) {
function applyOverridesToState(o?: KnowledgeProcessOverrides | null, state: UploadUIState = uiState.value) {
if (!o) return
const s = uiState.value
const s = state
const cc = o.chunking_config
if (cc) {
if (cc.chunk_size != null) s.chunkingConfig.chunkSize = cc.chunk_size
Expand Down Expand Up @@ -1294,9 +1348,27 @@ watch(
localTargetFolder.value = props.mode === 'file' ? (props.targetFolder || '') : ''
pendingFolderPaths.value = []
destinationPickerVisible.value = false
initFromKbInfo(props.kbInfo)
if (props.mode === 'reparse') {
applyOverridesToState(props.reparsePreview?.processOverrides)
reparseDocs.value = []
docStates.clear()
activeDocId.value = ''
if (isBatchReparse.value) {
for (const doc of props.reparsePreview?.docs || []) {
const state = buildKbState(props.kbInfo)
applyOverridesToState(doc.processOverrides, state)
docStates.set(doc.knowledgeId, state)
reparseDocs.value.push({
knowledgeId: doc.knowledgeId,
fileName: doc.fileName || '',
fileType: doc.fileType || '',
})
}
activeDocId.value = reparseDocs.value[0]?.knowledgeId || ''
uiState.value = (activeDocId.value && docStates.get(activeDocId.value)) || buildKbState(props.kbInfo)
} else {
uiState.value = buildKbState(props.kbInfo)
if (props.mode === 'reparse') {
applyOverridesToState(props.reparsePreview?.processOverrides)
}
}
activeSection.value = getDefaultSection()
chunkingMoreOpen.value = false
Expand Down Expand Up @@ -1378,6 +1450,42 @@ const handleNodeExtractUpdate = (config: UploadUIState['nodeExtractConfig']) =>
}

const validateBeforeConfirm = (): boolean => {
if (isBatchReparse.value) {
for (const doc of reparseDocs.value) {
const state = docStates.get(doc.knowledgeId)
if (!state) continue
const ext = (doc.fileType || '').toLowerCase()
const isImage = IMAGE_EXTENSIONS.includes(ext)
const isAudio = AUDIO_EXTENSIONS.includes(ext)
if (isImage && (!state.multimodalConfig.enabled || !state.multimodalConfig.vllmModelId)) {
selectReparseDoc(doc.knowledgeId)
MessagePlugin.warning(t('uploadConfirm.vlmModelRequired'))
uiState.value.multimodalConfig.enabled = true
goToSection('multimodal')
return false
}
if (!isImage && state.multimodalConfig.enabled && !state.multimodalConfig.vllmModelId) {
selectReparseDoc(doc.knowledgeId)
MessagePlugin.warning(t('uploadConfirm.vlmModelSelectRequired'))
goToSection('multimodal')
return false
}
if (isAudio && (!state.asrConfig.enabled || !state.asrConfig.modelId)) {
selectReparseDoc(doc.knowledgeId)
MessagePlugin.warning(t('uploadConfirm.asrModelRequired'))
uiState.value.asrConfig.enabled = true
goToSection('asr')
return false
}
if (!isAudio && state.asrConfig.enabled && !state.asrConfig.modelId) {
selectReparseDoc(doc.knowledgeId)
MessagePlugin.warning(t('uploadConfirm.asrModelSelectRequired'))
goToSection('asr')
return false
}
}
return true
}
if (hasImages.value) {
if (!uiState.value.multimodalConfig.enabled || !uiState.value.multimodalConfig.vllmModelId) {
MessagePlugin.warning(t('uploadConfirm.vlmModelRequired'))
Expand Down Expand Up @@ -1426,6 +1534,21 @@ const handleConfirm = () => {
tagIds: [...selectedTagIds.value],
manual: { ...props.manualPreview, tagIds: [...selectedTagIds.value] },
})
} else if (isBatchReparse.value) {
if (reparseDocs.value.length === 0) {
MessagePlugin.warning(t('uploadConfirm.noItems'))
return
}
const processConfigs: Record<string, KnowledgeProcessOverrides> = {}
for (const doc of reparseDocs.value) {
const state = docStates.get(doc.knowledgeId)
if (state) processConfigs[doc.knowledgeId] = buildProcessOverrides(state)
}
emit('confirm', {
processConfig,
mode: 'reparse',
reparse: { ...props.reparsePreview, knowledgeId: '', processConfigs },
})
} else if (props.mode === 'reparse' && props.reparsePreview) {
emit('confirm', { processConfig, mode: 'reparse', reparse: { ...props.reparsePreview } })
} else {
Expand Down Expand Up @@ -1672,6 +1795,19 @@ const handleConfirm = () => {
color: var(--td-brand-color);
}

.file-item--doc {
cursor: pointer;
}

.file-item--doc.file-item--active,
.file-item--doc.file-item--active:hover {
background: var(--td-brand-color-light);

.file-name {
color: var(--td-brand-color);
}
}

.file-meta {
flex: 1;
min-width: 0;
Expand Down