/* app.js — Codebase Oracle frontend logic */
const API = window.location.origin;
// ── State ─────────────────────────────────────────────────────────────────────
const state = {
indexed: false,
querying: false,
queryType: 'micro',
macroSubtype: 'overall_architecture',
lastResponse: '',
};
// ── DOM Refs ──────────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const dom = {
pathInput: $('pathInput'),
zipLabel: $('zipLabelText'),
zipInput: $('zipInput'),
zipIndexBtn: $('zipIndexBtn'),
zipIndexBtnText: $('zipIndexBtnText'),
zipIndexSpinner: $('zipIndexSpinner'),
indexBtn: $('indexBtn'),
indexBtnText: $('indexBtnText'),
indexSpinner: $('indexSpinner'),
statusBadge: $('statusBadge'),
statusText: $('statusText'),
chunkCount: $('chunkCount'),
treeContainer: $('treeContainer'),
messages: $('messages'),
queryInput: $('queryInput'),
sendBtn: $('sendBtn'),
functionInput: $('functionInput'),
classInput: $('classInput'),
moduleInput: $('moduleInput'),
macroSubtypes: $('macroSubtypes'),
};
// ── Marked config ─────────────────────────────────────────────────────────────
marked.setOptions({
breaks: true,
gfm: true,
});
marked.use({
renderer: (() => {
const r = new marked.Renderer();
r.code = (code, lang) => {
const highlighted = (lang && hljs.getLanguage(lang))
? hljs.highlight(code, { language: lang }).value
: hljs.highlightAuto(code).value;
return `
${highlighted}
`;
};
return r;
})(),
});
// ── Status ────────────────────────────────────────────────────────────────────
function setStatus(type, text) {
dom.statusBadge.className = `status-badge status-badge--${type}`;
dom.statusText.textContent = text;
}
// ── Index ─────────────────────────────────────────────────────────────────────
dom.zipInput.addEventListener('change', () => {
if (dom.zipInput.files.length > 0) {
dom.zipLabel.textContent = dom.zipInput.files[0].name;
document.getElementById('zipLabel').classList.add('has-file');
dom.pathInput.value = '';
}
});
dom.indexBtn.addEventListener('click', async () => {
const path = dom.pathInput.value.trim();
if (!path) {
appendMessage('error', null, '❌ Provide an absolute path to index.');
return;
}
dom.indexBtn.disabled = true;
dom.indexBtnText.classList.add('hidden');
dom.indexSpinner.classList.remove('hidden');
setStatus('indexing', 'Indexing…');
try {
const res = await fetch(`${API}/index`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ root_path: path }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Indexing failed');
state.indexed = true;
setStatus('ready', `Ready · ${data.total_chunks} chunks`);
dom.chunkCount.textContent = `${data.total_chunks} chunks`;
dom.sendBtn.disabled = false;
appendMessage('system', null,
`✔ Indexed **${path}**\n\n` +
`| Metric | Value |\n|--------|-------|\n` +
`| Class chunks | ${data.class_chunks} |\n` +
`| Function chunks | ${data.function_chunks} |\n` +
`| Total chunks | ${data.total_chunks} |\n` +
`| Graph nodes | ${data.graph_nodes} |\n` +
`| Graph edges | ${data.graph_edges} |`
);
loadTree();
} catch (err) {
setStatus('error', 'Error');
appendMessage('error', null, `❌ ${err.message}`);
} finally {
dom.indexBtn.disabled = false;
dom.indexBtnText.classList.remove('hidden');
dom.indexSpinner.classList.add('hidden');
}
});
dom.zipIndexBtn.addEventListener('click', async () => {
const zipFile = dom.zipInput.files[0];
if (!zipFile) {
appendMessage('error', null, '❌ Please upload a ZIP file first.');
return;
}
dom.zipIndexBtn.disabled = true;
dom.zipIndexBtnText.classList.add('hidden');
dom.zipIndexSpinner.classList.remove('hidden');
setStatus('indexing', 'Indexing…');
try {
const form = new FormData();
form.append('file', zipFile);
const res = await fetch(`${API}/upload-index`, { method: 'POST', body: form });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Indexing failed');
state.indexed = true;
setStatus('ready', `Ready · ${data.total_chunks} chunks`);
dom.chunkCount.textContent = `${data.total_chunks} chunks`;
dom.sendBtn.disabled = false;
appendMessage('system', null,
`✔ Indexed **${zipFile.name}**\n\n` +
`| Metric | Value |\n|--------|-------|\n` +
`| Class chunks | ${data.class_chunks} |\n` +
`| Function chunks | ${data.function_chunks} |\n` +
`| Total chunks | ${data.total_chunks} |\n` +
`| Graph nodes | ${data.graph_nodes} |\n` +
`| Graph edges | ${data.graph_edges} |`
);
loadTree();
} catch (err) {
setStatus('error', 'Error');
appendMessage('error', null, `❌ ${err.message}`);
} finally {
dom.zipIndexBtn.disabled = false;
dom.zipIndexBtnText.classList.remove('hidden');
dom.zipIndexSpinner.classList.add('hidden');
dom.zipLabel.textContent = 'Upload ZIP';
document.getElementById('zipLabel').classList.remove('has-file');
dom.zipInput.value = '';
}
});
// ── Tree ──────────────────────────────────────────────────────────────────────
async function loadTree() {
try {
const res = await fetch(`${API}/tree`);
const data = await res.json();
if (!data.success || !data.tree.length) {
dom.treeContainer.innerHTML =
'';
return;
}
dom.treeContainer.innerHTML = '';
data.tree.forEach(node => {
dom.treeContainer.appendChild(buildTreeNode(node, 0));
});
} catch (err) {
console.error('Tree load error:', err);
}
}
const ICONS = {
module: { icon: '📦', cls: 'icon-module' },
file: { icon: '📄', cls: 'icon-file' },
class: { icon: '🔷', cls: 'icon-class' },
function: { icon: 'ƒ', cls: 'icon-function' },
};
function buildTreeNode(node, depth) {
const wrap = document.createElement('div');
const hasKids = node.children && node.children.length > 0;
const info = ICONS[node.type] || ICONS.file;
wrap.className = 'tree-node';
wrap.dataset.depth = depth;
const row = document.createElement('div');
row.className = 'tree-node__row';
const toggle = document.createElement('span');
toggle.className = 'tree-node__toggle';
toggle.textContent = hasKids ? '▶' : '';
const icon = document.createElement('span');
icon.className = `tree-node__icon ${info.cls}`;
icon.textContent = info.icon;
const label = document.createElement('span');
label.className = 'tree-node__name';
label.textContent = node.name;
row.append(toggle, icon, label);
wrap.appendChild(row);
if (hasKids) {
const children = document.createElement('div');
children.className = 'tree-node__children';
node.children.forEach(child => {
children.appendChild(buildTreeNode(child, depth + 1));
});
wrap.appendChild(children);
row.addEventListener('click', () => {
const open = children.classList.toggle('open');
toggle.classList.toggle('open', open);
});
}
// Click leaf → auto-fill inputs
if (node.type === 'function') {
row.addEventListener('click', () => {
dom.functionInput.value = node.name;
highlightRow(row);
});
}
if (node.type === 'class') {
row.addEventListener('click', () => {
dom.classInput.value = node.name;
highlightRow(row);
});
}
return wrap;
}
function highlightRow(row) {
document.querySelectorAll('.tree-node__row.selected')
.forEach(r => r.classList.remove('selected'));
row.classList.add('selected');
}
// ── Query Type Tabs ───────────────────────────────────────────────────────────
document.querySelectorAll('.query-type-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.query-type-btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.queryType = btn.dataset.type;
updateControls();
});
});
document.querySelectorAll('.subtype-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.subtype-btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.macroSubtype = btn.dataset.subtype;
updateControls();
});
});
function updateControls() {
const isMacro = state.queryType === 'macro';
const needsModule =
isMacro && state.macroSubtype === 'module_responsibility';
dom.macroSubtypes.classList.toggle('hidden', !isMacro);
dom.functionInput.classList.toggle('hidden', isMacro);
dom.classInput.classList.toggle('hidden', isMacro);
dom.moduleInput.classList.toggle('hidden', !needsModule);
}
// ── Textarea auto-resize ──────────────────────────────────────────────────────
dom.queryInput.addEventListener('input', () => {
dom.queryInput.style.height = 'auto';
dom.queryInput.style.height =
Math.min(dom.queryInput.scrollHeight, 160) + 'px';
});
dom.queryInput.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!dom.sendBtn.disabled) sendQuery();
}
});
dom.sendBtn.addEventListener('click', sendQuery);
// ── Send Query ────────────────────────────────────────────────────────────────
async function sendQuery() {
const query = dom.queryInput.value.trim();
if (!query || state.querying) return;
const badge = [
state.queryType,
state.queryType === 'macro'
? state.macroSubtype.replace(/_/g, ' ')
: dom.functionInput.value.trim(),
].filter(Boolean).join(' · ');
appendMessage('user', badge, query);
dom.queryInput.value = '';
dom.queryInput.style.height = 'auto';
const thinkId = appendThinking();
state.querying = true;
dom.sendBtn.disabled = true;
try {
const res = await fetch(`${API}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query_type: state.queryType,
query,
subtype: state.queryType === 'macro' ? state.macroSubtype : '',
function_name: dom.functionInput.value.trim(),
class_name: dom.classInput.value.trim(),
module_name: dom.moduleInput.value.trim(),
followup: !!state.lastResponse,
previous_response: state.lastResponse,
}),
});
const data = await res.json();
removeMessage(thinkId);
if (!res.ok) throw new Error(data.detail || 'Query failed');
if (!data.success) throw new Error(data.error || 'No result');
state.lastResponse = data.content;
appendMessage('assistant', buildMetaBadge(data.metadata), data.content);
} catch (err) {
removeMessage(thinkId);
appendMessage('error', null, `❌ ${err.message}`);
} finally {
state.querying = false;
dom.sendBtn.disabled = false;
}
}
// ── Messages ──────────────────────────────────────────────────────────────────
let msgCounter = 0;
function appendMessage(role, badge, content) {
const id = `msg-${++msgCounter}`;
const div = document.createElement('div');
div.id = id;
div.className = `message message--${role}`;
const roleLabel = {
user: 'You', assistant: 'Oracle',
system: 'System', error: 'Error', thinking: 'Thinking',
}[role] || role;
let html = `
${roleLabel}
${badge ? `${badge}` : ''}
`;
html += role === 'thinking'
? `${content}`
: marked.parse(content || '');
html += `
`;
div.innerHTML = html;
dom.messages.appendChild(div);
dom.messages.scrollTop = dom.messages.scrollHeight;
return id;
}
function appendThinking() {
return appendMessage('thinking', null, 'Analyzing codebase…');
}
function removeMessage(id) {
document.getElementById(id)?.remove();
}
function buildMetaBadge(meta) {
if (!meta) return null;
return [
meta.query_type,
meta.function_name,
meta.chunks_used ? `${meta.chunks_used} chunks` : null,
meta.context_chars ? `${meta.context_chars} chars` : null,
].filter(Boolean).join(' · ');
}
// ── Init ──────────────────────────────────────────────────────────────────────
(async () => {
try {
const res = await fetch(`${API}/status`);
const data = await res.json();
if (data.indexed) {
state.indexed = true;
setStatus('ready', `Ready · ${data.total_chunks} chunks`);
dom.chunkCount.textContent = `${data.total_chunks} chunks`;
dom.sendBtn.disabled = false;
loadTree();
}
} catch { /* server not up yet — stay idle */ }
updateControls();
})();