Newer
Older
TelosDB / src / frontend / components / search-panel.js
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 {};