Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions assets/js/vertex-search.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,27 @@
if (data.summary?.summaryText) {
summaryTxt.textContent = data.summary.summaryText;
summaryEl.style.display = 'block';

// Render numbered citation list
const refs = data.summary?.citations || [];
const validRefs = refs.filter(r => r.title || r.uri);

if (validRefs.length > 0) {
const citationHtml = validRefs.map((ref, i) => `
<div class="search-citation">
<span class="citation-number">[${i + 1}]</span>
${ref.uri
? `<a href="${escapeHtml(ref.uri)}" target="_blank" rel="noopener">${escapeHtml(ref.title || ref.uri)}</a>`
: `<span>${escapeHtml(ref.title || 'Unknown source')}</span>`
}
</div>
`).join('');

const citationsDiv = document.createElement('div');
citationsDiv.className = 'search-citations mt-3';
citationsDiv.innerHTML = '<p class="citations-label">Sources</p>' + citationHtml;
summaryEl.querySelector('.ai-summary').appendChild(citationsDiv);
}
}

// Show results
Expand All @@ -73,16 +94,17 @@
}

// Show result count
hitsEl.innerHTML = `<p class="text-muted mb-3">${data.results.length} results for <strong>${escapeHtml(q)}</strong></p>` +
data.results.map(r => `
<div class="td-search-hit mb-4">
<h5 class="mb-1">
<a href="${escapeHtml(r.url)}">${escapeHtml(r.title || 'Untitled')}</a>
</h5>
${r.snippet ? `<p class="mb-1 text-muted small">${escapeHtml(r.snippet)}</p>` : ''}
<p class="mb-0"><small class="text-success">${escapeHtml(r.url)}</small></p>
</div>
`).join('');
hitsEl.innerHTML =
`<p class="text-muted mb-3">${data.results.length} results for <strong>${escapeHtml(q)}</strong></p>` +
data.results.map(r => `
<div class="td-search-hit mb-4">
<h5 class="mb-1">
<a href="${escapeHtml(r.url)}">${escapeHtml(r.title || 'Untitled')}</a>
</h5>
${r.snippet ? `<p class="mb-1 text-muted small">${escapeHtml(r.snippet)}</p>` : ''}
<p class="mb-0"><small class="text-success">${escapeHtml(r.url)}</small></p>
</div>
`).join('');

} catch (err) {
statusEl.textContent = 'Search error: ' + err.message;
Expand Down
31 changes: 31 additions & 0 deletions assets/scss/_styles_project.scss
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,34 @@
padding: 1rem 0;
color: #666;
}

.search-citations {
border-top: 1px solid #d0e4ff;
padding-top: 0.75rem;
margin-top: 0.75rem;

.citations-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #1a73e8;
margin-bottom: 0.4rem;
}
}

.search-citation {
font-size: 0.8rem;
margin-bottom: 0.25rem;

.citation-number {
color: #1a73e8;
font-weight: 600;
margin-right: 0.4rem;
}

a {
color: #444;
&:hover { color: #1a73e8; }
}
}
35 changes: 31 additions & 4 deletions search-function/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,13 @@ exports.search = async (req, res) => {
summarySpec: {
summaryResultCount: 5,
includeCitations: true,
useSemanticChunks: true,
languageCode: 'en-US',
modelPromptSpec: {
preamble: buildPreamble(context)
},
modelSpec: {
version: 'stable'
}
},
snippetSpec: {
Expand Down Expand Up @@ -87,6 +92,7 @@ exports.search = async (req, res) => {
// Add this right after const data = await response.json();
console.log('FIRST RESULT:', JSON.stringify(data.results?.[0], null, 2));
console.log('SUMMARY FULL:', JSON.stringify(data.summary, null, 2));
console.log('CITATIONS:', JSON.stringify(data.summary?.summaryWithMetadata?.references?.[0]));

console.log('RESPONSE KEYS:', Object.keys(data));
console.log('SUMMARY:', JSON.stringify(data.summary));
Expand All @@ -98,22 +104,43 @@ exports.search = async (req, res) => {

const url = derived.link || null;
const snippet = derived.snippets?.[0]?.snippet || null;

// Strip angle brackets to prevent HTML/script tag injection in display text
const stripHtml = (str) => str ? str.replace(/[<>]/g, '') : null;

return {
id: result.document?.id,
title: derived.title || null,
url,
snippet,
snippet: stripHtml(derived.snippets?.[0]?.snippet || null),
section: url?.replace('https://interlisp.org/', '')?.split('/')?.[0] || '',
};
}).filter(r => r?.url);

// Build a map of document ID to URL from search results
const docIdToUrl = {};
(data.results || []).forEach(result => {
const id = result.document?.id;
const url = result.document?.derivedStructData?.link;
if (id && url) docIdToUrl[id] = url;
});

// Enrich references with URLs by matching document IDs
const references = (data.summary?.summaryWithMetadata?.references || []).map(ref => {
// Extract document ID from the full document path
const docId = ref.document?.split('/').pop();
return {
title: ref.title,
uri: docIdToUrl[docId] || null,
docId
};
});

const summaryText = data.summary?.summaryText || null;

res.json({
summary: summaryText ? {
summaryText,
citations: data.summary?.summaryWithMetadata?.references || []
citations: references
} : null,
results
});
Expand Down
1 change: 1 addition & 0 deletions search-function/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"type": "commonjs",
"dependencies": {
"@google-cloud/discoveryengine": "^2.6.0",
"@google-cloud/firestore": "^8.5.0",
"google-auth-library": "^10.6.2"
}
}
101 changes: 101 additions & 0 deletions search-function/rateLimiter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
'use strict';

const { Firestore } = require('@google-cloud/firestore');

const db = new Firestore({ projectId: process.env.PROJECT_ID });

// Rate limit configuration
// Adjust these values based on your traffic patterns
const LIMITS = {
perIp: {
requests: 20, // max 20 requests per IP
windowSec: 60, // per 60 second window
},
global: {
requests: 500, // max 500 total requests
windowSec: 60, // per 60 second window
}
};

/**
* Atomically check and increment a rate limit counter in Firestore.
* Returns { allowed: true/false, count: current count }
*/
async function checkLimit(key, limit) {
const ref = db.collection('rate_limits').doc(key);
const now = Date.now();
const windowMs = limit.windowSec * 1000;

try {
const result = await db.runTransaction(async t => {
const doc = await t.get(ref);
const data = doc.exists ? doc.data() : null;

// Start a new window if first request or window has expired
if (!data || (now - data.windowStart) > windowMs) {
t.set(ref, {
count: 1,
windowStart: now,
updatedAt: now
});
return { allowed: true, count: 1 };
}

// Window is active — check if over limit
if (data.count >= limit.requests) {
return { allowed: false, count: data.count };
}

// Increment counter
t.update(ref, {
count: Firestore.FieldValue.increment(1),
updatedAt: now
});
return { allowed: true, count: data.count + 1 };
});

return result;

} catch (err) {
// Fail open — if Firestore is unavailable don't block searches
console.error('Rate limiter error:', err.message);
return { allowed: true, count: 0 };
}
}

/**
* Main rate limit check — runs IP and global checks in parallel.
* Returns { limited: false } or { limited: true, reason, retryAfter }
*/
async function isRateLimited(req) {
// Get client IP — Cloud Functions passes real IP in x-forwarded-for
const ip = req.headers['x-forwarded-for']
?.split(',')[0]
?.trim() || 'unknown';

// Run both checks in parallel
const [ipCheck, globalCheck] = await Promise.all([
checkLimit(`ip:${ip}`, LIMITS.perIp),
checkLimit('global', LIMITS.global),
]);

if (!ipCheck.allowed) {
return {
limited: true,
reason: 'Too many requests. Please wait a moment before searching again.',
retryAfter: LIMITS.perIp.windowSec
};
}

if (!globalCheck.allowed) {
return {
limited: true,
reason: 'Search service is temporarily busy. Please try again shortly.',
retryAfter: LIMITS.global.windowSec
};
}

return { limited: false };
}

module.exports = { isRateLimited };
Loading