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 bf4da4d89a4..e016c1aab49 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 UserMessage = { + '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 {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: { + '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 = '/api/assistant'; + 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['userMessage'] !== undefined) { + body = parameters['userMessage']; + } + + 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 {UserMessage} [parameters.userMessage] - The message to send to the AI support system. + * @param {string} [parameters.$domain] - Optional override for the API domain. + */ + getSupportUsingPOST(parameters: { + 'userMessage' ? : UserMessage, + $domain ? : string + }): Promise<{ aiResponse: 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..b67d575976e 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, + UserMessage, StructuralVariantQuery, StructuralVariantGeneSubQuery, StructuralVariantFilterQuery, 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/config/IAppConfig.ts b/src/config/IAppConfig.ts index d24467c48b8..e90106d0d4a 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; + assistant_enabled: boolean; download_custom_buttons_json: string; feature_study_export: boolean; } diff --git a/src/globalStyles/images/cbioportal_icon.png b/src/globalStyles/images/cbioportal_icon.png new file mode 100644 index 00000000000..71896c39c4f Binary files /dev/null and b/src/globalStyles/images/cbioportal_icon.png differ diff --git a/src/shared/components/query/GeneAssistant.tsx b/src/shared/components/query/GeneAssistant.tsx new file mode 100644 index 00000000000..5493ccf6cd6 --- /dev/null +++ b/src/shared/components/query/GeneAssistant.tsx @@ -0,0 +1,288 @@ +import * as React from 'react'; +import ReactMarkdown from 'react-markdown'; +import { observer } from 'mobx-react'; +import { action, observable, makeObservable } from 'mobx'; +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'; + +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) { + super(props); + makeObservable(this); + } + @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 CDKN2A PTEN SMAD4 ARID1A...', + 'Somatic missense mutations in PIK3CA': 'PIK3CA: MISSENSE_SOMATIC', + 'Find KRAS mutations excluding silent ones': 'KRAS: MUT', + }; + + @action.bound + private toggleSupport() { + 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; + } + + @action.bound + private handleInputChange(event: React.ChangeEvent) { + this.userMessage = event.target.value; + } + + @action.bound + private handleSendMessage(event: React.FormEvent) { + event.preventDefault(); + + if (!this.userMessage.trim()) return; + + this.store.messages.push({ + speaker: 'User', + text: this.userMessage, + }); + this.getResponse(); + this.userMessage = ''; + } + + @action.bound + private async getResponse() { + this.showErrorMessage = false; + this.pending = true; + + let userMessage = { + message: this.userMessage, + } as UserMessage; + + try { + const response = await internalClient.getSupportUsingPOST({ + userMessage, + }); + 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; + } + } + + renderButton() { + return ( + + ); + } + + renderThinking() { + return ( +
    + + + + + +
    + ); + } + + renderErrorMessage(error: string) { + return
    {error}
    ; + } + + renderMessages() { + return ( +
    + {this.store.messages.map((msg, index) => { + const isUser = msg.speaker === 'User'; + return ( +
    +
    + {msg.text.split('\n').map((line, i) => ( +

    + + {line} + +

    + ))} +
    + {!isUser && index !== 0 && ( +
    + +
    + )} +
    + ); + })} +
    + ); + } + + renderExamples() { + return ( +
    +

    + + Quick Examples: +

    + +
    + {Object.entries(this.examples).map(([example, genes]) => ( +
    this.queryExample(example)} + > + + {example} + + + {genes} + +
    + ))} +
    +
    + ); + } + + render() { + return ( +
    + {this.renderButton()} + {this.store.showSupport && ( +
    +
    + cBioPortal icon + cBioPortal Gene Assistant +
    + + {this.renderExamples()} + +
    +
    + Please ask your cBioPortal querying questions + here, for example how to correctly format a + query using Onco Query Language (OQL). +
    + {this.renderMessages()} + {this.pending && this.renderThinking()} + {this.showErrorMessage && ( + + )} +
    + +
    +
    + + +
    +
    +
    + )} +
    + ); + } +} diff --git a/src/shared/components/query/GeneSetSelector.tsx b/src/shared/components/query/GeneSetSelector.tsx index 0dc296272bc..f321953a9f6 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<{}, {}> { + + {getServerConfig().assistant_enabled && } ); } 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..a8faee2b307 100644 --- a/src/shared/components/query/styles/styles.module.scss +++ b/src/shared/components/query/styles/styles.module.scss @@ -674,3 +674,240 @@ 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; +} + +.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..c55ac57bace 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,22 @@ 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 "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 +55,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 +76,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 +95,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; 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{' '} - +