ruidiao commited on
Commit
fd5d5af
·
1 Parent(s): cf1c7cb

Upload 8 files

Browse files
Files changed (7) hide show
  1. icons/linkedin.svg +1 -0
  2. icons/substack.svg +1 -0
  3. icons/x.svg +1 -0
  4. index.html +17 -2
  5. main.js +15 -2
  6. offline_processing.py +52 -2
  7. worker.js +17 -12
icons/linkedin.svg ADDED
icons/substack.svg ADDED
icons/x.svg ADDED
index.html CHANGED
@@ -8,8 +8,23 @@
8
  </head>
9
  <body class="bg-gray-100 flex items-center justify-center min-h-screen">
10
  <div class="container mx-auto p-8 bg-white rounded-lg shadow-lg w-full max-w-2xl relative">
11
- <div id="delete-button-container" class="flex justify-end mb-4 hidden">
12
- <button id="delete-data-button" class="bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500">Delete Cached Data</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  </div>
14
  <h1 class="text-3xl font-bold text-center text-gray-800 mb-4">Client-Side Quote Search</h1>
15
 
 
8
  </head>
9
  <body class="bg-gray-100 flex items-center justify-center min-h-screen">
10
  <div class="container mx-auto p-8 bg-white rounded-lg shadow-lg w-full max-w-2xl relative">
11
+ <!-- Top controls row: icons on the left, delete button on the right. Row always visible; button visibility controlled by #delete-button-container -->
12
+ <div id="top-controls" class="flex items-center justify-between mb-4">
13
+ <div id="icons-row" class="flex items-center gap-3">
14
+ <!-- Icons from icons/ folder (wrapped in links that open in a new tab) -->
15
+ <a href="https://www.linkedin.com/in/ruidiao" target="_blank" rel="noopener noreferrer" aria-label="LinkedIn profile" title="LinkedIn">
16
+ <img src="icons/linkedin.svg" alt="LinkedIn" class="h-6 w-6" />
17
+ </a>
18
+ <a href="https://ruidiao.substack.com/" target="_blank" rel="noopener noreferrer" aria-label="Substack" title="Substack">
19
+ <img src="icons/substack.svg" alt="Substack" class="h-6 w-6" />
20
+ </a>
21
+ <a href="https://x.com/ruidiaox" target="_blank" rel="noopener noreferrer" aria-label="X (Twitter) profile" title="X">
22
+ <img src="icons/x.svg" alt="X" class="h-6 w-6" />
23
+ </a>
24
+ </div>
25
+ <div id="delete-button-container" class="flex justify-end hidden">
26
+ <button id="delete-data-button" class="bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500">Delete Cached Data</button>
27
+ </div>
28
  </div>
29
  <h1 class="text-3xl font-bold text-center text-gray-800 mb-4">Client-Side Quote Search</h1>
30
 
main.js CHANGED
@@ -62,7 +62,7 @@ worker.onmessage = (event) => {
62
  deleteButtonContainer.classList.add('hidden'); // Hide delete button if not cached
63
  searchInput.disabled = false; // Ensure input is enabled
64
  searchButton.disabled = false; // Ensure button is enabled
65
- statusDiv.textContent = `To enable search, a one-time download of approximately ${formatBytesToMB(payload.size)} MB is required. This will happen when you perform your first search.`;
66
  }
67
  } else if (type === 'dataDeleted') {
68
  statusDiv.textContent = payload;
@@ -82,14 +82,27 @@ worker.onmessage = (event) => {
82
  // Send initial request for index size when the page loads
83
  worker.postMessage({ type: 'getIndexSize' });
84
 
85
- searchButton.addEventListener('click', () => {
86
  const query = searchInput.value;
87
  if (query) {
88
  worker.postMessage({ type: 'search', payload: query });
89
  statusDiv.textContent = 'Searching...';
90
  resultsDiv.innerHTML = ''; // Clear previous results
 
91
  } else {
92
  resultsDiv.innerHTML = '<p class="text-gray-500">Please enter a query.</p>';
 
 
 
 
 
 
 
 
 
 
 
 
93
  }
94
  });
95
 
 
62
  deleteButtonContainer.classList.add('hidden'); // Hide delete button if not cached
63
  searchInput.disabled = false; // Ensure input is enabled
64
  searchButton.disabled = false; // Ensure button is enabled
65
+ statusDiv.textContent = `To enable search, a one-time download of approximately ${formatBytesToMB(payload.size)} MB is required when you first search. You can easily remove the downloaded files later.`;
66
  }
67
  } else if (type === 'dataDeleted') {
68
  statusDiv.textContent = payload;
 
82
  // Send initial request for index size when the page loads
83
  worker.postMessage({ type: 'getIndexSize' });
84
 
85
+ function doSearch() {
86
  const query = searchInput.value;
87
  if (query) {
88
  worker.postMessage({ type: 'search', payload: query });
89
  statusDiv.textContent = 'Searching...';
90
  resultsDiv.innerHTML = ''; // Clear previous results
91
+ return true;
92
  } else {
93
  resultsDiv.innerHTML = '<p class="text-gray-500">Please enter a query.</p>';
94
+ return false;
95
+ }
96
+ }
97
+
98
+ searchButton.addEventListener('click', doSearch);
99
+
100
+ // Trigger search on Enter (without Shift). Shift+Enter inserts newline.
101
+ searchInput.addEventListener('keydown', (e) => {
102
+ if (e.key === 'Enter' && !e.shiftKey) {
103
+ // Prevent newline from being inserted
104
+ e.preventDefault();
105
+ doSearch();
106
  }
107
  });
108
 
offline_processing.py CHANGED
@@ -4,6 +4,7 @@ import torch
4
  from sentence_transformers import SentenceTransformer
5
  import json
6
  import struct
 
7
 
8
  # 1. Constants
9
  MODEL_NAME = 'nomic-ai/nomic-embed-text-v1.5'
@@ -36,9 +37,58 @@ def load_quotes(file_path):
36
  if initial_rows - filtered_rows > 0:
37
  print(f"Ignored {initial_rows - filtered_rows} rows due to uppercase letters in category.")
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  # Prepend the required prefix for retrieval tasks
40
- df['quote_for_embedding'] = "search_query: " + df['quote']
41
- return df.to_dict('records')
 
 
42
 
43
  # 3. Generate Embeddings
44
  def generate_embeddings(quotes, model_name, embedding_dim):
 
4
  from sentence_transformers import SentenceTransformer
5
  import json
6
  import struct
7
+ import re
8
 
9
  # 1. Constants
10
  MODEL_NAME = 'nomic-ai/nomic-embed-text-v1.5'
 
37
  if initial_rows - filtered_rows > 0:
38
  print(f"Ignored {initial_rows - filtered_rows} rows due to uppercase letters in category.")
39
 
40
+ # Normalize categories and merge duplicate quotes (same quote + same author)
41
+ def normalize_category_string(cat_str):
42
+ if not isinstance(cat_str, str):
43
+ return []
44
+ # Split on common separators and strip whitespace
45
+ parts = re.split(r"[;,|/]", cat_str)
46
+ seen = set()
47
+ out = []
48
+ for p in parts:
49
+ p = p.strip()
50
+ if p == '':
51
+ continue
52
+ # collapse multiple spaces and lower
53
+ normalized = re.sub(r"\s+", " ", p).strip()
54
+ if normalized not in seen:
55
+ seen.add(normalized)
56
+ out.append(normalized)
57
+ return out
58
+
59
+ # Ensure author is a string for grouping (empty string for missing authors)
60
+ df['author'] = df['author'].fillna('').astype(str)
61
+
62
+ # Group by quote and author, merge categories
63
+ grouped = {}
64
+ for _, row in df.iterrows():
65
+ quote = row['quote']
66
+ author = row['author']
67
+ cat = row['category'] if isinstance(row['category'], str) else ''
68
+ key = (quote, author)
69
+ cats = normalize_category_string(cat)
70
+ if key not in grouped:
71
+ grouped[key] = []
72
+ # preserve order while merging
73
+ for c in cats:
74
+ if c not in grouped[key]:
75
+ grouped[key].append(c)
76
+
77
+ # Build records from grouped data
78
+ records = []
79
+ for (quote, author), cats in grouped.items():
80
+ category_combined = ', '.join(cats) if cats else ''
81
+ records.append({
82
+ 'quote': quote,
83
+ 'author': author if author != '' else None,
84
+ 'category': category_combined
85
+ })
86
+
87
  # Prepend the required prefix for retrieval tasks
88
+ for r in records:
89
+ r['quote_for_embedding'] = "search_query: " + r['quote']
90
+
91
+ return records
92
 
93
  # 3. Generate Embeddings
94
  def generate_embeddings(quotes, model_name, embedding_dim):
worker.js CHANGED
@@ -91,13 +91,15 @@ async function deleteFromDB(key) {
91
  // 3. Load Model and Index
92
  async function loadModel() {
93
  try {
94
- self.postMessage({ type: 'loading', payload: 'Loading model...' });
 
95
 
96
  // Load the model with a progress callback
97
  model = await pipeline('feature-extraction', MODEL_NAME, {
98
  progress_callback: (progress) => {
99
- const detailMessage = `Model ${progress.status}: ${progress.file || ''} ${Math.floor(progress.progress || 0)}%`;
100
- self.postMessage({ type: 'progress', payload: { ...progress, detail: detailMessage } });
 
101
  }
102
  });
103
  } catch (error) {
@@ -109,14 +111,15 @@ async function loadModel() {
109
 
110
  async function loadIndex() {
111
  try {
112
- self.postMessage({ type: 'loading', payload: 'Checking cached index...' });
113
  const cachedIndex = await getFromDB('quoteIndexData');
114
 
115
  if (cachedIndex) {
116
  indexData = cachedIndex;
117
  self.postMessage({ type: 'loading', payload: 'Index loaded from cache.' });
118
- } else {
119
- self.postMessage({ type: 'loading', payload: 'Loading index...' });
 
120
 
121
  // Fetch and parse the index file with progress reporting
122
  const response = await fetch(INDEX_PATH);
@@ -139,7 +142,7 @@ async function loadIndex() {
139
  status: 'Downloading Index',
140
  progress: (loaded / total) * 100,
141
  file: INDEX_PATH,
142
- detail: `Downloading index: ${Math.floor((loaded / total) * 100)}% (${(loaded / (1024 * 1024)).toFixed(2)}MB / ${(total / (1024 * 1024)).toFixed(2)}MB)`
143
  };
144
  self.postMessage({ type: 'progress', payload: progress });
145
  }
@@ -163,7 +166,7 @@ async function loadIndex() {
163
 
164
  const quantizedEmbeddings = new Int8Array(buffer.slice(offset));
165
 
166
- // De-quantize embeddings with progress reporting
167
  const embeddings = new Float32Array(quantizedEmbeddings.length);
168
  const totalEmbeddings = quantizedEmbeddings.length;
169
  const updateInterval = Math.floor(totalEmbeddings / 100); // Update every 1%
@@ -171,11 +174,12 @@ async function loadIndex() {
171
  for (let i = 0; i < totalEmbeddings; i++) {
172
  embeddings[i] = quantizedEmbeddings[i] / scale;
173
  if (updateInterval > 0 && i % updateInterval === 0) {
 
174
  const progress = {
175
- status: 'De-quantizing',
176
  progress: (i / totalEmbeddings) * 100,
177
  file: INDEX_PATH,
178
- detail: `De-quantizing embeddings: ${Math.floor((i / totalEmbeddings) * 100)}%`
179
  };
180
  self.postMessage({ type: 'progress', payload: progress });
181
  }
@@ -211,11 +215,12 @@ self.onmessage = async (event) => {
211
 
212
  if (type === 'search') {
213
  if (!indexData) {
214
- self.postMessage({ type: 'loading', payload: 'Loading index before search...' });
 
215
  await loadIndex();
216
  }
217
  if (!model) {
218
- self.postMessage({ type: 'loading', payload: 'Loading model before search...' });
219
  await loadModel();
220
  }
221
  self.postMessage({ type: 'loading', payload: 'Searching...' });
 
91
  // 3. Load Model and Index
92
  async function loadModel() {
93
  try {
94
+ // Inform the UI that the model will be downloaded/loaded
95
+ self.postMessage({ type: 'loading', payload: 'Downloading model (this may take a while)...' });
96
 
97
  // Load the model with a progress callback
98
  model = await pipeline('feature-extraction', MODEL_NAME, {
99
  progress_callback: (progress) => {
100
+ // Make it explicit that this progress refers to model download or model file operations
101
+ const detailMessage = `Downloading model ${progress.status}: ${progress.file || ''} ${Math.floor(progress.progress || 0)}%`;
102
+ self.postMessage({ type: 'progress', payload: { ...progress, detail: detailMessage } });
103
  }
104
  });
105
  } catch (error) {
 
111
 
112
  async function loadIndex() {
113
  try {
114
+ self.postMessage({ type: 'loading', payload: 'Checking for cached index file...' });
115
  const cachedIndex = await getFromDB('quoteIndexData');
116
 
117
  if (cachedIndex) {
118
  indexData = cachedIndex;
119
  self.postMessage({ type: 'loading', payload: 'Index loaded from cache.' });
120
+ } else {
121
+ // Inform UI that the index file will be downloaded
122
+ self.postMessage({ type: 'loading', payload: 'Downloading index file (this may take a while)...' });
123
 
124
  // Fetch and parse the index file with progress reporting
125
  const response = await fetch(INDEX_PATH);
 
142
  status: 'Downloading Index',
143
  progress: (loaded / total) * 100,
144
  file: INDEX_PATH,
145
+ detail: `Downloading index file: ${Math.floor((loaded / total) * 100)}% (${(loaded / (1024 * 1024)).toFixed(2)}MB / ${(total / (1024 * 1024)).toFixed(2)}MB)`
146
  };
147
  self.postMessage({ type: 'progress', payload: progress });
148
  }
 
166
 
167
  const quantizedEmbeddings = new Int8Array(buffer.slice(offset));
168
 
169
+ // De-quantize embeddings (processing step) with progress reporting
170
  const embeddings = new Float32Array(quantizedEmbeddings.length);
171
  const totalEmbeddings = quantizedEmbeddings.length;
172
  const updateInterval = Math.floor(totalEmbeddings / 100); // Update every 1%
 
174
  for (let i = 0; i < totalEmbeddings; i++) {
175
  embeddings[i] = quantizedEmbeddings[i] / scale;
176
  if (updateInterval > 0 && i % updateInterval === 0) {
177
+ // This is processing (de-quantization), not download
178
  const progress = {
179
+ status: 'Processing index (de-quantizing)',
180
  progress: (i / totalEmbeddings) * 100,
181
  file: INDEX_PATH,
182
+ detail: `Processing index: ${Math.floor((i / totalEmbeddings) * 100)}%`
183
  };
184
  self.postMessage({ type: 'progress', payload: progress });
185
  }
 
215
 
216
  if (type === 'search') {
217
  if (!indexData) {
218
+ // Be explicit: index may be downloaded before search
219
+ self.postMessage({ type: 'loading', payload: 'Downloading index before running your search...' });
220
  await loadIndex();
221
  }
222
  if (!model) {
223
+ self.postMessage({ type: 'loading', payload: 'Downloading model before running your search...' });
224
  await loadModel();
225
  }
226
  self.postMessage({ type: 'loading', payload: 'Searching...' });