claude-transcript-viewer 1.1.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 +215 -0
- package/dist/api/search.d.ts +50 -0
- package/dist/api/search.js +181 -0
- package/dist/api/search.js.map +1 -0
- package/dist/api/snippets.d.ts +2 -0
- package/dist/api/snippets.js +49 -0
- package/dist/api/snippets.js.map +1 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +52 -0
- package/dist/config.js.map +1 -0
- package/dist/db/chunks.d.ts +14 -0
- package/dist/db/chunks.js +43 -0
- package/dist/db/chunks.js.map +1 -0
- package/dist/db/conversations.d.ts +16 -0
- package/dist/db/conversations.js +40 -0
- package/dist/db/conversations.js.map +1 -0
- package/dist/db/index.d.ts +6 -0
- package/dist/db/index.js +40 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/schema.d.ts +5 -0
- package/dist/db/schema.js +71 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/embeddings/client.d.ts +16 -0
- package/dist/embeddings/client.js +155 -0
- package/dist/embeddings/client.js.map +1 -0
- package/dist/indexer/changeDetection.d.ts +16 -0
- package/dist/indexer/changeDetection.js +81 -0
- package/dist/indexer/changeDetection.js.map +1 -0
- package/dist/indexer/chunker.d.ts +5 -0
- package/dist/indexer/chunker.js +44 -0
- package/dist/indexer/chunker.js.map +1 -0
- package/dist/indexer/fileUtils.d.ts +2 -0
- package/dist/indexer/fileUtils.js +9 -0
- package/dist/indexer/fileUtils.js.map +1 -0
- package/dist/indexer/index.d.ts +19 -0
- package/dist/indexer/index.js +267 -0
- package/dist/indexer/index.js.map +1 -0
- package/dist/indexer/parser.d.ts +12 -0
- package/dist/indexer/parser.js +45 -0
- package/dist/indexer/parser.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +1851 -0
- package/dist/server.js.map +1 -0
- package/package.json +62 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1851 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import express from "express";
|
|
3
|
+
import { readFileSync, existsSync, readdirSync, mkdirSync } from "fs";
|
|
4
|
+
import { join, resolve } from "path";
|
|
5
|
+
import { spawn } from "child_process";
|
|
6
|
+
import * as cheerio from "cheerio";
|
|
7
|
+
import { createDatabase, getDatabase } from "./db/index.js";
|
|
8
|
+
import { searchHybrid } from "./api/search.js";
|
|
9
|
+
import { generateSnippet, highlightTerms } from "./api/snippets.js";
|
|
10
|
+
import { createEmbeddingClient } from "./embeddings/client.js";
|
|
11
|
+
import { getConfig } from "./config.js";
|
|
12
|
+
import { runIndexer } from "./indexer/index.js";
|
|
13
|
+
const app = express();
|
|
14
|
+
const config = getConfig();
|
|
15
|
+
const PORT = process.env.PORT || 3000;
|
|
16
|
+
const ARCHIVE_DIR = process.env.ARCHIVE_DIR || process.argv[2] || "./claude-archive";
|
|
17
|
+
const SOURCE_DIR = process.env.SOURCE_DIR || process.argv[3] || "";
|
|
18
|
+
const DATABASE_PATH = process.env.DATABASE_PATH || join(ARCHIVE_DIR, ".search.db");
|
|
19
|
+
// Initialize database and embedding client
|
|
20
|
+
let embeddingClient;
|
|
21
|
+
// Background indexing state
|
|
22
|
+
let indexingStatus = { isIndexing: false };
|
|
23
|
+
// Archive generation state
|
|
24
|
+
let archiveStatus = { isGenerating: false };
|
|
25
|
+
// Cache mapping database project slugs to archive directory names
|
|
26
|
+
let projectToArchiveMap = new Map();
|
|
27
|
+
// Generate HTML archive using claude-code-transcripts Python CLI
|
|
28
|
+
async function generateArchive() {
|
|
29
|
+
if (!SOURCE_DIR || archiveStatus.isGenerating) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (!existsSync(SOURCE_DIR)) {
|
|
33
|
+
console.log(`Source directory not found: ${SOURCE_DIR} - skipping archive generation`);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
// Ensure archive directory exists
|
|
37
|
+
if (!existsSync(ARCHIVE_DIR)) {
|
|
38
|
+
mkdirSync(ARCHIVE_DIR, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
console.log(`Generating HTML archive from ${SOURCE_DIR} to ${ARCHIVE_DIR}...`);
|
|
41
|
+
archiveStatus.isGenerating = true;
|
|
42
|
+
archiveStatus.progress = "Starting...";
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
// Use uv run to execute claude-code-transcripts all command
|
|
45
|
+
const proc = spawn("uv", [
|
|
46
|
+
"run",
|
|
47
|
+
"claude-code-transcripts",
|
|
48
|
+
"all",
|
|
49
|
+
"-s",
|
|
50
|
+
SOURCE_DIR,
|
|
51
|
+
"-o",
|
|
52
|
+
ARCHIVE_DIR,
|
|
53
|
+
"--include-agents",
|
|
54
|
+
"-q",
|
|
55
|
+
], {
|
|
56
|
+
cwd: process.env.TRANSCRIPTS_CLI_PATH || undefined,
|
|
57
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
58
|
+
});
|
|
59
|
+
let stdout = "";
|
|
60
|
+
let stderr = "";
|
|
61
|
+
proc.stdout.on("data", (data) => {
|
|
62
|
+
const line = data.toString();
|
|
63
|
+
stdout += line;
|
|
64
|
+
// Update progress from output
|
|
65
|
+
const match = line.match(/Processing|Generating|Writing/i);
|
|
66
|
+
if (match) {
|
|
67
|
+
archiveStatus.progress = line.trim().slice(0, 50);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
proc.stderr.on("data", (data) => {
|
|
71
|
+
stderr += data.toString();
|
|
72
|
+
});
|
|
73
|
+
proc.on("close", (code) => {
|
|
74
|
+
archiveStatus.isGenerating = false;
|
|
75
|
+
archiveStatus.lastRun = new Date().toISOString();
|
|
76
|
+
if (code === 0) {
|
|
77
|
+
archiveStatus.progress = "Complete";
|
|
78
|
+
console.log("Archive generation complete");
|
|
79
|
+
resolve(true);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
archiveStatus.lastError = stderr || `Exit code ${code}`;
|
|
83
|
+
console.error("Archive generation failed:", stderr || `Exit code ${code}`);
|
|
84
|
+
resolve(false);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
proc.on("error", (err) => {
|
|
88
|
+
archiveStatus.isGenerating = false;
|
|
89
|
+
archiveStatus.lastError = err.message;
|
|
90
|
+
console.error("Failed to spawn claude-code-transcripts:", err.message);
|
|
91
|
+
console.log("Make sure claude-code-transcripts is installed: pip install claude-code-transcripts");
|
|
92
|
+
resolve(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// Start background indexing if SOURCE_DIR is provided
|
|
97
|
+
async function startBackgroundIndexing() {
|
|
98
|
+
if (!SOURCE_DIR || indexingStatus.isIndexing) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!existsSync(SOURCE_DIR)) {
|
|
102
|
+
console.log(`Source directory not found: ${SOURCE_DIR} - skipping auto-indexing`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
console.log(`Starting background indexing from ${SOURCE_DIR}...`);
|
|
106
|
+
indexingStatus.isIndexing = true;
|
|
107
|
+
indexingStatus.progress = "Starting...";
|
|
108
|
+
try {
|
|
109
|
+
const stats = await runIndexer({
|
|
110
|
+
sourceDir: SOURCE_DIR,
|
|
111
|
+
databasePath: DATABASE_PATH,
|
|
112
|
+
embedSocketPath: process.env.EMBED_SOCKET,
|
|
113
|
+
embedUrl: process.env.EMBED_URL,
|
|
114
|
+
verbose: false,
|
|
115
|
+
});
|
|
116
|
+
indexingStatus.lastStats = {
|
|
117
|
+
added: stats.added,
|
|
118
|
+
modified: stats.modified,
|
|
119
|
+
deleted: stats.deleted,
|
|
120
|
+
chunks: stats.chunks,
|
|
121
|
+
};
|
|
122
|
+
indexingStatus.progress = "Complete";
|
|
123
|
+
if (stats.errors.length > 0) {
|
|
124
|
+
indexingStatus.lastError = `${stats.errors.length} files had errors`;
|
|
125
|
+
}
|
|
126
|
+
console.log(`Background indexing complete: ${stats.added} added, ${stats.modified} modified, ${stats.chunks} chunks`);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
indexingStatus.lastError = err instanceof Error ? err.message : String(err);
|
|
130
|
+
console.error("Background indexing failed:", err);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
indexingStatus.isIndexing = false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Build mapping from database project slugs to archive directory names.
|
|
138
|
+
* Database stores path-based slugs like "-Users-varunr-projects-podcast-summarizer-v2"
|
|
139
|
+
* Archive uses normalized names like "podcast-summarizer-v2"
|
|
140
|
+
*/
|
|
141
|
+
function buildProjectMapping() {
|
|
142
|
+
projectToArchiveMap.clear();
|
|
143
|
+
// Get archive directories
|
|
144
|
+
const archiveDirs = new Set();
|
|
145
|
+
try {
|
|
146
|
+
const entries = readdirSync(ARCHIVE_DIR, { withFileTypes: true });
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
if (entry.isDirectory()) {
|
|
149
|
+
archiveDirs.add(entry.name);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
console.error("Failed to read archive directory:", err);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Get database projects
|
|
158
|
+
const db = getDatabase();
|
|
159
|
+
const projects = db.prepare("SELECT DISTINCT project FROM conversations").all();
|
|
160
|
+
// Match each database project to an archive directory
|
|
161
|
+
for (const { project } of projects) {
|
|
162
|
+
// Try direct match first
|
|
163
|
+
if (archiveDirs.has(project)) {
|
|
164
|
+
projectToArchiveMap.set(project, project);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
// Convert slug to path segments for matching
|
|
168
|
+
// -Users-varunr-projects-podcast-summarizer-v2 -> potential matches
|
|
169
|
+
const slug = project.replace(/^-/, "");
|
|
170
|
+
// Try to find a matching archive directory
|
|
171
|
+
// Strategy: the archive dir should be a suffix of the slug when hyphens are considered
|
|
172
|
+
let bestMatch;
|
|
173
|
+
let bestMatchLen = 0;
|
|
174
|
+
for (const dir of archiveDirs) {
|
|
175
|
+
// Check if the slug ends with this directory name
|
|
176
|
+
// Account for the hyphen separator: slug should end with -<dir> or be exactly <dir>
|
|
177
|
+
if (slug === dir || slug.endsWith(`-${dir}`)) {
|
|
178
|
+
if (dir.length > bestMatchLen) {
|
|
179
|
+
bestMatch = dir;
|
|
180
|
+
bestMatchLen = dir.length;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (bestMatch) {
|
|
185
|
+
projectToArchiveMap.set(project, bestMatch);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// Fallback: use the last hyphen-separated segment
|
|
189
|
+
// This handles edge cases but may not always be correct
|
|
190
|
+
const segments = slug.split("-");
|
|
191
|
+
const lastSegment = segments[segments.length - 1];
|
|
192
|
+
if (lastSegment && archiveDirs.has(lastSegment)) {
|
|
193
|
+
projectToArchiveMap.set(project, lastSegment);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
console.log(`Built project mapping: ${projectToArchiveMap.size} projects mapped to archive directories`);
|
|
198
|
+
}
|
|
199
|
+
async function initializeSearch() {
|
|
200
|
+
try {
|
|
201
|
+
createDatabase(DATABASE_PATH);
|
|
202
|
+
console.log(`Search database initialized at ${DATABASE_PATH}`);
|
|
203
|
+
// Build project to archive directory mapping
|
|
204
|
+
buildProjectMapping();
|
|
205
|
+
// Try to connect to embedding server (HTTP URL or Unix socket)
|
|
206
|
+
const embedUrl = process.env.EMBED_URL; // e.g., http://localhost:8000
|
|
207
|
+
const socketPath = process.env.EMBED_SOCKET || "/tmp/qwen-embed.sock";
|
|
208
|
+
if (embedUrl) {
|
|
209
|
+
// HTTP endpoint (e.g., qwen3-embeddings-mlx)
|
|
210
|
+
embeddingClient = createEmbeddingClient(embedUrl);
|
|
211
|
+
const healthy = await embeddingClient.isHealthy();
|
|
212
|
+
if (healthy) {
|
|
213
|
+
const info = await embeddingClient.getModelInfo();
|
|
214
|
+
console.log(`Embedding client connected to ${embedUrl} (model: ${info?.model}, dim: ${info?.dim})`);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
console.log(`Embedding server at ${embedUrl} not responding - using FTS-only search`);
|
|
218
|
+
embeddingClient = undefined;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else if (existsSync(socketPath)) {
|
|
222
|
+
// Unix socket
|
|
223
|
+
embeddingClient = createEmbeddingClient(socketPath);
|
|
224
|
+
console.log(`Embedding client connected to ${socketPath}`);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
console.log(`Embedding server not found - using FTS-only search`);
|
|
228
|
+
console.log(` Set EMBED_URL=http://localhost:8000 for HTTP or EMBED_SOCKET for Unix socket`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
console.error("Failed to initialize search database:", err);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// CSS to inject for progressive disclosure and search bar
|
|
236
|
+
const INJECTED_CSS = `
|
|
237
|
+
<style id="viewer-enhancements">
|
|
238
|
+
/* Dark mode support - follows system preference */
|
|
239
|
+
@media (prefers-color-scheme: dark) {
|
|
240
|
+
:root {
|
|
241
|
+
--bg-color: #1a1a2e !important;
|
|
242
|
+
--card-bg: #16213e !important;
|
|
243
|
+
--user-bg: #1e3a5f !important;
|
|
244
|
+
--user-border: #4fc3f7 !important;
|
|
245
|
+
--assistant-bg: #16213e !important;
|
|
246
|
+
--assistant-border: #9e9e9e !important;
|
|
247
|
+
--thinking-bg: #2d2a1e !important;
|
|
248
|
+
--thinking-border: #ffc107 !important;
|
|
249
|
+
--thinking-text: #ccc !important;
|
|
250
|
+
--tool-bg: #2a1f3d !important;
|
|
251
|
+
--tool-border: #ce93d8 !important;
|
|
252
|
+
--tool-result-bg: #1e3d2f !important;
|
|
253
|
+
--tool-error-bg: #3d1e1e !important;
|
|
254
|
+
--text-color: #eee !important;
|
|
255
|
+
--text-muted: #888 !important;
|
|
256
|
+
--code-bg: #0d1117 !important;
|
|
257
|
+
--code-text: #aed581 !important;
|
|
258
|
+
--bg: #1a1a2e;
|
|
259
|
+
--surface: #16213e;
|
|
260
|
+
--primary: #e94560;
|
|
261
|
+
--text: #eee;
|
|
262
|
+
--border: #333;
|
|
263
|
+
}
|
|
264
|
+
body {
|
|
265
|
+
background: var(--bg-color) !important;
|
|
266
|
+
color: var(--text-color) !important;
|
|
267
|
+
}
|
|
268
|
+
.message.user { background: var(--user-bg) !important; }
|
|
269
|
+
.message.assistant { background: var(--card-bg) !important; }
|
|
270
|
+
.index-item { background: var(--card-bg) !important; }
|
|
271
|
+
pre { background: var(--code-bg) !important; }
|
|
272
|
+
code { background: rgba(255,255,255,0.1) !important; }
|
|
273
|
+
.expand-btn, .message-expand-btn {
|
|
274
|
+
background: rgba(255,255,255,0.1) !important;
|
|
275
|
+
color: var(--text-muted) !important;
|
|
276
|
+
border-color: rgba(255,255,255,0.2) !important;
|
|
277
|
+
}
|
|
278
|
+
.pagination a {
|
|
279
|
+
background: var(--card-bg) !important;
|
|
280
|
+
border-color: var(--user-border) !important;
|
|
281
|
+
}
|
|
282
|
+
.pagination .current { background: var(--user-border) !important; }
|
|
283
|
+
/* Fix gradients for dark mode */
|
|
284
|
+
.message-content.collapsed::after,
|
|
285
|
+
.index-item-content.collapsed::after {
|
|
286
|
+
background: linear-gradient(to bottom, transparent, var(--card-bg)) !important;
|
|
287
|
+
}
|
|
288
|
+
.message.user .message-content.collapsed::after {
|
|
289
|
+
background: linear-gradient(to bottom, transparent, var(--user-bg)) !important;
|
|
290
|
+
}
|
|
291
|
+
.truncatable.truncated::after {
|
|
292
|
+
background: linear-gradient(to bottom, transparent, var(--card-bg)) !important;
|
|
293
|
+
}
|
|
294
|
+
.message.user .truncatable.truncated::after {
|
|
295
|
+
background: linear-gradient(to bottom, transparent, var(--user-bg)) !important;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* Global search bar styles */
|
|
300
|
+
.viewer-search-bar {
|
|
301
|
+
position: sticky;
|
|
302
|
+
top: 0;
|
|
303
|
+
z-index: 1000;
|
|
304
|
+
background: var(--bg, #1a1a2e);
|
|
305
|
+
padding: 0.75rem 1rem;
|
|
306
|
+
border-bottom: 1px solid var(--border, #333);
|
|
307
|
+
display: flex;
|
|
308
|
+
align-items: center;
|
|
309
|
+
gap: 1rem;
|
|
310
|
+
}
|
|
311
|
+
.viewer-search-bar a.home-link {
|
|
312
|
+
color: var(--primary, #e94560);
|
|
313
|
+
text-decoration: none;
|
|
314
|
+
font-weight: bold;
|
|
315
|
+
font-size: 0.875rem;
|
|
316
|
+
}
|
|
317
|
+
.viewer-search-bar a.home-link:hover { text-decoration: underline; }
|
|
318
|
+
.viewer-search-bar .search-wrapper {
|
|
319
|
+
position: relative;
|
|
320
|
+
flex: 1;
|
|
321
|
+
max-width: 400px;
|
|
322
|
+
}
|
|
323
|
+
.viewer-search-bar input {
|
|
324
|
+
width: 100%;
|
|
325
|
+
padding: 0.5rem 0.75rem;
|
|
326
|
+
font-size: 0.875rem;
|
|
327
|
+
border: 1px solid var(--border, #333);
|
|
328
|
+
border-radius: 4px;
|
|
329
|
+
background: var(--surface, #16213e);
|
|
330
|
+
color: var(--text, #eee);
|
|
331
|
+
}
|
|
332
|
+
.viewer-search-bar input:focus {
|
|
333
|
+
outline: none;
|
|
334
|
+
border-color: var(--primary, #e94560);
|
|
335
|
+
}
|
|
336
|
+
.viewer-search-bar .search-dropdown {
|
|
337
|
+
position: absolute;
|
|
338
|
+
top: 100%;
|
|
339
|
+
left: 0;
|
|
340
|
+
right: 0;
|
|
341
|
+
background: var(--surface, #16213e);
|
|
342
|
+
border: 1px solid var(--border, #333);
|
|
343
|
+
border-radius: 4px;
|
|
344
|
+
margin-top: 4px;
|
|
345
|
+
display: none;
|
|
346
|
+
max-height: 400px;
|
|
347
|
+
overflow-y: auto;
|
|
348
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
349
|
+
}
|
|
350
|
+
.viewer-search-bar .search-dropdown.visible { display: block; }
|
|
351
|
+
.viewer-search-bar .search-dropdown-item {
|
|
352
|
+
padding: 0.75rem;
|
|
353
|
+
border-bottom: 1px solid var(--border, #333);
|
|
354
|
+
cursor: pointer;
|
|
355
|
+
}
|
|
356
|
+
.viewer-search-bar .search-dropdown-item:hover { background: rgba(255,255,255,0.05); }
|
|
357
|
+
.viewer-search-bar .search-dropdown-item:last-child { border-bottom: none; }
|
|
358
|
+
.viewer-search-bar .search-dropdown-item h4 {
|
|
359
|
+
margin: 0 0 0.25rem 0;
|
|
360
|
+
font-size: 0.875rem;
|
|
361
|
+
color: var(--text, #eee);
|
|
362
|
+
}
|
|
363
|
+
.viewer-search-bar .search-dropdown-item p {
|
|
364
|
+
margin: 0;
|
|
365
|
+
font-size: 0.75rem;
|
|
366
|
+
color: var(--text-muted, #888);
|
|
367
|
+
}
|
|
368
|
+
.viewer-search-bar .search-dropdown-item p strong { color: var(--primary, #e94560); }
|
|
369
|
+
|
|
370
|
+
/* Filter toggle buttons */
|
|
371
|
+
.filter-toggles {
|
|
372
|
+
display: flex;
|
|
373
|
+
gap: 0.5rem;
|
|
374
|
+
flex-wrap: wrap;
|
|
375
|
+
}
|
|
376
|
+
.filter-btn {
|
|
377
|
+
padding: 0.35rem 0.6rem;
|
|
378
|
+
font-size: 0.75rem;
|
|
379
|
+
border: 1px solid var(--border, #333);
|
|
380
|
+
border-radius: 4px;
|
|
381
|
+
background: transparent;
|
|
382
|
+
color: var(--text-muted, #888);
|
|
383
|
+
cursor: pointer;
|
|
384
|
+
opacity: 0.5;
|
|
385
|
+
transition: all 0.15s ease;
|
|
386
|
+
}
|
|
387
|
+
.filter-btn:hover {
|
|
388
|
+
opacity: 0.8;
|
|
389
|
+
border-color: var(--primary, #e94560);
|
|
390
|
+
}
|
|
391
|
+
.filter-btn.active {
|
|
392
|
+
opacity: 1;
|
|
393
|
+
background: var(--surface, #16213e);
|
|
394
|
+
border-color: var(--primary, #e94560);
|
|
395
|
+
color: var(--text, #eee);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* Filter visibility - elements get .filter-hidden class from JS */
|
|
399
|
+
.filter-hidden { display: none !important; }
|
|
400
|
+
|
|
401
|
+
/* Assistant text wrapper (dynamically added) */
|
|
402
|
+
.assistant-text { display: block; }
|
|
403
|
+
|
|
404
|
+
/* Insight blocks */
|
|
405
|
+
.insight-block {
|
|
406
|
+
border-left: 3px solid #ffd700;
|
|
407
|
+
padding-left: 0.5rem;
|
|
408
|
+
margin: 0.5rem 0;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/* Performance optimization - removed content-visibility from .message to allow proper collapse sizing */
|
|
412
|
+
.cell {
|
|
413
|
+
content-visibility: auto;
|
|
414
|
+
contain: layout style paint;
|
|
415
|
+
contain-intrinsic-size: 1px 400px;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/* Content collapsing - line-based (messages and index items) */
|
|
419
|
+
.message-content.collapsed,
|
|
420
|
+
.index-item-content.collapsed {
|
|
421
|
+
--collapse-lines: 10;
|
|
422
|
+
--line-height: 1.5em;
|
|
423
|
+
max-height: calc(var(--collapse-lines) * var(--line-height));
|
|
424
|
+
overflow: hidden;
|
|
425
|
+
position: relative;
|
|
426
|
+
}
|
|
427
|
+
.message-content.collapsed::after,
|
|
428
|
+
.index-item-content.collapsed::after {
|
|
429
|
+
content: '';
|
|
430
|
+
position: absolute;
|
|
431
|
+
bottom: 0;
|
|
432
|
+
left: 0;
|
|
433
|
+
right: 0;
|
|
434
|
+
height: 3em;
|
|
435
|
+
background: linear-gradient(to bottom, transparent, var(--card-bg, #fff));
|
|
436
|
+
pointer-events: none;
|
|
437
|
+
}
|
|
438
|
+
.message.user .message-content.collapsed::after {
|
|
439
|
+
background: linear-gradient(to bottom, transparent, var(--user-bg, #e3f2fd));
|
|
440
|
+
}
|
|
441
|
+
.message.tool-reply .message-content.collapsed::after {
|
|
442
|
+
background: linear-gradient(to bottom, transparent, #fff8e1);
|
|
443
|
+
}
|
|
444
|
+
.message-expand-btn {
|
|
445
|
+
display: block;
|
|
446
|
+
width: 100%;
|
|
447
|
+
padding: 8px;
|
|
448
|
+
margin-top: 4px;
|
|
449
|
+
background: rgba(0,0,0,0.05);
|
|
450
|
+
border: 1px solid rgba(0,0,0,0.1);
|
|
451
|
+
border-radius: 6px;
|
|
452
|
+
cursor: pointer;
|
|
453
|
+
font-size: 0.85rem;
|
|
454
|
+
color: var(--text-muted, #757575);
|
|
455
|
+
text-align: center;
|
|
456
|
+
}
|
|
457
|
+
.message-expand-btn:hover { background: rgba(0,0,0,0.1); }
|
|
458
|
+
|
|
459
|
+
/* Infinite scroll loading indicator */
|
|
460
|
+
#infinite-scroll-loader {
|
|
461
|
+
text-align: center;
|
|
462
|
+
padding: 20px;
|
|
463
|
+
color: var(--text-muted, #757575);
|
|
464
|
+
}
|
|
465
|
+
#infinite-scroll-loader.loading::after {
|
|
466
|
+
content: "Loading more...";
|
|
467
|
+
}
|
|
468
|
+
#infinite-scroll-loader.done::after {
|
|
469
|
+
content: "End of conversation";
|
|
470
|
+
}
|
|
471
|
+
</style>
|
|
472
|
+
`;
|
|
473
|
+
// JavaScript for infinite scroll and search bar
|
|
474
|
+
const INJECTED_JS = `
|
|
475
|
+
<script id="viewer-enhancements-js">
|
|
476
|
+
(function() {
|
|
477
|
+
// Collapse long content - line-based collapsing
|
|
478
|
+
// Applies to both message pages (.message-content) and index pages (.index-item-content)
|
|
479
|
+
const MAX_LINES = 10;
|
|
480
|
+
const MIN_HIDDEN_LINES = 5; // Only collapse if hiding at least this many lines
|
|
481
|
+
|
|
482
|
+
function collapseContent(root) {
|
|
483
|
+
const container = root || document;
|
|
484
|
+
// Target both message content and index item content
|
|
485
|
+
container.querySelectorAll('.message-content, .index-item-content').forEach(function(content) {
|
|
486
|
+
// Skip if already processed or has truncatable handling
|
|
487
|
+
if (content.dataset.collapseProcessed) return;
|
|
488
|
+
if (content.closest('.truncatable')) return;
|
|
489
|
+
content.dataset.collapseProcessed = 'true';
|
|
490
|
+
|
|
491
|
+
// Calculate line height and total lines
|
|
492
|
+
const style = window.getComputedStyle(content);
|
|
493
|
+
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5;
|
|
494
|
+
const totalHeight = content.scrollHeight;
|
|
495
|
+
const totalLines = Math.ceil(totalHeight / lineHeight);
|
|
496
|
+
const hiddenLines = totalLines - MAX_LINES;
|
|
497
|
+
|
|
498
|
+
if (hiddenLines >= MIN_HIDDEN_LINES) {
|
|
499
|
+
content.classList.add('collapsed');
|
|
500
|
+
content.style.setProperty('--line-height', lineHeight + 'px');
|
|
501
|
+
|
|
502
|
+
const btn = document.createElement('button');
|
|
503
|
+
btn.className = 'message-expand-btn';
|
|
504
|
+
btn.textContent = 'Show ' + hiddenLines + ' more lines';
|
|
505
|
+
btn.addEventListener('click', function() {
|
|
506
|
+
if (content.classList.contains('collapsed')) {
|
|
507
|
+
content.classList.remove('collapsed');
|
|
508
|
+
btn.textContent = 'Show less';
|
|
509
|
+
} else {
|
|
510
|
+
content.classList.add('collapsed');
|
|
511
|
+
btn.textContent = 'Show ' + hiddenLines + ' more lines';
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
content.parentNode.insertBefore(btn, content.nextSibling);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Initial collapse
|
|
520
|
+
collapseContent();
|
|
521
|
+
|
|
522
|
+
// Export for use by infinite scroll
|
|
523
|
+
window.collapseContent = collapseContent;
|
|
524
|
+
|
|
525
|
+
// Infinite scroll state - use window to persist across any script re-execution
|
|
526
|
+
if (typeof window.__infiniteScrollState === 'undefined') {
|
|
527
|
+
const pageMatch = window.location.pathname.match(/page-([0-9]+)\\.html/);
|
|
528
|
+
window.__infiniteScrollState = {
|
|
529
|
+
currentPage: pageMatch ? parseInt(pageMatch[1], 10) : 1,
|
|
530
|
+
totalPages: 1,
|
|
531
|
+
isLoading: false
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
const state = window.__infiniteScrollState;
|
|
535
|
+
|
|
536
|
+
// Detect total pages from pagination
|
|
537
|
+
function detectTotalPages() {
|
|
538
|
+
const pagination = document.querySelector('.pagination');
|
|
539
|
+
if (pagination) {
|
|
540
|
+
const links = pagination.querySelectorAll('a[href^="page-"]');
|
|
541
|
+
links.forEach(link => {
|
|
542
|
+
const match = link.href.match(/page-([0-9]+)\\.html/);
|
|
543
|
+
if (match) {
|
|
544
|
+
const pageNum = parseInt(match[1], 10);
|
|
545
|
+
if (pageNum > state.totalPages) state.totalPages = pageNum;
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Load next page content
|
|
552
|
+
async function loadNextPage() {
|
|
553
|
+
// Set isLoading and increment currentPage IMMEDIATELY to prevent race conditions
|
|
554
|
+
if (state.isLoading || state.currentPage >= state.totalPages) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
state.isLoading = true;
|
|
558
|
+
state.currentPage++; // Increment synchronously BEFORE any async work
|
|
559
|
+
|
|
560
|
+
const pageToLoad = state.currentPage;
|
|
561
|
+
const loader = document.getElementById('infinite-scroll-loader');
|
|
562
|
+
if (loader) loader.className = 'loading';
|
|
563
|
+
|
|
564
|
+
const nextUrl = window.location.pathname.replace(
|
|
565
|
+
/page-[0-9]+\\.html/,
|
|
566
|
+
'page-' + String(pageToLoad).padStart(3, '0') + '.html'
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
const response = await fetch(nextUrl);
|
|
571
|
+
if (!response.ok) throw new Error('Page not found');
|
|
572
|
+
|
|
573
|
+
const html = await response.text();
|
|
574
|
+
const parser = new DOMParser();
|
|
575
|
+
const doc = parser.parseFromString(html, 'text/html');
|
|
576
|
+
|
|
577
|
+
// Get messages from next page
|
|
578
|
+
const messages = doc.querySelectorAll('.message');
|
|
579
|
+
const container = document.querySelector('.container');
|
|
580
|
+
const loader = document.getElementById('infinite-scroll-loader');
|
|
581
|
+
|
|
582
|
+
if (container && messages.length > 0) {
|
|
583
|
+
messages.forEach(msg => {
|
|
584
|
+
const clone = document.importNode(msg, true);
|
|
585
|
+
// Always insert before loader to maintain correct order
|
|
586
|
+
container.insertBefore(clone, loader);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// Collapse newly loaded messages
|
|
590
|
+
if (window.collapseContent) {
|
|
591
|
+
window.collapseContent();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Apply filters to newly loaded content (order: wrap, then extract insights)
|
|
595
|
+
if (window.wrapAssistantText) window.wrapAssistantText();
|
|
596
|
+
if (window.markInsightBlocks) window.markInsightBlocks();
|
|
597
|
+
if (window.applyFilters) window.applyFilters();
|
|
598
|
+
}
|
|
599
|
+
} catch (err) {
|
|
600
|
+
console.error('Failed to load page', pageToLoad, ':', err);
|
|
601
|
+
// Rollback on error
|
|
602
|
+
state.currentPage--;
|
|
603
|
+
} finally {
|
|
604
|
+
state.isLoading = false;
|
|
605
|
+
const loader = document.getElementById('infinite-scroll-loader');
|
|
606
|
+
if (loader) {
|
|
607
|
+
loader.className = state.currentPage >= state.totalPages ? 'done' : '';
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Set up infinite scroll observer
|
|
613
|
+
function setupInfiniteScroll() {
|
|
614
|
+
// Check if already set up by looking for the loader element (most reliable guard)
|
|
615
|
+
if (document.getElementById('infinite-scroll-loader')) {
|
|
616
|
+
return; // Already set up
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const container = document.querySelector('.container');
|
|
620
|
+
const paginations = document.querySelectorAll('.pagination');
|
|
621
|
+
|
|
622
|
+
if (container) {
|
|
623
|
+
// Hide ALL pagination elements
|
|
624
|
+
paginations.forEach(p => p.style.display = 'none');
|
|
625
|
+
|
|
626
|
+
const loader = document.createElement('div');
|
|
627
|
+
loader.id = 'infinite-scroll-loader';
|
|
628
|
+
container.appendChild(loader);
|
|
629
|
+
|
|
630
|
+
// Observe loader for infinite scroll
|
|
631
|
+
const observer = new IntersectionObserver((entries) => {
|
|
632
|
+
if (entries[0].isIntersecting) {
|
|
633
|
+
loadNextPage();
|
|
634
|
+
}
|
|
635
|
+
}, { rootMargin: '200px' });
|
|
636
|
+
|
|
637
|
+
observer.observe(loader);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Initialize on DOM ready
|
|
642
|
+
if (document.readyState === 'loading') {
|
|
643
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
644
|
+
} else {
|
|
645
|
+
init();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function init() {
|
|
649
|
+
detectTotalPages();
|
|
650
|
+
setupInfiniteScroll();
|
|
651
|
+
setupSearchBar();
|
|
652
|
+
setupFilters();
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Filter toggle functionality
|
|
656
|
+
function setupFilters() {
|
|
657
|
+
const filterContainer = document.getElementById('filter-toggles');
|
|
658
|
+
if (!filterContainer) return;
|
|
659
|
+
|
|
660
|
+
// Order matters: wrap first, then extract insights
|
|
661
|
+
// 1. Wrap assistant text (excluding thinking, tool-use - insights handled after)
|
|
662
|
+
wrapAssistantText();
|
|
663
|
+
|
|
664
|
+
// 2. Extract insight blocks from assistant-text and move them out
|
|
665
|
+
markInsightBlocks();
|
|
666
|
+
|
|
667
|
+
filterContainer.addEventListener('click', (e) => {
|
|
668
|
+
const btn = e.target.closest('.filter-btn');
|
|
669
|
+
if (!btn) return;
|
|
670
|
+
|
|
671
|
+
btn.classList.toggle('active');
|
|
672
|
+
applyFilters();
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Extract insight blocks from assistant-text and wrap them properly
|
|
677
|
+
function markInsightBlocks() {
|
|
678
|
+
document.querySelectorAll('.message.assistant .message-content').forEach(content => {
|
|
679
|
+
if (content.dataset.insightProcessed) return;
|
|
680
|
+
content.dataset.insightProcessed = 'true';
|
|
681
|
+
|
|
682
|
+
// Find all code/pre elements that contain insight markers
|
|
683
|
+
const insightStarts = Array.from(content.querySelectorAll('code, pre')).filter(el => {
|
|
684
|
+
if (!el.textContent.includes('★ Insight')) return false;
|
|
685
|
+
// Skip if already inside an insight-block
|
|
686
|
+
if (el.closest('.insight-block')) return false;
|
|
687
|
+
return true;
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
insightStarts.forEach(startEl => {
|
|
691
|
+
// Find the container we need to work within (either assistant-text or message-content)
|
|
692
|
+
const assistantText = startEl.closest('.assistant-text');
|
|
693
|
+
const container = assistantText || content;
|
|
694
|
+
|
|
695
|
+
// Find the parent block element (usually a <p> tag) that is a direct child of container
|
|
696
|
+
let startBlock = startEl;
|
|
697
|
+
while (startBlock.parentElement && startBlock.parentElement !== container) {
|
|
698
|
+
startBlock = startBlock.parentElement;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// If startBlock is not a direct child of container, skip
|
|
702
|
+
if (startBlock.parentElement !== container) return;
|
|
703
|
+
|
|
704
|
+
// Collect all sibling elements until we find the closing dashes
|
|
705
|
+
const elementsToWrap = [startBlock];
|
|
706
|
+
let current = startBlock.nextElementSibling;
|
|
707
|
+
|
|
708
|
+
while (current) {
|
|
709
|
+
elementsToWrap.push(current);
|
|
710
|
+
|
|
711
|
+
// Check if this element contains the closing dashes (────)
|
|
712
|
+
const hasClosingDashes = current.textContent && current.textContent.includes('────') &&
|
|
713
|
+
!current.textContent.includes('★ Insight');
|
|
714
|
+
|
|
715
|
+
if (hasClosingDashes) {
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
current = current.nextElementSibling;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Create the insight wrapper
|
|
722
|
+
if (elementsToWrap.length > 0) {
|
|
723
|
+
const wrapper = document.createElement('div');
|
|
724
|
+
wrapper.className = 'insight-block';
|
|
725
|
+
|
|
726
|
+
// Insert wrapper before the first element in the container
|
|
727
|
+
container.insertBefore(wrapper, startBlock);
|
|
728
|
+
|
|
729
|
+
// Move all elements into the wrapper
|
|
730
|
+
elementsToWrap.forEach(el => wrapper.appendChild(el));
|
|
731
|
+
|
|
732
|
+
// If we extracted from assistant-text, move the wrapper to message-content level
|
|
733
|
+
if (assistantText && content !== assistantText) {
|
|
734
|
+
// Insert the insight wrapper after assistant-text in message-content
|
|
735
|
+
content.insertBefore(wrapper, assistantText.nextSibling);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Wrap non-special content in assistant messages
|
|
743
|
+
function wrapAssistantText() {
|
|
744
|
+
document.querySelectorAll('.message.assistant .message-content').forEach(content => {
|
|
745
|
+
if (content.querySelector(':scope > .assistant-text')) return; // Already wrapped at this level
|
|
746
|
+
|
|
747
|
+
const children = [...content.childNodes];
|
|
748
|
+
const wrapper = document.createElement('div');
|
|
749
|
+
wrapper.className = 'assistant-text';
|
|
750
|
+
|
|
751
|
+
children.forEach(child => {
|
|
752
|
+
// Skip special blocks - they stay outside the wrapper
|
|
753
|
+
if (child.nodeType === 1) { // Element node
|
|
754
|
+
const el = child;
|
|
755
|
+
if (el.classList.contains('thinking') ||
|
|
756
|
+
el.classList.contains('tool-use') ||
|
|
757
|
+
el.classList.contains('insight-block')) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
// Move to wrapper
|
|
762
|
+
wrapper.appendChild(child);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
// Insert wrapper at the beginning
|
|
766
|
+
if (wrapper.childNodes.length > 0) {
|
|
767
|
+
content.insertBefore(wrapper, content.firstChild);
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Apply current filter state
|
|
773
|
+
function applyFilters() {
|
|
774
|
+
const filters = {};
|
|
775
|
+
document.querySelectorAll('.filter-btn').forEach(btn => {
|
|
776
|
+
filters[btn.dataset.filter] = btn.classList.contains('active');
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// Apply to user messages
|
|
780
|
+
document.querySelectorAll('.message.user').forEach(el => {
|
|
781
|
+
el.classList.toggle('filter-hidden', !filters.user);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// Apply to tool results
|
|
785
|
+
document.querySelectorAll('.message.tool-reply').forEach(el => {
|
|
786
|
+
el.classList.toggle('filter-hidden', !filters['tool-reply']);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// Apply to thinking blocks
|
|
790
|
+
document.querySelectorAll('.thinking').forEach(el => {
|
|
791
|
+
el.classList.toggle('filter-hidden', !filters.thinking);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// Apply to tool-use blocks
|
|
795
|
+
document.querySelectorAll('.tool-use').forEach(el => {
|
|
796
|
+
el.classList.toggle('filter-hidden', !filters['tool-use']);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
// Apply to insight blocks
|
|
800
|
+
document.querySelectorAll('.insight-block').forEach(el => {
|
|
801
|
+
el.classList.toggle('filter-hidden', !filters.insight);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
// Apply to assistant text
|
|
805
|
+
document.querySelectorAll('.assistant-text').forEach(el => {
|
|
806
|
+
el.classList.toggle('filter-hidden', !filters.assistant);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Hide assistant messages that are now empty
|
|
810
|
+
document.querySelectorAll('.message.assistant').forEach(msg => {
|
|
811
|
+
const content = msg.querySelector('.message-content');
|
|
812
|
+
if (!content) return;
|
|
813
|
+
|
|
814
|
+
// Check if any visible filterable content remains
|
|
815
|
+
const hasVisibleThinking = filters.thinking && content.querySelector('.thinking:not(.filter-hidden)');
|
|
816
|
+
const hasVisibleToolUse = filters['tool-use'] && content.querySelector('.tool-use:not(.filter-hidden)');
|
|
817
|
+
const hasVisibleInsight = filters.insight && content.querySelector('.insight-block:not(.filter-hidden)');
|
|
818
|
+
|
|
819
|
+
// For assistant text, also check it has meaningful content (not just whitespace)
|
|
820
|
+
let hasVisibleText = false;
|
|
821
|
+
if (filters.assistant) {
|
|
822
|
+
const assistantText = content.querySelector('.assistant-text:not(.filter-hidden)');
|
|
823
|
+
if (assistantText && assistantText.textContent.trim().length > 0) {
|
|
824
|
+
hasVisibleText = true;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const hasVisibleContent = hasVisibleThinking || hasVisibleToolUse || hasVisibleInsight || hasVisibleText;
|
|
829
|
+
|
|
830
|
+
msg.classList.toggle('filter-hidden', !hasVisibleContent);
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Re-apply filters when new content is loaded (infinite scroll)
|
|
835
|
+
window.applyFilters = applyFilters;
|
|
836
|
+
window.markInsightBlocks = markInsightBlocks;
|
|
837
|
+
window.wrapAssistantText = wrapAssistantText;
|
|
838
|
+
|
|
839
|
+
// Search bar functionality
|
|
840
|
+
function setupSearchBar() {
|
|
841
|
+
const searchInput = document.getElementById('viewer-search-input');
|
|
842
|
+
const dropdown = document.getElementById('viewer-search-dropdown');
|
|
843
|
+
if (!searchInput || !dropdown) return;
|
|
844
|
+
|
|
845
|
+
let debounceTimer;
|
|
846
|
+
|
|
847
|
+
searchInput.addEventListener('input', () => {
|
|
848
|
+
clearTimeout(debounceTimer);
|
|
849
|
+
const q = searchInput.value.trim();
|
|
850
|
+
if (!q) {
|
|
851
|
+
dropdown.classList.remove('visible');
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
debounceTimer = setTimeout(() => performSearch(q), 200);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// Handle Enter key to go to full search page
|
|
858
|
+
searchInput.addEventListener('keydown', (e) => {
|
|
859
|
+
if (e.key === 'Enter') {
|
|
860
|
+
e.preventDefault();
|
|
861
|
+
const q = searchInput.value.trim();
|
|
862
|
+
if (q) {
|
|
863
|
+
window.location.href = '/search?q=' + encodeURIComponent(q);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// Close dropdown when clicking outside
|
|
869
|
+
document.addEventListener('click', (e) => {
|
|
870
|
+
if (!e.target.closest('.viewer-search-bar')) {
|
|
871
|
+
dropdown.classList.remove('visible');
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
async function performSearch(q) {
|
|
876
|
+
try {
|
|
877
|
+
const res = await fetch('/api/search?q=' + encodeURIComponent(q) + '&limit=5');
|
|
878
|
+
const data = await res.json();
|
|
879
|
+
|
|
880
|
+
dropdown.textContent = '';
|
|
881
|
+
|
|
882
|
+
if (data.results && data.results.length > 0) {
|
|
883
|
+
data.results.forEach(r => {
|
|
884
|
+
const item = document.createElement('div');
|
|
885
|
+
item.className = 'search-dropdown-item';
|
|
886
|
+
item.onclick = () => window.location.href = r.url;
|
|
887
|
+
|
|
888
|
+
const title = document.createElement('h4');
|
|
889
|
+
title.textContent = (r.title || 'Untitled').slice(0, 50);
|
|
890
|
+
item.appendChild(title);
|
|
891
|
+
|
|
892
|
+
const snippet = document.createElement('p');
|
|
893
|
+
// Safe: server escapes content before adding <strong> tags
|
|
894
|
+
snippet.innerHTML = r.snippet;
|
|
895
|
+
item.appendChild(snippet);
|
|
896
|
+
|
|
897
|
+
dropdown.appendChild(item);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
// Add "view all" link
|
|
901
|
+
const viewAll = document.createElement('div');
|
|
902
|
+
viewAll.className = 'search-dropdown-item';
|
|
903
|
+
viewAll.onclick = () => window.location.href = '/search?q=' + encodeURIComponent(q);
|
|
904
|
+
const em = document.createElement('em');
|
|
905
|
+
em.textContent = 'View all results...';
|
|
906
|
+
em.style.color = 'var(--primary, #e94560)';
|
|
907
|
+
viewAll.appendChild(em);
|
|
908
|
+
dropdown.appendChild(viewAll);
|
|
909
|
+
|
|
910
|
+
dropdown.classList.add('visible');
|
|
911
|
+
} else {
|
|
912
|
+
const noResults = document.createElement('div');
|
|
913
|
+
noResults.className = 'search-dropdown-item';
|
|
914
|
+
const em = document.createElement('em');
|
|
915
|
+
em.textContent = 'No results found';
|
|
916
|
+
noResults.appendChild(em);
|
|
917
|
+
dropdown.appendChild(noResults);
|
|
918
|
+
dropdown.classList.add('visible');
|
|
919
|
+
}
|
|
920
|
+
} catch (err) {
|
|
921
|
+
console.error('Search failed:', err);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
})();
|
|
926
|
+
</script>
|
|
927
|
+
`;
|
|
928
|
+
// Search bar HTML to inject at top of body
|
|
929
|
+
const SEARCH_BAR_HTML = `
|
|
930
|
+
<div class="viewer-search-bar">
|
|
931
|
+
<a href="/" class="home-link">← Home</a>
|
|
932
|
+
<div class="search-wrapper">
|
|
933
|
+
<input type="search" id="viewer-search-input" placeholder="Search all conversations..." autocomplete="off" />
|
|
934
|
+
<div id="viewer-search-dropdown" class="search-dropdown"></div>
|
|
935
|
+
</div>
|
|
936
|
+
<div class="filter-toggles" id="filter-toggles">
|
|
937
|
+
<button class="filter-btn active" data-filter="user" title="User messages">👤 User</button>
|
|
938
|
+
<button class="filter-btn active" data-filter="assistant" title="Assistant text">🤖 Assistant</button>
|
|
939
|
+
<button class="filter-btn active" data-filter="tool-use" title="Tool calls">🔧 Tools</button>
|
|
940
|
+
<button class="filter-btn active" data-filter="tool-reply" title="Tool results">📋 Results</button>
|
|
941
|
+
<button class="filter-btn active" data-filter="thinking" title="Thinking blocks">💭 Thinking</button>
|
|
942
|
+
<button class="filter-btn active" data-filter="insight" title="Insight blocks">★ Insight</button>
|
|
943
|
+
</div>
|
|
944
|
+
</div>
|
|
945
|
+
`;
|
|
946
|
+
// Inject enhancements into HTML
|
|
947
|
+
function enhanceHtml(html) {
|
|
948
|
+
const $ = cheerio.load(html);
|
|
949
|
+
// Inject CSS before </head>
|
|
950
|
+
$("head").append(INJECTED_CSS);
|
|
951
|
+
// Inject search bar at beginning of body
|
|
952
|
+
$("body").prepend(SEARCH_BAR_HTML);
|
|
953
|
+
// Inject JS before </body>
|
|
954
|
+
$("body").append(INJECTED_JS);
|
|
955
|
+
return $.html();
|
|
956
|
+
}
|
|
957
|
+
// Serve enhanced HTML files
|
|
958
|
+
// Redirect session index.html to page-001.html for direct navigation
|
|
959
|
+
app.get(/.*\.html$/, (req, res) => {
|
|
960
|
+
// Redirect session index.html to page-001.html
|
|
961
|
+
// Pattern: /project/uuid/index.html -> /project/uuid/page-001.html
|
|
962
|
+
const sessionIndexMatch = req.path.match(/^\/([^/]+)\/([a-f0-9-]{36})\/index\.html$/);
|
|
963
|
+
if (sessionIndexMatch) {
|
|
964
|
+
const [, project, session] = sessionIndexMatch;
|
|
965
|
+
return res.redirect(`/${project}/${session}/page-001.html`);
|
|
966
|
+
}
|
|
967
|
+
const filePath = join(ARCHIVE_DIR, req.path);
|
|
968
|
+
if (!existsSync(filePath)) {
|
|
969
|
+
res.status(404).send("File not found");
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
try {
|
|
973
|
+
const html = readFileSync(filePath, "utf-8");
|
|
974
|
+
const enhanced = enhanceHtml(html);
|
|
975
|
+
res.type("html").send(enhanced);
|
|
976
|
+
}
|
|
977
|
+
catch (err) {
|
|
978
|
+
console.error("Error serving file:", err);
|
|
979
|
+
res.status(500).send("Error serving file");
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
// Serve static assets (CSS, JS, images) - disable index.html serving so our dynamic routes work
|
|
983
|
+
app.use(express.static(ARCHIVE_DIR, { index: false }));
|
|
984
|
+
// Search results page - MUST be before /:project/ route to avoid matching "search" as project name
|
|
985
|
+
app.get("/search", async (req, res) => {
|
|
986
|
+
const startTime = Date.now();
|
|
987
|
+
try {
|
|
988
|
+
const query = req.query.q || "";
|
|
989
|
+
const options = {
|
|
990
|
+
project: req.query.project,
|
|
991
|
+
role: req.query.role,
|
|
992
|
+
after: req.query.after,
|
|
993
|
+
before: req.query.before,
|
|
994
|
+
limit: req.query.limit ? parseInt(req.query.limit, 10) : 50,
|
|
995
|
+
offset: req.query.offset ? parseInt(req.query.offset, 10) : 0,
|
|
996
|
+
};
|
|
997
|
+
const db = getDatabase();
|
|
998
|
+
// Get list of projects for filter dropdown
|
|
999
|
+
const projects = db.prepare(`
|
|
1000
|
+
SELECT DISTINCT project FROM conversations ORDER BY project
|
|
1001
|
+
`).all();
|
|
1002
|
+
let results = [];
|
|
1003
|
+
if (query) {
|
|
1004
|
+
const searchResult = await searchHybrid(query, options, embeddingClient);
|
|
1005
|
+
results = (searchResult.results || []).map((r) => {
|
|
1006
|
+
const terms = query.split(/\s+/).filter(Boolean);
|
|
1007
|
+
const snippet = generateSnippet(r.content, query, 150);
|
|
1008
|
+
const highlightedSnippet = highlightTerms(snippet, terms);
|
|
1009
|
+
return {
|
|
1010
|
+
chunk_id: r.chunk_id,
|
|
1011
|
+
conversation_id: r.conversation_id,
|
|
1012
|
+
project: r.project,
|
|
1013
|
+
title: r.title || "Untitled",
|
|
1014
|
+
snippet: highlightedSnippet,
|
|
1015
|
+
role: r.role,
|
|
1016
|
+
page: r.page_number || 1,
|
|
1017
|
+
score: r.score,
|
|
1018
|
+
url: `/${projectToArchivePath(r.project)}/${r.conversation_id}/page-001.html`,
|
|
1019
|
+
};
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
const html = renderSearchPage({
|
|
1023
|
+
query,
|
|
1024
|
+
results,
|
|
1025
|
+
projects: projects.map((p) => p.project),
|
|
1026
|
+
filters: {
|
|
1027
|
+
project: options.project,
|
|
1028
|
+
role: options.role,
|
|
1029
|
+
after: options.after,
|
|
1030
|
+
before: options.before,
|
|
1031
|
+
},
|
|
1032
|
+
queryTimeMs: Date.now() - startTime,
|
|
1033
|
+
offset: options.offset || 0,
|
|
1034
|
+
limit: options.limit || 50,
|
|
1035
|
+
});
|
|
1036
|
+
res.type("html").send(html);
|
|
1037
|
+
}
|
|
1038
|
+
catch (err) {
|
|
1039
|
+
console.error("Search page error:", err);
|
|
1040
|
+
res.status(500).send("Search failed");
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
// Serve project directory index.html files (since we disabled automatic index serving)
|
|
1044
|
+
// Rewrite session links to go directly to page-001.html instead of session index.html
|
|
1045
|
+
app.get("/:project/", (req, res) => {
|
|
1046
|
+
const project = req.params.project;
|
|
1047
|
+
const projectDir = join(ARCHIVE_DIR, project);
|
|
1048
|
+
const indexPath = resolve(projectDir, "index.html");
|
|
1049
|
+
if (existsSync(indexPath)) {
|
|
1050
|
+
try {
|
|
1051
|
+
let html = readFileSync(indexPath, "utf-8");
|
|
1052
|
+
// Rewrite session links: href="uuid/index.html" -> href="uuid/page-001.html"
|
|
1053
|
+
html = html.replace(/href="([a-f0-9-]{36})\/index\.html"/g, 'href="$1/page-001.html"');
|
|
1054
|
+
// Inject our enhancements (dark mode, etc.)
|
|
1055
|
+
html = enhanceHtml(html);
|
|
1056
|
+
res.type("html").send(html);
|
|
1057
|
+
}
|
|
1058
|
+
catch (err) {
|
|
1059
|
+
console.error("Error serving project index:", err);
|
|
1060
|
+
res.status(500).send("Error serving file");
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
else {
|
|
1064
|
+
res.status(404).send("Project not found");
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
// List available projects
|
|
1068
|
+
app.get("/api/projects", (req, res) => {
|
|
1069
|
+
try {
|
|
1070
|
+
const projects = readdirSync(ARCHIVE_DIR, { withFileTypes: true })
|
|
1071
|
+
.filter(d => d.isDirectory())
|
|
1072
|
+
.map(d => d.name);
|
|
1073
|
+
res.json({ projects });
|
|
1074
|
+
}
|
|
1075
|
+
catch (err) {
|
|
1076
|
+
res.status(500).json({ error: "Failed to list projects" });
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
// Search API endpoint
|
|
1080
|
+
app.get("/api/search", async (req, res) => {
|
|
1081
|
+
const startTime = Date.now();
|
|
1082
|
+
try {
|
|
1083
|
+
const query = req.query.q || "";
|
|
1084
|
+
const options = {
|
|
1085
|
+
project: req.query.project,
|
|
1086
|
+
role: req.query.role,
|
|
1087
|
+
after: req.query.after,
|
|
1088
|
+
before: req.query.before,
|
|
1089
|
+
limit: req.query.limit ? parseInt(req.query.limit, 10) : 20,
|
|
1090
|
+
offset: req.query.offset ? parseInt(req.query.offset, 10) : 0,
|
|
1091
|
+
};
|
|
1092
|
+
const result = await searchHybrid(query, options, embeddingClient);
|
|
1093
|
+
// Format response according to design spec
|
|
1094
|
+
if (result.type === "recent") {
|
|
1095
|
+
res.json({
|
|
1096
|
+
type: "recent",
|
|
1097
|
+
conversations: result.conversations,
|
|
1098
|
+
query_time_ms: Date.now() - startTime,
|
|
1099
|
+
});
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
// Add snippets with highlighting to results
|
|
1103
|
+
const resultsWithSnippets = (result.results || []).map((r) => {
|
|
1104
|
+
const terms = query.split(/\s+/).filter(Boolean);
|
|
1105
|
+
const snippet = generateSnippet(r.content, query, 100);
|
|
1106
|
+
const highlightedSnippet = highlightTerms(snippet, terms);
|
|
1107
|
+
return {
|
|
1108
|
+
chunk_id: r.chunk_id,
|
|
1109
|
+
conversation_id: r.conversation_id,
|
|
1110
|
+
project: r.project,
|
|
1111
|
+
title: r.title,
|
|
1112
|
+
snippet: highlightedSnippet,
|
|
1113
|
+
role: r.role,
|
|
1114
|
+
page: r.page_number,
|
|
1115
|
+
score: r.score,
|
|
1116
|
+
url: `/${projectToArchivePath(r.project)}/${r.conversation_id}/page-001.html`,
|
|
1117
|
+
};
|
|
1118
|
+
});
|
|
1119
|
+
res.json({
|
|
1120
|
+
type: result.type,
|
|
1121
|
+
results: resultsWithSnippets,
|
|
1122
|
+
total: resultsWithSnippets.length,
|
|
1123
|
+
query_time_ms: Date.now() - startTime,
|
|
1124
|
+
embedding_status: result.embeddingStatus,
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
catch (err) {
|
|
1128
|
+
console.error("Search error:", err);
|
|
1129
|
+
res.status(500).json({ error: "Search failed" });
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
// Index status endpoint
|
|
1133
|
+
app.get("/api/index/status", (req, res) => {
|
|
1134
|
+
try {
|
|
1135
|
+
const db = getDatabase();
|
|
1136
|
+
const chunkCount = db.prepare("SELECT COUNT(*) as count FROM chunks").get();
|
|
1137
|
+
const convCount = db.prepare("SELECT COUNT(*) as count FROM conversations").get();
|
|
1138
|
+
const overallStatus = archiveStatus.isGenerating
|
|
1139
|
+
? "generating"
|
|
1140
|
+
: indexingStatus.isIndexing
|
|
1141
|
+
? "indexing"
|
|
1142
|
+
: "ready";
|
|
1143
|
+
res.json({
|
|
1144
|
+
status: overallStatus,
|
|
1145
|
+
conversations: convCount.count,
|
|
1146
|
+
chunks: chunkCount.count,
|
|
1147
|
+
embedding_server: embeddingClient ? "connected" : "unavailable",
|
|
1148
|
+
archive: {
|
|
1149
|
+
isGenerating: archiveStatus.isGenerating,
|
|
1150
|
+
progress: archiveStatus.progress,
|
|
1151
|
+
lastError: archiveStatus.lastError,
|
|
1152
|
+
lastRun: archiveStatus.lastRun,
|
|
1153
|
+
},
|
|
1154
|
+
indexing: {
|
|
1155
|
+
isIndexing: indexingStatus.isIndexing,
|
|
1156
|
+
progress: indexingStatus.progress,
|
|
1157
|
+
lastError: indexingStatus.lastError,
|
|
1158
|
+
lastStats: indexingStatus.lastStats,
|
|
1159
|
+
},
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
catch (err) {
|
|
1163
|
+
res.json({
|
|
1164
|
+
status: "not_initialized",
|
|
1165
|
+
conversations: 0,
|
|
1166
|
+
chunks: 0,
|
|
1167
|
+
embedding_server: "unavailable",
|
|
1168
|
+
archive: archiveStatus,
|
|
1169
|
+
indexing: indexingStatus,
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
// Manually trigger archive regeneration
|
|
1174
|
+
app.post("/api/archive/regenerate", async (req, res) => {
|
|
1175
|
+
if (archiveStatus.isGenerating) {
|
|
1176
|
+
res.status(409).json({ error: "Archive generation already in progress" });
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (!SOURCE_DIR) {
|
|
1180
|
+
res.status(400).json({ error: "No SOURCE_DIR configured" });
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
// Start archive generation in background, then index
|
|
1184
|
+
(async () => {
|
|
1185
|
+
await generateArchive();
|
|
1186
|
+
await startBackgroundIndexing();
|
|
1187
|
+
})();
|
|
1188
|
+
res.json({ status: "started", message: "Archive generation started in background" });
|
|
1189
|
+
});
|
|
1190
|
+
// Manually trigger re-indexing
|
|
1191
|
+
app.post("/api/index/reindex", async (req, res) => {
|
|
1192
|
+
if (indexingStatus.isIndexing) {
|
|
1193
|
+
res.status(409).json({ error: "Indexing already in progress" });
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
if (!SOURCE_DIR) {
|
|
1197
|
+
res.status(400).json({ error: "No SOURCE_DIR configured" });
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
// Start indexing in background
|
|
1201
|
+
startBackgroundIndexing();
|
|
1202
|
+
res.json({ status: "started", message: "Indexing started in background" });
|
|
1203
|
+
});
|
|
1204
|
+
function renderSearchPage(data) {
|
|
1205
|
+
const projectOptions = data.projects
|
|
1206
|
+
.map((p) => `<option value="${escapeHtml(p)}"${data.filters.project === p ? " selected" : ""}>${escapeHtml(p)}</option>`)
|
|
1207
|
+
.join("");
|
|
1208
|
+
const resultItems = data.results
|
|
1209
|
+
.map((r) => `
|
|
1210
|
+
<div class="search-result-item">
|
|
1211
|
+
<a href="${escapeHtml(r.url)}">
|
|
1212
|
+
<h3>${escapeHtml(r.title?.slice(0, 80) || "Untitled")}${r.title && r.title.length > 80 ? "..." : ""}</h3>
|
|
1213
|
+
<div class="result-meta">
|
|
1214
|
+
<span class="project">${escapeHtml(r.project)}</span>
|
|
1215
|
+
<span class="role ${r.role}">${escapeHtml(r.role)}</span>
|
|
1216
|
+
</div>
|
|
1217
|
+
<p class="result-snippet">${r.snippet}</p>
|
|
1218
|
+
</a>
|
|
1219
|
+
</div>
|
|
1220
|
+
`)
|
|
1221
|
+
.join("");
|
|
1222
|
+
const hasMore = data.results.length === data.limit;
|
|
1223
|
+
const prevOffset = Math.max(0, data.offset - data.limit);
|
|
1224
|
+
const nextOffset = data.offset + data.limit;
|
|
1225
|
+
const buildUrl = (offset) => {
|
|
1226
|
+
const params = new URLSearchParams();
|
|
1227
|
+
if (data.query)
|
|
1228
|
+
params.set("q", data.query);
|
|
1229
|
+
if (data.filters.project)
|
|
1230
|
+
params.set("project", data.filters.project);
|
|
1231
|
+
if (data.filters.role)
|
|
1232
|
+
params.set("role", data.filters.role);
|
|
1233
|
+
if (data.filters.after)
|
|
1234
|
+
params.set("after", data.filters.after);
|
|
1235
|
+
if (data.filters.before)
|
|
1236
|
+
params.set("before", data.filters.before);
|
|
1237
|
+
if (offset > 0)
|
|
1238
|
+
params.set("offset", String(offset));
|
|
1239
|
+
return `/search?${params.toString()}`;
|
|
1240
|
+
};
|
|
1241
|
+
return `<!DOCTYPE html>
|
|
1242
|
+
<html lang="en">
|
|
1243
|
+
<head>
|
|
1244
|
+
<meta charset="UTF-8">
|
|
1245
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1246
|
+
<title>${data.query ? escapeHtml(data.query) + " - " : ""}Search - Claude Transcript Viewer</title>
|
|
1247
|
+
<style>
|
|
1248
|
+
:root {
|
|
1249
|
+
--bg: #1a1a2e;
|
|
1250
|
+
--surface: #16213e;
|
|
1251
|
+
--primary: #e94560;
|
|
1252
|
+
--text: #eee;
|
|
1253
|
+
--text-muted: #888;
|
|
1254
|
+
--border: #333;
|
|
1255
|
+
}
|
|
1256
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1257
|
+
body {
|
|
1258
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1259
|
+
background: var(--bg);
|
|
1260
|
+
color: var(--text);
|
|
1261
|
+
line-height: 1.6;
|
|
1262
|
+
}
|
|
1263
|
+
.header {
|
|
1264
|
+
background: var(--surface);
|
|
1265
|
+
padding: 1rem 2rem;
|
|
1266
|
+
border-bottom: 1px solid var(--border);
|
|
1267
|
+
}
|
|
1268
|
+
.header a { color: var(--primary); text-decoration: none; }
|
|
1269
|
+
.header a:hover { text-decoration: underline; }
|
|
1270
|
+
.search-form {
|
|
1271
|
+
max-width: 1200px;
|
|
1272
|
+
margin: 2rem auto;
|
|
1273
|
+
padding: 0 2rem;
|
|
1274
|
+
}
|
|
1275
|
+
.search-row {
|
|
1276
|
+
display: flex;
|
|
1277
|
+
gap: 1rem;
|
|
1278
|
+
margin-bottom: 1rem;
|
|
1279
|
+
}
|
|
1280
|
+
.search-row input[type="search"] {
|
|
1281
|
+
flex: 1;
|
|
1282
|
+
padding: 0.75rem 1rem;
|
|
1283
|
+
font-size: 1rem;
|
|
1284
|
+
border: 2px solid var(--border);
|
|
1285
|
+
border-radius: 8px;
|
|
1286
|
+
background: var(--surface);
|
|
1287
|
+
color: var(--text);
|
|
1288
|
+
}
|
|
1289
|
+
.search-row input[type="search"]:focus {
|
|
1290
|
+
outline: none;
|
|
1291
|
+
border-color: var(--primary);
|
|
1292
|
+
}
|
|
1293
|
+
.search-row button {
|
|
1294
|
+
padding: 0.75rem 1.5rem;
|
|
1295
|
+
font-size: 1rem;
|
|
1296
|
+
border: none;
|
|
1297
|
+
border-radius: 8px;
|
|
1298
|
+
background: var(--primary);
|
|
1299
|
+
color: #fff;
|
|
1300
|
+
cursor: pointer;
|
|
1301
|
+
}
|
|
1302
|
+
.search-row button:hover { opacity: 0.9; }
|
|
1303
|
+
.filters {
|
|
1304
|
+
display: flex;
|
|
1305
|
+
gap: 1rem;
|
|
1306
|
+
flex-wrap: wrap;
|
|
1307
|
+
}
|
|
1308
|
+
.filters select, .filters input {
|
|
1309
|
+
padding: 0.5rem;
|
|
1310
|
+
border: 1px solid var(--border);
|
|
1311
|
+
border-radius: 4px;
|
|
1312
|
+
background: var(--surface);
|
|
1313
|
+
color: var(--text);
|
|
1314
|
+
font-size: 0.875rem;
|
|
1315
|
+
}
|
|
1316
|
+
.filters label {
|
|
1317
|
+
color: var(--text-muted);
|
|
1318
|
+
font-size: 0.75rem;
|
|
1319
|
+
display: block;
|
|
1320
|
+
margin-bottom: 0.25rem;
|
|
1321
|
+
}
|
|
1322
|
+
.results-container {
|
|
1323
|
+
max-width: 1200px;
|
|
1324
|
+
margin: 0 auto;
|
|
1325
|
+
padding: 0 2rem 2rem;
|
|
1326
|
+
}
|
|
1327
|
+
.results-info {
|
|
1328
|
+
color: var(--text-muted);
|
|
1329
|
+
font-size: 0.875rem;
|
|
1330
|
+
margin-bottom: 1rem;
|
|
1331
|
+
}
|
|
1332
|
+
.search-result-item {
|
|
1333
|
+
background: var(--surface);
|
|
1334
|
+
border-radius: 8px;
|
|
1335
|
+
margin-bottom: 1rem;
|
|
1336
|
+
transition: transform 0.2s;
|
|
1337
|
+
}
|
|
1338
|
+
.search-result-item:hover {
|
|
1339
|
+
transform: translateX(4px);
|
|
1340
|
+
}
|
|
1341
|
+
.search-result-item a {
|
|
1342
|
+
display: block;
|
|
1343
|
+
padding: 1rem 1.5rem;
|
|
1344
|
+
text-decoration: none;
|
|
1345
|
+
color: inherit;
|
|
1346
|
+
}
|
|
1347
|
+
.search-result-item h3 {
|
|
1348
|
+
margin-bottom: 0.5rem;
|
|
1349
|
+
color: var(--text);
|
|
1350
|
+
}
|
|
1351
|
+
.result-meta {
|
|
1352
|
+
display: flex;
|
|
1353
|
+
gap: 0.5rem;
|
|
1354
|
+
margin-bottom: 0.5rem;
|
|
1355
|
+
}
|
|
1356
|
+
.result-meta span {
|
|
1357
|
+
font-size: 0.75rem;
|
|
1358
|
+
padding: 0.125rem 0.5rem;
|
|
1359
|
+
border-radius: 4px;
|
|
1360
|
+
}
|
|
1361
|
+
.result-meta .project {
|
|
1362
|
+
background: rgba(233, 69, 96, 0.2);
|
|
1363
|
+
color: var(--primary);
|
|
1364
|
+
}
|
|
1365
|
+
.result-meta .role {
|
|
1366
|
+
background: rgba(255, 255, 255, 0.1);
|
|
1367
|
+
color: var(--text-muted);
|
|
1368
|
+
}
|
|
1369
|
+
.result-meta .role.user { color: #64b5f6; }
|
|
1370
|
+
.result-meta .role.assistant { color: #81c784; }
|
|
1371
|
+
.result-snippet {
|
|
1372
|
+
color: var(--text-muted);
|
|
1373
|
+
font-size: 0.875rem;
|
|
1374
|
+
}
|
|
1375
|
+
.result-snippet strong {
|
|
1376
|
+
color: var(--primary);
|
|
1377
|
+
font-weight: normal;
|
|
1378
|
+
background: rgba(233, 69, 96, 0.2);
|
|
1379
|
+
padding: 0 2px;
|
|
1380
|
+
border-radius: 2px;
|
|
1381
|
+
}
|
|
1382
|
+
.pagination {
|
|
1383
|
+
display: flex;
|
|
1384
|
+
justify-content: center;
|
|
1385
|
+
gap: 1rem;
|
|
1386
|
+
margin-top: 2rem;
|
|
1387
|
+
}
|
|
1388
|
+
.pagination a {
|
|
1389
|
+
padding: 0.5rem 1rem;
|
|
1390
|
+
background: var(--surface);
|
|
1391
|
+
color: var(--text);
|
|
1392
|
+
text-decoration: none;
|
|
1393
|
+
border-radius: 4px;
|
|
1394
|
+
}
|
|
1395
|
+
.pagination a:hover { background: rgba(255,255,255,0.1); }
|
|
1396
|
+
.pagination a.disabled {
|
|
1397
|
+
opacity: 0.5;
|
|
1398
|
+
pointer-events: none;
|
|
1399
|
+
}
|
|
1400
|
+
.no-results {
|
|
1401
|
+
text-align: center;
|
|
1402
|
+
padding: 3rem;
|
|
1403
|
+
color: var(--text-muted);
|
|
1404
|
+
}
|
|
1405
|
+
</style>
|
|
1406
|
+
</head>
|
|
1407
|
+
<body>
|
|
1408
|
+
<div class="header">
|
|
1409
|
+
<a href="/">← Back to Home</a>
|
|
1410
|
+
</div>
|
|
1411
|
+
|
|
1412
|
+
<form class="search-form" method="GET" action="/search">
|
|
1413
|
+
<div class="search-row">
|
|
1414
|
+
<input type="search" name="q" value="${escapeHtml(data.query)}" placeholder="Search conversations..." autofocus />
|
|
1415
|
+
<button type="submit">Search</button>
|
|
1416
|
+
</div>
|
|
1417
|
+
<div class="filters">
|
|
1418
|
+
<div>
|
|
1419
|
+
<label>Project</label>
|
|
1420
|
+
<select name="project">
|
|
1421
|
+
<option value="">All projects</option>
|
|
1422
|
+
${projectOptions}
|
|
1423
|
+
</select>
|
|
1424
|
+
</div>
|
|
1425
|
+
<div>
|
|
1426
|
+
<label>Role</label>
|
|
1427
|
+
<select name="role">
|
|
1428
|
+
<option value="">All roles</option>
|
|
1429
|
+
<option value="user"${data.filters.role === "user" ? " selected" : ""}>User</option>
|
|
1430
|
+
<option value="assistant"${data.filters.role === "assistant" ? " selected" : ""}>Assistant</option>
|
|
1431
|
+
</select>
|
|
1432
|
+
</div>
|
|
1433
|
+
<div>
|
|
1434
|
+
<label>After</label>
|
|
1435
|
+
<input type="date" name="after" value="${escapeHtml(data.filters.after || "")}" />
|
|
1436
|
+
</div>
|
|
1437
|
+
<div>
|
|
1438
|
+
<label>Before</label>
|
|
1439
|
+
<input type="date" name="before" value="${escapeHtml(data.filters.before || "")}" />
|
|
1440
|
+
</div>
|
|
1441
|
+
</div>
|
|
1442
|
+
</form>
|
|
1443
|
+
|
|
1444
|
+
<div class="results-container">
|
|
1445
|
+
${data.query
|
|
1446
|
+
? `<p class="results-info">${data.results.length} results for "${escapeHtml(data.query)}" (${data.queryTimeMs}ms)</p>`
|
|
1447
|
+
: `<p class="results-info">Enter a search query above</p>`}
|
|
1448
|
+
|
|
1449
|
+
${data.results.length > 0
|
|
1450
|
+
? resultItems
|
|
1451
|
+
: data.query
|
|
1452
|
+
? `<div class="no-results"><p>No results found for "${escapeHtml(data.query)}"</p></div>`
|
|
1453
|
+
: ""}
|
|
1454
|
+
|
|
1455
|
+
${data.query && (data.offset > 0 || hasMore)
|
|
1456
|
+
? `
|
|
1457
|
+
<div class="pagination">
|
|
1458
|
+
<a href="${buildUrl(prevOffset)}" class="${data.offset === 0 ? "disabled" : ""}">← Previous</a>
|
|
1459
|
+
<a href="${buildUrl(nextOffset)}" class="${!hasMore ? "disabled" : ""}">Next →</a>
|
|
1460
|
+
</div>
|
|
1461
|
+
`
|
|
1462
|
+
: ""}
|
|
1463
|
+
</div>
|
|
1464
|
+
</body>
|
|
1465
|
+
</html>`;
|
|
1466
|
+
}
|
|
1467
|
+
// Landing page
|
|
1468
|
+
app.get("/", async (req, res) => {
|
|
1469
|
+
try {
|
|
1470
|
+
const db = getDatabase();
|
|
1471
|
+
// Get project stats
|
|
1472
|
+
const projects = db.prepare(`
|
|
1473
|
+
SELECT project, COUNT(*) as count, MAX(created_at) as last_updated
|
|
1474
|
+
FROM conversations
|
|
1475
|
+
GROUP BY project
|
|
1476
|
+
ORDER BY last_updated DESC
|
|
1477
|
+
`).all();
|
|
1478
|
+
// Get recent conversations
|
|
1479
|
+
const recentConversations = db.prepare(`
|
|
1480
|
+
SELECT id, project, title, created_at
|
|
1481
|
+
FROM conversations
|
|
1482
|
+
ORDER BY created_at DESC
|
|
1483
|
+
LIMIT 10
|
|
1484
|
+
`).all();
|
|
1485
|
+
// Get index stats
|
|
1486
|
+
const chunkCount = db.prepare("SELECT COUNT(*) as count FROM chunks").get();
|
|
1487
|
+
const html = renderLandingPage({
|
|
1488
|
+
projects,
|
|
1489
|
+
recentConversations,
|
|
1490
|
+
chunkCount: chunkCount.count,
|
|
1491
|
+
embeddingStatus: embeddingClient ? "connected" : "unavailable",
|
|
1492
|
+
});
|
|
1493
|
+
res.type("html").send(html);
|
|
1494
|
+
}
|
|
1495
|
+
catch (err) {
|
|
1496
|
+
// Database not initialized - show setup instructions
|
|
1497
|
+
res.type("html").send(renderSetupPage());
|
|
1498
|
+
}
|
|
1499
|
+
});
|
|
1500
|
+
function renderLandingPage(data) {
|
|
1501
|
+
const projectCards = data.projects
|
|
1502
|
+
.map((p) => {
|
|
1503
|
+
const archiveName = projectToArchivePath(p.project);
|
|
1504
|
+
return `
|
|
1505
|
+
<a href="/${escapeHtml(archiveName)}/" class="project-card">
|
|
1506
|
+
<h3>${escapeHtml(archiveName)}</h3>
|
|
1507
|
+
<p>${p.count} conversations</p>
|
|
1508
|
+
<small>Updated: ${new Date(p.last_updated).toLocaleDateString()}</small>
|
|
1509
|
+
</a>
|
|
1510
|
+
`;
|
|
1511
|
+
})
|
|
1512
|
+
.join("");
|
|
1513
|
+
const recentList = data.recentConversations
|
|
1514
|
+
.map((c) => {
|
|
1515
|
+
const archiveName = projectToArchivePath(c.project);
|
|
1516
|
+
return `
|
|
1517
|
+
<li>
|
|
1518
|
+
<a href="/${escapeHtml(archiveName)}/${escapeHtml(c.id)}/page-001.html">
|
|
1519
|
+
<strong>${escapeHtml(c.title?.slice(0, 60) || "Untitled")}${c.title && c.title.length > 60 ? "..." : ""}</strong>
|
|
1520
|
+
<span class="meta">${escapeHtml(archiveName)} - ${new Date(c.created_at).toLocaleDateString()}</span>
|
|
1521
|
+
</a>
|
|
1522
|
+
</li>
|
|
1523
|
+
`;
|
|
1524
|
+
})
|
|
1525
|
+
.join("");
|
|
1526
|
+
return `<!DOCTYPE html>
|
|
1527
|
+
<html lang="en">
|
|
1528
|
+
<head>
|
|
1529
|
+
<meta charset="UTF-8">
|
|
1530
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1531
|
+
<title>Claude Transcript Viewer</title>
|
|
1532
|
+
<style>
|
|
1533
|
+
:root {
|
|
1534
|
+
--bg: #1a1a2e;
|
|
1535
|
+
--surface: #16213e;
|
|
1536
|
+
--primary: #e94560;
|
|
1537
|
+
--text: #eee;
|
|
1538
|
+
--text-muted: #888;
|
|
1539
|
+
--border: #333;
|
|
1540
|
+
}
|
|
1541
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1542
|
+
body {
|
|
1543
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1544
|
+
background: var(--bg);
|
|
1545
|
+
color: var(--text);
|
|
1546
|
+
line-height: 1.6;
|
|
1547
|
+
padding: 2rem;
|
|
1548
|
+
max-width: 1200px;
|
|
1549
|
+
margin: 0 auto;
|
|
1550
|
+
}
|
|
1551
|
+
h1 { margin-bottom: 0.5rem; }
|
|
1552
|
+
.status {
|
|
1553
|
+
color: var(--text-muted);
|
|
1554
|
+
font-size: 0.875rem;
|
|
1555
|
+
margin-bottom: 2rem;
|
|
1556
|
+
}
|
|
1557
|
+
.status .dot {
|
|
1558
|
+
display: inline-block;
|
|
1559
|
+
width: 8px;
|
|
1560
|
+
height: 8px;
|
|
1561
|
+
border-radius: 50%;
|
|
1562
|
+
margin-right: 4px;
|
|
1563
|
+
}
|
|
1564
|
+
.status .dot.green { background: #4caf50; }
|
|
1565
|
+
.status .dot.yellow { background: #ff9800; }
|
|
1566
|
+
.search-container {
|
|
1567
|
+
margin-bottom: 2rem;
|
|
1568
|
+
}
|
|
1569
|
+
#search-input {
|
|
1570
|
+
width: 100%;
|
|
1571
|
+
padding: 1rem;
|
|
1572
|
+
font-size: 1.1rem;
|
|
1573
|
+
border: 2px solid var(--border);
|
|
1574
|
+
border-radius: 8px;
|
|
1575
|
+
background: var(--surface);
|
|
1576
|
+
color: var(--text);
|
|
1577
|
+
}
|
|
1578
|
+
#search-input:focus {
|
|
1579
|
+
outline: none;
|
|
1580
|
+
border-color: var(--primary);
|
|
1581
|
+
}
|
|
1582
|
+
#search-results {
|
|
1583
|
+
background: var(--surface);
|
|
1584
|
+
border: 1px solid var(--border);
|
|
1585
|
+
border-radius: 8px;
|
|
1586
|
+
margin-top: 0.5rem;
|
|
1587
|
+
display: none;
|
|
1588
|
+
}
|
|
1589
|
+
#search-results.visible { display: block; }
|
|
1590
|
+
.search-result {
|
|
1591
|
+
padding: 1rem;
|
|
1592
|
+
border-bottom: 1px solid var(--border);
|
|
1593
|
+
cursor: pointer;
|
|
1594
|
+
}
|
|
1595
|
+
.search-result:hover { background: rgba(255,255,255,0.05); }
|
|
1596
|
+
.search-result:last-child { border-bottom: none; }
|
|
1597
|
+
.search-result h4 { margin-bottom: 0.25rem; }
|
|
1598
|
+
.search-result .snippet { color: var(--text-muted); font-size: 0.875rem; }
|
|
1599
|
+
.search-result .snippet strong { color: var(--primary); }
|
|
1600
|
+
h2 { margin: 2rem 0 1rem; color: var(--text-muted); font-size: 0.875rem; text-transform: uppercase; }
|
|
1601
|
+
.projects {
|
|
1602
|
+
display: grid;
|
|
1603
|
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
1604
|
+
gap: 1rem;
|
|
1605
|
+
margin-bottom: 2rem;
|
|
1606
|
+
}
|
|
1607
|
+
.project-card {
|
|
1608
|
+
background: var(--surface);
|
|
1609
|
+
padding: 1.5rem;
|
|
1610
|
+
border-radius: 8px;
|
|
1611
|
+
text-decoration: none;
|
|
1612
|
+
color: var(--text);
|
|
1613
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
1614
|
+
}
|
|
1615
|
+
.project-card:hover {
|
|
1616
|
+
transform: translateY(-2px);
|
|
1617
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
1618
|
+
}
|
|
1619
|
+
.project-card h3 { margin-bottom: 0.5rem; }
|
|
1620
|
+
.project-card p { color: var(--text-muted); }
|
|
1621
|
+
.project-card small { color: var(--text-muted); font-size: 0.75rem; }
|
|
1622
|
+
.recent-list {
|
|
1623
|
+
list-style: none;
|
|
1624
|
+
}
|
|
1625
|
+
.recent-list li {
|
|
1626
|
+
margin-bottom: 0.75rem;
|
|
1627
|
+
}
|
|
1628
|
+
.recent-list a {
|
|
1629
|
+
display: block;
|
|
1630
|
+
padding: 1rem;
|
|
1631
|
+
background: var(--surface);
|
|
1632
|
+
border-radius: 8px;
|
|
1633
|
+
text-decoration: none;
|
|
1634
|
+
color: var(--text);
|
|
1635
|
+
}
|
|
1636
|
+
.recent-list a:hover { background: rgba(255,255,255,0.05); }
|
|
1637
|
+
.recent-list .meta {
|
|
1638
|
+
display: block;
|
|
1639
|
+
color: var(--text-muted);
|
|
1640
|
+
font-size: 0.75rem;
|
|
1641
|
+
margin-top: 0.25rem;
|
|
1642
|
+
}
|
|
1643
|
+
</style>
|
|
1644
|
+
</head>
|
|
1645
|
+
<body>
|
|
1646
|
+
<h1>Claude Transcript Viewer</h1>
|
|
1647
|
+
<p class="status">
|
|
1648
|
+
<span class="dot green"></span> ${data.chunkCount.toLocaleString()} chunks indexed
|
|
1649
|
+
<span style="margin-left: 1rem;">
|
|
1650
|
+
<span class="dot ${data.embeddingStatus === "connected" ? "green" : "yellow"}"></span>
|
|
1651
|
+
Embeddings: ${data.embeddingStatus}
|
|
1652
|
+
</span>
|
|
1653
|
+
</p>
|
|
1654
|
+
|
|
1655
|
+
<div class="search-container">
|
|
1656
|
+
<input type="search" id="search-input" placeholder="Search conversations..." autocomplete="off" />
|
|
1657
|
+
<div id="search-results"></div>
|
|
1658
|
+
</div>
|
|
1659
|
+
|
|
1660
|
+
<h2>Projects</h2>
|
|
1661
|
+
<div class="projects">
|
|
1662
|
+
${projectCards || "<p>No projects indexed yet.</p>"}
|
|
1663
|
+
</div>
|
|
1664
|
+
|
|
1665
|
+
<h2>Recent Conversations</h2>
|
|
1666
|
+
<ul class="recent-list">
|
|
1667
|
+
${recentList || "<li>No conversations indexed yet.</li>"}
|
|
1668
|
+
</ul>
|
|
1669
|
+
|
|
1670
|
+
<script>
|
|
1671
|
+
const input = document.getElementById('search-input');
|
|
1672
|
+
const resultsContainer = document.getElementById('search-results');
|
|
1673
|
+
let debounceTimer;
|
|
1674
|
+
|
|
1675
|
+
input.addEventListener('input', () => {
|
|
1676
|
+
clearTimeout(debounceTimer);
|
|
1677
|
+
const q = input.value.trim();
|
|
1678
|
+
if (!q) {
|
|
1679
|
+
resultsContainer.classList.remove('visible');
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
debounceTimer = setTimeout(() => search(q), 200);
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
async function search(q) {
|
|
1686
|
+
try {
|
|
1687
|
+
const res = await fetch('/api/search?q=' + encodeURIComponent(q) + '&limit=5');
|
|
1688
|
+
const data = await res.json();
|
|
1689
|
+
|
|
1690
|
+
// Clear previous results using safe DOM manipulation
|
|
1691
|
+
resultsContainer.textContent = '';
|
|
1692
|
+
|
|
1693
|
+
if (data.results && data.results.length > 0) {
|
|
1694
|
+
data.results.forEach(r => {
|
|
1695
|
+
const div = document.createElement('div');
|
|
1696
|
+
div.className = 'search-result';
|
|
1697
|
+
div.onclick = () => window.location.href = r.url;
|
|
1698
|
+
|
|
1699
|
+
const title = document.createElement('h4');
|
|
1700
|
+
title.textContent = r.title || 'Untitled';
|
|
1701
|
+
div.appendChild(title);
|
|
1702
|
+
|
|
1703
|
+
const snippet = document.createElement('p');
|
|
1704
|
+
snippet.className = 'snippet';
|
|
1705
|
+
// Safe: server escapes content before adding <strong> tags
|
|
1706
|
+
snippet.innerHTML = r.snippet;
|
|
1707
|
+
div.appendChild(snippet);
|
|
1708
|
+
|
|
1709
|
+
resultsContainer.appendChild(div);
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
// Add "view all" link
|
|
1713
|
+
const viewAll = document.createElement('div');
|
|
1714
|
+
viewAll.className = 'search-result';
|
|
1715
|
+
viewAll.onclick = () => window.location.href = '/search?q=' + encodeURIComponent(q);
|
|
1716
|
+
const em = document.createElement('em');
|
|
1717
|
+
em.textContent = 'View all results for "' + q + '"';
|
|
1718
|
+
viewAll.appendChild(em);
|
|
1719
|
+
resultsContainer.appendChild(viewAll);
|
|
1720
|
+
|
|
1721
|
+
resultsContainer.classList.add('visible');
|
|
1722
|
+
} else {
|
|
1723
|
+
const noResults = document.createElement('div');
|
|
1724
|
+
noResults.className = 'search-result';
|
|
1725
|
+
const em = document.createElement('em');
|
|
1726
|
+
em.textContent = 'No results found';
|
|
1727
|
+
noResults.appendChild(em);
|
|
1728
|
+
resultsContainer.appendChild(noResults);
|
|
1729
|
+
resultsContainer.classList.add('visible');
|
|
1730
|
+
}
|
|
1731
|
+
} catch (err) {
|
|
1732
|
+
console.error('Search failed:', err);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
</script>
|
|
1736
|
+
</body>
|
|
1737
|
+
</html>`;
|
|
1738
|
+
}
|
|
1739
|
+
function renderSetupPage() {
|
|
1740
|
+
return `<!DOCTYPE html>
|
|
1741
|
+
<html lang="en">
|
|
1742
|
+
<head>
|
|
1743
|
+
<meta charset="UTF-8">
|
|
1744
|
+
<title>Claude Transcript Viewer - Setup</title>
|
|
1745
|
+
<style>
|
|
1746
|
+
body {
|
|
1747
|
+
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
|
1748
|
+
background: #1a1a2e;
|
|
1749
|
+
color: #eee;
|
|
1750
|
+
padding: 2rem;
|
|
1751
|
+
max-width: 800px;
|
|
1752
|
+
margin: 0 auto;
|
|
1753
|
+
}
|
|
1754
|
+
h1 { margin-bottom: 1rem; }
|
|
1755
|
+
pre {
|
|
1756
|
+
background: #16213e;
|
|
1757
|
+
padding: 1rem;
|
|
1758
|
+
border-radius: 8px;
|
|
1759
|
+
overflow-x: auto;
|
|
1760
|
+
}
|
|
1761
|
+
code { color: #e94560; }
|
|
1762
|
+
</style>
|
|
1763
|
+
</head>
|
|
1764
|
+
<body>
|
|
1765
|
+
<h1>Welcome to Claude Transcript Viewer</h1>
|
|
1766
|
+
<p>To get started, index your transcripts:</p>
|
|
1767
|
+
<pre><code>npm run index /path/to/transcripts ./search.db</code></pre>
|
|
1768
|
+
<p>Then restart the server with the database path:</p>
|
|
1769
|
+
<pre><code>DATABASE_PATH=./search.db npm run dev /path/to/archive</code></pre>
|
|
1770
|
+
</body>
|
|
1771
|
+
</html>`;
|
|
1772
|
+
}
|
|
1773
|
+
function escapeHtml(str) {
|
|
1774
|
+
return str
|
|
1775
|
+
.replace(/&/g, "&")
|
|
1776
|
+
.replace(/</g, "<")
|
|
1777
|
+
.replace(/>/g, ">")
|
|
1778
|
+
.replace(/"/g, """)
|
|
1779
|
+
.replace(/'/g, "'");
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Convert database project slug to archive directory name.
|
|
1783
|
+
* Uses the pre-built mapping from buildProjectMapping().
|
|
1784
|
+
*/
|
|
1785
|
+
function projectToArchivePath(project) {
|
|
1786
|
+
// Check the mapping first
|
|
1787
|
+
const mapped = projectToArchiveMap.get(project);
|
|
1788
|
+
if (mapped) {
|
|
1789
|
+
return mapped;
|
|
1790
|
+
}
|
|
1791
|
+
// If it doesn't start with "-", it's already a simple name
|
|
1792
|
+
if (!project.startsWith("-")) {
|
|
1793
|
+
return project;
|
|
1794
|
+
}
|
|
1795
|
+
// Fallback: try suffix matching against archive directories
|
|
1796
|
+
// This handles cases where the mapping wasn't built yet
|
|
1797
|
+
const slug = project.replace(/^-/, "");
|
|
1798
|
+
try {
|
|
1799
|
+
const entries = readdirSync(ARCHIVE_DIR, { withFileTypes: true });
|
|
1800
|
+
let bestMatch;
|
|
1801
|
+
let bestMatchLen = 0;
|
|
1802
|
+
for (const entry of entries) {
|
|
1803
|
+
if (entry.isDirectory()) {
|
|
1804
|
+
const dir = entry.name;
|
|
1805
|
+
if (slug === dir || slug.endsWith(`-${dir}`)) {
|
|
1806
|
+
if (dir.length > bestMatchLen) {
|
|
1807
|
+
bestMatch = dir;
|
|
1808
|
+
bestMatchLen = dir.length;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
if (bestMatch) {
|
|
1814
|
+
// Cache for future use
|
|
1815
|
+
projectToArchiveMap.set(project, bestMatch);
|
|
1816
|
+
return bestMatch;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
catch {
|
|
1820
|
+
// Fall through to last-segment fallback
|
|
1821
|
+
}
|
|
1822
|
+
// Last resort: return the original project
|
|
1823
|
+
return project;
|
|
1824
|
+
}
|
|
1825
|
+
// Initialize search on startup
|
|
1826
|
+
initializeSearch();
|
|
1827
|
+
app.listen(PORT, () => {
|
|
1828
|
+
console.log(`
|
|
1829
|
+
Claude Transcript Viewer running at http://localhost:${PORT}
|
|
1830
|
+
|
|
1831
|
+
Serving archive from: ${ARCHIVE_DIR}
|
|
1832
|
+
Source directory: ${SOURCE_DIR || "(not configured)"}
|
|
1833
|
+
|
|
1834
|
+
Usage:
|
|
1835
|
+
npm run dev # Development mode with hot reload
|
|
1836
|
+
npm run dev -- /path/to/archive # Specify archive directory
|
|
1837
|
+
npm run dev -- /archive /source # Specify both archive and source directory
|
|
1838
|
+
|
|
1839
|
+
Open http://localhost:${PORT} to browse transcripts.
|
|
1840
|
+
`);
|
|
1841
|
+
// Start background archive generation and indexing after server is ready (non-blocking)
|
|
1842
|
+
if (SOURCE_DIR) {
|
|
1843
|
+
setTimeout(async () => {
|
|
1844
|
+
// First generate HTML archive from JSONL files
|
|
1845
|
+
await generateArchive();
|
|
1846
|
+
// Then index for search
|
|
1847
|
+
await startBackgroundIndexing();
|
|
1848
|
+
}, 1000);
|
|
1849
|
+
}
|
|
1850
|
+
});
|
|
1851
|
+
//# sourceMappingURL=server.js.map
|