diff --git a/api/src/org/labkey/api/mcp/AbstractAgentAction.java b/api/src/org/labkey/api/mcp/AbstractAgentAction.java index 159695afe78..a9cdf2f0a49 100644 --- a/api/src/org/labkey/api/mcp/AbstractAgentAction.java +++ b/api/src/org/labkey/api/mcp/AbstractAgentAction.java @@ -4,32 +4,48 @@ import com.google.genai.errors.ServerException; import jakarta.servlet.http.HttpSession; import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.json.JSONObject; import org.labkey.api.action.ReadOnlyApiAction; +import org.labkey.api.util.GUID; import org.labkey.api.util.HtmlString; +import org.labkey.api.util.SessionHelper; import org.springframework.ai.chat.client.ChatClient; import org.springframework.validation.BindException; +import org.springframework.validation.Errors; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import static org.apache.commons.lang3.StringUtils.isNotBlank; +import static org.labkey.api.action.SpringActionController.ERROR_GENERIC; /** - * "agent" it is too strong a word, but if you want to create a tools specific chat endpoint then + * "Agent" it is too strong a word, but if you want to create a tools-specific chat endpoint, then * start here. * First implement getServicePrompt() to tell your "agent its mission. You can also listen in on the - * conversation to help you user get the right results. + * conversation to help your user get the right results. */ public abstract class AbstractAgentAction extends ReadOnlyApiAction { + private static final int MAX_ISSUED_CONVERSATION_IDS = 16; + + protected GUID conversationId; + protected abstract String getAgentName(); protected abstract String getServicePrompt(); protected ChatClient getChat(boolean create) { - HttpSession session = getViewContext().getRequest().getSession(true); - ChatClient chatSession = McpService.get().getChat(session, getAgentName(), this::getServicePrompt, create); + String conversationName = getAgentName() + ":" + getConversationId(); + + HttpSession session = getViewContext().getSession(); + ChatClient chatSession = McpService.get().getChat(session, conversationName, this::getServicePrompt, create); + return chatSession; } @@ -43,38 +59,68 @@ protected String handleEscape(String prompt) ChatClient chatSession = getChat(false); // CONSIDER: getChat(boolean ifStarted) if (null != chatSession) McpService.get().close(getViewContext().getSession(), chatSession); - return "OK, let's start over."; + getIssuedConversationIds(getViewContext().getSession()).remove(conversationId); + return "OK, let's start over."; } } return null; } + @Override + public void validateForm(F form, Errors errors) + { + // Only honor a client-supplied conversationId if this agent previously issued this session that + // id. Otherwise, generate a fresh one. This prevents a same-session caller from splicing into + // another conversation by guessing or replaying a GUID. + Set issued = getIssuedConversationIds(getViewContext().getSession()); + String supplied = form.getConversationId(); + if (supplied != null) + { + GUID candidate; + + try + { + candidate = new GUID(supplied); + } + catch (IllegalArgumentException e) + { + errors.rejectValue("conversationId", ERROR_GENERIC, "Invalid conversationId"); + return; + } + + if (issued.contains(candidate)) + { + conversationId = candidate; + return; + } + } + conversationId = new GUID(); + issued.add(conversationId); + } + @Override public Object execute(PromptForm form, BindException errors) throws Exception { - try (var mcpPush = McpContext.withContext(getViewContext())) + try (var _ = McpContext.withContext(getViewContext())) { String prompt = form.getPrompt(); - String escapeResponse = handleEscape(prompt); + JSONObject escapeResponse = escapeResponse(prompt); if (null != escapeResponse) - { - return new JSONObject(Map.of( - "contentType", "text/plain", - "response", escapeResponse, - "success", Boolean.TRUE)); - } + return escapeResponse; // call getChat() after handleEscape() ChatClient chatSession = getChat(true); if (null == chatSession) + { return new JSONObject(Map.of( "contentType", "text/plain", "response", "Service is not ready yet", "success", Boolean.FALSE)); + } McpService.MessageResponse response = McpService.get().sendMessage(chatSession, prompt); - var ret = new JSONObject(Map.of("success", Boolean.TRUE)); + var ret = new JSONObject(Map.of("success", Boolean.TRUE, "conversationId", conversationId)); if (!HtmlString.isBlank(response.html())) { ret.put("contentType", "text/html"); @@ -101,11 +147,52 @@ else if (isNotBlank(response.text())) } catch (ClientException ex) { - var ret = new JSONObject(Map.of( - "text", ex.getMessage(), - "user", getViewContext().getUser().getName(), - "success", Boolean.FALSE)); - return ret; + return errorResponse(ex); } } + + protected @NotNull JSONObject errorResponse(Exception ex) + { + return new JSONObject(Map.of( + "text", ex.getMessage(), + "user", getViewContext().getUser().getName(), + "success", Boolean.FALSE)); + } + + protected @Nullable JSONObject escapeResponse(String prompt) + { + String escapeResponse = handleEscape(prompt); + if (null == escapeResponse) + return null; + + return new JSONObject(Map.of( + "contentType", "text/plain", + "text", escapeResponse, + "conversationId", conversationId, + "success", Boolean.TRUE)); + } + + protected GUID getConversationId() + { + return conversationId; + } + + private String getIssuedConversationIdsKey() + { + return getAgentName() + "#issuedConversationIds"; + } + + private Set getIssuedConversationIds(HttpSession session) + { + return SessionHelper.getAttribute(session, getIssuedConversationIdsKey(), () -> + Collections.synchronizedSet(Collections.newSetFromMap( + new LinkedHashMap<>(16, 0.75f, false) + { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) + { + return size() > MAX_ISSUED_CONVERSATION_IDS; + } + }))); + } } diff --git a/api/src/org/labkey/api/mcp/McpContext.java b/api/src/org/labkey/api/mcp/McpContext.java index 6078214d965..8601f942e61 100644 --- a/api/src/org/labkey/api/mcp/McpContext.java +++ b/api/src/org/labkey/api/mcp/McpContext.java @@ -8,6 +8,8 @@ import org.labkey.api.view.UnauthorizedException; import org.labkey.api.writer.ContainerUser; import org.springframework.ai.chat.model.ToolContext; + +import java.util.HashMap; import java.util.Map; /** @@ -19,11 +21,11 @@ public class McpContext implements ContainerUser { final User user; final Container container; + final Map attributes = new HashMap<>(); public McpContext(ContainerUser ctx) { - this.container = ctx.getContainer(); - this.user = ctx.getUser(); + this(ctx.getContainer(), ctx.getUser()); } public McpContext(Container container, User user) @@ -36,9 +38,24 @@ public McpContext(Container container, User user) public ToolContext getToolContext() { - return new ToolContext(Map.of("container", getContainer(), "user", getUser())); + Map map = new HashMap<>(attributes); + map.put("container", getContainer()); + map.put("user", getUser()); + return new ToolContext(map); } + public McpContext put(String key, Object value) + { + if ("container".equals(key) || "user".equals(key)) + throw new IllegalArgumentException("Reserved key: " + key); + attributes.put(key, value); + return this; + } + + public Object get(String key) + { + return attributes.get(key); + } @Override public Container getContainer() @@ -52,10 +69,9 @@ public User getUser() return user; } - // // I'd like to get away from using ThreadLocal, but I haven't - // researched if there are other ways to pass context around to Tools registerd by McpService + // researched if there are other ways to pass context around to Tools registered by McpService // private static final ThreadLocal contexts = new ThreadLocal(); diff --git a/api/src/org/labkey/api/mcp/McpInternal.java b/api/src/org/labkey/api/mcp/McpInternal.java new file mode 100644 index 00000000000..d7c1d7622b6 --- /dev/null +++ b/api/src/org/labkey/api/mcp/McpInternal.java @@ -0,0 +1,22 @@ +package org.labkey.api.mcp; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks an MCP tool as internal-only. Internal tools are available to in-process callers (e.g., + * {@link AbstractAgentAction}-backed agents that supply tool-specific context via {@link McpContext}) + * but are filtered out of the externally exposed MCP server: they do not appear in {@code listTools} + * and direct {@code callTool} requests for them are rejected. + *

+ * Use this for tools that require request-scoped context the LLM cannot reasonably supply on its own. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface McpInternal +{ + /** Optional reason surfaced in startup logs. */ + String value() default ""; +} diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java index 5021a86ec90..9bdaa93d772 100644 --- a/api/src/org/labkey/api/mcp/McpService.java +++ b/api/src/org/labkey/api/mcp/McpService.java @@ -1,6 +1,5 @@ package org.labkey.api.mcp; - import io.modelcontextprotocol.server.McpServerFeatures; import jakarta.servlet.http.HttpSession; import org.apache.logging.log4j.Logger; @@ -43,20 +42,20 @@ /// permission annotation is required, otherwise your tool will not be registered.** /// 4. Add `ToolContext` as the first parameter to the method /// 5. Add additional required or optional parameters to the method signature, as needed. Note that "required" is the -/// default. Again here, the parameter descriptions are very important. Provide examples of parameter values. +/// default. Again, here, the parameter descriptions are very important. Provide examples of parameter values. /// 6. Use the helper method `getContext(ToolContext)` to retrieve the current `Container` and `User` /// 7. Use the helper method `getUser(ToolContext)` in the rare cases where you need just a `User` /// 8. Perform additional permissions checking (beyond what the annotations offer), where appropriate /// 9. Filter all results to the current container, of course /// 10. For any error conditions, throw exceptions with detailed information. These will get translated into appropriate -/// failure responses and the LLM client will attempt to correct any problems (hopefully). +/// failure responses, and the LLM client will attempt to correct any problems (hopefully). /// 11. For success cases, return a String with a message or JSON content, for example, `JSONObject.toString()`. Spring /// has some limited ability to convert other objects into JSON strings, but we haven't experimented with that. See /// `DefaultToolCallResultConverter` and the ability to provide a custom result converter via the `@Tool` annotation. /// /// At registration time, the framework will: /// - Ensure all tools are annotated for permissions -/// - Ensure there aren't multiple tools with the same name +/// - Ensure there are not multiple tools with the same name /// /// On every tool request, before invoking any tool code, the framework will: /// - Authenticate the user or provide a guest user @@ -88,7 +87,7 @@ interface McpImpl { default ContainerUser getContext(ToolContext toolContext) { - User user = (User)toolContext.getContext().get("user"); + User user = getUser(toolContext); Container container = (Container)toolContext.getContext().get("container"); if (container == null) throw new McpException("No container path is set. Ask the user which container/folder they want to use (you can call listContainers to show available options), then call setContainer before retrying."); @@ -103,7 +102,7 @@ default User getUser(ToolContext toolContext) // Every MCP resource should call this on every invocation default void incrementResourceRequestCount(String resource) { - if (!OptionalFeatureService.get().isFeatureEnabled(ENABLE_MCP_SERVER_FLAG)) + if (!get().isEnabled()) throw new RuntimeException("The MCP server is not enabled for external requests. Consider toggling the experimental feature flag."); get().incrementResourceRequestCount(resource); @@ -123,6 +122,11 @@ static void setInstance(McpService service) ServiceRegistry.get().registerService(McpService.class, service); } + default boolean isEnabled() + { + return OptionalFeatureService.get().isFeatureEnabled(ENABLE_MCP_SERVER_FLAG); + } + boolean isReady(); // Register MCPs in Module.startup() @@ -153,22 +157,22 @@ default void register(McpImpl mcp) @Override ToolCallback @NotNull [] getToolCallbacks(); - default ChatClient getChat(HttpSession session, String agentName, Supplier systemPromptSupplier) + default ChatClient getChat(HttpSession session, String conversationName, Supplier systemPromptSupplier) { - return getChat(session, agentName, systemPromptSupplier, true); + return getChat(session, conversationName, systemPromptSupplier, true); } void saveSessionContainer(ToolContext context, Container container); void incrementResourceRequestCount(String resource); - ChatClient getChat(HttpSession session, String agentName, Supplier systemPromptSupplier, boolean createIfNotExists); + ChatClient getChat(HttpSession session, String conversationName, Supplier systemPromptSupplier, boolean createIfNotExists); void close(HttpSession session, ChatClient chat); record MessageResponse(String contentType, String text, HtmlString html) {} - /** get consolidated response (good for many text oriented agents/use-cases) */ + /** get a consolidated response (good for many text-oriented agents/use-cases) */ MessageResponse sendMessage(ChatClient chat, String message); /** get individual response parts, useful for agents that generate SQL or programmatic responses */ diff --git a/api/src/org/labkey/api/mcp/NoopMcpService.java b/api/src/org/labkey/api/mcp/NoopMcpService.java index 0bc5813398e..eed36135794 100644 --- a/api/src/org/labkey/api/mcp/NoopMcpService.java +++ b/api/src/org/labkey/api/mcp/NoopMcpService.java @@ -21,6 +21,12 @@ static McpService get() return INSTANCE; } + @Override + public boolean isEnabled() + { + return false; + } + @Override public boolean isReady() { @@ -30,19 +36,16 @@ public boolean isReady() @Override public void registerTools(@NotNull List tools, McpImpl mcp) { - } @Override public void registerPrompts(@NotNull List prompts) { - } @Override public void registerResources(@NotNull List resources) { - } @Override @@ -62,7 +65,7 @@ public void incrementResourceRequestCount(String resource) } @Override - public ChatClient getChat(HttpSession session, String agentName, Supplier systemPromptSupplier, boolean createIfNotExists) + public ChatClient getChat(HttpSession session, String conversationName, Supplier systemPromptSupplier, boolean createIfNotExists) { return null; } diff --git a/api/src/org/labkey/api/mcp/PromptForm.java b/api/src/org/labkey/api/mcp/PromptForm.java index a6017f4d266..61c58850ba7 100644 --- a/api/src/org/labkey/api/mcp/PromptForm.java +++ b/api/src/org/labkey/api/mcp/PromptForm.java @@ -2,8 +2,19 @@ public class PromptForm { + public String conversationId; public String prompt; + public String getConversationId() + { + return conversationId; + } + + public void setConversationId(String conversationId) + { + this.conversationId = conversationId; + } + public void setPrompt(String prompt) { this.prompt = prompt; diff --git a/api/src/org/labkey/api/util/PageFlowUtil.java b/api/src/org/labkey/api/util/PageFlowUtil.java index d8794ff538d..1ea8883b00a 100644 --- a/api/src/org/labkey/api/util/PageFlowUtil.java +++ b/api/src/org/labkey/api/util/PageFlowUtil.java @@ -50,6 +50,7 @@ import org.labkey.api.data.ContainerManager; import org.labkey.api.data.DataRegion; import org.labkey.api.data.Project; +import org.labkey.api.mcp.McpService; import org.labkey.api.miniprofiler.MiniProfiler; import org.labkey.api.miniprofiler.RequestInfo; import org.labkey.api.module.Module; @@ -2253,9 +2254,12 @@ public static JSONObject jsInitObject(ContainerUser context, @Nullable PageConfi json.put("helpLinkPrefix", HelpTopic.getHelpLinkPrefix()); json.put("jdkJavaDocLinkPrefix", HelpTopic.getJdkJavaDocLinkPrefix()); - if (AppProps.getInstance().isOptionalFeatureEnabled(NotificationMenuView.EXPERIMENTAL_NOTIFICATION_MENU)) + if (AppProps.getInstance().isOptionalFeatureEnabled(NotificationMenuView.EXPERIMENTAL_NOTIFICATION_MENU) && user != null) json.put("notifications", Map.of("unreadCount", NotificationService.get().getUnreadNotificationCountByUser(null, user.getUserId()))); + if (McpService.get().isEnabled()) + json.put("mcpReady", McpService.get().isReady()); + JSONObject defaultHeaders = new JSONObject(); defaultHeaders.put("X-ONUNAUTHORIZED", "UNAUTHORIZED"); diff --git a/assay/package-lock.json b/assay/package-lock.json index d798552208c..3e308c8d96b 100644 --- a/assay/package-lock.json +++ b/assay/package-lock.json @@ -8,7 +8,7 @@ "name": "assay", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -3741,9 +3741,9 @@ } }, "node_modules/@labkey/api": { - "version": "1.51.2", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.2.tgz", - "integrity": "sha512-WvP7cR/0taJYM2Tr9Cx4PC1EgearUVBk5+CbpUu57c9vN4z8aHnIcwJDuMaORM5DoUKGeqL70IOn8Z+QmPWTHw==", + "version": "1.51.3-fb-mcp-calc-cols.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.3-fb-mcp-calc-cols.0.tgz", + "integrity": "sha512-irA1mdns6EADwHYumFkniwN8Bmi2+XkPvF8p3tqLXc4A9VoSMbAPa4PeIPw5Mv1wEFT8wZ/yw6dlyn04bRbmqg==", "license": "Apache-2.0" }, "node_modules/@labkey/build": { @@ -3784,13 +3784,13 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.0.tgz", - "integrity": "sha512-JH0ddIpb3YkFJdYQZADFeIULcAe2O5UPfJ+Hs61WeraQ55iG847Ag0TloafDHneYSMSeBVlw4M4voxJtAS4Pjg==", + "version": "7.36.1-fb-mcp-calc-cols.5", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.5.tgz", + "integrity": "sha512-osed78xNJfG42fbogbxVvG8hldSDKiCcZLFIAf0vi9nVrDijNVqtP8eczZi8HVC1GAPtDwXPtEadLk9cCAArCQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.51.2", + "@labkey/api": "1.51.3-fb-mcp-calc-cols.0", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "~16.3.2", diff --git a/assay/package.json b/assay/package.json index 7c0f98e4d46..659c9223ae9 100644 --- a/assay/package.json +++ b/assay/package.json @@ -55,7 +55,7 @@ } }, "dependencies": { - "@labkey/components": "7.36.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5" }, "devDependencies": { "@labkey/build": "9.1.3", diff --git a/assay/src/client/AssayTypeSelect/AssayTypeSelect.tsx b/assay/src/client/AssayTypeSelect/AssayTypeSelect.tsx index cc66ea62711..f004dfe7a2e 100644 --- a/assay/src/client/AssayTypeSelect/AssayTypeSelect.tsx +++ b/assay/src/client/AssayTypeSelect/AssayTypeSelect.tsx @@ -1,25 +1,24 @@ -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { ActionURL, Ajax, getServerContext, Utils } from '@labkey/api'; +import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { ActionURL, Ajax, Utils } from '@labkey/api'; import { + AppContexts, AssayPicker, AssayPickerSelectionModel, AssayPickerTabs, GENERAL_ASSAY_PROVIDER_NAME, App as LabKeyApp, - ServerContextProvider, useServerContext, - withAppUser, } from '@labkey/components'; import './AssayTypeSelect.scss'; -function uploadXarFile(file: File, container: string): Promise { +function uploadXarFile(file: File, containerPath: string): Promise { return new Promise((resolve, reject) => { const form = new FormData(); form.append('file', file); Ajax.request({ - url: ActionURL.buildURL('experiment', 'assayXarFile', container), + url: ActionURL.buildURL('experiment', 'assayXarFile', containerPath), method: 'POST', form, success: Utils.getCallbackWrapper(() => { @@ -54,7 +53,7 @@ const AssayTypeSelect = memo(() => { const tab = useMemo(() => ActionURL.getParameter('tab'), []); const onCancel = useCallback(() => { - window.location.href = returnUrl || ActionURL.buildURL('project', 'begin', getServerContext().container.path); + window.location.href = returnUrl || ActionURL.buildURL('project', 'begin'); }, [returnUrl]); const onChange = useCallback((model: AssayPickerSelectionModel) => { @@ -62,17 +61,15 @@ const AssayTypeSelect = memo(() => { }, []); const onSubmit = useCallback(() => { - const container = assayPickerSelection.container ?? getServerContext().container.path; - if (assayPickerSelection.tab === AssayPickerTabs.XAR_IMPORT_TAB && assayPickerSelection.file) { - uploadXarFile(assayPickerSelection.file, assayPickerSelection.container).then(() => { + const { container, file, provider, tab } = assayPickerSelection; + if (tab === AssayPickerTabs.XAR_IMPORT_TAB && file) { + uploadXarFile(file, container).then(() => { window.location.href = ActionURL.buildURL('pipeline', 'status-showList', container); }); } else { window.location.href = ActionURL.buildURL('assay', 'designer', container, { - providerName: assayPickerSelection.provider - ? assayPickerSelection.provider.name - : GENERAL_ASSAY_PROVIDER_NAME, - returnUrl: returnUrl, + providerName: provider ? provider.name : GENERAL_ASSAY_PROVIDER_NAME, + returnUrl, }); } }, [assayPickerSelection, returnUrl]); @@ -119,13 +116,11 @@ const AssayTypeSelect = memo(() => { ); }); - AssayTypeSelect.displayName = 'AssayTypeSelect'; -export const App = memo(() => ( - +export const App: FC = () => ( + - -)); - + +); App.displayName = 'App'; diff --git a/core/package-lock.json b/core/package-lock.json index 31c1bdac4ce..4da2f4bfec2 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -8,7 +8,7 @@ "name": "labkey-core", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.0", + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5", "@labkey/themes": "1.9.3" }, "devDependencies": { @@ -3743,9 +3743,9 @@ } }, "node_modules/@labkey/api": { - "version": "1.51.2", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.2.tgz", - "integrity": "sha512-WvP7cR/0taJYM2Tr9Cx4PC1EgearUVBk5+CbpUu57c9vN4z8aHnIcwJDuMaORM5DoUKGeqL70IOn8Z+QmPWTHw==", + "version": "1.51.3-fb-mcp-calc-cols.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.3-fb-mcp-calc-cols.0.tgz", + "integrity": "sha512-irA1mdns6EADwHYumFkniwN8Bmi2+XkPvF8p3tqLXc4A9VoSMbAPa4PeIPw5Mv1wEFT8wZ/yw6dlyn04bRbmqg==", "license": "Apache-2.0" }, "node_modules/@labkey/build": { @@ -3786,13 +3786,13 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.0.tgz", - "integrity": "sha512-JH0ddIpb3YkFJdYQZADFeIULcAe2O5UPfJ+Hs61WeraQ55iG847Ag0TloafDHneYSMSeBVlw4M4voxJtAS4Pjg==", + "version": "7.36.1-fb-mcp-calc-cols.5", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.5.tgz", + "integrity": "sha512-osed78xNJfG42fbogbxVvG8hldSDKiCcZLFIAf0vi9nVrDijNVqtP8eczZi8HVC1GAPtDwXPtEadLk9cCAArCQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.51.2", + "@labkey/api": "1.51.3-fb-mcp-calc-cols.0", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "~16.3.2", diff --git a/core/package.json b/core/package.json index d97ffe8e65a..56c09c187b3 100644 --- a/core/package.json +++ b/core/package.json @@ -53,7 +53,7 @@ } }, "dependencies": { - "@labkey/components": "7.36.0", + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5", "@labkey/themes": "1.9.3" }, "devDependencies": { diff --git a/core/src/client/APIKeys/APIKeys.tsx b/core/src/client/APIKeys/APIKeys.tsx index 1f0b8b56c3e..f8a2e33a38c 100644 --- a/core/src/client/APIKeys/APIKeys.tsx +++ b/core/src/client/APIKeys/APIKeys.tsx @@ -14,19 +14,13 @@ * limitations under the License. */ import React, { FC } from 'react'; -import { APIKeysPanel, AppContextProvider, ServerContextProvider, withAppUser } from '@labkey/components'; -import { getServerContext } from '@labkey/api'; +import { APIKeysPanel, AppContexts } from '@labkey/components'; import '../DomainDesigner.scss'; -export const App: FC = () => { - const serverContext = withAppUser(getServerContext()); - - return ( - - - - - - ); -}; +export const App: FC = () => ( + + + +); +App.displayName = 'App'; diff --git a/core/src/client/AssayDesigner/AssayDesigner.tsx b/core/src/client/AssayDesigner/AssayDesigner.tsx index 518b97ca595..3af482aee78 100644 --- a/core/src/client/AssayDesigner/AssayDesigner.tsx +++ b/core/src/client/AssayDesigner/AssayDesigner.tsx @@ -13,23 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { ActionURL, Security, Utils, getServerContext, PermissionTypes } from '@labkey/api'; +import React, { FC } from 'react'; +import { ActionURL, getServerContext, PermissionTypes, Security, Utils } from '@labkey/api'; import { Alert, + AppContexts, AssayDesignerPanels, AssayProtocolModel, BeforeUnload, DomainFieldsDisplay, + GENERAL_ASSAY_PROVIDER_NAME, getProtocol, - LoadingSpinner, - ServerContext, - ServerContextProvider, - withAppUser, + getWebDavUrl, inferDomainFromFile, + LoadingSpinner, setDomainFields, - getWebDavUrl, - GENERAL_ASSAY_PROVIDER_NAME, } from '@labkey/components'; import '../DomainDesigner.scss'; @@ -43,11 +41,10 @@ type State = { protocolId: number; providerName?: string; returnUrl: string; - serverContext: ServerContext; }; -export class App extends React.Component { - private _dirty: boolean = false; +class AssayDesigner extends React.Component { + private _dirty = false; constructor(props) { super(props); @@ -69,7 +66,6 @@ export class App extends React.Component { copy, isLoadingModel: true, returnUrl, - serverContext: withAppUser(getServerContext()), }; } @@ -113,10 +109,10 @@ export class App extends React.Component { // Issue 45315: Support inferring the assay results domain from a file system file async resetDataDomainFromFile(model: AssayProtocolModel): Promise { - const { serverContext } = this.state; const { path, file } = ActionURL.getParameters(); const webdavUrl = - ActionURL.getBaseURL(true) + getWebDavUrl(serverContext.container.path, path + (path ? '/' : '') + file); + ActionURL.getBaseURL(true) + + getWebDavUrl(getServerContext().container.path, path + (path ? '/' : '') + file); try { const response = await inferDomainFromFile(webdavUrl, 3); @@ -127,7 +123,7 @@ export class App extends React.Component { return model.merge({ domains }) as AssayProtocolModel; } } catch (error) { - // no-op, file wasn't found or the domain couldn't be inferred so stick to the domain template from the assay protocol + // no-op, file wasn't found or the domain couldn't be inferred, so stick to the domain template from the assay protocol return model; } } @@ -184,7 +180,7 @@ export class App extends React.Component { } render() { - const { isLoadingModel, hasDesignAssayPerm, message, model, serverContext } = this.state; + const { isLoadingModel, hasDesignAssayPerm, message, model } = this.state; if (message) { return {message}; @@ -201,66 +197,71 @@ export class App extends React.Component { } return ( - - - {hasDesignAssayPerm && ( - - )} - {!hasDesignAssayPerm && ( - <> -

-
-
{model.name}
-
-
- - {this.renderReadOnlyProperty('Provider', model.providerName)} - {this.renderReadOnlyProperty('Description', model.description)} - {this.renderReadOnlyProperty('Plate Template', model.selectedPlateTemplate)} - {this.renderReadOnlyProperty('Detection Method', model.selectedDetectionMethod)} - {this.renderReadOnlyProperty( - 'Metadata Input Format', - model.selectedMetadataInputFormat - )} - {this.renderReadOnlyProperty('QC States', model.qcEnabled)} - {this.renderReadOnlyProperty( - 'Auto-Copy Data to Study', - model.autoCopyTargetContainer - ? model.autoCopyTargetContainer['path'] - : undefined - )} - {this.renderReadOnlyProperty('Import in Background', model.backgroundUpload)} - {this.renderReadOnlyProperty( - 'Transform Scripts', - model.protocolTransformScripts, - model.protocolTransformScripts.size === 0 - )} - {this.renderReadOnlyProperty( - 'Save Script Data for Debugging', - model.saveScriptFiles - )} - {this.renderReadOnlyProperty( - 'Module-Provided Scripts', - model.moduleTransformScripts, - model.moduleTransformScripts.size === 0 - )} - {this.renderReadOnlyProperty('Editable Runs', model.editableRuns)} - {this.renderReadOnlyProperty('Editable Results', model.editableResults)} -
-
+ + {hasDesignAssayPerm && ( + + )} + {!hasDesignAssayPerm && ( + <> +
+
+
{model.name}
- {model.domains - .map((domain, index) => ) - .toArray()} - - )} - - +
+ + {this.renderReadOnlyProperty('Provider', model.providerName)} + {this.renderReadOnlyProperty('Description', model.description)} + {this.renderReadOnlyProperty('Plate Template', model.selectedPlateTemplate)} + {this.renderReadOnlyProperty('Detection Method', model.selectedDetectionMethod)} + {this.renderReadOnlyProperty( + 'Metadata Input Format', + model.selectedMetadataInputFormat + )} + {this.renderReadOnlyProperty('QC States', model.qcEnabled)} + {this.renderReadOnlyProperty( + 'Auto-Copy Data to Study', + model.autoCopyTargetContainer + ? model.autoCopyTargetContainer['path'] + : undefined + )} + {this.renderReadOnlyProperty('Import in Background', model.backgroundUpload)} + {this.renderReadOnlyProperty( + 'Transform Scripts', + model.protocolTransformScripts, + model.protocolTransformScripts.size === 0 + )} + {this.renderReadOnlyProperty( + 'Save Script Data for Debugging', + model.saveScriptFiles + )} + {this.renderReadOnlyProperty( + 'Module-Provided Scripts', + model.moduleTransformScripts, + model.moduleTransformScripts.size === 0 + )} + {this.renderReadOnlyProperty('Editable Runs', model.editableRuns)} + {this.renderReadOnlyProperty('Editable Results', model.editableResults)} +
+
+
+ {model.domains + .map((domain, index) => ) + .toArray()} + + )} +
); } } + +export const App: FC = () => ( + + + +); +App.displayName = 'App'; diff --git a/core/src/client/AuthenticationConfiguration/AuthenticationConfiguration.tsx b/core/src/client/AuthenticationConfiguration/AuthenticationConfiguration.tsx index 7a6bbc671b5..c2a2ae16ff8 100644 --- a/core/src/client/AuthenticationConfiguration/AuthenticationConfiguration.tsx +++ b/core/src/client/AuthenticationConfiguration/AuthenticationConfiguration.tsx @@ -1,6 +1,6 @@ import React, { FC, memo, PureComponent, useMemo } from 'react'; import { ActionURL, Ajax, getServerContext, Utils } from '@labkey/api'; -import { LoadingSpinner, resolveErrorMessage, Alert, withAppUser, ServerContextProvider } from '@labkey/components'; +import { Alert, AppContexts, LoadingSpinner, resolveErrorMessage } from '@labkey/components'; import { GlobalSettings } from '../components/GlobalSettings'; import AuthConfigMasterPanel from '../components/AuthConfigMasterPanel'; @@ -347,12 +347,9 @@ class AuthenticationConfiguration extends PureComponent<{}, Partial> { } } -export const App: FC = memo(() => { - const initialServerContext = useMemo(() => withAppUser(getServerContext()), []); - - return ( - - - - ); -}); +export const App: FC = () => ( + + + +); +App.displayName = 'App'; diff --git a/core/src/client/DataClassDesigner/DataClassDesigner.tsx b/core/src/client/DataClassDesigner/DataClassDesigner.tsx index 4a19ca02075..bfb5960005e 100644 --- a/core/src/client/DataClassDesigner/DataClassDesigner.tsx +++ b/core/src/client/DataClassDesigner/DataClassDesigner.tsx @@ -13,16 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { FC } from 'react'; import { ActionURL, getServerContext } from "@labkey/api"; import { Alert, + AppContexts, BeforeUnload, DataClassDesigner, DataClassModel, fetchDataClass, LoadingSpinner, - withServerContext } from '@labkey/components'; import "../DomainDesigner.scss" @@ -113,4 +113,8 @@ class DataClassDesignerWrapper extends React.Component { } } -export const App = withServerContext(DataClassDesignerWrapper); +export const App: FC = () => ( + + + +); diff --git a/core/src/client/DatasetDesigner/DatasetDesigner.tsx b/core/src/client/DatasetDesigner/DatasetDesigner.tsx index 50eec7df716..fd1ace706d7 100644 --- a/core/src/client/DatasetDesigner/DatasetDesigner.tsx +++ b/core/src/client/DatasetDesigner/DatasetDesigner.tsx @@ -14,15 +14,15 @@ * limitations under the License. */ -import React, { PureComponent, ReactNode } from 'react'; +import React, { FC, PureComponent, ReactNode } from 'react'; import { Alert, + AppContexts, DatasetDesignerPanels, DatasetModel, fetchDatasetDesign, LoadingSpinner, BeforeUnload, - withServerContext, } from '@labkey/components'; import { ActionURL, Domain, getServerContext } from '@labkey/api'; @@ -141,4 +141,8 @@ class DatasetDesigner extends PureComponent { } } -export const App = withServerContext(DatasetDesigner); +export const App: FC = () => ( + + + +); diff --git a/core/src/client/DomainDesigner/DomainDesigner.tsx b/core/src/client/DomainDesigner/DomainDesigner.tsx index 08ae19c2d91..908ea8b66a5 100644 --- a/core/src/client/DomainDesigner/DomainDesigner.tsx +++ b/core/src/client/DomainDesigner/DomainDesigner.tsx @@ -14,11 +14,12 @@ * limitations under the License. */ -import React from 'react'; +import React, { FC } from 'react'; import { ActionURL, getServerContext } from '@labkey/api'; import { LoadingSpinner, Alert, + AppContexts, DomainForm, DomainDesign, fetchDomain, @@ -28,7 +29,6 @@ import { resolveErrorMessage, DomainException, Modal, - withServerContext, } from '@labkey/components'; import '../DomainDesigner.scss'; @@ -269,4 +269,8 @@ class DomainDesigner extends React.PureComponent> { } } -export const App = withServerContext(DomainDesigner); +export const App: FC = () => ( + + + +); diff --git a/core/src/client/IssuesListDesigner/IssuesListDesigner.tsx b/core/src/client/IssuesListDesigner/IssuesListDesigner.tsx index da294bfad17..18f36dbf467 100644 --- a/core/src/client/IssuesListDesigner/IssuesListDesigner.tsx +++ b/core/src/client/IssuesListDesigner/IssuesListDesigner.tsx @@ -13,16 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { FC } from 'react'; import { ActionURL, getServerContext } from "@labkey/api"; import { Alert, + AppContexts, LoadingSpinner, IssuesListDefModel, BeforeUnload, IssuesListDefDesignerPanels, fetchIssuesListDefDesign, - withServerContext, } from '@labkey/components'; import '../DomainDesigner.scss'; @@ -131,4 +131,8 @@ class IssuesListDesigner extends React.Component<{}, State> { } } -export const App = withServerContext(IssuesListDesigner); +export const App: FC = () => ( + + + +); diff --git a/core/src/client/LabKeyUIComponentsPage/LabKeyUIComponentsPage.tsx b/core/src/client/LabKeyUIComponentsPage/LabKeyUIComponentsPage.tsx index 7de58bb9e10..34f9cf0cec1 100644 --- a/core/src/client/LabKeyUIComponentsPage/LabKeyUIComponentsPage.tsx +++ b/core/src/client/LabKeyUIComponentsPage/LabKeyUIComponentsPage.tsx @@ -3,19 +3,8 @@ * any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ import React from 'react'; -import { fromJS, List } from 'immutable'; -import { - AppContextProvider, - initQueryGridState, - SelectInput, - SelectInputOption, - ServerContext, - ServerContextProvider, - withAppUser, - NotificationsContextProvider, - GlobalStateContextProvider, -} from '@labkey/components'; -import { getServerContext } from '@labkey/api'; +import { List } from 'immutable'; +import { AppContexts, initQueryGridState, SelectInput, SelectInputOption } from '@labkey/components'; import { EditableGridPage } from './EditableGridPage'; import { GridPanelPage } from './GridPanelPage'; @@ -24,7 +13,6 @@ const COMPONENT_NAMES = List([{ value: 'EditableGridPanel' }, type State = { selected: string; - serverContext: ServerContext; }; export class App extends React.Component { @@ -45,73 +33,51 @@ export class App extends React.Component { } getInitialState = (): State => { - return { - selected: undefined, - serverContext: withAppUser(getServerContext()), - }; + return { selected: undefined }; }; onSelectionChange = (id, selected) => { this.setState({ ...this.getInitialState(), selected }); }; - renderPanel(title, body) { - return ( -
-
{title}
-
{body}
-
- ); - } - render() { - const { selected, serverContext } = this.state; + const { selected } = this.state; return ( - - - - -

- This page is setup to show examples of shared React components from the{' '} - - labkey-ui-components - {' '} - repository. To find more information about any of the components, check the{' '} - - documentation - {' '} - page. -

+ +

+ This page is setup to show examples of shared React components from the{' '} + + labkey-ui-components + {' '} + repository. To find more information about any of the components, check the{' '} + + documentation + {' '} + page. +

- + -
+
- {selected === 'EditableGridPanel' && } - {selected === 'GridPanel' && } -
-
-
-
+ {selected === 'EditableGridPanel' && } + {selected === 'GridPanel' && } + ); } } diff --git a/core/src/client/ListDesigner/ListDesigner.tsx b/core/src/client/ListDesigner/ListDesigner.tsx index f3c0ad5da80..45ec48f5d47 100644 --- a/core/src/client/ListDesigner/ListDesigner.tsx +++ b/core/src/client/ListDesigner/ListDesigner.tsx @@ -13,17 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { ReactNode } from 'react'; +import React, { FC, ReactNode } from 'react'; import { ActionURL, PermissionTypes, Security } from '@labkey/api'; import { Alert, + AppContexts, LoadingSpinner, ListDesignerPanels, ListModel, fetchListDesign, getListIdFromDomainId, BeforeUnload, - withServerContext, } from '@labkey/components'; import '../DomainDesigner.scss'; @@ -154,4 +154,8 @@ export class ListDesigner extends React.Component { } } -export const App = withServerContext(ListDesigner); +export const App: FC = () => ( + + + +); diff --git a/core/src/client/SampleTypeDesigner/SampleTypeDesigner.tsx b/core/src/client/SampleTypeDesigner/SampleTypeDesigner.tsx index e3b0e084b06..d829b07ca24 100644 --- a/core/src/client/SampleTypeDesigner/SampleTypeDesigner.tsx +++ b/core/src/client/SampleTypeDesigner/SampleTypeDesigner.tsx @@ -14,10 +14,11 @@ * limitations under the License. */ -import React from 'react'; +import React, { FC } from 'react'; import { ActionURL, getServerContext } from '@labkey/api'; import { Alert, + AppContexts, BeforeUnload, DomainDesign, DomainDetails, @@ -27,7 +28,6 @@ import { SampleTypeDesigner, SampleTypeModel, getSampleDomainDefaultSystemFields, - withServerContext, } from '@labkey/components'; import '../DomainDesigner.scss'; @@ -171,4 +171,8 @@ class SampleTypeDesignerWrapper extends React.PureComponent { } } -export const App = withServerContext(SampleTypeDesignerWrapper); \ No newline at end of file +export const App: FC = () => ( + + + +); diff --git a/core/webapp/_images/ai_stars_icon.svg b/core/webapp/_images/ai_stars_icon.svg new file mode 100644 index 00000000000..aa5f3ec15ec --- /dev/null +++ b/core/webapp/_images/ai_stars_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/experiment/package-lock.json b/experiment/package-lock.json index 7ac9a05d50f..be9cfa41510 100644 --- a/experiment/package-lock.json +++ b/experiment/package-lock.json @@ -8,7 +8,7 @@ "name": "experiment", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -3579,9 +3579,9 @@ } }, "node_modules/@labkey/api": { - "version": "1.51.2", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.2.tgz", - "integrity": "sha512-WvP7cR/0taJYM2Tr9Cx4PC1EgearUVBk5+CbpUu57c9vN4z8aHnIcwJDuMaORM5DoUKGeqL70IOn8Z+QmPWTHw==", + "version": "1.51.3-fb-mcp-calc-cols.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.3-fb-mcp-calc-cols.0.tgz", + "integrity": "sha512-irA1mdns6EADwHYumFkniwN8Bmi2+XkPvF8p3tqLXc4A9VoSMbAPa4PeIPw5Mv1wEFT8wZ/yw6dlyn04bRbmqg==", "license": "Apache-2.0" }, "node_modules/@labkey/build": { @@ -3622,13 +3622,13 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.0.tgz", - "integrity": "sha512-JH0ddIpb3YkFJdYQZADFeIULcAe2O5UPfJ+Hs61WeraQ55iG847Ag0TloafDHneYSMSeBVlw4M4voxJtAS4Pjg==", + "version": "7.36.1-fb-mcp-calc-cols.5", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.5.tgz", + "integrity": "sha512-osed78xNJfG42fbogbxVvG8hldSDKiCcZLFIAf0vi9nVrDijNVqtP8eczZi8HVC1GAPtDwXPtEadLk9cCAArCQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.51.2", + "@labkey/api": "1.51.3-fb-mcp-calc-cols.0", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "~16.3.2", diff --git a/experiment/package.json b/experiment/package.json index bd0f97c8c31..73043e26c4b 100644 --- a/experiment/package.json +++ b/experiment/package.json @@ -13,7 +13,7 @@ "test-integration": "cross-env NODE_ENV=test jest --ci --runInBand -c test/js/jest.config.integration.js" }, "dependencies": { - "@labkey/components": "7.36.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5" }, "devDependencies": { "@labkey/build": "9.1.3", diff --git a/pipeline/package-lock.json b/pipeline/package-lock.json index 854f5ab8690..facac3173fe 100644 --- a/pipeline/package-lock.json +++ b/pipeline/package-lock.json @@ -8,7 +8,7 @@ "name": "pipeline", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -2914,9 +2914,9 @@ } }, "node_modules/@labkey/api": { - "version": "1.51.2", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.2.tgz", - "integrity": "sha512-WvP7cR/0taJYM2Tr9Cx4PC1EgearUVBk5+CbpUu57c9vN4z8aHnIcwJDuMaORM5DoUKGeqL70IOn8Z+QmPWTHw==", + "version": "1.51.3-fb-mcp-calc-cols.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.3-fb-mcp-calc-cols.0.tgz", + "integrity": "sha512-irA1mdns6EADwHYumFkniwN8Bmi2+XkPvF8p3tqLXc4A9VoSMbAPa4PeIPw5Mv1wEFT8wZ/yw6dlyn04bRbmqg==", "license": "Apache-2.0" }, "node_modules/@labkey/build": { @@ -2957,13 +2957,13 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.0.tgz", - "integrity": "sha512-JH0ddIpb3YkFJdYQZADFeIULcAe2O5UPfJ+Hs61WeraQ55iG847Ag0TloafDHneYSMSeBVlw4M4voxJtAS4Pjg==", + "version": "7.36.1-fb-mcp-calc-cols.5", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.5.tgz", + "integrity": "sha512-osed78xNJfG42fbogbxVvG8hldSDKiCcZLFIAf0vi9nVrDijNVqtP8eczZi8HVC1GAPtDwXPtEadLk9cCAArCQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.51.2", + "@labkey/api": "1.51.3-fb-mcp-calc-cols.0", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "~16.3.2", diff --git a/pipeline/package.json b/pipeline/package.json index 428d8f810ce..a95f50cd83f 100644 --- a/pipeline/package.json +++ b/pipeline/package.json @@ -14,7 +14,7 @@ "build-prod": "npm run clean && cross-env NODE_ENV=production PROD_SOURCE_MAP=source-map webpack --config node_modules/@labkey/build/webpack/prod.config.js --color --progress --profile" }, "dependencies": { - "@labkey/components": "7.36.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5" }, "devDependencies": { "@labkey/build": "9.1.3", diff --git a/query/src/org/labkey/query/QueryModule.java b/query/src/org/labkey/query/QueryModule.java index 7195986c513..08afcc10ad3 100644 --- a/query/src/org/labkey/query/QueryModule.java +++ b/query/src/org/labkey/query/QueryModule.java @@ -99,6 +99,7 @@ import org.labkey.query.audit.GridViewAuditProvider; import org.labkey.query.audit.QueryExportAuditProvider; import org.labkey.query.audit.QueryUpdateAuditProvider; +import org.labkey.query.controllers.ExpressionAssistantAgentAction; import org.labkey.query.controllers.OlapController; import org.labkey.query.controllers.QueryController; import org.labkey.query.controllers.QueryMcp; @@ -429,6 +430,7 @@ public Set getSchemaNames() MemberSet.TestCase.class, MetadataElementBase.TestCase.class, Method.TestCase.class, + ExpressionAssistantAgentAction.TestCase.class, QNode.TestCase.class, Query.TestCase.class, ReportsController.SerializationTest.class, diff --git a/query/src/org/labkey/query/QueryServiceImpl.java b/query/src/org/labkey/query/QueryServiceImpl.java index 34f80fd5459..2b0a8abb4c8 100644 --- a/query/src/org/labkey/query/QueryServiceImpl.java +++ b/query/src/org/labkey/query/QueryServiceImpl.java @@ -44,6 +44,7 @@ import org.labkey.api.collections.CaseInsensitiveHashMap; import org.labkey.api.collections.LabKeyCollectors; import org.labkey.api.data.AuditConfigurable; +import org.labkey.api.data.BaseColumnInfo; import org.labkey.api.data.ColumnHeaderType; import org.labkey.api.data.ColumnInfo; import org.labkey.api.data.ColumnRenderPropertiesImpl; @@ -60,6 +61,7 @@ import org.labkey.api.data.JdbcType; import org.labkey.api.data.MethodInfo; import org.labkey.api.data.MutableColumnInfo; +import org.labkey.api.data.PHI; import org.labkey.api.data.Parameter; import org.labkey.api.data.QueryLogging; import org.labkey.api.data.Results; @@ -73,6 +75,7 @@ import org.labkey.api.data.Table; import org.labkey.api.data.TableInfo; import org.labkey.api.data.TableSelector; +import org.labkey.api.data.VirtualTable; import org.labkey.api.data.dialect.SqlDialect; import org.labkey.api.exp.property.DomainKind; import org.labkey.api.exp.query.ExpTable; @@ -3047,6 +3050,40 @@ public void bindQueryExpressionColumn(ColumnInfo col, Map c calc.computeMetaData(columns); } + public record CalculatedColumnParseResult(JdbcType jdbcType, Set requiredColumns) { } + + public CalculatedColumnParseResult parseCalculatedColumn( + Container container, + User user, + String expression, + Map columnMap, + @Nullable List phiColumns + ) throws QueryException + { + var schema = DefaultSchema.get(user, container).getUserSchema("core"); + var table = new VirtualTable<>(schema.getDbSchema(), "EXPR", schema){}; + ColumnInfo calculatedCol = createQueryExpressionColumn(table, new FieldKey(null, "expr"), expression, null); + Map columns = new HashMap<>(); + + for (var entry : columnMap.entrySet()) + { + BaseColumnInfo entryCol = new BaseColumnInfo(entry.getKey(), entry.getValue()); + // bindQueryExpressionColumn has a check that restricts PHI columns from being used in expressions, + // so we need to set the PHI level to something other than NotPHI on these fake BaseColumnInfo objects + if (phiColumns != null && phiColumns.contains(entry.getKey())) + entryCol.setPHI(PHI.PHI); + columns.put(entry.getKey(), entryCol); + table.addColumn(entryCol); + } + + // TODO: calculating jdbcType still uses calculatedCol.getParentTable().getColumns() + var requiredColumns = new HashSet(); + bindQueryExpressionColumn(calculatedCol, columns, false, requiredColumns); + var jdbcType = calculatedCol.getJdbcType(); + + return new CalculatedColumnParseResult(jdbcType, requiredColumns); + } + @Override public void addCompareType(CompareType type) { @@ -3886,5 +3923,101 @@ public void testWhereClauseWithUnion() assertTrue(e.getMessage().contains("Syntax error near 'UNION'")); } } + + @Test + public void testParseCalculatedColumn() throws QueryException + { + QueryServiceImpl qs = QueryServiceImpl.get(); + Container c = JunitUtil.getTestContainer(); + User user = TestContext.get().getUser(); + + FieldKey rowId = new FieldKey(null, "RowId"); + FieldKey sortOrder = new FieldKey(null, "SortOrder"); + FieldKey name = new FieldKey(null, "Name"); + FieldKey title = new FieldKey(null, "Title"); + + // Integer arithmetic over existing core.Containers columns + { + Map columnMap = new HashMap<>(); + columnMap.put(rowId, JdbcType.INTEGER); + columnMap.put(sortOrder, JdbcType.INTEGER); + + CalculatedColumnParseResult result = qs.parseCalculatedColumn(c, user, "RowId + SortOrder", columnMap, null); + assertEquals(JdbcType.INTEGER, result.jdbcType()); + assertEquals(Set.of(rowId, sortOrder), result.requiredColumns()); + } + + // String concatenation; only references a subset of supplied columns + { + Map columnMap = new HashMap<>(); + columnMap.put(name, JdbcType.VARCHAR); + columnMap.put(title, JdbcType.VARCHAR); + columnMap.put(rowId, JdbcType.INTEGER); + + CalculatedColumnParseResult result = qs.parseCalculatedColumn(c, user, "Name || '_' || Title", columnMap, null); + assertEquals(JdbcType.VARCHAR, result.jdbcType()); + assertEquals(Set.of(name, title), result.requiredColumns()); + } + + // Client-supplied columns that don't exist yet on the domain are honored β€” the parser + // trusts the supplied columnMap rather than the live schema. + { + FieldKey newColA = new FieldKey(null, "NewColA"); + FieldKey newColB = new FieldKey(null, "NewColB"); + Map columnMap = new HashMap<>(); + columnMap.put(newColA, JdbcType.DOUBLE); + columnMap.put(newColB, JdbcType.DOUBLE); + + CalculatedColumnParseResult result = qs.parseCalculatedColumn(c, user, "NewColA * NewColB", columnMap, null); + assertEquals(JdbcType.DOUBLE, result.jdbcType()); + assertEquals(Set.of(newColA, newColB), result.requiredColumns()); + } + + // Referencing a column that the client did not supply is an error + { + Map columnMap = new HashMap<>(); + columnMap.put(rowId, JdbcType.INTEGER); + + try + { + qs.parseCalculatedColumn(c, user, "RowId + Missing", columnMap, null); + fail("Expected QueryException for reference to unknown column"); + } + catch (QueryException _) + { + } + } + + // Syntactically invalid expressions throw QueryException + { + Map columnMap = new HashMap<>(); + columnMap.put(rowId, JdbcType.INTEGER); + + try + { + qs.parseCalculatedColumn(c, user, "RowId +", columnMap, null); + fail("Expected QueryException for syntactically invalid expression"); + } + catch (QueryException _) + { + } + } + + // PHI-flagged columns cannot be referenced from calculated column expressions + { + Map columnMap = new HashMap<>(); + columnMap.put(rowId, JdbcType.INTEGER); + columnMap.put(sortOrder, JdbcType.INTEGER); + + try + { + qs.parseCalculatedColumn(c, user, "RowId + SortOrder", columnMap, List.of(sortOrder)); + fail("Expected QueryException when expression references a PHI column"); + } + catch (QueryException _) + { + } + } + } } } diff --git a/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java b/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java new file mode 100644 index 00000000000..010e3d62783 --- /dev/null +++ b/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java @@ -0,0 +1,504 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.query.controllers; + +import com.google.genai.errors.ClientException; +import com.google.genai.errors.ServerException; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Assert; +import org.junit.Test; +import org.labkey.api.markdown.MarkdownService; +import org.labkey.api.mcp.AbstractAgentAction; +import org.labkey.api.mcp.McpContext; +import org.labkey.api.mcp.McpService; +import org.labkey.api.query.RuntimeValidationException; +import org.labkey.api.security.RequiresLogin; +import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.query.controllers.QueryController.ParseForm; +import org.labkey.query.controllers.QueryController.PromptResource; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.validation.BindException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +@RequiresPermission(ReadPermission.class) +@RequiresLogin +public class ExpressionAssistantAgentAction extends AbstractAgentAction +{ + private static final Logger LOG = LogManager.getLogger(ExpressionAssistantAgentAction.class); + + @Override + protected String getAgentName() + { + return ExpressionAssistantAgentAction.class.getName(); + } + + @Override + protected String getServicePrompt() + { + return PromptResource.ExpressionAssistant.resource() + + "\n\nBefore starting, load the LabKey SQL documentation using the \"readResource\" tool with the URI \"" + PromptResource.LabKeySql.uri() + "\"\n\n"; + } + + @Override + public Object execute(ParseForm form, BindException errors) throws Exception + { + try (var _ = McpContext.withContext(getViewContext())) + { + boolean firstTurn = isBlank(form.getConversationId()); + String prompt = form.getPrompt(); + String composedPrompt = composePrompt(firstTurn, prompt, form.getDomainFields(), form.getFieldExpression(), form.getFieldError()); + + if (isBlank(composedPrompt)) + { + return new JSONObject(Map.of( + "contentType", "text/plain", + "text", "🀷", + "success", Boolean.TRUE)); + } + + ChatClient chatSession = getChat(true); + List responses; + + try + { + // Context for the QueryMcp.validateCalculatedColumnExpression() tool + McpContext.get() + .put("columnMap", form.getColumnMap()) + .put("phiColumns", form.getPhiColumns()); + + LOG.info("Expression assistant prompt: {}", prompt); + responses = McpService.get().sendMessageEx(chatSession, composedPrompt); + } + catch (ServerException x) + { + return new JSONObject(Map.of( + "error", x.getMessage(), + "text", "ERROR: " + x.getMessage(), + "success", Boolean.FALSE)); + } + + JSONArray segments = buildSegments(responses); + return new JSONObject(Map.of( + "success", Boolean.TRUE, + "conversationId", getConversationId(), + "segments", segments)); + } + catch (ClientException x) + { + return errorResponse(x); + } + } + + /** + * Combines the user's prompt with any first-turn context supplied by the client (the catalog + * of available fields, the current expression, and an error message when auto-evaluating an + * invalid expression). When {@code firstTurn} is false the context fields are ignored, and the + * user's prompt is returned verbatim. + */ + static String composePrompt(boolean firstTurn, String userPrompt, JSONArray domainFields, String fieldExpression, String fieldError) + { + if (!firstTurn) + return StringUtils.defaultString(userPrompt); + + boolean autoEvaluate = isBlank(userPrompt) && isNotBlank(fieldError); + if (isBlank(userPrompt) && !autoEvaluate) + return ""; + + StringBuilder sb = new StringBuilder(); + if (domainFields != null && !domainFields.isEmpty()) + { + sb.append("The following enumerates the available columns and their types:\n"); + sb.append(fence(domainFields.toString(), "json")); + } + + if (autoEvaluate) + { + if (isNotBlank(fieldExpression)) + { + sb.append("The user already has the following calculated column expression:\n"); + sb.append(fence(fieldExpression)); + } + sb.append("This expression contains an error:\n"); + sb.append(fence(fieldError)); + sb.append("Evaluate this expression and see if you can determine how to fix this error. If you can, point them out and propose corrections."); + } + else if (sb.isEmpty()) + { + sb.append(userPrompt); + } + else + { + sb.append("Generate a calculated column expression that matches the following description:\n"); + sb.append(userPrompt); + } + + return sb.toString(); + } + + static String fence(String body) + { + return fence(body, ""); + } + + /** Wraps {@code body} in a markdown fenced code block, optionally tagged with a language. */ + static String fence(String body, String tag) + { + return "```" + tag + "\n" + body + "\n```\n"; + } + + /** + * Walks the markdown of each MessageResponse and produces an ordered list of segments. + * Each segment is either a rendered-HTML span or a fenced SQL block. SQL blocks fenced as + * `expression` are tagged "expression" (the model's assertion that this SQL has been validated + * and is safe to apply); blocks fenced as `sql` are tagged "sql" (illustrative / unvalidated). + */ + private static JSONArray buildSegments(List responses) + { + JSONArray segments = new JSONArray(); + MarkdownService md = MarkdownService.get(); + StringBuilder htmlBuf = new StringBuilder(); + + for (var response : responses) + { + String text = response.text(); + if (isBlank(text)) + continue; + + String[] lines = text.split("\n", -1); + int i = 0; + while (i < lines.length) + { + String tag = fenceTag(lines[i]); + if ("sql".equals(tag) || "expression".equals(tag)) + { + int j = i + 1; + StringBuilder code = new StringBuilder(); + while (j < lines.length && !"```".equals(lines[j].trim())) + { + if (!code.isEmpty()) code.append("\n"); + code.append(lines[j]); + j++; + } + if (j >= lines.length) + { + // Unterminated fence β€” fold the body back into prose so we don't drop content, + // but skip the opening fence line itself so the user doesn't see a stray + // "```expression" rendered as a code marker. + if (!htmlBuf.isEmpty()) htmlBuf.append("\n"); + for (int k = i + 1; k < lines.length; k++) + { + htmlBuf.append(lines[k]); + if (k < lines.length - 1) htmlBuf.append("\n"); + } + break; + } + flushHtmlSegment(segments, htmlBuf, md); + segments.put(new JSONObject(Map.of("type", tag, "sql", code.toString()))); + i = j + 1; + } + else + { + if (!htmlBuf.isEmpty()) htmlBuf.append("\n"); + htmlBuf.append(lines[i]); + i++; + } + } + } + flushHtmlSegment(segments, htmlBuf, md); + return segments; + } + + private static String fenceTag(String line) + { + String trimmed = line.trim(); + if (!trimmed.startsWith("```")) + return null; + String rest = trimmed.substring(3).trim(); + return rest.isEmpty() ? null : rest.toLowerCase(); + } + + private static void flushHtmlSegment(JSONArray segments, StringBuilder buf, MarkdownService md) + { + if (buf.isEmpty()) return; + String raw = buf.toString().strip(); + buf.setLength(0); + if (raw.isEmpty()) return; + String html; + try + { + html = md != null ? md.toHtml(raw) : raw; + } + catch (Exception x) + { + html = raw; + } + + var validateErrors = new ArrayList(); + PageFlowUtil.validateHtml(html, validateErrors, validateErrors); + if (!validateErrors.isEmpty()) + throw new RuntimeValidationException("Invalid HTML markup. " + String.join("\n", validateErrors)); + + segments.put(new JSONObject(Map.of("type", "html", "html", html))); + } + + public static class TestCase extends Assert + { + private static McpService.MessageResponse markdownResponse(String md) + { + return new McpService.MessageResponse("text/markdown", md, HtmlString.of(md)); + } + + private static JSONObject segment(JSONArray segments, int i) + { + return segments.getJSONObject(i); + } + + @Test + public void emptyResponseList() + { + JSONArray segments = buildSegments(List.of()); + assertEquals(0, segments.length()); + } + + @Test + public void blankMessageProducesNoSegments() + { + JSONArray segments = buildSegments(List.of(markdownResponse(""), markdownResponse(" "))); + assertEquals(0, segments.length()); + } + + @Test + public void proseOnlyProducesSingleHtmlSegment() + { + JSONArray segments = buildSegments(List.of(markdownResponse("Hello, here is some advice."))); + assertEquals(1, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + assertTrue(segment(segments, 0).getString("html").contains("Hello, here is some advice.")); + } + + @Test + public void expressionFenceProducesExpressionSegment() + { + String md = "```expression\nSELECT 1\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(1, segments.length()); + assertEquals("expression", segment(segments, 0).getString("type")); + assertEquals("SELECT 1", segment(segments, 0).getString("sql")); + } + + @Test + public void sqlFenceProducesSqlSegment() + { + String md = "```sql\nSELECT 1\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(1, segments.length()); + assertEquals("sql", segment(segments, 0).getString("type")); + assertEquals("SELECT 1", segment(segments, 0).getString("sql")); + } + + @Test + public void interleavedProseAndFencesProduceOrderedSegments() + { + String md = String.join("\n", + "Here are two options.", + "", + "Option A (illustrative):", + "```sql", + "SELECT a FROM t", + "```", + "", + "Option B (ready to apply):", + "```expression", + "SELECT b FROM t", + "```", + "Pick whichever fits." + ); + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(5, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + assertEquals("sql", segment(segments, 1).getString("type")); + assertEquals("SELECT a FROM t", segment(segments, 1).getString("sql")); + assertEquals("html", segment(segments, 2).getString("type")); + assertEquals("expression", segment(segments, 3).getString("type")); + assertEquals("SELECT b FROM t", segment(segments, 3).getString("sql")); + assertEquals("html", segment(segments, 4).getString("type")); + } + + @Test + public void multipleExpressionFencesEachBecomeOwnSegment() + { + String md = String.join("\n", + "```expression", + "SELECT 1", + "```", + "```expression", + "SELECT 2", + "```" + ); + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(2, segments.length()); + assertEquals("expression", segment(segments, 0).getString("type")); + assertEquals("SELECT 1", segment(segments, 0).getString("sql")); + assertEquals("expression", segment(segments, 1).getString("type")); + assertEquals("SELECT 2", segment(segments, 1).getString("sql")); + } + + @Test + public void unterminatedFenceFallsBackToHtml() + { + String md = "Here's an expression:\n```expression\nSELECT 1\n(no closing fence)"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(1, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + String html = segment(segments, 0).getString("html"); + assertTrue("body should survive: " + html, html.contains("SELECT 1")); + assertTrue("leading prose should survive: " + html, html.contains("Here's an expression:") || html.contains("Here's an expression:")); + assertFalse("opening fence must be stripped: " + html, html.contains("```")); + } + + @Test + public void unknownFenceLanguageIsTreatedAsProse() + { + String md = "```python\nprint('hi')\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + // Only sql/expression are split out; other fenced blocks stay in the html segment. + assertEquals(1, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + } + + @Test + public void fenceTagIsCaseInsensitive() + { + String md = "```EXPRESSION\nSELECT 1\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(1, segments.length()); + assertEquals("expression", segment(segments, 0).getString("type")); + assertEquals("SELECT 1", segment(segments, 0).getString("sql")); + } + + @Test + public void preservesMultilineSqlBody() + { + String md = "```expression\nSELECT a,\n b\nFROM t\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(1, segments.length()); + assertEquals("SELECT a,\n b\nFROM t", segment(segments, 0).getString("sql")); + } + + @Test + public void multipleMessageResponsesAreConcatenatedInOrder() + { + McpService.MessageResponse r1 = markdownResponse("First response prose."); + McpService.MessageResponse r2 = markdownResponse("```expression\nSELECT 1\n```"); + JSONArray segments = buildSegments(List.of(r1, r2)); + assertEquals(2, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + assertEquals("expression", segment(segments, 1).getString("type")); + assertEquals("SELECT 1", segment(segments, 1).getString("sql")); + } + + @Test + public void flushHtmlSegmentRejectsScriptTag() + { + JSONArray segments = new JSONArray(); + StringBuilder buf = new StringBuilder("
Here is a greeting:
"); + RuntimeValidationException ex = assertThrows(RuntimeValidationException.class, + () -> flushHtmlSegment(segments, buf, null)); + assertTrue("expected validation error message, got: " + ex.getMessage(), + ex.getMessage().contains("Illegal element