first commit
This commit is contained in:
@@ -0,0 +1,675 @@
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #1c2333;
|
||||
--bg-card: #1a1f2e;
|
||||
--bg-hover: #252d3a;
|
||||
--bg-input: #0d1117;
|
||||
--border-color: #30363d;
|
||||
--border-active: #58a6ff;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #484f58;
|
||||
--accent-blue: #58a6ff;
|
||||
--accent-green: #3fb950;
|
||||
--accent-purple: #a371f7;
|
||||
--accent-orange: #f0883e;
|
||||
--accent-red: #f85149;
|
||||
--accent-yellow: #d29922;
|
||||
--accent-cyan: #39d2c0;
|
||||
--sidebar-width: 320px;
|
||||
--toolbar-height: 44px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar ───────────────────────────────────────────*/
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.sidebar-stats {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar-actions { margin-top: 8px; }
|
||||
|
||||
.skill-toggles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.skill-toggle {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.skill-toggle:hover { border-color: var(--border-color); color: var(--text-primary); }
|
||||
.skill-toggle input { display: none; }
|
||||
.skill-toggle input:checked + span,
|
||||
.skill-toggle:has(input:checked) {
|
||||
background: var(--accent-blue);
|
||||
color: #fff;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
textarea, input[type="text"], input[type="number"], select {
|
||||
width: 100%;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
textarea:focus, input:focus, select:focus { border-color: var(--border-active); }
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn:hover { background: var(--bg-hover); border-color: var(--text-muted); }
|
||||
.btn-primary { background: var(--accent-blue); color: #fff; border-color: var(--accent-blue); }
|
||||
.btn-primary:hover { opacity: 0.85; }
|
||||
.btn-secondary { background: var(--bg-tertiary); }
|
||||
.btn-small { padding: 4px 10px; font-size: 11px; }
|
||||
|
||||
.upload-label .btn { font-size: 11px; }
|
||||
|
||||
.doc-list { margin-top: 6px; }
|
||||
|
||||
.doc-item, .session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.1s;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.doc-item:hover, .session-item:hover { background: var(--bg-hover); }
|
||||
.doc-item.active, .session-item.active { background: var(--bg-tertiary); border-left-color: var(--accent-blue); }
|
||||
|
||||
.doc-item .file-icon { font-size: 14px; flex-shrink: 0; }
|
||||
.doc-item .file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.doc-item .file-status { font-size: 10px; padding: 1px 5px; border-radius: 4px; flex-shrink: 0; }
|
||||
.doc-item .file-status.indexed { background: rgba(63,185,80,0.15); color: var(--accent-green); }
|
||||
.doc-item .file-status.processing { background: rgba(240,136,62,0.15); color: var(--accent-orange); }
|
||||
|
||||
.doc-item .file-remove {
|
||||
width: 16px; height: 16px;
|
||||
background: none; border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.doc-item .file-remove:hover { color: var(--accent-red); }
|
||||
|
||||
.sidebar-footer { padding: 12px; margin-top: auto; }
|
||||
.health-indicator {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 11px; color: var(--text-secondary);
|
||||
}
|
||||
.health-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--accent-red);
|
||||
}
|
||||
.health-dot.healthy { background: var(--accent-green); animation: pulse 2s infinite; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ── Main Content ───────────────────────────────────────*/
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
height: var(--toolbar-height);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-tabs { display: flex; gap: 2px; }
|
||||
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text-primary); }
|
||||
.tab.active { color: var(--accent-blue); border-bottom-color: var(--accent-blue); }
|
||||
|
||||
.tab-panes { flex: 1; overflow: hidden; position: relative; }
|
||||
.tab-pane { display: none; height: calc(100vh - var(--toolbar-height)); overflow-y: auto; padding: 16px; }
|
||||
.tab-pane.active { display: block; }
|
||||
|
||||
/* ── Analysis ─────────────────────────────────────────────────*/
|
||||
|
||||
.analysis-container { height: 100%; }
|
||||
|
||||
.research-output {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.research-section { margin-bottom: 20px; }
|
||||
.research-section h3 { color: var(--accent-blue); font-size: 14px; margin-bottom: 8px; }
|
||||
.research-section h4 { color: var(--accent-purple); font-size: 13px; margin: 12px 0 6px; }
|
||||
.research-section p { margin-bottom: 8px; color: var(--text-primary); }
|
||||
|
||||
.research-section .citation {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
cursor: pointer;
|
||||
margin: 2px;
|
||||
}
|
||||
.research-section .citation:hover { border-color: var(--accent-blue); }
|
||||
|
||||
.research-section ul { padding-left: 18px; margin: 6px 0; }
|
||||
.research-section li { margin: 3px 0; }
|
||||
.research-section li::marker { color: var(--accent-blue); }
|
||||
|
||||
.research-section table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.research-section th { text-align: left; padding: 6px 10px; background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border-color); font-weight: 600; font-size: 11px; }
|
||||
.research-section td { padding: 5px 10px; border: 1px solid var(--border-color); color: var(--text-primary); }
|
||||
|
||||
.research-section .block {
|
||||
background: var(--bg-tertiary);
|
||||
border-left: 3px solid var(--accent-blue);
|
||||
padding: 10px 14px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.empty-icon { font-size: 40px; margin-bottom: 12px; opacity: 0.5; }
|
||||
.empty-text { font-size: 14px; }
|
||||
|
||||
/* ── Viewer ─────────────────────────────────────────────────*/
|
||||
|
||||
.viewer-container { height: 100%; display: flex; flex-direction: column; }
|
||||
|
||||
#viewer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#viewer-doc-name { font-weight: 600; color: var(--accent-blue); font-size: 14px; }
|
||||
#viewer-page-info { color: var(--text-secondary); font-size: 12px; }
|
||||
|
||||
.viewer-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.page-input {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.viewer-toggle {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.viewer-toggle label {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.viewer-toggle input { cursor: pointer; }
|
||||
|
||||
.polygon-viewer {
|
||||
flex: 1;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.polygon-svg, .polygon-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ── Search ────────────────────────────────────────────*/
|
||||
|
||||
.search-container { max-width: 800px; }
|
||||
|
||||
.search-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-input { flex: 1; }
|
||||
.search-type-select { width: 120px; }
|
||||
|
||||
.search-results-list { }
|
||||
|
||||
.search-result-item {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.search-result-item:hover {
|
||||
border-color: var(--accent-blue);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.search-result-item .result-score { font-size: 11px; color: var(--accent-blue); margin-bottom: 4px; }
|
||||
.search-result-item .result-content { font-size: 13px; color: var(--text-primary); }
|
||||
.search-result-item .result-content mark { background: var(--accent-orange); color: #000; }
|
||||
.search-result-item .result-meta { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
/* ── Memories ─────────────────────────────────────────*/
|
||||
|
||||
.memories-container { max-width: 800px; }
|
||||
.memories-container h3 { color: var(--accent-purple); margin-bottom: 12px; }
|
||||
|
||||
.memories-list { }
|
||||
|
||||
.memory-item {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 3px solid var(--accent-purple);
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.memory-item .memory-type { font-size: 10px; color: var(--accent-orange); font-weight: 600; }
|
||||
.memory-item .memory-content { margin-top: 4px; color: var(--text-primary); }
|
||||
|
||||
/* ── Findings ────────────────────────────────────────────*/
|
||||
|
||||
.findings-container { max-width: 800px; }
|
||||
.findings-container h3 { color: var(--accent-cyan); margin-bottom: 12px; }
|
||||
|
||||
.findings-list { }
|
||||
.finding-item {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.finding-item .finding-agent { font-size: 11px; color: var(--accent-purple); font-weight: 600; }
|
||||
.finding-item .finding-query { font-size: 12px; color: var(--text-secondary); margin: 4px 0; }
|
||||
.finding-item .finding-answer { font-size: 13px; color: var(--text-primary); margin-top: 6px; }
|
||||
|
||||
/* ── Chat Overlay ───────────────────────────────────────
|
||||
|
||||
.chat-overlay {
|
||||
position: fixed;
|
||||
top: 0; right: 0;
|
||||
width: 420px;
|
||||
height: 100vh;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border-color);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-blue);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.chat-header .btn-close {
|
||||
background: none; border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.chat-header .btn-close:hover { color: var(--text-primary); }
|
||||
|
||||
.chat-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.chat-message.sender { margin-left: 32px; }
|
||||
.chat-message.received { margin-right: 32px; }
|
||||
.chat-message .msg-label { font-size: 10px; font-weight: 600; margin-bottom: 2px; }
|
||||
.chat-message.sender .msg-label { color: var(--accent-purple); }
|
||||
.chat-message.received .msg-label { color: var(--accent-cyan); }
|
||||
|
||||
.chat-input-row {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* ── Loading ──────────────────────────────────────
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px; height: 16px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent-blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.typing { color: var(--text-muted); font-style: italic; font-size: 12px; }
|
||||
|
||||
/* ── Pipeline ─────────────────────────────────────*/
|
||||
|
||||
.mode-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mode-toggle input { cursor: pointer; margin: 0; }
|
||||
|
||||
.mode-select {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pipeline-container { max-width: 960px; }
|
||||
|
||||
.pipeline-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pipeline-header-row h3 { color: var(--accent-green); margin: 0; }
|
||||
.pipeline-controls { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.pipeline-output { }
|
||||
|
||||
.pipeline-stage-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pipeline-stage-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.pipeline-stage-header:hover { background: var(--bg-hover); }
|
||||
.pipeline-stage-header .stage-title { font-size: 13px; font-weight: 600; }
|
||||
.pipeline-stage-header .stage-status { font-size: 11px; padding: 2px 8px; border-radius: 4px; }
|
||||
.pipeline-stage-header .stage-status.complete { background: rgba(63,185,80,0.15); color: var(--accent-green); }
|
||||
.pipeline-stage-header .stage-body { padding: 14px 16px; font-size: 13px; color: var(--text-primary); max-height: 800px; overflow-y: auto; }
|
||||
.pipeline-stage-header .stage-body.collapsed { display: none; }
|
||||
|
||||
.pipeline-section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-blue);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
|
||||
.pipeline-section-content {
|
||||
padding: 6px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.evidence-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.evidence-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.evidence-table td {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
vertical-align: top;
|
||||
}
|
||||
.evidence-table td.confidence-high { color: var(--accent-green); }
|
||||
.evidence-table td.confidence-medium { color: var(--accent-yellow); }
|
||||
.evidence-table td.confidence-low { color: var(--accent-red); }
|
||||
|
||||
.synthesis-card {
|
||||
background: var(--bg-tertiary);
|
||||
border-left: 3px solid var(--accent-green);
|
||||
padding: 12px 16px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.synthesis-card h4 { color: var(--accent-green); font-size: 14px; margin: 0 0 8px; }
|
||||
|
||||
.gap-card {
|
||||
background: var(--bg-tertiary);
|
||||
border-left: 3px solid var(--accent-orange);
|
||||
padding: 10px 14px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.gap-card .gap-topic { font-weight: 600; color: var(--accent-orange); font-size: 12px; }
|
||||
.gap-card .gap-detail { font-size: 12px; color: var(--text-primary); margin-top: 4px; }
|
||||
|
||||
.matrix-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.matrix-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
}
|
||||
.matrix-table td {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Agentic Research</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Sidebar -->
|
||||
<div id="sidebar" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2 class="sidebar-title">Agentic Research</h2>
|
||||
<div class="sidebar-stats" id="sidebar-stats"></div>
|
||||
</div>
|
||||
|
||||
<!-- Query -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-label">Research Query</div>
|
||||
<textarea id="query-input" placeholder="Enter your research question..." rows="3"></textarea>
|
||||
<div class="sidebar-actions">
|
||||
<div class="mode-section">
|
||||
<label class="mode-toggle">
|
||||
<input type="checkbox" id="pipeline-mode">
|
||||
<span>Pipeline Mode</span>
|
||||
</label>
|
||||
<select id="pipeline-output-mode" class="mode-select" style="display:none">
|
||||
<option value="">Auto (detect)</option>
|
||||
<option value="brief">Brief</option>
|
||||
<option value="report">Report</option>
|
||||
<option value="gap">Gap Analysis</option>
|
||||
<option value="matrix">Matrix</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="skill-toggles" id="skill-toggles">
|
||||
<label class="skill-toggle"><input type="checkbox" value="researcher" checked> Research</label>
|
||||
<label class="skill-toggle"><input type="checkbox" value="extractor"> Extract</label>
|
||||
<label class="skill-toggle"><input type="checkbox" value="summarizer"> Summarize</label>
|
||||
<label class="skill-toggle"><input type="checkbox" value="qa_agent"> QA</label>
|
||||
<label class="skill-toggle"><input type="checkbox" value="critic"> Critique</label>
|
||||
<label class="skill-toggle"><input type="checkbox" value="comparator"> Compare</label>
|
||||
<label class="skill-toggle"><input type="checkbox" value="aggregator"> Aggregate</label>
|
||||
<label class="skill-toggle"><input type="checkbox" value="context_agent"> Context</label>
|
||||
</div>
|
||||
</div>
|
||||
<button id="run-research" class="btn btn-primary">Run Research</button>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">
|
||||
<span class="sidebar-label">Documents</span>
|
||||
<label class="upload-label">
|
||||
<input type="file" id="doc-upload" accept=".pdf,.txt,.md,.doc" style="display:none">
|
||||
<span class="btn btn-secondary">Upload</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="doc-list" class="doc-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Sessions -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">
|
||||
<span class="sidebar-label">Sessions</span>
|
||||
</div>
|
||||
<div id="session-list" class="doc-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Models -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">
|
||||
<span class="sidebar-label">Models</span>
|
||||
<button id="check-models" class="btn btn-small">Check</button>
|
||||
</div>
|
||||
<div id="model-list" class="doc-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div id="health-indicator" class="health-indicator"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div id="main" class="main-content">
|
||||
<div id="main-toolbar" class="toolbar">
|
||||
<div class="toolbar-tabs" id="main-tabs">
|
||||
<button class="tab active" data-tab="analysis">Analysis</button>
|
||||
<button class="tab" data-tab="pipeline">Pipeline</button>
|
||||
<button class="tab" data-tab="viewer">Document Viewer</button>
|
||||
<button class="tab" data-tab="search">Search</button>
|
||||
<button class="tab" data-tab="memories">Memories</button>
|
||||
<button class="tab" data-tab="findings">Findings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div id="tab-content" class="tab-panes">
|
||||
<!-- Analysis Tab -->
|
||||
<div id="tab-analysis" class="tab-pane active">
|
||||
<div class="analysis-container">
|
||||
<div id="research-output" class="research-output">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚙</div>
|
||||
<div class="empty-text">Run a research query to see results</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Viewer Tab -->
|
||||
<div id="tab-viewer" class="tab-pane">
|
||||
<div class="viewer-container">
|
||||
<div id="viewer-toolbar">
|
||||
<span id="viewer-doc-name">No document selected</span>
|
||||
<span id="viewer-page-info">Page 0 / 0</span>
|
||||
<div class="viewer-controls">
|
||||
<button id="viewer-prev" class="btn btn-small">◀ Prev</button>
|
||||
<input id="viewer-page-input" type="number" min="0" value="0" class="page-input">
|
||||
<button id="viewer-next" class="btn btn-small">Next ▶</button>
|
||||
</div>
|
||||
<div class="viewer-toggle">
|
||||
<label><input type="checkbox" id="show-polygons" checked> Polygons</label>
|
||||
<label><input type="checkbox" id="show-text-layers" checked> Text</label>
|
||||
<label><input type="checkbox" id="show-chunk-labels" checked> Chunk labels</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="polygon-viewer" class="polygon-viewer">
|
||||
<svg id="polygon-svg" class="polygon-svg"></svg>
|
||||
<canvas id="polygon-canvas" class="polygon-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Tab -->
|
||||
<div id="tab-search" class="tab-pane">
|
||||
<div class="search-container">
|
||||
<div class="search-input-row">
|
||||
<input id="search-query" type="text" placeholder="Search documents (text or semantic)..." class="search-input">
|
||||
<select id="search-type" class="search-type-select">
|
||||
<option value="semantic">Semantic</option>
|
||||
<option value="text">Text</option>
|
||||
</select>
|
||||
<button id="execute-search" class="btn btn-primary">Search</button>
|
||||
</div>
|
||||
<div id="search-results" class="search-results-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memories Tab -->
|
||||
<div id="tab-memories" class="tab-pane">
|
||||
<div class="memories-container">
|
||||
<div class="memories-section">
|
||||
<h3>Saved Memories</h3>
|
||||
<div id="memories-list" class="memories-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Findings Tab -->
|
||||
<div id="tab-findings" class="tab-pane">
|
||||
<div class="findings-container">
|
||||
<h3>Research Findings</h3>
|
||||
<div id="findings-list" class="findings-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline Tab -->
|
||||
<div id="tab-pipeline" class="tab-pane">
|
||||
<div class="pipeline-container">
|
||||
<div class="pipeline-header-row">
|
||||
<h3>Research Pipeline</h3>
|
||||
<div class="pipeline-controls">
|
||||
<select id="pipeline-output-mode-tab" class="mode-select">
|
||||
<option value="">Auto (detect)</option>
|
||||
<option value="brief">Brief</option>
|
||||
<option value="report">Report</option>
|
||||
<option value="gap">Gap Analysis</option>
|
||||
<option value="matrix">Matrix</option>
|
||||
</select>
|
||||
<button id="rebuild-pipeline" class="btn btn-secondary">Rebuild Pipeline</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pipeline-output" class="pipeline-output">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚙</div>
|
||||
<div class="empty-text">Run a research query in Pipeline mode to see staged results here</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat overlay -->
|
||||
<div id="chat-overlay" class="chat-overlay" style="display:none">
|
||||
<div class="chat-window">
|
||||
<div class="chat-header">
|
||||
<span>Agent Chat</span>
|
||||
<button class="btn-close" id="chat-close">×</button>
|
||||
</div>
|
||||
<div class="chat-body" id="chat-body"></div>
|
||||
<div class="chat-input-row">
|
||||
<input id="chat-input" type="text" placeholder="Ask the agent..." class="chat-input">
|
||||
<button id="chat-send" class="btn btn-primary">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/store.js"></script>
|
||||
<script src="/static/js/utils.js"></script>
|
||||
<script src="/static/js/doc-viewer.js"></script>
|
||||
<script src="/static/js/research.js"></script>
|
||||
<script src="/static/js/ui.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
// App initialization
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
UI.init();
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
// Document upload and list management
|
||||
const DOC_UI = {
|
||||
async init() {
|
||||
$("#doc-upload").addEventListener("change", (e) => this.handleUpload(e));
|
||||
|
||||
// Periodically refresh document list
|
||||
setInterval(() => this.refreshList(), 30000);
|
||||
},
|
||||
|
||||
async handleUpload(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const output = $("#research-output");
|
||||
if (!output.querySelector(".empty-state")) output.innerHTML = "";
|
||||
|
||||
output.innerHTML = '<div class="spinner"></div> <span class="typing">Reading file...</span>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await fetch("/api/documents/upload", { method: "POST", body: formData });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`Document indexed: ${data.chunks || "unknown"} chunks`, "info");
|
||||
STORE.documents = [];
|
||||
await this.refreshList();
|
||||
|
||||
// Select the new document
|
||||
if (data.doc_id) {
|
||||
STORE.currentDocId = data.doc_id;
|
||||
this.renderDocList();
|
||||
$("#viewer-doc-name").textContent = file.name;
|
||||
}
|
||||
} else {
|
||||
output.innerHTML = `<p style="color:var(--accent-red)">Upload failed: ${data.detail || data.error || "unknown"}</p>`;
|
||||
}
|
||||
|
||||
e.target.value = "";
|
||||
},
|
||||
|
||||
async refreshList() {
|
||||
try {
|
||||
const res = await API.get("/api/documents");
|
||||
STORE.documents = res.documents || [];
|
||||
this.renderDocList();
|
||||
|
||||
// Update stats
|
||||
const stats = $("#sidebar-stats");
|
||||
const total = STORE.documents.length;
|
||||
const indexed = STORE.documents.filter(d => d.status === "indexed").length;
|
||||
stats.textContent = `${indexed}/${total} documents indexed`;
|
||||
} catch (e) {
|
||||
console.warn("Doc list refresh failed:", e);
|
||||
}
|
||||
},
|
||||
|
||||
async selectDoc(docId) {
|
||||
STORE.currentDocId = docId;
|
||||
this.renderDocList();
|
||||
|
||||
// Load into viewer
|
||||
if (STORE.viewerDoc) {
|
||||
DOC_VIEWER.loadDocument(docId);
|
||||
}
|
||||
|
||||
// Switch to viewer tab
|
||||
$$(".tab").forEach(t => t.classList.remove("active"));
|
||||
$$(".tab-pane").forEach(p => p.classList.remove("active"));
|
||||
document.querySelector('[data-tab="viewer"]').classList.add("active");
|
||||
$("#tab-viewer").classList.add("active");
|
||||
},
|
||||
|
||||
renderDocList() {
|
||||
const list = $("#doc-list");
|
||||
if (!list) return;
|
||||
|
||||
if (!STORE.documents.length) {
|
||||
list.innerHTML = '<div style="font-size:12px;color:var(--text-muted);padding:8px">No documents</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = STORE.documents.map(doc => {
|
||||
const ext = doc.filename.split(".").pop().toLowerCase();
|
||||
const icons = { pdf: "📄", txt: "📝", md: "📋", doc: "📑" };
|
||||
const icon = icons[ext] || "📄";
|
||||
const isActive = doc.id === STORE.currentDocId;
|
||||
return `
|
||||
<div class="doc-item ${isActive ? 'active' : ''}" data-doc-id="${doc.id}">
|
||||
<span class="file-icon">${icon}</span>
|
||||
<span class="file-name" title="${doc.filename}">${doc.filename.length > 25 ? doc.filename.substring(0, 25) + '...' : doc.filename}</span>
|
||||
<span class="file-status ${doc.status}">${doc.status.charAt(0).toUpperCase() + doc.status.slice(1)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Click handlers
|
||||
list.querySelectorAll(".doc-item").forEach(item => {
|
||||
item.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".file-remove")) return;
|
||||
this.selectDoc(item.dataset.docId);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
// Doc viewer with polygon visualization
|
||||
const DOC_VIEWER = {
|
||||
svgEl: null,
|
||||
canvasEl: null,
|
||||
|
||||
init() {
|
||||
this.svgEl = $("#polygon-svg");
|
||||
this.canvasEl = $("#polygon-canvas");
|
||||
|
||||
$("#viewer-prev").addEventListener("click", () => this.goPage(-1));
|
||||
$("#viewer-next").addEventListener("click", () => this.goPage(1));
|
||||
$("#show-polygons").addEventListener("change", () => this.redraw());
|
||||
$("#show-text-layers").addEventListener("change", () => this.redraw());
|
||||
$("#show-chunk-labels").addEventListener("change", () => this.redraw());
|
||||
|
||||
$("input[id='viewer-page-input']").addEventListener("change", (e) => {
|
||||
const page = parseInt(e.target.value, 10);
|
||||
if (page >= 0 && page <= STORE.totalPages) {
|
||||
this.currentPage = page;
|
||||
this.loadPage(page);
|
||||
}
|
||||
});
|
||||
|
||||
// Click on polygon to show chunk info
|
||||
this.svgEl.addEventListener("click", (e) => {
|
||||
const target = e.target;
|
||||
if (target.dataset.chunkId) {
|
||||
this.showChunkInfo(target.dataset.chunkId);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async loadDocument(docId) {
|
||||
STORE.currentDocId = docId;
|
||||
const res = await API.get(`/api/documents/${docId}/chunks`);
|
||||
STORE.viewerDoc = res.chunks || [];
|
||||
|
||||
// Compute page range
|
||||
const pages = new Set(STORE.viewerDoc.map(c => c.page_num));
|
||||
STORE.totalPages = Math.max(...pages) + 1;
|
||||
STORE.currentPage = 0;
|
||||
|
||||
$("#viewer-doc-name").textContent = STORE.documents.find(d => d.id === docId)?.filename || "Unknown";
|
||||
$("#viewer-page-input").max = STORE.totalPages - 1;
|
||||
this.updatePageInfo();
|
||||
|
||||
await this.loadPage(0);
|
||||
},
|
||||
|
||||
async loadPage(page) {
|
||||
STORE.currentPage = page;
|
||||
this.updatePageInfo();
|
||||
|
||||
// Get chunks for this page
|
||||
const chunks = STORE.viewerDoc.filter(c => c.page_num === page);
|
||||
if (!chunks.length) {
|
||||
$("#viewer-toolbar > span:nth-child(2)").textContent = "No content";
|
||||
this.clearSvg();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get polygon data
|
||||
const polyRes = await API.get(`/api/documents/${STORE.currentDocId}/polygon-view?page=${page}`);
|
||||
STORE.polygons = polyRes.blocks || chunks.map((c, i) => ({
|
||||
...c,
|
||||
polygon: c.polygon || { x_min: 50, y_min: 50 + (i * 30), width: 900, height: 25 },
|
||||
}));
|
||||
|
||||
this.redraw();
|
||||
},
|
||||
|
||||
updatePageInfo() {
|
||||
$("#viewer-page-input").value = STORE.currentPage;
|
||||
$("#viewer-page-info").textContent = `Page ${STORE.currentPage + 1} / ${STORE.totalPages}`;
|
||||
},
|
||||
|
||||
goPage(dir) {
|
||||
const newPage = STORE.currentPage + dir;
|
||||
if (newPage >= 0 && newPage < STORE.totalPages) {
|
||||
this.loadPage(newPage);
|
||||
}
|
||||
},
|
||||
|
||||
clearSvg() {
|
||||
if (this.svgEl) {
|
||||
this.svgEl.innerHTML = "";
|
||||
}
|
||||
},
|
||||
|
||||
redraw() {
|
||||
const chunks = STORE.polygons.filter(c => c.polygon);
|
||||
if (!chunks.length || !this.svgEl) return;
|
||||
|
||||
this.clearSvg();
|
||||
|
||||
// Calculate bounding box from all polygons
|
||||
const bbox = chunks.reduce((acc, c) => {
|
||||
const p = c.polygon;
|
||||
if (!p || !p.bbox) return acc;
|
||||
const [x1, y1, x2, y2] = p.bbox;
|
||||
acc.x1 = Math.min(acc.x1, x1);
|
||||
acc.y1 = Math.min(acc.y1, y1);
|
||||
acc.x2 = Math.max(acc.x2, x2);
|
||||
acc.y2 = Math.max(acc.y2, y2);
|
||||
return acc;
|
||||
}, { x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity });
|
||||
|
||||
const padding = 20;
|
||||
const svgWidth = 1000;
|
||||
const svgHeight = 1200;
|
||||
const scaleX = svgWidth / Math.max(bbox.x2 - bbox.x1, 100);
|
||||
const scaleY = svgHeight / Math.max(bbox.y2 - bbox.y1, 100);
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
this.svgEl.setAttribute("viewBox", `0 0 ${svgWidth} ${svgHeight}`);
|
||||
|
||||
// Page background
|
||||
const bg = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
bg.setAttribute("x", padding);
|
||||
bg.setAttribute("y", padding);
|
||||
bg.setAttribute("width", svgWidth - padding * 2);
|
||||
bg.setAttribute("height", svgHeight - padding * 2);
|
||||
bg.setAttribute("fill", "#1e1e2e");
|
||||
bg.setAttribute("rx", "4");
|
||||
this.svgEl.appendChild(bg);
|
||||
|
||||
const showPolys = $("#show-polygons").checked;
|
||||
const showText = $("#show-text-layers").checked;
|
||||
const showLabels = $("#show-chunk-labels").checked;
|
||||
|
||||
// Draw each chunk as a polygon/rect
|
||||
chunks.forEach((chunk, idx) => {
|
||||
let rect;
|
||||
let x, y, w, h;
|
||||
|
||||
// Handle both bbox format [x1, y1, x2, y2] and explicit polygon format
|
||||
let coords;
|
||||
if (chunk.polygon.bbox && Array.isArray(chunk.polygon.bbox)) {
|
||||
const [x1, y1, x2, y2] = chunk.polygon.bbox;
|
||||
x = padding + (x1 - bbox.x1) * scale;
|
||||
y = padding + (y1 - bbox.y1) * scale;
|
||||
w = Math.max((x2 - x1) * scale, 10);
|
||||
h = Math.max((y2 - y1) * scale, 5);
|
||||
} else if (chunk.polygon.polygon && Array.isArray(chunk.polygon.polygon)) {
|
||||
const pol = chunk.polygon.polygon;
|
||||
const minX = Math.min(...pol.map(p => p[0]));
|
||||
const minY = Math.min(...pol.map(p => p[1]));
|
||||
const maxX = Math.max(...pol.map(p => p[0]));
|
||||
const maxY = Math.max(...pol.map(p => p[1]));
|
||||
x = padding + (minX - bbox.x1) * scale;
|
||||
y = padding + (minY - bbox.y1) * scale;
|
||||
w = Math.max((maxX - minX) * scale, 10);
|
||||
h = Math.max((maxY - minY) * scale, 5);
|
||||
} else if (chunk.polygon.x_min != null) {
|
||||
x = padding + (chunk.polygon.x_min - bbox.x1) * scale;
|
||||
y = padding + (chunk.polygon.y_min - bbox.y1) * scale;
|
||||
w = Math.max((chunk.polygon.width || 100) * scale, 50);
|
||||
h = Math.max((chunk.polygon.height || 20) * scale, 5);
|
||||
} else {
|
||||
return; // Skip if no polygon data
|
||||
}
|
||||
|
||||
// Polygon shape
|
||||
if (showPolys) {
|
||||
rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
rect.setAttribute("x", x);
|
||||
rect.setAttribute("y", y);
|
||||
rect.setAttribute("width", w);
|
||||
rect.setAttribute("height", h);
|
||||
rect.setAttribute("rx", "2");
|
||||
|
||||
// Color by chunk type
|
||||
const colors = {
|
||||
"text": "#58a6ff",
|
||||
"figure": "#3fb950",
|
||||
"table": "#a371f7",
|
||||
"footnote": "#f0883e",
|
||||
"equation": "#f85149",
|
||||
"list": "#d29922",
|
||||
"caption": "#39d2c0",
|
||||
"title": "#e6edf3",
|
||||
};
|
||||
const color = colors[chunk.type] || colors["text"];
|
||||
|
||||
rect.setAttribute("fill", `${color}15`);
|
||||
rect.setAttribute("stroke", `${color}80`);
|
||||
rect.setAttribute("stroke-width", "1");
|
||||
rect.setAttribute("stroke-dasharray", "3,2");
|
||||
rect.dataset.chunkId = chunk.id;
|
||||
rect.style.cursor = "pointer";
|
||||
rect.addEventListener("mouseenter", () => {
|
||||
rect.setAttribute("stroke-width", "2");
|
||||
rect.setAttribute("fill", `${color}30`);
|
||||
});
|
||||
rect.addEventListener("mouseleave", () => {
|
||||
rect.setAttribute("stroke-width", "1");
|
||||
rect.setAttribute("fill", `${color}15`);
|
||||
});
|
||||
|
||||
// Store text content as tooltip
|
||||
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
||||
title.textContent = chunk.content ? chunk.content.substring(0, 100) : "";
|
||||
rect.appendChild(title);
|
||||
|
||||
this.svgEl.appendChild(rect);
|
||||
}
|
||||
|
||||
// Text label overlay
|
||||
if (showText && chunk.content) {
|
||||
const fontSize = Math.max(6, Math.min(12, h * 0.6));
|
||||
if (h > 12) {
|
||||
const textEl = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
textEl.setAttribute("x", x + 3);
|
||||
textEl.setAttribute("y", y + fontSize + 1);
|
||||
textEl.setAttribute("fill", "#e6edf3");
|
||||
textEl.setAttribute("font-size", fontSize);
|
||||
textEl.setAttribute("font-family", "monospace");
|
||||
|
||||
// Truncate text to fit
|
||||
const maxChars = Math.max(2, Math.floor(w / (fontSize * 0.55)));
|
||||
let displayText = chunk.content.substring(0, maxChars);
|
||||
if (chunk.content.length > maxChars) displayText += "...";
|
||||
|
||||
textEl.textContent = displayText;
|
||||
this.svgEl.appendChild(textEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Chunk index label
|
||||
if (showLabels) {
|
||||
const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
label.setAttribute("x", x);
|
||||
label.setAttribute("y", Math.max(y - 4, 12));
|
||||
label.setAttribute("fill", `${color || "#58a6ff"}aa`);
|
||||
label.setAttribute("font-size", "8");
|
||||
label.setAttribute("font-family", "monospace");
|
||||
label.textContent = `${idx}`;
|
||||
this.svgEl.appendChild(label);
|
||||
}
|
||||
});
|
||||
|
||||
// Legend
|
||||
const legendY = svgHeight - 30;
|
||||
const legendBg = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
legendBg.setAttribute("x", padding);
|
||||
legendBg.setAttribute("y", legendY);
|
||||
legendBg.setAttribute("width", "180");
|
||||
legendBg.setAttribute("height", "22");
|
||||
legendBg.setAttribute("fill", "var(--bg-primary)");
|
||||
legendBg.setAttribute("rx", "3");
|
||||
this.svgEl.appendChild(legendBg);
|
||||
|
||||
const legendText = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
legendText.setAttribute("x", padding + 8);
|
||||
legendText.setAttribute("y", legendY + 14);
|
||||
legendText.setAttribute("fill", "#8b949e");
|
||||
legendText.setAttribute("font-size", "9");
|
||||
legendText.textContent = "Click a region to view content";
|
||||
this.svgEl.appendChild(legendText);
|
||||
},
|
||||
|
||||
showChunkInfo(chunkId) {
|
||||
const chunk = STORE.viewerDoc.find(c => c.id === chunkId) ||
|
||||
STORE.polygons.find(c => c.id === chunkId);
|
||||
if (!chunk) return;
|
||||
|
||||
// Switch to analysis tab and show chunk
|
||||
const content = chunk.content || "(no text content)";
|
||||
const page = chunk.page_num ?? "?";
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "block";
|
||||
div.style.cursor = "pointer";
|
||||
div.innerHTML = `<div style="font-size:10px;color:var(--accent-cyan);margin-bottom:4px">[p${page}]${chunk.polygon ? ' (polygon)' : ''}</div><div style="font-size:12px;color:var(--text-primary)">${content.substring(0, 300)}</div>`;
|
||||
|
||||
div.addEventListener("click", () => {
|
||||
// Open in research tab
|
||||
const output = $("#research-output");
|
||||
const section = document.createElement("div");
|
||||
section.className = "research-section";
|
||||
section.innerHTML = `<h3>Chunk Viewer - Page ${page}</h3>
|
||||
<div style="background:var(--bg-tertiary);padding:10px;border-radius:6px;margin:8px 0;font-family:monospace;font-size:12px;color:var(--text-secondary);white-space:pre-wrap;max-height:300px;overflow-y:auto">${content}</div>`;
|
||||
|
||||
if (output.querySelector(".empty-state")) {
|
||||
output.innerHTML = "";
|
||||
}
|
||||
output.appendChild(section);
|
||||
|
||||
// Switch to analysis tab
|
||||
$$(".tab").forEach(t => t.classList.remove("active"));
|
||||
$$(".tab-pane").forEach(p => p.classList.remove("active"));
|
||||
document.querySelector('[data-tab="analysis"]').classList.add("active");
|
||||
$("#tab-analysis").classList.add("active");
|
||||
});
|
||||
|
||||
showToast(`Chunk p${page}: ${content.substring(0, 50)}...`, "info");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,299 @@
|
||||
// Research orchestration UI
|
||||
const RESEARCH_UI = {
|
||||
async init() {
|
||||
$("#run-research").addEventListener("click", () => this.runResearch());
|
||||
$("#query-input").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && e.ctrlKey) this.runResearch();
|
||||
});
|
||||
},
|
||||
|
||||
async runResearch() {
|
||||
const query = $("#query-input").value.trim();
|
||||
if (!query) return showToast("Enter a research query", "error");
|
||||
|
||||
// Get selected skills
|
||||
const checked = [...document.querySelectorAll('.skill-toggle input:checked')].map(c => c.value);
|
||||
|
||||
const output = $("#research-output");
|
||||
output.innerHTML = "";
|
||||
showSpinner(output);
|
||||
|
||||
// Create session
|
||||
let sessionId = STORE.currentSessionId;
|
||||
if (!sessionId) {
|
||||
try {
|
||||
const session = await API.post("/api/research/session", { query });
|
||||
STORE.currentSessionId = session.session_id;
|
||||
sessionId = session.session_id;
|
||||
} catch (e) {
|
||||
showToast("Failed to create session", "error");
|
||||
output.innerHTML = `<p class='typing'>Session error: ${e.message}</p>`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Run research
|
||||
try {
|
||||
const results = await API.post("/api/research/run", {
|
||||
query,
|
||||
doc_id: STORE.currentDocId,
|
||||
skills: checked.length ? checked : undefined,
|
||||
});
|
||||
|
||||
output.innerHTML = "";
|
||||
|
||||
// Session info
|
||||
const info = document.createElement("div");
|
||||
info.className = "research-section";
|
||||
info.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<h3 style="color:var(--accent-blue)">Research Results</h3>
|
||||
<span style="font-size:11px;color:var(--text-muted)">Session: ${sessionId.substring(0, 8)}</span>
|
||||
</div>
|
||||
<p style="color:var(--text-secondary)">Query: ${query}</p>
|
||||
<hr style="border:none;border-top:1px solid var(--border-color);margin:8px 0">`;
|
||||
output.appendChild(info);
|
||||
|
||||
// Results from each skill
|
||||
if (results.results) {
|
||||
for (const [skill, response] of Object.entries(results.results)) {
|
||||
const section = document.createElement("div");
|
||||
section.className = "research-section";
|
||||
section.id = `section-${skill}`;
|
||||
section.innerHTML = `<h3>${skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}</h3>`;
|
||||
|
||||
const content = document.createElement("div");
|
||||
content.innerHTML = renderContent(response);
|
||||
section.appendChild(content);
|
||||
output.appendChild(section);
|
||||
}
|
||||
}
|
||||
|
||||
STORE.currentSessionId = sessionId;
|
||||
showToast("Research complete", "info");
|
||||
|
||||
} catch (e) {
|
||||
output.innerHTML = `<p class='typing' style="color:var(--accent-red)">Error: ${e.message}</p>`;
|
||||
showToast("Research failed", "error");
|
||||
}
|
||||
},
|
||||
|
||||
async runSemanticSearch(query, docId = null) {
|
||||
const results = await API.post("/api/research/semantic", { query, doc_id: docId, limit: 20 });
|
||||
return results.results || [];
|
||||
},
|
||||
|
||||
async runTextSearch(query, docId = null) {
|
||||
const results = await API.post("/api/research/text-search", { query, doc_id: docId, limit: 20 });
|
||||
return results.results || [];
|
||||
},
|
||||
|
||||
async getFindings(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
try {
|
||||
const res = await API.get(`/api/research/findings?session_id=${sessionId}`);
|
||||
return res.findings || [];
|
||||
} catch { return []; }
|
||||
},
|
||||
|
||||
async saveMemory(sessionId, content, type = "fact", importance = 3) {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
await API.post("/api/memories/save", {
|
||||
session_id: sessionId,
|
||||
content,
|
||||
memory_type: type,
|
||||
importance,
|
||||
source_doc_id: STORE.currentDocId,
|
||||
});
|
||||
showToast("Memory saved", "info");
|
||||
} catch (e) { showToast("Save failed: " + e.message, "error"); }
|
||||
},
|
||||
|
||||
async runPipeline() {
|
||||
const query = $("#query-input").value.trim();
|
||||
if (!query) return showToast("Enter a research query", "error");
|
||||
|
||||
const mode = $("#pipeline-output-mode").value || "";
|
||||
|
||||
// Create session
|
||||
let sessionId = STORE.currentSessionId;
|
||||
if (!sessionId) {
|
||||
try {
|
||||
const session = await API.post("/api/research/session", { query });
|
||||
sessionId = session.session_id;
|
||||
STORE.currentSessionId = sessionId;
|
||||
} catch (e) {
|
||||
showToast("Failed to create session", "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const pipelineEl = $("#pipeline-output");
|
||||
pipelineEl.innerHTML = '<div class="spinner"></div> <span class="typing">Running: triage → evidence extraction → synthesis...</span>';
|
||||
|
||||
// Run the pipeline
|
||||
try {
|
||||
const results = await API.post("/api/research/pipeline", {
|
||||
query,
|
||||
doc_ids: STORE.currentDocId ? [STORE.currentDocId] : undefined,
|
||||
output_mode: mode || undefined,
|
||||
});
|
||||
|
||||
// Save session to the first doc or temp session
|
||||
STORE.currentSessionId = sessionId;
|
||||
|
||||
// Render pipeline sections
|
||||
// First render: get structured sections from results
|
||||
const sections = RESEARCH_UI.renderPipelineSections(results);
|
||||
pipelineEl.innerHTML = sections.map(s => RESEARCH_UI.renderSectionHtml(s)).join('');
|
||||
|
||||
// Wire up collapse toggles
|
||||
pipelineEl.querySelectorAll(".pipeline-stage-header").forEach(header => {
|
||||
header.addEventListener("click", () => {
|
||||
const body = header.nextElementSibling;
|
||||
body.classList.toggle("collapsed");
|
||||
const arrow = header.querySelector(".stage-arrow");
|
||||
if (arrow) arrow.textContent = body.classList.contains("collapsed") ? "▶" : "▼";
|
||||
});
|
||||
});
|
||||
|
||||
showToast("Pipeline complete", "info");
|
||||
|
||||
} catch (e) {
|
||||
pipelineEl.innerHTML = `<p class='typing' style="color:var(--accent-red)">Error: ${e.message}</p>`;
|
||||
showToast("Pipeline failed", "error");
|
||||
}
|
||||
},
|
||||
|
||||
renderPipelineSections(results) {
|
||||
const sections = [];
|
||||
|
||||
// Triage stage
|
||||
if (results.triage_state) {
|
||||
const plan = results.triage_state;
|
||||
sections.push({
|
||||
stage: "triage",
|
||||
title: "Stage 1: Source Triage",
|
||||
status: "complete",
|
||||
sections: [
|
||||
{ label: "Objective", content: plan.OBJECTIVE || "" },
|
||||
{ label: "Sub-questions", content: plan["SUB-QUESTIONS"] || "" },
|
||||
{ label: "Classification", content: plan.CLASSIFICATION || "" },
|
||||
{ label: "Reading Order", content: plan.READING_ORDER || "" },
|
||||
{ label: "Extraction Criteria", content: plan["EXTRACTION CRITERIA"] || "" },
|
||||
{ label: "Risks & Gaps", content: plan["RISKS and GAPS"] || plan.RISKS_AND_GAPS || "" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Evidence stage
|
||||
if (results.evidence_rows && results.evidence_rows.length > 0) {
|
||||
sections.push({
|
||||
stage: "evidence",
|
||||
title: `Stage 2: Extracted Evidence (${results.evidence_rows.length} rows)`,
|
||||
status: "complete",
|
||||
sections: [
|
||||
{ label: "", content: "table", rows: results.evidence_rows },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Synthesis stage
|
||||
if (results.synthesis) {
|
||||
sections.push({
|
||||
stage: "synthesis",
|
||||
title: `Stage 3: Synthesis (mode: ${results.output_mode || "auto"})`,
|
||||
status: "complete",
|
||||
sections: [
|
||||
{ label: "Result", content: results.synthesis, mode: results.output_mode },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
},
|
||||
|
||||
renderSectionHtml(section) {
|
||||
let cardsHtml = "";
|
||||
section.sections.forEach(sec => {
|
||||
if (sec.content === "table") {
|
||||
const rows = sec.rows || [];
|
||||
const hasComparison = rows[0] && rows[0].source_a !== undefined;
|
||||
if (hasComparison) {
|
||||
cardsHtml += `<div class="pipeline-section-label">Cross-Comparison</div>`;
|
||||
cardsHtml += `<table class="matrix-table"><thead><tr><th>Topic</th><th>Source A</th><th>Source B</th><th>Agreement</th><th>Conflict</th><th>Notes</th></tr></thead><tbody>`;
|
||||
rows.forEach(r => {
|
||||
cardsHtml += `<tr>
|
||||
<td>${r.topic || ""}</td>
|
||||
<td>${(r.source_a || "").substring(0, 200)}</td>
|
||||
<td>${(r.source_b || "").substring(0, 200)}</td>
|
||||
<td>${r.agreement || ""}</td>
|
||||
<td>${r.conflict || ""}</td>
|
||||
<td>${r.notes || ""}</td>
|
||||
</tr>`;
|
||||
});
|
||||
cardsHtml += `</tbody></table>`;
|
||||
} else {
|
||||
const cols = ["#", "Topic", "Evidence Type", "Description", "Doc Ref", "Evidence", "Analyst Note", "Confidence", "Review"];
|
||||
cardsHtml += `<div class="pipeline-section-label">Extracted Evidence</div>`;
|
||||
cardsHtml += `<table class="evidence-table"><thead><tr>${cols.map(c => `<th>${c}</th>`).join("")}</tr></thead><tbody>`;
|
||||
rows.forEach((r, i) => {
|
||||
const confClass = (r.confidence || "medium").toLowerCase().replace(" ", "-");
|
||||
cardsHtml += `<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td>${r.topic || ""}</td>
|
||||
<td>${r.evidence_type || ""}</td>
|
||||
<td>${(r.description || "").substring(0, 150)}</td>
|
||||
<td>${(r.trace_ref || "").substring(0, 150)}</td>
|
||||
<td>${(r.evidence || "").substring(0, 200)}</td>
|
||||
<td>${(r.analyst_note || "").substring(0, 150)}</td>
|
||||
<td class="confidence-${confClass}">${r.confidence || "Medium"}</td>
|
||||
<td>${r.review_needed || "No"}</td>
|
||||
</tr>`;
|
||||
});
|
||||
cardsHtml += `</tbody></table>`;
|
||||
}
|
||||
} else {
|
||||
cardsHtml += `<div class="pipeline-section-label">${sec.label || ""}</div>`;
|
||||
cardsHtml += `<div class="pipeline-section-content">${sec.content}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="pipeline-stage-card">
|
||||
<div class="pipeline-stage-header" data-stage="${section.stage}">
|
||||
<span class="stage-title">${section.title}</span>
|
||||
<span><span class="stage-arrow" style="margin-right:6px;">▼</span><span class="stage-status ${section.status}">Complete</span></span>
|
||||
</div>
|
||||
<div class="stage-body">${cardsHtml}</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
async loadPipelineSession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
const pipelineEl = $("#pipeline-output");
|
||||
|
||||
try {
|
||||
const res = await API.get(`/api/research/pipeline/${sessionId}/render`);
|
||||
const sections = res.sections || [];
|
||||
|
||||
pipelineEl.innerHTML = sections.length > 0
|
||||
? sections.map(s => this.renderSectionHtml(s)).join('')
|
||||
: "<div class='empty-state'><div class='empty-icon'>ℹ️</div><div class='empty-text'>No pipeline data for this session</div></div>";
|
||||
|
||||
// Wire up collapse toggles
|
||||
pipelineEl.querySelectorAll(".pipeline-stage-header").forEach(header => {
|
||||
header.addEventListener("click", () => {
|
||||
const body = header.nextElementSibling;
|
||||
body.classList.toggle("collapsed");
|
||||
const arrow = header.querySelector(".stage-arrow");
|
||||
if (arrow) arrow.textContent = body.classList.contains("collapsed") ? "▶" : "▼";
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
pipelineEl.innerHTML = `<p class='typing' style="color:var(--accent-red)">Error loading pipeline: ${e.message}</p>`;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Store - Application state management
|
||||
const Store = {
|
||||
currentDoc: null,
|
||||
currentSession: null,
|
||||
documents: [],
|
||||
sessions: [],
|
||||
memories: [],
|
||||
findings: [],
|
||||
pageChunks: [],
|
||||
currentPage: 0,
|
||||
polygonsVisible: true,
|
||||
textVisible: true,
|
||||
chunkLabelsVisible: true,
|
||||
};
|
||||
|
||||
// ── API Helper ──
|
||||
async function api(endpoint, options = {}) {
|
||||
const defaultOpts = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
const merged = { ...defaultOpts, ...options };
|
||||
if (options.body && typeof options.body === 'object' && !(options.body instanceof FormData)) {
|
||||
merged.body = JSON.stringify(options.body);
|
||||
}
|
||||
const url = endpoint.startsWith('http') ? endpoint : `/api${endpoint}`;
|
||||
const resp = await fetch(url, merged);
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`${resp.status}: ${text}`);
|
||||
}
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
if (ct.includes('json')) return resp.json();
|
||||
return resp;
|
||||
}
|
||||
|
||||
// ── Document Store ──
|
||||
const DocStore = {
|
||||
async load() {
|
||||
const res = await api('/documents');
|
||||
Store.documents = res.documents || [];
|
||||
return Store.documents;
|
||||
},
|
||||
|
||||
async getChunks(docId, page) {
|
||||
const params = new URLSearchParams();
|
||||
if (page !== undefined) params.set('page', page);
|
||||
const res = await api(`/documents/${docId}/chunks?${params}`);
|
||||
Store.pageChunks = res.chunks || [];
|
||||
return Store.pageChunks;
|
||||
},
|
||||
|
||||
async upload(file) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await api('/documents/upload', { method: 'POST', body: fd });
|
||||
return res;
|
||||
},
|
||||
|
||||
async uploadUrl(url, filename) {
|
||||
const res = await api('/documents/upload', {
|
||||
method: 'POST',
|
||||
body: { pdf_url: url, filename },
|
||||
});
|
||||
return res;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Research Store ──
|
||||
const ResearchStore = {
|
||||
async run(query, skillNames) {
|
||||
const res = await api('/research/run', {
|
||||
method: 'POST',
|
||||
body: { query, skills: skillNames, doc_id: Store.currentDoc },
|
||||
});
|
||||
return res;
|
||||
},
|
||||
|
||||
async createSession(query) {
|
||||
const res = await api('/research/session', {
|
||||
method: 'POST',
|
||||
body: query,
|
||||
});
|
||||
return res;
|
||||
},
|
||||
|
||||
async semanticSearch(query, limit = 20) {
|
||||
const res = await api('/research/semantic', {
|
||||
method: 'POST',
|
||||
body: { query, doc_id: Store.currentDoc, limit },
|
||||
});
|
||||
return res;
|
||||
},
|
||||
|
||||
async textSearch(query, limit = 20) {
|
||||
const res = await api('/research/text-search', {
|
||||
method: 'POST',
|
||||
body: { query, doc_id: Store.currentDoc, limit },
|
||||
});
|
||||
return res;
|
||||
},
|
||||
|
||||
async getFindings(sessionId) {
|
||||
const res = await api(`/research/findings?session_id=${sessionId || ''}`);
|
||||
Store.findings = res.findings || [];
|
||||
return Store.findings;
|
||||
},
|
||||
|
||||
async getMemories(sessionId) {
|
||||
const res = await api(`/research/memories?session_id=${sessionId || ''}`);
|
||||
Store.memories = res.memories || [];
|
||||
return Store.memories;
|
||||
},
|
||||
|
||||
async saveFinding(sessionId, query, response) {
|
||||
await api('/research/run', {
|
||||
method: 'POST',
|
||||
body: { query, doc_id: sessionId },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ── Memory Store ──
|
||||
const MemoryStore = {
|
||||
async save(content, type = 'fact', importance = 3, sourceDocId) {
|
||||
const res = await api('/memories/save', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
session_id: Store.currentSession,
|
||||
content,
|
||||
memory_type: type,
|
||||
importance,
|
||||
source_doc_id: sourceDocId,
|
||||
},
|
||||
});
|
||||
return res;
|
||||
},
|
||||
|
||||
async search(query, limit = 10) {
|
||||
const res = await api('/memories/search', {
|
||||
method: 'POST',
|
||||
body: { query, limit },
|
||||
});
|
||||
return res;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Agent Store ──
|
||||
const AgentStore = {
|
||||
async getSkills() {
|
||||
const res = await api('/agents/skills');
|
||||
return res.skills;
|
||||
},
|
||||
|
||||
async chat(message, model) {
|
||||
const res = await api('/ollama/chat', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
model: model || 'gpt-oss:20b',
|
||||
messages: [{ role: 'user', content: message }],
|
||||
},
|
||||
});
|
||||
return res;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Viewer Store ──
|
||||
const ViewerStore = {
|
||||
async getPolygonView(docId, page) {
|
||||
const res = await api(`/documents/${docId}/polygon-view?page=${page}`);
|
||||
return res;
|
||||
},
|
||||
|
||||
async getPageText(docId, page) {
|
||||
const res = await api(`/documents/${docId}/page-text?page=${page}`);
|
||||
return res;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Health Store ──
|
||||
const HealthStore = {
|
||||
async check() {
|
||||
const res = await api('/health');
|
||||
return res;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
// Main UI management
|
||||
const UI = {
|
||||
async init() {
|
||||
// Tab switching
|
||||
$$(".tab").forEach(tab => {
|
||||
tab.addEventListener("click", () => {
|
||||
const target = tab.dataset.tab;
|
||||
this.switchTab(target);
|
||||
|
||||
// Load tab data
|
||||
if (target === "search") this.initSearch();
|
||||
if (target === "memories") this.loadMemories();
|
||||
if (target === "findings") this.loadFindings();
|
||||
if (target === "pipeline" && STORE.currentSessionId) this.loadPipelineSession(STORE.currentSessionId);
|
||||
});
|
||||
});
|
||||
|
||||
// Pipeline mode toggle
|
||||
const pipelineCheck = $("#pipeline-mode");
|
||||
const pipelineModeSelect = $("#pipeline-output-mode");
|
||||
if (pipelineCheck) {
|
||||
pipelineCheck.addEventListener("change", () => {
|
||||
const on = pipelineCheck.checked;
|
||||
pipelineModeSelect.style.display = on ? "inline-block" : "none";
|
||||
const toggles = document.querySelectorAll("#skill-toggles .skill-toggle");
|
||||
toggles.forEach(t => {
|
||||
const cb = t.querySelector("input");
|
||||
if (cb) cb.disabled = on;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Rebuild pipeline button
|
||||
const rebuildBtn = $("#rebuild-pipeline");
|
||||
if (rebuildBtn && STORE.currentSessionId) {
|
||||
rebuildBtn.addEventListener("click", () => UI.loadPipelineSession(STORE.currentSessionId));
|
||||
}
|
||||
|
||||
// Search
|
||||
$("#execute-search").addEventListener("click", () => this.executeSearch());
|
||||
$("#search-query").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") this.executeSearch();
|
||||
});
|
||||
|
||||
// Check models
|
||||
$("#check-models").addEventListener("click", () => this.checkModels());
|
||||
|
||||
// Load state
|
||||
await loadInitialState();
|
||||
|
||||
// Init sub-modules
|
||||
DOC_VIEWER.init();
|
||||
RESEARCH_UI.init();
|
||||
DOC_UI.init();
|
||||
},
|
||||
|
||||
switchTab(tabName) {
|
||||
$$(".tab").forEach(t => t.classList.remove("active"));
|
||||
$$(".tab-pane").forEach(p => p.classList.remove("active"));
|
||||
document.querySelector(`[data-tab="${tabName}"]`).classList.add("active");
|
||||
$(`#tab-${tabName}`).classList.add("active");
|
||||
},
|
||||
|
||||
initSearch() {
|
||||
// Search already bound in HTML
|
||||
},
|
||||
|
||||
async executeSearch() {
|
||||
const query = $("#search-query").value.trim();
|
||||
if (!query) return showToast("Enter a search query", "error");
|
||||
|
||||
const type = $("#search-type").value;
|
||||
const resultsEl = $("#search-results");
|
||||
resultsEl.innerHTML = '<div class="spinner"></div> <span class="typing">Searching...</span>';
|
||||
|
||||
try {
|
||||
let results;
|
||||
if (type === "semantic") {
|
||||
results = await RESEARCH_UI.runSemanticSearch(query, STORE.currentDocId);
|
||||
} else {
|
||||
results = await RESEARCH_UI.runTextSearch(query, STORE.currentDocId);
|
||||
}
|
||||
|
||||
if (!results.length) {
|
||||
resultsEl.innerHTML = '<div style="padding:20px;color:var(--text-muted);text-align:center">No results found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsEl.innerHTML = results.map(r => {
|
||||
const score = r.similarity ?? r.rank ?? 0;
|
||||
const page = r.page_num != null ? `p${r.page_num}` : "";
|
||||
const truncated = r.content ? r.content.substring(0, 200) : "";
|
||||
|
||||
// Highlight matching text
|
||||
let content = truncated.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
if (query) {
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||
content = content.replace(regex, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="search-result-item" data-chunk-id="${r.id}">
|
||||
<div class="result-score">Score: ${score.toFixed(3)}</div>
|
||||
<div class="result-content">${content}${truncated.length >= 200 ? '...' : ''}</div>
|
||||
<div class="result-meta">${page} · ${r.chunk_type || 'text'}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Click to show chunk in viewer
|
||||
resultsEl.querySelectorAll(".search-result-item").forEach(item => {
|
||||
item.addEventListener("click", () => {
|
||||
const chunkId = item.dataset.chunkId;
|
||||
if (STORE.currentDocId) {
|
||||
DOC_VIEWER.showChunkInfo(chunkId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
resultsEl.innerHTML = `<p style="color:var(--accent-red);padding:20px">Search error: ${e.message}</p>`;
|
||||
}
|
||||
},
|
||||
|
||||
async loadMemories() {
|
||||
const list = $("#memories-list");
|
||||
list.innerHTML = '<div class="spinner"></div>';
|
||||
|
||||
try {
|
||||
if (STORE.currentSessionId) {
|
||||
const res = await API.get(`/api/research/memories?session_id=${STORE.currentSessionId}`);
|
||||
list.innerHTML = (res.memories || []).map(m => `
|
||||
<div class="memory-item">
|
||||
<div class="memory-type">${m.memory_type || "fact"} (imp: ${m.importance || "?"})</div>
|
||||
<div class="memory-content">${m.content ? m.content.substring(0, 300) : "(empty)"}</div>
|
||||
</div>`).join('') || '<div style="color:var(--text-muted);padding:12px">No memories</div>';
|
||||
} else {
|
||||
list.innerHTML = '<div style="color:var(--text-muted);padding:12px">No active session</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div style="color:var(--accent-red);padding:12px">${e.message}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
async loadFindings() {
|
||||
const list = $("#findings-list");
|
||||
list.innerHTML = '<div class="spinner"></div>';
|
||||
|
||||
try {
|
||||
const findings = await RESEARCH_UI.getFindings(STORE.currentSessionId);
|
||||
list.innerHTML = findings.length ? findings.map(f => `
|
||||
<div class="finding-item">
|
||||
<div class="finding-agent">${f.agent_name || "unknown"}</div>
|
||||
<div class="finding-query">${f.question || "N/A"}</div>
|
||||
<div class="finding-answer">${(f.answer || f.summary || "").substring(0, 400)}</div>
|
||||
</div>`).join('') : '<div style="color:var(--text-muted);padding:12px">No findings yet</div>';
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div style="color:var(--accent-red);padding:12px">${e.message}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
async checkModels() {
|
||||
const list = $("#model-list");
|
||||
list.innerHTML = '<div class="spinner"></div>';
|
||||
|
||||
try {
|
||||
const res = await API.get("/api/ollama/models");
|
||||
const models = res.models || [];
|
||||
list.innerHTML = models.length ? models.map(m => `
|
||||
<div style="font-size:11px;padding:3px 8px;color:var(--accent-green)">
|
||||
${m.name || m.model}
|
||||
</div>`).join('') : '<div style="font-size:11px;padding:3px 8px;color:var(--text-muted)">None found</div>';
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div style="font-size:11px;padding:3px 8px;color:var(--accent-red)">Off: ${e.message}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
updateHealth(health) {
|
||||
const dot = $("#health-indicator .health-dot");
|
||||
const text = $("#health-indicator");
|
||||
if (dot) {
|
||||
dot.className = health.status === "ok" ? "health-dot healthy" : "health-dot";
|
||||
text.textContent = health.status === "ok" ? "All systems operational" : "Systems degraded";
|
||||
}
|
||||
},
|
||||
|
||||
renderModels(models) {
|
||||
if (!models?.models?.length) return;
|
||||
const list = $("#model-list");
|
||||
if (list) {
|
||||
list.innerHTML = models.models.map(m => `
|
||||
<div style="font-size:11px;padding:3px 8px;color:var(--accent-green)">
|
||||
${m.name || m.model}
|
||||
</div>`).join('');
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
// App state store
|
||||
const STORE = {
|
||||
currentDocId: null,
|
||||
currentSessionId: null,
|
||||
currentPage: 0,
|
||||
totalPages: 0,
|
||||
documents: [],
|
||||
sessions: [],
|
||||
polygons: [],
|
||||
viewerDoc: null,
|
||||
chunkCache: {},
|
||||
chatMessages: [],
|
||||
};
|
||||
|
||||
// API helpers
|
||||
const API = {
|
||||
async get(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`API ${r.status}: ${r.statusText}`);
|
||||
return r.json();
|
||||
},
|
||||
async post(url, body) {
|
||||
const r = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(`API ${r.status}: ${r.statusText}`);
|
||||
return r.json();
|
||||
},
|
||||
};
|
||||
|
||||
// DOM helpers
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => document.querySelectorAll(sel);
|
||||
|
||||
// Render markdown-style content
|
||||
function renderContent(text) {
|
||||
if (!text) return "<p class='typing'>No content</p>";
|
||||
|
||||
let html = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
// Headers
|
||||
html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
|
||||
html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^# (.+)$/gm, '<h3 class="research-answer" style="color:var(--accent-blue)">$1</h3>');
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
|
||||
// Code blocks
|
||||
html = html.replace(/```([\s\S]*?)```/g, '<div class="code-block" style="background:var(--bg-tertiary);padding:10px;border-radius:6px;font-family:monospace;font-size:12px;margin:8px 0;overflow-x:auto">$1</div>');
|
||||
|
||||
// Inline code
|
||||
html = html.replace(/`(.+?)`/g, '<code style="background:var(--bg-tertiary);padding:2px 5px;border-radius:3px;font-size:12px">$1</code>');
|
||||
|
||||
// Unordered lists
|
||||
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
|
||||
html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
|
||||
|
||||
// Ordered lists
|
||||
html = html.replace(/^\d+\. (.+)$/gm, '<li><strong>$1</strong></li>');
|
||||
|
||||
// Horizontal rules
|
||||
html = html.replace(/^---$/gm, '<hr style="border:none;border-top:1px solid var(--border-color);margin:12px 0">');
|
||||
|
||||
// Blockquotes
|
||||
html = html.replace(/^> (.+)$/gm, '<div class="block" style="border-left:3px solid var(--accent-purple);padding:8px 12px;margin:6px 0;font-style:italic;color:var(--text-secondary)">$1</div>');
|
||||
|
||||
// Tables
|
||||
html = html.replace(/^\|(.+)\|$/gm, (match, content) => {
|
||||
const cells = content.split('|').map(c => c.trim());
|
||||
if (cells.every(c => /^[-:]+$/.test(c))) {
|
||||
return '<!--table-sep-->';
|
||||
}
|
||||
if (cells.every(c => c.length < 30 && !c.includes(' '))) {
|
||||
return '<tr>' + cells.map(c => `<td>${c}</td>`).join('') + '</tr>';
|
||||
}
|
||||
return `<tr><td colspan="${cells.length}">${cells.join('</td><td>')}</td></tr>`;
|
||||
});
|
||||
|
||||
// Wrap rows in table
|
||||
let inTable = false;
|
||||
const lines = html.split('\n');
|
||||
let result = [];
|
||||
let tableBuffer = [];
|
||||
|
||||
for (let line of lines) {
|
||||
if (line.includes('<!--table-sep-->')) {
|
||||
if (tableBuffer.length > 0) {
|
||||
result.push('<table>' + tableBuffer.join('') + '</table>');
|
||||
tableBuffer = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('<tr>')) {
|
||||
tableBuffer.push(line);
|
||||
} else {
|
||||
if (tableBuffer.length > 0) {
|
||||
result.push('<table>' + tableBuffer.join('') + '</table>');
|
||||
tableBuffer = [];
|
||||
}
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
if (tableBuffer.length > 0) {
|
||||
result.push('<table>' + tableBuffer.join('') + '</table>');
|
||||
}
|
||||
html = result.join('\n');
|
||||
|
||||
// Paragraphs
|
||||
html = html.replace(/^(?!<[huldtbr]|<!--)/gm, '<p>$&</p>');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function showSpinner(el) {
|
||||
el.innerHTML = '<div class="spinner"></div> <span class="typing">Processing...</span>';
|
||||
}
|
||||
|
||||
function showToast(msg, type = "info") {
|
||||
const toast = document.createElement("div");
|
||||
toast.textContent = msg;
|
||||
toast.style.cssText = `
|
||||
position: fixed; bottom: 20px; right: 20px; padding: 10px 16px;
|
||||
background: ${type === "error" ? "var(--accent-red)" : "var(--accent-blue)"};
|
||||
color: #fff; border-radius: 6px; font-size: 13px; z-index: 999;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3); animation: fadein 0.3s;
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
// Load initial state
|
||||
async function loadInitialState() {
|
||||
try {
|
||||
const docs = await API.get("/api/documents");
|
||||
STORE.documents = docs.documents || [];
|
||||
renderDocList();
|
||||
|
||||
const health = await API.get("/health");
|
||||
updateHealth(health);
|
||||
|
||||
const models = await API.get("/api/ollama/models");
|
||||
renderModels(models);
|
||||
} catch (e) {
|
||||
console.warn("Initial load failed:", e);
|
||||
updateHealth({ status: "err" });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user