Files
RAG-CUT/frontend/js/app.js
T
2026-07-16 11:12:17 +08:00

958 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `<span class="badge badge-${type}">${text}</span>`;
}
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 `
<div class="history-item${active ? " active" : ""}" role="option"
data-doc-id="${escapeHtml(item.doc_id)}"
aria-selected="${active ? "true" : "false"}">
<button type="button" class="history-item-load" data-doc-id="${escapeHtml(item.doc_id)}" title="加载此切片结果">
<span class="history-item-name">${escapeHtml(item.filename || item.doc_id)}</span>
<span class="history-item-meta">${item.chunk_count || 0} 片 · ${escapeHtml(item.split_mode || "—")}${when ? ` · ${escapeHtml(when)}` : ""}${orig}</span>
</button>
<button type="button" class="history-item-delete" data-doc-id="${escapeHtml(item.doc_id)}"
title="删除此历史记录" aria-label="删除 ${escapeHtml(item.filename || item.doc_id)}">删除</button>
</div>
`;
}).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 =
'<p class="placeholder">历史记录中未找到原文文件(可能已被清理)。仍可查看右侧切片结果。</p>';
return;
}
els.originalPreview.innerHTML = '<p class="placeholder">原文加载中…</p>';
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 =
`<p class="placeholder">原文预览失败:${escapeHtml(err.message || String(err))}。仍可查看切片结果。</p>`;
}
}
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 <form> 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 = `
<div><dt>模式</dt><dd>${escapeHtml(modeLabel)}</dd></div>
<div><dt>长度</dt><dd>${escapeHtml(maxSize)}</dd></div>
<div><dt>重叠</dt><dd>${escapeHtml(overlap)}</dd></div>
<div><dt>策略</dt><dd>${escapeHtml(strategyLabel)}</dd></div>
${childSize ? `<div><dt>子级长度</dt><dd>${escapeHtml(childSize)}</dd></div>` : ""}
`;
}
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 = `<figure class="chunk-block chunk-block-image" ${order} ${page}>`;
if (block.bound_heading || block.nearest_heading) {
html += `<div class="block-context">${escapeHtml(block.bound_heading || block.nearest_heading)}</div>`;
}
html += `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" loading="lazy" />`;
if (block.ocr_text) {
html += `<figcaption class="ocr-caption">*[OCR]* ${escapeHtml(block.ocr_text)}</figcaption>`;
}
if (metaBadge) html += `<div class="block-meta">${escapeHtml(metaBadge)}</div>`;
html += "</figure>";
return html;
}
if (block.type === "heading") {
const level = Math.min(Math.max(block.level || 2, 1), 6);
return `<h${level} class="chunk-block chunk-block-heading" ${order} ${page}>${escapeHtml(block.text || "")}</h${level}>`;
}
if (block.type === "table") {
const title = block.table_title || block.nearest_heading || "";
const imgPath = block.image_path || block.crop_path;
let html = `<div class="chunk-block chunk-block-table" ${order} ${page}>`;
if (title) html += `<div class="block-context">${escapeHtml(title)}</div>`;
if (imgPath) {
const src = toAssetUrl(imgPath, docId);
html += `<figure class="table-screenshot"><img src="${escapeHtml(src)}" alt="${escapeHtml(title || "table")}" loading="lazy" /></figure>`;
}
html += `<div class="table-markdown">${renderMarkdown(block.markdown || block.text || "", docId)}</div>`;
if (block.ocr_text) {
html += `<div class="ocr-caption">*[表格 OCR]* ${escapeHtml(block.ocr_text)}</div>`;
}
if (block.footnotes) {
html += `<div class="table-footnote">${escapeHtml(block.footnotes)}</div>`;
}
if (metaBadge) html += `<div class="block-meta">${escapeHtml(metaBadge)}</div>`;
html += "</div>";
return html;
}
const inner = block.text ? `<p>${escapeHtml(block.text)}</p>` : "";
return `<div class="chunk-block chunk-block-paragraph" ${order} ${page}>${inner}${metaBadge ? `<div class="block-meta">${escapeHtml(metaBadge)}</div>` : ""}</div>`;
}
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 `<div class="chunk-blocks">${sorted.map((b) => renderBlockItem(b, docId)).join("")}</div>`;
}
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 `<pre>${escapeHtml(fixed)}</pre>`;
}
}
return `<pre>${escapeHtml(fixed)}</pre>`;
}
function escapeHtml(str) {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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 ? '<span class="type-tag type-tag-heading">子切片</span>' : "";
const parentBadge = chunk.meta?.is_section_parent ? '<span class="type-tag">完整节</span>' : "";
const previewOnlyBadge = chunk.meta?.retrieval === false ? '<span class="type-tag type-tag-preview-only">仅展示</span>' : "";
const snippet = chunk.content.replace(/!\[.*?\]\([^)]+\)/g, "[图片]").slice(0, 160);
btn.innerHTML = `
<div class="chunk-card-header">
<span class="chunk-index">切片 ${chunk.index + 1}</span>
<span class="chunk-meta">${chunk.char_count} 字符${pageHint ? ` · ${pageHint}` : ""}${imageCount ? ` · ${imageCount} 图` : ""}${tableCount ? ` · ${tableCount} 表` : ""}${parentRef}</span>
</div>
<div class="chunk-types">
${subBadge}${parentBadge}${previewOnlyBadge}${types.map((t) => `<span class="${typeTagClass(t)}">${t}</span>`).join("")}
</div>
${heading ? `<div class="chunk-snippet" style="-webkit-line-clamp:1;font-weight:500;color:var(--color-text)">${escapeHtml(heading)}</div>` : ""}
<div class="chunk-snippet">${escapeHtml(snippet)}</div>
`;
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 =
`<iframe class="original-pdf" src="${state.objectUrl}" title="${escapeHtml(file.name)} PDF 原文预览"></iframe>`;
return true;
} catch (err) {
console.warn("Word PDF 预览加载失败:", err);
return false;
}
}
async function renderOriginalPreview(file, docId = null) {
els.originalPreview.innerHTML = '<p class="placeholder">原文加载中…</p>';
els.originalTag.textContent = file.name;
const ext = file.name.split(".").pop()?.toLowerCase() || "";
if (ext === "pdf") {
state.objectUrl = URL.createObjectURL(file);
els.originalPreview.innerHTML = `<iframe class="original-pdf" src="${state.objectUrl}" title="PDF 原文预览"></iframe>`;
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 = `<div class="original-docx">${value}</div>`;
return;
}
if (["doc", "docx"].includes(ext)) {
els.originalPreview.innerHTML =
'<p class="placeholder">Word 原文将在切分完成后以 PDF 方式显示。</p>';
return;
}
if (["jpg", "jpeg", "png", "bmp", "gif"].includes(ext)) {
state.objectUrl = URL.createObjectURL(file);
els.originalPreview.innerHTML = `<img class="original-image" src="${state.objectUrl}" alt="上传的图片" />`;
return;
}
if (["txt", "md", "csv", "json", "xml", "log", "html", "htm"].includes(ext)) {
const text = await file.text();
els.originalPreview.innerHTML = `<pre class="original-text">${escapeHtml(text.slice(0, 50000))}${text.length > 50000 ? "\n\n… (已截断)" : ""}</pre>`;
return;
}
if (["ppt", "pptx", "ppsx"].includes(ext)) {
els.originalPreview.innerHTML =
`<p class="placeholder">演示文稿将先转为 PDF 再解析;原文预览暂不支持,请查看右侧切片结果。</p>`;
return;
}
els.originalPreview.innerHTML = `<p class="placeholder">该格式暂不支持原文预览,请查看右侧切片结果。</p>`;
}
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 = '<p class="placeholder">没有匹配的切片,请尝试更具体或更短的关键词。</p>';
return;
}
els.recallResults.innerHTML = data.results.map((item) => `
<button class="recall-result" type="button" data-chunk-index="${item.chunk_index}">
<span class="recall-rank">#${item.rank}</span>
<span class="recall-result-main">
<strong>${escapeHtml(item.heading || `切片 ${item.chunk_index + 1}`)}</strong>
<span>${escapeHtml(item.content.replace(/!\[.*?\]\([^)]+\)/g, "[图片]").slice(0, 180))}</span>
</span>
<span class="recall-score">${Number(item.score).toFixed(3)}</span>
</button>
`).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();