Skip to content

Commit 2372c58

Browse files
committed
refactor: decouple frontend into a modular ES architecture with segmented renderers and state management
1 parent 49e7433 commit 2372c58

25 files changed

Lines changed: 2332 additions & 2024 deletions

frontend/app.js

Lines changed: 7 additions & 2023 deletions
Large diffs are not rendered by default.

frontend/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,6 @@ <h4>Enterprise Graph Database &amp; Repository Sync</h4>
588588
</div>
589589
</div>
590590

591-
<script src="app.js?v=2.4"></script>
591+
<script type="module" src="js/main.js?v=2.5"></script>
592592
</body>
593593
</html>

frontend/js/api.js

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* Unified Backend API Client & SSE Stream Handler
3+
*/
4+
5+
export async function fetchSystemStatus() {
6+
const res = await fetch('/api/status');
7+
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
8+
return await res.json();
9+
}
10+
11+
export async function fetchDocuments() {
12+
const res = await fetch('/api/documents');
13+
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
14+
return await res.json();
15+
}
16+
17+
export async function deleteDocument(name) {
18+
const res = await fetch(`/api/documents/${encodeURIComponent(name)}`, {
19+
method: 'DELETE'
20+
});
21+
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
22+
return await res.json();
23+
}
24+
25+
export async function clearWorkspace() {
26+
const res = await fetch('/api/workspace/clear', {
27+
method: 'POST'
28+
});
29+
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
30+
return await res.json();
31+
}
32+
33+
export async function uploadFiles(fileList) {
34+
const formData = new FormData();
35+
for (let i = 0; i < fileList.length; i++) {
36+
formData.append('files', fileList[i]);
37+
}
38+
const res = await fetch('/api/upload', {
39+
method: 'POST',
40+
body: formData
41+
});
42+
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
43+
return await res.json();
44+
}
45+
46+
export async function syncKnowledgeBase(parserSettings) {
47+
const res = await fetch('/api/sync', {
48+
method: 'POST',
49+
headers: { 'Content-Type': 'application/json' },
50+
body: JSON.stringify({
51+
parser: parserSettings.parser,
52+
llamaparse_key: parserSettings.llamaparseKey,
53+
unstructured_key: parserSettings.unstructuredKey
54+
})
55+
});
56+
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
57+
return await res.json();
58+
}
59+
60+
export async function fetchExistingJsonLd(fileName) {
61+
const res = await fetch(`/api/jsonld/${encodeURIComponent(fileName)}`);
62+
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
63+
return await res.json();
64+
}
65+
66+
export async function extractJsonLdStream(params, signal, onEvent) {
67+
const response = await fetch('/api/extract-jsonld-stream', {
68+
method: 'POST',
69+
headers: { 'Content-Type': 'application/json' },
70+
signal: signal,
71+
body: JSON.stringify({
72+
file_name: params.fileName,
73+
llm_provider: params.provider,
74+
llm_model: params.model,
75+
api_key: params.apiKey,
76+
base_url: params.baseUrl
77+
})
78+
});
79+
80+
if (!response.ok) {
81+
throw new Error(`Server returned status ${response.status}`);
82+
}
83+
84+
const reader = response.body.getReader();
85+
const decoder = new TextDecoder('utf-8');
86+
let buffer = '';
87+
88+
while (true) {
89+
const { value, done } = await reader.read();
90+
if (done) break;
91+
92+
buffer += decoder.decode(value, { stream: true });
93+
const lines = buffer.split(/\r?\n/);
94+
buffer = lines.pop();
95+
96+
for (const line of lines) {
97+
const trimmed = line.trim();
98+
if (trimmed.startsWith('data:')) {
99+
const jsonStr = trimmed.replace(/^data:\s*/, '').trim();
100+
if (jsonStr) {
101+
try {
102+
const event = JSON.parse(jsonStr);
103+
onEvent(event);
104+
} catch (err) {
105+
console.warn('Failed to parse SSE JSON:', jsonStr, err);
106+
}
107+
}
108+
}
109+
}
110+
}
111+
112+
if (buffer && buffer.trim().startsWith('data:')) {
113+
try {
114+
const event = JSON.parse(buffer.trim().replace(/^data:\s*/, ''));
115+
onEvent(event);
116+
} catch (e) {}
117+
}
118+
}
119+
120+
export async function sendChatMessage(params) {
121+
const res = await fetch('/api/chat', {
122+
method: 'POST',
123+
headers: { 'Content-Type': 'application/json' },
124+
body: JSON.stringify({
125+
query: params.query,
126+
file_name: params.fileName,
127+
llm_provider: params.provider,
128+
llm_model: params.model,
129+
api_key: params.apiKey,
130+
base_url: params.baseUrl
131+
})
132+
});
133+
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
134+
return await res.json();
135+
}
136+
137+
export async function testLlmConnection(params) {
138+
const res = await fetch('/api/diagnostics/llm/test', {
139+
method: 'POST',
140+
headers: { 'Content-Type': 'application/json' },
141+
body: JSON.stringify(params)
142+
});
143+
return await res.json();
144+
}
145+
146+
export async function testParserService(params) {
147+
const res = await fetch('/api/diagnostics/parser/test', {
148+
method: 'POST',
149+
headers: { 'Content-Type': 'application/json' },
150+
body: JSON.stringify(params)
151+
});
152+
return await res.json();
153+
}
154+
155+
export async function testGraphdbConnection(params) {
156+
const res = await fetch('/api/enterprise/graphdb/test', {
157+
method: 'POST',
158+
headers: { 'Content-Type': 'application/json' },
159+
body: JSON.stringify(params)
160+
});
161+
return await res.json();
162+
}
163+
164+
export async function syncGraphdb(params) {
165+
const res = await fetch('/api/enterprise/graphdb/sync', {
166+
method: 'POST',
167+
headers: { 'Content-Type': 'application/json' },
168+
body: JSON.stringify(params)
169+
});
170+
return await res.json();
171+
}
172+
173+
export function getExportUrl(format, docName) {
174+
const encoded = encodeURIComponent(docName);
175+
if (format === 'ttl') return `/api/export/ttl/${encoded}`;
176+
if (format === 'bibtex') return `/api/export/bibtex/${encoded}`;
177+
if (format === 'ris') return `/api/export/ris/${encoded}`;
178+
if (format === 'csl') return `/api/export/csl/${encoded}`;
179+
if (format === 'cypher') return `/api/export/cypher/${encoded}`;
180+
if (format === 'graph') return `/api/export/graph/${encoded}`;
181+
return `/api/export/${encoded}`;
182+
}

frontend/js/main.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* CORPUSLD: Modern Client Controller
3+
* Native ES Module Entry Point & System Orchestrator
4+
*/
5+
6+
import { appState, loadSettingsFromStorage, updateIndexStatus } from './state.js';
7+
import { fetchSystemStatus } from './api.js';
8+
import { initSettingsModule, applySettingsToUI } from './modules/settings.js';
9+
import { initDocumentsModule, fetchDocumentsList } from './modules/documents.js';
10+
import { initExtractionModule } from './modules/extraction.js';
11+
import { initChatModule } from './modules/chat.js';
12+
import { initScholarCopyHandlers } from './renderers/scholar.js';
13+
14+
document.addEventListener('DOMContentLoaded', async () => {
15+
// 1. Initialize UI Submodules
16+
initSettingsModule();
17+
initDocumentsModule();
18+
initExtractionModule();
19+
initChatModule();
20+
initScholarCopyHandlers();
21+
22+
// 2. Setup Tabs Controller
23+
const tabBtns = document.querySelectorAll('.tab-btn');
24+
const tabPanels = document.querySelectorAll('.tab-panel');
25+
tabBtns.forEach(btn => {
26+
btn.addEventListener('click', () => {
27+
tabBtns.forEach(b => b.classList.remove('active'));
28+
tabPanels.forEach(p => p.classList.remove('active'));
29+
btn.classList.add('active');
30+
const targetId = btn.getAttribute('data-tab');
31+
if (targetId) {
32+
document.getElementById(targetId)?.classList.add('active');
33+
}
34+
});
35+
});
36+
37+
const subtabBtns = document.querySelectorAll('.subtab-btn');
38+
const subtabPanels = document.querySelectorAll('.subtab-panel');
39+
subtabBtns.forEach(btn => {
40+
btn.addEventListener('click', () => {
41+
subtabBtns.forEach(b => b.classList.remove('active'));
42+
subtabPanels.forEach(p => p.classList.remove('active'));
43+
btn.classList.add('active');
44+
const targetId = btn.getAttribute('data-subtab');
45+
if (targetId) {
46+
document.getElementById(targetId)?.classList.add('active');
47+
}
48+
});
49+
});
50+
51+
// 3. Mobile Navigation & Drawer
52+
const appSidebar = document.getElementById('app-sidebar');
53+
const btnMobileSidebar = document.getElementById('btn-mobile-sidebar');
54+
const btnCloseSidebar = document.getElementById('btn-close-sidebar');
55+
const sidebarBackdrop = document.getElementById('sidebar-backdrop');
56+
const settingsModal = document.getElementById('settings-modal');
57+
58+
const closeMobileSidebar = () => {
59+
if (appSidebar) appSidebar.classList.remove('open');
60+
if (sidebarBackdrop) sidebarBackdrop.classList.add('hidden');
61+
};
62+
63+
if (btnMobileSidebar && appSidebar && sidebarBackdrop) {
64+
btnMobileSidebar.addEventListener('click', () => {
65+
appSidebar.classList.add('open');
66+
sidebarBackdrop.classList.remove('hidden');
67+
});
68+
}
69+
70+
if (btnCloseSidebar) btnCloseSidebar.addEventListener('click', closeMobileSidebar);
71+
if (sidebarBackdrop) sidebarBackdrop.addEventListener('click', closeMobileSidebar);
72+
73+
// 4. Keyboard Shortcuts & Accessibility
74+
document.addEventListener('keydown', (e) => {
75+
if (e.key === 'Escape') {
76+
if (settingsModal && !settingsModal.classList.contains('hidden')) {
77+
settingsModal.classList.add('hidden');
78+
}
79+
closeMobileSidebar();
80+
}
81+
});
82+
83+
// 5. Load Stored Settings & Initial State
84+
loadSettingsFromStorage();
85+
applySettingsToUI();
86+
87+
// 6. Fetch System & Model Status
88+
try {
89+
const statusData = await fetchSystemStatus();
90+
appState.isIndexed = statusData.is_indexed;
91+
appState.localModels = statusData.available_local_models || ['qwen2.5:3b'];
92+
93+
const settingOllamaModel = document.getElementById('setting-ollama-model');
94+
if (settingOllamaModel) {
95+
settingOllamaModel.innerHTML = '';
96+
appState.localModels.forEach(m => {
97+
const opt = document.createElement('option');
98+
opt.value = m;
99+
opt.textContent = m;
100+
if (m === appState.settings.ollamaModel) opt.selected = true;
101+
settingOllamaModel.appendChild(opt);
102+
});
103+
}
104+
updateIndexStatus(statusData.is_indexed);
105+
} catch (e) {
106+
console.error('Initial status fetch failed:', e);
107+
}
108+
109+
// 7. Fetch Documents List
110+
await fetchDocumentsList();
111+
});

frontend/js/modules/chat.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { appState } from '../state.js';
2+
import { sendChatMessage } from '../api.js';
3+
import { escapeHtml } from '../utils/dom.js';
4+
5+
export function appendChatMessage(role, text, sources = [], duration = null) {
6+
const chatMessages = document.getElementById('chat-messages');
7+
if (!chatMessages) return;
8+
9+
const row = document.createElement('div');
10+
row.className = `msg-row ${role === 'user' ? 'msg-user' : 'msg-bot'}`;
11+
12+
const avatar = document.createElement('div');
13+
avatar.className = 'msg-avatar';
14+
avatar.textContent = role === 'user' ? '👤' : '🧬';
15+
16+
const bubble = document.createElement('div');
17+
bubble.className = 'msg-bubble';
18+
bubble.innerHTML = `<p>${escapeHtml(text).replace(/\n/g, '<br>')}</p>`;
19+
20+
if (sources && sources.length > 0) {
21+
const cit = document.createElement('div');
22+
cit.className = 'msg-citations';
23+
cit.innerHTML = `<strong>📌 Citations & Grounded Evidence (${duration}s):</strong><br>` + sources.map(s => `• ${escapeHtml(s)}`).join('<br>');
24+
bubble.appendChild(cit);
25+
}
26+
27+
row.appendChild(avatar);
28+
row.appendChild(bubble);
29+
chatMessages.appendChild(row);
30+
chatMessages.scrollTop = chatMessages.scrollHeight;
31+
}
32+
33+
export function initChatModule() {
34+
const chatForm = document.getElementById('chat-form');
35+
const chatInput = document.getElementById('chat-input');
36+
const btnSendChat = document.getElementById('btn-send-chat');
37+
const chatMessages = document.getElementById('chat-messages');
38+
const selectChatScope = document.getElementById('select-chat-scope');
39+
40+
if (!chatForm || !chatInput || !btnSendChat) return;
41+
42+
chatForm.addEventListener('submit', async (e) => {
43+
e.preventDefault();
44+
const query = chatInput.value.trim();
45+
if (!query) return;
46+
47+
appendChatMessage('user', query);
48+
chatInput.value = '';
49+
btnSendChat.disabled = true;
50+
const originalBtnIcon = btnSendChat.innerHTML;
51+
btnSendChat.innerHTML = '<span class="spinner"></span>';
52+
53+
// Temporary loading indicator bubble
54+
const loadingRow = document.createElement('div');
55+
loadingRow.className = 'msg-row msg-bot loading-row';
56+
loadingRow.innerHTML = `
57+
<div class="msg-avatar">🧬</div>
58+
<div class="msg-bubble" style="display: flex; align-items: center; gap: 8px; font-style: italic; color: var(--text-secondary);">
59+
<span class="spinner"></span> <span>AI is analyzing documents...</span>
60+
</div>
61+
`;
62+
if (chatMessages) {
63+
chatMessages.appendChild(loadingRow);
64+
chatMessages.scrollTop = chatMessages.scrollHeight;
65+
}
66+
67+
const provider = appState.settings.provider;
68+
const model = provider === 'ollama' ? appState.settings.ollamaModel : appState.settings.cloudModel;
69+
const apiKey = appState.settings.apiKey;
70+
const scopeDoc = selectChatScope?.value ? selectChatScope.value : (appState.selectedDoc || undefined);
71+
72+
try {
73+
const data = await sendChatMessage({
74+
query,
75+
fileName: scopeDoc,
76+
provider,
77+
model,
78+
apiKey,
79+
baseUrl: appState.settings.baseUrl
80+
});
81+
loadingRow.remove();
82+
appendChatMessage('bot', data.answer, data.sources, data.duration_seconds);
83+
} catch (err) {
84+
loadingRow.remove();
85+
appendChatMessage('bot', '⚠️ An error occurred / Chat stopped: ' + err);
86+
} finally {
87+
btnSendChat.disabled = false;
88+
btnSendChat.innerHTML = originalBtnIcon;
89+
}
90+
});
91+
}

0 commit comments

Comments
 (0)