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 || "";