// saas-search Frontend - Modern UI (Multi-Tenant)
// API 基础地址策略(优先级从高到低):
// 1. window.API_BASE_URL:若在 HTML / 部署层显式注入,则直接使用(可为绝对 URL 或相对路径,如 "/api")
// 2. 默认:空前缀(同源调用),由 6003 前端服务在服务端转发到本机 6002
//
// 说明:在“外网 web 服务器对 6002 另加认证”的场景下,
// 浏览器不应直接调用 web:6002,而应调用同源 web:6003/search/*,
// 再由 frontend_server.py 代理到 GPU 机本地 6002。
(function initApiBaseUrl() {
let base;
if (window.API_BASE_URL) {
base = window.API_BASE_URL;
} else {
base = '';
}
window.API_BASE_URL = base;
const apiUrlEl = document.getElementById('apiUrl');
if (apiUrlEl) {
apiUrlEl.textContent = base || '(same-origin)';
}
})();
const API_BASE_URL = window.API_BASE_URL;
// Get tenant ID from select
function getTenantId() {
const tenantSelect = document.getElementById('tenantSelect');
if (tenantSelect) {
return tenantSelect.value.trim();
}
return '';
}
// Get sku_filter_dimension (as list) from input
function getSkuFilterDimension() {
const skuFilterInput = document.getElementById('skuFilterDimension');
if (skuFilterInput) {
const value = skuFilterInput.value.trim();
if (!value.length) {
return null;
}
// 支持用逗号分隔多个维度,例如:color,size 或 option1,color
const parts = value.split(',').map(v => v.trim()).filter(v => v.length > 0);
return parts.length > 0 ? parts : null;
}
return null;
}
// State Management
let state = {
query: '',
currentPage: 1,
pageSize: 20,
totalResults: 0,
filters: {},
rangeFilters: {},
sortBy: '',
sortOrder: 'desc',
facets: null,
lastSearchData: null,
debug: true // Always enable debug mode for test frontend
};
// Initialize
function initializeApp() {
// 初始化租户下拉框和分面面板
console.log('Initializing app...');
initTenantSelect();
setupProductGridResultDocToggle();
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.focus();
}
}
/** Delegated handler: toggle inline current result JSON under each result card (survives innerHTML refresh on re-search). */
function setupProductGridResultDocToggle() {
const grid = document.getElementById('productGrid');
if (!grid || grid.dataset.resultDocToggleBound === '1') {
return;
}
grid.dataset.resultDocToggleBound = '1';
grid.addEventListener('click', onProductGridResultDocToggleClick);
}
function onProductGridResultDocToggleClick(event) {
const btn = event.target.closest('[data-action="toggle-result-inline-doc"]');
if (!btn) {
return;
}
event.preventDefault();
const debugRoot = btn.closest('.product-debug');
if (!debugRoot) {
return;
}
const panel = debugRoot.querySelector('.product-result-doc-panel');
const pre = debugRoot.querySelector('.product-result-doc-pre');
if (!panel || !pre) {
return;
}
if (debugRoot.dataset.resultInlineOpen === '1') {
panel.setAttribute('hidden', '');
debugRoot.classList.remove('product-debug--result-expanded');
debugRoot.dataset.resultInlineOpen = '0';
btn.textContent = '在结果中显示当前结果数据';
return;
}
panel.removeAttribute('hidden');
debugRoot.classList.add('product-debug--result-expanded');
debugRoot.dataset.resultInlineOpen = '1';
btn.textContent = '隐藏当前结果数据';
if (pre.textContent.length === 0) {
pre.textContent = btn.getAttribute('data-result-json') || '{}';
}
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// 在 DOM 加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeApp);
} else {
// DOM 已经加载完成,直接执行
initializeApp();
}
// 备用初始化:如果上面的初始化失败,在 window.onload 时再试一次
window.addEventListener('load', function() {
const tenantList = document.getElementById('tenantList');
if (tenantList && tenantList.options.length === 0) {
console.log('Retrying tenant select initialization on window.load...');
initTenantSelect();
}
});
// 最后尝试:延迟执行,确保所有脚本都已加载
setTimeout(function() {
const tenantList = document.getElementById('tenantList');
if (tenantList && tenantList.options.length === 0) {
console.log('Final retry: Initializing tenant select after delay...');
if (typeof getAvailableTenantIds === 'function') {
initTenantSelect();
} else {
console.error('getAvailableTenantIds still not available after delay');
}
}
}, 100);
// Keyboard handler
function handleKeyPress(event) {
if (event.key === 'Enter') {
performSearch();
}
}
// 初始化租户输入框(带 162/170 等候选,可自行填写任意 tenant ID)
function initTenantSelect() {
const tenantSelect = document.getElementById('tenantSelect');
const tenantList = document.getElementById('tenantList');
if (!tenantSelect || !tenantList) {
console.error('tenantSelect or tenantList element not found');
return;
}
// 检查函数是否可用
if (typeof getAvailableTenantIds !== 'function') {
console.error('getAvailableTenantIds function not found. Make sure tenant_facets_config.js is loaded before app.js');
return;
}
const availableTenants = getAvailableTenantIds();
console.log('Available tenants:', availableTenants);
// 清空 datalist 现有选项
tenantList.innerHTML = '';
if (availableTenants && availableTenants.length > 0) {
availableTenants.forEach(tenantId => {
const option = document.createElement('option');
option.value = tenantId;
tenantList.appendChild(option);
});
// 设置默认值(仅当输入框为空时)
if (!tenantSelect.value.trim()) {
tenantSelect.value = availableTenants.includes('0') ? '0' : availableTenants[0];
}
}
// 初始化分面面板
renderFacetsPanel();
}
// 租户ID改变时的处理
function onTenantIdChange() {
renderFacetsPanel();
// 清空当前的分面数据
clearFacetsData();
}
// 根据当前 tenant_id 渲染分面面板结构
function renderFacetsPanel() {
const tenantId = getTenantId();
const config = getTenantFacetsConfig(tenantId);
const container = document.getElementById('specificationFacetsContainer');
if (!container) return;
// 清空现有规格分面
container.innerHTML = '';
// 为每个规格字段创建分面行
config.specificationFields.forEach(specField => {
const row = document.createElement('div');
row.className = 'filter-row';
row.setAttribute('data-facet-field', specField.field);
row.innerHTML = `
${escapeHtml(specField.label)}:
`;
container.appendChild(row);
});
}
// 清空所有分面数据(保留结构)
function clearFacetsData() {
const allTagContainers = document.querySelectorAll('.filter-tags');
allTagContainers.forEach(container => {
container.innerHTML = '';
});
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Perform search
async function performSearch(page = 1) {
const query = document.getElementById('searchInput').value.trim();
const tenantId = getTenantId();
const skuFilterDimension = getSkuFilterDimension();
if (!query) {
alert('Please enter search keywords');
return;
}
if (!tenantId) {
alert('Please enter tenant ID');
return;
}
state.query = query;
state.currentPage = page;
state.pageSize = parseInt(document.getElementById('resultSize').value);
const from = (page - 1) * state.pageSize;
// Define facets
// 使用 disjunctive 控制分面行为:
// - 类目(category1/2/3)使用标准模式(disjunctive: false),用于层级下钻
// - 规格(color/size/material)使用 Multi-Select 模式(disjunctive: true)
const facets = [];
// 一级类目分面(始终存在,用于顶层类目导航)
facets.push({
field: "category1_name",
size: 15,
type: "terms",
disjunctive: false
});
// 如果已选择一级类目,则在该过滤条件下请求二级类目分面
if (state.filters.category1_name && state.filters.category1_name.length > 0) {
facets.push({
field: "category2_name",
size: 15,
type: "terms",
disjunctive: false
});
}
// 如果已选择二级类目,则在进一步过滤条件下请求三级类目分面
if (state.filters.category2_name && state.filters.category2_name.length > 0) {
facets.push({
field: "category3_name",
size: 15,
type: "terms",
disjunctive: false
});
}
// 规格相关分面(Multi-Select 模式)
// 根据 tenant_id 使用不同的配置
const tenantFacetsConfig = getTenantFacetsConfig(tenantId);
tenantFacetsConfig.specificationFields.forEach(specField => {
// 只发送查询参数,不包含显示相关的配置(label, containerId)
facets.push({
field: specField.field,
size: specField.size,
type: specField.type,
disjunctive: specField.disjunctive
});
});
// Show loading
document.getElementById('loading').style.display = 'block';
document.getElementById('productGrid').innerHTML = '';
try {
const response = await fetch(`${API_BASE_URL}/search/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenantId,
},
body: JSON.stringify({
query: query,
size: state.pageSize,
from: from,
filters: Object.keys(state.filters).length > 0 ? state.filters : null,
range_filters: Object.keys(state.rangeFilters).length > 0 ? state.rangeFilters : null,
facets: facets,
sort_by: state.sortBy || null,
sort_order: state.sortOrder,
sku_filter_dimension: skuFilterDimension,
// 测试前端始终开启后端调试信息
debug: true
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
state.lastSearchData = data;
state.totalResults = data.total;
state.facets = data.facets;
displayResults(data);
displayFacets(data.facets);
displayPagination();
displayDebugInfo(data);
updateProductCount(data.total);
updateClearFiltersButton();
} catch (error) {
console.error('Search error:', error);
document.getElementById('productGrid').innerHTML = `
Search Error: ${error.message}
Please ensure backend service is running (${API_BASE_URL})
`;
} finally {
document.getElementById('loading').style.display = 'none';
}
}
// Display results in grid
function displayResults(data) {
const grid = document.getElementById('productGrid');
if (!data.results || data.results.length === 0) {
grid.innerHTML = `
No Results Found
Try different keywords or filters
`;
return;
}
let html = '';
// Build per-SPU debug lookup from debug_info.per_result (if present)
let perResultDebugBySpu = {};
if (state.debug && data.debug_info && Array.isArray(data.debug_info.per_result)) {
data.debug_info.per_result.forEach((item) => {
if (item && item.spu_id) {
perResultDebugBySpu[String(item.spu_id)] = item;
}
});
}
const tenantId = getTenantId();
data.results.forEach((result) => {
const product = result;
const title = product.title || product.name || 'N/A';
const price = product.min_price || product.price || 'N/A';
const imageUrl = product.image_url || product.imageUrl || '';
const category = product.category || product.categoryName || '';
const vendor = product.vendor || product.brandName || '';
const spuId = product.spu_id || '';
const debug = spuId ? perResultDebugBySpu[String(spuId)] : null;
let debugHtml = '';
if (debug) {
debugHtml = buildProductDebugHtml({
debug,
result,
spuId,
tenantId,
});
}
html += `
${imageUrl ? `
})
` : `
No Image
`}
${price !== 'N/A' ? `¥${price}` : 'N/A'}
${escapeHtml(title)}
${debug ? buildMultilingualFieldsHtml(debug) : ''}
${category ? escapeHtml(category) : ''}
${vendor ? ' | ' + escapeHtml(vendor) : ''}
${debugHtml}
`;
});
grid.innerHTML = html;
}
function formatDebugNumber(value, digits = 4) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toFixed(digits);
}
return value == null || value === '' ? 'N/A' : String(value);
}
function renderMetricList(items) {
const lines = items
.filter((item) => item && item.value !== undefined && item.value !== null && item.value !== '')
.map(
(item) => `
${escapeHtml(item.label)}:${escapeHtml(String(item.value))}
`
)
.join('');
return lines ? `${lines}
` : '';
}
function renderScorePills(items) {
const pills = items
.filter((item) => item && item.value !== undefined && item.value !== null && item.value !== '')
.map((item) => `
${escapeHtml(item.label)}
${escapeHtml(String(item.value))}
`)
.join('');
return pills ? `${pills}
` : '';
}
function renderJsonDetails(title, payload, open = false) {
if (!payload || (typeof payload === 'object' && Object.keys(payload).length === 0)) {
return '';
}
return `
${escapeHtml(title)}
${escapeHtml(customStringify(payload))}
`;
}
/** Multilingual title/brief/vendor from per-result debug; shown under image/price/title on the left. */
function buildMultilingualFieldsHtml(debug) {
if (!debug || typeof debug !== 'object') {
return '';
}
const titlePayload = {};
if (debug.title_multilingual) titlePayload.title = debug.title_multilingual;
if (debug.brief_multilingual) titlePayload.brief = debug.brief_multilingual;
if (debug.vendor_multilingual) titlePayload.vendor = debug.vendor_multilingual;
if (Object.keys(titlePayload).length === 0) {
return '';
}
return `${renderJsonDetails('Multilingual Fields', titlePayload, true)}
`;
}
function buildProductDebugHtml({ debug, result, spuId, tenantId }) {
const resultJson = customStringify(result);
const rawUrl = `${API_BASE_URL}/search/es-doc/${encodeURIComponent(spuId)}?tenant_id=${encodeURIComponent(tenantId)}`;
const funnel = debug.ranking_funnel || {};
const esStage = funnel.es_recall || {};
const coarseStage = funnel.coarse_rank || {};
const fineStage = funnel.fine_rank || {};
const rerankStage = funnel.rerank || {};
const finalPageStage = funnel.final_page || {};
const rankSummary = renderMetricList([
{ label: 'Initial Rank', value: debug.initial_rank ?? 'N/A' },
{ label: 'Final Rank', value: debug.final_rank ?? 'N/A' },
{ label: 'Rank Delta', value: (debug.initial_rank && debug.final_rank) ? String(debug.initial_rank - debug.final_rank) : 'N/A' },
{ label: 'SPU', value: spuId || 'N/A' },
]);
const stageScores = renderScorePills([
{ label: 'ES', value: formatDebugNumber(esStage.score ?? debug.es_score), tone: 'tone-es' },
{ label: 'ES Norm', value: formatDebugNumber(esStage.normalized_score ?? debug.es_score_normalized), tone: 'tone-neutral' },
{ label: 'Coarse', value: formatDebugNumber(coarseStage.score ?? debug.coarse_score), tone: 'tone-coarse' },
{ label: 'Fine', value: formatDebugNumber(fineStage.score ?? debug.fine_score), tone: 'tone-fine' },
{ label: 'Rerank', value: formatDebugNumber(rerankStage.rerank_score ?? debug.rerank_score), tone: 'tone-rerank' },
{ label: 'Fused', value: formatDebugNumber(rerankStage.fused_score ?? debug.fused_score), tone: 'tone-final' },
]);
const stageGrid = `
${buildStageCard('ES Recall', 'Matched queries and ES raw score', [
{ label: 'rank', value: esStage.rank ?? debug.initial_rank ?? 'N/A' },
{ label: 'es_score', value: formatDebugNumber(esStage.score ?? debug.es_score) },
{ label: 'es_norm', value: formatDebugNumber(esStage.normalized_score ?? debug.es_score_normalized) },
], renderJsonDetails('Matched Queries', esStage.matched_queries ?? debug.matched_queries, true))}
${buildStageCard('Coarse Rank', 'Text + vector fusion', [
{ label: 'rank', value: coarseStage.rank ?? 'N/A' },
{ label: 'rank_change', value: coarseStage.rank_change ?? 'N/A' },
{ label: 'coarse_score', value: formatDebugNumber(coarseStage.score ?? debug.coarse_score) },
{ label: 'text_score', value: formatDebugNumber(coarseStage.text_score ?? debug.text_score) },
{ label: 'text_source', value: formatDebugNumber(coarseStage.signals?.text_source_score ?? debug.text_source_score) },
{ label: 'text_translation', value: formatDebugNumber(coarseStage.signals?.text_translation_score ?? debug.text_translation_score) },
{ label: 'text_primary', value: formatDebugNumber(coarseStage.signals?.text_primary_score ?? debug.text_primary_score) },
{ label: 'text_support', value: formatDebugNumber(coarseStage.signals?.text_support_score ?? debug.text_support_score) },
{ label: 'knn_score', value: formatDebugNumber(coarseStage.knn_score ?? debug.knn_score) },
{ label: 'text_knn', value: formatDebugNumber(coarseStage.signals?.text_knn_score ?? debug.text_knn_score) },
{ label: 'image_knn', value: formatDebugNumber(coarseStage.signals?.image_knn_score ?? debug.image_knn_score) },
{ label: 'text_factor', value: formatDebugNumber(coarseStage.text_factor ?? debug.coarse_text_factor) },
{ label: 'knn_factor', value: formatDebugNumber(coarseStage.knn_factor ?? debug.coarse_knn_factor) },
], renderJsonDetails('Coarse Signals', coarseStage.signals, true))}
${buildStageCard('Fine Rank', 'Lightweight reranker output', [
{ label: 'rank', value: fineStage.rank ?? 'N/A' },
{ label: 'rank_change', value: fineStage.rank_change ?? 'N/A' },
{ label: 'stage_score', value: formatDebugNumber(fineStage.score ?? debug.score) },
{ label: 'fine_score', value: formatDebugNumber(fineStage.fine_score ?? debug.fine_score) },
{ label: 'text_score', value: formatDebugNumber(fineStage.text_score ?? debug.text_score) },
{ label: 'knn_score', value: formatDebugNumber(fineStage.knn_score ?? debug.knn_score) },
], `${renderJsonDetails('Fine Fusion', fineStage.fusion_summary || debug.fusion_summary || fineStage.fusion_factors, true)}${renderJsonDetails('Fine Input', fineStage.rerank_input ?? debug.rerank_input, true)}`)}
${buildStageCard('Final Rerank', 'Heavy reranker + final fusion', [
{ label: 'rank', value: rerankStage.rank ?? finalPageStage.rank ?? debug.final_rank ?? 'N/A' },
{ label: 'rank_change', value: rerankStage.rank_change ?? finalPageStage.rank_change ?? 'N/A' },
{ label: 'stage_score', value: formatDebugNumber(rerankStage.score ?? rerankStage.fused_score ?? debug.score) },
{ label: 'rerank_score', value: formatDebugNumber(rerankStage.rerank_score ?? debug.rerank_score) },
{ label: 'fine_score', value: formatDebugNumber(rerankStage.fine_score ?? debug.fine_score) },
{ label: 'text_score', value: formatDebugNumber(rerankStage.text_score ?? debug.text_score) },
{ label: 'knn_score', value: formatDebugNumber(rerankStage.knn_score ?? debug.knn_score) },
{ label: 'fine_factor', value: formatDebugNumber(rerankStage.fine_factor ?? debug.fine_factor) },
{ label: 'rerank_factor', value: formatDebugNumber(rerankStage.rerank_factor ?? debug.rerank_factor) },
{ label: 'text_factor', value: formatDebugNumber(rerankStage.text_factor ?? debug.text_factor) },
{ label: 'knn_factor', value: formatDebugNumber(rerankStage.knn_factor ?? debug.knn_factor) },
{ label: 'fused_score', value: formatDebugNumber(rerankStage.fused_score ?? debug.fused_score) },
], `${renderJsonDetails('Final Fusion', rerankStage.fusion_summary || debug.fusion_summary || rerankStage.fusion_factors, false)}${renderJsonDetails('Rerank Signals', rerankStage.signals, false)}`)}
`;
return `
Ranking Funnel
${rankSummary}
${stageScores}
${stageGrid}
${renderJsonDetails('Selected SKU', debug.style_intent_sku, true)}
`;
}
// Display facets as filter tags (一级分类 + 三个属性分面)
function displayFacets(facets) {
if (!facets || !Array.isArray(facets)) {
return;
}
const tenantId = getTenantId();
facets.forEach((facet) => {
// 根据配置获取分面显示信息
const displayConfig = getFacetDisplayConfig(tenantId, facet.field);
if (!displayConfig) {
// 如果没有配置,跳过该分面
return;
}
const containerId = displayConfig.containerId;
const maxDisplay = displayConfig.maxDisplay;
const container = document.getElementById(containerId);
if (!container) {
return;
}
// 检查values是否存在且是数组
if (!facet.values || !Array.isArray(facet.values) || facet.values.length === 0) {
container.innerHTML = '';
return;
}
let html = '';
// 渲染分面值
facet.values.slice(0, maxDisplay).forEach((facetValue) => {
if (!facetValue || typeof facetValue !== 'object') {
return;
}
const value = facetValue.value;
const count = facetValue.count;
// 允许value为0或空字符串,但不允许undefined/null
if (value === undefined || value === null) {
return;
}
// 检查是否已选中
let selected = false;
if (facet.field.startsWith('specifications.')) {
// 检查specifications过滤
const specName = facet.field.split('.')[1];
if (state.filters.specifications) {
const specs = Array.isArray(state.filters.specifications)
? state.filters.specifications
: [state.filters.specifications];
selected = specs.some(spec => spec && spec.name === specName && spec.value === value);
}
} else {
// 检查普通字段过滤
if (state.filters[facet.field]) {
selected = state.filters[facet.field].includes(value);
}
}
html += `
${escapeHtml(String(value))} (${count || 0})
`;
});
container.innerHTML = html;
});
}
// Toggle filter (支持specifications嵌套过滤)
function toggleFilter(field, value) {
// 处理specifications属性过滤 (specifications.color, specifications.size, specifications.material)
if (field.startsWith('specifications.')) {
const specName = field.split('.')[1]; // 提取name (color, size, material)
// 初始化specifications过滤
if (!state.filters.specifications) {
state.filters.specifications = [];
}
// 确保是数组格式
if (!Array.isArray(state.filters.specifications)) {
// 如果已经是单个对象,转换为数组
state.filters.specifications = [state.filters.specifications];
}
// 查找是否已存在相同的name和value组合
const existingIndex = state.filters.specifications.findIndex(
spec => spec.name === specName && spec.value === value
);
if (existingIndex > -1) {
// 移除
state.filters.specifications.splice(existingIndex, 1);
if (state.filters.specifications.length === 0) {
delete state.filters.specifications;
} else if (state.filters.specifications.length === 1) {
// 如果只剩一个,可以保持为数组,或转换为单个对象(API都支持)
// 这里保持为数组,更一致
}
} else {
// 添加
state.filters.specifications.push({ name: specName, value: value });
}
} else {
// 处理普通字段过滤 (category1_name 等)
// 对类目字段使用互斥单选 + 层级重置逻辑
const isCategoryField =
field === 'category1_name' ||
field === 'category2_name' ||
field === 'category3_name';
if (isCategoryField) {
// 点击已选中的类目 -> 取消该层和以下层级
const currentValues = state.filters[field] || [];
const isAlreadySelected = currentValues.includes(value);
if (isAlreadySelected) {
delete state.filters[field];
if (field === 'category1_name') {
delete state.filters.category2_name;
delete state.filters.category3_name;
} else if (field === 'category2_name') {
delete state.filters.category3_name;
}
} else {
// 选择新的类目值:该层级单选,下级全部重置
state.filters[field] = [value];
if (field === 'category1_name') {
delete state.filters.category2_name;
delete state.filters.category3_name;
} else if (field === 'category2_name') {
delete state.filters.category3_name;
}
}
} else {
// 其他普通字段维持原有多选行为
if (!state.filters[field]) {
state.filters[field] = [];
}
const index = state.filters[field].indexOf(value);
if (index > -1) {
state.filters[field].splice(index, 1);
if (state.filters[field].length === 0) {
delete state.filters[field];
}
} else {
state.filters[field].push(value);
}
}
}
performSearch(1); // Reset to page 1
}
// Handle price filter (重构版 - 使用 rangeFilters)
function handlePriceFilter(value) {
if (!value) {
delete state.rangeFilters.min_price;
} else {
const priceRanges = {
'0-50': { lt: 50 },
'50-100': { gte: 50, lt: 100 },
'100-200': { gte: 100, lt: 200 },
'200+': { gte: 200 }
};
if (priceRanges[value]) {
state.rangeFilters.min_price = priceRanges[value];
}
}
performSearch(1);
}
// Handle time filter (重构版 - 使用 rangeFilters)
function handleTimeFilter(value) {
if (!value) {
delete state.rangeFilters.create_time;
} else {
const now = new Date();
let fromDate;
switch(value) {
case 'today':
fromDate = new Date(now.setHours(0, 0, 0, 0));
break;
case 'week':
fromDate = new Date(now.setDate(now.getDate() - 7));
break;
case 'month':
fromDate = new Date(now.setMonth(now.getMonth() - 1));
break;
case '3months':
fromDate = new Date(now.setMonth(now.getMonth() - 3));
break;
case '6months':
fromDate = new Date(now.setMonth(now.getMonth() - 6));
break;
}
if (fromDate) {
state.rangeFilters.create_time = {
gte: fromDate.toISOString()
};
}
}
performSearch(1);
}
// Clear all filters
function clearAllFilters() {
state.filters = {};
state.rangeFilters = {};
document.getElementById('priceFilter').value = '';
document.getElementById('timeFilter').value = '';
performSearch(1);
}
// Update clear filters button visibility
function updateClearFiltersButton() {
const btn = document.getElementById('clearFiltersBtn');
if (Object.keys(state.filters).length > 0 || Object.keys(state.rangeFilters).length > 0) {
btn.style.display = 'inline-block';
} else {
btn.style.display = 'none';
}
}
// Update product count
function updateProductCount(total) {
document.getElementById('productCount').textContent = `${total.toLocaleString()} SPUs found`;
}
// Sort functions
function setSortByDefault() {
// Remove active from all buttons and arrows
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.arrow-up, .arrow-down').forEach(a => a.classList.remove('active'));
// Set default button active
const defaultBtn = document.querySelector('.sort-btn[data-sort=""]');
if (defaultBtn) defaultBtn.classList.add('active');
state.sortBy = '';
state.sortOrder = 'desc';
performSearch(1);
}
function sortByField(field, order) {
state.sortBy = field;
state.sortOrder = order;
// Remove active from all buttons (but keep "By default" if no sort)
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
// Remove active from all arrows
document.querySelectorAll('.arrow-up, .arrow-down').forEach(a => a.classList.remove('active'));
// Add active to clicked arrow
const activeArrow = document.querySelector(`.arrow-up[data-field="${field}"][data-order="${order}"], .arrow-down[data-field="${field}"][data-order="${order}"]`);
if (activeArrow) {
activeArrow.classList.add('active');
}
performSearch(state.currentPage);
}
// Pagination
function displayPagination() {
const paginationDiv = document.getElementById('pagination');
if (state.totalResults <= state.pageSize) {
paginationDiv.style.display = 'none';
return;
}
paginationDiv.style.display = 'flex';
const totalPages = Math.ceil(state.totalResults / state.pageSize);
const currentPage = state.currentPage;
let html = `
`;
// Page numbers
const maxVisible = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2));
let endPage = Math.min(totalPages, startPage + maxVisible - 1);
if (endPage - startPage < maxVisible - 1) {
startPage = Math.max(1, endPage - maxVisible + 1);
}
if (startPage > 1) {
html += ``;
if (startPage > 2) {
html += `...`;
}
}
for (let i = startPage; i <= endPage; i++) {
html += `
`;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
html += `...`;
}
html += ``;
}
html += `
`;
html += `
Page ${currentPage} of ${totalPages} (${state.totalResults.toLocaleString()} results)
`;
paginationDiv.innerHTML = html;
}
function goToPage(page) {
const totalPages = Math.ceil(state.totalResults / state.pageSize);
if (page < 1 || page > totalPages) return;
performSearch(page);
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
}
/** Query-analysis intent block: dimensions, matched surface form, canonical value, source query variant. */
function formatIntentDetectionHtml(intent) {
const profile = intent || null;
let block = 'intent_detection:
';
if (!profile || typeof profile !== 'object') {
block += '(no intent payload — style intent may be disabled or context missing)
';
return block;
}
const active = !!profile.active;
block += `active: ${active ? 'yes' : 'no'}
`;
const intents = Array.isArray(profile.intents) ? profile.intents : [];
if (!intents.length) {
block += 'intents: (none — no vocabulary match on query variants)
';
return block;
}
block += 'intents:
';
for (const it of intents) {
const aliases = Array.isArray(it.dimension_aliases) ? it.dimension_aliases.join(', ') : '';
block += '- ';
block += `
intent_type: ${escapeHtml(it.intent_type || '')}
`;
block += `dimension_aliases: ${escapeHtml(aliases || 'N/A')}
`;
block += `matched_term: ${escapeHtml(it.matched_term || '')}
`;
block += `canonical_value: ${escapeHtml(it.canonical_value || '')}
`;
block += `matched_query_text: ${escapeHtml(it.matched_query_text || '')}
`;
block += ' ';
}
block += '
';
if (Array.isArray(profile.query_variants) && profile.query_variants.length > 0) {
block += 'query_variants:
';
block += `${escapeHtml(customStringify(profile.query_variants))}`;
}
return block;
}
function buildStageCard(title, subtitle, metrics, extraHtml = '') {
return `
${escapeHtml(title)}
${subtitle ? `
${escapeHtml(subtitle)}
` : ''}
${renderMetricList(metrics)}
${extraHtml}
`;
}
function renderTimingBars(stageTimings) {
if (!stageTimings || typeof stageTimings !== 'object') {
return '';
}
const orderedStages = [
'query_parsing',
'query_building',
'elasticsearch_search_primary',
'coarse_ranking',
'style_sku_prepare_hits',
'fine_ranking',
'reranking',
'elasticsearch_page_fill',
'result_processing',
'total_search',
];
const entries = Object.entries(stageTimings)
.sort((a, b) => {
const ai = orderedStages.indexOf(a[0]);
const bi = orderedStages.indexOf(b[0]);
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
});
const total = Number(stageTimings.total_search || 0);
return `
${entries.map(([stage, duration]) => {
const numeric = Number(duration) || 0;
const width = total > 0 ? Math.max(2, Math.round((numeric / total) * 100)) : 2;
return `
${escapeHtml(stage)}
${numeric.toFixed(2)}ms
`;
}).join('')}
`;
}
function buildGlobalFunnelHtml(data, debugInfo) {
const queryAnalysis = debugInfo.query_analysis || {};
const searchParams = debugInfo.search_params || {};
const featureFlags = debugInfo.feature_flags || {};
const esResponse = debugInfo.es_response || {};
const esQueryContext = debugInfo.es_query_context || {};
const rankingFunnel = debugInfo.ranking_funnel || {};
const coarseInfo = rankingFunnel.coarse_rank || debugInfo.coarse_rank || {};
const fineInfo = rankingFunnel.fine_rank || debugInfo.fine_rank || {};
const rerankInfo = rankingFunnel.rerank || debugInfo.rerank || {};
const translations = queryAnalysis.translations || {};
const summaryHtml = `
Query Context
${renderMetricList([
{ label: 'original_query', value: queryAnalysis.original_query || 'N/A' },
{ label: 'rewritten_query', value: queryAnalysis.rewritten_query || 'N/A' },
{ label: 'detected_language', value: queryAnalysis.detected_language || 'N/A' },
{ label: 'index_languages', value: (queryAnalysis.index_languages || []).join(', ') || 'N/A' },
{ label: 'query_tokens', value: (queryAnalysis.query_tokens || []).join(', ') || 'N/A' },
{ label: 'translation_enabled', value: featureFlags.translation_enabled ? 'enabled' : 'disabled' },
{ label: 'embedding_enabled', value: featureFlags.embedding_enabled ? 'enabled' : 'disabled' },
{ label: 'style_intent_active', value: featureFlags.style_intent_active ? 'yes' : 'no' },
])}
${Object.keys(translations).length ? renderJsonDetails('Translations', translations, true) : ''}
${formatIntentDetectionHtml(queryAnalysis.intent_detection ?? queryAnalysis.style_intent_profile)}
`;
const funnelHtml = `
Ranking Funnel
${buildStageCard('ES Recall', 'First-pass retrieval', [
{ label: 'fetch_from', value: searchParams.es_fetch_from ?? 0 },
{ label: 'fetch_size', value: searchParams.es_fetch_size ?? 'N/A' },
{ label: 'total_hits', value: esResponse.total_hits ?? 'N/A' },
{ label: 'es_took_ms', value: esResponse.took_ms ?? 'N/A' },
{ label: 'include_named_queries_score', value: esQueryContext.include_named_queries_score ? 'yes' : 'no' },
])}
${buildStageCard('Coarse Rank', 'Lexical + vector fusion only', [
{ label: 'docs_in', value: coarseInfo.docs_in ?? searchParams.es_fetch_size ?? 'N/A' },
{ label: 'docs_out', value: coarseInfo.docs_out ?? 'N/A' },
{ label: 'formula', value: 'text x knn' },
], coarseInfo.fusion ? renderJsonDetails('Coarse Fusion', coarseInfo.fusion, false) : '')}
${buildStageCard('Fine Rank', 'Lightweight reranker', [
{ label: 'service_url', value: fineInfo.service_url || 'N/A' },
{ label: 'docs_in', value: fineInfo.docs_in ?? 'N/A' },
{ label: 'docs_out', value: fineInfo.docs_out ?? fineInfo.top_n ?? 'N/A' },
{ label: 'top_n', value: fineInfo.top_n ?? 'N/A' },
{ label: 'backend', value: fineInfo.backend || 'N/A' },
{ label: 'model', value: fineInfo.model || fineInfo.backend_model_name || 'N/A' },
{ label: 'query_template', value: fineInfo.query_template || 'N/A' },
], fineInfo.meta ? renderJsonDetails('Fine Meta', fineInfo.meta, false) : '')}
${buildStageCard('Final Rerank', 'Heavy reranker + final fusion', [
{ label: 'service_url', value: rerankInfo.service_url || 'N/A' },
{ label: 'docs_in', value: rerankInfo.docs_in ?? 'N/A' },
{ label: 'docs_out', value: rerankInfo.docs_out ?? 'N/A' },
{ label: 'top_n', value: rerankInfo.top_n ?? 'N/A' },
{ label: 'backend', value: rerankInfo.backend || 'N/A' },
{ label: 'model', value: rerankInfo.model || rerankInfo.backend_model_name || 'N/A' },
{ label: 'query_template', value: rerankInfo.query_template || 'N/A' },
], `${rerankInfo.fusion ? renderJsonDetails('Final Fusion', rerankInfo.fusion, false) : ''}${rerankInfo.meta ? renderJsonDetails('Rerank Meta', rerankInfo.meta, false) : ''}`)}
${buildStageCard('Page Return', 'Final slice returned to UI', [
{ label: 'from', value: searchParams.from_ ?? 0 },
{ label: 'size', value: searchParams.size ?? 'N/A' },
{ label: 'returned', value: (data.results || []).length },
{ label: 'max_score', value: formatDebugNumber(esResponse.max_score, 3) },
])}
`;
const timingHtml = `
Timing Breakdown
${renderTimingBars(debugInfo.stage_timings)}
`;
const rawPayloadHtml = `
Raw Payloads
${renderJsonDetails('ES Query DSL', debugInfo.es_query, false)}
${renderJsonDetails('ES Query Context', debugInfo.es_query_context, false)}
${renderJsonDetails('Search Params', debugInfo.search_params, false)}
`;
return `
${summaryHtml}
${funnelHtml}
${timingHtml}
${rawPayloadHtml}
`;
}
// Display debug info
function displayDebugInfo(data) {
const debugInfoDiv = document.getElementById('debugInfo');
if (!state.debug || !data.debug_info) {
if (data.query_info) {
debugInfoDiv.innerHTML = `
Query Context
${renderMetricList([
{ label: 'original_query', value: data.query_info.original_query || 'N/A' },
{ label: 'detected_language', value: data.query_info.detected_language || 'N/A' },
])}
`;
} else {
debugInfoDiv.innerHTML = '';
}
return;
}
debugInfoDiv.innerHTML = buildGlobalFunnelHtml(data, data.debug_info);
}
// Custom JSON stringify that compresses numeric arrays (like embeddings) to single line
function customStringify(obj) {
return JSON.stringify(obj, (key, value) => {
if (Array.isArray(value)) {
// Only collapse arrays that contain numbers (like embeddings)
if (value.every(item => typeof item === 'number')) {
return JSON.stringify(value);
}
}
return value;
}, 2).replace(/"\[/g, '[').replace(/\]"/g, ']');
}
// Helper functions
function escapeAttr(text) {
if (!text) return '';
return text.replace(/'/g, "\\'").replace(/"/g, '"');
}
function formatDate(dateStr) {
if (!dateStr) return '';
try {
const date = new Date(dateStr);
return date.toLocaleDateString('zh-CN');
} catch {
return dateStr;
}
}