From c5707464e23da574e989552e136c6aab84d6b82f Mon Sep 17 00:00:00 2001 From: lycorismmoonlights Date: Wed, 19 Aug 2026 01:45:18 +0800 Subject: [PATCH 1/4] Improve search suggestions and overlay scrollbar --- STATE_FRAMEWORK.md | 10 + api/_lib/search-suggest.js | 694 ++++++++++++- api/suggest.js | 18 +- docs/CURRENT_STATUS.md | 18 +- docs/DECISIONS.md | 72 ++ docs/NEXT_STEPS.md | 11 +- docs/WORK_LOG.md | 167 +++ .../2026-04-16-chrome-omnibox-suggest.md | 69 ++ index.html | 14 + script.js | 963 +++++++++++++++++- styles.css | 175 +++- 11 files changed, 2050 insertions(+), 161 deletions(-) create mode 100644 docs/threads/2026-04-16-chrome-omnibox-suggest.md diff --git a/STATE_FRAMEWORK.md b/STATE_FRAMEWORK.md index a580e7c..6018df9 100644 --- a/STATE_FRAMEWORK.md +++ b/STATE_FRAMEWORK.md @@ -28,6 +28,16 @@ This project uses one shared state/storage framework in [`script.js`](./script.j - Clear persisted value: `stateStore.persist("wallpaper", "")` +## Search History Split + +- `recent`: only the visible recent panel, capped at 4 items. +- `history`: hidden ranking memory for search suggestions, capped at 48 items. +- `suggestDebug`: developer-facing switch that shows suggestion provider, score, and feature breakdown in the suggestion list. +- `addRecentEntry(entry)`: updates both fields, increments `visitCount`, refreshes `lastVisitedAt`, and records the last suggestion provider. +- `stateStore.clearRecent()`: clears both visible recent entries and hidden ranking history. + +Do not rank search suggestions directly from `recent` unless `history` is empty for backward compatibility. + ## Guardrail `state` is wrapped in a guarded `Proxy`. Direct writes still work technically, but they log a warning in the console so accidental bypasses are easier to catch during development. diff --git a/api/_lib/search-suggest.js b/api/_lib/search-suggest.js index 73bb2c2..8aabe6d 100644 --- a/api/_lib/search-suggest.js +++ b/api/_lib/search-suggest.js @@ -12,6 +12,183 @@ const ENGINE_LABELS = { bing: "Bing" }; +const PROVIDER_QUOTAS = { + zero: 5, + primary_engine: 4, + secondary_engine: 2, + entity: 2, + site: 2, + content: 2, + root: 1, + spell: 1 +}; + +const PROVIDER_DEMOTIONS = { + zero: 0, + primary_engine: 0, + secondary_engine: 28, + entity: 8, + site: 12, + content: 34, + root: 56, + spell: 18 +}; + +const RANKER_FEATURE_WEIGHTS = { + basePriority: 1, + textMatchScore: 1, + navigationScore: 1, + providerDemotion: -1, + typoPenalty: -1 +}; + +const OMNIBOX_SITE_CATALOG = [ + { + title: "Google", + url: "https://www.google.com", + aliases: ["google", "谷歌"], + description: "搜索、图片、地图与常用 Google 服务入口", + popularity: 96 + }, + { + title: "YouTube", + url: "https://www.youtube.com", + aliases: ["youtube", "yt", "油管"], + description: "视频、频道与创作者内容", + popularity: 94 + }, + { + title: "GitHub", + url: "https://github.com", + aliases: ["github", "git hub", "代码"], + description: "代码仓库、Issues、Actions 与开发者协作", + popularity: 90 + }, + { + title: "ChatGPT", + url: "https://chatgpt.com", + aliases: ["chatgpt", "chat gpt", "gpt", "openai chat"], + description: "AI 对话、写作、编程与多模态助手", + popularity: 88 + }, + { + title: "OpenAI", + url: "https://openai.com", + aliases: ["openai", "open ai", "gpt api", "openai api"], + description: "OpenAI 产品、API、研究与文档入口", + popularity: 84 + }, + { + title: "Wikipedia", + url: "https://www.wikipedia.org", + aliases: ["wikipedia", "wiki", "维基百科", "百科"], + description: "多语言百科内容与主题概览", + popularity: 78 + }, + { + title: "Bilibili", + url: "https://www.bilibili.com", + aliases: ["bilibili", "b站", "哔哩哔哩", "bili"], + description: "视频、番剧、直播与中文社区内容", + popularity: 76 + }, + { + title: "Pixiv", + url: "https://www.pixiv.net", + aliases: ["pixiv", "p站", "插画"], + description: "插画、漫画与创作者作品", + popularity: 72 + } +]; + +const OMNIBOX_ENTITY_CATALOG = [ + { + id: "entity:ai", + title: "人工智能", + value: "人工智能", + aliases: ["ai", "人工智能", "机器学习", "大模型", "llm"], + description: "模型、工具、论文与产业动态", + typeLabel: "主题", + popularity: 86 + }, + { + id: "entity:iphone", + title: "iPhone", + value: "iPhone", + aliases: ["iphone", "苹果手机", "ios", "iphone 17"], + description: "设备、系统、配件与使用技巧", + typeLabel: "实体", + popularity: 82 + }, + { + id: "entity:chrome", + title: "Chrome Omnibox", + value: "Chrome Omnibox", + aliases: ["chrome", "omnibox", "地址栏", "搜索联想", "chrome 地址栏"], + description: "多路召回、历史记录、站点直达与排序模型", + typeLabel: "主题", + popularity: 80 + }, + { + id: "entity:vercel", + title: "Vercel", + value: "Vercel", + aliases: ["vercel", "部署", "serverless", "edge functions"], + description: "前端部署、Serverless Functions 与预览环境", + typeLabel: "服务", + popularity: 74 + }, + { + id: "entity:javascript", + title: "JavaScript", + value: "JavaScript", + aliases: ["javascript", "js", "前端", "nodejs", "node.js"], + description: "浏览器脚本、Node.js、生态工具与工程实践", + typeLabel: "技术", + popularity: 78 + }, + { + id: "entity:weather", + title: "天气", + value: "天气", + aliases: ["天气", "weather", "气温", "下雨"], + description: "当前位置或城市天气查询", + typeLabel: "服务", + popularity: 70 + } +]; + +const ZERO_INPUT_SUGGESTIONS = [ + { + label: "Google", + url: "https://www.google.com", + badge: "常用", + meta: "常用站点 · 搜索入口", + priority: 850 + }, + { + label: "YouTube", + url: "https://www.youtube.com", + badge: "常用", + meta: "常用站点 · 视频", + priority: 832 + }, + { + label: "GitHub", + url: "https://github.com", + badge: "常用", + meta: "常用站点 · 代码", + priority: 810 + }, + { + label: "ChatGPT", + url: "https://chatgpt.com", + badge: "常用", + meta: "常用站点 · AI 助手", + priority: 792 + } +]; + function normalizeQuery(value) { return typeof value === "string" ? value.trim().replace(/\s+/g, " ").slice(0, MAX_QUERY_LENGTH) : ""; } @@ -37,6 +214,19 @@ function stripHtml(value) { return decodeHtml(value).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); } +function normalizeLoose(value) { + return normalizeQuery(value) + .toLocaleLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^\p{L}\p{N}\u3400-\u9fff]+/gu, " ") + .trim(); +} + +function compactText(value) { + return normalizeLoose(value).replace(/\s+/g, ""); +} + function getEngineLabel(engine) { return ENGINE_LABELS[engine] || ENGINE_LABELS.google; } @@ -45,6 +235,14 @@ function getProviderIcon(provider) { return String(provider || "S").trim().charAt(0).toUpperCase() || "S"; } +function getHostLabel(url) { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch (error) { + return url; + } +} + function isReadableSuggestionText(value, query) { const normalizedValue = normalizeQuery(value); if (!normalizedValue || normalizedValue.includes("\uFFFD")) { @@ -100,11 +298,7 @@ function stemEnglishToken(token) { continue; } - if (suffix === "ies") { - return `${lower.slice(0, -suffix.length)}y`; - } - - if (suffix === "ied") { + if (suffix === "ies" || suffix === "ied") { return `${lower.slice(0, -suffix.length)}y`; } @@ -224,7 +418,7 @@ async function fetchWikipediaResults(query, locale) { })); } -function createSearchSuggestionItems(values, { provider, query, badge, priority }) { +function createSearchSuggestionItems(values, { provider, query, badge, priority, providerType }) { const normalizedQuery = normalizeQuery(query).toLowerCase(); const providerLabel = getEngineLabel(provider); @@ -245,7 +439,9 @@ function createSearchSuggestionItems(values, { provider, query, badge, priority icon: getProviderIcon(providerLabel), meta: `${providerLabel} · ${badge}`, provider: providerLabel, - priority: priority + (exact ? 90 : prefix ? 42 : 0) + providerType, + priority: priority + (exact ? 90 : prefix ? 42 : 0), + matchText: normalizedValue }; }); } @@ -261,7 +457,9 @@ function createRootFallbackItems(roots, engine) { icon: getProviderIcon(engineLabel), meta: `${engineLabel} · 词根扩展`, provider: engineLabel, - priority: 720 - index * 12 + providerType: "root", + priority: 690 - index * 18, + matchText: root })); } @@ -275,6 +473,7 @@ function createContentSuggestionItems(entries, { query, badge, priority }) { const lowerTitle = entry.title.toLowerCase(); const exact = lowerTitle === normalizedQuery; const prefix = lowerTitle.startsWith(normalizedQuery); + const description = entry.description || "互联网内容结果"; return { label: entry.title, @@ -283,58 +482,445 @@ function createContentSuggestionItems(entries, { query, badge, priority }) { action: "navigate", badge, icon: "W", - meta: `Wikipedia · ${entry.description || "互联网内容结果"}`, + meta: `Wikipedia · ${description}`, provider: "Wikipedia", + providerType: "content", title: entry.title, + description, recentMeta: "互联网内容", recentType: "link", - priority: priority + (exact ? 70 : prefix ? 30 : 0) + priority: priority + (exact ? 70 : prefix ? 30 : 0), + matchText: `${entry.title} ${description}` }; }); } -function dedupeSuggestions(items) { - const seen = new Set(); +function scoreCatalogEntry(entry, query) { + const normalizedQuery = normalizeLoose(query); + const compactQuery = compactText(query); + if (!normalizedQuery || !compactQuery) { + return { + score: 0, + matchType: "zero" + }; + } + + const fields = unique([ + entry.title, + entry.value, + entry.url ? getHostLabel(entry.url) : "", + ...(Array.isArray(entry.aliases) ? entry.aliases : []) + ]); + + for (const field of fields) { + const normalizedField = normalizeLoose(field); + const compactField = compactText(field); + + if (!normalizedField || !compactField) { + continue; + } + + if (normalizedField === normalizedQuery || compactField === compactQuery) { + return { + score: 185, + matchType: "exact" + }; + } + + if (normalizedField.startsWith(normalizedQuery) || compactField.startsWith(compactQuery)) { + return { + score: 132, + matchType: "prefix" + }; + } + + if (normalizedField.includes(normalizedQuery) || compactField.includes(compactQuery)) { + return { + score: 74, + matchType: "substring" + }; + } + } + + return { + score: 0, + matchType: "none" + }; +} + +function getEditDistanceWithinLimit(left, right, limit) { + if (!left || !right || Math.abs(left.length - right.length) > limit) { + return limit + 1; + } + + let previous = Array.from({ length: right.length + 1 }, (_, index) => index); + + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + const current = [leftIndex]; + let rowMin = current[0]; + + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1; + const value = Math.min( + previous[rightIndex] + 1, + current[rightIndex - 1] + 1, + previous[rightIndex - 1] + cost + ); + current[rightIndex] = value; + rowMin = Math.min(rowMin, value); + } + + if (rowMin > limit) { + return limit + 1; + } + + previous = current; + } + + return previous[right.length]; +} + +function createSiteSuggestionItems(query) { + const normalizedQuery = normalizeQuery(query); + if (!normalizedQuery) { + return []; + } + + return OMNIBOX_SITE_CATALOG + .map((site) => { + const match = scoreCatalogEntry(site, normalizedQuery); + if (match.score <= 0) { + return null; + } + + return { + label: site.title, + value: site.title, + url: site.url, + action: "navigate", + badge: "站点", + icon: getProviderIcon(site.title), + meta: `${getHostLabel(site.url)} · ${site.description}`, + provider: "站点索引", + providerType: "site", + title: site.title, + description: site.description, + aliases: site.aliases, + recentMeta: getHostLabel(site.url), + recentType: "link", + priority: 790 + Math.round(site.popularity * 0.52) + match.score, + matchType: match.matchType, + matchText: `${site.title} ${site.url} ${site.description} ${(site.aliases || []).join(" ")}` + }; + }) + .filter(Boolean); +} + +function createEntitySuggestionItems(query) { + const normalizedQuery = normalizeQuery(query); + if (!normalizedQuery) { + return []; + } + + return OMNIBOX_ENTITY_CATALOG + .map((entity) => { + const match = scoreCatalogEntry(entity, normalizedQuery); + if (match.score <= 0) { + return null; + } + + return { + label: entity.title, + value: entity.value || entity.title, + action: "search", + badge: entity.typeLabel || "实体", + icon: "E", + meta: `${entity.typeLabel || "实体"} · ${entity.description}`, + provider: "实体索引", + providerType: "entity", + entityId: entity.id, + description: entity.description, + aliases: entity.aliases, + priority: 805 + Math.round(entity.popularity * 0.48) + match.score, + matchType: match.matchType, + matchText: `${entity.title} ${entity.value || ""} ${entity.description} ${(entity.aliases || []).join(" ")}` + }; + }) + .filter(Boolean); +} + +function createSpellingSuggestionItems(query) { + const normalizedQuery = compactText(query); + if (normalizedQuery.length < 4 || containsCjk(query)) { + return []; + } - return items.filter((item) => { - const key = `${item.action}:${(item.url || item.value || item.label).toLowerCase()}`; - if (seen.has(key)) { - return false; + const candidates = [...OMNIBOX_SITE_CATALOG, ...OMNIBOX_ENTITY_CATALOG] + .flatMap((entry) => unique([entry.title, entry.value, ...(entry.aliases || [])]).map((alias) => ({ entry, alias }))); + + const best = candidates.reduce((currentBest, candidate) => { + const normalizedAlias = compactText(candidate.alias); + if ( + !normalizedAlias || + normalizedAlias === normalizedQuery || + Math.abs(normalizedAlias.length - normalizedQuery.length) > 2 + ) { + return currentBest; } - seen.add(key); - return true; + const distance = getEditDistanceWithinLimit(normalizedQuery, normalizedAlias, 2); + if (distance > 2) { + return currentBest; + } + + const score = 760 - distance * 58 + Math.round((candidate.entry.popularity || 60) * 0.35); + if (!currentBest || score > currentBest.score) { + return { + ...candidate, + distance, + score + }; + } + + return currentBest; + }, null); + + if (!best) { + return []; + } + + const isSite = Boolean(best.entry.url); + const label = best.entry.title || best.alias; + + return [{ + label, + value: best.entry.value || label, + url: isSite ? best.entry.url : "", + action: isSite ? "navigate" : "search", + badge: "拼写", + icon: isSite ? getProviderIcon(label) : "E", + meta: `可能是 ${label} · ${best.entry.description || "拼写容错"}`, + provider: "拼写容错", + providerType: "spell", + title: label, + description: best.entry.description || "", + recentMeta: isSite ? getHostLabel(best.entry.url) : "", + recentType: isSite ? "link" : "search", + priority: best.score, + typoDistance: best.distance, + matchText: `${label} ${best.alias} ${best.entry.description || ""}` + }]; +} + +function createZeroSuggestionItems() { + return ZERO_INPUT_SUGGESTIONS.map((entry, index) => ({ + label: entry.label, + value: entry.label, + url: entry.url, + action: "navigate", + badge: entry.badge, + icon: getProviderIcon(entry.label), + meta: entry.meta, + provider: "零输入", + providerType: "zero", + title: entry.label, + recentMeta: getHostLabel(entry.url), + recentType: "link", + priority: entry.priority - index * 10, + matchText: `${entry.label} ${entry.url} ${entry.meta}` + })); +} + +function getTextMatchScore(item, query) { + const normalizedQuery = normalizeLoose(query); + const compactQuery = compactText(query); + if (!normalizedQuery || !compactQuery) { + return 0; + } + + const fields = unique([ + item.label, + item.value, + item.url ? getHostLabel(item.url) : "", + item.description, + item.matchText, + ...(Array.isArray(item.aliases) ? item.aliases : []) + ]); + + let bestScore = 0; + for (const field of fields) { + const normalizedField = normalizeLoose(field); + const compactField = compactText(field); + + if (!normalizedField || !compactField) { + continue; + } + + if (normalizedField === normalizedQuery || compactField === compactQuery) { + bestScore = Math.max(bestScore, 230); + continue; + } + + if (normalizedField.startsWith(normalizedQuery) || compactField.startsWith(compactQuery)) { + bestScore = Math.max(bestScore, 154); + continue; + } + + if (normalizedField.includes(normalizedQuery) || compactField.includes(compactQuery)) { + bestScore = Math.max(bestScore, 86); + } + } + + return bestScore; +} + +function normalizeCanonicalUrl(url) { + if (!url) { + return ""; + } + + try { + const parsedUrl = new URL(url); + parsedUrl.hash = ""; + parsedUrl.search = ""; + parsedUrl.hostname = parsedUrl.hostname.replace(/^www\./, ""); + return parsedUrl.toString().replace(/\/$/, "").toLocaleLowerCase(); + } catch (error) { + return String(url).toLocaleLowerCase(); + } +} + +function getSuggestionCanonicalKey(item) { + if (item.entityId) { + return `entity:${item.entityId}`; + } + + if (item.url) { + return `url:${normalizeCanonicalUrl(item.url)}`; + } + + return `${item.action || "search"}:${normalizeLoose(item.value || item.label)}`; +} + +function rankSuggestionItem(item, query, index) { + const providerType = item.providerType || "primary_engine"; + const features = createRankerFeatureVector(item, query, providerType); + const relevance = scoreRankerFeatures(features); + + return { + ...item, + providerType, + relevance, + priority: relevance, + _features: features, + _index: index + }; +} + +function createRankerFeatureVector(item, query, providerType) { + return { + basePriority: Number(item.priority) || 0, + textMatchScore: getTextMatchScore(item, query), + navigationScore: item.action === "navigate" ? 10 : 0, + providerDemotion: PROVIDER_DEMOTIONS[providerType] || 0, + typoPenalty: Number(item.typoDistance || 0) * 26 + }; +} + +function scoreRankerFeatures(features) { + return Math.round(Object.entries(RANKER_FEATURE_WEIGHTS).reduce((score, [featureName, weight]) => ( + score + (Number(features[featureName]) || 0) * weight + ), 0)); +} + +function rankAndCullSuggestions(items, query, { debug = false } = {}) { + const bestByKey = new Map(); + + items.forEach((item, index) => { + if (!item || !item.label || !item.action) { + return; + } + + const ranked = rankSuggestionItem(item, query, index); + const key = getSuggestionCanonicalKey(ranked); + const previous = bestByKey.get(key); + + if (!previous || ranked.relevance > previous.relevance) { + bestByKey.set(key, { + ...ranked, + key + }); + } }); + + const providerCounts = {}; + const results = []; + const rankedItems = Array.from(bestByKey.values()).sort((left, right) => ( + right.relevance - left.relevance || + left.label.length - right.label.length || + left._index - right._index + )); + + rankedItems.forEach((item) => { + const providerType = item.providerType || "primary_engine"; + const quota = PROVIDER_QUOTAS[providerType] || MAX_RESULT_ITEMS; + const count = providerCounts[providerType] || 0; + + if (count >= quota) { + return; + } + + providerCounts[providerType] = count + 1; + const { + _index, + _features, + aliases, + matchText, + ...publicItem + } = item; + + results.push(debug ? { ...publicItem, debugFeatures: _features, debugScore: item.relevance } : publicItem); + }); + + return results.slice(0, MAX_RESULT_ITEMS); } -function sortSuggestions(items) { - return items - .map((item, index) => ({ ...item, __index: index })) - .sort((left, right) => ( - (right.priority || 0) - (left.priority || 0) || - left.label.length - right.label.length || - left.__index - right.__index - )) - .map(({ __index, ...item }) => item); +function buildSuggestionStats(suggestions, roots) { + const providerCounts = suggestions.reduce((counts, item) => { + const providerType = item.providerType || "primary_engine"; + counts[providerType] = (counts[providerType] || 0) + 1; + return counts; + }, {}); + + return { + searchCount: suggestions.filter((item) => item.action === "search").length, + contentCount: suggestions.filter((item) => item.providerType === "content").length, + entityCount: suggestions.filter((item) => item.providerType === "entity").length, + siteCount: suggestions.filter((item) => item.providerType === "site" || item.providerType === "zero").length, + spellCount: suggestions.filter((item) => item.providerType === "spell").length, + zeroCount: suggestions.filter((item) => item.providerType === "zero").length, + rootCount: roots.length, + providerCounts, + model: "omnibox-feature-ranker-v2" + }; } -async function getRemoteSuggestions({ query, engine = "google", locale = "zh-CN" }) { +async function getRemoteSuggestions({ query, engine = "google", locale = "zh-CN", debug = false }) { const normalizedQuery = normalizeQuery(query); + const roots = buildQueryRoots(normalizedQuery); + const safeEngine = ENGINE_LABELS[engine] ? engine : "google"; + if (!normalizedQuery) { + const suggestions = rankAndCullSuggestions(createZeroSuggestionItems(), "", { debug }); + return { query: "", roots: [], - suggestions: [], - stats: { - searchCount: 0, - contentCount: 0, - rootCount: 0 - } + suggestions, + stats: buildSuggestionStats(suggestions, []) }; } - const safeEngine = ENGINE_LABELS[engine] ? engine : "google"; - const roots = buildQueryRoots(normalizedQuery); const primaryLocale = containsCjk(normalizedQuery) || locale.toLowerCase().startsWith("zh") ? "zh" : "en"; const secondaryLocale = primaryLocale === "zh" ? "en" : "zh"; const searchTasks = [ @@ -342,7 +928,8 @@ async function getRemoteSuggestions({ query, engine = "google", locale = "zh-CN" provider: safeEngine, query: roots[0], badge: "联网", - priority: 840 + priority: 840, + providerType: "primary_engine" })) ]; @@ -352,20 +939,22 @@ async function getRemoteSuggestions({ query, engine = "google", locale = "zh-CN" provider: "bing", query: roots[0], badge: "扩展", - priority: 760 + priority: 760, + providerType: "secondary_engine" })) ); } + const contentTasks = [ fetchWikipediaResults(roots[0], primaryLocale).then((entries) => createContentSuggestionItems(entries, { query: roots[0], badge: "内容", - priority: 740 + priority: 720 })), fetchWikipediaResults(roots[0], secondaryLocale).then((entries) => createContentSuggestionItems(entries, { query: roots[0], badge: "内容", - priority: 680 + priority: 650 })) ]; @@ -375,7 +964,8 @@ async function getRemoteSuggestions({ query, engine = "google", locale = "zh-CN" provider: safeEngine, query: roots[0], badge: "词根", - priority: 790 + priority: 790, + providerType: "primary_engine" })) ); @@ -383,13 +973,18 @@ async function getRemoteSuggestions({ query, engine = "google", locale = "zh-CN" fetchWikipediaResults(roots[1], primaryLocale).then((entries) => createContentSuggestionItems(entries, { query: roots[0], badge: "词根", - priority: 700 + priority: 680 })) ); } const settled = await Promise.allSettled([...searchTasks, ...contentTasks]); - const merged = []; + const merged = [ + ...createSiteSuggestionItems(normalizedQuery), + ...createEntitySuggestionItems(normalizedQuery), + ...createSpellingSuggestionItems(normalizedQuery), + ...createRootFallbackItems(roots, safeEngine) + ]; settled.forEach((result) => { if (result.status === "fulfilled" && Array.isArray(result.value)) { @@ -406,24 +1001,19 @@ async function getRemoteSuggestions({ query, engine = "google", locale = "zh-CN" } }); - merged.push(...createRootFallbackItems(roots, safeEngine)); - - const suggestions = sortSuggestions(dedupeSuggestions(merged)).slice(0, MAX_RESULT_ITEMS); + const suggestions = rankAndCullSuggestions(merged, normalizedQuery, { debug }); return { query: normalizedQuery, roots, suggestions, - stats: { - searchCount: suggestions.filter((item) => item.action === "search").length, - contentCount: suggestions.filter((item) => item.action === "navigate").length, - rootCount: roots.length - } + stats: buildSuggestionStats(suggestions, roots) }; } module.exports = { buildQueryRoots, getRemoteSuggestions, - normalizeQuery + normalizeQuery, + rankAndCullSuggestions }; diff --git a/api/suggest.js b/api/suggest.js index bf44e03..c1baf35 100644 --- a/api/suggest.js +++ b/api/suggest.js @@ -24,24 +24,10 @@ module.exports = async function handler(req, res) { const query = normalizeQuery(req.query?.q || ""); const engine = normalizeQuery(req.query?.engine || "google").toLowerCase() || "google"; const locale = normalizeQuery(req.query?.locale || "zh-CN") || "zh-CN"; - - if (!query) { - sendJson(res, 200, { - ok: true, - query: "", - roots: [], - suggestions: [], - stats: { - searchCount: 0, - contentCount: 0, - rootCount: 0 - } - }); - return; - } + const debug = req.query?.debug === "1" || req.query?.debug === "true"; try { - const payload = await getRemoteSuggestions({ query, engine, locale }); + const payload = await getRemoteSuggestions({ query, engine, locale, debug }); sendJson(res, 200, { ok: true, ...payload diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index cad25b2..54b9afe 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -1,16 +1,22 @@ # 当前状态 -更新日期:2026-04-03 +更新日期:2026-08-19 ## 当前项目状态 -项目已经是一个可直接打开使用的静态前端原型,核心 UI、视觉风格切换、状态持久化与基础交互都已经成型;同时已补上 Vercel Serverless 搜索联想接口,用于承接更接近浏览器 omnibox 的词根和联网联想。 +项目已经是一个可直接打开使用的静态前端原型,核心 UI、视觉风格切换、状态持久化与基础交互都已经成型;同时已补上 Vercel Serverless 搜索联想接口,并把联想逻辑推进到更接近 Chrome Omnibox 的“多 provider 召回 + canonical 去重 + 分数融合 + 来源配额”模型。 ## 已经稳定的部分 - 根目录静态文件可直接运行:`index.html`、`styles.css`、`script.js` +- Windows 桌面版 Chrome 使用悬浮半透明滚动条:不再保留白色轨道,滚动或鼠标靠近右侧时显示,静止后自动隐藏;Mac 和移动端仍使用系统滚动条 - 已接入桌面端搜索联想弹层,联想窗口沿用液态玻璃视觉 -- 搜索联想已支持“本地记录 + 词根扩展 + 联网建议 + 内容结果”的混合排序 +- 搜索联想已支持“本地历史行为 + 快捷入口 + 零输入常用入口 + 站点索引 + 实体索引 + 拼写容错 + 词根扩展 + 联网建议 + 内容结果”的混合排序 +- 前端已拆分 `recent` 与 `history`:`recent` 只展示 4 条,`history` 保留最多 48 条用于访问次数、最近访问时间和 provider 来源排序 +- 设置面板新增“联想调试”开关,打开后会在联想项下显示 provider、score 和主要排序 feature,方便确认排序模型是否生效 +- 输入任意非空 query 时,前端会立即生成 Chrome 式本地 query 扩展候选;本地兜底已经按查询意图分流,并会在远端候选足够时自动降到少量补位,未命中明确词域的通用兜底最多 3 条,避免不同 query 看起来套同一组模板 +- 联想面板已改成更接近 Chrome 的高密度白色宽列表:桌面聚焦时扩展到接近视口宽度、隐藏说明性头部、使用搜索图标行、提高候选数量上限 +- `/api/suggest` 已输出 `providerType`、`relevance`、扩展 stats 和 `omnibox-feature-ranker-v2` 模型标记,便于后续继续调权重 - Vercel 生产环境已可稳定返回真实联想结果,GitHub 与部署链路已打通 - 状态读写已有统一框架,避免新线程继续把持久化逻辑写散 - 已接入现成液态玻璃相关库,减少重复造轮子 @@ -19,7 +25,7 @@ ## 当前结构上的真实情况 - 仓库里有一个 `app/` 目录,但当前可用实现仍然以根目录静态文件为主 -- 新增 `api/` 目录承载 Vercel Serverless Functions,目前主要提供 `/api/suggest` +- `api/` 目录承载 Vercel Serverless Functions,目前主要提供 `/api/suggest`;候选召回和排序主体在 [`api/_lib/search-suggest.js`](/Volumes/HP%20P900/goole_ui_pro/api/_lib/search-suggest.js) - 线程之间的上下文之前主要依赖人工口头说明,缺少统一交接入口 - 本次已经开始补齐跨线程文档系统 @@ -27,6 +33,10 @@ - 任何新增持久化字段都要先走状态框架 - 搜索联想的前端降级路径要保留,远端接口失败时不能影响本地联想与基本搜索 +- 如果直接用 `file://` 打开 `index.html`,`/api/suggest` 后端联想不会运行;此时只能验证本地联想、历史行为和调试分数。需要后端 provider 时用 Vercel 生产环境或本地 serverless 开发环境。 +- Chrome 式联想现在依赖 providerType、priority/relevance、canonical key、provider quota 和显式 ranking feature;后续加新来源时应先作为 provider 接入,再进入统一 ranker +- `query_template` 只用于本地兜底和补位,不应使用固定全局后缀刷满候选列表;远端搜索引擎、站点、实体、历史和内容 provider 应优先决定主要联想内容 +- 用户行为信号通过 [`script.js`](/Volumes/HP%20P900/goole_ui_pro/script.js) 的 `history` 字段维护;不要把访问计数或时间戳散写到 DOM 或单独 localStorage key - 功能变更完成后,应同步更新文档中的“当前状态”和“下一步” - 如果一次线程涉及较复杂分析、试验或交接,应留下单独线程记录 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c50847d..4f8707c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -31,3 +31,75 @@ ### 参考 - [`STATE_FRAMEWORK.md`](/Volumes/HP%20P900/goole_ui_pro/STATE_FRAMEWORK.md) + +## 2026-04-16: 搜索联想向 Chrome Omnibox 的 provider/ranker 模型靠拢 + +### 决策 + +搜索联想不再按“本地数组 + 远端数组”直接拼接,而是把每个来源都转成带 `providerType`、`priority/relevance`、canonical key 和展示字段的候选项,再统一做去重、配额和排序。 + +### 原因 + +- Chrome Omnibox 的体验来自多来源召回和统一融合,而不是单一搜索引擎建议 +- 站点直达、实体词、拼写容错、词根扩展、内容结果和零输入推荐需要不同权重与来源配额 +- 后续接入点击反馈、访问频次、书签或更复杂模型时,需要稳定的候选结构 + +### 影响 + +- 后端 [`api/_lib/search-suggest.js`](/Volumes/HP%20P900/goole_ui_pro/api/_lib/search-suggest.js) 负责远端 provider、站点/实体/拼写/零输入候选和 API 级 ranker +- 前端 [`script.js`](/Volumes/HP%20P900/goole_ui_pro/script.js) 负责本地 provider、空输入推荐和远端候选的二次融合 +- 新增联想来源时,应先定义 providerType 和 quota/demotion,再进入统一 ranker + +## 2026-04-16: `recent` 只做展示,`history` 承担联想行为记忆 + +### 决策 + +新增隐藏持久化字段 `history`,用于保存最多 48 条搜索/访问行为;原有 `recent` 保持最多 4 条,只负责首页“最近浏览”展示。 + +### 原因 + +- Chrome 式排序需要访问次数、最近访问时间和上次 provider 来源,4 条展示数据不够做排序记忆 +- 首页 UI 不应该因为排序模型需要更多历史而变长 +- 清空最近浏览时,用户直觉上也应该清掉排序记忆 + +### 影响 + +- `addRecentEntry(entry)` 同时更新 `history` 和 `recent` +- `stateStore.clearRecent()` 同时清空两者 +- 本地搜索联想优先从 `history` 召回,只有旧用户没有 `history` 时才回退到 `recent` + +## 2026-04-16: Ranker 改为显式 feature vector + weights + +### 决策 + +前端本地 ranker 和后端 `/api/suggest` ranker 都先提取 feature vector,再通过 weights 计算最终分数;后端模型标记更新为 `omnibox-feature-ranker-v2`。 + +### 原因 + +- 搜索联想排序需要持续调参,显式 feature 比散落的加减分更容易理解和比较 +- 后续可以围绕 feature 做 A/B 权重实验、调试输出或轻量 ML rerank +- 前后端 ranker 使用同一类概念,避免一个地方是行为特征,另一个地方只是 opaque relevance + +### 影响 + +- 前端 `script.js` 中的本地联想排序现在经过 `createSearchSuggestionFeatureVector()` 和 `scoreSearchSuggestionFeatures()` +- 后端 `api/_lib/search-suggest.js` 中的远端联想排序现在经过 `createRankerFeatureVector()` 和 `scoreRankerFeatures()` +- 新增权重时应先进入 feature vector,再更新 weights,不要直接改排序处的加法表达式 + +## 2026-04-17: 本地 query 模板只做意图化兜底,不再固定铺满列表 + +### 决策 + +前端 `query_template` 不再对所有 query 使用同一组固定后缀;它会先匹配影视、硬件、开发、AI、Chrome、iOS 等意图词表,再按远端 provider 的候选数量决定补几条。未命中明确词域时,通用兜底最多保留 3 条。 + +### 原因 + +- 固定后缀会让不同搜索词看起来联想内容一模一样,违背 Chrome Omnibox 的“按当前输入召回”体验 +- 本地模板的价值是无网、接口慢或远端不足时补位,而不是替代搜索引擎和站点/实体 provider +- 查询意图词表比全局后缀更容易继续扩展和调试 + +### 影响 + +- `query_template` 的候选数量现在由 `getLocalQueryExpansionLimit()` 控制 +- 远端搜索引擎、实体、站点、历史和内容 provider 足够时,本地模板只留下少量补位 +- 后续扩充本地联想时应新增意图集或 provider,而不是恢复全局固定后缀 diff --git a/docs/NEXT_STEPS.md b/docs/NEXT_STEPS.md index 5d35a5e..5b60866 100644 --- a/docs/NEXT_STEPS.md +++ b/docs/NEXT_STEPS.md @@ -1,17 +1,22 @@ # 下一步 -更新日期:2026-04-03 +更新日期:2026-08-19 ## 高优先级 - 后续每次有实质变更时,同步更新 [`docs/CURRENT_STATUS.md`](/Volumes/HP%20P900/goole_ui_pro/docs/CURRENT_STATUS.md) 和 [`docs/WORK_LOG.md`](/Volumes/HP%20P900/goole_ui_pro/docs/WORK_LOG.md) - 新线程在开始改动前,先检查 [`docs/threads/`](/Volumes/HP%20P900/goole_ui_pro/docs/threads/README.md) 是否已经有同类任务的交接文档 +- 搜索联想继续迭代时,优先沿用 provider/ranker 模型,不要回退成前端或 API 里各自手写排序 +- 本地 `query_template` 只能作为远端不足时的兜底补位;继续调体验时优先扩充真实 provider 或意图词表,不要再用一组固定后缀铺满所有 query +- 下次能接触实体 Windows 设备时,复核悬浮滚动条在 Chrome 触控板、鼠标滚轮和 100%/125% 系统缩放下的滑块尺寸与拖拽手感 ## 建议下一批可继续推进的方向 - 把桌面端这套搜索联想补到移动端,并决定移动端弹层是跟随搜索框展开,还是改成全宽面板 -- 继续优化联想来源和排序,重点处理内容结果的相关性、摘要长度和不同搜索引擎之间的权重 -- 如果后续要长期保留 `api/`,可以补一份简单的接口说明,写清 `/api/suggest` 的输入、输出和降级策略 +- 继续优化 Chrome 式排序,下一步重点是用真实使用数据微调 feature weights、provider 动态配额和更稳定的 URL/title 特征 +- 可以在设置里补一个“清除搜索历史/排序记忆”的明确入口;当前“清空最近浏览”已经会同时清除隐藏 `history` +- 把站点/实体 catalog 从硬编码演进为可配置数据源,或者接入真正的历史记录/书签/站点索引 +- 如果后续要长期保留 `api/`,可以补一份简单的接口说明,写清 `/api/suggest` 的输入、输出、`providerType`、`relevance` 和降级策略 - 把当前主要页面模块梳理成更明确的区块说明,方便以后做局部重构 - 如果后续会反复做视觉实验,可按主题或功能建立更细的线程交接文件 - 若项目未来转向构建型工程,可补一份“迁移路线”文档,避免根目录静态实现和新结构并行失控 diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 3abf731..b56c1ea 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -4,6 +4,51 @@ --- +## 2026-08-19 + +### 主题 + +把 Windows Chrome 的页面滚动条改为 macOS 式悬浮滚动条 + +### 完成内容 + +- 仅在 Windows 桌面版 Chrome 隐藏占位式原生滚动条,Mac、移动端和其他浏览器保持原行为 +- 新增半透明圆角悬浮滑块,不占页面布局宽度 +- 滚动或鼠标靠近窗口右侧时显示,停止交互约 0.9 秒后自动淡出 +- 保留轨道点击、滑块拖拽、滚轮和键盘滚动能力 + +### 验证 + +- 已通过 `node --check script.js` +- 已通过 `git diff --check` +- 已用 Playwright 模拟 Windows Chrome:原生滚动条宽度为 `0px`,无页面 gutter,滚动/靠边显示、自动隐藏和滑块拖拽均生效 +- 已用独立 Mac 会话确认自定义滚动条保持禁用,不覆盖 macOS 系统滚动条 + +--- + +## 2026-04-17 + +### 主题 + +修正本地 query 模板导致不同搜索词联想内容相同的问题 + +### 完成内容 + +- 前端 `query_template` 从固定后缀列表改为按查询意图分流 +- 为影视、硬件、开发、AI、Chrome、iOS 等常见词域配置不同扩展词表 +- 远端 provider 返回足够候选时,本地模板会自动收缩为 1 到 3 条补位;远端不足时才承担完整兜底 +- 未命中明确词域的通用兜底最多保留 3 条,避免普通 query 继续被同一组泛化模板刷屏 +- “黑寡妇”等特定 query 不再只套用通用“电影/演员/百科/图片/视频”模板,而会混合蜘蛛、演员、键盘、版本、英文等更贴近真实联想的候选 + +### 验证 + +- 已通过 `node --check script.js` +- 已通过 `node --check api/_lib/search-suggest.js` +- 已通过 `node --check api/suggest.js` +- 已通过 `git diff --check` + +--- + ## 2026-04-03 ### 主题 @@ -59,3 +104,125 @@ - 把同一套联想体验补到移动端 - 继续调排序策略,减少内容结果过重或词根结果过早冒头的情况 - 视需要补一份 `/api/suggest` 的接口说明,方便后续线程继续接 + +--- + +## 2026-04-16 + +### 主题 + +搜索联想向 Chrome Omnibox 风格的 provider/ranker 架构推进 + +### 完成内容 + +- 重构 [`api/_lib/search-suggest.js`](/Volumes/HP%20P900/goole_ui_pro/api/_lib/search-suggest.js),新增站点索引、实体索引、拼写容错、零输入推荐、provider 配额、canonical 去重和 relevance 排序 +- 更新 [`api/suggest.js`](/Volumes/HP%20P900/goole_ui_pro/api/suggest.js),让空输入也能通过同一套后端模型返回零输入候选 +- 更新 [`script.js`](/Volumes/HP%20P900/goole_ui_pro/script.js),把前端本地最近记录、快捷入口、常用入口和远端候选统一进本地 ranker 二次融合 +- 更新联想弹层标题逻辑,能区分零输入、拼写容错、站点/实体、词根和内容联想 +- 追加关键决策与当前状态文档 + +### 验证 + +- 已通过 `node --check api/_lib/search-suggest.js` +- 已通过 `node --check api/suggest.js` +- 已通过 `node --check script.js` +- 已用 Node 直接调用 `/api/suggest` handler 验证空输入、`goole` 拼写容错和 `chrome 地址栏` 实体联想 + +### 下一步建议 + +- 接入真实点击反馈、访问频次和最近访问时间,让排序从规则权重进一步走向可学习模型 +- 把硬编码站点/实体 catalog 抽成可配置数据或真实索引 +- 为 `/api/suggest` 补接口说明,固定 `providerType`、`relevance`、`stats` 的兼容约定 + +--- + +## 2026-04-16 + +### 主题 + +为 Chrome 式联想补本地行为信号 + +### 完成内容 + +- 新增隐藏持久化字段 `history`,最多保留 48 条搜索/访问行为 +- 保留 `recent` 作为首页展示列表,最多 4 条;`history` 用于联想排序记忆 +- `addRecentEntry(entry)` 现在会记录 `visitCount`、`firstVisitedAt`、`lastVisitedAt`、`lastProviderType`、`lastBadge` 和 `lastQuery` +- 本地联想 ranker 已把访问频次、最近访问时间和 provider affinity 纳入分数 +- 搜索提交、网址直达、联想点击、快捷入口点击都会记录来源 provider +- `stateStore.clearRecent()` 现在会同时清空可见最近记录和隐藏排序历史 + +### 验证 + +- 已通过 `node --check script.js` + +### 下一步建议 + +- 增加设置项文案,明确“清空最近浏览”也会清除排序记忆 +- 若继续靠近 Chrome,可把 `history` 的行为信号导出为更明确的 feature 对象,便于之后接入 ML rerank 或 A/B 权重实验 + +--- + +## 2026-04-16 + +### 主题 + +把联想排序改成显式 feature vector + +### 完成内容 + +- 前端本地 ranker 新增 `createSearchSuggestionFeatureVector()` 和 `scoreSearchSuggestionFeatures()` +- 后端 suggest ranker 新增 `createRankerFeatureVector()` 和 `scoreRankerFeatures()` +- 后端模型标记从 `omnibox-provider-ranker-v1` 升级为 `omnibox-feature-ranker-v2` +- 排序分数不再散落为临时加减法,而是由 base priority、文本命中、导航加分、零输入加分、频次、最近访问、provider affinity、provider demotion、typo penalty 等 feature 组合而成 + +### 下一步建议 + +- 做一个开发态 debug 开关,临时显示每条候选的 feature breakdown,方便肉眼调权重 +- 根据真实使用一两天后的 `history` 数据,微调 feature weights 和 provider quotas + +--- + +## 2026-04-16 + +### 主题 + +补上联想排序的可见调试入口 + +### 完成内容 + +- 设置面板新增“联想调试”开关 +- 前端联想项在调试开启时显示 `score`、`providerType` 和主要 feature breakdown +- `/api/suggest` 支持 `debug=1`,后端会返回 `debugFeatures` 和 `debugScore` +- 补充说明:直接用 `file://` 打开页面时,后端 `/api/suggest` 不会运行,只能验证本地联想与本地排序 + +### 验证 + +- 已通过 `node --check api/_lib/search-suggest.js` +- 已通过 `node --check api/suggest.js` +- 已通过 `node --check script.js` +- 已用 `getRemoteSuggestions({ debug: true })` 验证后端返回 `debugFeatures` + +--- + +## 2026-04-16 + +### 主题 + +把联想面板从“结果卡片”改成真正的候选列表 + +### 完成内容 + +- 输入非空 query 时,前端会立即生成本地 query 扩展候选,不再依赖 `/api/suggest` 才能出现多条联想 +- 搜索候选第一条改为“query - 搜索引擎 搜索”的 omnibox 行 +- 联想上限提高到 10 条,本地候选上限提高到 8 条 +- 新增 `query_template` providerType,并接入本地 ranker 的 quota/demotion +- 联想面板视觉改为更接近 Chrome 的白色高密度列表,隐藏说明性 header,搜索项使用放大镜图标,网址/内容项保留方形缩略占位 +- 桌面聚焦时搜索区会扩展到接近视口宽度,让联想面板从小卡片变成 omnibox 式宽列表 + +### 验证 + +- 已通过 `node --check script.js` +- 已通过 `node --check api/_lib/search-suggest.js` +- 已通过 `node --check api/suggest.js` +- 已通过 `git diff --check` +- 已用本地静态服务 + Playwright/Chrome 验证输入“黑寡妇”时返回 9 条候选,并生成截图 `/tmp/liquid-tab-suggest-wide2.png` diff --git a/docs/threads/2026-04-16-chrome-omnibox-suggest.md b/docs/threads/2026-04-16-chrome-omnibox-suggest.md new file mode 100644 index 0000000..a2d44dd --- /dev/null +++ b/docs/threads/2026-04-16-chrome-omnibox-suggest.md @@ -0,0 +1,69 @@ +# 2026-04-16 Chrome Omnibox 搜索联想升级 + +## 背景 + +用户希望搜索联想继续往 Chrome 的方向做。前一版已经有本地记录、快捷入口、词根扩展、搜索引擎联想和 Wikipedia 内容结果,但整体仍偏“数组拼接 + priority 排序”。 + +## 本次方向 + +把联想逻辑整理成更像 Chrome Omnibox 的多 provider 融合: + +- provider 召回:本地记录、快捷入口、常用入口、站点索引、实体索引、拼写容错、搜索引擎、内容结果、词根扩展 +- canonical 去重:URL 去掉 hash/search、域名去 `www.`;实体用 `entityId`;搜索项用规范化文本 +- 排序融合:每条候选有 `providerType` 和 `priority/relevance` +- 来源配额:避免 Wikipedia、搜索引擎或本地记录单一路径刷屏 +- 零输入:前端优先展示最近/快捷/常用,后端 API 也支持空 query 返回常用站点 + +## 改动文件 + +- [`api/_lib/search-suggest.js`](/Volumes/HP%20P900/goole_ui_pro/api/_lib/search-suggest.js) +- [`api/suggest.js`](/Volumes/HP%20P900/goole_ui_pro/api/suggest.js) +- [`script.js`](/Volumes/HP%20P900/goole_ui_pro/script.js) +- [`docs/CURRENT_STATUS.md`](/Volumes/HP%20P900/goole_ui_pro/docs/CURRENT_STATUS.md) +- [`docs/NEXT_STEPS.md`](/Volumes/HP%20P900/goole_ui_pro/docs/NEXT_STEPS.md) +- [`docs/DECISIONS.md`](/Volumes/HP%20P900/goole_ui_pro/docs/DECISIONS.md) +- [`docs/WORK_LOG.md`](/Volumes/HP%20P900/goole_ui_pro/docs/WORK_LOG.md) + +## 验证 + +- `node --check api/_lib/search-suggest.js` +- `node --check api/suggest.js` +- `node --check script.js` +- Node 直接调用 handler: + - 空输入返回 `zero` provider 的常用站点 + - `goole` 返回 `spell` provider 的 Google + - `chrome 地址栏` 返回实体 `Chrome Omnibox` 和词根扩展 + +## 后续接手提示 + +已经补上第一版真实行为信号: + +- `history` 最多保留 48 条搜索/访问行为 +- `recent` 仍只展示首页 4 条 +- `visitCount`、`lastVisitedAt`、`lastProviderType` 会影响本地联想排序 +- 搜索提交、网址直达、联想点击、快捷入口点击都会记录 provider 来源 +- 前后端 ranker 已拆成 feature vector + weights;后端模型标记为 `omnibox-feature-ranker-v2` +- 设置面板已新增“联想调试”,打开后能看到每条候选的 score/provider/features +- `/api/suggest?debug=1` 会返回后端 `debugFeatures` +- 输入非空 query 时,前端会立即生成 `query_template` 本地扩展候选,所以即使 API 不可用也会有多条联想 +- 联想面板视觉已改为 Chrome 式白色高密度列表,搜索项使用放大镜图标,说明性 header 默认隐藏 +- 2026-04-17 追加:`query_template` 已从固定全局后缀改为意图化兜底,并会根据远端 provider 数量动态收缩;未命中明确词域时最多保留 3 条通用兜底,不要再让同一组模板铺满所有 query + +注意:如果直接用 `file://.../index.html` 打开页面,后端 `/api/suggest` 不会运行,调试时只能看到本地联想和本地二次排序。要验证站点/实体/拼写等后端 provider,需要走 Vercel 生产环境或本地 serverless 环境。 + +下一步如果继续追 Chrome 味道,可以继续加强这些信号: + +- 最近访问时间 +- 访问频次 +- 点击过的 suggestion 类型 +- 书签/固定站点 +- 是否从当前输入命中过 URL/title/entity + +这些信号应先转成候选特征,不要直接在 UI 渲染处硬改排序。 + +## Ranker 接手提示 + +- 前端入口:`createSearchSuggestionFeatureVector()`、`scoreSearchSuggestionFeatures()` +- 后端入口:`createRankerFeatureVector()`、`scoreRankerFeatures()` +- 新增排序因素时,先新增 feature,再在 weights 里调权重。 +- 肉眼验证入口:设置 -> 联想调试 -> 显示分数。 diff --git a/index.html b/index.html index 4c6c724..9635e9b 100644 --- a/index.html +++ b/index.html @@ -285,6 +285,15 @@

裁切壁纸

+
+

联想调试

+
+ + +
+

打开后,联想项会显示 provider、score 和主要排序特征,用来确认 Chrome 式排序确实在工作。

+
+