@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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) => `})`);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user