import { getApiBase, SETTINGS_KEY, DEFAULTS, escapeHtml } from '../js/shared.js';
class SearchPanel extends HTMLElement {
connectedCallback() {
if (this._initialized) return;
this._initialized = true;
this.className = 'panel panel-search';
this.id = 'panel-search';
this.innerHTML = `
<div class="search-container">
<input type="text" id="query" class="search-input" placeholder="知識を検索..." autocomplete="off">
<select id="search-category-filter" class="search-category-filter" title="カテゴリで絞り込み">
<option value="">すべて</option>
</select>
<button type="button" class="search-btn">検索</button>
</div>
<div id="result" class="results-panel">
<div class="empty-state">クエリを入力して検索を開始してください</div>
</div>
<div class="sidebar-bottom" style="margin-top: auto; border-top: 1px solid var(--border-base);">
<div class="accordion">
<button class="accordion-header activity-toggle" aria-expanded="false" style="padding: 12px 16px;">
MCP ACTIVITY <span id="activity-count" style="font-weight:normal; font-size: 0.65rem; margin-left: 8px;">LISTEN</span>
<span class="accordion-toggle" style="margin-left: auto;">▸</span>
</button>
<div class="accordion-content activity-content" hidden style="padding: 0 16px 16px 16px;">
<div id="connection-error-hint" class="connection-error-hint" style="display:none; margin-bottom:8px; padding:8px 10px; background: rgba(255,193,7,0.12); border: 1px solid var(--border-subtle); border-radius:6px; font-size:0.8rem; color: var(--text-secondary);">
バックエンドの起動を待っています。しばらくお待ちください。
</div>
<div class="activity-log" style="height: 140px; margin-top: 0; background: rgba(255,255,255,0.02); border-color: var(--border-subtle);">
<div id="activity-items"></div>
</div>
</div>
</div>
</div>
`;
const queryEl = this.querySelector('#query');
const resultPanel = this.querySelector('#result');
const searchBtn = this.querySelector('.search-btn');
searchBtn?.addEventListener('click', () => this.search());
const activityToggle = this.querySelector('.activity-toggle');
const activityContent = this.querySelector('.activity-content');
if (activityToggle && activityContent) {
activityToggle.addEventListener('click', () => {
const expanded = activityToggle.getAttribute('aria-expanded') === 'true';
activityToggle.setAttribute('aria-expanded', String(!expanded));
if (expanded) {
activityContent.hidden = true;
const toggleSpan = activityToggle.querySelector('.accordion-toggle');
if (toggleSpan) toggleSpan.textContent = '▸';
} else {
activityContent.hidden = false;
const toggleSpan = activityToggle.querySelector('.accordion-toggle');
if (toggleSpan) toggleSpan.textContent = '▾';
}
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target.id === 'query') this.search();
});
}
async search() {
const queryEl = this.querySelector('#query');
const resultPanel = this.querySelector('#result');
if (!queryEl || !resultPanel) return;
const query = queryEl.value;
if (!query.trim()) return;
const settings = (() => {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
const def = { min_score: DEFAULTS.min_score, limit: DEFAULTS.limit, run_on_login: DEFAULTS.run_on_login };
return raw ? { ...def, ...JSON.parse(raw) } : def;
} catch (e) {
return { min_score: DEFAULTS.min_score, limit: DEFAULTS.limit };
}
})();
resultPanel.innerHTML = '<div class="empty-state">検索中...</div>';
try {
const categoryFilter = this.querySelector('#search-category-filter')?.value || '';
const searchParams = {
content: query,
limit: settings.limit,
min_score: settings.min_score,
};
if (categoryFilter) searchParams.category = categoryFilter;
const res = await fetch(`${getApiBase()}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'search_text',
params: searchParams,
id: 1,
}),
});
const data = await res.json();
let results = data.result?.content || [];
let vectorSearchUsed = null;
if (results.length === 1 && results[0].type === 'text') {
try {
const parsed = JSON.parse(results[0].text);
if (Array.isArray(parsed)) {
results = parsed;
} else if (parsed && Array.isArray(parsed.items)) {
vectorSearchUsed = parsed.vector_search_used === true;
results = parsed.items;
} else {
results = [];
}
} catch (e) {
results = [];
}
}
if (results.length === 0 || (results.length === 1 && results[0].isError)) {
const msg = results[0]?.text || '結果が見つかりませんでした';
resultPanel.innerHTML = `<div class="empty-state">${escapeHtml(msg)}</div>`;
return;
}
const currentEdition = typeof window !== 'undefined' ? window.currentEdition : 'community';
const searchHint = (() => {
if (currentEdition !== 'pro') return '';
if (vectorSearchUsed === true) {
return '<div class="search-hint search-hint-ok">意味検索(近似近傍)でランキングしています</div>';
}
if (vectorSearchUsed === false) {
return '<div class="search-hint search-hint-warn">キーワード一致のみです。RE-INDEX を実行すると意味検索が有効になります</div>';
}
return '';
})();
resultPanel.innerHTML = searchHint + results.map((r) => {
const id = r.id !== undefined ? String(r.id).padStart(4, '0') : '????';
const score = typeof r.similarity === 'number' ? r.similarity.toFixed(4) : 'N/A';
const content = r.content || r.text || '';
const category = r.category || '';
const categoryBadge = category ? `<span class="result-category-badge">${escapeHtml(category)}</span>` : '';
return `
<div class="result-card">
<div class="result-header">
<span>DOC_${escapeHtml(String(id))} ${categoryBadge}</span>
<span style="color:var(--accent-purple)">SCORE: ${escapeHtml(String(score))}</span>
</div>
<div class="result-content">${escapeHtml(content)}</div>
</div>
`;
}).join('');
} catch (e) {
resultPanel.innerHTML = `<div class="empty-state error-state">検索エラー: ${escapeHtml(e.message)}</div>`;
}
}
updateCategoryFilter() {
const select = this.querySelector('#search-category-filter');
if (!select) return;
const raw = localStorage.getItem(SETTINGS_KEY);
const s = raw ? JSON.parse(raw) : {};
const cats = new Set();
(s.monitor_paths || []).forEach((p) => {
const c = typeof p === 'object' ? (p.category || '') : '';
if (c) cats.add(c);
});
const current = select.value;
select.innerHTML = '<option value="">すべて</option>';
[...cats].sort().forEach((c) => {
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c;
select.appendChild(opt);
});
select.value = current;
}
}
customElements.define('search-panel', SearchPanel);
export {};