diff --git a/api/routes/graphs.py b/api/routes/graphs.py index e6101016..81a18510 100644 --- a/api/routes/graphs.py +++ b/api/routes/graphs.py @@ -10,6 +10,7 @@ from pydantic import BaseModel from redis import ResponseError +from api.routes.database import connect_database, DatabaseConnectionRequest from api.agents import AnalysisAgent, RelevancyAgent, ResponseFormatterAgent, FollowUpAgent from api.auth.user_management import token_required from api.config import Config @@ -799,45 +800,23 @@ async def refresh_graph_schema(request: Request, graph_id: str): graph_id = _graph_name(request, graph_id) try: - # Get database connection details + # Get database description and URL _, db_url = await get_db_description(graph_id) if not db_url or db_url == "No URL available for this database.": - return JSONResponse({ - "success": False, - "error": "No database URL found for this graph" - }, status_code=400) + raise HTTPException(status_code=404, detail="No database URL found for this graph") - # Determine database type and get appropriate loader - db_type, loader_class = get_database_type_and_loader(db_url) + # Create a database connection request with the stored URL + db_request = DatabaseConnectionRequest(url=db_url) - if not loader_class: - return JSONResponse({ - "success": False, - "error": "Unable to determine database type" - }, status_code=400) - - # Perform schema refresh using the appropriate loader - success, _ = await loader_class.refresh_graph_schema(graph_id, db_url) - - if success: - return JSONResponse({ - "success": True, - "message": f"Graph schema refreshed successfully using {db_type}" - }) - - logging.error("Schema refresh failed") # nosemgrep - return JSONResponse({ - "success": False, - "error": "Failed to refresh schema" - }, status_code=500) + # Call connect_database to refresh the schema by reconnecting + return await connect_database(request, db_request) - except Exception as e: # pylint: disable=broad-exception-caught - logging.error("Error in manual schema refresh: %s", e) # nosemgrep - return JSONResponse({ - "success": False, - "error": "Error refreshing schema" - }, status_code=500) + except HTTPException: + raise + except Exception as e: + logging.error("Error in refresh_graph_schema: %s", str(e)) + raise HTTPException(status_code=500, detail="Internal server error while refreshing schema") # pylint: disable=raise-missing-from @graphs_router.delete("/{graph_id}") @token_required diff --git a/app/public/css/menu.css b/app/public/css/menu.css index 431eb4f2..3e800664 100644 --- a/app/public/css/menu.css +++ b/app/public/css/menu.css @@ -116,6 +116,20 @@ border-color: var(--text-secondary); } +#graph-select-refresh { + display: flex; + align-items: center; + justify-content: center; +} + +#graph-select-refresh.loading svg { + animation: spin 0.5s linear infinite; + opacity: 0.5; +} + +#graph-select-refresh svg { + height: 20px; + width: 20px; #query-final-result-table { border-collapse: collapse; } @@ -134,28 +148,9 @@ border-top: 1px solid var(--text-primary); } -#graph-select { - height: 100%; - padding: 8px 12px; - border-radius: 6px; - border: 1px solid var(--text-primary); - font-size: 14px; - background-color: var(--falkor-secondary); - color: var(--text-primary); - transition: border-color 0.2s; - min-width: 180px; - appearance: none; - background-image: linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%), - linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%); - background-position: calc(100% - 20px) center, calc(100% - 15px) center; - background-size: 5px 5px, 5px 5px; - background-repeat: no-repeat; - cursor: pointer; -} - #schema-graph { width: 100%; - height: 100%; + height: 100%; } #schema-content { @@ -385,6 +380,7 @@ } .graph-selected { + height: 100%; padding: 8px 14px; border-radius: 6px; background: var(--falkor-quaternary); @@ -398,18 +394,17 @@ min-width: 160px; box-sizing: border-box; font-size: 14px; + gap: 4px; } .graph-options { position: absolute; - top: calc(100%); - left: 0; - right: 0; + top: calc(100% + 20px); + left: -20%; + right: -20%; background: var(--falkor-secondary); border: 1px solid var(--border-color); border-radius: 6px; - border-top-left-radius: 0; - border-top-right-radius: 0; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); max-height: 260px; overflow: auto; @@ -417,6 +412,11 @@ z-index: 50; } +.graph-custom-dropdown { + height: 100%; + position: relative; +} + .dropdown-option { display: flex; align-items: center; @@ -462,4 +462,4 @@ .graph-options.open { display: block; -} \ No newline at end of file +} diff --git a/app/public/css/responsive.css b/app/public/css/responsive.css index b4cc0c7f..fa729f56 100644 --- a/app/public/css/responsive.css +++ b/app/public/css/responsive.css @@ -2,6 +2,7 @@ /* Layout Responsive */ @media (max-width: 768px) { + /* When left toolbar is open, push content to make room */ body.left-toolbar-open .chat-container { max-width: calc(100vw - 48px); @@ -42,13 +43,14 @@ /* Ensure chat header elements are properly positioned when toolbar is open */ body.left-toolbar-open .chat-header { - padding-left: 15px; /* Add extra padding to prevent overlap */ + padding-left: 15px; + /* Add extra padding to prevent overlap */ width: 100%; box-sizing: border-box; } /* Ensure dropdown and buttons in header don't get cut off */ - body.left-toolbar-open .chat-header > * { + body.left-toolbar-open .chat-header>* { margin-left: 0; width: 100%; } @@ -84,15 +86,15 @@ padding: 10px 12px; font-size: 14px; } - + .chat-header h1 { font-size: 18px; } - + #message-input { font-size: 16px !important; } - + #message-input::placeholder { font-size: 16px !important; } @@ -110,14 +112,13 @@ flex-wrap: nowrap; align-items: stretch; } - + /* Hide vertical separators on mobile */ .vertical-separator { display: none; } - + /* Make selectors and buttons fit screen width */ - #graph-select, #custom-file-upload { flex: 1; min-width: 0; @@ -129,11 +130,7 @@ height: 40px; box-sizing: border-box; } - - #graph-select { - max-width: 30%; - } - + #custom-file-upload { max-width: 35%; text-align: center; @@ -142,12 +139,12 @@ justify-content: center; cursor: pointer; } - + .header-button { width: 100%; height: 40px; } - + .dropdown-selected { padding: 8px 6px; font-size: 13px; @@ -157,14 +154,14 @@ align-items: center; justify-content: space-between; } - + .dropdown-text { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; flex: 1; } - + .dropdown-arrow { margin-left: 4px; flex-shrink: 0; @@ -176,18 +173,17 @@ padding: 0; gap: 5px; } - - #graph-select, + #custom-file-upload { padding: 6px 4px; font-size: 12px; height: 36px; } - + .header-button { height: 36px; } - + .dropdown-selected { padding: 6px 4px; font-size: 12px; @@ -211,7 +207,7 @@ transform: none; margin-left: 10px; } - + #reset-button svg { width: 18px; height: 18px; @@ -229,13 +225,16 @@ /* Reduce padding on mobile to maximize input space */ padding: 8px; gap: 4px; - min-width: 0; /* Allow container to shrink */ + min-width: 0; + /* Allow container to shrink */ flex-shrink: 1; } #message-input { - font-size: 16px !important; /* Prevent zoom on iOS */ - min-width: 0; /* Allow input to shrink */ + font-size: 16px !important; + /* Prevent zoom on iOS */ + min-width: 0; + /* Allow input to shrink */ } #message-input::placeholder { @@ -246,7 +245,8 @@ .input-button { width: 40px; height: 40px; - flex-shrink: 0; /* Prevent buttons from shrinking */ + flex-shrink: 0; + /* Prevent buttons from shrinking */ } } @@ -266,7 +266,7 @@ width: 40px; height: 40px; } - + #github-link-btn { top: 15px; right: 60px; @@ -274,7 +274,7 @@ font-size: 12px; height: 40px; } - + .theme-icon { width: 18px; height: 18px; @@ -306,4 +306,4 @@ min-width: 90vw; margin: 0 5vw; } -} +} \ No newline at end of file diff --git a/app/templates/components/chat_header.j2 b/app/templates/components/chat_header.j2 index a3dbb9bd..345b6cc3 100644 --- a/app/templates/components/chat_header.j2 +++ b/app/templates/components/chat_header.j2 @@ -3,10 +3,15 @@

Natural Language to SQL Generator

- +
- -
+
\ No newline at end of file diff --git a/app/templates/components/chat_input.j2 b/app/templates/components/chat_input.j2 index ac50e12e..b7fa6ea4 100644 --- a/app/templates/components/chat_input.j2 +++ b/app/templates/components/chat_input.j2 @@ -10,8 +10,9 @@ - + \ No newline at end of file diff --git a/app/ts/app.ts b/app/ts/app.ts index aa6eb810..c5f5ce4f 100644 --- a/app/ts/app.ts +++ b/app/ts/app.ts @@ -2,121 +2,221 @@ * Main application entry point (TypeScript) */ -import { DOM } from './modules/config'; -import { initChat } from './modules/messages'; -import { sendMessage, pauseRequest } from './modules/chat'; -import { loadGraphs, handleFileUpload, onGraphChange } from './modules/graphs'; -import { getSelectedGraph } from './modules/graph_select'; +import { DOM } from "./modules/config"; +import { initChat } from "./modules/messages"; +import { sendMessage, pauseRequest } from "./modules/chat"; +import { loadGraphs, handleFileUpload, onGraphChange } from "./modules/graphs"; +import { getSelectedGraph } from "./modules/graph_select"; import { - toggleContainer, - showResetConfirmation, - hideResetConfirmation, - handleResetConfirmation, - setupUserProfileDropdown, - setupThemeToggle, - setupToolbar, - handleWindowResize, - setupCustomDropdown -} from './modules/ui'; + toggleContainer, + showResetConfirmation, + hideResetConfirmation, + handleResetConfirmation, + setupUserProfileDropdown, + setupThemeToggle, + setupToolbar, + handleWindowResize, + setupCustomDropdown, +} from "./modules/ui"; import { setupAuthenticationModal, setupDatabaseModal } from './modules/modals'; import { resizeGraph, showGraph } from './modules/schema'; import { setupTokenManagement } from './modules/tokens'; import { initLeftToolbar } from './modules/left_toolbar'; -import { resizeGraph } from './modules/schema'; async function loadAndShowGraph(selected: string | undefined) { - if (!selected) return; - try { - const resp = await fetch(`/graphs/${encodeURIComponent(selected)}/data`); - if (!resp.ok) { - console.error('Failed to load graph data:', resp.status, resp.statusText); - return; - } + if (!selected) return; + try { + const resp = await fetch(`/graphs/${encodeURIComponent(selected)}/data`); + if (!resp.ok) { + console.error("Failed to load graph data:", resp.status, resp.statusText); + return; + } - const data = await resp.json(); + const data = await resp.json(); - if (!data || !Array.isArray(data.nodes) || !Array.isArray(data.links)) { - console.warn('Graph data returned in unexpected shape, showing empty message', data); - return; - } + if (!data || !Array.isArray(data.nodes) || !Array.isArray(data.links)) { + console.warn( + "Graph data returned in unexpected shape, showing empty message", + data + ); + return; + } - const container = document.getElementById('schema-graph'); - if (container) container.innerHTML = ''; + const container = document.getElementById("schema-graph"); + if (container) container.innerHTML = ""; - showGraph(data); - } catch (err) { - console.error('Error fetching graph data:', err); - } + showGraph(data); + } catch (err) { + console.error("Error fetching graph data:", err); + } } function initializeApp() { - initChat(); - setupEventListeners(); - setupUIComponents(); - loadInitialData(); + initChat(); + setupEventListeners(); + setupUIComponents(); + loadInitialData(); } function setupEventListeners() { - DOM.submitButton?.addEventListener('click', sendMessage); - DOM.pauseButton?.addEventListener('click', pauseRequest); - DOM.messageInput?.addEventListener('keypress', (e: KeyboardEvent) => { - if ((e as KeyboardEvent).key === 'Enter') sendMessage(); - }); - - DOM.menuButton?.addEventListener('click', () => toggleContainer(DOM.menuContainer as HTMLElement)); - - DOM.schemaButton?.addEventListener('click', () => { - toggleContainer(DOM.schemaContainer as HTMLElement, async () => { - const selected = getSelectedGraph(); - if (!selected) return; - loadAndShowGraph(selected); - setTimeout(resizeGraph, 450); - }); - }); - - DOM.newChatButton?.addEventListener('click', showResetConfirmation); - DOM.resetConfirmBtn?.addEventListener('click', handleResetConfirmation); - DOM.resetCancelBtn?.addEventListener('click', hideResetConfirmation); - - DOM.resetConfirmationModal?.addEventListener('click', (e) => { - if (e.target === DOM.resetConfirmationModal) hideResetConfirmation(); + DOM.submitButton?.addEventListener("click", sendMessage); + DOM.pauseButton?.addEventListener("click", pauseRequest); + DOM.messageInput?.addEventListener("keypress", (e: KeyboardEvent) => { + if ((e as KeyboardEvent).key === "Enter") sendMessage(); + }); + + DOM.menuButton?.addEventListener("click", () => + toggleContainer(DOM.menuContainer as HTMLElement) + ); + + DOM.schemaButton?.addEventListener("click", () => { + toggleContainer(DOM.schemaContainer as HTMLElement, async () => { + const selected = getSelectedGraph(); + if (!selected) return; + loadAndShowGraph(selected); + setTimeout(resizeGraph, 450); }); + }); + + DOM.graphSelectRefresh?.addEventListener("click", async () => { + const selected = getSelectedGraph(); + const refreshButton = DOM.graphSelectRefresh; + + if (!refreshButton) return; + + if ( + !selected || + selected === "Select database" || + selected === "No databases" + ) + return alert("Please select a database to refresh"); + + refreshButton.classList.add("loading"); + + const result = await fetch( + `/graphs/${encodeURIComponent(selected)}/refresh`, + { + method: "POST", + } + ); + + if (!result.ok) { + console.error( + "Failed to refresh graph:", + result.status, + result.statusText + ); + return; + } - document.addEventListener('keydown', (e) => { - if ((e as KeyboardEvent).key === 'Escape' && DOM.resetConfirmationModal && DOM.resetConfirmationModal.style.display === 'flex') { - hideResetConfirmation(); + if (result.body) { + const reader = result.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete messages separated by boundary + const messages = buffer.split("|||FALKORDB_MESSAGE_BOUNDARY|||"); + buffer = messages.pop() || ""; // Keep incomplete message in buffer + + for (const message of messages) { + if (message.trim()) { + try { + const parsed = JSON.parse(message.trim()); + + if (parsed.type === "reasoning_step") { + refreshButton.title += ` ${parsed.message}\n`; + } else if (parsed.type === "final_result") { + refreshButton.title += ` ${parsed.message}`; + } + } catch (e) { + console.warn("Failed to parse message:", message, e); + } + } + } } - }); + } finally { + reader.releaseLock(); + } + } + + if (DOM.schemaContainer && DOM.schemaContainer.classList.contains("open")) { + await loadAndShowGraph(selected); + setTimeout(resizeGraph, 450); + } - // Legacy select is hidden; custom UI will trigger load via graph_select helper - document.getElementById('graph-options')?.addEventListener('click', async () => { - onGraphChange(); - const selected = getSelectedGraph(); - if (!selected) return; - if (DOM.schemaContainer && DOM.schemaContainer.classList.contains('open')) { - loadAndShowGraph(selected); - setTimeout(resizeGraph, 450); + refreshButton.classList.remove("loading"); + refreshButton.title = ""; + }); + + DOM.newChatButton?.addEventListener("click", showResetConfirmation); + DOM.resetConfirmBtn?.addEventListener("click", handleResetConfirmation); + DOM.resetCancelBtn?.addEventListener("click", hideResetConfirmation); + + DOM.resetConfirmationModal?.addEventListener("click", (e) => { + if (e.target === DOM.resetConfirmationModal) hideResetConfirmation(); + }); + + document.addEventListener("keydown", (e) => { + if ( + (e as KeyboardEvent).key === "Escape" && + DOM.resetConfirmationModal && + DOM.resetConfirmationModal.style.display === "flex" + ) { + hideResetConfirmation(); + } + }); + + // Legacy select is hidden; custom UI will trigger load via graph_select helper + document + .getElementById("graph-options") + ?.addEventListener("click", async () => { + onGraphChange(); + const selected = getSelectedGraph(); + if (!selected) return; + const selectedLabel = document.getElementById("graph-selected"); + if (selectedLabel) { + const dropdownText = selectedLabel.querySelector( + ".dropdown-text" + ) as HTMLElement | null; + if (dropdownText) { + dropdownText.textContent = selected; + dropdownText.title = selected; } + } + if ( + DOM.schemaContainer && + DOM.schemaContainer.classList.contains("open") + ) { + loadAndShowGraph(selected); + setTimeout(resizeGraph, 450); + } }); - DOM.fileUpload?.addEventListener('change', handleFileUpload); - window.addEventListener('resize', handleWindowResize); + DOM.fileUpload?.addEventListener("change", handleFileUpload); + window.addEventListener("resize", handleWindowResize); } function setupUIComponents() { - setupUserProfileDropdown(); - setupThemeToggle(); - setupAuthenticationModal(); - setupDatabaseModal(); - setupTokenManagement(); - setupToolbar(); - // initialize left toolbar behavior (burger, responsive default) - initLeftToolbar(); - setupCustomDropdown(); + setupUserProfileDropdown(); + setupThemeToggle(); + setupAuthenticationModal(); + setupDatabaseModal(); + setupTokenManagement(); + setupToolbar(); + // initialize left toolbar behavior (burger, responsive default) + initLeftToolbar(); + setupCustomDropdown(); } function loadInitialData() { - loadGraphs(); + loadGraphs(); } -document.addEventListener('DOMContentLoaded', initializeApp); +document.addEventListener("DOMContentLoaded", initializeApp); diff --git a/app/ts/modules/config.ts b/app/ts/modules/config.ts index e85da9f7..7717f096 100644 --- a/app/ts/modules/config.ts +++ b/app/ts/modules/config.ts @@ -58,6 +58,7 @@ export const DOM = { leftToolbarInner: getElement('left-toolbar-inner'), expInstructions: getElement('instructions-textarea'), inputContainer: getElement('input-container'), + graphSelectRefresh: getElement('graph-select-refresh'), resetConfirmationModal: getElement('reset-confirmation-modal'), resetConfirmBtn: getElement('reset-confirm-btn'), resetCancelBtn: getElement('reset-cancel-btn') diff --git a/app/ts/modules/ui.ts b/app/ts/modules/ui.ts index c72355f5..28fdf4ff 100644 --- a/app/ts/modules/ui.ts +++ b/app/ts/modules/ui.ts @@ -190,10 +190,14 @@ export function handleWindowResize() { } export function setupCustomDropdown() { - const dropdown = document.getElementById('database-type-dropdown') as HTMLElement; - const selected = dropdown?.querySelector('.dropdown-selected') as HTMLElement; - const options = dropdown?.querySelector('.dropdown-options') as HTMLElement; - const hiddenInput = document.getElementById('database-type-select') as HTMLInputElement; + const dropdown = document.getElementById( + "database-type-dropdown" + ) as HTMLElement; + const selected = dropdown?.querySelector(".dropdown-selected") as HTMLElement; + const options = dropdown?.querySelector(".dropdown-options") as HTMLElement; + const hiddenInput = document.getElementById( + "database-type-select" + ) as HTMLInputElement; selected.addEventListener("click", (e) => { e.stopPropagation(); @@ -201,9 +205,11 @@ export function setupCustomDropdown() { selected.classList.toggle("active"); }); - options.addEventListener('click', (e) => { - const option = (e.target as HTMLElement).closest('.dropdown-option') as HTMLElement; - if (!option) return; + options.addEventListener("click", (e) => { + const option = (e.target as HTMLElement).closest( + ".dropdown-option" + ) as HTMLElement; + if (!option) return; const value = option.dataset.value || ""; const text = option.querySelector("span")?.textContent || "";