Files
research-workbench/static/index.html
T
Ken 7e19b740cb feat: LLM-powered search with free-text query, filter toggles, and auto-generated guide
- Search box: type anything, toggle filter pills (hybrid, price range, distance, etc.)
- Backend calls Anthropic to parse query into search params
- Playwright crawls Craigslist, CarGurus, AutoTempest with dynamic URLs
- Results scored/ranked with car-specific scoring engine
- Second Anthropic call generates a custom buying guide from actual results
- Guide sections: top picks, checklist, mechanics, negotiation, budget
- All visible live in the browser panel via noVNC
2026-05-25 23:31:19 +00:00

785 lines
20 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>car-help | AI Car Finder</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--border: #30363d;
--text: #e6edf3;
--text-dim: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--orange: #db6d28;
--card-bg: #1c2128;
--hover: #1f2937;
--pill-bg: #21262d;
--pill-active: #1f6feb;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
}
.layout {
display: flex;
height: 100vh;
}
/* Left panel -- browser view */
.browser-panel {
width: 55%;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.browser-header {
padding: 8px 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text-dim);
}
.browser-header .dot {
width: 10px; height: 10px; border-radius: 50%;
}
.dot.green { background: var(--green); }
.dot.yellow { background: var(--yellow); }
.dot.red { background: var(--red); }
.browser-header span { margin-left: 8px; font-weight: 500; }
.browser-frame { flex: 1; }
.browser-frame iframe {
width: 100%;
height: 100%;
border: none;
}
/* Right panel -- app */
.app-panel {
width: 45%;
display: flex;
flex-direction: column;
background: var(--bg);
overflow: hidden;
}
.app-header {
padding: 14px 20px 10px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.app-header h1 {
font-size: 18px;
font-weight: 600;
color: var(--text);
}
.app-header .subtitle {
font-size: 12px;
color: var(--text-dim);
margin-top: 2px;
}
/* Search box area */
.search-area {
padding: 16px 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.search-input-row {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.search-input {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.search-input:focus {
border-color: var(--accent);
}
.search-input::placeholder {
color: var(--text-dim);
}
.btn {
padding: 10px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn-search {
background: #238636;
color: #fff;
}
.btn-search:hover { background: #2ea043; }
.btn-search:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Filter pills */
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pill {
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
user-select: none;
border: 1px solid var(--border);
background: var(--pill-bg);
color: var(--text-dim);
transition: all 0.15s;
}
.pill:hover {
border-color: var(--accent);
color: var(--text);
}
.pill.active {
background: var(--pill-active);
border-color: var(--pill-active);
color: #fff;
}
/* Scrollable content area */
.content-area {
flex: 1;
overflow-y: auto;
padding: 0;
}
/* Status bar */
.status-bar {
padding: 10px 20px;
font-size: 12px;
color: var(--text-dim);
background: var(--bg);
border-bottom: 1px solid var(--border);
min-height: 36px;
display: flex;
align-items: center;
gap: 8px;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Results section */
.results-section {
padding: 16px 20px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
/* Listing cards */
.listing-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
margin-bottom: 10px;
transition: border-color 0.15s;
overflow: hidden;
}
.listing-card:hover { border-color: var(--accent); }
.card-body {
display: flex;
gap: 12px;
padding: 12px 14px;
}
.card-img {
width: 80px;
height: 60px;
border-radius: 6px;
object-fit: cover;
background: var(--pill-bg);
flex-shrink: 0;
}
.card-img-placeholder {
width: 80px;
height: 60px;
border-radius: 6px;
background: var(--pill-bg);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--border);
}
.card-info {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 13px;
font-weight: 500;
line-height: 1.3;
margin-bottom: 4px;
}
.card-title a {
color: var(--accent);
text-decoration: none;
}
.card-title a:hover { text-decoration: underline; }
.card-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.card-price {
font-size: 14px;
font-weight: 700;
color: var(--green);
}
.score-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
color: #fff;
}
.score-high { background: #238636; }
.score-mid { background: #9e6a03; }
.score-low { background: #da3633; }
.source-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
background: var(--pill-bg);
border: 1px solid var(--border);
color: var(--text-dim);
}
/* Guide section */
.guide-section {
padding: 16px 20px;
border-top: 1px solid var(--border);
}
.guide-accordion {
margin-bottom: 8px;
}
.guide-accordion-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
user-select: none;
transition: border-color 0.15s;
}
.guide-accordion-header:hover {
border-color: var(--accent);
}
.guide-accordion-header h3 {
font-size: 14px;
font-weight: 600;
color: var(--accent);
}
.guide-accordion-header .arrow {
font-size: 12px;
color: var(--text-dim);
transition: transform 0.2s;
}
.guide-accordion.open .arrow {
transform: rotate(180deg);
}
.guide-accordion-body {
display: none;
padding: 14px;
background: var(--card-bg);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 8px 8px;
font-size: 13px;
line-height: 1.7;
color: var(--text-dim);
}
.guide-accordion.open .guide-accordion-header {
border-radius: 8px 8px 0 0;
border-color: var(--accent);
}
.guide-accordion.open .guide-accordion-body {
display: block;
}
.guide-accordion-body h1,
.guide-accordion-body h2,
.guide-accordion-body h3,
.guide-accordion-body h4 {
color: var(--text);
margin: 12px 0 6px;
}
.guide-accordion-body h1 { font-size: 16px; }
.guide-accordion-body h2 { font-size: 15px; }
.guide-accordion-body h3 { font-size: 14px; }
.guide-accordion-body h4 { font-size: 13px; }
.guide-accordion-body p {
margin: 6px 0;
}
.guide-accordion-body ul, .guide-accordion-body ol {
margin: 6px 0 6px 20px;
}
.guide-accordion-body li {
margin: 3px 0;
}
.guide-accordion-body strong {
color: var(--text);
}
.guide-accordion-body code {
background: var(--bg);
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
}
.empty-state .icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
.empty-state p { font-size: 14px; line-height: 1.6; }
.empty-state .hint { font-size: 12px; margin-top: 8px; color: var(--text-dim); }
/* Error state */
.error-box {
padding: 14px;
background: #2d1b1b;
border: 1px solid #5c2020;
border-radius: 8px;
color: var(--red);
font-size: 13px;
margin: 16px 20px;
}
/* Scrollbar */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
</style>
</head>
<body>
<div class="layout">
<!-- Left: Browser view -->
<div class="browser-panel">
<div class="browser-header">
<div class="dot red"></div>
<div class="dot yellow"></div>
<div class="dot green"></div>
<span>Live Browser</span>
</div>
<div class="browser-frame">
<iframe id="vnc-frame" src="about:blank" allow="clipboard-read; clipboard-write"></iframe>
</div>
</div>
<!-- Right: App panel -->
<div class="app-panel">
<div class="app-header">
<h1>car-help</h1>
<div class="subtitle">AI-powered car search -- Woodinville WA</div>
</div>
<!-- Search area -->
<div class="search-area">
<div class="search-input-row">
<input type="text" class="search-input" id="search-query"
placeholder="What are you looking for?"
onkeydown="if(event.key==='Enter') runSearch()">
<button class="btn btn-search" id="btn-search" onclick="runSearch()">Search</button>
</div>
<div class="filter-row" id="filter-row">
<!-- Filters rendered by JS -->
</div>
</div>
<!-- Status bar -->
<div class="status-bar" id="status-bar">
Type what you want and hit Search
</div>
<!-- Scrollable content -->
<div class="content-area" id="content-area">
<div class="empty-state" id="empty-state">
<div class="icon">&#128663;</div>
<p>Describe the car you want and I'll find it.</p>
<div class="hint">Try: "reliable hybrid under 8K for a teen" or "Toyota Prius 2012-2015 low miles"</div>
</div>
<div id="results-container" style="display:none"></div>
<div id="guide-container" style="display:none"></div>
</div>
</div>
</div>
<script>
// --- Filter definitions ---
const FILTERS = [
{ id: "hybrid", label: "Hybrid", default: true },
{ id: "electric", label: "Electric", default: false },
{ id: "under_8k", label: "Under $8K", default: false },
{ id: "under_10k", label: "Under $10K", default: true },
{ id: "toyota_honda", label: "Toyota/Honda only", default: false },
{ id: "low_miles", label: "Low miles (<100K)", default: false },
{ id: "within_25mi", label: "Within 25mi", default: false },
{ id: "within_50mi", label: "Within 50mi", default: true },
{ id: "private_party", label: "Private party only", default: false },
{ id: "dealer_only", label: "Dealer only", default: false },
];
let activeFilters = new Set(FILTERS.filter(f => f.default).map(f => f.id));
function renderFilters() {
const row = document.getElementById('filter-row');
row.innerHTML = FILTERS.map(f => {
const active = activeFilters.has(f.id) ? 'active' : '';
return `<div class="pill ${active}" data-filter="${f.id}" onclick="toggleFilter('${f.id}', this)">${f.label}</div>`;
}).join('');
}
function toggleFilter(id, el) {
// Handle mutual exclusions
if (id === 'under_8k' && !activeFilters.has(id)) activeFilters.delete('under_10k');
if (id === 'under_10k' && !activeFilters.has(id)) activeFilters.delete('under_8k');
if (id === 'within_25mi' && !activeFilters.has(id)) activeFilters.delete('within_50mi');
if (id === 'within_50mi' && !activeFilters.has(id)) activeFilters.delete('within_25mi');
if (id === 'private_party' && !activeFilters.has(id)) activeFilters.delete('dealer_only');
if (id === 'dealer_only' && !activeFilters.has(id)) activeFilters.delete('private_party');
if (id === 'hybrid' && !activeFilters.has(id)) activeFilters.delete('electric');
if (id === 'electric' && !activeFilters.has(id)) activeFilters.delete('hybrid');
if (activeFilters.has(id)) {
activeFilters.delete(id);
} else {
activeFilters.add(id);
}
renderFilters();
}
// --- Search ---
let searching = false;
async function runSearch() {
if (searching) return;
const query = document.getElementById('search-query').value.trim();
if (!query) {
setStatus('Please enter what you\'re looking for');
return;
}
searching = true;
const btn = document.getElementById('btn-search');
btn.disabled = true;
btn.textContent = 'Searching...';
// Hide empty state, show containers
document.getElementById('empty-state').style.display = 'none';
document.getElementById('results-container').style.display = 'none';
document.getElementById('guide-container').style.display = 'none';
setStatus('<span class="spinner"></span> Researching... parsing your query, searching sites, ranking results. Watch the browser on the left.', true);
try {
const resp = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: query,
filters: Array.from(activeFilters),
}),
});
const data = await resp.json();
if (data.error) {
setStatus('Error: ' + data.error);
document.getElementById('results-container').innerHTML =
`<div class="error-box">${escHtml(data.error)}</div>`;
document.getElementById('results-container').style.display = 'block';
} else {
const total = data.total || 0;
const summary = data.search_summary || query;
setStatus(`Found ${total} listings for "${summary}"`);
renderResults(data.listings || []);
renderGuide(data.guide || {});
}
} catch (e) {
setStatus('Search failed: ' + e.message);
document.getElementById('results-container').innerHTML =
`<div class="error-box">${escHtml(e.message)}</div>`;
document.getElementById('results-container').style.display = 'block';
}
searching = false;
btn.disabled = false;
btn.textContent = 'Search';
}
function setStatus(html, raw) {
const bar = document.getElementById('status-bar');
if (raw) {
bar.innerHTML = html;
} else {
bar.textContent = html;
}
}
// --- Render results ---
function renderResults(listings) {
const container = document.getElementById('results-container');
if (!listings.length) {
container.innerHTML = `
<div class="results-section">
<div class="section-title">Results</div>
<div class="empty-state" style="padding:30px">
<p>No listings found. Try broadening your search.</p>
</div>
</div>`;
container.style.display = 'block';
return;
}
let html = `<div class="results-section">
<div class="section-title">Results (${listings.length})</div>`;
for (const l of listings) {
const score = l.score != null ? l.score : null;
let scoreBadge = '';
if (score !== null) {
const cls = score >= 70 ? 'score-high' : score >= 50 ? 'score-mid' : 'score-low';
scoreBadge = `<span class="score-badge ${cls}">${score}</span>`;
}
const title = l.title || 'Unknown listing';
const price = l.price || '';
const link = l.url
? `<a href="${escHtml(l.url)}" target="_blank">${escHtml(title)}</a>`
: escHtml(title);
const imgHtml = l.image
? `<img class="card-img" src="${escHtml(l.image)}" alt="" onerror="this.outerHTML='<div class=\\'card-img-placeholder\\'>&#128663;</div>'">`
: `<div class="card-img-placeholder">&#128663;</div>`;
html += `
<div class="listing-card">
<div class="card-body">
${imgHtml}
<div class="card-info">
<div class="card-title">${link}</div>
<div class="card-meta">
${price ? `<span class="card-price">${escHtml(price)}</span>` : ''}
<span class="source-badge">${escHtml(l.source || '?')}</span>
${scoreBadge}
</div>
</div>
</div>
</div>`;
}
html += '</div>';
container.innerHTML = html;
container.style.display = 'block';
}
// --- Render guide ---
function renderGuide(guide) {
const container = document.getElementById('guide-container');
if (!guide || Object.keys(guide).length === 0) {
container.style.display = 'none';
return;
}
const sections = [
{ key: 'top_picks', title: 'Top Picks', defaultOpen: true },
{ key: 'checklist', title: 'What to Check', defaultOpen: false },
{ key: 'mechanics', title: 'Nearby Mechanics', defaultOpen: false },
{ key: 'negotiation', title: 'Negotiation Tips', defaultOpen: false },
{ key: 'budget', title: 'Budget Breakdown', defaultOpen: false },
];
let html = `<div class="guide-section">
<div class="section-title">Your Custom Buying Guide</div>`;
for (const sec of sections) {
const content = guide[sec.key];
if (!content) continue;
const openClass = sec.defaultOpen ? 'open' : '';
html += `
<div class="guide-accordion ${openClass}">
<div class="guide-accordion-header" onclick="this.parentElement.classList.toggle('open')">
<h3>${sec.title}</h3>
<span class="arrow">&#9660;</span>
</div>
<div class="guide-accordion-body">
${renderMarkdown(content)}
</div>
</div>`;
}
html += '</div>';
container.innerHTML = html;
container.style.display = 'block';
}
// --- Simple markdown renderer ---
function renderMarkdown(text) {
if (!text) return '';
let html = escHtml(text);
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Headers (process before line breaks)
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Unordered lists
html = html.replace(/^[-*] (.+)$/gm, '<li>$1</li>');
// Wrap consecutive <li> in <ul>
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
// Numbered lists
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
// Paragraphs: double newlines
html = html.replace(/\n\n/g, '</p><p>');
// Single newlines that aren't inside tags
html = html.replace(/\n/g, '<br>');
// Wrap in paragraph if not starting with a tag
if (!html.startsWith('<')) {
html = '<p>' + html + '</p>';
}
return html;
}
// --- Util ---
function escHtml(s) {
if (!s) return '';
const div = document.createElement('div');
div.textContent = String(s);
return div.innerHTML;
}
// --- Init ---
renderFilters();
// noVNC iframe setup
(function() {
const vncFrame = document.getElementById('vnc-frame');
const host = window.location.hostname;
const proto = window.location.protocol;
if (host.endsWith('.ampbox.io')) {
vncFrame.src = proto + '//vnc.ampbox.io/vnc.html?autoconnect=true&resize=scale&reconnect=true&reconnect_delay=1000';
} else {
vncFrame.src = proto + '//' + host + ':6080/vnc.html?autoconnect=true&resize=scale&reconnect=true&reconnect_delay=1000';
}
})();
</script>
</body>
</html>