From c045c1be688b4722386dc1ae6053effc152d73b7 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 13 Feb 2026 09:36:53 -0800 Subject: [PATCH] Source query chat improvements --- .../src/org/labkey/query/view/sourceQuery.jsp | 396 ++++++++++-------- 1 file changed, 225 insertions(+), 171 deletions(-) diff --git a/query/src/org/labkey/query/view/sourceQuery.jsp b/query/src/org/labkey/query/view/sourceQuery.jsp index c12fa995bcd..967346caab2 100644 --- a/query/src/org/labkey/query/view/sourceQuery.jsp +++ b/query/src/org/labkey/query/view/sourceQuery.jsp @@ -23,7 +23,6 @@ <%@ page import="org.labkey.api.view.template.ClientDependencies" %> <%@ page import="org.labkey.query.controllers.QueryController" %> <%@ page import="org.labkey.api.mcp.McpService" %> -<%@ page import="org.labkey.api.util.JavaScriptFragment" %> <%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> <%@ page extends="org.labkey.api.jsp.JspBase" %> <%! @@ -53,7 +52,7 @@ boolean canEdit = queryDef.canEdit(getUser()); boolean canEditMetadata = queryDef.canEditMetadata(getUser()); boolean canDelete = queryDef.canDelete(getUser()); - boolean isChatReady = McpService.get().isReady(); + boolean isChatReady = canEdit && McpService.get().isReady(); %> <% if (isChatReady) { %> -<%-- should use Ext4 Panel for layout, but this is just a prototype anyway --%> - - - -
- +
+
+
-
-
- -
+ +
+
+ +
+ <% } else { %> - +
<% } %> @@ -261,170 +298,187 @@ }); <% if (isChatReady) { %> - const isChatReady = <%=JavaScriptFragment.bool(isChatReady)%>; + function updateLayoutHeight() { + const el = document.getElementById('querySourceLayout'); + const top = el.getBoundingClientRect().top; + el.style.height = Math.max(400, window.innerHeight - top - 60) + 'px'; + } + window.addEventListener('resize', updateLayoutHeight); + updateLayoutHeight(); - const resizeFn = function(evt) - { - // console.log(evt); - // console.log("window " + window.innerHeight + " " + window.innerWidth); - var el = document.getElementById("querySourceLayout"); - if (el) - { - const rect = document.getElementById("querySourceLayout").getBoundingClientRect(); - const width = Math.max(600, window.innerWidth-rect.left-40) - const height = Math.max(400, window.innerHeight-rect.top-40); - el.style.width = width + "px"; - el.style.height = height + "px"; - panel.setWidth(Math.max(400,width*0.75)); - panel.setHeight(Math.max(650,height)); - document.getElementById("geminiPrompt").style.width = Math.max(200,width*0.25) + 'px'; - document.getElementById("chatHistory").style.height = Math.max(200,height-100) + 'px'; + const editorPanelEl = document.getElementById('query-editor-panel'); + new ResizeObserver(function() { + panel.setWidth(editorPanelEl.clientWidth); + panel.setHeight(editorPanelEl.clientHeight); panel.updateLayout(); + }).observe(editorPanelEl); + + function addChatItem(item) { + const history = document.getElementById('chatHistory'); + history.appendChild(item); + history.scrollTo({ top: history.scrollHeight, behavior: 'smooth' }); } - }; - window.onresize = resizeFn; - resizeFn({}); - var elPrompt = document.getElementById('geminiPrompt'); + function endLoading() { + const el = document.getElementById('loadingPrompt'); + if (el) { + el.remove(); + } + } - function scrollToBottom() - { - const div = document.getElementById("chatHistory"); - div.scrollTo({ top: div.scrollHeight, behavior: "smooth" }); - } - function appendUserPrompt(text) - { - const chatItem = document.createElement('div'); - chatItem.className = 'chatItem userPrompt'; - chatItem.innerText = text; - document.getElementById('chatHistory').appendChild(chatItem); - scrollToBottom(); - } - function appendTextResponse(text) - { - const chatItem = document.createElement('div'); - chatItem.className = 'chatItem genaiResponse'; - chatItem.innerText = text; - document.getElementById('chatHistory').appendChild(chatItem); - scrollToBottom(); - } - function appendHtmlResponse(html) - { - const chatItem = document.createElement('div'); - chatItem.className = 'chatItem genaiResponse'; - chatItem.innerHTML = html; - document.getElementById('chatHistory').appendChild(chatItem); - scrollToBottom(); - } - function appendSqlResponse(text) - { - const chatItem = document.createElement('div'); - chatItem.className = 'chatItem sqlResponse'; - const copy = document.createElement("i"); - copy.className = "fa fa-copy"; - chatItem.appendChild(copy); - const pre = document.createElement('pre'); - pre.innerText = text; - chatItem.appendChild(pre); - chatItem.onclick = function(evt) - { - navigator.clipboard.writeText(evt.target.parentElement.innerText); - return false; - }; - document.getElementById('chatHistory').appendChild(chatItem); - scrollToBottom(); - } + function startLoading() { + const chatItem = document.createElement('div'); + chatItem.id = 'loadingPrompt'; + chatItem.className = 'chatItem genaiResponse'; + const text = document.createElement('span'); + const spinner = document.createElement('i'); + spinner.className = 'fa fa-spinner fa-spin'; + text.appendChild(spinner); + text.appendChild(document.createTextNode(' Loading...')); + + chatItem.appendChild(text); + addChatItem(chatItem); + } - function initChat() - { - if (!isChatReady) - return; - - // initialize conversation with the current SQL - // CONSIDER: update before every prompt if the SQL has changed - var schemaName = <%=q(queryDef.getSchemaPath().getName())%>; - var queryText = null; - try - { - queryText = Ext4.getCmp("qep").getSourceEditor().getValue(); + function createTimestamp() { + const span = document.createElement('span'); + span.className = 'chatTimestamp'; + span.textContent = new Date().toLocaleTimeString(); + return span; + } + + function appendUserPrompt(text) { + const chatItem = document.createElement('div'); + chatItem.className = 'chatItem userPrompt'; + chatItem.appendChild(createTimestamp()); + chatItem.appendChild(document.createTextNode(text)); + addChatItem(chatItem); } - catch(ex) - { - // pass; + + function appendTextResponse(text) { + const chatItem = document.createElement('div'); + chatItem.className = 'chatItem genaiResponse'; + chatItem.appendChild(createTimestamp()); + chatItem.appendChild(document.createTextNode(text)); + addChatItem(chatItem); } - var initPrompt = ''; - if (schemaName) - initPrompt += "The current schema is " + schemaName + ".\n"; - - if (queryText) - initPrompt += "This is my current SQL query, this may be relevant to subsequent prompts:\n```" + queryText + "```\n"; - - if (initPrompt) - { - var url = new URL('./query-queryagent.api', window.location.href); - url.searchParams.set('schemaName', schemaName || ''); - url.searchParams.set('prompt', initPrompt); - var req = new XMLHttpRequest(); - req.open('GET', url.toString(), true); - req.send(); + function appendHtmlResponse(html) { + const chatItem = document.createElement('div'); + chatItem.className = 'chatItem genaiResponse'; + chatItem.appendChild(createTimestamp()); + chatItem.insertAdjacentHTML('beforeend', html); + addChatItem(chatItem); } - } - let firstChat = true; - if (isChatReady) - { - elPrompt.addEventListener('keydown', function (ev) - { - var isEnter = (ev.key === 'Enter') || (ev.keyCode === 13); - if (ev.shiftKey && isEnter) - { - const prompt = elPrompt.value; - if (!prompt) - return; - if (firstChat) - { - firstChat = false; - initChat(); - } - appendUserPrompt(prompt); - elPrompt.value = ''; - // TODO waiting/thinking UI - // Build URL with same base as current document, endpoint /query-queryagent.api and prompt parameter - var url = new URL('./query-queryagent.api', window.location.href); - url.searchParams.set('prompt', prompt); - var req = new XMLHttpRequest(); - req.open('GET', url.toString(), true); - req.onreadystatechange = function () { - if (req.readyState === 4) { - if (req.status >= 200 && req.status < 300) { - var responseJson = JSON.parse(req.responseText); - var responseText = responseJson['text']; - var responseHtml = responseJson['html']; - var responseSql = responseJson['sql']; - if (responseSql) { - Ext4.getCmp("qep").getSourceEditor().setValue(responseSql); - appendSqlResponse(responseSql); - } - if (responseHtml) { - appendHtmlResponse(responseHtml); - } - if (responseText) { - appendTextResponse(responseText); - } - } else { - appendTextResponse('Request failed: ' + req.status + ' ' + (req.statusText || '')); - } - } - }; - req.send(); - ev.preventDefault(); - ev.stopPropagation(); + function appendSqlResponse(text) { + const chatItem = document.createElement('div'); + chatItem.className = 'chatItem sqlResponse'; + chatItem.appendChild(createTimestamp()); + const copy = document.createElement('i'); + copy.className = 'fa fa-copy'; + chatItem.appendChild(copy); + const pre = document.createElement('pre'); + pre.innerText = text; + chatItem.appendChild(pre); + chatItem.onclick = function() { + navigator.clipboard.writeText(text); return false; + }; + addChatItem(chatItem); + } + + async function queryAgent(prompt) { + return new Promise((resolve, reject) => { + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('query', 'queryagent.api', undefined, { prompt: prompt }), + success: LABKEY.Utils.getCallbackWrapper(response => { + resolve(response); + }), + failure: LABKEY.Utils.getCallbackWrapper((_, req) => { + reject(req); + }, undefined, true), + }); + }); + } + + let firstChat = true; + + function initialPrompt() { + if (!firstChat) + return ''; + + firstChat = false; + + // initialize conversation with the current SQL + // CONSIDER: update before every prompt if the SQL has changed + const schemaName = <%=q(queryDef.getSchemaPath().getName())%>; + let queryText; + try { + queryText = Ext4.getCmp('qep').getSourceEditor().getValue(); } - return true; - }); - } + catch (ex) { + // pass; + } + + let initPrompt = ''; + if (schemaName) { + initPrompt += "The current schema is " + schemaName + ".\n"; + } + + if (queryText) { + initPrompt += "This is my current SQL query, this may be relevant to subsequent prompts:\n```" + queryText + "```\n"; + } + + if (initPrompt) { + initPrompt += '\n'; + } + + return initPrompt; + } + + async function executePrompt(prompt) { + if (!prompt) return; + appendUserPrompt(prompt); + + try { + startLoading(); + + const response = await queryAgent(initialPrompt() + prompt); + + if (response.sql) { + Ext4.getCmp('qep').getSourceEditor().setValue(response.sql); + appendSqlResponse(response.sql); + } else if (response.html) { + appendHtmlResponse(response.html); + } else if (response.text) { + appendTextResponse(response.text); + } + } catch (req) { + appendTextResponse('Request failed: ' + req.status + ' ' + (req.statusText || '')); + } finally { + endLoading(); + } + } + + function onKeyDown(evt) { + const isShiftEnter = evt.shiftKey && ((evt.key === 'Enter') || (evt.keyCode === 13)); + if (!isShiftEnter) return true; + + const prompt = evt.target.value; + if (!prompt) + return; + + // Do not await + executePrompt(prompt); + evt.target.value = ''; + + evt.preventDefault(); + evt.stopPropagation(); + return false; + } + + document.getElementById('geminiPrompt').addEventListener('keydown', onKeyDown); <% } %> });