/** * RAG-cut Demo Frontend */ const API_BASE = (() => { const meta = document.querySelector('meta[name="api-base"]')?.content?.trim(); if (meta) return meta.replace(/\/$/, ""); if (window.location.port && window.location.port !== "8000") { return "http://127.0.0.1:8000"; } return ""; })(); function apiUrl(path) { return `${API_BASE}${path}`; } const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); const state = { file: null, result: null, selectedIndex: 0, objectUrl: null, history: [], activeHistoryId: null, }; const els = { dropzone: $("#dropzone"), fileInput: $("#fileInput"), fileName: $("#fileName"), configForm: $("#configForm"), submitBtn: $("#submitBtn"), splitMode: $("#splitMode"), modeHint: $("#modeHint"), modeOptionsDefault: $("#modeOptionsDefault"), modeOptionsDelimiter: $("#modeOptionsDelimiter"), modeOptionsParentChild: $("#modeOptionsParentChild"), modeOptionsByRow: $("#modeOptionsByRow"), maxChunkSize: $("#maxChunkSize"), overlap: $("#overlap"), delimiter: $("#delimiter"), delimiterMaxSize: $("#delimiterMaxSize"), delimiterOverlap: $("#delimiterOverlap"), parentDelimiter: $("#parentDelimiter"), childDelimiter: $("#childDelimiter"), parentMaxSize: $("#parentMaxSize"), childMaxSize: $("#childMaxSize"), parentChildOverlap: $("#parentChildOverlap"), headerRowStart: $("#headerRowStart"), headerRowEnd: $("#headerRowEnd"), startRow: $("#startRow"), rowsPerChunk: $("#rowsPerChunk"), autoPolicy: $("#autoPolicy"), policyKicker: $("#policyKicker"), policyTitle: $("#policyTitle"), autoPolicyGrid: $("#autoPolicyGrid"), statusBadge: $("#statusBadge"), emptyState: $("#emptyState"), dualPane: $("#workspacePane"), previewPanel: $("#previewPanel"), apiPanel: $("#apiPanel"), recallPanel: $("#recallPanel"), recallForm: $("#recallForm"), recallQuery: $("#recallQuery"), recallTopK: $("#recallTopK"), recallSubmit: $("#recallSubmit"), recallHint: $("#recallHint"), recallResults: $("#recallResults"), statsSection: $("#statsSection"), historyList: $("#historyList"), historyHint: $("#historyHint"), historyRefreshBtn: $("#historyRefreshBtn"), originalPreview: $("#originalPreview"), chunkList: $("#chunkList"), chunkPreview: $("#chunkPreview"), originalTag: $("#originalTag"), chunkCountTag: $("#chunkCountTag"), selectedChunkTag: $("#selectedChunkTag"), statBlocks: $("#statBlocks"), statChunks: $("#statChunks"), statMode: $("#statMode"), statDocId: $("#statDocId"), apiRequest: $("#apiRequest"), apiResponse: $("#apiResponse"), }; function setStatus(type, text) { els.statusBadge.innerHTML = `${text}`; } function setFile(file) { if (state.objectUrl) { URL.revokeObjectURL(state.objectUrl); state.objectUrl = null; } state.file = file; state.result = null; state.activeHistoryId = null; els.fileName.textContent = file ? file.name : ""; els.submitBtn.disabled = !file; els.submitBtn.title = file ? "开始切分已选文件" : "请先上传文档"; highlightHistoryActive(); if (file) setStatus("idle", "已选择文件"); else setStatus("idle", "等待上传"); } function formatSavedAt(iso) { if (!iso) return ""; try { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; return d.toLocaleString("zh-CN", { hour12: false }); } catch { return ""; } } function highlightHistoryActive() { if (!els.historyList) return; els.historyList.querySelectorAll(".history-item").forEach((row) => { const active = row.dataset.docId === state.activeHistoryId; row.classList.toggle("active", active); row.setAttribute("aria-selected", active ? "true" : "false"); }); } function clearWorkspace() { state.result = null; state.selectedIndex = 0; state.activeHistoryId = null; if (state.objectUrl) { URL.revokeObjectURL(state.objectUrl); state.objectUrl = null; } els.emptyState?.classList.remove("hidden"); els.dualPane?.classList.add("hidden"); els.apiPanel?.classList.add("hidden"); els.recallPanel?.classList.add("hidden"); els.statsSection?.classList.add("hidden"); if (els.originalPreview) els.originalPreview.innerHTML = ""; if (els.chunkList) els.chunkList.innerHTML = ""; if (els.chunkPreview) els.chunkPreview.innerHTML = ""; if (els.apiRequest) els.apiRequest.textContent = ""; if (els.apiResponse) els.apiResponse.textContent = ""; if (els.recallResults) els.recallResults.innerHTML = ""; highlightHistoryActive(); setStatus("idle", "等待上传"); } function renderHistoryList(items) { state.history = items || []; if (!els.historyList) return; if (!state.history.length) { els.historyHint.textContent = "暂无历史记录,上传并切分后会出现在这里"; els.historyList.innerHTML = ""; return; } els.historyHint.textContent = `共 ${state.history.length} 条 · 点击加载`; els.historyList.innerHTML = state.history.map((item) => { const when = formatSavedAt(item.saved_at); const orig = item.has_original ? "" : " · 无原文"; const active = item.doc_id === state.activeHistoryId; return `
`; }).join(""); els.historyList.querySelectorAll(".history-item-load").forEach((btn) => { btn.addEventListener("click", () => loadHistoryResult(btn.dataset.docId)); }); els.historyList.querySelectorAll(".history-item-delete").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); deleteHistoryResult(btn.dataset.docId); }); }); } async function deleteHistoryResult(docId) { if (!docId) return; const item = state.history.find((h) => h.doc_id === docId); const label = item?.filename || docId; if (!confirm(`确定删除「${label}」?\n将同时删除切片结果、上传原文与资源文件。`)) { return; } setStatus("loading", "删除历史…"); try { const res = await fetch(apiUrl(`/api/results/${docId}`), { method: "DELETE" }); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = null; } if (!res.ok) { const detail = data?.detail; const msg = typeof detail === "string" ? detail : (detail ? JSON.stringify(detail) : `HTTP ${res.status}`); throw new Error(msg); } if (state.activeHistoryId === docId) { clearWorkspace(); } await refreshHistory(); setStatus("success", `已删除 · ${label}`); } catch (err) { setStatus("error", "删除失败"); alert(`删除失败:${err.message || err}`); } } async function refreshHistory() { if (!els.historyHint) return; els.historyHint.textContent = "加载中…"; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 30000); try { const res = await fetch(apiUrl("/api/results"), { signal: controller.signal }); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { throw new Error(res.ok ? "响应不是 JSON" : `HTTP ${res.status}`); } if (!res.ok) { const detail = data?.detail; const msg = typeof detail === "string" ? detail : (detail ? JSON.stringify(detail) : `HTTP ${res.status}`); if (res.status === 404) { throw new Error("接口不存在,请重启后端(backend: python run.py)"); } throw new Error(msg); } renderHistoryList(data.results || []); } catch (err) { const message = err?.name === "AbortError" ? "请求超时,请确认后端已启动在 :8000" : (err.message || String(err)); els.historyHint.textContent = `历史加载失败:${message}`; } finally { clearTimeout(timer); } } async function renderOriginalFromHistory(docId, filename, hasOriginal) { if (!hasOriginal) { els.originalTag.textContent = filename || docId; els.originalPreview.innerHTML = '

历史记录中未找到原文文件(可能已被清理)。仍可查看右侧切片结果。

'; return; } els.originalPreview.innerHTML = '

原文加载中…

'; try { const res = await fetch(apiUrl(`/api/results/${docId}/original`)); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `HTTP ${res.status}`); } const blob = await res.blob(); const name = filename || "document"; const file = new File([blob], name, { type: blob.type || "application/octet-stream" }); state.file = file; els.fileName.textContent = name; els.submitBtn.disabled = false; await renderOriginalPreview(file, docId); } catch (err) { els.originalTag.textContent = filename || docId; els.originalPreview.innerHTML = `

原文预览失败:${escapeHtml(err.message || String(err))}。仍可查看切片结果。

`; } } async function loadHistoryResult(docId) { if (!docId) return; setStatus("loading", "加载历史…"); els.apiRequest.textContent = JSON.stringify( { method: "GET", url: apiUrl(`/api/results/${docId}`) }, null, 2, ); try { const res = await fetch(apiUrl(`/api/results/${docId}`)); const data = await res.json(); if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`); state.activeHistoryId = docId; highlightHistoryActive(); els.apiResponse.textContent = JSON.stringify( { doc_id: data.doc_id, filename: data.filename, chunk_count: data.chunk_count, split_mode: data.split_mode }, null, 2, ); showResult(data); setStatus("success", `已加载历史 · ${data.chunk_count} 片`); await renderOriginalFromHistory(docId, data.filename, data.has_original); } catch (err) { setStatus("error", "加载失败"); els.apiResponse.textContent = JSON.stringify({ error: err.message || String(err) }, null, 2); alert(`加载历史失败:${err.message || err}`); } } function buildFormData() { const fd = new FormData(); fd.append("file", state.file); const fields = collectSplitFields(); Object.entries(fields).forEach(([key, value]) => { if (value != null && value !== "") fd.append(key, String(value)); }); return fd; } function unescapeDelimiter(raw) { return String(raw || "") .replace(/\\n/g, "\n") .replace(/\\t/g, "\t") .replace(/\\r/g, "\r"); } function collectSplitFields() { const mode = els.splitMode?.value || "default"; if (mode === "default") { return { mode: "default", max_chunk_size: Number(els.maxChunkSize?.value || 2200), overlap: Number(els.overlap?.value || 0), }; } if (mode === "delimiter") { const delimiter = unescapeDelimiter(els.delimiter?.value?.trim()); return { mode: "delimiter", delimiter: delimiter || "###", max_chunk_size: Number(els.delimiterMaxSize?.value || 1500), overlap: Number(els.delimiterOverlap?.value || 0), }; } if (mode === "parent_child") { const parentDelimiter = unescapeDelimiter(els.parentDelimiter?.value?.trim()); const childDelimiter = unescapeDelimiter(els.childDelimiter?.value?.trim()); return { mode: "parent_child", parent_delimiter: parentDelimiter || "##", child_delimiter: childDelimiter || "", max_chunk_size: Number(els.parentMaxSize?.value || 2000), child_max_size: Number(els.childMaxSize?.value || 512), overlap: Number(els.parentChildOverlap?.value || 0), }; } if (mode === "by_row") { return { mode: "by_row", header_row_start: Number(els.headerRowStart?.value || 1), header_row_end: Number(els.headerRowEnd?.value || 1), start_row: Number(els.startRow?.value || 2), rows_per_chunk: Number(els.rowsPerChunk?.value || 10), max_chunk_size: 2400, overlap: 0, }; } return {}; } function buildRequestPreview() { const fields = { file: state.file?.name, ...collectSplitFields(), }; return { method: "POST", url: apiUrl("/api/chunk"), contentType: "multipart/form-data", fields, }; } const MODE_HINTS = { default: "按标题/结构切分,适合 PDF、Word、PPT 等", delimiter: "按自定义标识符切开,适合 Markdown/带固定分隔符的文本", parent_child: "先按父标识符切父片,再按子标识符切子片;检索用子片、父片作上下文", by_row: "按表格行切分,适合 xlsx / xls / csv", }; function syncModeOptions() { const mode = els.splitMode?.value || "default"; els.modeOptionsDefault?.classList.toggle("hidden", mode !== "default"); els.modeOptionsDelimiter?.classList.toggle("hidden", mode !== "delimiter"); els.modeOptionsParentChild?.classList.toggle("hidden", mode !== "parent_child"); els.modeOptionsByRow?.classList.toggle("hidden", mode !== "by_row"); if (els.modeHint) els.modeHint.textContent = MODE_HINTS[mode] || MODE_HINTS.default; // CSS-hidden fields still join HTML5 constraint validation and can silently // block
submit with no JS callback. Disable inactive panels' controls. [ [els.modeOptionsDefault, mode === "default"], [els.modeOptionsDelimiter, mode === "delimiter"], [els.modeOptionsParentChild, mode === "parent_child"], [els.modeOptionsByRow, mode === "by_row"], ].forEach(([panel, active]) => { panel?.querySelectorAll("input, select, textarea").forEach((input) => { input.disabled = !active; }); }); if (els.policyKicker) { els.policyKicker.textContent = "MANUAL"; } if (els.policyTitle) { els.policyTitle.textContent = "将使用上方手动选择的切分模式与参数"; } const fields = collectSplitFields(); renderAutoPolicy({ mode: fields.mode, max_chunk_size: fields.max_chunk_size, child_max_size: fields.child_max_size, overlap: fields.overlap, pdf_chunk_strategy: mode === "by_row" ? null : undefined, chunk_strategy: mode === "parent_child" ? "parent_child_delimiter" : undefined, }); } function renderAutoPolicy(config) { if (!els.autoPolicyGrid) return; const modeLabel = { default: "结构感知", delimiter: "标识符", parent_child: "父子标识符", by_row: "按行", }[config?.mode] || "待上传后判断"; const strategyLabel = { pdf_feature_step_screenshot: "功能步骤 + 截图", pdf_outline_report: "章节报告(标题层级)", heading_hierarchy: "标题层级优先", heading_layout_multimodal: "标题布局多模态", parent_child_delimiter: "父子标识符", }[config?.pdf_chunk_strategy || config?.chunk_strategy] || (config?.mode === "by_row" ? "表格按行" : (config?.mode === "delimiter" ? "通用标识符" : (config?.mode === "parent_child" ? "父子标识符" : "自动判断"))); const maxSize = config?.max_chunk_size != null ? `${config.max_chunk_size} 字符` : "自动"; const overlap = config?.overlap != null ? `${config.overlap} 字符` : "自动"; const childSize = config?.child_max_size != null ? `${config.child_max_size} 字符` : null; els.autoPolicyGrid.innerHTML = `
模式
${escapeHtml(modeLabel)}
长度
${escapeHtml(maxSize)}
重叠
${escapeHtml(overlap)}
策略
${escapeHtml(strategyLabel)}
${childSize ? `
子级长度
${escapeHtml(childSize)}
` : ""} `; } function toAssetUrl(path, docId) { const raw = (path || "").trim(); if (!raw || raw.startsWith("http://") || raw.startsWith("https://") || raw.startsWith("data:")) { return raw; } if (raw.startsWith("/assets/")) return apiUrl(raw); if (raw.startsWith("assets/")) return apiUrl(`/${raw}`); const name = raw.split(/[/\\]/).pop(); return apiUrl(`/assets/${docId}/${name}`); } function fixAssetPaths(markdown, docId) { if (!markdown) return ""; return markdown.replace(/!\[(.*?)\]\(([^)]+)\)/g, (_, alt, path) => `![${alt}](${toAssetUrl(path, docId)})`); } function rewritePreviewImages(container, docId) { container.querySelectorAll("img").forEach((img) => { const src = img.getAttribute("src"); if (src) img.src = toAssetUrl(src, docId); }); } function formatBbox(bbox) { if (!Array.isArray(bbox) || bbox.length < 4) return ""; return bbox.map((v) => Number(v).toFixed(0)).join(", "); } function renderBlockItem(block, docId) { const order = block.order_index != null ? `data-order="${block.order_index}"` : ""; const page = block.page != null ? `data-page="${block.page}"` : ""; const bbox = formatBbox(block.bbox); const metaBadge = [block.page != null ? `p${block.page}` : "", bbox ? `[${bbox}]` : ""] .filter(Boolean) .join(" · "); if (block.type === "image") { const src = toAssetUrl(block.image_path, docId); const alt = block.text || block.image_id || "image"; let html = `
`; if (block.bound_heading || block.nearest_heading) { html += `
${escapeHtml(block.bound_heading || block.nearest_heading)}
`; } html += `${escapeHtml(alt)}`; if (block.ocr_text) { html += `
*[OCR]* ${escapeHtml(block.ocr_text)}
`; } if (metaBadge) html += `
${escapeHtml(metaBadge)}
`; html += "
"; return html; } if (block.type === "heading") { const level = Math.min(Math.max(block.level || 2, 1), 6); return `${escapeHtml(block.text || "")}`; } if (block.type === "table") { const title = block.table_title || block.nearest_heading || ""; const imgPath = block.image_path || block.crop_path; let html = `
`; if (title) html += `
${escapeHtml(title)}
`; if (imgPath) { const src = toAssetUrl(imgPath, docId); html += `
${escapeHtml(title ||
`; } html += `
${renderMarkdown(block.markdown || block.text || "", docId)}
`; if (block.ocr_text) { html += `
*[表格 OCR]* ${escapeHtml(block.ocr_text)}
`; } if (block.footnotes) { html += `
${escapeHtml(block.footnotes)}
`; } if (metaBadge) html += `
${escapeHtml(metaBadge)}
`; html += "
"; return html; } const inner = block.text ? `

${escapeHtml(block.text)}

` : ""; return `
${inner}${metaBadge ? `
${escapeHtml(metaBadge)}
` : ""}
`; } function renderChunkContent(chunk, docId) { const blocks = chunk.meta?.blocks; if (Array.isArray(blocks) && blocks.length > 0) { const sorted = [...blocks].sort((a, b) => { const ao = a.order_index ?? Number.MAX_SAFE_INTEGER; const bo = b.order_index ?? Number.MAX_SAFE_INTEGER; return ao - bo; }); return `
${sorted.map((b) => renderBlockItem(b, docId)).join("")}
`; } return renderMarkdown(chunk.content, docId); } function renderMarkdown(markdown, docId) { const fixed = fixAssetPaths(markdown, docId); if (typeof marked !== "undefined") { try { if (typeof marked.use === "function") { marked.use({ breaks: true, gfm: true }); } else if (typeof marked.setOptions === "function") { marked.setOptions({ breaks: true, gfm: true }); } const html = marked.parse(fixed); return typeof html === "string" ? html : String(html); } catch (err) { console.error("Markdown render failed:", err); return `
${escapeHtml(fixed)}
`; } } return `
${escapeHtml(fixed)}
`; } function escapeHtml(str) { return str .replace(/&/g, "&") .replace(//g, ">"); } function uniqueTypes(types) { return [...new Set(types)]; } function typeTagClass(type) { if (type === "heading") return "type-tag type-tag-heading"; if (type === "image") return "type-tag type-tag-image"; if (type === "table") return "type-tag type-tag-table"; return "type-tag"; } function renderChunkList(result) { els.chunkList.innerHTML = ""; result.chunks.forEach((chunk, i) => { const btn = document.createElement("button"); btn.type = "button"; btn.className = `chunk-card${i === state.selectedIndex ? " active" : ""}`; btn.setAttribute("role", "option"); btn.setAttribute("aria-selected", i === state.selectedIndex ? "true" : "false"); btn.dataset.index = String(i); const types = uniqueTypes(chunk.block_types); const heading = chunk.meta?.heading || chunk.meta?.nearest_heading || chunk.meta?.parent_heading; const pageHint = chunk.meta?.page ? `p${chunk.meta.page}` : chunk.meta?.pages?.length ? `p${chunk.meta.pages.join(",")}` : ""; const imageCount = chunk.meta?.images?.length || chunk.block_types.filter((t) => t === "image").length; const tableCount = chunk.meta?.tables?.length || chunk.block_types.filter((t) => t === "table").length; const parentRef = chunk.meta?.parent_chunk_id != null ? ` · 父切片 ${chunk.meta.parent_chunk_id + 1}` : ""; const subBadge = chunk.meta?.is_sub_chunk ? '子切片' : ""; const parentBadge = chunk.meta?.is_section_parent ? '完整节' : ""; const previewOnlyBadge = chunk.meta?.retrieval === false ? '仅展示' : ""; const snippet = chunk.content.replace(/!\[.*?\]\([^)]+\)/g, "[图片]").slice(0, 160); btn.innerHTML = `
切片 ${chunk.index + 1} ${chunk.char_count} 字符${pageHint ? ` · ${pageHint}` : ""}${imageCount ? ` · ${imageCount} 图` : ""}${tableCount ? ` · ${tableCount} 表` : ""}${parentRef}
${subBadge}${parentBadge}${previewOnlyBadge}${types.map((t) => `${t}`).join("")}
${heading ? `
${escapeHtml(heading)}
` : ""}
${escapeHtml(snippet)}
`; btn.addEventListener("click", () => selectChunk(i)); els.chunkList.appendChild(btn); }); } function selectChunk(index) { if (!state.result) return; state.selectedIndex = index; const chunk = state.result.chunks[index]; const docId = state.result.doc_id; $$(".chunk-card").forEach((el, i) => { el.classList.toggle("active", i === index); el.setAttribute("aria-selected", i === index ? "true" : "false"); }); els.selectedChunkTag.textContent = `切片 ${chunk.index + 1} · ${chunk.char_count} 字符${chunk.meta?.parent_chunk_id != null ? ` · 父切片 ${chunk.meta.parent_chunk_id + 1}` : ""}`; els.chunkPreview.innerHTML = renderChunkContent(chunk, docId); rewritePreviewImages(els.chunkPreview, docId); } async function renderConvertedWordPreview(file, docId) { if (!docId) return false; try { const res = await fetch(apiUrl(`/api/results/${docId}/preview`)); if (!res.ok) return false; const blob = await res.blob(); if (state.objectUrl) URL.revokeObjectURL(state.objectUrl); state.objectUrl = URL.createObjectURL(blob); els.originalPreview.innerHTML = ``; return true; } catch (err) { console.warn("Word PDF 预览加载失败:", err); return false; } } async function renderOriginalPreview(file, docId = null) { els.originalPreview.innerHTML = '

原文加载中…

'; els.originalTag.textContent = file.name; const ext = file.name.split(".").pop()?.toLowerCase() || ""; if (ext === "pdf") { state.objectUrl = URL.createObjectURL(file); els.originalPreview.innerHTML = ``; return; } if (["doc", "docx"].includes(ext) && await renderConvertedWordPreview(file, docId)) { return; } if (ext === "docx" && typeof mammoth !== "undefined") { const buffer = await file.arrayBuffer(); const { value } = await mammoth.convertToHtml({ arrayBuffer: buffer }); els.originalPreview.innerHTML = `
${value}
`; return; } if (["doc", "docx"].includes(ext)) { els.originalPreview.innerHTML = '

Word 原文将在切分完成后以 PDF 方式显示。

'; return; } if (["jpg", "jpeg", "png", "bmp", "gif"].includes(ext)) { state.objectUrl = URL.createObjectURL(file); els.originalPreview.innerHTML = `上传的图片`; return; } if (["txt", "md", "csv", "json", "xml", "log", "html", "htm"].includes(ext)) { const text = await file.text(); els.originalPreview.innerHTML = `
${escapeHtml(text.slice(0, 50000))}${text.length > 50000 ? "\n\n… (已截断)" : ""}
`; return; } if (["ppt", "pptx", "ppsx"].includes(ext)) { els.originalPreview.innerHTML = `

演示文稿将先转为 PDF 再解析;原文预览暂不支持,请查看右侧切片结果。

`; return; } els.originalPreview.innerHTML = `

该格式暂不支持原文预览,请查看右侧切片结果。

`; } function showResult(result) { state.result = result; state.selectedIndex = 0; state.activeHistoryId = result.doc_id || null; highlightHistoryActive(); els.emptyState.classList.add("hidden"); els.dualPane.classList.remove("hidden"); els.apiPanel.classList.remove("hidden"); els.recallPanel.classList.remove("hidden"); els.statsSection.classList.remove("hidden"); els.statBlocks.textContent = result.block_count; els.statChunks.textContent = result.chunk_count; els.statMode.textContent = result.split_mode; els.statDocId.textContent = result.doc_id; els.chunkCountTag.textContent = `${result.chunk_count} 个切片`; renderAutoPolicy(result.split_config); renderChunkList(result); selectChunk(0); setStatus("success", `切分完成 · ${result.chunk_count} 片`); } function renderRecallResults(data) { els.recallHint.textContent = `候选 ${data.candidate_count} 个 · 返回 ${data.returned_count} 个 · ${data.elapsed_ms} ms`; if (!data.results.length) { els.recallResults.innerHTML = '

没有匹配的切片,请尝试更具体或更短的关键词。

'; return; } els.recallResults.innerHTML = data.results.map((item) => ` `).join(""); els.recallResults.querySelectorAll(".recall-result").forEach((button) => { button.addEventListener("click", () => { const chunkIndex = Number(button.dataset.chunkIndex); const position = state.result.chunks.findIndex((chunk) => chunk.index === chunkIndex); if (position >= 0) selectChunk(position); }); }); } async function handleRecall(e) { e.preventDefault(); if (!state.result) return; const request = { doc_id: state.result.doc_id, query: els.recallQuery.value.trim(), top_k: Number(els.recallTopK.value), }; if (!request.query) return; els.recallSubmit.disabled = true; els.recallHint.textContent = "正在召回…"; els.apiPanel.classList.remove("hidden"); els.apiRequest.textContent = JSON.stringify({ method: "POST", url: apiUrl("/api/recall"), body: request }, null, 2); try { const response = await fetch(apiUrl("/api/recall"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); const data = await response.json(); if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`); els.apiResponse.textContent = JSON.stringify(data, null, 2); renderRecallResults(data); } catch (err) { els.recallHint.textContent = `召回失败:${err.message}`; els.apiResponse.textContent = JSON.stringify({ error: err.message }, null, 2); } finally { els.recallSubmit.disabled = false; } } async function handleSubmit(e) { e.preventDefault(); if (!state.file) { alert("请先在上方上传文档,再点击「开始切分」"); els.dropzone?.focus(); return; } const mode = els.splitMode?.value || "default"; if (mode === "delimiter") { const delimiter = unescapeDelimiter(els.delimiter?.value?.trim()); if (!delimiter) { alert("通用标识符切分需要填写标识符"); els.delimiter?.focus(); return; } } if (mode === "parent_child") { const parentDelimiter = unescapeDelimiter(els.parentDelimiter?.value?.trim()); if (!parentDelimiter) { alert("父子标识符切分需要填写父级标识符"); els.parentDelimiter?.focus(); return; } const parentMax = Number(els.parentMaxSize?.value || 2000); const childMax = Number(els.childMaxSize?.value || 512); if (childMax > parentMax) { alert("子级最大长度不能超过父级最大长度"); els.childMaxSize?.focus(); return; } } const btnLabel = els.submitBtn.querySelector(".btn-label"); const spinner = els.submitBtn.querySelector(".btn-spinner"); const setBusy = (busy) => { els.submitBtn.disabled = busy ? true : !state.file; if (btnLabel) btnLabel.textContent = busy ? "切分中…" : "开始切分"; else els.submitBtn.textContent = busy ? "切分中…" : "开始切分"; if (spinner) spinner.classList.toggle("hidden", !busy); }; setBusy(true); setStatus("loading", "正在切分…"); if (els.apiRequest) { els.apiRequest.textContent = JSON.stringify(buildRequestPreview(), null, 2); } els.apiPanel?.classList.remove("hidden"); const controller = new AbortController(); const abortTimer = setTimeout(() => controller.abort(), 10 * 60 * 1000); try { // Kick off API first; do not block chunking on original preview. const previewPromise = renderOriginalPreview(state.file).catch((err) => { console.warn("原文预览失败:", err); }); const fd = buildFormData(); const res = await fetch(apiUrl("/api/chunk"), { method: "POST", body: fd, signal: controller.signal, }); const data = await res.json(); if (!res.ok) { const detail = Array.isArray(data.detail) ? data.detail.map((d) => d.msg || JSON.stringify(d)).join("; ") : data.detail; throw new Error(detail || `HTTP ${res.status}`); } await previewPromise; if (els.apiResponse) els.apiResponse.textContent = JSON.stringify(data, null, 2); showResult(data); await renderOriginalPreview(state.file, data.doc_id); refreshHistory(); } catch (err) { console.error("chunk submit failed:", err); const message = err?.name === "AbortError" ? "请求超时(超过 10 分钟)。后端可能卡在 MinerU/Office 转换,请重试或换较小文件。" : err.message || String(err); setStatus("error", "切分失败"); if (els.apiResponse) els.apiResponse.textContent = JSON.stringify({ error: message }, null, 2); alert(`切分失败:${message}`); } finally { clearTimeout(abortTimer); setBusy(false); } } function initDropzone() { els.dropzone.addEventListener("click", () => els.fileInput.click()); els.dropzone.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); els.fileInput.click(); } }); els.fileInput.addEventListener("change", () => { if (els.fileInput.files?.[0]) setFile(els.fileInput.files[0]); }); ["dragenter", "dragover"].forEach((ev) => { els.dropzone.addEventListener(ev, (e) => { e.preventDefault(); els.dropzone.classList.add("dragover"); }); }); ["dragleave", "drop"].forEach((ev) => { els.dropzone.addEventListener(ev, (e) => { e.preventDefault(); els.dropzone.classList.remove("dragover"); }); }); els.dropzone.addEventListener("drop", (e) => { const file = e.dataTransfer?.files?.[0]; if (file) setFile(file); }); } els.configForm.addEventListener("submit", handleSubmit); els.recallForm.addEventListener("submit", handleRecall); els.historyRefreshBtn?.addEventListener("click", refreshHistory); els.splitMode?.addEventListener("change", syncModeOptions); [ els.maxChunkSize, els.overlap, els.delimiter, els.delimiterMaxSize, els.delimiterOverlap, els.parentDelimiter, els.childDelimiter, els.parentMaxSize, els.childMaxSize, els.parentChildOverlap, els.headerRowStart, els.headerRowEnd, els.startRow, els.rowsPerChunk, ].forEach((el) => el?.addEventListener("change", syncModeOptions)); syncModeOptions(); initDropzone(); refreshHistory();