958 lines
34 KiB
JavaScript
958 lines
34 KiB
JavaScript
/**
|
||
* 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) => `})`);
|
||
}
|
||
|
||
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, "&")
|
||
.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 ? '<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();
|