class MainPanel extends HTMLElement {
connectedCallback() {
if (this._initialized) return;
this._initialized = true;
this.className = 'main-panel';
this.innerHTML = `
<div id="panel-search" class="panel panel-search">
<div class="search-container">
<input type="text" id="query" class="search-input" placeholder="知識を検索..." autocomplete="off">
<button onclick="search()" 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 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>
</div>
<div id="panel-docs" class="panel panel-docs hidden">
<h2 style="font-family:Outfit; margin-bottom:12px;">文書管理</h2>
<div class="docs-toolbar">
<button type="button" id="docs-add-btn" class="secondary-btn">新規登録</button>
<button type="button" id="docs-refresh-btn" class="secondary-btn">一覧更新</button>
</div>
<div id="docs-list" class="docs-list">
<div class="empty-state">一覧を読み込み中...</div>
</div>
<div id="docs-edit-modal" class="docs-edit-modal hidden">
<div class="docs-edit-modal-backdrop"></div>
<div class="docs-edit-modal-box">
<div id="docs-form-area" class="docs-form-area">
<fieldset class="settings-fieldset">
<legend id="docs-form-legend">新規登録</legend>
<div class="setting-row">
<label for="docs-path">パス (path)</label>
<input type="text" id="docs-path" placeholder="例: my-note.md" class="docs-input">
</div>
<div class="setting-row docs-import-row">
<label>ファイル</label>
<div class="docs-import-controls">
<input type="file" id="docs-file-input" accept=".txt,.md,.json,.csv,.html,.xml,.log,text/plain,text/markdown,text/html,application/json" aria-label="ファイルを選択">
<button type="button" id="docs-import-file-btn" class="secondary-btn">ファイルを取り込む</button>
<span id="docs-import-feedback" class="docs-import-feedback"></span>
</div>
</div>
<div class="setting-row">
<label for="docs-editor-container">本文</label>
<div id="docs-editor-container" class="docs-editor-container"></div>
</div>
<div class="setting-actions">
<button type="button" id="docs-save-btn" class="secondary-btn">保存</button>
<button type="button" id="docs-cancel-btn" class="secondary-btn">キャンセル</button>
<span id="docs-form-feedback" class="settings-feedback"></span>
</div>
</fieldset>
</div>
</div>
</div>
</div>
<div id="panel-settings" class="panel panel-settings hidden">
<h2 style="font-family:Outfit; margin-bottom:12px;">設定</h2>
<div class="settings-form">
<fieldset class="settings-fieldset">
<legend>検索</legend>
<div class="setting-row">
<label for="setting-min-score">スコア足切り (0〜1)</label>
<input type="number" id="setting-min-score" min="0" max="1" step="0.05" value="0.3" title="この値未満の類似度の結果は表示しません">
</div>
<div class="setting-row">
<label for="setting-limit">取得件数</label>
<input type="number" id="setting-limit" min="1" max="100" value="10" title="検索で返す最大件数">
</div>
<div class="setting-actions">
<button type="button" id="settings-save-btn" class="secondary-btn">保存</button>
<span id="settings-feedback" class="settings-feedback" aria-live="polite"></span>
</div>
</fieldset>
</div>
</div>
`;
// Handle navigation events from sidebar
document.addEventListener('navigate-panel', (e) => {
const target = String(e.detail || 'search');
this.showPanel(target);
});
// Expose showPanel as method
const SETTINGS_KEY = 'telosdb_settings';
const DEFAULTS = { min_score: 0.3, limit: 10 };
const loadSettingsIntoForm = () => {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
const s = raw ? { ...DEFAULTS, ...JSON.parse(raw) } : DEFAULTS;
const minScoreEl = this.querySelector('#setting-min-score');
const limitEl = this.querySelector('#setting-limit');
if (minScoreEl) minScoreEl.value = String(Number(s.min_score));
if (limitEl) limitEl.value = String(Number(s.limit));
} catch (e) {
const minScoreEl = this.querySelector('#setting-min-score');
const limitEl = this.querySelector('#setting-limit');
if (minScoreEl) minScoreEl.value = String(DEFAULTS.min_score);
if (limitEl) limitEl.value = String(DEFAULTS.limit);
}
};
this.showPanel = (panelId) => {
const panels = this.querySelectorAll('.panel');
panels.forEach(p => p.classList.add('hidden'));
switch (panelId) {
case 'docs':
this.querySelector('#panel-docs').classList.remove('hidden');
break;
case 'settings':
this.querySelector('#panel-settings').classList.remove('hidden');
loadSettingsIntoForm();
break;
default:
this.querySelector('#panel-search').classList.remove('hidden');
}
};
// default show search
this.showPanel('search');
const saveBtn = this.querySelector('#settings-save-btn');
const feedbackEl = this.querySelector('#settings-feedback');
if (saveBtn && feedbackEl) {
saveBtn.addEventListener('click', () => {
const minScoreEl = this.querySelector('#setting-min-score');
const limitEl = this.querySelector('#setting-limit');
const min_score = Math.max(0, Math.min(1, parseFloat(minScoreEl?.value) || DEFAULTS.min_score));
const limit = Math.max(1, Math.min(100, parseInt(limitEl?.value, 10) || DEFAULTS.limit));
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ min_score, limit }));
if (minScoreEl) minScoreEl.value = String(min_score);
if (limitEl) limitEl.value = String(limit);
feedbackEl.textContent = '保存しました';
feedbackEl.classList.remove('error');
setTimeout(() => { feedbackEl.textContent = ''; }, 2000);
} catch (e) {
feedbackEl.textContent = '保存に失敗しました';
feedbackEl.classList.add('error');
}
});
}
// Activity Log accordion behavior
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;
activityToggle.querySelector('.accordion-toggle').textContent = '▸';
} else {
activityContent.hidden = false;
activityToggle.querySelector('.accordion-toggle').textContent = '▾';
}
});
}
// --- 文書管理パネル ---
const API_BASE = typeof window !== 'undefined' && window.API_BASE ? window.API_BASE : 'http://127.0.0.1:3001';
const callMcp = async (method, params = {}) => {
const res = await fetch(`${API_BASE}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method,
params,
id: Date.now(),
}),
});
const data = await res.json();
if (data.error) throw new Error(data.error.message || JSON.stringify(data.error));
return data.result;
};
const parseResultText = (result) => {
const content = result?.content;
if (!content || !Array.isArray(content) || content.length === 0) return null;
if (content[0].type === 'text' && content[0].text) {
try { return JSON.parse(content[0].text); } catch (_) { return content[0].text; }
}
return null;
};
let editingDocumentId = null;
let docsEditorInstance = null;
const docsListEl = this.querySelector('#docs-list');
const docsEditModal = this.querySelector('#docs-edit-modal');
const docsFormArea = this.querySelector('#docs-form-area');
const docsPathEl = this.querySelector('#docs-path');
const docsEditorContainer = this.querySelector('#docs-editor-container');
const docsFormLegend = this.querySelector('#docs-form-legend');
const docsFormFeedback = this.querySelector('#docs-form-feedback');
const docsFileInput = this.querySelector('#docs-file-input');
const docsImportFileBtn = this.querySelector('#docs-import-file-btn');
const docsImportFeedback = this.querySelector('#docs-import-feedback');
const loadDocsList = async () => {
if (!docsListEl) return;
docsListEl.innerHTML = '<div class="empty-state">一覧を読み込み中...</div>';
try {
const result = await callMcp('list_documents', {});
const list = parseResultText(result);
if (!Array.isArray(list)) {
docsListEl.innerHTML = '<div class="empty-state">一覧の取得に失敗しました</div>';
return;
}
if (list.length === 0) {
docsListEl.innerHTML = '<div class="empty-state">登録された文書はありません</div>';
return;
}
docsListEl.innerHTML = `
<table class="docs-table">
<thead>
<tr><th>パス</th><th>MIME</th><th>チャンク数</th><th>操作</th></tr>
</thead>
<tbody>
${list.map(d => `
<tr>
<td class="docs-cell-path">${escapeHtml(d.path || '')}</td>
<td>${escapeHtml(d.mime || '')}</td>
<td>${Number(d.chunk_count) || 0}</td>
<td class="docs-actions">
<button type="button" class="docs-btn docs-btn-edit" data-id="${escapeHtml(String(d.id))}">編集</button>
<button type="button" class="docs-btn docs-btn-delete" data-id="${escapeHtml(String(d.id))}">削除</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
docsListEl.querySelectorAll('.docs-btn-edit').forEach(btn => {
btn.addEventListener('click', () => openDocEdit(btn.dataset.id));
});
docsListEl.querySelectorAll('.docs-btn-delete').forEach(btn => {
btn.addEventListener('click', () => deleteDoc(btn.dataset.id));
});
} catch (e) {
docsListEl.innerHTML = `<div class="empty-state error-state">エラー: ${escapeHtml(e.message)}</div>`;
}
};
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
const destroyDocsEditor = () => {
if (docsEditorInstance) {
try { docsEditorInstance.destroy(); } catch (_) {}
docsEditorInstance = null;
}
};
const createDocsEditor = (initialValue = '') => {
if (!docsEditorContainer) return;
destroyDocsEditor();
if (typeof toastui === 'undefined' || !toastui.Editor) {
docsEditorContainer.innerHTML = '<p class="docs-editor-fallback">Toast UI Editor を読み込んでください。npm run build-editor を実行し、vendor/toast-ui/ にバンドルを生成してください。</p>';
return;
}
docsEditorInstance = new toastui.Editor({
el: docsEditorContainer,
initialValue: initialValue || '',
height: '400px',
initialEditType: 'wysiwyg',
previewStyle: 'tab',
usageStatistics: false,
theme: 'dark',
});
};
const getDocsEditorMarkdown = () => docsEditorInstance ? docsEditorInstance.getMarkdown() : '';
const setDocsEditorMarkdown = (markdown) => { if (docsEditorInstance) docsEditorInstance.setMarkdown(markdown || ''); };
const showDocsForm = (legend, path = '', content = '', documentId = null) => {
editingDocumentId = documentId;
if (docsFormLegend) docsFormLegend.textContent = legend;
if (docsPathEl) docsPathEl.value = path;
if (docsFormFeedback) { docsFormFeedback.textContent = ''; docsFormFeedback.classList.remove('error'); }
if (docsEditModal) docsEditModal.classList.remove('hidden');
createDocsEditor(content);
};
const hideDocsForm = () => {
destroyDocsEditor();
editingDocumentId = null;
if (docsEditModal) docsEditModal.classList.add('hidden');
loadDocsList();
};
const openDocEdit = async (id) => {
const docId = typeof id === 'string' ? parseInt(id, 10) : id;
if (!Number.isFinite(docId)) {
if (docsFormFeedback) docsFormFeedback.textContent = '無効な文書IDです';
return;
}
showDocsForm('編集', '', '読み込み中...', docId);
try {
const result = await callMcp('get_document', { document_id: docId });
const doc = parseResultText(result);
if (doc && typeof doc === 'object') {
if (docsPathEl) docsPathEl.value = doc.path || '';
setDocsEditorMarkdown(doc.content != null ? String(doc.content) : '');
if (docsFormFeedback) { docsFormFeedback.textContent = ''; docsFormFeedback.classList.remove('error'); }
} else {
if (docsFormFeedback) docsFormFeedback.textContent = '文書の取得に失敗しました';
}
} catch (e) {
if (docsFormFeedback) docsFormFeedback.textContent = 'エラー: ' + e.message;
if (docsFormFeedback) docsFormFeedback.classList.add('error');
}
};
const deleteDoc = async (id) => {
const docId = typeof id === 'string' ? parseInt(id, 10) : id;
if (!Number.isFinite(docId)) {
alert('無効な文書IDです');
return;
}
if (!confirm('この文書を削除しますか?')) return;
try {
await callMcp('delete_document', { document_id: docId });
loadDocsList();
if (typeof window !== 'undefined' && window.updateDocCount) window.updateDocCount();
} catch (e) {
alert('削除に失敗しました: ' + e.message);
}
};
this.querySelector('#docs-add-btn')?.addEventListener('click', () => {
showDocsForm('新規登録', '', '');
});
this.querySelector('#docs-refresh-btn')?.addEventListener('click', () => {
loadDocsList();
});
this.querySelector('#docs-save-btn')?.addEventListener('click', async () => {
const path = docsPathEl?.value?.trim();
const content = getDocsEditorMarkdown();
if (!path) {
if (docsFormFeedback) { docsFormFeedback.textContent = 'パスを入力してください'; docsFormFeedback.classList.add('error'); }
return;
}
if (docsFormFeedback) { docsFormFeedback.textContent = '保存中...'; docsFormFeedback.classList.remove('error'); }
try {
if (editingDocumentId) {
await callMcp('delete_document', { document_id: editingDocumentId });
}
await callMcp('add_item_text', { path, content });
if (docsFormFeedback) { docsFormFeedback.textContent = '保存しました'; docsFormFeedback.classList.remove('error'); }
setTimeout(() => hideDocsForm(), 600);
if (typeof window !== 'undefined' && window.updateDocCount) window.updateDocCount();
} catch (e) {
if (docsFormFeedback) { docsFormFeedback.textContent = 'エラー: ' + e.message; docsFormFeedback.classList.add('error'); }
}
});
this.querySelector('#docs-cancel-btn')?.addEventListener('click', hideDocsForm);
docsFileInput?.addEventListener('change', () => {
const file = docsFileInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const text = typeof reader.result === 'string' ? reader.result : '';
if (!docsPathEl?.value?.trim()) docsPathEl.value = file.name;
setDocsEditorMarkdown(text);
if (docsImportFeedback) {
docsImportFeedback.textContent = `「${escapeHtml(file.name)}」を読み込みました`;
docsImportFeedback.classList.remove('error');
setTimeout(() => { docsImportFeedback.textContent = ''; }, 3000);
}
docsFileInput.value = '';
};
reader.onerror = () => {
if (docsImportFeedback) {
docsImportFeedback.textContent = 'ファイルの読み込みに失敗しました';
docsImportFeedback.classList.add('error');
}
docsFileInput.value = '';
};
reader.readAsText(file, 'UTF-8');
});
docsImportFileBtn?.addEventListener('click', () => docsFileInput?.click());
const backdrop = this.querySelector('.docs-edit-modal-backdrop');
backdrop?.addEventListener('click', hideDocsForm);
const originalShowPanel = this.showPanel;
this.showPanel = (panelId) => {
originalShowPanel.call(this, panelId);
if (panelId === 'docs') loadDocsList();
};
}
}
customElements.define('main-panel', MainPanel);
export { };