From 11c98317c54143f919eaf0c4d45ce2c3bbdbc846 Mon Sep 17 00:00:00 2001 From: Floris Vleugels Date: Fri, 30 May 2025 11:57:43 +0200 Subject: [PATCH 1/8] initial chatbox button / hint add to comp add request code add loading animation Enable markdown in messages --- .../src/generated/CBioPortalAPIInternal.ts | 54 +++++ .../cbioportal-ts-api-client/src/index.tsx | 1 + src/AppStore.ts | 9 + src/appShell/App/PortalSupport.tsx | 203 ++++++++++++++++++ src/appShell/App/cbioportal_icon.png | Bin 0 -> 33747 bytes src/appShell/App/support.module.scss | 187 ++++++++++++++++ src/appShell/App/support.module.scss.d.ts | 23 ++ .../components/PageLayout/PageLayout.tsx | 6 + 8 files changed, 483 insertions(+) create mode 100644 src/appShell/App/PortalSupport.tsx create mode 100644 src/appShell/App/cbioportal_icon.png create mode 100644 src/appShell/App/support.module.scss create mode 100644 src/appShell/App/support.module.scss.d.ts diff --git a/packages/cbioportal-ts-api-client/src/generated/CBioPortalAPIInternal.ts b/packages/cbioportal-ts-api-client/src/generated/CBioPortalAPIInternal.ts index bf4da4d89a4..5342598fdfa 100644 --- a/packages/cbioportal-ts-api-client/src/generated/CBioPortalAPIInternal.ts +++ b/packages/cbioportal-ts-api-client/src/generated/CBioPortalAPIInternal.ts @@ -133,6 +133,9 @@ export type ClinicalAttributeCountFilter = { 'sampleListId': string }; +export type SupportMessage = { + 'message': string +}; export type ClinicalData = { 'clinicalAttribute': ClinicalAttribute @@ -8480,4 +8483,55 @@ export default class CBioPortalAPIInternal { return response.body; }); }; + + /** + * Send a support message to the AI support endpoint. + * @method + * @name CBioPortalAPIInternal#getSupportUsingPOST + * @param {Object} parameters - Parameters for the request. + * @param {SupportMessage} [parameters.supportMessage] - The message to send to the AI support system. This can contain user queries, questions, or other requests. + * @param {string} [parameters.$domain] - Optional override for the API domain. Defaults to the instance's domain if not provided. + */ + getSupportUsingPOSTWithHttpInfo(parameters: { + 'supportMessage' ? : SupportMessage, + $domain ? : string + }): Promise < request.Response > { + const domain = parameters.$domain ? parameters.$domain : this.domain; + const errorHandlers = this.errorHandlers; + const request = this.request; + let path = '/support'; + let body: any; + let queryParameters: any = {}; + let headers: any = {}; + let form: any = {}; + return new Promise(function(resolve, reject) { + headers['Accept'] = 'application/json'; + headers['Content-Type'] = 'application/json'; + + if (parameters['supportMessage'] !== undefined) { + body = parameters['supportMessage']; + } + + request('POST', domain + path, body, headers, queryParameters, form, reject, resolve, errorHandlers); + + }); + }; + + /** + * Send a support message to the AI support endpoint and return only the response body. + * @method + * @name CBioPortalAPIInternal#getSupprtUsingPOST + * @param {Object} parameters - Parameters for the request. + * @param {SupportMessage} [parameters.supportMessage] - The message to send to the AI support system. + * @param {string} [parameters.$domain] - Optional override for the API domain. + */ + getSupportUsingPOST(parameters: { + 'supportMessage' ? : SupportMessage, + $domain ? : string + }): Promise<{ answer: string }> + { + return this.getSupportUsingPOSTWithHttpInfo(parameters).then(function(response: request.Response) { + return response.body; + }); + }; } diff --git a/packages/cbioportal-ts-api-client/src/index.tsx b/packages/cbioportal-ts-api-client/src/index.tsx index af3719a4e23..9c8c01fcd14 100644 --- a/packages/cbioportal-ts-api-client/src/index.tsx +++ b/packages/cbioportal-ts-api-client/src/index.tsx @@ -82,6 +82,7 @@ export { CustomDriverAnnotationReport, StructuralVariant, StructuralVariantFilter, + SupportMessage, StructuralVariantQuery, StructuralVariantGeneSubQuery, StructuralVariantFilterQuery, diff --git a/src/AppStore.ts b/src/AppStore.ts index 5682f5679f9..5a9556da390 100644 --- a/src/AppStore.ts +++ b/src/AppStore.ts @@ -42,6 +42,15 @@ export class AppStore { } @observable private _appReady = false; + @observable public showSupport = false; + @observable public messages = [ + { + speaker: 'AI', + text: + "Hi there!\nMy name is Tobi, I'm cBioPortal's Support Robot 🤖", + }, + { speaker: 'AI', text: 'What can I do for you today?' }, + ]; siteErrors = observable.array(); diff --git a/src/appShell/App/PortalSupport.tsx b/src/appShell/App/PortalSupport.tsx new file mode 100644 index 00000000000..8728c0318ed --- /dev/null +++ b/src/appShell/App/PortalSupport.tsx @@ -0,0 +1,203 @@ +import * as React from 'react'; +import ReactMarkdown from 'react-markdown'; +import './footer.scss'; +import _ from 'lodash'; +import { AppStore } from '../../AppStore'; +import { observer } from 'mobx-react'; +import { action, observable, makeObservable } from 'mobx'; +import styles from './support.module.scss'; +import internalClient from '../../shared/api/cbioportalInternalClientInstance'; +import { SupportMessage } from 'cbioportal-ts-api-client/dist/generated/CBioPortalAPIInternal'; + +@observer +export default class PortalSupport extends React.Component<{ + appStore: AppStore; +}> { + @observable private userInput = ''; + @observable private pending = false; + @observable private showErrorMessage = false; + + constructor(props: { appStore: AppStore }) { + super(props); + makeObservable(this); + } + + @action.bound + private toggleSupport() { + this.props.appStore.showSupport = !this.props.appStore.showSupport; + } + + @action.bound + private handleInputChange(event: React.ChangeEvent) { + this.userInput = event.target.value; + } + + @action.bound + private handleSendMessage(event: React.FormEvent) { + event.preventDefault(); + + if (!this.userInput.trim()) return; + + this.props.appStore.messages.push({ + speaker: 'User', + text: this.userInput, + }); + this.getResponse(); + this.userInput = ''; + } + + @action.bound + private async getResponse() { + this.showErrorMessage = false; + this.pending = true; + + let supportMessage = { + message: this.userInput, + } as SupportMessage; + + try { + const response = await internalClient.getSupportUsingPOST({ + supportMessage, + }); + this.props.appStore.messages.push({ + speaker: 'AI', + text: response.answer, + }); + this.pending = false; + } catch (error) { + this.pending = false; + this.showErrorMessage = true; + } + } + + renderButton() { + return ( + + ); + } + + renderThinking() { + return ( +
+ + + + + +
+ ); + } + + renderErrorMessage() { + return ( +
+ Something went wrong, please try again. +
+ ); + } + + renderMessages() { + return ( +
+ {this.props.appStore.messages.map((msg, index) => { + const isUser = msg.speaker === 'User'; + return ( +
+
+ {msg.text.split('\n').map((line, i) => ( +

+ + {line} + +

+ ))} +
+
+ ); + })} +
+ ); + } + + render() { + return ( +
+ {this.props.appStore.showSupport && ( +
+
+ cBioPortal Icon + cBioPortal Support +
+ +
+
+ Please ask your cBioPortal related questions + here, for example how to correctly format a + query using Onco Query Language (OQL). +
+ {this.renderMessages()} + {this.pending && this.renderThinking()} + {this.showErrorMessage && this.renderErrorMessage()} +
+ +
+
+ +
+
+ )} + {this.renderButton()} +
+ ); + } +} diff --git a/src/appShell/App/cbioportal_icon.png b/src/appShell/App/cbioportal_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..71896c39c4f6cda382636ae68aafc375768bcc10 GIT binary patch literal 33747 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

eEcP(h+oju_>WW#j*H|0JRiwq$t@=7)4)Gms@lc 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 literal 0 HcmV?d00001 diff --git a/src/appShell/App/support.module.scss b/src/appShell/App/support.module.scss new file mode 100644 index 00000000000..acd4de106d8 --- /dev/null +++ b/src/appShell/App/support.module.scss @@ -0,0 +1,187 @@ +.supportContainer { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 9999; + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.chatWindow { + width: 380px; + height: 700px; + border-radius: 20px; + overflow: hidden; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + background-color: #fff; + margin-bottom: 8px; +} + +.titlearea { + background-color: #3498db; + color: white; + font-weight: bold; + font-size: 20px; + padding: 12px 16px; + display: flex; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.titleIcon { + width: 32px; + height: 32px; + margin-right: 16px; + margin-left: 6px; +} + +.textarea { + height: 84%; + padding: 8px 10px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; + background-color: #ffffff; +} + +.textheader { + padding: 8px 10px; + font-size: 11px; + text-align: center; + color: rgba(0, 0, 0, 0.65); +} + +.messageRow { + display: flex; + margin-bottom: 8px; + width: 100%; + justify-content: flex-start; +} + +.messageRowRight { + justify-content: flex-end; +} + +.message, +.question { + display: inline-block; + max-width: 70%; + min-width: 32px; + margin: 0 6px; + padding: 8px 12px; + border-radius: 12px; + font-size: 14px; + line-height: 1.25; + word-wrap: break-word; + white-space: pre-wrap; + box-sizing: border-box; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03) inset; +} + +/* AI answers (left) */ +.message { + background-color: rgba(100, 100, 100, 0.08); + color: #1f2933; + text-align: left; +} + +/* User questions (right) */ +.question { + background-color: rgba(52, 152, 219, 0.18); + color: #0b2140; + text-align: left; +} + +.error { +} + +.messageLine { + margin: 0 0 6px 0; +} +.messageLine:last-child { + margin-bottom: 0; +} + +.inputarea { + height: 8%; + display: flex; + align-items: center; + padding: 8px 12px; + background-color: #ffffff; + border-top: 1px solid rgba(0, 0, 0, 0.05); +} + +.form { + display: flex; + align-items: center; + width: 100%; + gap: 8px; +} + +.input { + flex: 1 1 auto; + height: 36px; + font-size: 14px; + border: 1px solid rgba(0, 0, 0, 0.15); + padding: 0 14px; + border-radius: 20px; + color: rgba(0, 0, 0, 0.85); + background: #fff; + outline: none; +} + +/* Animations */ +.thinking { + display: flex; + padding: 12px 12px; +} + +.dots { + display: flex; + gap: 6px; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #555; + transform: scale(0.6); + animation: bubble-bounce 1s infinite ease-in-out; +} + +.dot:nth-of-type(1) { + animation-delay: 0s; +} +.dot:nth-of-type(2) { + animation-delay: 0.15s; +} +.dot:nth-of-type(3) { + animation-delay: 0.3s; +} + +@keyframes bubble-bounce { + 0%, + 80%, + 100% { + transform: scale(0.6); + opacity: 0.35; + } + 40% { + transform: scale(1); + opacity: 0.9; + } +} + +@media (prefers-reduced-motion: reduce) { + .dot { + animation: none; + opacity: 0.5; + transform: none; + } +} diff --git a/src/appShell/App/support.module.scss.d.ts b/src/appShell/App/support.module.scss.d.ts new file mode 100644 index 00000000000..3be3f8d24bb --- /dev/null +++ b/src/appShell/App/support.module.scss.d.ts @@ -0,0 +1,23 @@ +declare const styles: { + readonly "bubble-bounce": string; + readonly "chatWindow": string; + readonly "dot": string; + readonly "dots": string; + readonly "error": string; + readonly "form": string; + readonly "input": string; + readonly "inputarea": string; + readonly "message": string; + readonly "messageLine": string; + readonly "messageRow": string; + readonly "messageRowRight": string; + readonly "question": string; + readonly "supportContainer": string; + readonly "textarea": string; + readonly "textheader": string; + readonly "thinking": string; + readonly "titleIcon": string; + readonly "titlearea": string; +}; +export = styles; + diff --git a/src/shared/components/PageLayout/PageLayout.tsx b/src/shared/components/PageLayout/PageLayout.tsx index 7809d72c868..7afc4079edc 100644 --- a/src/shared/components/PageLayout/PageLayout.tsx +++ b/src/shared/components/PageLayout/PageLayout.tsx @@ -3,6 +3,7 @@ import classNames from 'classnames'; import { inject } from 'mobx-react'; import { AppStore } from '../../../AppStore'; import PortalFooter from '../../../appShell/App/PortalFooter'; +import PortalSupport from '../../../appShell/App/PortalSupport'; import { RFC80Test } from 'shared/components/rfc80Tester'; interface IPageLayout { @@ -11,6 +12,7 @@ interface IPageLayout { noMargin?: boolean; appStore?: AppStore; hideFooter?: boolean; + enableSupport?: boolean; } @inject('appStore') @@ -35,6 +37,10 @@ export class PageLayout extends React.Component { )} + {!this.props.enableSupport && ( + + )} + {!this.props.hideFooter && ( )} From f2c3c8e5b523cfe25826c275080b36cc117d9e5a Mon Sep 17 00:00:00 2001 From: Floris Vleugels Date: Thu, 9 Oct 2025 11:33:38 +0200 Subject: [PATCH 2/8] Add query page only chatbot Add example queries Romve portal wide support --- my-index.ejs | 3 +- .../src/generated/CBioPortalAPIInternal.ts | 16 +- .../cbioportal-ts-api-client/src/index.tsx | 2 +- src/AppStore.ts | 9 - src/appShell/App/support.module.scss | 187 -------------- src/appShell/App/support.module.scss.d.ts | 23 -- .../images}/cbioportal_icon.png | Bin .../components/PageLayout/PageLayout.tsx | 6 - .../components/query/GeneAssistant.tsx} | 123 ++++++--- .../components/query/GeneSetSelector.tsx | 3 + src/shared/components/query/QueryStore.ts | 8 + .../query/styles/styles.module.scss | 240 ++++++++++++++++++ .../query/styles/styles.module.scss.d.ts | 25 ++ 13 files changed, 372 insertions(+), 273 deletions(-) delete mode 100644 src/appShell/App/support.module.scss delete mode 100644 src/appShell/App/support.module.scss.d.ts rename src/{appShell/App => globalStyles/images}/cbioportal_icon.png (100%) rename src/{appShell/App/PortalSupport.tsx => shared/components/query/GeneAssistant.tsx} (62%) diff --git a/my-index.ejs b/my-index.ejs index 7439a385201..5c7854a3262 100644 --- a/my-index.ejs +++ b/my-index.ejs @@ -50,7 +50,8 @@ - + + diff --git a/packages/cbioportal-ts-api-client/src/generated/CBioPortalAPIInternal.ts b/packages/cbioportal-ts-api-client/src/generated/CBioPortalAPIInternal.ts index 5342598fdfa..643c5c66b58 100644 --- a/packages/cbioportal-ts-api-client/src/generated/CBioPortalAPIInternal.ts +++ b/packages/cbioportal-ts-api-client/src/generated/CBioPortalAPIInternal.ts @@ -133,7 +133,7 @@ export type ClinicalAttributeCountFilter = { 'sampleListId': string }; -export type SupportMessage = { +export type UserMessage = { 'message': string }; export type ClinicalData = { @@ -8489,17 +8489,17 @@ export default class CBioPortalAPIInternal { * @method * @name CBioPortalAPIInternal#getSupportUsingPOST * @param {Object} parameters - Parameters for the request. - * @param {SupportMessage} [parameters.supportMessage] - The message to send to the AI support system. This can contain user queries, questions, or other requests. + * @param {UserMessage} [parameters.userMessage] - The message to send to the AI support system. This can contain user queries, questions, or other requests. * @param {string} [parameters.$domain] - Optional override for the API domain. Defaults to the instance's domain if not provided. */ getSupportUsingPOSTWithHttpInfo(parameters: { - 'supportMessage' ? : SupportMessage, + 'userMessage' ? : UserMessage, $domain ? : string }): Promise < request.Response > { const domain = parameters.$domain ? parameters.$domain : this.domain; const errorHandlers = this.errorHandlers; const request = this.request; - let path = '/support'; + let path = '/api/assistant'; let body: any; let queryParameters: any = {}; let headers: any = {}; @@ -8508,8 +8508,8 @@ export default class CBioPortalAPIInternal { headers['Accept'] = 'application/json'; headers['Content-Type'] = 'application/json'; - if (parameters['supportMessage'] !== undefined) { - body = parameters['supportMessage']; + if (parameters['userMessage'] !== undefined) { + body = parameters['userMessage']; } request('POST', domain + path, body, headers, queryParameters, form, reject, resolve, errorHandlers); @@ -8522,11 +8522,11 @@ export default class CBioPortalAPIInternal { * @method * @name CBioPortalAPIInternal#getSupprtUsingPOST * @param {Object} parameters - Parameters for the request. - * @param {SupportMessage} [parameters.supportMessage] - The message to send to the AI support system. + * @param {UserMessage} [parameters.userMessage] - The message to send to the AI support system. * @param {string} [parameters.$domain] - Optional override for the API domain. */ getSupportUsingPOST(parameters: { - 'supportMessage' ? : SupportMessage, + 'userMessage' ? : UserMessage, $domain ? : string }): Promise<{ answer: string }> { diff --git a/packages/cbioportal-ts-api-client/src/index.tsx b/packages/cbioportal-ts-api-client/src/index.tsx index 9c8c01fcd14..b67d575976e 100644 --- a/packages/cbioportal-ts-api-client/src/index.tsx +++ b/packages/cbioportal-ts-api-client/src/index.tsx @@ -82,7 +82,7 @@ export { CustomDriverAnnotationReport, StructuralVariant, StructuralVariantFilter, - SupportMessage, + UserMessage, StructuralVariantQuery, StructuralVariantGeneSubQuery, StructuralVariantFilterQuery, diff --git a/src/AppStore.ts b/src/AppStore.ts index 5a9556da390..5682f5679f9 100644 --- a/src/AppStore.ts +++ b/src/AppStore.ts @@ -42,15 +42,6 @@ export class AppStore { } @observable private _appReady = false; - @observable public showSupport = false; - @observable public messages = [ - { - speaker: 'AI', - text: - "Hi there!\nMy name is Tobi, I'm cBioPortal's Support Robot 🤖", - }, - { speaker: 'AI', text: 'What can I do for you today?' }, - ]; siteErrors = observable.array(); diff --git a/src/appShell/App/support.module.scss b/src/appShell/App/support.module.scss deleted file mode 100644 index acd4de106d8..00000000000 --- a/src/appShell/App/support.module.scss +++ /dev/null @@ -1,187 +0,0 @@ -.supportContainer { - position: fixed; - bottom: 20px; - right: 20px; - z-index: 9999; - display: flex; - flex-direction: column; - align-items: flex-end; -} - -.chatWindow { - width: 380px; - height: 700px; - border-radius: 20px; - overflow: hidden; - box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4); - display: flex; - flex-direction: column; - background-color: #fff; - margin-bottom: 8px; -} - -.titlearea { - background-color: #3498db; - color: white; - font-weight: bold; - font-size: 20px; - padding: 12px 16px; - display: flex; - border-top-left-radius: 10px; - border-top-right-radius: 10px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); -} - -.titleIcon { - width: 32px; - height: 32px; - margin-right: 16px; - margin-left: 6px; -} - -.textarea { - height: 84%; - padding: 8px 10px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 6px; - background-color: #ffffff; -} - -.textheader { - padding: 8px 10px; - font-size: 11px; - text-align: center; - color: rgba(0, 0, 0, 0.65); -} - -.messageRow { - display: flex; - margin-bottom: 8px; - width: 100%; - justify-content: flex-start; -} - -.messageRowRight { - justify-content: flex-end; -} - -.message, -.question { - display: inline-block; - max-width: 70%; - min-width: 32px; - margin: 0 6px; - padding: 8px 12px; - border-radius: 12px; - font-size: 14px; - line-height: 1.25; - word-wrap: break-word; - white-space: pre-wrap; - box-sizing: border-box; - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03) inset; -} - -/* AI answers (left) */ -.message { - background-color: rgba(100, 100, 100, 0.08); - color: #1f2933; - text-align: left; -} - -/* User questions (right) */ -.question { - background-color: rgba(52, 152, 219, 0.18); - color: #0b2140; - text-align: left; -} - -.error { -} - -.messageLine { - margin: 0 0 6px 0; -} -.messageLine:last-child { - margin-bottom: 0; -} - -.inputarea { - height: 8%; - display: flex; - align-items: center; - padding: 8px 12px; - background-color: #ffffff; - border-top: 1px solid rgba(0, 0, 0, 0.05); -} - -.form { - display: flex; - align-items: center; - width: 100%; - gap: 8px; -} - -.input { - flex: 1 1 auto; - height: 36px; - font-size: 14px; - border: 1px solid rgba(0, 0, 0, 0.15); - padding: 0 14px; - border-radius: 20px; - color: rgba(0, 0, 0, 0.85); - background: #fff; - outline: none; -} - -/* Animations */ -.thinking { - display: flex; - padding: 12px 12px; -} - -.dots { - display: flex; - gap: 6px; -} - -.dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #555; - transform: scale(0.6); - animation: bubble-bounce 1s infinite ease-in-out; -} - -.dot:nth-of-type(1) { - animation-delay: 0s; -} -.dot:nth-of-type(2) { - animation-delay: 0.15s; -} -.dot:nth-of-type(3) { - animation-delay: 0.3s; -} - -@keyframes bubble-bounce { - 0%, - 80%, - 100% { - transform: scale(0.6); - opacity: 0.35; - } - 40% { - transform: scale(1); - opacity: 0.9; - } -} - -@media (prefers-reduced-motion: reduce) { - .dot { - animation: none; - opacity: 0.5; - transform: none; - } -} diff --git a/src/appShell/App/support.module.scss.d.ts b/src/appShell/App/support.module.scss.d.ts deleted file mode 100644 index 3be3f8d24bb..00000000000 --- a/src/appShell/App/support.module.scss.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -declare const styles: { - readonly "bubble-bounce": string; - readonly "chatWindow": string; - readonly "dot": string; - readonly "dots": string; - readonly "error": string; - readonly "form": string; - readonly "input": string; - readonly "inputarea": string; - readonly "message": string; - readonly "messageLine": string; - readonly "messageRow": string; - readonly "messageRowRight": string; - readonly "question": string; - readonly "supportContainer": string; - readonly "textarea": string; - readonly "textheader": string; - readonly "thinking": string; - readonly "titleIcon": string; - readonly "titlearea": string; -}; -export = styles; - diff --git a/src/appShell/App/cbioportal_icon.png b/src/globalStyles/images/cbioportal_icon.png similarity index 100% rename from src/appShell/App/cbioportal_icon.png rename to src/globalStyles/images/cbioportal_icon.png diff --git a/src/shared/components/PageLayout/PageLayout.tsx b/src/shared/components/PageLayout/PageLayout.tsx index 7afc4079edc..7809d72c868 100644 --- a/src/shared/components/PageLayout/PageLayout.tsx +++ b/src/shared/components/PageLayout/PageLayout.tsx @@ -3,7 +3,6 @@ import classNames from 'classnames'; import { inject } from 'mobx-react'; import { AppStore } from '../../../AppStore'; import PortalFooter from '../../../appShell/App/PortalFooter'; -import PortalSupport from '../../../appShell/App/PortalSupport'; import { RFC80Test } from 'shared/components/rfc80Tester'; interface IPageLayout { @@ -12,7 +11,6 @@ interface IPageLayout { noMargin?: boolean; appStore?: AppStore; hideFooter?: boolean; - enableSupport?: boolean; } @inject('appStore') @@ -37,10 +35,6 @@ export class PageLayout extends React.Component { )} - {!this.props.enableSupport && ( - - )} - {!this.props.hideFooter && ( )} diff --git a/src/appShell/App/PortalSupport.tsx b/src/shared/components/query/GeneAssistant.tsx similarity index 62% rename from src/appShell/App/PortalSupport.tsx rename to src/shared/components/query/GeneAssistant.tsx index 8728c0318ed..804d1b54c53 100644 --- a/src/appShell/App/PortalSupport.tsx +++ b/src/shared/components/query/GeneAssistant.tsx @@ -1,49 +1,55 @@ import * as React from 'react'; import ReactMarkdown from 'react-markdown'; -import './footer.scss'; import _ from 'lodash'; -import { AppStore } from '../../AppStore'; import { observer } from 'mobx-react'; import { action, observable, makeObservable } from 'mobx'; -import styles from './support.module.scss'; -import internalClient from '../../shared/api/cbioportalInternalClientInstance'; -import { SupportMessage } from 'cbioportal-ts-api-client/dist/generated/CBioPortalAPIInternal'; +import styles from './styles/styles.module.scss'; +import internalClient from '../../../shared/api/cbioportalInternalClientInstance'; +import { UserMessage } from 'cbioportal-ts-api-client/dist/generated/CBioPortalAPIInternal'; +import { QueryStoreComponent } from './QueryStore'; @observer -export default class PortalSupport extends React.Component<{ - appStore: AppStore; -}> { - @observable private userInput = ''; - @observable private pending = false; - @observable private showErrorMessage = false; - - constructor(props: { appStore: AppStore }) { +export default class GeneAssistant extends QueryStoreComponent<{}, {}> { + constructor(props: any) { super(props); makeObservable(this); } + @observable private userMessage = ''; + @observable private pending = false; + @observable private showErrorMessage = false; + private examples = { + 'Find mutations in tumor suppressor genes': 'TP53, RB1, PTEN, APC', + 'Look for oncogene amplifications': 'MYC, ERBB2, EGFR', + 'Find KRAS mutations excluding silent ones': 'KRAS', + }; @action.bound private toggleSupport() { - this.props.appStore.showSupport = !this.props.appStore.showSupport; + this.store.showSupport = !this.store.showSupport; + } + + @action.bound + private queryExample(example: string) { + this.userMessage = example; } @action.bound private handleInputChange(event: React.ChangeEvent) { - this.userInput = event.target.value; + this.userMessage = event.target.value; } @action.bound private handleSendMessage(event: React.FormEvent) { event.preventDefault(); - if (!this.userInput.trim()) return; + if (!this.userMessage.trim()) return; - this.props.appStore.messages.push({ + this.store.messages.push({ speaker: 'User', - text: this.userInput, + text: this.userMessage, }); this.getResponse(); - this.userInput = ''; + this.userMessage = ''; } @action.bound @@ -52,14 +58,14 @@ export default class PortalSupport extends React.Component<{ this.pending = true; let supportMessage = { - message: this.userInput, - } as SupportMessage; + message: this.userMessage, + } as UserMessage; try { const response = await internalClient.getSupportUsingPOST({ supportMessage, }); - this.props.appStore.messages.push({ + this.store.messages.push({ speaker: 'AI', text: response.answer, }); @@ -73,18 +79,27 @@ export default class PortalSupport extends React.Component<{ renderButton() { return ( ); @@ -113,7 +128,7 @@ export default class PortalSupport extends React.Component<{ renderMessages() { return (

- {this.props.appStore.messages.map((msg, index) => { + {this.store.messages.map((msg, index) => { const isUser = msg.speaker === 'User'; return (
+

+ + Quick Examples: +

+ +
+ {Object.entries(this.examples).map(([example, genes]) => ( +
this.queryExample(example)} + > + + {example} + + + {genes} + +
+ ))} +
+
+ ); + } + render() { return (
- {this.props.appStore.showSupport && ( + {this.renderButton()} + {this.store.showSupport && (
cBioPortal Icon - cBioPortal Support + cBioPortal Gene Assistant
+ {this.renderExamples()} +
- Please ask your cBioPortal related questions + Please ask your cBioPortal querying questions here, for example how to correctly format a query using Onco Query Language (OQL).
@@ -176,9 +224,9 @@ export default class PortalSupport extends React.Component<{
)} - {this.renderButton()}
); } diff --git a/src/shared/components/query/GeneSetSelector.tsx b/src/shared/components/query/GeneSetSelector.tsx index 0dc296272bc..02e3a185399 100644 --- a/src/shared/components/query/GeneSetSelector.tsx +++ b/src/shared/components/query/GeneSetSelector.tsx @@ -20,6 +20,7 @@ import { Gene } from 'cbioportal-ts-api-client'; import GenesetsValidator from './GenesetsValidator'; import FontAwesome from 'react-fontawesome'; import GeneSymbolValidationError from './GeneSymbolValidationError'; +import GeneAssistant from './GeneAssistant'; @observer export default class GeneSetSelector extends QueryStoreComponent<{}, {}> { @@ -201,6 +202,8 @@ export default class GeneSetSelector extends QueryStoreComponent<{}, {}> { + + ); } diff --git a/src/shared/components/query/QueryStore.ts b/src/shared/components/query/QueryStore.ts index 97258d3f945..8c85e2020de 100644 --- a/src/shared/components/query/QueryStore.ts +++ b/src/shared/components/query/QueryStore.ts @@ -594,6 +594,14 @@ export class QueryStore { @observable genesetQueryErrorDisplayStatus = Focus.Unfocused; @observable showMutSigPopup = false; @observable showGisticPopup = false; + @observable showSupport = false; + @observable public messages = [ + { + speaker: 'AI', + text: + 'Hi there!\nThis is your place to get help with gene symbols and OQL query construction 🤖', + }, + ]; @observable showGenesetsHierarchyPopup = false; @observable showGenesetsVolcanoPopup = false; @observable priorityStudies = ServerConfigHelpers.parseConfigFormat( diff --git a/src/shared/components/query/styles/styles.module.scss b/src/shared/components/query/styles/styles.module.scss index 47181ecec49..64629278ccf 100644 --- a/src/shared/components/query/styles/styles.module.scss +++ b/src/shared/components/query/styles/styles.module.scss @@ -674,3 +674,243 @@ div.submitRow { margin-right: 10px; display: inline-block; } + +// Gene Assistant +.supportContainer { + display: flex; + flex-direction: row; + align-items: flex-start; +} + +.chatWindow { + position: fixed; + top: 50%; + right: 2%; + transform: translateY(-50%); + z-index: 9999; + width: 380px; + height: 700px; + border-radius: 20px; + overflow: hidden; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + background-color: #fff; + margin-bottom: 8px; + animation: fadeInRight 0.2s ease-out forwards; +} + +@keyframes fadeInRight { + from { + transform: translate(100%, -50%); + } + to { + transform: translate(0, -50%); + } +} + +.titlearea { + background-color: #3498db; + color: white; + font-weight: bold; + font-size: 20px; + padding: 12px 16px; + display: flex; + flex-direction: row; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.titleIcon { + width: 32px; + height: 32px; + margin-right: 16px; + margin-left: 6px; +} + +.examplesarea { + height: 30%; + display: flex; + flex-direction: column; + padding: 8px 12px; + border-bottom: 1px solid rgba(0, 0, 0, 0.2); +} + +.examplestext { + display: flex; + flex-direction: column; + gap: 3px; +} + +.exampleitem { + padding: 6px 8px; + border-radius: 6px; + display: flex; + flex-direction: column; + gap: 2px; + transition: background-color 0.2s ease; +} + +.exampleitem:hover { + background-color: $lightBlue; + cursor: pointer; +} + +.exampletitle { + font-weight: 600; + font-size: 14px; + color: #333333; +} + +.exampledescription { + font-size: 13px; + color: #555555; + line-height: 1.4; +} + +.textarea { + height: 84%; + padding: 8px 10px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; +} + +.textheader { + padding: 8px 10px; + font-size: 11px; + text-align: center; + color: rgba(0, 0, 0, 0.65); +} + +.messageRow { + display: flex; + margin-bottom: 8px; + width: 100%; + justify-content: flex-start; +} + +.messageRowRight { + justify-content: flex-end; +} + +.message, +.question { + display: inline-block; + max-width: 70%; + min-width: 32px; + margin: 0 6px; + padding: 8px 12px; + border-radius: 12px; + font-size: 14px; + line-height: 1.25; + word-wrap: break-word; + white-space: pre-wrap; + box-sizing: border-box; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03) inset; +} + +/* AI answers (left) */ +.message { + background-color: rgba(100, 100, 100, 0.08); + color: #1f2933; + text-align: left; +} + +/* User questions (right) */ +.question { + background-color: rgba(52, 152, 219, 0.18); + color: #0b2140; + text-align: left; +} + +.error { +} + +.messageLine { + margin: 0 0 6px 0; +} +.messageLine:last-child { + margin-bottom: 0; +} + +.inputarea { + height: 8%; + display: flex; + align-items: center; + padding: 8px 12px; + background-color: #ffffff; + border-top: 1px solid rgba(0, 0, 0, 0.2); +} + +.form { + display: flex; + align-items: center; + width: 100%; + gap: 8px; +} + +.input { + flex: 1 1 auto; + height: 36px; + font-size: 14px; + border: 1px solid rgba(0, 0, 0, 0.15); + padding: 0 14px; + border-radius: 20px; + color: rgba(0, 0, 0, 0.85); + background: #fff; + outline: none; +} + +/* Animations */ +.thinking { + display: flex; + padding: 12px 12px; +} + +.dots { + display: flex; + gap: 6px; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #555; + transform: scale(0.6); + animation: bubble-bounce 1s infinite ease-in-out; +} + +.dot:nth-of-type(1) { + animation-delay: 0s; +} +.dot:nth-of-type(2) { + animation-delay: 0.15s; +} +.dot:nth-of-type(3) { + animation-delay: 0.3s; +} + +@keyframes bubble-bounce { + 0%, + 80%, + 100% { + transform: scale(0.6); + opacity: 0.35; + } + 40% { + transform: scale(1); + opacity: 0.9; + } +} + +@media (prefers-reduced-motion: reduce) { + .dot { + animation: none; + opacity: 0.5; + transform: none; + } +} diff --git a/src/shared/components/query/styles/styles.module.scss.d.ts b/src/shared/components/query/styles/styles.module.scss.d.ts index 1de48f1e10f..5af029ca6aa 100644 --- a/src/shared/components/query/styles/styles.module.scss.d.ts +++ b/src/shared/components/query/styles/styles.module.scss.d.ts @@ -21,6 +21,7 @@ declare const styles: { readonly "SelectedStudiesWindow": string; readonly "StudySelection": string; readonly "amp": string; + readonly "bubble-bounce": string; readonly "buttonRow": string; readonly "cancerStudyListContainer": string; readonly "cancerStudySelectorBody": string; @@ -28,12 +29,23 @@ declare const styles: { readonly "cancerTypeListContainer": string; readonly "cancerTypeListItemCount": string; readonly "cancerTypeListItemLabel": string; + readonly "chatWindow": string; readonly "containsSelectedStudies": string; readonly "del": string; + readonly "dot": string; + readonly "dots": string; readonly "downloadSubmitExplanation": string; readonly "empty": string; + readonly "error": string; readonly "errorMessage": string; + readonly "exampledescription": string; + readonly "exampleitem": string; + readonly "examplesarea": string; + readonly "examplestext": string; + readonly "exampletitle": string; + readonly "fadeInRight": string; readonly "forkedButtons": string; + readonly "form": string; readonly "geneCount": string; readonly "geneSet": string; readonly "geneToggle": string; @@ -44,9 +56,15 @@ declare const styles: { readonly "header": string; readonly "icon": string; readonly "infoIcon": string; + readonly "input": string; + readonly "inputarea": string; readonly "invalidBubble": string; readonly "learnOql": string; readonly "matchingNodeText": string; + readonly "message": string; + readonly "messageLine": string; + readonly "messageRow": string; + readonly "messageRowRight": string; readonly "moreGenes": string; readonly "multiChoiceLabel": string; readonly "noChoiceLabel": string; @@ -59,6 +77,7 @@ declare const styles: { readonly "pendingMessage": string; readonly "profileName": string; readonly "queryHelp": string; + readonly "question": string; readonly "quickSelect": string; readonly "radioRow": string; readonly "searchTextInput": string; @@ -77,6 +96,12 @@ declare const styles: { readonly "studyName": string; readonly "submitRow": string; readonly "suggestionBubble": string; + readonly "supportContainer": string; + readonly "textarea": string; + readonly "textheader": string; + readonly "thinking": string; + readonly "titleIcon": string; + readonly "titlearea": string; readonly "tooltip": string; readonly "transposeDataMatrix": string; readonly "validBubble": string; From 788a5dbb60d76d25affc9349f886be1329ad8fe4 Mon Sep 17 00:00:00 2001 From: Floris Vleugels Date: Sat, 11 Oct 2025 15:37:30 +0200 Subject: [PATCH 3/8] Update userMessage field --- .../src/generated/CBioPortalAPIInternal.ts | 2 +- src/shared/components/query/GeneAssistant.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cbioportal-ts-api-client/src/generated/CBioPortalAPIInternal.ts b/packages/cbioportal-ts-api-client/src/generated/CBioPortalAPIInternal.ts index 643c5c66b58..e016c1aab49 100644 --- a/packages/cbioportal-ts-api-client/src/generated/CBioPortalAPIInternal.ts +++ b/packages/cbioportal-ts-api-client/src/generated/CBioPortalAPIInternal.ts @@ -8528,7 +8528,7 @@ export default class CBioPortalAPIInternal { getSupportUsingPOST(parameters: { 'userMessage' ? : UserMessage, $domain ? : string - }): Promise<{ answer: string }> + }): Promise<{ aiResponse: string }> { return this.getSupportUsingPOSTWithHttpInfo(parameters).then(function(response: request.Response) { return response.body; diff --git a/src/shared/components/query/GeneAssistant.tsx b/src/shared/components/query/GeneAssistant.tsx index 804d1b54c53..d96d235d53a 100644 --- a/src/shared/components/query/GeneAssistant.tsx +++ b/src/shared/components/query/GeneAssistant.tsx @@ -57,17 +57,17 @@ export default class GeneAssistant extends QueryStoreComponent<{}, {}> { this.showErrorMessage = false; this.pending = true; - let supportMessage = { + let userMessage = { message: this.userMessage, } as UserMessage; try { const response = await internalClient.getSupportUsingPOST({ - supportMessage, + userMessage, }); this.store.messages.push({ speaker: 'AI', - text: response.answer, + text: response.aiResponse, }); this.pending = false; } catch (error) { From 26fc469830ba1050057d43ca8c03e175ec0512d8 Mon Sep 17 00:00:00 2001 From: Floris Vleugels Date: Mon, 13 Oct 2025 14:33:02 +0200 Subject: [PATCH 4/8] Add button and fix error messages --- src/shared/components/query/GeneAssistant.tsx | 65 +++++++++++++++---- .../query/styles/styles.module.scss | 3 - .../query/styles/styles.module.scss.d.ts | 1 - 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/src/shared/components/query/GeneAssistant.tsx b/src/shared/components/query/GeneAssistant.tsx index d96d235d53a..7bb986ad9d2 100644 --- a/src/shared/components/query/GeneAssistant.tsx +++ b/src/shared/components/query/GeneAssistant.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import ReactMarkdown from 'react-markdown'; -import _ from 'lodash'; import { observer } from 'mobx-react'; import { action, observable, makeObservable } from 'mobx'; import styles from './styles/styles.module.scss'; @@ -8,6 +7,15 @@ import internalClient from '../../../shared/api/cbioportalInternalClientInstance import { UserMessage } from 'cbioportal-ts-api-client/dist/generated/CBioPortalAPIInternal'; import { QueryStoreComponent } from './QueryStore'; +enum OQLError { + io = 'Something went wrong, please try again', + invalid = 'Please submit a valid OQL question', +} + +const ErrorMessage: React.FC<{ message: string }> = ({ message }) => ( +
{message}
+); + @observer export default class GeneAssistant extends QueryStoreComponent<{}, {}> { constructor(props: any) { @@ -17,6 +25,7 @@ export default class GeneAssistant extends QueryStoreComponent<{}, {}> { @observable private userMessage = ''; @observable private pending = false; @observable private showErrorMessage = false; + @observable private errorMessage = OQLError.io; private examples = { 'Find mutations in tumor suppressor genes': 'TP53, RB1, PTEN, APC', 'Look for oncogene amplifications': 'MYC, ERBB2, EGFR', @@ -28,6 +37,11 @@ export default class GeneAssistant extends QueryStoreComponent<{}, {}> { this.store.showSupport = !this.store.showSupport; } + @action.bound + private submitOQL(oql: string) { + this.store.geneQuery = oql; + } + @action.bound private queryExample(example: string) { this.userMessage = example; @@ -65,14 +79,22 @@ export default class GeneAssistant extends QueryStoreComponent<{}, {}> { const response = await internalClient.getSupportUsingPOST({ userMessage, }); - this.store.messages.push({ - speaker: 'AI', - text: response.aiResponse, - }); + const parts = response.aiResponse.split('OQL: ', 2); + + if (parts.length < 2 || parts[1].trim().toUpperCase() === 'FALSE') { + this.showErrorMessage = true; + this.errorMessage = OQLError.invalid; + } else { + this.store.messages.push({ + speaker: 'AI', + text: parts[0].trim(), + }); + } this.pending = false; } catch (error) { this.pending = false; this.showErrorMessage = true; + this.errorMessage = OQLError.io; } } @@ -117,12 +139,8 @@ export default class GeneAssistant extends QueryStoreComponent<{}, {}> { ); } - renderErrorMessage() { - return ( -
- Something went wrong, please try again. -
- ); + renderErrorMessage(error: string) { + return
{error}
; } renderMessages() { @@ -151,6 +169,22 @@ export default class GeneAssistant extends QueryStoreComponent<{}, {}> {

))}
+ {!isUser && index !== 0 && ( +
+ +
+ )}
); })} @@ -213,7 +247,9 @@ export default class GeneAssistant extends QueryStoreComponent<{}, {}> { {this.renderMessages()} {this.pending && this.renderThinking()} - {this.showErrorMessage && this.renderErrorMessage()} + {this.showErrorMessage && ( + + )}
@@ -230,7 +266,6 @@ export default class GeneAssistant extends QueryStoreComponent<{}, {}> { />
diff --git a/src/shared/components/query/styles/styles.module.scss b/src/shared/components/query/styles/styles.module.scss index 64629278ccf..a8faee2b307 100644 --- a/src/shared/components/query/styles/styles.module.scss +++ b/src/shared/components/query/styles/styles.module.scss @@ -826,9 +826,6 @@ div.submitRow { text-align: left; } -.error { -} - .messageLine { margin: 0 0 6px 0; } diff --git a/src/shared/components/query/styles/styles.module.scss.d.ts b/src/shared/components/query/styles/styles.module.scss.d.ts index 5af029ca6aa..c55ac57bace 100644 --- a/src/shared/components/query/styles/styles.module.scss.d.ts +++ b/src/shared/components/query/styles/styles.module.scss.d.ts @@ -36,7 +36,6 @@ declare const styles: { readonly "dots": string; readonly "downloadSubmitExplanation": string; readonly "empty": string; - readonly "error": string; readonly "errorMessage": string; readonly "exampledescription": string; readonly "exampleitem": string; From edbf8423f5ca400613297a76f44e570ef8937fb1 Mon Sep 17 00:00:00 2001 From: Floris Vleugels Date: Mon, 13 Oct 2025 14:35:10 +0200 Subject: [PATCH 5/8] Change to new X icon since twitter icon depr in new font awesome --- src/appShell/App/PortalFooter.tsx | 4 ++-- src/shared/components/rightbar/RightBar.tsx | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/appShell/App/PortalFooter.tsx b/src/appShell/App/PortalFooter.tsx index 69cbe3d61df..4f5df1c85f4 100644 --- a/src/appShell/App/PortalFooter.tsx +++ b/src/appShell/App/PortalFooter.tsx @@ -187,9 +187,9 @@ export default class PortalFooter extends React.Component<
  • - Twitter + X
  • diff --git a/src/shared/components/rightbar/RightBar.tsx b/src/shared/components/rightbar/RightBar.tsx index 869d183af2b..c7794fafda0 100644 --- a/src/shared/components/rightbar/RightBar.tsx +++ b/src/shared/components/rightbar/RightBar.tsx @@ -102,14 +102,11 @@ export default class RightBar extends React.Component<

    What's New @cbioportal{' '} - +

    From 3822ec9fd66bd269821e596748f2b7b98636a31c Mon Sep 17 00:00:00 2001 From: Floris Vleugels Date: Mon, 13 Oct 2025 15:40:47 +0200 Subject: [PATCH 6/8] Change 'use oql' icon and example queries --- src/shared/components/query/GeneAssistant.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/shared/components/query/GeneAssistant.tsx b/src/shared/components/query/GeneAssistant.tsx index 7bb986ad9d2..5493ccf6cd6 100644 --- a/src/shared/components/query/GeneAssistant.tsx +++ b/src/shared/components/query/GeneAssistant.tsx @@ -27,9 +27,10 @@ export default class GeneAssistant extends QueryStoreComponent<{}, {}> { @observable private showErrorMessage = false; @observable private errorMessage = OQLError.io; private examples = { - 'Find mutations in tumor suppressor genes': 'TP53, RB1, PTEN, APC', - 'Look for oncogene amplifications': 'MYC, ERBB2, EGFR', - 'Find KRAS mutations excluding silent ones': 'KRAS', + 'Find mutations in tumor suppressor genes': + 'TP53 RB1 CDKN2A PTEN SMAD4 ARID1A...', + 'Somatic missense mutations in PIK3CA': 'PIK3CA: MISSENSE_SOMATIC', + 'Find KRAS mutations excluding silent ones': 'KRAS: MUT', }; @action.bound @@ -181,7 +182,7 @@ export default class GeneAssistant extends QueryStoreComponent<{}, {}> { background: 'none', }} > - + )} From b5862b0ded60cef2cb5e5ab384fb9b812b115129 Mon Sep 17 00:00:00 2001 From: Floris Vleugels Date: Thu, 16 Oct 2025 10:52:22 +0200 Subject: [PATCH 7/8] Only show gene assistant when spring.ai.enabled --- src/config/IAppConfig.ts | 1 + src/shared/components/query/GeneSetSelector.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config/IAppConfig.ts b/src/config/IAppConfig.ts index d24467c48b8..c93ea3ab267 100644 --- a/src/config/IAppConfig.ts +++ b/src/config/IAppConfig.ts @@ -188,6 +188,7 @@ export interface IServerConfig { skin_study_view_show_sv_table: boolean; // this has a default enable_study_tags: boolean; clickhouse_mode: boolean; + spring_ai_enabled: boolean; download_custom_buttons_json: string; feature_study_export: boolean; } diff --git a/src/shared/components/query/GeneSetSelector.tsx b/src/shared/components/query/GeneSetSelector.tsx index 02e3a185399..fe29a59a5ac 100644 --- a/src/shared/components/query/GeneSetSelector.tsx +++ b/src/shared/components/query/GeneSetSelector.tsx @@ -203,7 +203,7 @@ export default class GeneSetSelector extends QueryStoreComponent<{}, {}> { - + {getServerConfig().spring_ai_enabled && } ); } From 68e8497082e19c802261b6a0dc5e9e5d8fa3f66e Mon Sep 17 00:00:00 2001 From: Floris Vleugels Date: Thu, 27 Nov 2025 14:04:37 +0100 Subject: [PATCH 8/8] fix property name --- src/config/IAppConfig.ts | 2 +- src/shared/components/query/GeneSetSelector.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/IAppConfig.ts b/src/config/IAppConfig.ts index c93ea3ab267..e90106d0d4a 100644 --- a/src/config/IAppConfig.ts +++ b/src/config/IAppConfig.ts @@ -188,7 +188,7 @@ export interface IServerConfig { skin_study_view_show_sv_table: boolean; // this has a default enable_study_tags: boolean; clickhouse_mode: boolean; - spring_ai_enabled: boolean; + assistant_enabled: boolean; download_custom_buttons_json: string; feature_study_export: boolean; } diff --git a/src/shared/components/query/GeneSetSelector.tsx b/src/shared/components/query/GeneSetSelector.tsx index fe29a59a5ac..f321953a9f6 100644 --- a/src/shared/components/query/GeneSetSelector.tsx +++ b/src/shared/components/query/GeneSetSelector.tsx @@ -203,7 +203,7 @@ export default class GeneSetSelector extends QueryStoreComponent<{}, {}> { - {getServerConfig().spring_ai_enabled && } + {getServerConfig().assistant_enabled && } ); }