Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 31 additions & 22 deletions web/pgadmin/llm/static/js/ai_tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import AIReport from './AIReport';
import { AllPermissionTypes, BROWSER_PANELS } from '../../../browser/static/js/constants';
import getApiInstance from '../../../static/js/api_instance';
import MainMenuFactory from '../../../browser/static/js/MainMenuFactory';
import usePreferences from '../../../preferences/static/js/store';
import pgAdmin from 'sources/pgadmin';
import url_for from 'sources/url_for';

// AI Reports Module
Expand All @@ -37,7 +39,15 @@ define([

this.initialized = true;

// Check LLM status and only register menus if enabled
const getProvider = (state) => state.getPreferences('ai', 'default_provider')?.value;
let provider = getProvider(usePreferences.getState());
usePreferences.subscribe((state) => {
const nextProvider = getProvider(state);
if (provider !== nextProvider) {
provider = nextProvider;
this.checkLLMStatus();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
this.checkLLMStatus();

return this;
Expand Down Expand Up @@ -167,27 +177,26 @@ define([
},

// Check if LLM is configured, register menus only if system-enabled
checkLLMStatus: function() {
const api = getApiInstance();
api.get(url_for('llm.status'))
.then((res) => {
if (res?.data?.success) {
this.llmEnabled = res.data.data?.enabled || false;
this.llmSystemEnabled = res.data.data?.system_enabled || false;
}
this.llmStatusChecked = true;

// Only register menus if LLM is enabled at system level
if (this.llmSystemEnabled) {
this.registerMenus();
MainMenuFactory.createMainMenus();
}
})
.catch(() => {
this.llmEnabled = false;
this.llmSystemEnabled = false;
this.llmStatusChecked = true;
});
checkLLMStatus: async function() {
const request = getApiInstance().get(url_for('llm.status'));
this.statusRequest = request;
const res = await request.catch(() => null);
// An earlier save can finish after a newer one.
if (request !== this.statusRequest) return;
this.statusRequest = null;
const status = res?.data?.success ? res.data.data : null;
this.llmEnabled = Boolean(status?.enabled);
this.llmSystemEnabled = Boolean(status?.system_enabled);
this.llmStatusChecked = true;
pgAdmin.llm_enabled = this.llmEnabled;

if (this.llmSystemEnabled && !this.menusRegistered) {
this.registerMenus();
this.menusRegistered = true;
MainMenuFactory.createMainMenus();
} else if (this.menusRegistered) {
MainMenuFactory.enableDisableMenus();
}
},

// Get the node type from tree item
Expand Down
3 changes: 3 additions & 0 deletions web/pgadmin/tools/sqleditor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,8 @@ def panel(trans_id):
trans_id: unique transaction id
"""

from pgadmin.llm.utils import is_llm_enabled

params = None
if request.args:
params = {k: v for k, v in request.args.items()}
Expand Down Expand Up @@ -442,6 +444,7 @@ def panel(trans_id):
params['user'] = underscore_escape(s.username)
if 'role' not in params and s.role:
params['role'] = underscore_escape(s.role)
params['llm_enabled'] = is_llm_enabled()
params['layout'] = get_setting('SQLEditor/Layout')
params['macros'] = get_user_macros()
params['is_desktop_mode'] = current_app.PGADMIN_RUNTIME
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
maximizable: true,
tabs: [
LayoutDocker.getPanel({id: PANELS.QUERY, title: gettext('Query'), content: <Query onTextSelect={(text) => setSelectedText(text)} setQtStatePartial={setQtStatePartial}/>}),
...(pgAdmin.llm_enabled ? [LayoutDocker.getPanel({id: PANELS.AI_ASSISTANT, title: gettext('AI Assistant'), content: <NLQChatPanel />})] : []),
...(params.llm_enabled ? [LayoutDocker.getPanel({id: PANELS.AI_ASSISTANT, title: gettext('AI Assistant'), content: <NLQChatPanel />})] : []),
LayoutDocker.getPanel({id: PANELS.HISTORY, title: gettext('Query History'), content: <QueryHistory />}),
],
},
Expand Down Expand Up @@ -1159,6 +1159,7 @@ QueryToolComponent.propTypes = {
server_name: PropTypes.string,
database_name: PropTypes.string,
layout: PropTypes.string,
llm_enabled: PropTypes.bool,
fileName: PropTypes.string,
storage: PropTypes.string,
}),
Expand Down
35 changes: 35 additions & 0 deletions web/pgadmin/tools/sqleditor/tests/test_panel_ai_availability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################

import json
from types import SimpleNamespace
from unittest.mock import patch

from pgadmin.utils.route import BaseTestGenerator


class TestPanelAIAvailability(BaseTestGenerator):
"""Each new Query Tool layout gets the current server-side AI status."""

@patch('pgadmin.tools.sqleditor.render_template', return_value='')
@patch('pgadmin.tools.sqleditor.get_server')
@patch('pgadmin.llm.utils.is_llm_enabled')
def runTest(self, enabled, get_server, render):
get_server.return_value = SimpleNamespace(
shared=False, bgcolor=None, name='test', username='test', role='')
for value in (True, False):
with self.subTest(enabled=value):
enabled.return_value = value
response = self.tester.post('/sqleditor/panel/1', query_string={
'sid': 1, 'title': 'Query Tool', 'selectedNodeInfo': '{}',
'llm_enabled': str(not value).lower(),
})
self.assertEqual(response.status_code, 200)
params = json.loads(render.call_args.kwargs['params'])
self.assertIs(params['llm_enabled'], value)
83 changes: 83 additions & 0 deletions web/regression/javascript/llm/ai_tools_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////

import {waitFor} from '@testing-library/react';

jest.unmock('zustand');
jest.mock('../../../pgadmin/llm/static/js/AIReport', () => jest.fn());
jest.mock('../../../pgadmin/static/js/api_instance', () => () => ({get: mockGet}));
jest.mock('sources/url_for', () => (name) => name);
jest.mock('sources/pgadmin', () => ({}));
jest.mock('../../../pgadmin/browser/static/js/MainMenuFactory', () => ({
createMainMenus: jest.fn(), enableDisableMenus: jest.fn(),
}));

const mockGet = jest.fn();
const status = (enabled) => ({data: {success: true, data: {enabled, system_enabled: true}}});

describe('AI tools after preferences are saved', () => {
let browser, pgAdmin, preferences, tools;

beforeEach(() => {
jest.resetModules();
mockGet.mockReset();
browser = {add_menus: jest.fn(), add_menu_category: jest.fn()};
pgAdmin = require('sources/pgadmin');
preferences = require('../../../pgadmin/preferences/static/js/store').default;
global.define = (_deps, factory) => factory((s) => s, browser);
try {
require('../../../pgadmin/llm/static/js/ai_tools');
} finally {
delete global.define;
}
tools = browser.AITools;
});

// PreferencesComponent calls cache() after a successful save.
async function refreshPreferences(provider) {
mockGet.mockResolvedValueOnce({data: [{module: 'ai', name: 'default_provider', value: provider}]});
await preferences.getState().cache();
}

it('updates report menus and the flag for new Query Tools without registering duplicate menus', async () => {
await refreshPreferences('');
mockGet.mockResolvedValue(status(false));
tools.init();
await waitFor(() => expect(tools.llmStatusChecked).toBe(true));
expect(tools.checkLLMEnabled()).toBe(false);
mockGet.mockResolvedValue(status(true));
await refreshPreferences('ollama');
await waitFor(() => expect(pgAdmin.llm_enabled).toBe(true));
expect(tools.checkLLMEnabled()).toBe(true);

tools.init();
const calls = mockGet.mock.calls.length;
await refreshPreferences('ollama');
expect(mockGet.mock.calls.slice(calls)).toEqual([['preferences.get_all']]);
mockGet.mockResolvedValue(status(false));
await refreshPreferences('');
await waitFor(() => expect(pgAdmin.llm_enabled).toBe(false));
expect(tools.checkLLMEnabled()).toBe(false);
expect(browser.add_menus).toHaveBeenCalledTimes(1);
});

it('keeps a failed lookup disabled even if an older successful request finishes later', async () => {
let resolveFirst;
mockGet.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve; }));
tools.init();
mockGet.mockRejectedValue(new Error('status unavailable'));
await refreshPreferences('openai');
await waitFor(() => expect(tools.llmStatusChecked).toBe(true));
resolveFirst(status(true));
await new Promise(resolve => setTimeout(resolve, 0));
expect(tools.checkLLMEnabled()).toBe(false);
expect(pgAdmin.llm_enabled).toBe(false);
expect(browser.add_menus).not.toHaveBeenCalled();
});
});