diff --git a/client-app/package.json b/client-app/package.json index cd92f8f1b..5b1dd3ccb 100755 --- a/client-app/package.json +++ b/client-app/package.json @@ -50,7 +50,8 @@ "lodash": "4.x", "moment": "2.x", "react": "~18.2.0", - "react-dom": "~18.2.0" + "react-dom": "~18.2.0", + "react-markdown": "~8.0.7" }, "devDependencies": { "@xh/hoist-dev-utils": "^9.0.0-SNAPSHOT", diff --git a/client-app/src/Bootstrap.ts b/client-app/src/Bootstrap.ts index 42908079d..d2a87bdc4 100755 --- a/client-app/src/Bootstrap.ts +++ b/client-app/src/Bootstrap.ts @@ -13,6 +13,7 @@ import {XH} from '@xh/hoist/core'; import {when} from '@xh/hoist/mobx'; import {ContactService} from './examples/contact/svc/ContactService'; +import {ChatGptService} from './core/svc/ChatGptService'; import {GitHubService} from './core/svc/GitHubService'; import {PortfolioService} from './core/svc/PortfolioService'; import {AuthService} from './core/svc/AuthService'; @@ -21,6 +22,7 @@ import {TaskService} from './examples/todo/TaskService'; declare module '@xh/hoist/core' { // Merge interface with XHApi class to include injected services. export interface XHApi { + chatGptService: ChatGptService; contactService: ContactService; gitHubService: GitHubService; authService: AuthService; diff --git a/client-app/src/apps/chat.ts b/client-app/src/apps/chat.ts new file mode 100644 index 000000000..0c9c5243a --- /dev/null +++ b/client-app/src/apps/chat.ts @@ -0,0 +1,17 @@ +import '../Bootstrap'; + +import {XH} from '@xh/hoist/core'; +import {AppContainer} from '@xh/hoist/desktop/appcontainer'; +import {AppComponent} from '../examples/chat/AppComponent'; +import {AppModel} from '../examples/chat/AppModel'; + +XH.renderApp({ + clientAppCode: 'chat', + clientAppName: 'ChatGPT Labs', + componentClass: AppComponent, + modelClass: AppModel, + containerClass: AppContainer, + isMobileApp: false, + isSSO: true, + checkAccess: 'CHAT_GPT_USER' +}); diff --git a/client-app/src/core/svc/ChatGptService.ts b/client-app/src/core/svc/ChatGptService.ts new file mode 100644 index 000000000..f329c3f94 --- /dev/null +++ b/client-app/src/core/svc/ChatGptService.ts @@ -0,0 +1,359 @@ +import {HoistService, persist, XH} from '@xh/hoist/core'; +import {dropRight, isEmpty, isString, last, pick, remove} from 'lodash'; +import {bindable, makeObservable, observable} from '@xh/hoist/mobx'; +import {logInfo, withInfo} from '@xh/hoist/utils/js'; +import type {SetOptional} from 'type-fest'; + +export interface GptMessage { + role: 'system' | 'user' | 'assistant' | 'function'; + timestamp: number; + content?: string; + name?: string; + function_call?: GptFnCallResponse; + responseJson?: string; +} + +export interface GptFnCallResponse { + // Name of the function to be called. + name: string; + // Args to pass to function, escaped JSON string + // ...or maybe a primitive if that's the function signature - need to check + arguments: string; +} + +export interface GptChatOptions { + model?: GptModel; + function_call?: GptFnCallRequest; +} + +export type GptFnCallRequest = 'none' | 'auto' | {name: string}; + +export type GptModel = 'gpt-3.5-turbo' | 'gpt-4'; + +export class ChatGptService extends HoistService { + override persistWith = {localStorageKey: 'chatGptService'}; + + // Initialized from config via dedicated server call. + // Configs are protected and not sent to all clients - the CHAT_GPT_USER role is required. + apiKey: string; + completionUrl: string; + + /** + * Log of all messages sent back and forth between user and GPT. + * Sent with each new message to provide GPT with the overall conversation / context. + */ + @bindable.ref + @persist + messages: GptMessage[] = []; + + /** + * History of recent user messages to support quick re-selection by user. + * Persisted separately from the main message stream sent to GPT with each request. + */ + @bindable.ref + @persist + userMessageHistory: string[] = []; + + get userMessages(): GptMessage[] { + return this.messages.filter(it => it.role === 'user'); + } + + get systemMessage(): GptMessage { + return this.messages.find(it => it.role === 'system'); + } + + get hasMessages(): boolean { + return !isEmpty(this.messages); + } + + @bindable + @persist + model = 'gpt-3.5-turbo'; + selectableModels = ['gpt-3.5-turbo', 'gpt-4']; + + @observable isInitialized = false; + + @bindable + @persist + sendSystemMessage = true; + + initialSystemMessage = + 'You are a professional AI assistant embedded within a custom financial reporting dashboard application created by a\n' + + 'hedge fund with headquarters in the United States. Your role is to respond to user queries with either a function call\n' + + 'that the application can run OR a message asking the user to clarify or explaining why you are unable to help.\n' + + '\n' + + '### Objects returned and aggregated by the application API\n' + + '\n' + + 'The `getPortfolioPositions` function returns a list of `Position` objects. A `Position` satisfies the following\n' + + 'interface:\n' + + '\n' + + '```typescript\n' + + 'interface Position {\n' + + ' id: string;\n' + + ' name: string;\n' + + ' pnl: number;\n' + + ' mktVal: number;\n' + + ' children: Position[];\n' + + '}\n' + + '```\n' + + '\n' + + 'A `Position` represents an aggregate of one or more `RawPosition` objects. A `RawPosition` models a single investment\n' + + 'within a portfolio. It satisfies the following interface:\n' + + '\n' + + '```typescript\n' + + 'interface RawPosition {\n' + + ' // Dimension - the trading strategy to which this position belongs. The value will be a code returned by the `findStrategies` function.\n' + + ' strategy: string;\n' + + " // Dimension - the stock ticker or identifier of the position's instrument, an equity stock or other security - e.g. ['AAPL', 'GOOG', 'MSFT']\n" + + ' symbol: string;\n' + + " // Dimension - the industry sector of the instrument - e.g. ['Technology', 'Healthcare', 'Energy']\n" + + ' sector: string;\n' + + " // Dimension - the name of an investment fund - e.g. ['Winter Star Fund', 'Oak Mount Fund']\n" + + ' fund: string;\n' + + " // Dimension - the name of the trader or portfolio manager responsible for the investment - e.g. ['Susan Major', 'Fred Corn', 'HedgeSys']\n" + + ' trader: string;\n' + + ' // Measure - the current value of the position, in USD.\n' + + ' mktVal: number;\n' + + ' // Measure - the current profit and loss of the position, in USD.\n' + + ' pnl: number;\n' + + '}\n' + + '```\n' + + '\n' + + 'The `getPortfolioPositions` function takes a list of `groupByDimensions` when aggregating results, representing\n' + + 'the field names of `RawPosition` dimensions within the portfolio data.\n' + + '\n' + + 'Begin by introducing yourself to the user and ask them how you can help them.\n'; + + functions = [ + { + name: 'getPortfolioPositions', + description: + 'Query a portfolio of `RawPosition` objects representing investments to return aggregated `Position` ' + + 'objects with P&L (profit and loss) and market value data, grouped by one or more specified dimensions. ' + + 'Each grouped row in the return will have the following properties: `name`, `pnl` (profit and loss), and ' + + '`mktVal` (market value). If multiple grouping dimensions are specified, the results will be returned in ' + + 'a tree structure, where each parent group will have a `children` property containing an array of nested sub-groups. ' + + 'Many queries should be run by first finding a list of strategy codes using the `findStrategies` function, ' + + 'then passing those codes to the `strategies` parameter in this function.', + parameters: { + type: 'object', + properties: { + groupByDimensions: { + description: + 'Array of one or more dimensions by which the portfolio positions should be aggregated.', + type: 'array', + items: { + type: 'string', + enum: ['fund', 'model', 'region', 'sector', 'trader', 'symbol'] + }, + minItems: 1, + uniqueItems: true + }, + sortBy: { + description: + 'The sort order of the returned results, by P&L or Market Value, either ascending or descending. Default is pnl|desc.', + type: 'string', + enum: ['pnl|desc', 'pnl|asc', 'mktVal|desc', 'mktVal|asc'] + }, + maxRows: { + description: + 'The maximum number of top-level rows to return. Leave unspecified to return all available groupings.', + type: 'integer', + minimum: 1 + }, + strategies: { + type: 'array', + items: { + type: 'string' + }, + description: + 'Optional list of strategy codes to filter by. Strategy codes should be first looked up via the `findStrategies` function, then used in this parameter to find suitable positions.' + } + }, + required: ['groupByDimensions'] + } + }, + { + name: 'findStrategies', + description: + 'Search for suitable strategy codes that can then be used in portfolio queries.', + parameters: { + type: 'object', + properties: { + sector: { + type: 'string', + description: + 'Optional filter by sector, the econonmic area or industry covered by a strategy. e.g. "Healthcare" or "Technology".' + }, + analyst: { + type: 'string', + description: + 'Optional filter by lead analyst, the individual person who is responsible for managing the strategy. e.g. "Susan Major".' + }, + freeText: { + type: 'string', + description: + 'Optional free text search that can be used to match across multiple strategy fields, when you are unsure which specific field or fields to filter on.' + } + }, + minProperties: 1 + } + } + ]; + + constructor() { + super(); + makeObservable(this); + + // this.messages.forEach(msg => this.logMsg(msg)); + + this.addReaction( + { + track: () => this.messages, + run: msgs => { + this.hasMessages ? this.logMsg(last(msgs)) : logInfo('Messages cleared', this); + } + }, + { + track: () => this.sendSystemMessage, + run: () => this.clearAndReInitAsync() + } + ); + } + + logMsg(msg: GptMessage) { + logInfo(`Received message: ${JSON.stringify(msg, null, 2)}`, this); + } + + override async initAsync() { + const conf = await XH.fetchJson({ + url: 'chatGpt/config' + }); + this.apiKey = conf.apiKey; + this.completionUrl = conf.completionUrl; + + if (!this.apiKey || !this.completionUrl) { + throw XH.exception('ChatGPT configuration is missing required values.'); + } + + const {systemMessage, sendSystemMessage, initialSystemMessage, hasMessages} = this; + if ( + (systemMessage && !sendSystemMessage) || + (systemMessage && systemMessage.content !== initialSystemMessage) || + (!systemMessage && hasMessages && sendSystemMessage) + ) { + this.clearHistory(); + XH.toast('System message has changed - history cleared.'); + } + + if (!this.hasMessages && this.sendSystemMessage) { + this.sendChatAsync({ + role: 'system', + content: this.initialSystemMessage + }) + .thenAction(() => (this.isInitialized = true)) + .catch(e => { + this.isInitialized = false; + XH.handleException(e, { + message: 'Failed to initialize ChatGPTService.', + alertType: 'toast' + }); + }); + } else { + this.isInitialized = true; + } + } + + // TODO - cancel any pending requests + async clearAndReInitAsync() { + this.clearHistory(); + await this.initAsync(); + } + + async sendChatAsync( + message: SetOptional | string, + options: GptChatOptions = {} + ) { + const msgToSend: GptMessage = isString(message) + ? { + role: 'user', + content: message, + timestamp: Date.now() + } + : { + timestamp: Date.now(), + ...message + }; + + // Push user message onto state immediately, to indicate that it's been sent. + this.messages = [...this.messages, msgToSend]; + + // And user messages to history for convenient re-selection. + if (msgToSend.role === 'user') { + this.updateUserMessageHistory(msgToSend.content); + } + + console.log(this.messages.map(it => this.formatMessageForPost(it))); + const body = { + model: this.model, + messages: this.messages.map(it => this.formatMessageForPost(it)), + functions: this.functions, + ...options + }; + + let resp; + try { + await withInfo('Called ChatGPT', async () => { + resp = await XH.fetchService.postJson({ + url: this.completionUrl, + headers: { + Authorization: `Bearer ${this.apiKey}` + }, + fetchOpts: {credentials: 'omit'}, + body + }); + }); + } catch (e) { + // Unwind user message - was not successfully posted. + this.messages = dropRight(this.messages); + throw e; + } + + console.debug(resp); + if (isEmpty(resp?.choices)) throw XH.exception('GPT did not return any choices'); + + const gptReplyChoice = resp.choices[0], + gptResponse: GptMessage = { + ...gptReplyChoice.message, + timestamp: Date.now(), + responseJson: JSON.stringify(resp, null, 2) + }; + this.messages = [...this.messages, gptResponse]; + } + + // Strip any extra fields from message before sending to GPT. + formatMessageForPost(msg: GptMessage) { + return pick(msg, ['role', 'content', 'name', 'function_call']); + } + + updateUserMessageHistory(msg: string) { + const history = [...this.userMessageHistory]; + if (history.includes(msg)) { + remove(history, it => it === msg); + } + history.unshift(msg); + this.userMessageHistory = history; + } + + removeFromMessageHistory(msg: string) { + this.userMessageHistory = this.userMessageHistory.filter(it => it !== msg); + } + + clearUserMessageHistory() { + this.userMessageHistory = []; + } + + clearHistory() { + this.messages = []; + } +} diff --git a/client-app/src/core/svc/PortfolioService.ts b/client-app/src/core/svc/PortfolioService.ts index dc454ce11..b7643a17b 100644 --- a/client-app/src/core/svc/PortfolioService.ts +++ b/client-app/src/core/svc/PortfolioService.ts @@ -21,8 +21,7 @@ export class PortfolioService extends HoistService { * Return a portfolio of hierarchically grouped positions for the selected dimension(s). * @param dims - field names for dimensions on which to group. * @param includeSummary - true to include a root summary node - * @param maxPositions - truncate position tree, by smallest pnl, until this number of - * positions is reached. + * @param maxPositions - truncate position tree, by smallest pnl, until this number of positions is reached. */ async getPositionsAsync( dims: string[], @@ -82,7 +81,7 @@ export class PortfolioService extends HoistService { return XH.fetchJson({url: 'portfolio/pricedRawPositions', loadSpec}); } - async getAllOrdersAsync({loadSpec}: any = {}): Promise { + async getAllOrdersAsync({loadSpec}: any = {}): Promise { return XH.fetchJson({url: 'portfolio/orders', loadSpec}); } @@ -157,3 +156,21 @@ export interface PricedRawPosition { mktVal: number; pnl: number; } + +export interface Order { + id: string; + symbol: string; + sector: string; + region: string; + model: string; + trader: string; + fund: string; + dir: string; + quantity: number; + price: number; + mktVal: number; + time: number; + commission: number; + confidences: number; + closingPrices: number[]; +} diff --git a/client-app/src/examples/chat/AppComponent.ts b/client-app/src/examples/chat/AppComponent.ts new file mode 100644 index 000000000..1fe0f1e6a --- /dev/null +++ b/client-app/src/examples/chat/AppComponent.ts @@ -0,0 +1,100 @@ +import {library} from '@fortawesome/fontawesome-svg-core'; +import {faPaperPlane, faRobot, faUserRobotXmarks} from '@fortawesome/pro-regular-svg-icons'; +import {elementFactory, hoistCmp, uses, XH} from '@xh/hoist/core'; +import {appBar, appBarSeparator} from '@xh/hoist/desktop/cmp/appbar'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {Icon} from '@xh/hoist/icon'; +import {AppModel} from './AppModel'; +import '../../core/Toolbox.scss'; +import {div, fragment} from '@xh/hoist/cmp/layout'; +import {jsonInput, select, switchInput} from '@xh/hoist/desktop/cmp/input'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import ReactMarkdown from 'react-markdown'; +import {popover} from '@xh/hoist/kit/blueprint'; +import {chatPanel} from './cmp/ChatPanel'; + +library.add(faPaperPlane, faRobot, faUserRobotXmarks); + +export const AppComponent = hoistCmp({ + displayName: 'App', + model: uses(AppModel), + + render() { + return panel({ + tbar: appBar({ + icon: Icon.icon({iconName: 'robot', size: '2x'}), + appMenuButtonProps: {hideLogoutItem: false}, + rightItems: [appBarControls()] + }), + items: chatPanel() + }); + } +}); + +const appBarControls = hoistCmp.factory({ + render({model}) { + const popSize = {width: '70vw', minWidth: '800px', height: '80vh'}; + return fragment( + popover({ + target: button({ + text: 'Functions', + icon: Icon.func(), + outlined: true + }), + content: panel({ + title: 'Provided Function Library', + icon: Icon.func(), + compactHeader: true, + className: 'xh-popup--framed', + ...popSize, + item: jsonInput({ + value: JSON.stringify(XH.chatGptService.functions, null, 2), + readonly: true, + width: '100%', + height: '100%' + }) + }) + }), + appBarSeparator(), + popover({ + target: button({ + text: 'System Message', + icon: Icon.gear(), + outlined: true + }), + content: panel({ + title: 'Initial System Message', + icon: Icon.gear(), + compactHeader: true, + className: 'xh-popup--framed', + item: div({ + style: {...popSize, padding: 10, overflow: 'auto'}, + item: reactMarkdown({ + item: XH.chatGptService.systemMessage?.content ?? 'None found' + }) + }) + }) + }), + switchInput({ + value: XH.chatGptService.sendSystemMessage, + onChange: v => (XH.chatGptService.sendSystemMessage = v) + }), + appBarSeparator(), + modelSelector() + ); + } +}); + +const modelSelector = hoistCmp.factory({ + render() { + return select({ + enableFilter: false, + width: 150, + value: XH.chatGptService.model, + options: XH.chatGptService.selectableModels, + onChange: v => (XH.chatGptService.model = v) + }); + } +}); + +const reactMarkdown = elementFactory(ReactMarkdown); diff --git a/client-app/src/examples/chat/AppModel.ts b/client-app/src/examples/chat/AppModel.ts new file mode 100644 index 000000000..1800beb5f --- /dev/null +++ b/client-app/src/examples/chat/AppModel.ts @@ -0,0 +1,24 @@ +import {HoistAppModel, XH} from '@xh/hoist/core'; +import {OauthService} from '../../core/svc/OauthService'; +import {ChatGptService} from '../../core/svc/ChatGptService'; +import {PortfolioService} from '../../core/svc/PortfolioService'; + +export class AppModel extends HoistAppModel { + static instance: AppModel; + + static override async preAuthAsync() { + await XH.installServicesAsync(OauthService); + } + + override async logoutAsync() { + await XH.oauthService.logoutAsync(); + } + + override get supportsVersionBar(): boolean { + return window.self === window.top; + } + + override async initAsync() { + await XH.installServicesAsync(ChatGptService, PortfolioService); + } +} diff --git a/client-app/src/examples/chat/cmp/Chat.scss b/client-app/src/examples/chat/cmp/Chat.scss new file mode 100644 index 000000000..22cf251f2 --- /dev/null +++ b/client-app/src/examples/chat/cmp/Chat.scss @@ -0,0 +1,93 @@ +.tb-msg-list { + flex: 1; + overflow-y: scroll; +} + +.tb-msg { + padding: var(--xh-pad-px); + border-bottom: 1px solid var(--xh-grid-border-color); + + &:nth-child(odd) { + background-color: var(--xh-grid-bg-odd); + } + + border-left: 4px solid transparent; + &--selected { + border-left-color: var(--xh-orange); + background-color: var(--xh-intent-primary-trans1) !important; + } + + &__avatar { + color: var(--xh-orange-muted); + width: 40px; + margin-right: var(--xh-pad-double-px); + + > * { + border-radius: 10px; + } + + img { + width: 40px; + height: 40px; + } + + .xh-icon { + width: 30px; + height: 30px; + padding: 4px; + border: 1px solid var(--xh-orange-muted); + } + } + + &__content { + &--func { + align-items: center; + margin: 4px; + border: var(--xh-border-solid); + border-radius: var(--xh-border-radius-px); + font-family: var(--xh-font-family-mono); + font-size: 0.8em; + + .xh-icon { + padding: var(--xh-pad-px); + color: var(--xh-orange-muted); + font-size: 1.5em; + border-right: var(--xh-border-solid); + } + + > div { + padding: 10px; + } + } + + pre { + padding: var(--xh-pad-px); + border: var(--xh-border-solid); + border-radius: var(--xh-border-radius-px); + font-family: var(--xh-font-family-mono); + font-size: 0.8em; + } + } +} + +.tb-prompt-input { + flex: none; + padding: 10px; + justify-content: center; + border-top: 2px solid var(--xh-border-color); + background-color: var(--xh-bg-alt); + + &__inner { + width: 80vw; + align-items: stretch; + + textarea { + border-radius: 8px; + } + } + + &__buttons { + margin-left: 10px; + justify-content: center; + } +} diff --git a/client-app/src/examples/chat/cmp/ChatModel.ts b/client-app/src/examples/chat/cmp/ChatModel.ts new file mode 100644 index 000000000..52ad3735b --- /dev/null +++ b/client-app/src/examples/chat/cmp/ChatModel.ts @@ -0,0 +1,158 @@ +import {HoistModel, managed, TaskObserver, XH} from '@xh/hoist/core'; +import {bindable, when} from '@xh/hoist/mobx'; +import {GridModel} from '@xh/hoist/cmp/grid'; +import {createObservableRef} from '@xh/hoist/utils/react'; +import {HoistInputModel} from '@xh/hoist/cmp/input'; +import {isEmpty, last} from 'lodash'; +import {wait} from '@xh/hoist/promise'; +import {actionCol} from '@xh/hoist/desktop/cmp/grid'; +import {Icon} from '@xh/hoist/icon'; +import {GptMessage} from '../../../core/svc/ChatGptService'; + +export class ChatModel extends HoistModel { + @bindable inputMsg: string; + + @bindable.ref selectedMsg: GptMessage; + + /** If the currently selected message is a GPT response, show the preceding user message. */ + get userPromptForSelectedMsg() { + const {selectedMsg} = this; + if (!selectedMsg || selectedMsg.role !== 'assistant') return null; + + // TODO - assumes user/assistant messages are always alternating + const msgIdx = XH.chatGptService.messages.indexOf(selectedMsg); + return XH.chatGptService.messages[msgIdx - 1]; + } + + @bindable showUserMessageHistory = false; + @managed userHistoryGridModel: GridModel; + + taskObserver = TaskObserver.trackLast({message: 'Generating...'}); + + inputRef = createObservableRef(); + get input(): HoistInputModel { + return this.inputRef?.current as HoistInputModel; + } + + scrollRef = createObservableRef(); + + constructor() { + super(); + + this.userHistoryGridModel = this.createUsersHistoryGridModel(); + + this.addReaction( + { + track: () => [XH.chatGptService.messages, this.scrollRef.current], + run: () => this.scrollMessages() + }, + { + track: () => XH.pageIsActive, + run: isActive => { + if (isActive) this.focusInput(); + } + }, + { + track: () => XH.chatGptService.userMessageHistory, + run: msgs => { + this.userHistoryGridModel.loadData(msgs.map(message => ({message}))); + }, + fireImmediately: true + } + ); + + when( + () => !!this.input, + () => this.focusInput() + ); + } + + //------------------ + // Component logic + //------------------ + async submitAsync() { + const {inputMsg, taskObserver} = this; + if (!inputMsg) return; + + try { + await XH.chatGptService.sendChatAsync(inputMsg).linkTo(taskObserver); + // await wait(1000).linkTo(taskObserver); + this.inputMsg = ''; + this.focusInput(); + } catch (e) { + XH.handleException(e, {alertType: 'toast'}); + } + } + + onInputKeyDown(e: KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + this.submitAsync(); + } else if (e.key === 'ArrowUp' && !this.inputMsg) { + const {userMessages} = XH.chatGptService; + if (!isEmpty(userMessages)) { + const lastMsg = last(userMessages).content; + this.inputMsg = lastMsg; + wait().then(() => { + this.input.inputEl.selectionStart = lastMsg.length; + this.input.inputEl.selectionEnd = lastMsg.length; + }); + } + } + } + + async clearAndReInitAsync() { + await XH.chatGptService.clearAndReInitAsync(); + XH.toast({message: 'Chat history cleared.', position: 'top'}); + this.focusInput(); + } + + //------------------ + // Implementation + //------------------ + focusInput() { + wait(300).then(() => { + this.input?.focus(); + }); + } + + scrollMessages() { + wait(500).then(() => { + this.scrollRef.current?.scrollIntoView({behavior: 'auto'}); + this.selectedMsg = last(XH.chatGptService.messages); + }); + } + + createUsersHistoryGridModel() { + return new GridModel({ + store: { + idSpec: XH.genId + }, + emptyText: 'No messages yet...', + hideHeaders: true, + stripeRows: true, + rowBorders: true, + columns: [ + {field: 'message', flex: 1}, + { + ...actionCol, + actions: [ + { + icon: Icon.x(), + intent: 'danger', + actionFn: ({record}) => { + XH.chatGptService.removeFromMessageHistory(record.data.message); + } + } + ] + } + ], + onCellClicked: ({data: record, column}) => { + if (column.getColId() === 'message') { + this.showUserMessageHistory = false; + this.inputMsg = record.data.message; + this.focusInput(); + } + } + }); + } +} diff --git a/client-app/src/examples/chat/cmp/ChatPanel.ts b/client-app/src/examples/chat/cmp/ChatPanel.ts new file mode 100644 index 000000000..1c5a33079 --- /dev/null +++ b/client-app/src/examples/chat/cmp/ChatPanel.ts @@ -0,0 +1,29 @@ +import {creates, hoistCmp, XH} from '@xh/hoist/core'; +import {ChatModel} from './ChatModel'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {messageList} from './impl/MessageList'; +import {promptInput} from './impl/PromptInput'; +import {placeholder} from '@xh/hoist/cmp/layout'; +import {Icon} from '@xh/hoist/icon'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import './Chat.scss'; + +export const chatPanel = hoistCmp.factory({ + displayName: 'ChatPanel', + model: creates(ChatModel), + + render({model}) { + return panel({ + items: XH.chatGptService.isInitialized + ? [messageList(), promptInput()] + : placeholder( + Icon.icon({iconName: 'user-robot-xmarks'}), + 'ChatGPTService not initialized.', + button({ + text: 'Retry', + onClick: () => XH.chatGptService.clearAndReInitAsync() + }) + ) + }); + } +}); diff --git a/client-app/src/examples/chat/cmp/impl/MessageList.ts b/client-app/src/examples/chat/cmp/impl/MessageList.ts new file mode 100644 index 000000000..8688b921d --- /dev/null +++ b/client-app/src/examples/chat/cmp/impl/MessageList.ts @@ -0,0 +1,111 @@ +import {elementFactory, hoistCmp, HoistProps, uses, XH} from '@xh/hoist/core'; +import {isEmpty} from 'lodash'; +import {box, div, hbox, img, placeholder} from '@xh/hoist/cmp/layout'; +import {Icon} from '@xh/hoist/icon'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {errorMessage} from '@xh/hoist/desktop/cmp/error'; +import {GptMessage} from '../../../../core/svc/ChatGptService'; +import ReactMarkdown from 'react-markdown'; +import {ChatModel} from '../ChatModel'; +import {structResponsePanel} from './StructResponsePanel'; + +export const messageList = hoistCmp.factory({ + displayName: 'MessageList', + model: uses(ChatModel), + + render({model}) { + const {messages} = XH.chatGptService, + item = isEmpty(messages) + ? placeholder(Icon.ellipsisHorizontal(), 'No messages yet...') + : hbox({ + flex: 1, + items: [ + div({ + className: 'tb-msg-list', + items: [ + ...messages.map(message => msgItem({message})), + // Supports scrolling to bottom of list. + box({ref: model.scrollRef}) + ] + }), + structResponsePanel({}) + ] + }); + + return panel({ + item, + loadingIndicator: model.taskObserver + }); + } +}); + +const msgItem = hoistCmp.factory({ + render({model, message}) { + const {role, content, function_call} = message, + isSelected = model.selectedMsg === message, + items = []; + + // System message is visible via popover from top toolbar. + if (role === 'system') return null; + + if (content) { + items.push(reactMarkdown(content)); + } + + if (function_call) { + const {name, arguments: args} = function_call; + items.push( + hbox({ + className: 'tb-msg__content--func', + items: [Icon.func(), div(`${name}(${args})`)] + }) + ); + } + + if (isEmpty(items)) { + items.push(errorMessage({error: 'No content returned - unexpected'})); + } + + return hbox({ + className: `tb-msg ${isSelected ? 'tb-msg--selected' : ''}`, + items: [ + avatar({role}), + div({ + className: 'tb-msg__content', + items + }) + ], + onClick: () => (model.selectedMsg = message) + }); + } +}); + +const avatar = hoistCmp.factory({ + render({role}) { + let item, + isIcon = true; + switch (role) { + case 'system': + item = Icon.gear(); + break; + case 'assistant': + item = Icon.icon({iconName: 'robot'}); + break; + case 'user': + item = img({src: XH.getUser().profilePicUrl, referrerPolicy: 'no-referrer'}); + isIcon = false; + break; + } + + return div({ + className: `tb-msg__avatar ${isIcon ? '' : 'tb-msg__avatar--icon'}`, + item + }); + } +}); + +const reactMarkdown = elementFactory(ReactMarkdown); + +interface MsgItemProps extends HoistProps { + message: GptMessage; +} diff --git a/client-app/src/examples/chat/cmp/impl/PromptInput.ts b/client-app/src/examples/chat/cmp/impl/PromptInput.ts new file mode 100644 index 000000000..280ce263a --- /dev/null +++ b/client-app/src/examples/chat/cmp/impl/PromptInput.ts @@ -0,0 +1,87 @@ +import {hoistCmp, uses, XH} from '@xh/hoist/core'; +import {hbox, vbox, vspacer} from '@xh/hoist/cmp/layout'; +import {textArea} from '@xh/hoist/desktop/cmp/input'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {Icon} from '@xh/hoist/icon'; +import {isEmpty} from 'lodash'; +import {popover} from '@xh/hoist/kit/blueprint'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {grid} from '@xh/hoist/cmp/grid'; +import {ChatModel} from '../ChatModel'; + +export const promptInput = hoistCmp.factory({ + model: uses(ChatModel), + render({model}) { + const {inputMsg, taskObserver, inputRef} = model; + + return hbox({ + className: 'tb-prompt-input', + item: hbox({ + className: 'tb-prompt-input__inner', + items: [ + textArea({ + placeholder: 'Enter a message...', + flex: 1, + bind: 'inputMsg', + commitOnChange: true, + ref: inputRef, + disabled: taskObserver.isPending, + onKeyDown: e => model.onInputKeyDown(e) + }), + vbox({ + className: 'tb-prompt-input__buttons', + items: [ + button({ + icon: Icon.icon({iconName: 'paper-plane'}), + intent: 'success', + outlined: true, + tooltip: 'Send message - or press [Enter]', + disabled: !inputMsg || taskObserver.isPending, + onClick: () => model.submitAsync() + }), + vspacer(5), + button({ + icon: Icon.reset(), + intent: 'danger', + tooltip: 'Restart conversation', + disabled: isEmpty(XH.chatGptService.messages), + onClick: () => model.clearAndReInitAsync() + }), + vspacer(5), + popover({ + isOpen: model.showUserMessageHistory, + onClose: () => (model.showUserMessageHistory = false), + target: button({ + icon: Icon.history(), + intent: 'primary', + onClick: () => (model.showUserMessageHistory = true) + }), + content: userMsgHistory() + }) + ] + }) + ] + }) + }); + } +}); + +const userMsgHistory = hoistCmp.factory({ + render({model}) { + return panel({ + title: 'Message History', + icon: Icon.history(), + compactHeader: true, + headerItems: [ + button({ + text: 'Clear History', + icon: Icon.reset(), + onClick: () => XH.chatGptService.clearUserMessageHistory() + }) + ], + width: 600, + height: 300, + item: grid({model: model.userHistoryGridModel}) + }); + } +}); diff --git a/client-app/src/examples/chat/cmp/impl/StructResponseModel.ts b/client-app/src/examples/chat/cmp/impl/StructResponseModel.ts new file mode 100644 index 000000000..08e25a2a2 --- /dev/null +++ b/client-app/src/examples/chat/cmp/impl/StructResponseModel.ts @@ -0,0 +1,142 @@ +import {HoistModel, lookup, PlainObject, XH} from '@xh/hoist/core'; +import {ChatModel} from '../ChatModel'; +import {GptMessage} from '../../../../core/svc/ChatGptService'; +import {GridModel} from '@xh/hoist/cmp/grid'; +import {bindable} from '@xh/hoist/mobx'; +import {forOwn, isEmpty, isNil, isNumber, orderBy, take} from 'lodash'; +import {numberRenderer} from '@xh/hoist/format'; +import {mktValCol, pnlCol} from '../../../../core/columns'; + +export class StructResponseModel extends HoistModel { + @lookup(ChatModel) chatModel: ChatModel; + + get selectedMsg(): GptMessage { + return this.chatModel?.selectedMsg; + } + + get shouldDisplay() { + return this.selectedMsg?.function_call != null; + } + + get title() { + return this.shouldDisplay + ? this.chatModel.userPromptForSelectedMsg?.content ?? + this.selectedMsg?.function_call?.name + : null; + } + + override onLinked() { + this.addReaction({ + track: () => this.selectedMsg, + run: () => this.onMsgChangeAsync(), + fireImmediately: true + }); + } + + // Set based on data extracted from selectedMsg + @bindable.ref gridModel: GridModel; + @bindable.ref data: PlainObject[]; + + async onMsgChangeAsync() { + const {selectedMsg} = this; + + XH.safeDestroy(this.gridModel); + this.gridModel = null; + + if (!selectedMsg) { + } else { + const data = await this.getDataAsync(selectedMsg), + gridModel = this.createGridModel(data, selectedMsg); + + gridModel?.loadData(data); + + XH.safeDestroy(this.gridModel); + this.data = data; + this.gridModel = gridModel; + } + } + + async getDataAsync(msg: GptMessage) { + const {function_call} = msg; + if (!function_call) return null; + + let data; + switch (function_call.name) { + case 'getPortfolioPositions': + data = await this.getPortfolioPositionsAsync(msg); + break; + default: + throw XH.exception(`Unsupported function call: ${function_call.name}`); + } + + const args = this.getArgs(msg); + console.log(args); + console.log(data); + if (args.maxRows && data.length > args.maxRows) { + console.log(`truncating to ${args.maxRows} rows`); + if (args.sortBy) { + const sortFieldAndDir = args.sortBy.split('|'); + data = orderBy(data, [sortFieldAndDir[0]], [sortFieldAndDir[1]]); + } + + data = take(data, args.maxRows); + } + + return data; + } + + async getPortfolioPositionsAsync(msg: GptMessage) { + const args = this.getArgs(msg), + {groupByDimensions} = args; + return XH.portfolioService.getPositionsAsync(groupByDimensions, false); + } + + createGridModel(data: PlainObject[], msg: GptMessage) { + if (isEmpty(data)) return null; + + try { + const args = this.getArgs(msg), + columns = [], + skippedKeys = ['children', 'id']; + + forOwn(data[0], (value, key) => { + if (!skippedKeys.includes(key)) { + const isNum = this.isNumberField(data, key), + colDef = this.cols[key] ?? {}; + + columns.push({ + field: {name: key, type: isNum ? 'number' : 'auto'}, + isTreeColumn: key === 'name', + renderer: isNum ? numberRenderer() : null, + ...colDef + }); + } + }); + + return new GridModel({ + autosizeOptions: {mode: 'managed'}, + store: {idSpec: XH.genId}, + treeMode: data[0].hasOwnProperty('children'), + sortBy: args.sortBy ?? 'name', + columns + }); + } catch (e) { + XH.handleException(e); + return null; + } + } + + // Rough heuristic - doesn't attempt to recurse into children, etc. + isNumberField(data: PlainObject[], key: string) { + return data.every(it => isNil(it[key]) || isNumber(it[key])); + } + + getArgs(msg: GptMessage): PlainObject { + return JSON.parse(msg.function_call.arguments); + } + + cols = { + pnl: pnlCol, + mktVal: mktValCol + }; +} diff --git a/client-app/src/examples/chat/cmp/impl/StructResponsePanel.ts b/client-app/src/examples/chat/cmp/impl/StructResponsePanel.ts new file mode 100644 index 000000000..2293496b9 --- /dev/null +++ b/client-app/src/examples/chat/cmp/impl/StructResponsePanel.ts @@ -0,0 +1,42 @@ +import {creates, hoistCmp} from '@xh/hoist/core'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {StructResponseModel} from './StructResponseModel'; +import {placeholder} from '@xh/hoist/cmp/layout'; +import {Icon} from '@xh/hoist/icon'; +import {grid} from '@xh/hoist/cmp/grid'; + +export const structResponsePanel = hoistCmp.factory({ + displayName: 'StructuredResponsePanel', + model: creates(StructResponseModel), + + render({model, ...rest}) { + const {title, shouldDisplay} = model; + return panel({ + title, + icon: title ? Icon.terminal() : null, + compactHeader: true, + item: shouldDisplay + ? dataComponent() + : placeholder( + Icon.grid(), + 'Select a GPT response with structured data attached to view the results.' + ), + modelConfig: { + defaultSize: 450, + side: 'right' + }, + ...rest + }); + } +}); + +const dataComponent = hoistCmp.factory({ + render({model}) { + const {gridModel} = model; + if (gridModel) { + return grid({model: gridModel, flex: 1, agOptions: {groupDefaultExpanded: 1}}); + } + + return placeholder('No structured data found.'); + } +}); diff --git a/client-app/src/examples/chat/docs/system-message.md b/client-app/src/examples/chat/docs/system-message.md new file mode 100644 index 000000000..bf301c9fd --- /dev/null +++ b/client-app/src/examples/chat/docs/system-message.md @@ -0,0 +1,43 @@ +You are a professional AI assistant embedded within a custom financial reporting dashboard application created by a +hedge fund with headquarters in the United States. Your role is to respond to user queries with either a function call +that the application can run OR a message asking the user to clarify or explaining why you are unable to help. + +### Objects returned and aggregated by the application API + +The `getPortfolioPositions` function returns a list of `Position` objects. A `Position` satisfies the following +interface: + +```typescript +interface Position { + id: string; + name: string; + pnl: number; + mktVal: number; + children: Position[]; +} +``` + +A `Position` represents an aggregate of one or more `RawPosition` objects. A `RawPosition` models a single investment +within a portfolio. It satisfies the following interface: + +```typescript +interface RawPosition { + // Dimension - the stock ticker or identifier of the position's instrument, an equity stock or other security - e.g. ['AAPL', 'GOOG', 'MSFT'] + symbol: string; + // Dimension - the industry sector of the instrument - e.g. ['Technology', 'Healthcare', 'Energy'] + sector: string; + // Dimension - the name of an investment fund - e.g. ['Winter Star Fund', 'Oak Mount Fund'] + fund: string; + // Dimension - the name of the trader or portfolio manager responsible for the investment - e.g. ['Susan Major', 'Fred Corn', 'HedgeSys'] + trader: string; + // Measure - the current value of the position, in USD. + mktVal: number; + // Measure - the current profit and loss of the position, in USD. + pnl: number; +} +``` + +The `getPortfolioPositions` function takes a list of `groupByDimensions` when aggregating results, representing +the field names of `RawPosition` dimensions within the portfolio data. + +Introduce yourself to the user and ask them how you can help them. diff --git a/client-app/yarn.lock b/client-app/yarn.lock index ff17ef673..cfd9ebe1e 100644 --- a/client-app/yarn.lock +++ b/client-app/yarn.lock @@ -6457,7 +6457,7 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -react-markdown@^8.0.7: +react-markdown@^8.0.7, react-markdown@~8.0.7: version "8.0.7" resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-8.0.7.tgz#c8dbd1b9ba5f1c5e7e5f2a44de465a3caafdf89b" integrity sha512-bvWbzG4MtOU62XqBx3Xx+zB2raaFFsq4mYiAzfjXJMEz2sixgeAfraA3tvzULF02ZdOMUOKTBFFaZJDDrq+BJQ== diff --git a/grails-app/controllers/io/xh/toolbox/admin/ChatGptController.groovy b/grails-app/controllers/io/xh/toolbox/admin/ChatGptController.groovy new file mode 100644 index 000000000..a07aa7caa --- /dev/null +++ b/grails-app/controllers/io/xh/toolbox/admin/ChatGptController.groovy @@ -0,0 +1,23 @@ +package io.xh.toolbox.admin + + +import io.xh.hoist.security.Access +import io.xh.toolbox.BaseController + +@Access('CHAT_GPT_USER') +class ChatGptController extends BaseController { + + def configService + + def config() { + // TODO - would like to get initialSystemMessage from config, but config editor doesn't persist newlines + // which then breaks markdown formatting/detection. Prob. not important to GPT but doesn't look as + // good in the UI. Currently copied into ChatGptService.ts. +// renderJSON([ +// *:configService.getMap('chatGptConfig'), +// initialSystemMessage: configService.getString('chatGptInitialSystemMessage') +// ]) + renderJSON(configService.getMap('chatGptConfig')) + } + +}