llm-kb 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/bin/{chunk-DHOXVEIR.js → chunk-3WBSKCCH.js} +96 -119
- package/bin/chunk-EZ7LPPEP.js +218 -0
- package/bin/chunk-Y2764FFH.js +1356 -0
- package/bin/cli.js +385 -874
- package/bin/{indexer-KSYRIVVN.js → indexer-K37QM2HP.js} +2 -1
- package/bin/public/index.html +949 -0
- package/bin/server-QC5SN6T4.js +1069 -0
- package/package.json +4 -3
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>llm-kb</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&family=Instrument+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
9
|
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
10
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
|
11
|
+
<style>
|
|
12
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
13
|
+
html, body { height: 100%; overflow: hidden; }
|
|
14
|
+
body { font-family: 'Instrument Sans', system-ui, sans-serif; font-size: 14px; line-height: 1.6; color: #4a4340; background: #f8f6f3; -webkit-font-smoothing: antialiased; }
|
|
15
|
+
:root { --paper:#f8f6f3; --paper-warm:#f2efe9; --paper-dim:#e8e4dd; --ink:#1a1520; --ink-soft:#4a4340; --ink-faint:#7a7470; --mid:#9b9590; --mid-light:#c4bfb8; --purple:#4F2D7F; --purple-soft:#6B4C9A; --purple-bg:#2A1845; --teal:#0d7d85; --good:#2d8a4e; --warn:#b87a00; --urgent:#c41d2e; }
|
|
16
|
+
.font-serif { font-family: 'Fraunces', Georgia, serif; }
|
|
17
|
+
.font-mono { font-family: 'JetBrains Mono', monospace; }
|
|
18
|
+
|
|
19
|
+
/* ── Layout ─── */
|
|
20
|
+
#app { display: flex; height: 100vh; }
|
|
21
|
+
#sidebar { width: 220px; min-width: 220px; background: var(--purple-bg); color: rgba(255,255,255,0.7); display: flex; flex-direction: column; border-right: 1px solid rgba(255,255,255,0.08); }
|
|
22
|
+
#sidebar-brand { padding: 20px 20px 16px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
23
|
+
#sidebar-brand h1 { font-family: 'Fraunces', Georgia, serif; font-size: 20px; font-weight: 700; color: white; }
|
|
24
|
+
#sidebar-brand .subtitle { font-size: 11px; font-weight: 600; letter-spacing: 1.5px; text-transform: uppercase; color: rgba(255,255,255,0.35); margin-top: 4px; }
|
|
25
|
+
.nav-section-label { font-size: 9.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 1.5px; color: rgba(255,255,255,0.25); padding: 8px 8px 4px; }
|
|
26
|
+
.nav-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; font-size: 13px; font-weight: 450; color: rgba(255,255,255,0.55); cursor: pointer; transition: all 0.15s; text-decoration: none; border: none; background: none; width: 100%; text-align: left; }
|
|
27
|
+
.nav-item:hover { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.8); }
|
|
28
|
+
.nav-item.active { background: rgba(255,255,255,0.1); color: white; font-weight: 550; }
|
|
29
|
+
#sidebar-nav { padding: 8px 12px; }
|
|
30
|
+
#sessions-list { flex: 1; overflow-y: auto; padding: 4px 12px; }
|
|
31
|
+
.session-item { display: block; width: 100%; padding: 8px 10px; border-radius: 6px; font-size: 13px; color: rgba(255,255,255,0.55); cursor: pointer; transition: all 0.15s; text-align: left; border: none; background: none; }
|
|
32
|
+
.session-item:hover { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.8); }
|
|
33
|
+
.session-item .title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
34
|
+
.session-item .meta { font-size: 10.5px; color: rgba(255,255,255,0.25); margin-top: 2px; }
|
|
35
|
+
#sidebar-footer { padding: 12px; border-top: 1px solid rgba(255,255,255,0.08); }
|
|
36
|
+
#new-chat-btn { display: flex; align-items: center; justify-content: center; gap: 6px; width: 100%; padding: 8px; border-radius: 6px; font-size: 13px; font-weight: 550; color: white; background: rgba(255,255,255,0.1); border: none; cursor: pointer; transition: all 0.15s; }
|
|
37
|
+
#new-chat-btn:hover { background: rgba(255,255,255,0.15); }
|
|
38
|
+
|
|
39
|
+
/* ── Main ─── */
|
|
40
|
+
#main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
41
|
+
#header { display: flex; align-items: center; justify-content: space-between; padding: 12px 24px; border-bottom: 1px solid var(--paper-dim); }
|
|
42
|
+
.tab { padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 500; color: var(--ink-faint); cursor: pointer; border: none; background: none; transition: all 0.15s; }
|
|
43
|
+
.tab:hover { background: var(--paper-warm); color: var(--ink-soft); }
|
|
44
|
+
.tab.active { background: var(--paper-warm); color: var(--ink); font-weight: 600; }
|
|
45
|
+
#chat-container { flex: 1; overflow-y: auto; padding: 24px; }
|
|
46
|
+
#chat-messages { max-width: 720px; margin: 0 auto; }
|
|
47
|
+
#welcome { text-align: center; padding: 80px 20px; }
|
|
48
|
+
#welcome h2 { font-family: 'Fraunces', Georgia, serif; font-size: 24px; font-weight: 600; color: var(--ink); }
|
|
49
|
+
#welcome p { margin-top: 8px; font-size: 14px; color: var(--ink-faint); max-width: 400px; margin-left: auto; margin-right: auto; }
|
|
50
|
+
#input-area { padding: 4px 24px 16px; }
|
|
51
|
+
#input-bar { max-width: 720px; margin: 0 auto; display: flex; align-items: center; gap: 8px; border: 1px solid var(--paper-dim); background: white; border-radius: 999px; padding: 8px 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); transition: all 0.15s; }
|
|
52
|
+
#input-bar:focus-within { border-color: var(--mid-light); box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
|
53
|
+
#input-bar input { flex: 1; border: none; outline: none; font-size: 14px; color: var(--ink); background: transparent; }
|
|
54
|
+
#input-bar input::placeholder { color: var(--mid); }
|
|
55
|
+
#send-btn { width: 28px; height: 28px; border-radius: 50%; border: none; background: var(--paper-dim); color: var(--mid-light); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; font-size: 14px; }
|
|
56
|
+
#send-btn.ready { background: var(--purple); color: white; }
|
|
57
|
+
#send-btn.ready:hover { background: var(--purple-soft); }
|
|
58
|
+
#status-bar { padding: 8px 24px; border-top: 1px solid var(--paper-dim); font-size: 11px; color: var(--mid); text-align: center; }
|
|
59
|
+
#status-bar .dot { display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: var(--good); margin-right: 4px; vertical-align: middle; }
|
|
60
|
+
|
|
61
|
+
/* ── Messages ─── */
|
|
62
|
+
.msg-user { display: flex; justify-content: flex-end; margin-bottom: 16px; }
|
|
63
|
+
.msg-user .bubble { max-width: 75%; background: var(--purple); color: white; padding: 10px 16px; border-radius: 16px 16px 4px 16px; font-size: 14px; line-height: 1.5; }
|
|
64
|
+
.msg-assistant { margin-bottom: 16px; max-width: 85%; }
|
|
65
|
+
.msg-model { font-size: 12px; color: var(--mid); margin-bottom: 6px; }
|
|
66
|
+
.msg-thinking { margin-bottom: 8px; font-size: 13px; color: var(--ink-faint); border-left: 2px solid var(--border); padding-left: 10px; cursor: default; }
|
|
67
|
+
.msg-thinking .thinking-header { display: flex; align-items: center; gap: 8px; }
|
|
68
|
+
.msg-thinking .thinking-content { margin-top: 4px; white-space: pre-wrap; max-height: 200px; overflow-y: auto; font-size: 12px; opacity: 0.7; }
|
|
69
|
+
.msg-thinking.collapsed .thinking-content { max-height: 1.4em; overflow: hidden; cursor: pointer; }
|
|
70
|
+
.msg-thinking.collapsed .msg-thinking-text::after { content: ' (click to expand)'; font-size: 11px; opacity: 0.5; }
|
|
71
|
+
.msg-thinking.empty .thinking-content { display: none; }
|
|
72
|
+
.msg-thinking.empty .msg-thinking-text::after { content: ''; }
|
|
73
|
+
.thinking-dots { display: flex; gap: 3px; }
|
|
74
|
+
.thinking-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--purple); animation: bounce 1.4s ease-in-out infinite; }
|
|
75
|
+
.thinking-dots span:nth-child(2) { animation-delay: 0.15s; }
|
|
76
|
+
.thinking-dots span:nth-child(3) { animation-delay: 0.3s; }
|
|
77
|
+
@keyframes bounce { 0%,80%,100% { transform: translateY(0); } 40% { transform: translateY(-6px); } }
|
|
78
|
+
.msg-tool { display: flex; align-items: center; gap: 8px; padding: 8px 12px; margin-bottom: 4px; border: 1px solid var(--paper-dim); background: white; border-radius: 8px; font-size: 13px; color: var(--ink-soft); }
|
|
79
|
+
.tool-elapsed { margin-left: auto; font-size: 11px; color: var(--ink-faint); font-variant-numeric: tabular-nums; }
|
|
80
|
+
.tool-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
|
81
|
+
.tool-dot.running { background: var(--teal); animation: pulse 1.5s ease-in-out infinite; }
|
|
82
|
+
.tool-dot.done { background: var(--good); }
|
|
83
|
+
.tool-dot.error { background: var(--urgent); }
|
|
84
|
+
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
|
|
85
|
+
.msg-answer { background: var(--paper-warm); padding: 12px 16px; border-radius: 16px 16px 16px 4px; font-size: 14px; line-height: 1.6; color: var(--ink-soft); }
|
|
86
|
+
.msg-answer h1,.msg-answer h2,.msg-answer h3 { font-family:'Fraunces',Georgia,serif; color:var(--ink); margin-top:12px; margin-bottom:6px; }
|
|
87
|
+
.msg-answer h1 { font-size:20px; } .msg-answer h2 { font-size:17px; } .msg-answer h3 { font-size:15px; }
|
|
88
|
+
.msg-answer p { margin: 6px 0; } .msg-answer strong { font-weight:600; color:var(--ink); }
|
|
89
|
+
.msg-answer ul,.msg-answer ol { padding-left:20px; margin:4px 0 8px; } .msg-answer li { margin:2px 0; }
|
|
90
|
+
.msg-answer code { font-family:'JetBrains Mono',monospace; font-size:12px; background:var(--paper); padding:1px 4px; border-radius:3px; }
|
|
91
|
+
.msg-answer blockquote { border-left:3px solid var(--purple); padding-left:12px; margin:8px 0; color:var(--ink-faint); font-style:italic; }
|
|
92
|
+
.msg-answer hr { border:none; border-top:1px solid var(--paper-dim); margin:12px 0; }
|
|
93
|
+
.msg-answer table { width:100%; border-collapse:collapse; margin:8px 0; font-size:13px; }
|
|
94
|
+
.msg-answer th { text-align:left; font-weight:600; padding:8px 12px; border-bottom:2px solid var(--paper-dim); background:var(--paper); font-size:11px; text-transform:uppercase; letter-spacing:0.5px; }
|
|
95
|
+
.msg-answer td { padding:6px 12px; border-bottom:1px solid var(--paper-dim); }
|
|
96
|
+
.msg-done { font-size: 11px; color: var(--mid); margin-top: 6px; }
|
|
97
|
+
.citations-block { margin-top: 12px; border-top: 1px solid var(--paper-dim); padding-top: 10px; }
|
|
98
|
+
.citation-chip { display: flex; flex-direction: column; padding: 6px 10px; margin-bottom: 4px; border-radius: 8px; cursor: pointer; transition: background 0.15s; font-size: 13px; }
|
|
99
|
+
.citation-chip:hover { background: var(--paper-warm); }
|
|
100
|
+
.citation-num { font-weight: 700; color: var(--purple); }
|
|
101
|
+
.citation-file { color: var(--teal); }
|
|
102
|
+
.citation-quote { color: var(--ink-faint); font-size: 12px; margin-top: 2px; }
|
|
103
|
+
.citation-status { font-size: 11px; margin-top: 2px; }
|
|
104
|
+
.citation-chip.active { background: white; box-shadow: inset 0 0 0 1px var(--paper-dim); }
|
|
105
|
+
.citation-link { color: var(--purple); text-decoration: none; font-weight: 600; cursor: pointer; border-bottom: 1px dotted var(--purple); }
|
|
106
|
+
.citation-link:hover { background: rgba(79, 45, 127, 0.08); border-radius: 2px; }
|
|
107
|
+
|
|
108
|
+
/* ── Source Viewer ─── */
|
|
109
|
+
#source-viewer { width: 0; min-width: 0; opacity: 0; pointer-events: none; overflow: hidden; display: flex; flex-direction: column; background: white; border-left: 1px solid var(--paper-dim); transition: width 0.2s ease, opacity 0.2s ease; }
|
|
110
|
+
#source-viewer.open { width: min(44vw, 560px); min-width: 380px; opacity: 1; pointer-events: auto; }
|
|
111
|
+
#source-viewer-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--paper-dim); }
|
|
112
|
+
#source-viewer-title-wrap { min-width: 0; }
|
|
113
|
+
#source-viewer-title { font-size: 14px; font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
114
|
+
#source-viewer-meta { font-size: 12px; color: var(--ink-faint); margin-top: 2px; }
|
|
115
|
+
#source-viewer-close { width: 30px; height: 30px; border-radius: 50%; border: none; background: var(--paper-warm); color: var(--ink-soft); cursor: pointer; font-size: 18px; line-height: 1; }
|
|
116
|
+
#source-viewer-close:hover { background: var(--paper-dim); }
|
|
117
|
+
#source-viewer-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--paper-dim); background: #fcfbf8; }
|
|
118
|
+
.viewer-nav { display: flex; gap: 8px; }
|
|
119
|
+
.viewer-btn { border: 1px solid var(--paper-dim); background: white; color: var(--ink-soft); border-radius: 8px; padding: 6px 10px; font-size: 12px; cursor: pointer; }
|
|
120
|
+
.viewer-btn:hover:not(:disabled) { background: var(--paper-warm); }
|
|
121
|
+
.viewer-btn:disabled { opacity: 0.45; cursor: default; }
|
|
122
|
+
#source-viewer-page-indicator { font-size: 12px; color: var(--ink-faint); }
|
|
123
|
+
#source-viewer-body { flex: 1; overflow: auto; padding: 16px; background: #fbfaf8; }
|
|
124
|
+
#source-viewer-empty, #source-viewer-loading, #source-viewer-error { display: flex; align-items: center; justify-content: center; min-height: 180px; text-align: center; font-size: 13px; color: var(--ink-faint); }
|
|
125
|
+
#source-viewer-error { color: var(--urgent); }
|
|
126
|
+
#source-viewer-canvas-wrap { display: flex; justify-content: center; align-items: flex-start; }
|
|
127
|
+
#source-viewer-page-wrap { position: relative; display: inline-block; }
|
|
128
|
+
#source-viewer-canvas { display: block; background: white; border-radius: 10px; box-shadow: 0 8px 24px rgba(26, 21, 32, 0.08); }
|
|
129
|
+
#source-viewer-overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
|
|
130
|
+
.source-highlight { fill: rgba(255, 200, 0, 0.5); stroke: rgba(220, 160, 0, 1); stroke-width: 2; rx: 2; animation: sourcePulse 1.8s ease-in-out infinite; }
|
|
131
|
+
@keyframes sourcePulse { 0%,100% { fill-opacity: 0.5; } 50% { fill-opacity: 0.7; } }
|
|
132
|
+
#source-viewer-empty[hidden],
|
|
133
|
+
#source-viewer-loading[hidden],
|
|
134
|
+
#source-viewer-error[hidden],
|
|
135
|
+
#source-viewer-canvas-wrap[hidden] { display: none !important; }
|
|
136
|
+
|
|
137
|
+
@media (max-width: 1100px) {
|
|
138
|
+
#source-viewer.open { width: 50vw; min-width: 320px; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@media (max-width: 900px) {
|
|
142
|
+
#app { position: relative; }
|
|
143
|
+
#source-viewer { position: absolute; top: 0; right: 0; height: 100%; z-index: 20; box-shadow: -16px 0 40px rgba(26, 21, 32, 0.12); }
|
|
144
|
+
#source-viewer.open { width: 100%; min-width: 0; }
|
|
145
|
+
}
|
|
146
|
+
</style>
|
|
147
|
+
</head>
|
|
148
|
+
<body>
|
|
149
|
+
<div id="app">
|
|
150
|
+
<aside id="sidebar">
|
|
151
|
+
<div id="sidebar-brand"><h1 class="font-serif">llm-kb</h1><div class="subtitle">Knowledge Base</div></div>
|
|
152
|
+
<nav id="sidebar-nav">
|
|
153
|
+
<div class="nav-section-label">Navigate</div>
|
|
154
|
+
<button class="nav-item active" data-tab="chat">💬 Chat</button>
|
|
155
|
+
<button class="nav-item" data-tab="wiki">📚 Wiki</button>
|
|
156
|
+
</nav>
|
|
157
|
+
<div class="nav-section-label" style="padding:8px 20px 4px;">Sessions</div>
|
|
158
|
+
<div id="sessions-list"><div style="padding:12px 10px;font-size:12px;color:rgba(255,255,255,0.25);">Loading...</div></div>
|
|
159
|
+
<div id="sidebar-footer"><button id="new-chat-btn">+ New Chat</button></div>
|
|
160
|
+
</aside>
|
|
161
|
+
<div id="main">
|
|
162
|
+
<header id="header">
|
|
163
|
+
<div class="font-serif" style="font-size:15px;font-weight:600;color:var(--ink);">Chat</div>
|
|
164
|
+
<div id="header-tabs">
|
|
165
|
+
<button class="tab active" data-tab="chat">Chat</button>
|
|
166
|
+
<button class="tab" data-tab="wiki">Wiki</button>
|
|
167
|
+
</div>
|
|
168
|
+
</header>
|
|
169
|
+
<div id="chat-container">
|
|
170
|
+
<div id="chat-messages">
|
|
171
|
+
<div id="welcome">
|
|
172
|
+
<h2 class="font-serif">What would you like to investigate?</h2>
|
|
173
|
+
<p>Ask about any document in the knowledge base. Answers include verified citations with bounding boxes.</p>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
<div id="input-area">
|
|
178
|
+
<div id="input-bar">
|
|
179
|
+
<input type="text" id="chat-input" placeholder="Ask anything..." autocomplete="off" />
|
|
180
|
+
<button id="send-btn" aria-label="Send">↑</button>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
<div id="status-bar"><span class="dot"></span><span id="status-text">Connecting...</span></div>
|
|
184
|
+
</div>
|
|
185
|
+
<aside id="source-viewer" aria-hidden="true">
|
|
186
|
+
<div id="source-viewer-header">
|
|
187
|
+
<div id="source-viewer-title-wrap">
|
|
188
|
+
<div id="source-viewer-title">Source Viewer</div>
|
|
189
|
+
<div id="source-viewer-meta">Click a citation to inspect the original page.</div>
|
|
190
|
+
</div>
|
|
191
|
+
<button id="source-viewer-close" aria-label="Close source viewer">×</button>
|
|
192
|
+
</div>
|
|
193
|
+
<div id="source-viewer-toolbar">
|
|
194
|
+
<div class="viewer-nav">
|
|
195
|
+
<button class="viewer-btn" id="source-viewer-prev">← Prev</button>
|
|
196
|
+
<button class="viewer-btn" id="source-viewer-next">Next →</button>
|
|
197
|
+
</div>
|
|
198
|
+
<div id="source-viewer-page-indicator">Page —</div>
|
|
199
|
+
</div>
|
|
200
|
+
<div id="source-viewer-body">
|
|
201
|
+
<div id="source-viewer-empty">Click any citation chip to open the cited PDF page here.</div>
|
|
202
|
+
<div id="source-viewer-loading" hidden>Loading PDF…</div>
|
|
203
|
+
<div id="source-viewer-error" hidden></div>
|
|
204
|
+
<div id="source-viewer-canvas-wrap" hidden>
|
|
205
|
+
<div id="source-viewer-page-wrap">
|
|
206
|
+
<canvas id="source-viewer-canvas"></canvas>
|
|
207
|
+
<svg id="source-viewer-overlay" aria-hidden="true"></svg>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</aside>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<script>
|
|
215
|
+
// ── State ────────────────────────────────────────────────────
|
|
216
|
+
let ws = null;
|
|
217
|
+
let ready = false;
|
|
218
|
+
let streaming = false;
|
|
219
|
+
let currentAssistant = null; // DOM container for current assistant response
|
|
220
|
+
let answerEl = null; // current answer bubble element
|
|
221
|
+
let answerText = ''; // raw accumulated markdown
|
|
222
|
+
let viewerPdf = null; // active PDF document in Source Viewer
|
|
223
|
+
let viewerFile = ''; // current PDF filename
|
|
224
|
+
let viewerPage = 1; // current page number in Source Viewer
|
|
225
|
+
let viewerCitation = null; // current citation shown in Source Viewer
|
|
226
|
+
let activeCitationChip = null;
|
|
227
|
+
let resizeTimer = null;
|
|
228
|
+
|
|
229
|
+
const messagesEl = document.getElementById('chat-messages');
|
|
230
|
+
const chatContainer = document.getElementById('chat-container');
|
|
231
|
+
const input = document.getElementById('chat-input');
|
|
232
|
+
const sendBtn = document.getElementById('send-btn');
|
|
233
|
+
const statusText = document.getElementById('status-text');
|
|
234
|
+
const welcomeEl = document.getElementById('welcome');
|
|
235
|
+
const sourceViewerEl = document.getElementById('source-viewer');
|
|
236
|
+
const sourceViewerTitle = document.getElementById('source-viewer-title');
|
|
237
|
+
const sourceViewerMeta = document.getElementById('source-viewer-meta');
|
|
238
|
+
const sourceViewerPageIndicator = document.getElementById('source-viewer-page-indicator');
|
|
239
|
+
const sourceViewerBody = document.getElementById('source-viewer-body');
|
|
240
|
+
const sourceViewerEmpty = document.getElementById('source-viewer-empty');
|
|
241
|
+
const sourceViewerLoading = document.getElementById('source-viewer-loading');
|
|
242
|
+
const sourceViewerError = document.getElementById('source-viewer-error');
|
|
243
|
+
const sourceViewerCanvasWrap = document.getElementById('source-viewer-canvas-wrap');
|
|
244
|
+
const sourceViewerCanvas = document.getElementById('source-viewer-canvas');
|
|
245
|
+
const sourceViewerOverlay = document.getElementById('source-viewer-overlay');
|
|
246
|
+
const sourceViewerPageWrap = document.getElementById('source-viewer-page-wrap');
|
|
247
|
+
const sourceViewerPrevBtn = document.getElementById('source-viewer-prev');
|
|
248
|
+
const sourceViewerNextBtn = document.getElementById('source-viewer-next');
|
|
249
|
+
const sourceViewerCloseBtn = document.getElementById('source-viewer-close');
|
|
250
|
+
|
|
251
|
+
if (window.pdfjsLib) {
|
|
252
|
+
window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── WebSocket ────────────────────────────────────────────────
|
|
256
|
+
function connectWS() {
|
|
257
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
258
|
+
ws = new WebSocket(`${protocol}//${location.host}/ws/chat`);
|
|
259
|
+
|
|
260
|
+
ws.onopen = () => { statusText.textContent = 'Connecting to agent...'; };
|
|
261
|
+
|
|
262
|
+
ws.onmessage = (evt) => {
|
|
263
|
+
const data = JSON.parse(evt.data);
|
|
264
|
+
handleEvent(data);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
ws.onclose = () => {
|
|
268
|
+
ready = false;
|
|
269
|
+
statusText.textContent = 'Disconnected — reconnecting...';
|
|
270
|
+
document.querySelector('#status-bar .dot').style.background = 'var(--urgent)';
|
|
271
|
+
setTimeout(connectWS, 2000);
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function handleEvent(data) {
|
|
276
|
+
switch (data.type) {
|
|
277
|
+
case 'connected':
|
|
278
|
+
statusText.textContent = 'Initializing session...';
|
|
279
|
+
break;
|
|
280
|
+
case 'ready':
|
|
281
|
+
ready = true;
|
|
282
|
+
streaming = false;
|
|
283
|
+
document.querySelector('#status-bar .dot').style.background = 'var(--good)';
|
|
284
|
+
loadStatus();
|
|
285
|
+
break;
|
|
286
|
+
case 'error':
|
|
287
|
+
addSystemMessage('Error: ' + data.message);
|
|
288
|
+
streaming = false;
|
|
289
|
+
input.disabled = false;
|
|
290
|
+
break;
|
|
291
|
+
|
|
292
|
+
// ── Agent streaming events ───
|
|
293
|
+
case 'status':
|
|
294
|
+
ensureAssistant();
|
|
295
|
+
currentAssistant.querySelector('.msg-model').textContent = '⟡ ' + data.model;
|
|
296
|
+
break;
|
|
297
|
+
case 'thinking_start':
|
|
298
|
+
ensureAssistant();
|
|
299
|
+
if (answerEl) answerEl = null; // new phase — allow new answer bubble
|
|
300
|
+
showThinking(true);
|
|
301
|
+
break;
|
|
302
|
+
case 'thinking_delta':
|
|
303
|
+
appendThinkingText(data.text);
|
|
304
|
+
break;
|
|
305
|
+
case 'thinking_end':
|
|
306
|
+
collapseThinking();
|
|
307
|
+
break;
|
|
308
|
+
case 'tool_start':
|
|
309
|
+
ensureAssistant();
|
|
310
|
+
if (answerEl) answerEl = null; // new phase — allow new answer bubble after tools
|
|
311
|
+
addToolCard(data.id, data.label, data.name);
|
|
312
|
+
break;
|
|
313
|
+
case 'tool_end':
|
|
314
|
+
updateToolCard(data.id, data.isError);
|
|
315
|
+
break;
|
|
316
|
+
case 'text_start':
|
|
317
|
+
ensureAssistant();
|
|
318
|
+
ensureAnswerBubble();
|
|
319
|
+
break;
|
|
320
|
+
case 'text_delta':
|
|
321
|
+
ensureAnswerBubble();
|
|
322
|
+
answerText += data.text;
|
|
323
|
+
renderAnswer();
|
|
324
|
+
scrollToBottom();
|
|
325
|
+
break;
|
|
326
|
+
case 'text_end':
|
|
327
|
+
renderAnswer();
|
|
328
|
+
break;
|
|
329
|
+
case 'citations':
|
|
330
|
+
renderCitations(data.data);
|
|
331
|
+
preloadCitationPdfs(data.data);
|
|
332
|
+
break;
|
|
333
|
+
case 'done':
|
|
334
|
+
finishResponse(data);
|
|
335
|
+
streaming = false;
|
|
336
|
+
input.disabled = false;
|
|
337
|
+
input.focus();
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── Send ─────────────────────────────────────────────────────
|
|
343
|
+
function sendMessage(text) {
|
|
344
|
+
if (!ready) { addSystemMessage('Session still initializing, please wait...'); return; }
|
|
345
|
+
if (streaming || !text.trim()) return;
|
|
346
|
+
if (welcomeEl) { welcomeEl.remove(); }
|
|
347
|
+
streaming = true;
|
|
348
|
+
input.disabled = true;
|
|
349
|
+
|
|
350
|
+
// Add user bubble
|
|
351
|
+
const div = document.createElement('div');
|
|
352
|
+
div.className = 'msg-user';
|
|
353
|
+
div.innerHTML = `<div class="bubble">${escapeHtml(text)}</div>`;
|
|
354
|
+
messagesEl.appendChild(div);
|
|
355
|
+
scrollToBottom();
|
|
356
|
+
|
|
357
|
+
// Reset assistant state
|
|
358
|
+
currentAssistant = null;
|
|
359
|
+
answerEl = null;
|
|
360
|
+
answerText = '';
|
|
361
|
+
answerSegments = [];
|
|
362
|
+
activeThinkingEl = null;
|
|
363
|
+
|
|
364
|
+
ws.send(JSON.stringify({ type: 'message', text }));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
input.addEventListener('input', () => {
|
|
368
|
+
sendBtn.classList.toggle('ready', input.value.trim().length > 0);
|
|
369
|
+
});
|
|
370
|
+
input.addEventListener('keydown', (e) => {
|
|
371
|
+
if (e.key === 'Enter' && input.value.trim()) {
|
|
372
|
+
sendMessage(input.value.trim());
|
|
373
|
+
input.value = '';
|
|
374
|
+
sendBtn.classList.remove('ready');
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
sendBtn.addEventListener('click', () => {
|
|
378
|
+
if (input.value.trim()) {
|
|
379
|
+
sendMessage(input.value.trim());
|
|
380
|
+
input.value = '';
|
|
381
|
+
sendBtn.classList.remove('ready');
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// ── DOM builders ─────────────────────────────────────────────
|
|
386
|
+
function ensureAssistant() {
|
|
387
|
+
if (currentAssistant) return;
|
|
388
|
+
currentAssistant = document.createElement('div');
|
|
389
|
+
currentAssistant.className = 'msg-assistant';
|
|
390
|
+
currentAssistant.innerHTML = `<div class="msg-model font-mono">⟡</div>`;
|
|
391
|
+
messagesEl.appendChild(currentAssistant);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let activeThinkingEl = null;
|
|
395
|
+
|
|
396
|
+
function showThinking(show) {
|
|
397
|
+
if (!currentAssistant) return;
|
|
398
|
+
if (show) {
|
|
399
|
+
const el = document.createElement('div');
|
|
400
|
+
el.className = 'msg-thinking';
|
|
401
|
+
el.innerHTML = `<div class="thinking-header"><div class="thinking-dots"><span></span><span></span><span></span></div><span class="msg-thinking-text">Thinking</span></div><div class="thinking-content"></div>`;
|
|
402
|
+
currentAssistant.appendChild(el);
|
|
403
|
+
activeThinkingEl = el;
|
|
404
|
+
scrollToBottom();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function appendThinkingText(text) {
|
|
409
|
+
if (!activeThinkingEl) return;
|
|
410
|
+
const content = activeThinkingEl.querySelector('.thinking-content');
|
|
411
|
+
if (content) {
|
|
412
|
+
content.textContent += text;
|
|
413
|
+
scrollToBottom();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function collapseThinking() {
|
|
418
|
+
if (!activeThinkingEl) return;
|
|
419
|
+
const dots = activeThinkingEl.querySelector('.thinking-dots');
|
|
420
|
+
if (dots) dots.remove();
|
|
421
|
+
const content = activeThinkingEl.querySelector('.thinking-content');
|
|
422
|
+
if (content && content.textContent.trim().length > 0) {
|
|
423
|
+
// Collapse to show first line with toggle
|
|
424
|
+
activeThinkingEl.classList.add('collapsed');
|
|
425
|
+
activeThinkingEl.onclick = () => activeThinkingEl.classList.toggle('collapsed');
|
|
426
|
+
} else {
|
|
427
|
+
// No thinking text — just show "Thought" label
|
|
428
|
+
activeThinkingEl.classList.add('collapsed');
|
|
429
|
+
activeThinkingEl.classList.add('empty');
|
|
430
|
+
}
|
|
431
|
+
activeThinkingEl = null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const toolTimers = {};
|
|
435
|
+
|
|
436
|
+
function addToolCard(id, label, name) {
|
|
437
|
+
if (!currentAssistant) return;
|
|
438
|
+
const el = document.createElement('div');
|
|
439
|
+
el.className = 'msg-tool';
|
|
440
|
+
el.dataset.toolId = id;
|
|
441
|
+
el.innerHTML = `<span class="tool-dot running"></span><span>${escapeHtml(label)}</span><span class="tool-elapsed"></span>`;
|
|
442
|
+
currentAssistant.appendChild(el);
|
|
443
|
+
scrollToBottom();
|
|
444
|
+
// Start elapsed timer
|
|
445
|
+
const start = Date.now();
|
|
446
|
+
const timerEl = el.querySelector('.tool-elapsed');
|
|
447
|
+
toolTimers[id] = setInterval(() => {
|
|
448
|
+
const secs = ((Date.now() - start) / 1000).toFixed(0);
|
|
449
|
+
timerEl.textContent = `${secs}s`;
|
|
450
|
+
}, 1000);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function updateToolCard(id, isError) {
|
|
454
|
+
if (!currentAssistant) return;
|
|
455
|
+
// Stop timer
|
|
456
|
+
if (toolTimers[id]) { clearInterval(toolTimers[id]); delete toolTimers[id]; }
|
|
457
|
+
const el = currentAssistant.querySelector(`.msg-tool[data-tool-id="${id}"]`);
|
|
458
|
+
if (!el) return;
|
|
459
|
+
const dot = el.querySelector('.tool-dot');
|
|
460
|
+
if (dot) { dot.classList.remove('running'); dot.classList.add(isError ? 'error' : 'done'); }
|
|
461
|
+
const timer = el.querySelector('.tool-elapsed');
|
|
462
|
+
if (timer) timer.textContent = '';
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** All answer segments — each gets its own div for interleaving with tools/thinking */
|
|
466
|
+
let answerSegments = [];
|
|
467
|
+
|
|
468
|
+
function ensureAnswerBubble() {
|
|
469
|
+
if (answerEl) return;
|
|
470
|
+
if (!currentAssistant) ensureAssistant();
|
|
471
|
+
answerEl = document.createElement('div');
|
|
472
|
+
answerEl.className = 'msg-answer';
|
|
473
|
+
answerEl._segmentStart = answerText.length;
|
|
474
|
+
currentAssistant.appendChild(answerEl);
|
|
475
|
+
answerSegments.push(answerEl);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function renderAnswer() {
|
|
479
|
+
if (!answerEl) return;
|
|
480
|
+
// Get text for this segment only
|
|
481
|
+
const segStart = answerEl._segmentStart || 0;
|
|
482
|
+
let display = answerText.slice(segStart);
|
|
483
|
+
// Strip CITATIONS block from display
|
|
484
|
+
const citIdx = display.search(/^CITATIONS:\s*$/im);
|
|
485
|
+
if (citIdx >= 0) display = display.slice(0, citIdx).trimEnd();
|
|
486
|
+
try {
|
|
487
|
+
answerEl.innerHTML = marked.parse(display);
|
|
488
|
+
} catch {
|
|
489
|
+
answerEl.textContent = display;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Final render — re-render all segments to strip CITATIONS from whichever has it */
|
|
494
|
+
function renderAllAnswers() {
|
|
495
|
+
for (const seg of answerSegments) {
|
|
496
|
+
const segStart = seg._segmentStart || 0;
|
|
497
|
+
// Find end: next segment's start, or end of answerText
|
|
498
|
+
const segIdx = answerSegments.indexOf(seg);
|
|
499
|
+
const segEnd = segIdx < answerSegments.length - 1 ? answerSegments[segIdx + 1]._segmentStart : answerText.length;
|
|
500
|
+
let display = answerText.slice(segStart, segEnd);
|
|
501
|
+
const citIdx = display.search(/^CITATIONS:\s*$/im);
|
|
502
|
+
if (citIdx >= 0) display = display.slice(0, citIdx).trimEnd();
|
|
503
|
+
if (!display.trim()) { seg.style.display = 'none'; continue; }
|
|
504
|
+
try {
|
|
505
|
+
seg.innerHTML = marked.parse(display);
|
|
506
|
+
} catch {
|
|
507
|
+
seg.textContent = display;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Store citations for inline linking */
|
|
513
|
+
let lastCitations = [];
|
|
514
|
+
|
|
515
|
+
function linkInlineCitations(citations) {
|
|
516
|
+
if (!currentAssistant) return;
|
|
517
|
+
// Find all [N] references in answer segments and make them clickable
|
|
518
|
+
const answerEls = currentAssistant.querySelectorAll('.msg-answer');
|
|
519
|
+
for (const el of answerEls) {
|
|
520
|
+
el.innerHTML = el.innerHTML.replace(/\[(\d+)\]/g, (match, num) => {
|
|
521
|
+
const idx = parseInt(num, 10) - 1;
|
|
522
|
+
if (idx >= 0 && idx < citations.length) {
|
|
523
|
+
return `<a class="citation-link" href="#" data-citation-idx="${idx}" title="${escapeHtml(citations[idx].file)}, p.${citations[idx].page}">[${num}]</a>`;
|
|
524
|
+
}
|
|
525
|
+
return match;
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
// Add click handlers
|
|
529
|
+
currentAssistant.querySelectorAll('.citation-link').forEach(link => {
|
|
530
|
+
link.addEventListener('click', (e) => {
|
|
531
|
+
e.preventDefault();
|
|
532
|
+
const idx = parseInt(link.dataset.citationIdx, 10);
|
|
533
|
+
const chip = currentAssistant.querySelectorAll('.citation-chip')[idx];
|
|
534
|
+
openSourceViewer(citations[idx], chip);
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function renderCitations(citations) {
|
|
540
|
+
if (!currentAssistant || !citations.length) return;
|
|
541
|
+
lastCitations = citations;
|
|
542
|
+
const block = document.createElement('div');
|
|
543
|
+
block.className = 'citations-block';
|
|
544
|
+
for (let i = 0; i < citations.length; i++) {
|
|
545
|
+
const c = citations[i];
|
|
546
|
+
const pageStr = c.pages ? `p.${c.pages.map(p => p.page).join('-')}` : `p.${c.page}`;
|
|
547
|
+
const hasBbox = c.bbox || (c.pages && c.pages.length > 0);
|
|
548
|
+
const statusIcon = hasBbox ? '✅' : '⚠️';
|
|
549
|
+
const quote = c.quote.length > 70 ? c.quote.slice(0, 67) + '...' : c.quote;
|
|
550
|
+
|
|
551
|
+
const chip = document.createElement('div');
|
|
552
|
+
chip.className = 'citation-chip';
|
|
553
|
+
chip.innerHTML = `
|
|
554
|
+
<div><span class="citation-num">[${i+1}]</span> 📄 <span class="citation-file">${escapeHtml(c.file)}</span>, ${pageStr}</div>
|
|
555
|
+
<span class="citation-quote">"${escapeHtml(quote)}"</span>
|
|
556
|
+
<span class="citation-status">${statusIcon} ${hasBbox ? 'bbox verified' : 'no bbox'}</span>
|
|
557
|
+
`;
|
|
558
|
+
chip.onclick = () => {
|
|
559
|
+
openSourceViewer(c, chip);
|
|
560
|
+
};
|
|
561
|
+
block.appendChild(chip);
|
|
562
|
+
}
|
|
563
|
+
currentAssistant.appendChild(block);
|
|
564
|
+
scrollToBottom();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function citationStartPage(citation) {
|
|
568
|
+
if (Array.isArray(citation.pages) && citation.pages.length > 0) {
|
|
569
|
+
return citation.pages[0].page || citation.page || 1;
|
|
570
|
+
}
|
|
571
|
+
return citation.page || 1;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function setActiveCitationChip(chip) {
|
|
575
|
+
if (activeCitationChip) activeCitationChip.classList.remove('active');
|
|
576
|
+
activeCitationChip = chip || null;
|
|
577
|
+
if (activeCitationChip) activeCitationChip.classList.add('active');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function showSourceViewerState(state, message = '') {
|
|
581
|
+
sourceViewerEmpty.hidden = state !== 'empty';
|
|
582
|
+
sourceViewerLoading.hidden = state !== 'loading';
|
|
583
|
+
sourceViewerError.hidden = state !== 'error';
|
|
584
|
+
sourceViewerCanvasWrap.hidden = state !== 'canvas';
|
|
585
|
+
|
|
586
|
+
if (state === 'loading') sourceViewerLoading.textContent = message || 'Loading PDF…';
|
|
587
|
+
if (state === 'error') sourceViewerError.textContent = message || 'Failed to load source page.';
|
|
588
|
+
if (state === 'empty') sourceViewerEmpty.textContent = message || 'Click any citation chip to open the cited PDF page here.';
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function currentCitationBoxes(pageNum) {
|
|
592
|
+
if (!viewerCitation) return [];
|
|
593
|
+
if (Array.isArray(viewerCitation.pages) && viewerCitation.pages.length > 0) {
|
|
594
|
+
return viewerCitation.pages.filter(p => p.page === pageNum);
|
|
595
|
+
}
|
|
596
|
+
if (viewerCitation.bbox && (viewerCitation.page || 1) === pageNum) {
|
|
597
|
+
return [viewerCitation.bbox];
|
|
598
|
+
}
|
|
599
|
+
return [];
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function renderSourceHighlights(pageNum, pageWidth, pageHeight) {
|
|
603
|
+
const boxes = currentCitationBoxes(pageNum);
|
|
604
|
+
sourceViewerOverlay.setAttribute('viewBox', `0 0 ${pageWidth} ${pageHeight}`);
|
|
605
|
+
sourceViewerOverlay.innerHTML = boxes.map((box) =>
|
|
606
|
+
`<rect class="source-highlight" x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="4" ry="4"></rect>`
|
|
607
|
+
).join('');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function updateSourceViewerNav() {
|
|
611
|
+
const total = viewerPdf?.numPages || 0;
|
|
612
|
+
sourceViewerPrevBtn.disabled = !viewerPdf || viewerPage <= 1;
|
|
613
|
+
sourceViewerNextBtn.disabled = !viewerPdf || viewerPage >= total;
|
|
614
|
+
sourceViewerPageIndicator.textContent = total > 0 ? `Page ${viewerPage} of ${total}` : 'Page —';
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Preload PDFs in background when citations arrive so clicks are instant */
|
|
618
|
+
const preloadedPdfs = {};
|
|
619
|
+
function preloadCitationPdfs(citations) {
|
|
620
|
+
if (!window.pdfjsLib) return;
|
|
621
|
+
const files = [...new Set(citations.map(c => c.file))];
|
|
622
|
+
for (const file of files) {
|
|
623
|
+
if (preloadedPdfs[file]) continue;
|
|
624
|
+
const url = `/api/pdf/${encodeURIComponent(file)}`;
|
|
625
|
+
preloadedPdfs[file] = window.pdfjsLib.getDocument({ url }).promise.catch(() => null);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Cache bbox page data: bboxPageCache["file.pdf:3"] = pageData */
|
|
630
|
+
const bboxPageCache = {};
|
|
631
|
+
|
|
632
|
+
/** Client-side quote matching against bbox JSON to get accurate highlight */
|
|
633
|
+
async function verifyCitationBbox(citation) {
|
|
634
|
+
try {
|
|
635
|
+
const pageNum = citation.page || 1;
|
|
636
|
+
const cacheKey = `${citation.file}:${pageNum}`;
|
|
637
|
+
let bboxData = bboxPageCache[cacheKey];
|
|
638
|
+
if (!bboxData) {
|
|
639
|
+
const res = await fetch(`/api/bbox/${encodeURIComponent(citation.file)}?page=${pageNum}`);
|
|
640
|
+
if (!res.ok) return;
|
|
641
|
+
bboxData = await res.json();
|
|
642
|
+
if (!bboxData.error) bboxPageCache[cacheKey] = bboxData;
|
|
643
|
+
}
|
|
644
|
+
if (bboxData.error) return;
|
|
645
|
+
|
|
646
|
+
const page = bboxData.pages?.find(p => p.page === citation.page);
|
|
647
|
+
if (!page || !page.textItems?.length) return;
|
|
648
|
+
|
|
649
|
+
// Build text run from textItems (same logic as server-side)
|
|
650
|
+
const Y_TOL = 3, X_GAP = 15;
|
|
651
|
+
const items = page.textItems.filter(t => t.text.trim().length > 0);
|
|
652
|
+
if (!items.length) return;
|
|
653
|
+
|
|
654
|
+
const sorted = [...items].sort((a, b) => {
|
|
655
|
+
const dy = a.y - b.y;
|
|
656
|
+
return Math.abs(dy) > Y_TOL ? dy : a.x - b.x;
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// Group into lines
|
|
660
|
+
const lines = [];
|
|
661
|
+
let curLine = [sorted[0]], curY = sorted[0].y;
|
|
662
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
663
|
+
if (Math.abs(sorted[i].y - curY) <= Y_TOL) {
|
|
664
|
+
curLine.push(sorted[i]);
|
|
665
|
+
} else {
|
|
666
|
+
lines.push(curLine);
|
|
667
|
+
curLine = [sorted[i]];
|
|
668
|
+
curY = sorted[i].y;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
lines.push(curLine);
|
|
672
|
+
for (const line of lines) line.sort((a, b) => a.x - b.x);
|
|
673
|
+
|
|
674
|
+
// Concatenate with segments
|
|
675
|
+
let text = '';
|
|
676
|
+
const segments = [];
|
|
677
|
+
for (let li = 0; li < lines.length; li++) {
|
|
678
|
+
const line = lines[li];
|
|
679
|
+
for (let ii = 0; ii < line.length; ii++) {
|
|
680
|
+
const item = line[ii];
|
|
681
|
+
if (ii > 0) {
|
|
682
|
+
const prev = line[ii - 1];
|
|
683
|
+
const gap = item.x - (prev.x + prev.width);
|
|
684
|
+
text += gap > X_GAP ? ' ' : gap >= 0 ? ' ' : '';
|
|
685
|
+
}
|
|
686
|
+
const start = text.length;
|
|
687
|
+
text += item.text;
|
|
688
|
+
segments.push({ start, end: text.length, bbox: { x: item.x, y: item.y, width: item.width, height: item.height } });
|
|
689
|
+
}
|
|
690
|
+
if (li < lines.length - 1) text += '\n';
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Try exact match, then normalized
|
|
694
|
+
const quote = citation.quote;
|
|
695
|
+
let matchStart = -1, matchEnd = -1;
|
|
696
|
+
|
|
697
|
+
// Exact
|
|
698
|
+
const idx = text.indexOf(quote);
|
|
699
|
+
if (idx >= 0) { matchStart = idx; matchEnd = idx + quote.length; }
|
|
700
|
+
|
|
701
|
+
// Normalized
|
|
702
|
+
if (matchStart < 0) {
|
|
703
|
+
const norm = s => s.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
|
|
704
|
+
const nHay = norm(text), nNeedle = norm(quote);
|
|
705
|
+
const nIdx = nHay.indexOf(nNeedle);
|
|
706
|
+
if (nIdx >= 0) {
|
|
707
|
+
// Map back to original positions
|
|
708
|
+
let nPos = 0, oStart = -1, oEnd = -1;
|
|
709
|
+
for (let i = 0; i < text.length && oEnd < 0; i++) {
|
|
710
|
+
const ch = text[i];
|
|
711
|
+
if (/\s/.test(ch)) {
|
|
712
|
+
let j = i;
|
|
713
|
+
while (j < text.length && /\s/.test(text[j])) j++;
|
|
714
|
+
if (nPos === nIdx) oStart = i;
|
|
715
|
+
nPos++;
|
|
716
|
+
if (nPos >= nIdx + nNeedle.length && oEnd < 0) oEnd = j;
|
|
717
|
+
i = j - 1;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (/[^\w]/.test(ch)) continue;
|
|
721
|
+
if (nPos === nIdx) oStart = i;
|
|
722
|
+
nPos++;
|
|
723
|
+
if (nPos >= nIdx + nNeedle.length && oEnd < 0) oEnd = i + 1;
|
|
724
|
+
}
|
|
725
|
+
if (oStart >= 0 && oEnd > oStart) { matchStart = oStart; matchEnd = oEnd; }
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (matchStart < 0) return;
|
|
730
|
+
|
|
731
|
+
// Merge bboxes of matching segments
|
|
732
|
+
const boxes = segments.filter(s => s.end > matchStart && s.start < matchEnd).map(s => s.bbox);
|
|
733
|
+
if (!boxes.length) return;
|
|
734
|
+
|
|
735
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
736
|
+
for (const b of boxes) {
|
|
737
|
+
if (b.x < minX) minX = b.x;
|
|
738
|
+
if (b.y < minY) minY = b.y;
|
|
739
|
+
if (b.x + b.width > maxX) maxX = b.x + b.width;
|
|
740
|
+
if (b.y + b.height > maxY) maxY = b.y + b.height;
|
|
741
|
+
}
|
|
742
|
+
citation.bbox = {
|
|
743
|
+
x: Math.round(minX * 100) / 100,
|
|
744
|
+
y: Math.round(minY * 100) / 100,
|
|
745
|
+
width: Math.round((maxX - minX) * 100) / 100,
|
|
746
|
+
height: Math.round((maxY - minY) * 100) / 100,
|
|
747
|
+
};
|
|
748
|
+
} catch (err) {
|
|
749
|
+
console.warn('[viewer] Citation bbox verification failed:', err);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function openSourceViewer(citation, chip) {
|
|
754
|
+
setActiveCitationChip(chip);
|
|
755
|
+
viewerCitation = citation;
|
|
756
|
+
sourceViewerEl.classList.add('open');
|
|
757
|
+
sourceViewerEl.setAttribute('aria-hidden', 'false');
|
|
758
|
+
sourceViewerTitle.textContent = citation.file || 'Source Viewer';
|
|
759
|
+
sourceViewerMeta.textContent = `Loading page ${citationStartPage(citation)}…`;
|
|
760
|
+
showSourceViewerState('loading', 'Loading PDF…');
|
|
761
|
+
|
|
762
|
+
try {
|
|
763
|
+
if (!window.pdfjsLib) {
|
|
764
|
+
throw new Error('pdf.js did not load. Refresh and try again.');
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Verify bbox in parallel with PDF loading
|
|
768
|
+
const verifyPromise = verifyCitationBbox(citation);
|
|
769
|
+
|
|
770
|
+
if (!viewerPdf || viewerFile !== citation.file) {
|
|
771
|
+
// Use preloaded PDF if available, otherwise fetch now
|
|
772
|
+
if (preloadedPdfs[citation.file]) {
|
|
773
|
+
viewerPdf = await preloadedPdfs[citation.file];
|
|
774
|
+
}
|
|
775
|
+
if (!viewerPdf) {
|
|
776
|
+
const url = `/api/pdf/${encodeURIComponent(citation.file)}`;
|
|
777
|
+
viewerPdf = await window.pdfjsLib.getDocument({ url }).promise;
|
|
778
|
+
}
|
|
779
|
+
viewerFile = citation.file;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
await verifyPromise;
|
|
783
|
+
viewerPage = Math.max(1, Math.min(citationStartPage(citation), viewerPdf.numPages || 1));
|
|
784
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
785
|
+
await renderSourceViewerPage(viewerPage);
|
|
786
|
+
} catch (err) {
|
|
787
|
+
console.error('Source Viewer failed:', err);
|
|
788
|
+
showSourceViewerState('error', err?.message || 'Failed to load PDF.');
|
|
789
|
+
sourceViewerMeta.textContent = 'Source unavailable';
|
|
790
|
+
sourceViewerPageIndicator.textContent = 'Page —';
|
|
791
|
+
viewerPdf = null;
|
|
792
|
+
viewerFile = '';
|
|
793
|
+
updateSourceViewerNav();
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function renderSourceViewerPage(pageNum) {
|
|
798
|
+
if (!viewerPdf) return;
|
|
799
|
+
|
|
800
|
+
showSourceViewerState('loading', 'Rendering page…');
|
|
801
|
+
const page = await viewerPdf.getPage(pageNum);
|
|
802
|
+
const baseViewport = page.getViewport({ scale: 1 });
|
|
803
|
+
const availableWidth = Math.max(240, sourceViewerBody.clientWidth - 32);
|
|
804
|
+
const cssScale = availableWidth / baseViewport.width;
|
|
805
|
+
const viewport = page.getViewport({ scale: cssScale });
|
|
806
|
+
const outputScale = window.devicePixelRatio || 1;
|
|
807
|
+
const ctx = sourceViewerCanvas.getContext('2d');
|
|
808
|
+
if (!ctx) throw new Error('Could not get canvas context for PDF rendering.');
|
|
809
|
+
|
|
810
|
+
sourceViewerCanvas.width = Math.floor(viewport.width * outputScale);
|
|
811
|
+
sourceViewerCanvas.height = Math.floor(viewport.height * outputScale);
|
|
812
|
+
sourceViewerCanvas.style.width = `${viewport.width}px`;
|
|
813
|
+
sourceViewerCanvas.style.height = `${viewport.height}px`;
|
|
814
|
+
sourceViewerPageWrap.style.width = `${viewport.width}px`;
|
|
815
|
+
sourceViewerPageWrap.style.height = `${viewport.height}px`;
|
|
816
|
+
|
|
817
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
818
|
+
ctx.clearRect(0, 0, sourceViewerCanvas.width, sourceViewerCanvas.height);
|
|
819
|
+
|
|
820
|
+
await page.render({
|
|
821
|
+
canvasContext: ctx,
|
|
822
|
+
viewport,
|
|
823
|
+
transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null,
|
|
824
|
+
}).promise;
|
|
825
|
+
|
|
826
|
+
viewerPage = pageNum;
|
|
827
|
+
renderSourceHighlights(pageNum, baseViewport.width, baseViewport.height);
|
|
828
|
+
const highlightCount = currentCitationBoxes(pageNum).length;
|
|
829
|
+
sourceViewerMeta.textContent = highlightCount > 0
|
|
830
|
+
? `${viewerFile} · ${highlightCount} highlight${highlightCount !== 1 ? 's' : ''}`
|
|
831
|
+
: `${viewerFile} · no highlight on this page`;
|
|
832
|
+
showSourceViewerState('canvas');
|
|
833
|
+
updateSourceViewerNav();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function closeSourceViewer() {
|
|
837
|
+
sourceViewerEl.classList.remove('open');
|
|
838
|
+
sourceViewerEl.setAttribute('aria-hidden', 'true');
|
|
839
|
+
viewerCitation = null;
|
|
840
|
+
sourceViewerOverlay.innerHTML = '';
|
|
841
|
+
setActiveCitationChip(null);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function finishResponse(data) {
|
|
845
|
+
if (!currentAssistant) return;
|
|
846
|
+
renderAllAnswers(); // final render of all segments
|
|
847
|
+
if (lastCitations.length > 0) linkInlineCitations(lastCitations);
|
|
848
|
+
const done = document.createElement('div');
|
|
849
|
+
done.className = 'msg-done';
|
|
850
|
+
const parts = [`${data.elapsed}s`];
|
|
851
|
+
if (data.filesRead > 0) parts.push(`${data.filesRead} files read`);
|
|
852
|
+
else parts.push('wiki');
|
|
853
|
+
if (data.citationCount > 0) parts.push(`${data.citationCount} citations`);
|
|
854
|
+
done.textContent = parts.join(' · ');
|
|
855
|
+
currentAssistant.appendChild(done);
|
|
856
|
+
scrollToBottom();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function addSystemMessage(text) {
|
|
860
|
+
const div = document.createElement('div');
|
|
861
|
+
div.style.cssText = 'text-align:center; padding:8px; font-size:12px; color:var(--urgent); margin-bottom:12px;';
|
|
862
|
+
div.textContent = text;
|
|
863
|
+
messagesEl.appendChild(div);
|
|
864
|
+
scrollToBottom();
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function scrollToBottom() {
|
|
868
|
+
requestAnimationFrame(() => {
|
|
869
|
+
chatContainer.scrollTop = chatContainer.scrollHeight;
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// ── Sessions ─────────────────────────────────────────────────
|
|
874
|
+
async function loadSessions() {
|
|
875
|
+
try {
|
|
876
|
+
const res = await fetch('/api/sessions');
|
|
877
|
+
const sessions = await res.json();
|
|
878
|
+
const list = document.getElementById('sessions-list');
|
|
879
|
+
if (sessions.length === 0) {
|
|
880
|
+
list.innerHTML = '<div style="padding:12px 10px;font-size:12px;color:rgba(255,255,255,0.25);">No sessions yet</div>';
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
list.innerHTML = sessions.map(s => {
|
|
884
|
+
const ago = timeAgo(new Date(s.timestamp));
|
|
885
|
+
const title = s.question.length > 35 ? s.question.slice(0, 32) + '...' : s.question;
|
|
886
|
+
return `<button class="session-item" data-id="${s.id}">
|
|
887
|
+
<span class="title">💬 ${escapeHtml(title)}</span>
|
|
888
|
+
<span class="meta">${ago} · ${s.citationCount} citations</span>
|
|
889
|
+
</button>`;
|
|
890
|
+
}).join('');
|
|
891
|
+
} catch (e) { console.error('Failed to load sessions:', e); }
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async function loadStatus() {
|
|
895
|
+
try {
|
|
896
|
+
const res = await fetch('/api/status');
|
|
897
|
+
const data = await res.json();
|
|
898
|
+
statusText.textContent = `${data.sourceCount} sources · ${data.wikiConcepts} wiki concepts`;
|
|
899
|
+
} catch {}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
document.getElementById('new-chat-btn').addEventListener('click', () => {
|
|
903
|
+
// Reload page to start fresh session
|
|
904
|
+
location.reload();
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
sourceViewerCloseBtn.addEventListener('click', closeSourceViewer);
|
|
908
|
+
sourceViewerPrevBtn.addEventListener('click', () => {
|
|
909
|
+
if (viewerPdf && viewerPage > 1) renderSourceViewerPage(viewerPage - 1);
|
|
910
|
+
});
|
|
911
|
+
sourceViewerNextBtn.addEventListener('click', () => {
|
|
912
|
+
if (viewerPdf && viewerPage < viewerPdf.numPages) renderSourceViewerPage(viewerPage + 1);
|
|
913
|
+
});
|
|
914
|
+
window.addEventListener('keydown', (e) => {
|
|
915
|
+
if (e.key === 'Escape' && sourceViewerEl.classList.contains('open')) {
|
|
916
|
+
closeSourceViewer();
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
window.addEventListener('resize', () => {
|
|
920
|
+
if (!viewerPdf || !sourceViewerEl.classList.contains('open')) return;
|
|
921
|
+
clearTimeout(resizeTimer);
|
|
922
|
+
resizeTimer = setTimeout(() => {
|
|
923
|
+
renderSourceViewerPage(viewerPage).catch((err) => console.error('Resize rerender failed:', err));
|
|
924
|
+
}, 120);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
// ── Helpers ──────────────────────────────────────────────────
|
|
928
|
+
function timeAgo(date) {
|
|
929
|
+
const diff = Date.now() - date.getTime();
|
|
930
|
+
const mins = Math.floor(diff / 60000);
|
|
931
|
+
if (mins < 1) return 'just now';
|
|
932
|
+
if (mins < 60) return `${mins}m ago`;
|
|
933
|
+
const hours = Math.floor(mins / 60);
|
|
934
|
+
if (hours < 24) return `${hours}h ago`;
|
|
935
|
+
const days = Math.floor(hours / 24);
|
|
936
|
+
if (days === 1) return 'yesterday';
|
|
937
|
+
return `${days}d ago`;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function escapeHtml(s) {
|
|
941
|
+
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// ── Init ─────────────────────────────────────────────────────
|
|
945
|
+
connectWS();
|
|
946
|
+
loadSessions();
|
|
947
|
+
</script>
|
|
948
|
+
</body>
|
|
949
|
+
</html>
|