KB viewer/editor: full CRUD UI, DELETE API, 41 tests
Add knowledge base management to dashboard: clickable topic tags, inline content viewer, edit/create modal, delete with confirmation. New DELETE /api/knowledge/:topic endpoint and deleteKnowledge() function. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c8e9377afc
commit
3301dfa081
131
dashboard.html
131
dashboard.html
@ -66,8 +66,16 @@
|
||||
.event-agent { color: var(--purple); min-width: 60px; }
|
||||
.event-action { color: var(--accent); }
|
||||
|
||||
.kb-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.kb-tag { background: var(--border); color: var(--text); padding: 4px 12px; border-radius: 16px; font-size: 13px; }
|
||||
.kb-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
||||
.kb-tag { background: var(--border); color: var(--text); padding: 4px 12px; border-radius: 16px; font-size: 13px; cursor: pointer; border: 1px solid transparent; transition: border-color 0.2s; }
|
||||
.kb-tag:hover { border-color: var(--accent); }
|
||||
.kb-tag.active { border-color: var(--accent); background: rgba(88,166,255,0.15); }
|
||||
.kb-viewer { margin-top: 8px; }
|
||||
.kb-content { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 16px; font-size: 13px; line-height: 1.7; white-space: pre-wrap; font-family: monospace; max-height: 400px; overflow-y: auto; }
|
||||
.kb-content h1, .kb-content h2, .kb-content h3 { font-family: inherit; margin: 8px 0 4px; color: var(--accent); }
|
||||
.kb-toolbar { display: flex; gap: 6px; margin-top: 8px; }
|
||||
.kb-editor { width: 100%; min-height: 250px; background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 12px; border-radius: 6px; font-size: 13px; font-family: monospace; line-height: 1.6; resize: vertical; }
|
||||
.kb-editor:focus { border-color: var(--accent); outline: none; }
|
||||
|
||||
.lock-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; border-bottom: 1px solid var(--border); }
|
||||
.lock-row:last-child { border-bottom: none; }
|
||||
@ -184,10 +192,25 @@
|
||||
<div class="card-body" id="events"><div class="empty">Loading...</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">Knowledge Base</div>
|
||||
<div class="card full">
|
||||
<div class="card-header">Knowledge Base <button class="btn btn-accent btn-sm" style="margin-left:auto" onclick="showKBEditor()">+ New Topic</button></div>
|
||||
<div class="card-body" id="knowledge"><div class="empty">Loading...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- KB Editor Modal -->
|
||||
<div class="modal-overlay" id="kb-modal" onclick="if(event.target===this)closeKBModal()">
|
||||
<div class="modal" style="width:600px">
|
||||
<h3 id="kb-modal-title">New Knowledge Entry</h3>
|
||||
<label>Topic (filename without .md)</label>
|
||||
<input type="text" id="kb-topic" placeholder="e.g. architecture-decisions">
|
||||
<label>Content (Markdown)</label>
|
||||
<textarea class="kb-editor" id="kb-editor-content" placeholder="# Topic Title Write your knowledge entry here..."></textarea>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" onclick="closeKBModal()">Cancel</button>
|
||||
<button class="btn btn-accent" onclick="saveKBEntry()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@ -305,9 +328,20 @@ async function refresh() {
|
||||
if (d.knowledge_topics.length === 0) {
|
||||
kbEl.innerHTML = '<div class="empty">No knowledge entries</div>';
|
||||
} else {
|
||||
kbEl.innerHTML = '<div class="kb-list">' +
|
||||
d.knowledge_topics.map(t => `<span class="kb-tag">${esc(t)}</span>`).join('') +
|
||||
'</div>';
|
||||
const tagHtml = d.knowledge_topics.map(t =>
|
||||
`<span class="kb-tag${activeKBTopic === t ? ' active' : ''}" onclick="viewKBTopic('${esc(t)}')">${esc(t)}</span>`
|
||||
).join('');
|
||||
const viewerHtml = activeKBTopic && activeKBContent !== null
|
||||
? `<div class="kb-viewer">
|
||||
<div class="kb-content">${esc(activeKBContent)}</div>
|
||||
<div class="kb-toolbar">
|
||||
<button class="btn btn-accent btn-sm" onclick="editKBTopic('${esc(activeKBTopic)}')">Edit</button>
|
||||
<button class="btn btn-sm" style="color:var(--red)" onclick="deleteKBTopic('${esc(activeKBTopic)}')">Delete</button>
|
||||
<button class="btn btn-sm" onclick="closeKBViewer()">Close</button>
|
||||
</div>
|
||||
</div>`
|
||||
: '';
|
||||
kbEl.innerHTML = '<div class="kb-list">' + tagHtml + '</div>' + viewerHtml;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Refresh failed:', e);
|
||||
@ -326,6 +360,8 @@ function timeAgo(iso) {
|
||||
// State for modals and open forms
|
||||
let claimWs = '', claimId = '';
|
||||
let openAddForms = new Set(); // track which workstream add-task forms are open
|
||||
let activeKBTopic = null, activeKBContent = null;
|
||||
let kbEditMode = false; // true = editing existing, false = creating new
|
||||
|
||||
function showAddWorkstream() {
|
||||
document.getElementById('ws-name').value = '';
|
||||
@ -405,6 +441,87 @@ async function completeTask(wsName, taskId) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
// --- Knowledge Base functions ---
|
||||
async function viewKBTopic(topic) {
|
||||
if (activeKBTopic === topic) { closeKBViewer(); return; }
|
||||
try {
|
||||
const res = await fetch('/api/knowledge/' + encodeURIComponent(topic));
|
||||
if (!res.ok) throw new Error('Not found');
|
||||
const data = await res.json();
|
||||
activeKBTopic = topic;
|
||||
activeKBContent = data.content;
|
||||
refresh();
|
||||
} catch (e) {
|
||||
console.error('Failed to load topic:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function closeKBViewer() {
|
||||
activeKBTopic = null;
|
||||
activeKBContent = null;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function showKBEditor(topic, content) {
|
||||
const titleEl = document.getElementById('kb-modal-title');
|
||||
const topicEl = document.getElementById('kb-topic');
|
||||
const editorEl = document.getElementById('kb-editor-content');
|
||||
if (topic) {
|
||||
titleEl.textContent = 'Edit: ' + topic;
|
||||
topicEl.value = topic;
|
||||
topicEl.readOnly = true;
|
||||
editorEl.value = content || '';
|
||||
kbEditMode = true;
|
||||
} else {
|
||||
titleEl.textContent = 'New Knowledge Entry';
|
||||
topicEl.value = '';
|
||||
topicEl.readOnly = false;
|
||||
editorEl.value = '';
|
||||
kbEditMode = false;
|
||||
}
|
||||
document.getElementById('kb-modal').classList.add('show');
|
||||
(topic ? editorEl : topicEl).focus();
|
||||
}
|
||||
|
||||
function closeKBModal() {
|
||||
document.getElementById('kb-modal').classList.remove('show');
|
||||
}
|
||||
|
||||
function editKBTopic(topic) {
|
||||
showKBEditor(topic, activeKBContent);
|
||||
}
|
||||
|
||||
async function saveKBEntry() {
|
||||
const topic = document.getElementById('kb-topic').value.trim();
|
||||
const content = document.getElementById('kb-editor-content').value;
|
||||
if (!topic || !content) return;
|
||||
try {
|
||||
await fetch('/api/knowledge/' + encodeURIComponent(topic), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, agent: 'dashboard' })
|
||||
});
|
||||
closeKBModal();
|
||||
activeKBTopic = topic;
|
||||
activeKBContent = content;
|
||||
refresh();
|
||||
} catch (e) {
|
||||
console.error('Failed to save:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKBTopic(topic) {
|
||||
if (!confirm('Delete knowledge entry "' + topic + '"?')) return;
|
||||
try {
|
||||
await fetch('/api/knowledge/' + encodeURIComponent(topic), { method: 'DELETE' });
|
||||
activeKBTopic = null;
|
||||
activeKBContent = null;
|
||||
refresh();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete:', e);
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
</script>
|
||||
|
||||
@ -281,6 +281,14 @@ function readKnowledge(topic) {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
function deleteKnowledge(topic, agentId = 'system') {
|
||||
const filePath = path.join(KNOWLEDGE_DIR, `${topic}.md`);
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
fs.unlinkSync(filePath);
|
||||
logEvent(agentId, 'knowledge_deleted', { topic });
|
||||
return true;
|
||||
}
|
||||
|
||||
function listKnowledge() {
|
||||
return fs.readdirSync(KNOWLEDGE_DIR)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
@ -375,6 +383,7 @@ module.exports = {
|
||||
releaseLock,
|
||||
writeKnowledge,
|
||||
readKnowledge,
|
||||
deleteKnowledge,
|
||||
listKnowledge,
|
||||
status,
|
||||
statusText
|
||||
|
||||
12
server.js
12
server.js
@ -71,6 +71,11 @@ const server = http.createServer(async (req, res) => {
|
||||
return res.end(html);
|
||||
}
|
||||
|
||||
if (pathname === '/favicon.ico') {
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
// --- API: Status ---
|
||||
if (pathname === '/api/status' && method === 'GET') {
|
||||
return json(res, orc.status());
|
||||
@ -204,6 +209,13 @@ const server = http.createServer(async (req, res) => {
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
if (kbMatch && method === 'DELETE') {
|
||||
const topic = decodeURIComponent(kbMatch[1]);
|
||||
const deleted = orc.deleteKnowledge(topic, 'dashboard');
|
||||
if (!deleted) return error(res, `Topic "${topic}" not found`, 404);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
// --- API: Locks ---
|
||||
if (pathname === '/api/locks' && method === 'GET') {
|
||||
const state = orc.loadState();
|
||||
|
||||
5
test.js
5
test.js
@ -126,6 +126,11 @@ test('Knowledge base', () => {
|
||||
|
||||
const topics = orc.listKnowledge();
|
||||
assert(topics.includes('test-topic'), 'Topic listed');
|
||||
|
||||
const deleted = orc.deleteKnowledge('test-topic');
|
||||
assert(deleted === true, 'Topic deleted');
|
||||
assert(orc.readKnowledge('test-topic') === null, 'Deleted topic returns null');
|
||||
assert(orc.deleteKnowledge('nonexistent') === false, 'Delete nonexistent returns false');
|
||||
});
|
||||
|
||||
test('Event log', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user