mirror of
https://github.com/myronblair/jarvis
synced 2026-07-28 08:43:00 -05:00
feat: DB-driven KB topic manager — admin UI + migration
- Create kb_generator_topics table (140 topics migrated from hardcoded array) - kb_intent_generator.php now loads active topics from DB instead of $BATCHES array - Admin panel: MANAGE TOPICS button opens full topic manager modal - Browse all 140 topics with search, category, and active/disabled filters - Inline active toggle per topic (checkbox) - Add new topic form (topic_id, category, name, description) - Edit existing topics (all fields) - Delete topics with confirmation - Run count display - Backend API cases: kb_topics, kb_topic_save, kb_topic_delete, kb_topic_toggle - Topics can now be added/managed without any code changes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
This commit is contained in:
@@ -616,6 +616,57 @@ if ($action) {
|
||||
}
|
||||
j(['lines'=>$out, 'next_line'=>$total]);
|
||||
|
||||
// ── KB GENERATOR TOPICS ─────────────────────────────────────────────
|
||||
case 'kb_topics':
|
||||
$where = ['1=1']; $params = [];
|
||||
if (!empty($_GET['q'])) {
|
||||
$q2 = '%'.$_GET['q'].'%';
|
||||
$where[] = '(topic_name LIKE ? OR topic_id LIKE ? OR category LIKE ? OR description LIKE ?)';
|
||||
$params = array_merge($params, [$q2,$q2,$q2,$q2]);
|
||||
}
|
||||
if (isset($_GET['cat']) && $_GET['cat'] !== '') { $where[] = 'category=?'; $params[] = $_GET['cat']; }
|
||||
if (isset($_GET['active']) && $_GET['active'] !== '') { $where[] = 'active=?'; $params[] = (int)$_GET['active']; }
|
||||
$topics = JarvisDB::query(
|
||||
'SELECT id,topic_id,category,topic_name,description,active,run_count,last_run_at
|
||||
FROM kb_generator_topics WHERE '.implode(' AND ',$where).' ORDER BY category,topic_name',
|
||||
$params
|
||||
);
|
||||
$catRows = JarvisDB::query('SELECT DISTINCT category FROM kb_generator_topics ORDER BY category');
|
||||
j(['topics'=>$topics, 'categories'=>array_column($catRows,'category')]);
|
||||
|
||||
case 'kb_topic_save':
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$tid = preg_replace('/[^a-z0-9_]/', '_', strtolower(trim($_POST['topic_id'] ?? '')));
|
||||
$cat = trim($_POST['category'] ?? '');
|
||||
$name = trim($_POST['topic_name'] ?? '');
|
||||
$desc = trim($_POST['description'] ?? '');
|
||||
$act = (int)!empty($_POST['active']);
|
||||
if (!$tid||!$cat||!$name||!$desc) bad('All fields required');
|
||||
if ($id) {
|
||||
JarvisDB::execute(
|
||||
'UPDATE kb_generator_topics SET topic_id=?,category=?,topic_name=?,description=?,active=?,updated_at=NOW() WHERE id=?',
|
||||
[$tid,$cat,$name,$desc,$act,$id]
|
||||
);
|
||||
j(['ok'=>true,'msg'=>'Topic updated']);
|
||||
} else {
|
||||
JarvisDB::execute(
|
||||
'INSERT INTO kb_generator_topics (topic_id,category,topic_name,description,active) VALUES (?,?,?,?,?)',
|
||||
[$tid,$cat,$name,$desc,$act]
|
||||
);
|
||||
j(['ok'=>true,'msg'=>'Topic created']);
|
||||
}
|
||||
|
||||
case 'kb_topic_delete':
|
||||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||||
JarvisDB::execute('DELETE FROM kb_generator_topics WHERE id=?', [$id]);
|
||||
j(['ok'=>true]);
|
||||
|
||||
case 'kb_topic_toggle':
|
||||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||||
JarvisDB::execute('UPDATE kb_generator_topics SET active=NOT active, updated_at=NOW() WHERE id=?', [$id]);
|
||||
$row = JarvisDB::single('SELECT active FROM kb_generator_topics WHERE id=?', [$id]);
|
||||
j(['ok'=>true,'active'=>(bool)$row['active']]);
|
||||
|
||||
case 'arc_setup_log':
|
||||
$logFile = '/var/log/jarvis/arc-setup.log';
|
||||
$since = max(0, (int)($_GET['since'] ?? 0));
|
||||
@@ -1498,6 +1549,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
|
||||
<button class="btn btn-sm btn-green" onclick="intentModal()">+ ADD INTENT</button>
|
||||
<button class="btn btn-sm btn-yellow" onclick="intentTestModal()">TEST PATTERN</button>
|
||||
<button class="btn btn-sm" onclick="loadIntents()">REFRESH</button>
|
||||
<button class="btn btn-sm" style="border-color:#7f5fff;color:#7f5fff" onclick="openTopicsManager()">⚙ MANAGE TOPICS</button>
|
||||
<button class="btn btn-sm" style="border-color:#00d4ff;color:#00d4ff" onclick="runIntentGenerator()">▶ RUN GENERATOR</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2386,6 +2438,219 @@ async function runIntentGenerator() {
|
||||
setTimeout(pollLog, 2000);
|
||||
}
|
||||
|
||||
let _topicsData = [];
|
||||
let _topicsCats = [];
|
||||
|
||||
async function openTopicsManager() {
|
||||
const modalHtml = `
|
||||
<div style="font-family:var(--mono);font-size:0.72rem">
|
||||
<div id="tm-view-list">
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center;flex-wrap:wrap">
|
||||
<input id="tm-search" type="text" placeholder="Search topics…" style="flex:1;min-width:140px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit" oninput="tmFilter()">
|
||||
<select id="tm-cat-filter" style="background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit" onchange="tmFilter()">
|
||||
<option value="">ALL CATEGORIES</option>
|
||||
</select>
|
||||
<select id="tm-active-filter" style="background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit" onchange="tmFilter()">
|
||||
<option value="">ALL STATUS</option>
|
||||
<option value="1">ACTIVE</option>
|
||||
<option value="0">DISABLED</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-green" onclick="tmEditTopic(null)">+ ADD</button>
|
||||
</div>
|
||||
<div id="tm-stats" style="color:var(--text-dim);font-size:0.6rem;margin-bottom:6px;letter-spacing:0.5px"></div>
|
||||
<div id="tm-list" style="max-height:370px;overflow-y:auto"><div class="loading">LOADING…</div></div>
|
||||
</div>
|
||||
<div id="tm-view-edit" style="display:none">
|
||||
<div style="margin-bottom:12px">
|
||||
<button class="btn btn-sm" onclick="tmShowList()">← BACK</button>
|
||||
<span id="tm-edit-title" style="color:var(--cyan);margin-left:12px;font-size:0.65rem;letter-spacing:1px"></span>
|
||||
</div>
|
||||
<div style="display:grid;gap:8px">
|
||||
<input type="hidden" id="tm-edit-id">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<label style="font-size:0.6rem;color:var(--text-dim)">TOPIC ID (slug)
|
||||
<input id="tm-edit-tid" type="text" placeholder="e.g. math_arith" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;box-sizing:border-box">
|
||||
</label>
|
||||
<label style="font-size:0.6rem;color:var(--text-dim)">CATEGORY
|
||||
<input id="tm-edit-cat" type="text" placeholder="e.g. mathematics" list="tm-cat-dl" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;box-sizing:border-box">
|
||||
<datalist id="tm-cat-dl"></datalist>
|
||||
</label>
|
||||
</div>
|
||||
<label style="font-size:0.6rem;color:var(--text-dim)">TOPIC NAME
|
||||
<input id="tm-edit-name" type="text" placeholder="e.g. Arithmetic and number sense" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;box-sizing:border-box">
|
||||
</label>
|
||||
<label style="font-size:0.6rem;color:var(--text-dim)">DESCRIPTION (subtopics to cover — comma-separated)
|
||||
<textarea id="tm-edit-desc" rows="5" placeholder="e.g. fractions, decimals, order of operations, prime numbers…" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;resize:vertical;box-sizing:border-box"></textarea>
|
||||
</label>
|
||||
<label style="font-size:0.6rem;color:var(--text-dim);display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||
<input id="tm-edit-active" type="checkbox" checked style="cursor:pointer"> ACTIVE (included in rotation)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
openModal('⚙ KB TOPIC MANAGER', modalHtml, null, null);
|
||||
const saveBtn = document.getElementById('modalSave');
|
||||
const closeBtn = document.getElementById('modalClose');
|
||||
saveBtn.textContent = 'SAVE TOPIC';
|
||||
saveBtn.style.display = 'none';
|
||||
saveBtn.onclick = tmSaveTopic;
|
||||
closeBtn.onclick = closeModal;
|
||||
|
||||
await tmLoadTopics();
|
||||
}
|
||||
|
||||
async function tmLoadTopics() {
|
||||
const res = await api('kb_topics');
|
||||
_topicsData = res?.topics || [];
|
||||
_topicsCats = res?.categories || [];
|
||||
|
||||
const catSel = document.getElementById('tm-cat-filter');
|
||||
if (catSel) {
|
||||
const cur = catSel.value;
|
||||
while (catSel.options.length > 1) catSel.remove(1);
|
||||
_topicsCats.forEach(c => catSel.add(new Option(c.toUpperCase(), c)));
|
||||
if (cur) catSel.value = cur;
|
||||
}
|
||||
const dl = document.getElementById('tm-cat-dl');
|
||||
if (dl) { dl.innerHTML = ''; _topicsCats.forEach(c => { const o = document.createElement('option'); o.value=c; dl.appendChild(o); }); }
|
||||
|
||||
tmFilter();
|
||||
}
|
||||
|
||||
function tmFilter() {
|
||||
const q = (document.getElementById('tm-search')?.value || '').toLowerCase();
|
||||
const cat = document.getElementById('tm-cat-filter')?.value || '';
|
||||
const active = document.getElementById('tm-active-filter')?.value;
|
||||
|
||||
const rows = _topicsData.filter(t => {
|
||||
if (cat && t.category !== cat) return false;
|
||||
if (active !== '' && active !== null && active !== undefined && String(t.active) !== String(active)) return false;
|
||||
if (q && !`${t.topic_name} ${t.topic_id} ${t.category} ${t.description}`.toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const total = _topicsData.length;
|
||||
const activeCount = _topicsData.filter(t => t.active == 1).length;
|
||||
const statsEl = document.getElementById('tm-stats');
|
||||
if (statsEl) statsEl.innerHTML =
|
||||
`<span style="color:var(--cyan)">${total} topics</span> | ` +
|
||||
`<span style="color:var(--green)">${activeCount} active</span> | ` +
|
||||
`<span style="color:var(--yellow)">${total-activeCount} disabled</span>` +
|
||||
(rows.length < total ? ` | <span style="color:var(--text-dim)">${rows.length} shown</span>` : '');
|
||||
|
||||
const listEl = document.getElementById('tm-list');
|
||||
if (!listEl) return;
|
||||
if (!rows.length) {
|
||||
listEl.innerHTML = '<div style="color:var(--text-dim);padding:20px;text-align:center">No topics match filter</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = `<table style="width:100%;border-collapse:collapse">
|
||||
<thead><tr style="color:var(--cyan);font-size:0.58rem;border-bottom:1px solid var(--border)">
|
||||
<th style="padding:4px 6px;text-align:left;font-weight:normal">TOPIC ID</th>
|
||||
<th style="padding:4px 6px;text-align:left;font-weight:normal">CATEGORY</th>
|
||||
<th style="padding:4px 6px;text-align:left;font-weight:normal">TOPIC NAME</th>
|
||||
<th style="padding:4px 6px;text-align:center;font-weight:normal">ON</th>
|
||||
<th style="padding:4px 6px;text-align:center;font-weight:normal">RUNS</th>
|
||||
<th style="padding:4px 6px;text-align:right;font-weight:normal">ACTIONS</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows.map(t => `
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,0.03);font-size:0.65rem">
|
||||
<td style="padding:4px 6px;color:var(--text-dim);font-size:0.57rem">${tmEsc(t.topic_id)}</td>
|
||||
<td style="padding:4px 6px"><span style="background:var(--bg2);padding:1px 5px;border-radius:2px;font-size:0.57rem;white-space:nowrap">${tmEsc(t.category)}</span></td>
|
||||
<td style="padding:4px 6px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${tmEsc(t.description)}">${tmEsc(t.topic_name)}</td>
|
||||
<td style="padding:4px 6px;text-align:center">
|
||||
<input type="checkbox" ${t.active==1?'checked':''} onchange="tmToggle(${t.id},this)" style="cursor:pointer;accent-color:var(--green)">
|
||||
</td>
|
||||
<td style="padding:4px 6px;text-align:center;color:var(--text-dim);font-size:0.6rem">${t.run_count||0}</td>
|
||||
<td style="padding:4px 6px;text-align:right;white-space:nowrap">
|
||||
<button class="btn btn-sm btn-yellow" style="padding:2px 7px;font-size:0.58rem" onclick="tmEditTopic(${t.id})">EDIT</button>
|
||||
<button class="btn btn-sm btn-red" style="padding:2px 7px;font-size:0.58rem;margin-left:3px" onclick="tmDelete(${t.id},${JSON.stringify(t.topic_name)})">DEL</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
function tmShowList() {
|
||||
document.getElementById('tm-view-list').style.display = '';
|
||||
document.getElementById('tm-view-edit').style.display = 'none';
|
||||
document.getElementById('modalSave').style.display = 'none';
|
||||
}
|
||||
|
||||
function tmEditTopic(id) {
|
||||
document.getElementById('tm-view-list').style.display = 'none';
|
||||
document.getElementById('tm-view-edit').style.display = '';
|
||||
document.getElementById('modalSave').style.display = '';
|
||||
|
||||
const tidEl = document.getElementById('tm-edit-tid');
|
||||
if (id) {
|
||||
const t = _topicsData.find(x => x.id == id);
|
||||
if (!t) return;
|
||||
document.getElementById('tm-edit-title').textContent = 'EDIT TOPIC: ' + t.topic_id;
|
||||
document.getElementById('tm-edit-id').value = t.id;
|
||||
tidEl.value = t.topic_id;
|
||||
tidEl.readOnly = true;
|
||||
tidEl.style.opacity = '0.6';
|
||||
document.getElementById('tm-edit-cat').value = t.category;
|
||||
document.getElementById('tm-edit-name').value = t.topic_name;
|
||||
document.getElementById('tm-edit-desc').value = t.description;
|
||||
document.getElementById('tm-edit-active').checked = t.active == 1;
|
||||
} else {
|
||||
document.getElementById('tm-edit-title').textContent = 'ADD NEW TOPIC';
|
||||
document.getElementById('tm-edit-id').value = '';
|
||||
tidEl.value = '';
|
||||
tidEl.readOnly = false;
|
||||
tidEl.style.opacity = '1';
|
||||
document.getElementById('tm-edit-cat').value = '';
|
||||
document.getElementById('tm-edit-name').value = '';
|
||||
document.getElementById('tm-edit-desc').value = '';
|
||||
document.getElementById('tm-edit-active').checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function tmSaveTopic() {
|
||||
const id = document.getElementById('tm-edit-id').value;
|
||||
const data = {
|
||||
id: id ? parseInt(id) : 0,
|
||||
topic_id: document.getElementById('tm-edit-tid').value.trim(),
|
||||
category: document.getElementById('tm-edit-cat').value.trim(),
|
||||
topic_name: document.getElementById('tm-edit-name').value.trim(),
|
||||
description: document.getElementById('tm-edit-desc').value.trim(),
|
||||
active: document.getElementById('tm-edit-active').checked ? 1 : 0,
|
||||
};
|
||||
if (!data.topic_id || !data.category || !data.topic_name || !data.description) {
|
||||
toast('All fields are required', 'err'); return;
|
||||
}
|
||||
apiPost('kb_topic_save', data, async (res) => {
|
||||
toast(res?.msg || 'Saved', 'ok');
|
||||
tmShowList();
|
||||
await tmLoadTopics();
|
||||
});
|
||||
}
|
||||
|
||||
async function tmToggle(id, el) {
|
||||
apiPost('kb_topic_toggle', {id}, (res) => {
|
||||
const t = _topicsData.find(x => x.id == id);
|
||||
if (t) t.active = res.active ? 1 : 0;
|
||||
tmFilter();
|
||||
});
|
||||
}
|
||||
|
||||
async function tmDelete(id, name) {
|
||||
if (!confirm('Delete topic "' + name + '"?\nThis cannot be undone.')) return;
|
||||
apiPost('kb_topic_delete', {id}, async () => {
|
||||
toast('Topic deleted', 'ok');
|
||||
await tmLoadTopics();
|
||||
});
|
||||
}
|
||||
|
||||
function tmEsc(s) {
|
||||
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
async function arcSetup() {
|
||||
const modalHtml = `
|
||||
|
||||
Reference in New Issue
Block a user