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

feat: add semantic search mode (voyage embeddings + turbopuffer ANN)

- tpuf.zig: embedQuery() calls Voyage API with input_type="query" for asymmetric search
- search.zig: SearchMode enum, searchSemantic() dispatches to tpuf, keyword path untouched
- server.zig: parse mode query param, pass to search
- site: mode toggle (keyword/semantic/hybrid), hybrid shows keyword instantly + appends semantic
- docs: document mode parameter on /search endpoint

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

+403 -65
+80 -1
backend/src/search.zig
··· 6 6 const db = @import("db/mod.zig"); 7 7 const tpuf = @import("tpuf.zig"); 8 8 9 + pub const SearchMode = enum { 10 + keyword, 11 + semantic, 12 + 13 + pub fn fromString(s: ?[]const u8) SearchMode { 14 + const str = s orelse return .keyword; 15 + if (std.mem.eql(u8, str, "semantic")) return .semantic; 16 + return .keyword; 17 + } 18 + }; 19 + 9 20 // JSON output type for search results 10 21 const SearchResultJson = struct { 11 22 type: []const u8, ··· 263 274 \\ORDER BY rank + (julianday('now') - julianday(p.created_at)) / 30.0 LIMIT 10 264 275 ); 265 276 266 - pub fn search(alloc: Allocator, query: []const u8, tag_filter: ?[]const u8, platform_filter: ?[]const u8, since_filter: ?[]const u8) ![]const u8 { 277 + pub fn search(alloc: Allocator, query: []const u8, tag_filter: ?[]const u8, platform_filter: ?[]const u8, since_filter: ?[]const u8, mode: SearchMode) ![]const u8 { 278 + if (mode == .semantic) return searchSemantic(alloc, query, platform_filter); 279 + 267 280 // try local SQLite first (faster for FTS queries) 268 281 if (db.getLocalDb()) |local| { 269 282 if (searchLocal(alloc, local, query, tag_filter, platform_filter, since_filter)) |result| { ··· 644 657 .path = r.path, 645 658 }); 646 659 count += 1; 660 + } 661 + try jw.endArray(); 662 + 663 + return try output.toOwnedSlice(); 664 + } 665 + 666 + /// Semantic search: embed query via Voyage, ANN search via turbopuffer. 667 + fn searchSemantic(alloc: Allocator, query: []const u8, platform_filter: ?[]const u8) ![]const u8 { 668 + if (query.len == 0) return try alloc.dupe(u8, "[]"); 669 + 670 + if (!tpuf.isSemanticEnabled()) { 671 + return try alloc.dupe(u8, "{\"error\":\"semantic search not available\"}"); 672 + } 673 + 674 + const span = logfire.span("search.semantic", .{}); 675 + defer span.end(); 676 + 677 + // embed query (input_type="query" for asymmetric search) 678 + const vector = tpuf.embedQuery(alloc, query) catch |err| { 679 + logfire.warn("search.semantic: embed failed: {}", .{err}); 680 + return try alloc.dupe(u8, "{\"error\":\"embedding failed\"}"); 681 + }; 682 + defer alloc.free(vector); 683 + 684 + // ANN query 685 + const results = tpuf.query(alloc, vector, 20) catch |err| { 686 + logfire.warn("search.semantic: tpuf query failed: {}", .{err}); 687 + return try alloc.dupe(u8, "{\"error\":\"vector search failed\"}"); 688 + }; 689 + defer { 690 + for (results) |r| { 691 + alloc.free(r.id); 692 + alloc.free(r.uri); 693 + alloc.free(r.title); 694 + alloc.free(r.did); 695 + alloc.free(r.created_at); 696 + alloc.free(r.rkey); 697 + alloc.free(r.base_path); 698 + alloc.free(r.platform); 699 + alloc.free(r.path); 700 + } 701 + alloc.free(results); 702 + } 703 + 704 + // serialize results, post-filtering by platform if set 705 + var output: std.Io.Writer.Allocating = .init(alloc); 706 + errdefer output.deinit(); 707 + 708 + var jw: json.Stringify = .{ .writer = &output.writer }; 709 + try jw.beginArray(); 710 + for (results) |r| { 711 + if (platform_filter) |pf| { 712 + if (!std.mem.eql(u8, r.platform, pf)) continue; 713 + } 714 + try jw.write(SearchResultJson{ 715 + .type = if (r.has_publication) "article" else "looseleaf", 716 + .uri = r.uri, 717 + .did = r.did, 718 + .title = r.title, 719 + .snippet = "", 720 + .createdAt = r.created_at, 721 + .rkey = r.rkey, 722 + .basePath = r.base_path, 723 + .platform = r.platform, 724 + .path = r.path, 725 + }); 647 726 } 648 727 try jw.endArray(); 649 728
+5 -2
backend/src/server.zig
··· 103 103 const tag_filter = parseQueryParam(alloc, target, "tag") catch null; 104 104 const platform_filter = parseQueryParam(alloc, target, "platform") catch null; 105 105 const since_filter = parseQueryParam(alloc, target, "since") catch null; 106 + const mode_str = parseQueryParam(alloc, target, "mode") catch null; 107 + const mode = search.SearchMode.fromString(mode_str); 106 108 107 109 // span attributes are now copied internally, safe to use arena strings 108 110 const span = logfire.span("http.search", .{ 109 111 .query = query, 110 112 .tag = tag_filter, 111 113 .platform = platform_filter, 114 + .mode = @tagName(mode), 112 115 }); 113 116 defer span.end(); 114 117 ··· 117 120 return; 118 121 } 119 122 120 - // perform FTS search - arena handles cleanup 121 - const results = search.search(alloc, query, tag_filter, platform_filter, since_filter) catch |err| { 123 + // perform search - arena handles cleanup 124 + const results = search.search(alloc, query, tag_filter, platform_filter, since_filter, mode) catch |err| { 122 125 logfire.err("search failed: {}", .{err}); 123 126 metrics.stats.recordError(); 124 127 return err;
+100
backend/src/tpuf.zig
··· 21 21 const API_BASE = "https://api.turbopuffer.com/v2/namespaces/"; 22 22 23 23 var api_key: ?[]const u8 = null; 24 + var voyage_api_key: ?[]const u8 = null; 24 25 var namespace: []const u8 = "leaflet-search"; 25 26 26 27 // pre-formatted URL paths (built at init) ··· 84 85 } else { 85 86 logfire.info("tpuf: TURBOPUFFER_API_KEY not set, vector store disabled", .{}); 86 87 } 88 + 89 + voyage_api_key = posix.getenv("VOYAGE_API_KEY"); 90 + if (voyage_api_key != null) { 91 + logfire.info("tpuf: voyage query embedding enabled", .{}); 92 + } 87 93 } 88 94 89 95 pub fn isEnabled() bool { 90 96 return api_key != null; 97 + } 98 + 99 + pub fn isSemanticEnabled() bool { 100 + return api_key != null and voyage_api_key != null; 101 + } 102 + 103 + /// Embed a search query via Voyage API (input_type="query" for asymmetric search). 104 + /// Returns a 512-dim f32 vector. Caller owns the returned slice. 105 + pub fn embedQuery(allocator: Allocator, text: []const u8) ![]f32 { 106 + const vk = voyage_api_key orelse return error.NotConfigured; 107 + 108 + const span = logfire.span("tpuf.embed_query", .{}); 109 + defer span.end(); 110 + 111 + // build request body 112 + var body_buf: std.Io.Writer.Allocating = .init(allocator); 113 + errdefer body_buf.deinit(); 114 + 115 + var jw: json.Stringify = .{ .writer = &body_buf.writer }; 116 + try jw.beginObject(); 117 + try jw.objectField("model"); 118 + try jw.write("voyage-3-lite"); 119 + try jw.objectField("input_type"); 120 + try jw.write("query"); 121 + try jw.objectField("input"); 122 + try jw.beginArray(); 123 + try jw.write(text); 124 + try jw.endArray(); 125 + try jw.endObject(); 126 + 127 + const body = try body_buf.toOwnedSlice(); 128 + defer allocator.free(body); 129 + 130 + // make request 131 + var http_client: http.Client = .{ .allocator = allocator }; 132 + defer http_client.deinit(); 133 + 134 + var auth_buf: [256]u8 = undefined; 135 + const auth = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{vk}) catch 136 + return error.AuthTooLong; 137 + 138 + var response_body: std.Io.Writer.Allocating = .init(allocator); 139 + errdefer response_body.deinit(); 140 + 141 + const res = http_client.fetch(.{ 142 + .location = .{ .url = "https://api.voyageai.com/v1/embeddings" }, 143 + .method = .POST, 144 + .headers = .{ 145 + .content_type = .{ .override = "application/json" }, 146 + .authorization = .{ .override = auth }, 147 + }, 148 + .payload = body, 149 + .response_writer = &response_body.writer, 150 + }) catch |err| { 151 + logfire.err("tpuf: voyage embed_query failed: {}", .{err}); 152 + return error.RequestFailed; 153 + }; 154 + 155 + if (res.status != .ok) { 156 + const resp_text = response_body.toOwnedSlice() catch ""; 157 + defer if (resp_text.len > 0) allocator.free(resp_text); 158 + logfire.err("tpuf: voyage embed_query error {}: {s}", .{ res.status, resp_text[0..@min(resp_text.len, 200)] }); 159 + return error.ApiError; 160 + } 161 + 162 + const response_text = try response_body.toOwnedSlice(); 163 + defer allocator.free(response_text); 164 + 165 + // parse data[0].embedding 166 + const parsed = json.parseFromSlice(json.Value, allocator, response_text, .{}) catch { 167 + logfire.err("tpuf: failed to parse voyage response", .{}); 168 + return error.ParseError; 169 + }; 170 + defer parsed.deinit(); 171 + 172 + const data = parsed.value.object.get("data") orelse return error.ParseError; 173 + if (data != .array or data.array.items.len == 0) return error.ParseError; 174 + 175 + const embedding_val = data.array.items[0].object.get("embedding") orelse return error.ParseError; 176 + if (embedding_val != .array) return error.ParseError; 177 + 178 + const dims = embedding_val.array.items; 179 + const vector = try allocator.alloc(f32, dims.len); 180 + errdefer allocator.free(vector); 181 + 182 + for (dims, 0..) |val, i| { 183 + vector[i] = switch (val) { 184 + .float => @floatCast(val.float), 185 + .integer => @floatFromInt(val.integer), 186 + else => return error.ParseError, 187 + }; 188 + } 189 + 190 + return vector; 91 191 } 92 192 93 193 /// Hash a URI to a tpuf-safe ID (max 64 bytes).
+2 -1
docs/api.md
··· 7 7 ### search 8 8 9 9 ``` 10 - GET /search?q=<query>&tag=<tag>&platform=<platform>&since=<date> 10 + GET /search?q=<query>&tag=<tag>&platform=<platform>&since=<date>&mode=<mode> 11 11 ``` 12 12 13 13 full-text search across documents and publications. ··· 19 19 | `tag` | string | no | filter by tag (documents only) | 20 20 | `platform` | string | no | filter by platform: `leaflet`, `pckt`, `offprint`, `greengale`, `other` | 21 21 | `since` | string | no | ISO date, filter to documents created after | 22 + | `mode` | string | no | `keyword` (default) or `semantic`. semantic uses vector similarity via voyage embeddings + turbopuffer ANN. ignores `tag` and `since` filters. | 22 23 23 24 *at least one of `q` or `tag` required 24 25
+216 -61
site/index.html
··· 182 182 color: #d4956a; 183 183 } 184 184 185 + .mode-toggle { 186 + display: flex; 187 + gap: 0.5rem; 188 + margin-bottom: 1rem; 189 + } 190 + 191 + .mode-option { 192 + font-size: 11px; 193 + padding: 3px 8px; 194 + background: #151515; 195 + border: 1px solid #252525; 196 + border-radius: 3px; 197 + cursor: pointer; 198 + color: #777; 199 + } 200 + 201 + .mode-option:hover { 202 + background: #1a1a1a; 203 + border-color: #333; 204 + color: #aaa; 205 + } 206 + 207 + .mode-option.active { 208 + background: rgba(27, 115, 64, 0.2); 209 + border-color: #1B7340; 210 + color: #2a9d5c; 211 + } 212 + 213 + .source-badge { 214 + font-size: 9px; 215 + padding: 1px 5px; 216 + border-radius: 3px; 217 + margin-left: 6px; 218 + text-transform: lowercase; 219 + } 220 + 221 + .source-badge.keyword { 222 + background: rgba(64, 115, 180, 0.2); 223 + color: #6a9fd4; 224 + } 225 + 226 + .source-badge.semantic { 227 + background: rgba(140, 80, 200, 0.2); 228 + color: #b08ae0; 229 + } 230 + 185 231 .status { 186 232 padding: 1rem; 187 233 text-align: center; ··· 442 488 } 443 489 444 490 /* ensure minimum 44px touch targets */ 445 - .tag, .platform-option, .suggestion, input.tag-input { 491 + .tag, .platform-option, .mode-option, .suggestion, input.tag-input { 446 492 min-height: 44px; 447 493 display: inline-flex; 448 494 align-items: center; ··· 520 566 521 567 /* ensure touch targets on tablets too */ 522 568 @media (hover: none) and (pointer: coarse) { 523 - .tag, .platform-option, .suggestion, .related-item, input.tag-input { 569 + .tag, .platform-option, .mode-option, .suggestion, .related-item, input.tag-input { 524 570 min-height: 44px; 525 571 display: inline-flex; 526 572 align-items: center; ··· 536 582 <input type="text" id="query" placeholder="search content..." autofocus> 537 583 <button id="search-btn">search</button> 538 584 </div> 585 + 586 + <div id="mode-toggle" class="mode-toggle"></div> 539 587 540 588 <div id="suggestions"></div> 541 589 ··· 568 616 const activeFilterDiv = document.getElementById('active-filter'); 569 617 const suggestionsDiv = document.getElementById('suggestions'); 570 618 const platformFilterDiv = document.getElementById('platform-filter'); 619 + const modeToggleDiv = document.getElementById('mode-toggle'); 571 620 572 621 let currentTag = null; 573 622 let currentPlatform = null; 623 + let currentMode = 'keyword'; 574 624 let allTags = []; 575 625 let popularSearches = []; 576 626 const authorCache = new Map(); 627 + 628 + function renderModeToggle() { 629 + const modes = [ 630 + { id: 'keyword', label: 'keyword' }, 631 + { id: 'semantic', label: 'semantic' }, 632 + { id: 'hybrid', label: 'hybrid' }, 633 + ]; 634 + modeToggleDiv.innerHTML = modes.map(m => ` 635 + <span class="mode-option${currentMode === m.id ? ' active' : ''}" onclick="setMode('${m.id}')">${m.label}</span> 636 + `).join(''); 637 + } 638 + 639 + function setMode(mode) { 640 + currentMode = mode; 641 + renderModeToggle(); 642 + // hide tag filter in semantic/hybrid since tpuf doesn't store tags 643 + tagsDiv.style.display = (mode === 'keyword') ? '' : 'none'; 644 + if (mode !== 'keyword') { 645 + currentTag = null; 646 + renderActiveFilter(); 647 + } 648 + if (queryInput.value.trim() || currentTag || currentPlatform) { 649 + doSearch(); 650 + } 651 + } 652 + 577 653 async function search(query, tag = null, platform = null) { 578 654 if (!query.trim() && !tag && !platform) return; 579 655 580 656 searchBtn.disabled = true; 581 657 resultsDiv.innerHTML = `<div class="status">searching...</div>`; 582 658 659 + try { 660 + if (currentMode === 'hybrid') { 661 + await searchHybrid(query, tag, platform); 662 + } else if (currentMode === 'semantic') { 663 + await searchSingle(query, platform, 'semantic'); 664 + } else { 665 + await searchSingle(query, platform, 'keyword', tag); 666 + } 667 + } catch (err) { 668 + resultsDiv.innerHTML = `<div class="status error">error: ${err.message}</div>`; 669 + } finally { 670 + searchBtn.disabled = false; 671 + } 672 + } 673 + 674 + async function searchSingle(query, platform, mode, tag = null) { 583 675 let searchUrl = `${API_URL}/search?q=${encodeURIComponent(query || '')}`; 584 676 if (tag) searchUrl += `&tag=${encodeURIComponent(tag)}`; 585 677 if (platform) searchUrl += `&platform=${encodeURIComponent(platform)}`; 678 + if (mode === 'semantic') searchUrl += '&mode=semantic'; 679 + 680 + const res = await fetch(searchUrl); 681 + const rawText = await res.text(); 682 + let results; 586 683 587 684 try { 588 - const res = await fetch(searchUrl); 589 - const rawText = await res.text(); 590 - let results; 685 + results = JSON.parse(rawText); 686 + } catch (parseErr) { 687 + resultsDiv.innerHTML = `<div class="status error">JSON parse error<pre style="text-align:left;font-size:0.7rem;overflow:auto;max-height:200px">${escapeHtml(rawText)}</pre></div>`; 688 + return; 689 + } 690 + 691 + if (results.error) { 692 + resultsDiv.innerHTML = `<div class="status error">${results.error}</div>`; 693 + return; 694 + } 591 695 592 - try { 593 - results = JSON.parse(rawText); 594 - } catch (parseErr) { 595 - resultsDiv.innerHTML = `<div class="status error">JSON parse error<pre style="text-align:left;font-size:0.7rem;overflow:auto;max-height:200px">${escapeHtml(rawText)}</pre></div>`; 596 - return; 597 - } 696 + if (results.length === 0) { 697 + resultsDiv.innerHTML = ` 698 + <div class="empty-state"> 699 + <p>no results${query ? ` for ${formatQueryForDisplay(query)}` : ''}${tag ? ` in #${escapeHtml(tag)}` : ''}${platform ? ` on ${escapeHtml(platform)}` : ''}</p> 700 + </div> 701 + `; 702 + statsDiv.textContent = ''; 703 + return; 704 + } 705 + 706 + renderResults(results, query); 707 + resolveAuthors(results); 708 + 709 + if (results.length > 0 && results[0].uri) { 710 + loadRelated(results[0]); 711 + } 712 + } 713 + 714 + async function searchHybrid(query, tag, platform) { 715 + let kwUrl = `${API_URL}/search?q=${encodeURIComponent(query || '')}`; 716 + if (tag) kwUrl += `&tag=${encodeURIComponent(tag)}`; 717 + if (platform) kwUrl += `&platform=${encodeURIComponent(platform)}`; 718 + 719 + let semUrl = `${API_URL}/search?q=${encodeURIComponent(query || '')}&mode=semantic`; 720 + if (platform) semUrl += `&platform=${encodeURIComponent(platform)}`; 598 721 599 - if (results.error) { 600 - resultsDiv.innerHTML = `<div class="status error">${results.error}</div>`; 601 - return; 602 - } 722 + const kwPromise = fetch(kwUrl).then(r => r.json()); 723 + const semPromise = fetch(semUrl).then(r => r.json()); 603 724 604 - if (results.length === 0) { 605 - resultsDiv.innerHTML = ` 606 - <div class="empty-state"> 607 - <p>no results${query ? ` for ${formatQueryForDisplay(query)}` : ''}${tag ? ` in #${escapeHtml(tag)}` : ''}${platform ? ` on ${escapeHtml(platform)}` : ''}</p> 608 - </div> 609 - `; 610 - statsDiv.textContent = ''; 611 - return; 612 - } 725 + // show keyword results as soon as they arrive 726 + const kwResults = await kwPromise; 727 + if (kwResults.error) { 728 + resultsDiv.innerHTML = `<div class="status error">${kwResults.error}</div>`; 729 + return; 730 + } 613 731 614 - let html = ''; 615 - for (const doc of results) { 616 - const entityType = doc.type || 'article'; 617 - const plat = doc.platform || 'leaflet'; 618 - const docUrl = buildDocUrl(doc, entityType, plat); 619 - const platformConfig = PLATFORM_CONFIG[plat]; 620 - const platformBadge = platformConfig 621 - ? `<span class="platform-badge">${escapeHtml(platformConfig.label)}</span>` 622 - : ''; 623 - const date = doc.createdAt ? new Date(doc.createdAt).toLocaleDateString() : ''; 624 - const platformHome = getPlatformHome(plat, doc.basePath); 732 + // tag each result with source 733 + const tagged = (kwResults || []).map(r => ({ ...r, _source: 'keyword' })); 734 + renderResults(tagged, query, true); 735 + resolveAuthors(tagged); 625 736 626 - html += ` 627 - <div class="result"> 628 - <div class="result-title"> 629 - <span class="entity-type ${entityType}">${entityType}</span>${platformBadge} 630 - ${docUrl 631 - ? `<a href="${docUrl}" target="_blank">${escapeHtml(doc.title || 'Untitled')}</a>` 632 - : escapeHtml(doc.title || 'Untitled')} 633 - </div> 634 - <div class="result-snippet">${highlightTerms(doc.snippet, query)}</div> 635 - <div class="result-meta" ${doc.did ? `data-did="${escapeHtml(doc.did)}"` : ''}> 636 - ${date ? `${date} | ` : ''}<span class="author-name"></span>${platformHome.url 637 - ? `<a href="${platformHome.url}" target="_blank">${platformHome.label}</a>` 638 - : platformHome.label} 639 - </div> 640 - </div> 641 - `; 737 + // append semantic results when ready 738 + try { 739 + const semResults = await semPromise; 740 + if (!semResults.error && semResults.length > 0) { 741 + const seenUris = new Set(tagged.map(r => r.uri)); 742 + const unique = semResults.filter(r => !seenUris.has(r.uri)).map(r => ({ ...r, _source: 'semantic' })); 743 + if (unique.length > 0) { 744 + const merged = [...tagged, ...unique]; 745 + renderResults(merged, query, true); 746 + resolveAuthors(unique); 747 + } 642 748 } 749 + } catch (e) { 750 + // semantic failed silently — keyword results already shown 751 + } 643 752 644 - resultsDiv.innerHTML = html; 645 - statsDiv.textContent = `${results.length} result${results.length === 1 ? '' : 's'}`; 646 - resolveAuthors(results); 753 + if (tagged.length > 0 && tagged[0].uri) { 754 + loadRelated(tagged[0]); 755 + } 756 + } 757 + 758 + function renderResults(results, query, showSource = false) { 759 + if (results.length === 0) { 760 + resultsDiv.innerHTML = ` 761 + <div class="empty-state"> 762 + <p>no results${query ? ` for ${formatQueryForDisplay(query)}` : ''}</p> 763 + </div> 764 + `; 765 + statsDiv.textContent = ''; 766 + return; 767 + } 647 768 648 - if (results.length > 0 && results[0].uri) { 649 - loadRelated(results[0]); 650 - } 769 + let html = ''; 770 + for (const doc of results) { 771 + const entityType = doc.type || 'article'; 772 + const plat = doc.platform || 'leaflet'; 773 + const docUrl = buildDocUrl(doc, entityType, plat); 774 + const platformConfig = PLATFORM_CONFIG[plat]; 775 + const platformBadge = platformConfig 776 + ? `<span class="platform-badge">${escapeHtml(platformConfig.label)}</span>` 777 + : ''; 778 + const sourceBadge = showSource && doc._source 779 + ? `<span class="source-badge ${doc._source}">${doc._source}</span>` 780 + : ''; 781 + const date = doc.createdAt ? new Date(doc.createdAt).toLocaleDateString() : ''; 782 + const platformHome = getPlatformHome(plat, doc.basePath); 651 783 652 - } catch (err) { 653 - resultsDiv.innerHTML = `<div class="status error">error: ${err.message}</div>`; 654 - } finally { 655 - searchBtn.disabled = false; 784 + html += ` 785 + <div class="result"> 786 + <div class="result-title"> 787 + <span class="entity-type ${entityType}">${entityType}</span>${platformBadge} 788 + ${docUrl 789 + ? `<a href="${docUrl}" target="_blank">${escapeHtml(doc.title || 'Untitled')}</a>` 790 + : escapeHtml(doc.title || 'Untitled')}${sourceBadge} 791 + </div> 792 + ${doc.snippet ? `<div class="result-snippet">${highlightTerms(doc.snippet, query)}</div>` : ''} 793 + <div class="result-meta" ${doc.did ? `data-did="${escapeHtml(doc.did)}"` : ''}> 794 + ${date ? `${date} | ` : ''}<span class="author-name"></span>${platformHome.url 795 + ? `<a href="${platformHome.url}" target="_blank">${platformHome.label}</a>` 796 + : platformHome.label} 797 + </div> 798 + </div> 799 + `; 656 800 } 801 + 802 + resultsDiv.innerHTML = html; 803 + statsDiv.textContent = `${results.length} result${results.length === 1 ? '' : 's'}`; 657 804 } 658 805 659 806 async function resolveAuthors(results) { ··· 834 981 if (q) params.set('q', q); 835 982 if (currentTag) params.set('tag', currentTag); 836 983 if (currentPlatform) params.set('platform', currentPlatform); 984 + if (currentMode !== 'keyword') params.set('mode', currentMode); 837 985 const url = params.toString() ? `?${params}` : '/'; 838 986 history.pushState(null, '', url); 839 987 } ··· 1009 1157 queryInput.value = params.get('q') || ''; 1010 1158 currentTag = params.get('tag') || null; 1011 1159 currentPlatform = params.get('platform') || null; 1160 + currentMode = params.get('mode') || 'keyword'; 1012 1161 renderActiveFilter(); 1013 1162 renderTags(); 1014 1163 renderPlatformFilter(); 1164 + renderModeToggle(); 1165 + tagsDiv.style.display = (currentMode === 'keyword') ? '' : 'none'; 1015 1166 if (queryInput.value || currentTag || currentPlatform) search(queryInput.value, currentTag, currentPlatform); 1016 1167 }); 1017 1168 ··· 1020 1171 const initialQuery = initialParams.get('q'); 1021 1172 const initialTag = initialParams.get('tag'); 1022 1173 const initialPlatform = initialParams.get('platform'); 1174 + const initialMode = initialParams.get('mode'); 1023 1175 if (initialQuery) queryInput.value = initialQuery; 1024 1176 if (initialTag) currentTag = initialTag; 1025 1177 if (initialPlatform) currentPlatform = initialPlatform; 1178 + if (initialMode) currentMode = initialMode; 1026 1179 renderActiveFilter(); 1027 1180 renderPlatformFilter(); 1181 + renderModeToggle(); 1182 + tagsDiv.style.display = (currentMode === 'keyword') ? '' : 'none'; 1028 1183 1029 1184 if (initialQuery || initialTag || initialPlatform) { 1030 1185 search(initialQuery || '', initialTag, initialPlatform);