search for standard sites pub-search.waow.tech
search zig blog atproto

feat: backend hybrid search with RRF + mode toggle UI + dashboard updates

single-request hybrid mode merges keyword (FTS5) and semantic (voyage +
tpuf) results using Reciprocal Rank Fusion scoring. adds mode toggle to
frontend, source badges on results, per-mode latency tracking, and
embeddings count on dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+360 -16
+13 -3
backend/src/dashboard.zig
··· 16 16 searches: i64, 17 17 publications: i64, 18 18 documents: i64, 19 + embeddings: i64, 19 20 tags_json: []const u8, 20 21 timeline_json: []const u8, 21 22 top_pubs_json: []const u8, ··· 30 31 \\ (SELECT COUNT(*) FROM publications) as pubs, 31 32 \\ (SELECT total_searches FROM stats WHERE id = 1) as searches, 32 33 \\ (SELECT total_errors FROM stats WHERE id = 1) as errors, 33 - \\ (SELECT service_started_at FROM stats WHERE id = 1) as started_at 34 + \\ (SELECT service_started_at FROM stats WHERE id = 1) as started_at, 35 + \\ (SELECT COUNT(*) FROM documents WHERE embedded_at IS NOT NULL) as embeddings 34 36 ; 35 37 36 38 const PLATFORMS_SQL = ··· 93 95 const searches = if (stats_row) |r| r.int(2) else 0; 94 96 const publications = if (stats_row) |r| r.int(1) else 0; 95 97 const documents = if (stats_row) |r| r.int(0) else 0; 98 + const embeddings = if (stats_row) |r| r.int(5) else 0; 96 99 97 100 return .{ 98 101 .started_at = started_at, 99 102 .searches = searches, 100 103 .publications = publications, 101 104 .documents = documents, 105 + .embeddings = embeddings, 102 106 .tags_json = try formatTagsJson(alloc, batch.get(2)), 103 107 .timeline_json = try formatTimelineJson(alloc, batch.get(3)), 104 108 .top_pubs_json = try formatPubsJson(alloc, batch.get(4)), ··· 118 122 const searches = if (turso_stats) |r| r.int(0) else 0; 119 123 const started_at = if (turso_stats) |r| r.int(1) else 0; 120 124 121 - // get document/publication counts from local (fast) 125 + // get document/publication/embedding counts from local (fast) 122 126 var counts_rows = try local.query( 123 127 \\SELECT 124 128 \\ (SELECT COUNT(*) FROM documents) as docs, 125 - \\ (SELECT COUNT(*) FROM publications) as pubs 129 + \\ (SELECT COUNT(*) FROM publications) as pubs, 130 + \\ (SELECT COUNT(*) FROM documents WHERE embedded_at IS NOT NULL) as embeddings 126 131 , .{}); 127 132 defer counts_rows.deinit(); 128 133 const counts_row = counts_rows.next() orelse return error.NoStats; 129 134 const documents = counts_row.int(0); 130 135 const publications = counts_row.int(1); 136 + const embeddings = counts_row.int(2); 131 137 132 138 // platforms query 133 139 var platforms_rows = try local.query(PLATFORMS_SQL, .{}); ··· 154 160 .searches = searches, 155 161 .publications = publications, 156 162 .documents = documents, 163 + .embeddings = embeddings, 157 164 .tags_json = tags_json, 158 165 .timeline_json = timeline_json, 159 166 .top_pubs_json = top_pubs_json, ··· 318 325 319 326 try jw.objectField("documents"); 320 327 try jw.write(data.documents); 328 + 329 + try jw.objectField("embeddings"); 330 + try jw.write(data.embeddings); 321 331 322 332 try jw.objectField("platforms"); 323 333 try jw.beginWriteRaw();
+3 -1
backend/src/metrics/timing.zig
··· 2 2 3 3 /// endpoints we track latency for 4 4 pub const Endpoint = enum { 5 - search, 5 + search_keyword, 6 + search_semantic, 7 + search_hybrid, 6 8 similar, 7 9 tags, 8 10 popular,
+189
backend/src/search.zig
··· 9 9 pub const SearchMode = enum { 10 10 keyword, 11 11 semantic, 12 + hybrid, 12 13 13 14 pub fn fromString(s: ?[]const u8) SearchMode { 14 15 const str = s orelse return .keyword; 15 16 if (std.mem.eql(u8, str, "semantic")) return .semantic; 17 + if (std.mem.eql(u8, str, "hybrid")) return .hybrid; 16 18 return .keyword; 17 19 } 18 20 }; ··· 29 31 basePath: []const u8, 30 32 platform: []const u8, 31 33 path: []const u8 = "", // URL path from record (e.g., "/001") 34 + source: []const u8 = "", 32 35 }; 33 36 34 37 /// Document search result (internal) ··· 275 278 ); 276 279 277 280 pub fn search(alloc: Allocator, query: []const u8, tag_filter: ?[]const u8, platform_filter: ?[]const u8, since_filter: ?[]const u8, mode: SearchMode) ![]const u8 { 281 + if (mode == .hybrid) return searchHybrid(alloc, query, tag_filter, platform_filter, since_filter); 278 282 if (mode == .semantic) return searchSemantic(alloc, query, platform_filter); 283 + return searchKeyword(alloc, query, tag_filter, platform_filter, since_filter); 284 + } 279 285 286 + /// Keyword search: FTS5 via local SQLite or Turso fallback. 287 + fn searchKeyword(alloc: Allocator, query: []const u8, tag_filter: ?[]const u8, platform_filter: ?[]const u8, since_filter: ?[]const u8) ![]const u8 { 280 288 // try local SQLite first (faster for FTS queries) 281 289 if (db.getLocalDb()) |local| { 282 290 if (searchLocal(alloc, local, query, tag_filter, platform_filter, since_filter)) |result| { ··· 663 671 return try output.toOwnedSlice(); 664 672 } 665 673 674 + /// Hybrid search: run keyword + semantic, merge with Reciprocal Rank Fusion. 675 + /// score(doc) = 1/(k + rank_keyword) + 1/(k + rank_semantic), k=60 676 + fn searchHybrid(alloc: Allocator, query: []const u8, tag_filter: ?[]const u8, platform_filter: ?[]const u8, since_filter: ?[]const u8) ![]const u8 { 677 + if (query.len == 0) return try alloc.dupe(u8, "[]"); 678 + 679 + const span = logfire.span("search.hybrid", .{}); 680 + defer span.end(); 681 + 682 + // 1. keyword search (~10ms via local SQLite) 683 + const kw_json = searchKeyword(alloc, query, tag_filter, platform_filter, since_filter) catch |err| blk: { 684 + logfire.warn("search.hybrid: keyword failed: {}", .{err}); 685 + break :blk try alloc.dupe(u8, "[]"); 686 + }; 687 + 688 + // 2. semantic search (~550ms via voyage + tpuf) 689 + const sem_json = searchSemantic(alloc, query, platform_filter) catch |err| blk: { 690 + logfire.warn("search.hybrid: semantic failed: {}", .{err}); 691 + break :blk try alloc.dupe(u8, "[]"); 692 + }; 693 + 694 + // check if semantic returned an error object (starts with '{') 695 + const sem_is_error = sem_json.len > 0 and sem_json[0] == '{'; 696 + 697 + // 3. parse both into json.Value arrays 698 + const kw_parsed = json.parseFromSlice(json.Value, alloc, kw_json, .{}) catch { 699 + // if keyword parse fails, just return semantic (or empty) 700 + if (sem_is_error) return try alloc.dupe(u8, "[]"); 701 + return sem_json; 702 + }; 703 + defer kw_parsed.deinit(); 704 + 705 + const kw_items = switch (kw_parsed.value) { 706 + .array => |arr| arr.items, 707 + else => &[_]json.Value{}, 708 + }; 709 + 710 + var sem_items: []const json.Value = &.{}; 711 + var sem_parsed_opt: ?json.Parsed(json.Value) = null; 712 + defer if (sem_parsed_opt) |*p| p.deinit(); 713 + 714 + if (!sem_is_error) { 715 + if (json.parseFromSlice(json.Value, alloc, sem_json, .{}) catch null) |parsed| { 716 + sem_parsed_opt = parsed; 717 + sem_items = switch (parsed.value) { 718 + .array => |arr| arr.items, 719 + else => &[_]json.Value{}, 720 + }; 721 + } 722 + } 723 + 724 + // if one side is empty, return the other with source annotation 725 + if (kw_items.len == 0 and sem_items.len == 0) { 726 + return try alloc.dupe(u8, "[]"); 727 + } 728 + 729 + // 4. build RRF score map 730 + const RRF_K: f64 = 60.0; 731 + 732 + // source bits: 1=keyword, 2=semantic 733 + var scores = std.StringHashMap(f64).init(alloc); 734 + defer scores.deinit(); 735 + var source_bits = std.StringHashMap(u8).init(alloc); 736 + defer source_bits.deinit(); 737 + 738 + // map URI -> json object from keyword results (preferred for snippets) 739 + var kw_objects = std.StringHashMap(json.ObjectMap).init(alloc); 740 + defer kw_objects.deinit(); 741 + var sem_objects = std.StringHashMap(json.ObjectMap).init(alloc); 742 + defer sem_objects.deinit(); 743 + 744 + for (kw_items, 0..) |item, i| { 745 + const obj = switch (item) { 746 + .object => |o| o, 747 + else => continue, 748 + }; 749 + const uri = jsonStr(obj, "uri"); 750 + if (uri.len == 0) continue; 751 + 752 + const rank: f64 = @floatFromInt(i + 1); 753 + const rrf_score = 1.0 / (RRF_K + rank); 754 + 755 + const prev = scores.get(uri) orelse 0.0; 756 + try scores.put(uri, prev + rrf_score); 757 + 758 + const prev_bits = source_bits.get(uri) orelse 0; 759 + try source_bits.put(uri, prev_bits | 0b01); 760 + 761 + if (!kw_objects.contains(uri)) { 762 + try kw_objects.put(uri, obj); 763 + } 764 + } 765 + 766 + for (sem_items, 0..) |item, i| { 767 + const obj = switch (item) { 768 + .object => |o| o, 769 + else => continue, 770 + }; 771 + const uri = jsonStr(obj, "uri"); 772 + if (uri.len == 0) continue; 773 + 774 + const rank: f64 = @floatFromInt(i + 1); 775 + const rrf_score = 1.0 / (RRF_K + rank); 776 + 777 + const prev = scores.get(uri) orelse 0.0; 778 + try scores.put(uri, prev + rrf_score); 779 + 780 + const prev_bits = source_bits.get(uri) orelse 0; 781 + try source_bits.put(uri, prev_bits | 0b10); 782 + 783 + if (!sem_objects.contains(uri)) { 784 + try sem_objects.put(uri, obj); 785 + } 786 + } 787 + 788 + // 5. collect and sort by RRF score 789 + const ScoredUri = struct { 790 + uri: []const u8, 791 + score: f64, 792 + }; 793 + 794 + var scored: std.ArrayList(ScoredUri) = .empty; 795 + defer scored.deinit(alloc); 796 + 797 + var it = scores.iterator(); 798 + while (it.next()) |entry| { 799 + try scored.append(alloc, .{ .uri = entry.key_ptr.*, .score = entry.value_ptr.* }); 800 + } 801 + 802 + std.mem.sort(ScoredUri, scored.items, {}, struct { 803 + fn lessThan(_: void, a: ScoredUri, b: ScoredUri) bool { 804 + return a.score > b.score; // descending 805 + } 806 + }.lessThan); 807 + 808 + // 6. serialize top 20 with source annotation 809 + var output: std.Io.Writer.Allocating = .init(alloc); 810 + errdefer output.deinit(); 811 + 812 + var jw: json.Stringify = .{ .writer = &output.writer }; 813 + try jw.beginArray(); 814 + 815 + const limit = @min(scored.items.len, 20); 816 + for (scored.items[0..limit]) |entry| { 817 + const bits = source_bits.get(entry.uri) orelse 0; 818 + // prefer keyword version (has FTS snippet) 819 + const obj = kw_objects.get(entry.uri) orelse sem_objects.get(entry.uri) orelse continue; 820 + 821 + const source_label: []const u8 = switch (bits) { 822 + 0b01 => "keyword", 823 + 0b10 => "semantic", 824 + 0b11 => "keyword+semantic", 825 + else => "", 826 + }; 827 + 828 + try jw.beginObject(); 829 + // write all standard fields from the source object 830 + inline for (.{ "type", "uri", "did", "title", "snippet", "rkey", "basePath", "platform", "path" }) |field| { 831 + try jw.objectField(field); 832 + try jw.write(jsonStr(obj, field)); 833 + } 834 + try jw.objectField("createdAt"); 835 + try jw.write(jsonStr(obj, "createdAt")); 836 + try jw.objectField("source"); 837 + try jw.write(source_label); 838 + try jw.endObject(); 839 + } 840 + 841 + try jw.endArray(); 842 + return try output.toOwnedSlice(); 843 + } 844 + 666 845 /// Semantic search: embed query via Voyage, ANN search via turbopuffer. 667 846 fn searchSemantic(alloc: Allocator, query: []const u8, platform_filter: ?[]const u8) ![]const u8 { 668 847 if (query.len == 0) return try alloc.dupe(u8, "[]"); ··· 734 913 try jw.endArray(); 735 914 736 915 return try output.toOwnedSlice(); 916 + } 917 + 918 + // --- JSON helpers (for hybrid search parsing) --- 919 + 920 + fn jsonStr(obj: json.ObjectMap, key: []const u8) []const u8 { 921 + const val = obj.get(key) orelse return ""; 922 + return switch (val) { 923 + .string => |s| s, 924 + else => "", 925 + }; 737 926 } 738 927 739 928 /// Build FTS5 query with OR between terms: "cat dog" -> "cat OR dog*"
+8 -1
backend/src/server.zig
··· 93 93 94 94 fn handleSearch(request: *http.Server.Request, target: []const u8) !void { 95 95 const start_time = std.time.microTimestamp(); 96 - defer metrics.timing.record(.search, start_time); 97 96 98 97 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 99 98 defer arena.deinit(); ··· 105 104 const since_filter = parseQueryParam(alloc, target, "since") catch null; 106 105 const mode_str = parseQueryParam(alloc, target, "mode") catch null; 107 106 const mode = search.SearchMode.fromString(mode_str); 107 + 108 + // record per-mode latency 109 + const timing_endpoint: metrics.timing.Endpoint = switch (mode) { 110 + .keyword => .search_keyword, 111 + .semantic => .search_semantic, 112 + .hybrid => .search_hybrid, 113 + }; 114 + defer metrics.timing.record(timing_endpoint, start_time); 108 115 109 116 // span attributes are now copied internally, safe to use arena strings 110 117 const span = logfire.span("http.search", .{
+1 -1
site/dashboard.css
··· 118 118 119 119 .latency-grid { 120 120 display: grid; 121 - grid-template-columns: 1fr 1fr; 121 + grid-template-columns: 1fr 1fr 1fr; 122 122 gap: 0.75rem; 123 123 } 124 124 .latency-cell {
+4
site/dashboard.html
··· 25 25 <div class="metric-value" id="publications">--</div> 26 26 <div class="metric-label">publications</div> 27 27 </div> 28 + <div> 29 + <div class="metric-value" id="embeddings">--</div> 30 + <div class="metric-label">embeddings</div> 31 + </div> 28 32 </div> 29 33 <div class="live" id="live"></div> 30 34 </section>
+25 -6
site/dashboard.js
··· 84 84 return Math.round(ms * 1000) + 'µs'; 85 85 } 86 86 87 - const ENDPOINT_COLORS = { search: '#8b5cf6', similar: '#06b6d4', tags: '#10b981', popular: '#f59e0b' }; 87 + const ENDPOINT_COLORS = { 88 + search_keyword: '#3b82f6', 89 + search_semantic: '#8b5cf6', 90 + search_hybrid: '#10b981', 91 + similar: '#06b6d4', 92 + tags: '#f59e0b', 93 + popular: '#f97316', 94 + }; 95 + 96 + const ENDPOINT_LABELS = { 97 + search_keyword: 'keyword', 98 + search_semantic: 'semantic', 99 + search_hybrid: 'hybrid', 100 + similar: 'similar', 101 + tags: 'tags', 102 + popular: 'popular', 103 + }; 88 104 89 105 function renderTiming(timing) { 90 106 const el = document.getElementById('timing'); 91 107 if (!timing) return; 92 108 93 - const endpoints = ['search', 'similar', 'tags', 'popular']; 109 + const endpoints = ['search_keyword', 'search_semantic', 'search_hybrid', 'similar', 'tags', 'popular']; 94 110 endpoints.forEach(name => { 95 111 const t = timing[name]; 96 112 if (!t) return; ··· 98 114 const row = document.createElement('div'); 99 115 row.className = 'timing-row'; 100 116 const color = ENDPOINT_COLORS[name]; 117 + const label = ENDPOINT_LABELS[name] || name; 101 118 102 119 if (t.count === 0) { 103 - row.innerHTML = '<span class="timing-name" style="color:' + color + '">' + name + '</span><span class="timing-value dim">--</span>'; 120 + row.innerHTML = '<span class="timing-name" style="color:' + color + '">' + label + '</span><span class="timing-value dim">--</span>'; 104 121 } else { 105 - row.innerHTML = '<span class="timing-name" style="color:' + color + '">' + name + '</span>' + 122 + row.innerHTML = '<span class="timing-name" style="color:' + color + '">' + label + '</span>' + 106 123 '<span class="timing-value">' + formatMs(t.p50_ms) + ' <span class="dim">p50</span> · ' + 107 124 formatMs(t.p95_ms) + ' <span class="dim">p95</span></span>'; 108 125 } ··· 117 134 const container = document.getElementById('latency-history'); 118 135 if (!container) return; 119 136 120 - const endpoints = ['search', 'similar', 'tags', 'popular']; 137 + const endpoints = ['search_keyword', 'search_semantic', 'search_hybrid', 'similar', 'tags', 'popular']; 121 138 122 139 // check if any endpoint has history data 123 140 const hasData = endpoints.some(name => timing[name]?.history?.some(h => h.count > 0)); ··· 143 160 const cell = document.createElement('div'); 144 161 cell.className = 'latency-cell'; 145 162 163 + const friendlyName = ENDPOINT_LABELS[name] || name; 146 164 const label = document.createElement('div'); 147 165 label.className = 'latency-cell-label'; 148 - label.innerHTML = '<span class="dot" style="background:' + color + '"></span>' + name + 166 + label.innerHTML = '<span class="dot" style="background:' + color + '"></span>' + friendlyName + 149 167 '<span class="latency-cell-max">' + formatMs(maxVal) + '</span>'; 150 168 cell.appendChild(label); 151 169 ··· 256 274 257 275 document.getElementById('searches').textContent = data.searches; 258 276 document.getElementById('publications').textContent = data.publications; 277 + document.getElementById('embeddings').textContent = data.embeddings ?? '--'; 259 278 260 279 renderPlatforms(data.platforms); 261 280 renderTiming(data.timing);
+117 -4
site/index.html
··· 430 430 color: #c44; 431 431 } 432 432 433 + .mode-toggle { 434 + margin-bottom: 1rem; 435 + } 436 + 437 + .mode-toggle-label { 438 + font-size: 11px; 439 + color: #444; 440 + margin-bottom: 0.5rem; 441 + } 442 + 443 + .mode-toggle-list { 444 + display: flex; 445 + gap: 0.5rem; 446 + } 447 + 448 + .mode-option { 449 + font-size: 11px; 450 + padding: 3px 8px; 451 + background: #151515; 452 + border: 1px solid #252525; 453 + border-radius: 3px; 454 + cursor: pointer; 455 + color: #777; 456 + } 457 + 458 + .mode-option:hover { 459 + background: #1a1a1a; 460 + border-color: #333; 461 + color: #aaa; 462 + } 463 + 464 + .mode-option.active { 465 + background: rgba(139, 92, 246, 0.2); 466 + border-color: #8b5cf6; 467 + color: #a78bfa; 468 + } 469 + 470 + .source-badge { 471 + font-size: 9px; 472 + padding: 1px 5px; 473 + border-radius: 3px; 474 + margin-left: 6px; 475 + text-transform: lowercase; 476 + } 477 + 478 + .source-badge.keyword { 479 + background: rgba(59, 130, 246, 0.2); 480 + color: #60a5fa; 481 + } 482 + 483 + .source-badge.semantic { 484 + background: rgba(139, 92, 246, 0.2); 485 + color: #a78bfa; 486 + } 487 + 488 + .source-badge.keyword-semantic { 489 + background: rgba(16, 185, 129, 0.2); 490 + color: #34d399; 491 + } 492 + 433 493 /* mobile improvements */ 434 494 @media (max-width: 600px) { 435 495 body { ··· 442 502 } 443 503 444 504 /* ensure minimum 44px touch targets */ 445 - .tag, .platform-option, .suggestion, input.tag-input { 505 + .tag, .platform-option, .mode-option, .suggestion, input.tag-input { 446 506 min-height: 44px; 447 507 display: inline-flex; 448 508 align-items: center; ··· 520 580 521 581 /* ensure touch targets on tablets too */ 522 582 @media (hover: none) and (pointer: coarse) { 523 - .tag, .platform-option, .suggestion, .related-item, input.tag-input { 583 + .tag, .platform-option, .mode-option, .suggestion, .related-item, input.tag-input { 524 584 min-height: 44px; 525 585 display: inline-flex; 526 586 align-items: center; ··· 536 596 <input type="text" id="query" placeholder="search content..." autofocus> 537 597 <button id="search-btn">search</button> 538 598 </div> 599 + 600 + <div id="mode-toggle" class="mode-toggle"></div> 539 601 540 602 <div id="suggestions"></div> 541 603 ··· 568 630 const activeFilterDiv = document.getElementById('active-filter'); 569 631 const suggestionsDiv = document.getElementById('suggestions'); 570 632 const platformFilterDiv = document.getElementById('platform-filter'); 633 + const modeToggleDiv = document.getElementById('mode-toggle'); 571 634 let currentTag = null; 572 635 let currentPlatform = null; 636 + let currentMode = 'keyword'; 573 637 let allTags = []; 574 638 let popularSearches = []; 575 639 const authorCache = new Map(); ··· 582 646 let searchUrl = `${API_URL}/search?q=${encodeURIComponent(query || '')}`; 583 647 if (tag) searchUrl += `&tag=${encodeURIComponent(tag)}`; 584 648 if (platform) searchUrl += `&platform=${encodeURIComponent(platform)}`; 649 + if (currentMode !== 'keyword') searchUrl += `&mode=${currentMode}`; 585 650 586 651 try { 587 652 const res = await fetch(searchUrl); ··· 622 687 const date = doc.createdAt ? new Date(doc.createdAt).toLocaleDateString() : ''; 623 688 const platformHome = getPlatformHome(plat, doc.basePath); 624 689 690 + // source badge (only shown in hybrid/semantic modes) 691 + let sourceBadge = ''; 692 + if (doc.source && currentMode !== 'keyword') { 693 + const cls = doc.source.replace('+', '-'); 694 + sourceBadge = `<span class="source-badge ${cls}">${escapeHtml(doc.source)}</span>`; 695 + } 696 + 697 + // skip empty snippet divs (semantic results have no snippet) 698 + const snippetHtml = doc.snippet 699 + ? `<div class="result-snippet">${highlightTerms(doc.snippet, query)}</div>` 700 + : ''; 701 + 625 702 html += ` 626 703 <div class="result"> 627 704 <div class="result-title"> 628 705 <span class="entity-type ${entityType}">${entityType}</span>${platformBadge} 629 706 ${docUrl 630 707 ? `<a href="${docUrl}" target="_blank">${escapeHtml(doc.title || 'Untitled')}</a>` 631 - : escapeHtml(doc.title || 'Untitled')} 708 + : escapeHtml(doc.title || 'Untitled')}${sourceBadge} 632 709 </div> 633 - <div class="result-snippet">${highlightTerms(doc.snippet, query)}</div> 710 + ${snippetHtml} 634 711 <div class="result-meta" ${doc.did ? `data-did="${escapeHtml(doc.did)}"` : ''}> 635 712 ${date ? `${date} | ` : ''}<span class="author-name"></span>${platformHome.url 636 713 ? `<a href="${platformHome.url}" target="_blank">${platformHome.label}</a>` ··· 833 910 if (q) params.set('q', q); 834 911 if (currentTag) params.set('tag', currentTag); 835 912 if (currentPlatform) params.set('platform', currentPlatform); 913 + if (currentMode !== 'keyword') params.set('mode', currentMode); 836 914 const url = params.toString() ? `?${params}` : '/'; 837 915 history.pushState(null, '', url); 838 916 } ··· 902 980 platformFilterDiv.innerHTML = `<div class="platform-filter-label">filter by platform:</div><div class="platform-filter-list">${html}</div>`; 903 981 } 904 982 983 + function renderModeToggle() { 984 + const modes = [ 985 + { id: 'keyword', label: 'keyword' }, 986 + { id: 'semantic', label: 'semantic' }, 987 + { id: 'hybrid', label: 'hybrid' }, 988 + ]; 989 + const html = modes.map(m => ` 990 + <span class="mode-option${currentMode === m.id ? ' active' : ''}" onclick="setMode('${m.id}')">${m.label}</span> 991 + `).join(''); 992 + modeToggleDiv.innerHTML = `<div class="mode-toggle-label">search mode:</div><div class="mode-toggle-list">${html}</div>`; 993 + } 994 + 995 + function setMode(mode) { 996 + if (currentMode === mode) return; 997 + currentMode = mode; 998 + renderModeToggle(); 999 + // hide tags/since for non-keyword modes (tpuf doesn't support them) 1000 + tagsDiv.style.display = currentMode === 'keyword' ? '' : 'none'; 1001 + if (currentMode !== 'keyword') { 1002 + currentTag = null; 1003 + renderActiveFilter(); 1004 + } 1005 + // trigger search if there's a query 1006 + if (queryInput.value.trim() || currentTag || currentPlatform) { 1007 + doSearch(); 1008 + } 1009 + } 1010 + 905 1011 function renderActiveFilter() { 906 1012 if (!currentTag && !currentPlatform) { 907 1013 activeFilterDiv.innerHTML = ''; ··· 1008 1114 queryInput.value = params.get('q') || ''; 1009 1115 currentTag = params.get('tag') || null; 1010 1116 currentPlatform = params.get('platform') || null; 1117 + currentMode = params.get('mode') || 'keyword'; 1011 1118 renderActiveFilter(); 1012 1119 renderTags(); 1013 1120 renderPlatformFilter(); 1121 + renderModeToggle(); 1122 + tagsDiv.style.display = currentMode === 'keyword' ? '' : 'none'; 1014 1123 if (queryInput.value || currentTag || currentPlatform) search(queryInput.value, currentTag, currentPlatform); 1015 1124 }); 1016 1125 ··· 1019 1128 const initialQuery = initialParams.get('q'); 1020 1129 const initialTag = initialParams.get('tag'); 1021 1130 const initialPlatform = initialParams.get('platform'); 1131 + const initialMode = initialParams.get('mode'); 1022 1132 if (initialQuery) queryInput.value = initialQuery; 1023 1133 if (initialTag) currentTag = initialTag; 1024 1134 if (initialPlatform) currentPlatform = initialPlatform; 1135 + if (initialMode) currentMode = initialMode; 1025 1136 renderActiveFilter(); 1026 1137 renderPlatformFilter(); 1138 + renderModeToggle(); 1139 + tagsDiv.style.display = currentMode === 'keyword' ? '' : 'none'; 1027 1140 1028 1141 if (initialQuery || initialTag || initialPlatform) { 1029 1142 search(initialQuery || '', initialTag, initialPlatform);