first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
陈辅元
2026-07-16 11:12:17 +08:00
co-authored by Cursor
commit 4003624b8c
80 changed files with 9990 additions and 0 deletions
+807
View File
@@ -0,0 +1,807 @@
/* RAG-cut Design System — ui-ux-pro-max: Accessible & Ethical + Tech SaaS */
:root {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-secondary: #3b82f6;
--color-cta: #f97316;
--color-bg: #f8fafc;
--color-surface: #ffffff;
--color-border: #e2e8f0;
--color-border-strong: #cbd5e1;
--color-text: #1e293b;
--color-text-muted: #64748b;
--color-text-subtle: #94a3b8;
--color-success: #16a34a;
--color-error: #dc2626;
--color-focus: #2563eb;
--color-chunk-active: #eff6ff;
--color-chunk-border: #93c5fd;
--font-heading: "Space Grotesk", system-ui, sans-serif;
--font-body: "DM Sans", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.08);
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--transition: 180ms ease;
--header-h: 64px;
--sidebar-w: 300px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
*, *::before, *::after { box-sizing: border-box; }
html { font-size: 16px; scroll-behavior: smooth; }
body {
margin: 0;
font-family: var(--font-body);
color: var(--color-text);
background: var(--color-bg);
line-height: 1.5;
min-height: 100dvh;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.skip-link {
position: absolute;
top: -100%;
left: var(--space-4);
z-index: 1000;
padding: var(--space-2) var(--space-4);
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-weight: 600;
}
.skip-link:focus { top: var(--space-2); }
/* Header */
.app-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
height: var(--header-h);
padding: 0 var(--space-5);
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
}
.brand {
display: flex;
align-items: center;
gap: var(--space-3);
}
.brand-icon { color: var(--color-primary); flex-shrink: 0; }
.brand h1 {
margin: 0;
font-family: var(--font-heading);
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.brand-sub {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.badge {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-3);
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 500;
}
.badge-idle { background: #f1f5f9; color: var(--color-text-muted); }
.badge-loading { background: #fef3c7; color: #92400e; }
.badge-success { background: #dcfce7; color: #166534; }
.badge-error { background: #fee2e2; color: var(--color-error); }
/* Layout */
.app-layout {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
min-height: calc(100dvh - var(--header-h));
}
.config-panel {
background: var(--color-surface);
border-right: 1px solid var(--color-border);
padding: var(--space-5);
overflow-y: auto;
max-height: calc(100dvh - var(--header-h));
}
.panel-section { margin-bottom: var(--space-5); }
.panel-section h2 {
margin: 0 0 var(--space-3);
font-family: var(--font-heading);
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
/* Dropzone */
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 140px;
padding: var(--space-5);
border: 2px dashed var(--color-border-strong);
border-radius: var(--radius-lg);
background: var(--color-bg);
cursor: pointer;
transition: border-color var(--transition), background var(--transition);
text-align: center;
}
.dropzone:hover, .dropzone:focus-visible, .dropzone.dragover {
border-color: var(--color-primary);
background: #eff6ff;
outline: none;
}
.dropzone:focus-visible { box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.35); }
.dropzone svg { color: var(--color-text-subtle); }
.dropzone-title { margin: 0; font-weight: 600; font-size: 0.9375rem; }
.dropzone-hint { margin: 0; font-size: 0.8125rem; color: var(--color-text-muted); }
.dropzone-file { margin: var(--space-2) 0 0; font-size: 0.8125rem; color: var(--color-primary); font-weight: 500; word-break: break-all; }
/* Form */
.config-form { display: flex; flex-direction: column; gap: var(--space-3); }
.field { display: flex; flex-direction: column; gap: var(--space-1); }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
.field label { font-size: 0.8125rem; font-weight: 500; color: var(--color-text); }
.field input, .field select {
height: 44px;
padding: 0 var(--space-3);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
font-family: var(--font-body);
font-size: 0.9375rem;
color: var(--color-text);
background: var(--color-surface);
transition: border-color var(--transition), box-shadow var(--transition);
}
.field input:focus, .field select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}
.helper { font-size: 0.75rem; color: var(--color-text-muted); }
.mode-options {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.hidden { display: none !important; }
.auto-policy {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: #f8fafc;
}
.auto-policy-kicker {
display: inline-flex;
width: fit-content;
padding: 2px 6px;
border-radius: 4px;
background: #dbeafe;
color: #1e40af;
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0;
}
.auto-policy-title {
margin: var(--space-2) 0 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.auto-policy-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2);
margin: 0;
}
.auto-policy-grid div {
min-width: 0;
padding: var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
}
.auto-policy-grid dt {
margin: 0;
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.auto-policy-grid dd {
margin: 2px 0 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text);
word-break: break-word;
}
/* Button */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 44px;
padding: 0 var(--space-4);
border: none;
border-radius: var(--radius-sm);
font-family: var(--font-body);
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition), opacity var(--transition), transform 80ms ease;
}
.btn:active:not(:disabled) { transform: scale(0.98); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--color-primary); color: #fff; width: 100%; }
.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.4); }
.btn-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
margin: 0;
}
.stats-grid div {
padding: var(--space-3);
background: var(--color-bg);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
}
.stats-grid dt { margin: 0; font-size: 0.75rem; color: var(--color-text-muted); }
.stats-grid dd { margin: var(--space-1) 0 0; font-family: var(--font-heading); font-size: 1.125rem; font-weight: 600; }
.mono { font-family: var(--font-mono); font-size: 0.75rem !important; word-break: break-all; }
/* History */
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.history-header h2 { margin: 0; }
.btn-ghost {
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
width: auto;
min-height: 32px;
padding: 0 var(--space-3);
font-size: 0.8125rem;
font-weight: 500;
}
.btn-ghost:hover:not(:disabled) {
background: var(--color-bg);
color: var(--color-text);
border-color: var(--color-border-strong);
}
.btn-sm { min-height: 32px; }
.history-hint {
margin: 0 0 var(--space-2);
font-size: 0.75rem;
color: var(--color-text-muted);
}
.history-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
max-height: 280px;
overflow-y: auto;
}
.history-item {
display: flex;
align-items: stretch;
gap: var(--space-1);
width: 100%;
padding: var(--space-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
transition: border-color var(--transition), background var(--transition);
}
.history-item:hover {
border-color: var(--color-chunk-border);
background: var(--color-chunk-active);
}
.history-item.active {
border-color: var(--color-primary);
background: var(--color-chunk-active);
box-shadow: inset 3px 0 0 var(--color-primary);
}
.history-item-load {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: var(--space-2);
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
font-family: var(--font-body);
color: inherit;
}
.history-item-delete {
flex-shrink: 0;
align-self: center;
margin-right: var(--space-1);
min-height: 28px;
padding: 0 var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-muted);
font-family: var(--font-body);
font-size: 0.75rem;
cursor: pointer;
transition: color var(--transition), border-color var(--transition), background var(--transition);
}
.history-item-delete:hover {
color: #b42318;
border-color: #fecdca;
background: #fef3f2;
}
.history-item-name {
font-size: 0.8125rem;
font-weight: 600;
word-break: break-all;
line-height: 1.35;
}
.history-item-meta {
font-size: 0.6875rem;
color: var(--color-text-muted);
font-family: var(--font-mono);
}
/* Workspace */
.workspace {
padding: var(--space-5);
overflow-y: auto;
max-height: calc(100dvh - var(--header-h));
display: flex;
flex-direction: column;
gap: var(--space-4);
min-height: 0;
}
.workspace:has(#workspacePane:not(.hidden)) {
overflow: hidden;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-6);
color: var(--color-text-muted);
}
.empty-state svg { color: var(--color-border-strong); margin-bottom: var(--space-4); }
.empty-state h2 { margin: 0 0 var(--space-2); font-family: var(--font-heading); color: var(--color-text); }
.empty-state p { max-width: 420px; margin: 0; }
/* Workspace panes: original | chunk list | preview */
.workspace-pane {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 0.95fr) minmax(0, 1.15fr);
gap: var(--space-4);
flex: 1;
min-height: 0;
}
.pane {
display: flex;
flex-direction: column;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
min-height: 0;
max-height: 100%;
}
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
}
.pane-header h2 {
margin: 0;
font-family: var(--font-heading);
font-size: 0.9375rem;
font-weight: 600;
}
.pane-tag {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-muted);
background: var(--color-surface);
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--color-border);
}
.pane-body {
flex: 1;
overflow: auto;
padding: var(--space-4);
min-height: 0;
}
.placeholder { color: var(--color-text-muted); font-size: 0.9375rem; }
/* Original preview */
.original-docx { font-size: 0.9375rem; line-height: 1.6; }
.original-docx img { max-width: 100%; height: auto; border-radius: var(--radius-sm); }
.original-pdf { width: 100%; height: 100%; min-height: 240px; border: none; }
.original-text {
font-family: var(--font-mono);
font-size: 0.8125rem;
white-space: pre-wrap;
word-break: break-word;
color: var(--color-text);
}
.original-image { max-width: 100%; border-radius: var(--radius-sm); }
/* Chunk list */
.chunk-list { padding: var(--space-2); display: flex; flex-direction: column; gap: var(--space-2); }
.chunk-card {
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
cursor: pointer;
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
text-align: left;
width: 100%;
font-family: inherit;
}
.chunk-card:hover {
border-color: var(--color-chunk-border);
background: #f8fafc;
}
.chunk-card:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
}
.chunk-card.active {
border-color: var(--color-primary);
background: var(--color-chunk-active);
box-shadow: var(--shadow-sm);
}
.chunk-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.chunk-index {
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.875rem;
color: var(--color-primary);
}
.chunk-meta { font-size: 0.75rem; color: var(--color-text-muted); }
.chunk-types {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.type-tag {
font-size: 0.6875rem;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: #f1f5f9;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.type-tag-heading { background: #dbeafe; color: #1e40af; }
.type-tag-image { background: #fce7f3; color: #9d174d; }
.type-tag-table { background: #d1fae5; color: #065f46; }
.type-tag-preview-only { background: #fef3c7; color: #92400e; }
.chunk-snippet {
font-size: 0.8125rem;
color: var(--color-text-muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Markdown preview (right pane) */
.pane-preview .markdown-body {
font-size: 0.9375rem;
line-height: 1.65;
max-width: 75ch;
}
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
font-family: var(--font-heading);
margin: 1.25em 0 0.5em;
line-height: 1.3;
}
.markdown-body h1 { font-size: 1.375rem; }
.markdown-body h2 { font-size: 1.125rem; }
.markdown-body h3 { font-size: 1rem; }
.markdown-body p { margin: 0.75em 0; }
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
margin: var(--space-3) 0;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
margin: var(--space-3) 0;
}
.markdown-body th, .markdown-body td {
border: 1px solid var(--color-border);
padding: var(--space-2) var(--space-3);
text-align: left;
}
.markdown-body th { background: var(--color-bg); font-weight: 600; }
.markdown-body code {
font-family: var(--font-mono);
font-size: 0.85em;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
}
/* Structured chunk blocks — images at original layout positions */
.chunk-blocks {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.chunk-block-image {
margin: 0;
padding: var(--space-3);
background: #f8fafc;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.chunk-block-image img {
display: block;
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
margin: var(--space-2) 0;
}
.chunk-block-image .block-context {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-primary);
margin-bottom: var(--space-2);
}
.chunk-block-image .ocr-caption {
font-size: 0.8125rem;
color: var(--color-text-muted);
margin-top: var(--space-2);
line-height: 1.45;
}
.block-meta {
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-text-subtle);
margin-top: var(--space-1);
}
.chunk-block-heading {
margin: var(--space-2) 0 var(--space-1);
font-family: var(--font-heading);
}
.chunk-block-paragraph p {
margin: 0;
line-height: 1.6;
}
.chunk-block-table {
overflow-x: auto;
padding: var(--space-3);
background: #f8fafc;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.chunk-block-table .table-screenshot {
margin: 0 0 var(--space-3);
}
.chunk-block-table .table-screenshot img {
display: block;
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
}
.chunk-block-table .table-markdown {
overflow-x: auto;
}
.chunk-block-table .table-footnote {
margin-top: var(--space-2);
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.api-panel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.recall-panel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.recall-panel summary {
cursor: pointer;
font-weight: 600;
font-family: var(--font-heading);
}
.recall-form {
display: grid;
grid-template-columns: minmax(240px, 1fr) 100px 140px;
align-items: end;
gap: var(--space-3);
margin-top: var(--space-4);
}
.recall-form .field span { font-size: 0.8125rem; font-weight: 500; }
.recall-submit { width: auto; }
.recall-hint { margin: var(--space-3) 0; font-size: 0.8125rem; color: var(--color-text-muted); }
.recall-results { display: flex; flex-direction: column; gap: var(--space-2); }
.recall-result {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) 64px;
gap: var(--space-3);
align-items: start;
width: 100%;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
text-align: left;
cursor: pointer;
}
.recall-result:hover { border-color: var(--color-primary); background: #f8fafc; }
.recall-result-main { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
.recall-result-main span { color: var(--color-text-muted); font-size: 0.8125rem; line-height: 1.4; }
.recall-rank, .recall-score { font-family: var(--font-mono); font-size: 0.75rem; color: var(--color-primary); }
.recall-score { text-align: right; }
.api-panel summary {
cursor: pointer;
font-weight: 600;
font-family: var(--font-heading);
padding: var(--space-1) 0;
}
.api-panel summary:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.api-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
margin-top: var(--space-4);
}
.api-grid h3 { margin: 0 0 var(--space-2); font-size: 0.8125rem; color: var(--color-text-muted); text-transform: uppercase; }
.code-block {
margin: 0;
padding: var(--space-3);
background: #0f172a;
color: #e2e8f0;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.5;
overflow: auto;
max-height: 280px;
white-space: pre-wrap;
word-break: break-word;
}
/* Responsive */
@media (max-width: 1024px) {
.app-layout { grid-template-columns: 1fr; }
.config-panel {
max-height: none;
border-right: none;
border-bottom: 1px solid var(--color-border);
}
.workspace-pane { grid-template-columns: 1fr; height: auto; }
.workspace-pane .pane { max-height: 420px; }
.api-grid { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.app-header { padding: 0 var(--space-4); }
.workspace { padding: var(--space-4); }
.field-row { grid-template-columns: 1fr; }
.recall-form { grid-template-columns: 1fr; }
}
+268
View File
@@ -0,0 +1,268 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="api-base" content="http://127.0.0.1:8000" />
<title>RAG-cut — 文档切片演示</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/styles.css" />
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mammoth@1.8.0/mammoth.browser.min.js"></script>
</head>
<body>
<a class="skip-link" href="#main-content">跳到主要内容</a>
<header class="app-header">
<div class="brand">
<svg class="brand-icon" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/>
<line x1="8" y1="17" x2="13" y2="17"/>
</svg>
<div>
<h1>RAG-cut</h1>
<p class="brand-sub">AI 知识库 · 文档切片演示</p>
</div>
</div>
<div class="header-meta" id="statusBadge" aria-live="polite">
<span class="badge badge-idle">等待上传</span>
</div>
</header>
<div class="app-layout">
<aside class="config-panel" aria-label="切分配置">
<section class="panel-section">
<h2>上传文档</h2>
<div
class="dropzone"
id="dropzone"
tabindex="0"
role="button"
aria-label="点击或拖拽上传文档"
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<p class="dropzone-title">拖拽文件到此处</p>
<p class="dropzone-hint">或点击选择 · pdf docx ppt pptx xlsx md txt 等</p>
<p class="dropzone-file" id="fileName"></p>
</div>
<input type="file" id="fileInput" class="sr-only" accept=".pdf,.doc,.docx,.ppt,.pptx,.ppsx,.xlsx,.xls,.csv,.md,.txt,.html,.json,.xml,.log,.jpg,.jpeg,.png,.bmp,.gif" />
</section>
<section class="panel-section">
<h2>切分设置</h2>
<form id="configForm" class="config-form" novalidate>
<div class="field">
<label for="splitMode">切分模式</label>
<select id="splitMode" name="mode">
<option value="default" selected>默认切分(结构感知)</option>
<option value="delimiter">通用标识符切分</option>
<option value="parent_child">父子标识符切分</option>
<option value="by_row">按行切分(表格)</option>
</select>
<p class="helper" id="modeHint">按标题/结构切分,适合 PDF、Word、PPT 等</p>
</div>
<div class="mode-options" id="modeOptionsDefault">
<div class="field-row">
<div class="field">
<label for="maxChunkSize">最大长度</label>
<input type="number" id="maxChunkSize" name="max_chunk_size" min="200" max="8000" step="100" value="2200" />
</div>
<div class="field">
<label for="overlap">重叠长度</label>
<input type="number" id="overlap" name="overlap" min="0" max="2000" step="10" value="120" />
</div>
</div>
</div>
<div class="mode-options hidden" id="modeOptionsDelimiter">
<div class="field">
<label for="delimiter">标识符</label>
<input type="text" id="delimiter" name="delimiter" value="###" placeholder="例如 ### 或 \\n\\n" />
<p class="helper">按该标识符切开;标识符本身不会写入切片</p>
</div>
<div class="field-row">
<div class="field">
<label for="delimiterMaxSize">最大长度</label>
<input type="number" id="delimiterMaxSize" name="delimiter_max_chunk_size" min="200" max="8000" step="100" value="1500" />
</div>
<div class="field">
<label for="delimiterOverlap">重叠长度</label>
<input type="number" id="delimiterOverlap" name="delimiter_overlap" min="0" max="2000" step="10" value="150" />
</div>
</div>
</div>
<div class="mode-options hidden" id="modeOptionsParentChild">
<div class="field">
<label for="parentDelimiter">父级标识符</label>
<input type="text" id="parentDelimiter" name="parent_delimiter" value="##" placeholder="例如 ##" />
</div>
<div class="field">
<label for="childDelimiter">子级标识符</label>
<input type="text" id="childDelimiter" name="child_delimiter" value="###" placeholder="例如 ### 或 \\n\\n" />
<p class="helper">先按父标识符切父片,再按子标识符切子片;检索用子片,父片作上下文</p>
</div>
<div class="field-row">
<div class="field">
<label for="parentMaxSize">父级最大长度</label>
<input type="number" id="parentMaxSize" name="parent_max_chunk_size" min="200" max="8000" step="100" value="2000" />
</div>
<div class="field">
<label for="childMaxSize">子级最大长度</label>
<input type="number" id="childMaxSize" name="child_max_size" min="50" max="1500" step="50" value="512" />
</div>
</div>
<div class="field">
<label for="parentChildOverlap">重叠长度</label>
<input type="number" id="parentChildOverlap" name="parent_child_overlap" min="0" max="2000" step="10" value="80" />
</div>
</div>
<div class="mode-options hidden" id="modeOptionsByRow">
<div class="field-row">
<div class="field">
<label for="headerRowStart">表头起始行</label>
<input type="number" id="headerRowStart" name="header_row_start" min="1" value="1" />
</div>
<div class="field">
<label for="headerRowEnd">表头结束行</label>
<input type="number" id="headerRowEnd" name="header_row_end" min="1" value="1" />
</div>
</div>
<div class="field-row">
<div class="field">
<label for="startRow">数据起始行</label>
<input type="number" id="startRow" name="start_row" min="1" value="2" />
</div>
<div class="field">
<label for="rowsPerChunk">每片行数</label>
<input type="number" id="rowsPerChunk" name="rows_per_chunk" min="1" max="500" value="10" />
</div>
</div>
</div>
<div class="auto-policy" id="autoPolicy">
<div>
<span class="auto-policy-kicker" id="policyKicker">AUTO</span>
<p class="auto-policy-title" id="policyTitle">系统根据上传文档自动选择切分参数</p>
</div>
<dl class="auto-policy-grid" id="autoPolicyGrid">
<div><dt>模式</dt><dd>待上传后判断</dd></div>
<div><dt>长度</dt><dd>自动</dd></div>
<div><dt>重叠</dt><dd>自动</dd></div>
<div><dt>策略</dt><dd>自动判断</dd></div>
</dl>
</div>
<button type="submit" class="btn btn-primary" id="submitBtn" disabled title="请先上传文档">
<span class="btn-label">开始切分</span>
<span class="btn-spinner hidden" aria-hidden="true"></span>
</button>
</form>
</section>
<section class="panel-section history-section" aria-label="历史切片">
<div class="history-header">
<h2>历史切片</h2>
<button type="button" class="btn btn-ghost btn-sm" id="historyRefreshBtn" title="刷新列表">刷新</button>
</div>
<p class="history-hint" id="historyHint">加载中…</p>
<div class="history-list" id="historyList" role="listbox" aria-label="历史切片列表"></div>
</section>
<section class="panel-section stats-section hidden" id="statsSection">
<h2>切分概览</h2>
<dl class="stats-grid">
<div><dt>Blocks</dt><dd id="statBlocks">—</dd></div>
<div><dt>Chunks</dt><dd id="statChunks">—</dd></div>
<div><dt>模式</dt><dd id="statMode">—</dd></div>
<div><dt>Doc ID</dt><dd id="statDocId" class="mono">—</dd></div>
</dl>
</section>
</aside>
<main class="workspace" id="main-content">
<div class="empty-state" id="emptyState">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18M9 21V9"/>
</svg>
<h2>上传文档查看切片效果</h2>
<p>支持 Word / PDF / Excel / Markdown 等格式。左侧原文、中间切片列表、右侧切片预览。</p>
</div>
<div class="workspace-pane hidden" id="workspacePane">
<section class="pane pane-original" aria-label="原文预览">
<div class="pane-header">
<h2>查看原文</h2>
<span class="pane-tag" id="originalTag"></span>
</div>
<div class="pane-body" id="originalPreview">
<p class="placeholder">原文加载中…</p>
</div>
</section>
<section class="pane pane-chunks" aria-label="切片列表">
<div class="pane-header">
<h2>管理切片</h2>
<span class="pane-tag" id="chunkCountTag"></span>
</div>
<div class="pane-body chunk-list" id="chunkList" role="listbox" aria-label="切片列表"></div>
</section>
<section class="pane pane-preview" id="previewPanel" aria-label="选中切片预览">
<div class="pane-header">
<h2>切片预览</h2>
<span class="pane-tag" id="selectedChunkTag">未选择</span>
</div>
<div class="pane-body markdown-body" id="chunkPreview">
<p class="placeholder">点击中间列表中的切片查看 Markdown 渲染结果</p>
</div>
</section>
</div>
<details class="recall-panel hidden" id="recallPanel">
<summary>召回测试</summary>
<form class="recall-form" id="recallForm">
<label class="field recall-query">
<span>查询内容</span>
<input id="recallQuery" type="search" maxlength="500" placeholder="输入要检索的问题或关键词" required />
</label>
<label class="field recall-top-k">
<span>Top K</span>
<input id="recallTopK" type="number" min="1" max="20" value="5" required />
</label>
<button class="btn btn-primary recall-submit" id="recallSubmit" type="submit">开始召回</button>
</form>
<p class="recall-hint" id="recallHint">仅检索有语义价值的切片,“仅展示”切片不会进入候选集。</p>
<div class="recall-results" id="recallResults"></div>
</details>
<details class="api-panel hidden" id="apiPanel">
<summary>API 请求 / 响应</summary>
<div class="api-grid">
<div>
<h3>Request</h3>
<pre id="apiRequest" class="code-block"></pre>
</div>
<div>
<h3>Response</h3>
<pre id="apiResponse" class="code-block"></pre>
</div>
</div>
</details>
</main>
</div>
<script src="/js/app.js"></script>
</body>
</html>
+957
View File
@@ -0,0 +1,957 @@
/**
* 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();
+39
View File
@@ -0,0 +1,39 @@
"""Serve RAG-cut frontend static files (development mode).
Requires the API backend to be running separately:
python backend/run.py
"""
from __future__ import annotations
import argparse
import http.server
import socketserver
from pathlib import Path
FRONTEND_DIR = Path(__file__).resolve().parent
DEFAULT_API = "http://127.0.0.1:8000"
class FrontendHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(FRONTEND_DIR), **kwargs)
def main() -> None:
parser = argparse.ArgumentParser(description="Start RAG-cut frontend dev server")
parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=5173, help="Bind port (default: 5173)")
args = parser.parse_args()
url = f"http://{args.host}:{args.port}"
socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer((args.host, args.port), FrontendHandler) as httpd:
print(f"Frontend: {url}")
print(f"API: {DEFAULT_API} (start with: python backend/run.py)")
print("Press Ctrl+C to stop.")
httpd.serve_forever()
if __name__ == "__main__":
main()