diff --git a/.babelrc b/.babelrc deleted file mode 100644 index e21b3f2f..00000000 --- a/.babelrc +++ /dev/null @@ -1,18 +0,0 @@ -{ - "presets": [ - [ - "@babel/preset-env", - { - "targets": { "node": "10" } - } - ], - "@babel/preset-react", - "@babel/preset-typescript" - ], - "plugins": [ - "@babel/plugin-transform-modules-commonjs", - ["@babel/plugin-transform-runtime", { "regenerator": true }], - "@babel/plugin-proposal-class-properties", - "@babel/plugin-proposal-object-rest-spread" - ] -} \ No newline at end of file diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 00000000..c8dfef41 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,43 @@ +name: Backport +on: + pull_request_target: + types: + - closed + - labeled + +jobs: + backport: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + name: Backport + # Only react to merged PRs for security reasons. + # See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target. + if: > + github.event.pull_request.merged + && ( + github.event.action == 'closed' + || ( + github.event.action == 'labeled' + && contains(github.event.label.name, 'backport') + ) + ) + steps: + - name: GitHub App token + id: github_app_token + uses: tibdex/github-app-token@v1.5.0 + with: + app_id: ${{ secrets.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + # opensearch-trigger-bot installation ID + installation_id: 22958780 + + - name: Backport + uses: VachaShah/backport@v2.2.0 + with: + github_token: ${{ steps.github_app_token.outputs.token }} + head_template: backport/backport-<%= number %>-to-<%= base %> + files_to_skip: "CHANGELOG.md" + labels_template: "<%= JSON.stringify([...labels, 'autocut']) %>" + failure_labels: "failed backport" diff --git a/.github/workflows/changelog_verifier.yml b/.github/workflows/changelog_verifier.yml new file mode 100644 index 00000000..2cf959c0 --- /dev/null +++ b/.github/workflows/changelog_verifier.yml @@ -0,0 +1,19 @@ +name: "Changelog Verifier" +on: + pull_request: + branches: [ '**' ] + types: [opened, edited, review_requested, synchronize, reopened, ready_for_review, labeled, unlabeled] + +jobs: + # Enforces the update of a changelog file on every pull request + verify-changelog: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ github.event.pull_request.head.sha }} + + - uses: dangoslen/changelog-enforcer@v3 + with: + skipLabels: "autocut, Skip-Changelog" diff --git a/.github/workflows/links_checker.yml b/.github/workflows/links_checker.yml new file mode 100644 index 00000000..06cfbf33 --- /dev/null +++ b/.github/workflows/links_checker.yml @@ -0,0 +1,37 @@ +# Copyright OpenSearch Contributors +# SPDX-License-Identifier: Apache-2.0 + +name: Link Checker + +on: + push: + branches: [ "**" ] + pull_request: + branches: [ "**" ] + +jobs: + linkchecker: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Checkout OpenSearch-Dashboards + uses: actions/checkout@v2 + with: + repository: opensearch-project/OpenSearch-Dashboards + ref: main + path: OpenSearch-Dashboards + - name: Load Excludes + run: | + LYCHEE_EXCLUDE=$(sed -e :a -e 'N;s/\n/ --exclude /;ta' OpenSearch-Dashboards/.lycheeexclude) + echo "LYCHEE_EXCLUDE=$LYCHEE_EXCLUDE" >> $GITHUB_ENV + - name: Lychee Link Checker + id: lychee + uses: lycheeverse/lychee-action@v1.0.9 + with: + args: --accept=200,403,429 --exclude ${{ env.LYCHEE_EXCLUDE }} --exclude-mail "**/*.html" "**/*.md" "**/*.txt" "**/*.json" "**/*.js" "**/*.ts" "**/*.tsx" + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: Fail if there were link errors + run: exit ${{ steps.lychee.outputs.exit_code }} \ No newline at end of file diff --git a/.github/workflows/unit_test_workflow.yml b/.github/workflows/unit_test_workflow.yml new file mode 100644 index 00000000..8c301874 --- /dev/null +++ b/.github/workflows/unit_test_workflow.yml @@ -0,0 +1,98 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: Build and test + +# trigger on every commit push and PR for all branches except pushes for backport branches +on: + pull_request: + branches: ["**"] + push: + branches: ["**"] +env: + OPENSEARCH_DASHBOARDS_VERSION: '2.x' + NODE_OPTIONS: "--max-old-space-size=6144 --dns-result-order=ipv4first" + +jobs: + Get-CI-Image-Tag: + uses: opensearch-project/opensearch-build/.github/workflows/get-ci-image-tag.yml@main + with: + product: opensearch-dashboards + + tests-linux: + needs: Get-CI-Image-Tag + name: Run unit tests + runs-on: ubuntu-latest + container: + # using the same image which is used by opensearch-build team to build the OpenSearch Distribution + # this image tag is subject to change as more dependencies and updates will arrive over time + image: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-version-linux }} + # need to switch to root so that github actions can install runner binary on container without permission issues. + options: --user root + + steps: + # Enable longer filenames for windows + - name: Checkout OpenSearch-Dashboards + uses: actions/checkout@v2 + with: + repository: opensearch-project/OpenSearch-Dashboards + ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} + path: OpenSearch-Dashboards + - name: Checkout dashboards-assistant plugin + uses: actions/checkout@v2 + with: + path: OpenSearch-Dashboards/plugins/dashboards-assistant + - name: Bootstrap / Run tests + run: | + chown -R 1000:1000 `pwd` + cd ./OpenSearch-Dashboards/ + su `id -un 1000` -c "source $NVM_DIR/nvm.sh && nvm use && node -v && yarn -v && + cd ./plugins/dashboards-assistant && + whoami && yarn osd bootstrap && yarn run test:jest --coverage" + + - name: Uploads coverage + uses: codecov/codecov-action@v1 + + tests-windows-macos: + name: Run unit tests + strategy: + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + # Enable longer filenames for windows + - name: Enable longer filenames + if: ${{ matrix.os == 'windows-latest' }} + run: git config --system core.longpaths true + - name: Checkout OpenSearch-Dashboards + uses: actions/checkout@v2 + with: + repository: opensearch-project/OpenSearch-Dashboards + ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} + path: OpenSearch-Dashboards + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version-file: './OpenSearch-Dashboards/.nvmrc' + registry-url: 'https://registry.npmjs.org' + - name: Install Yarn + # Need to use bash to avoid having a windows/linux specific step + shell: bash + run: | + YARN_VERSION=$(node -p "require('./OpenSearch-Dashboards/package.json').engines.yarn") + echo "Installing yarn@$YARN_VERSION" + npm i -g yarn@$YARN_VERSION + - run: node -v + - run: yarn -v + - name: Checkout dashboards-assistant plugin + uses: actions/checkout@v2 + with: + path: OpenSearch-Dashboards/plugins/dashboards-assistant + - name: Bootstrap plugin/dashboards-assistant + run: | + cd OpenSearch-Dashboards/plugins/dashboards-assistant + yarn osd bootstrap + - name: Run tests + run: | + cd OpenSearch-Dashboards/plugins/dashboards-assistant + yarn run test:jest --coverage diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..055c2ab4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# CHANGELOG + +Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) + +### 📈 Features/Enhancements + +- Add support for registerMessageParser ([#5](https://github.com/opensearch-project/dashboards-assistant/pull/5)) +- Change implementation of basic_input_output to built-in parser ([#10](https://github.com/opensearch-project/dashboards-assistant/pull/10)) +- Add interactions into ChatState and pass specific interaction into message_bubble ([#12](https://github.com/opensearch-project/dashboards-assistant/pull/12)) \ No newline at end of file diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 00000000..3805f7cf --- /dev/null +++ b/babel.config.js @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// babelrc doesn't respect NODE_PATH anymore but using require does. +// Alternative to install them locally in node_modules +module.exports = function (api) { + // ensure env is test so that this config won't impact build or dev server + if (api.env('test')) { + return { + presets: [ + require('@babel/preset-env'), + require('@babel/preset-react'), + require('@babel/preset-typescript'), + ], + plugins: [ + [require('@babel/plugin-transform-runtime'), { regenerator: true }], + require('@babel/plugin-transform-class-properties'), + require('@babel/plugin-transform-object-rest-spread'), + [require('@babel/plugin-transform-modules-commonjs'), { allowTopLevelThis: true }], + ], + }; + } + return {}; +}; diff --git a/common/constants/llm.ts b/common/constants/llm.ts index 53029273..8a68dba2 100644 --- a/common/constants/llm.ts +++ b/common/constants/llm.ts @@ -18,6 +18,7 @@ export const ASSISTANT_API = { FEEDBACK: `${API_BASE}/feedback`, ABORT_AGENT_EXECUTION: `${API_BASE}/abort`, REGENERATE: `${API_BASE}/regenerate`, + TRACE: `${API_BASE}/trace`, } as const; export const LLM_INDEX = { diff --git a/common/types/chat_saved_object_attributes.ts b/common/types/chat_saved_object_attributes.ts index fb3e0d2a..118ca75f 100644 --- a/common/types/chat_saved_object_attributes.ts +++ b/common/types/chat_saved_object_attributes.ts @@ -6,12 +6,23 @@ export const CHAT_SAVED_OBJECT = 'assistant-chat'; export const SAVED_OBJECT_VERSION = 1; +export interface Interaction { + input: string; + response: string; + conversation_id: string; + interaction_id: string; + create_time: string; + additional_info?: Record; + parent_interaction_id?: string; +} + export interface ISession { title: string; - version: number; + version?: number; createdTimeMs: number; updatedTimeMs: number; messages: IMessage[]; + interactions: Interaction[]; } export interface ISessionFindResponse { diff --git a/common/utils/llm_chat/traces.ts b/common/utils/llm_chat/traces.ts index 8ac0d314..73ee3088 100644 --- a/common/utils/llm_chat/traces.ts +++ b/common/utils/llm_chat/traces.ts @@ -7,6 +7,16 @@ import { Run } from 'langchain/callbacks'; import { AgentRun } from 'langchain/dist/callbacks/handlers/tracer'; import _ from 'lodash'; +export interface AgentFrameworkTrace { + interactionId: string; + parentInteractionId: string; + createTime: string; + input: string; + output: string; + origin: string; + traceNumber: number; +} + export interface LangchainTrace { id: Run['id']; parentRunId?: Run['parent_run_id']; diff --git a/package.json b/package.json index 4681ec31..d79d30a9 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "osd": "node ../../scripts/osd", "build": "yarn plugin-helpers build", - "test": "../../node_modules/.bin/jest --config ./test/jest.config.js", + "test:jest": "../../node_modules/.bin/jest --config ./test/jest.config.js", "plugin-helpers": "node ../../scripts/plugin_helpers", "prepare": "husky install", "lint:es": "node ../../scripts/eslint", @@ -19,6 +19,7 @@ }, "dependencies": { "autosize": "^6.0.1", + "csv-parser": "^3.0.0", "dompurify": "^2.4.1", "jsdom": "^22.1.0", "langchain": "^0.0.164", diff --git a/public/chat_flyout.tsx b/public/chat_flyout.tsx index 04e9c34b..42d6e32c 100644 --- a/public/chat_flyout.tsx +++ b/public/chat_flyout.tsx @@ -10,7 +10,8 @@ import { useChatContext } from './contexts/chat_context'; import { ChatPage } from './tabs/chat/chat_page'; import { ChatWindowHeader } from './tabs/chat_window_header'; import { ChatHistoryPage } from './tabs/history/chat_history_page'; -import { LangchainTracesFlyoutBody } from './components/langchain_traces_flyout_body'; +import { AgentFrameworkTracesFlyoutBody } from './components/agent_framework_traces_flyout_body'; +import { TAB_ID } from './utils/constants'; let chatHistoryPageLoaded = false; @@ -31,15 +32,15 @@ export const ChatFlyout: React.FC = (props) => { if (!props.overrideComponent) { switch (chatContext.selectedTabId) { - case 'chat': + case TAB_ID.CHAT: chatPageVisible = true; break; - case 'history': + case TAB_ID.HISTORY: chatHistoryPageVisible = true; break; - case 'trace': + case TAB_ID.TRACE: chatTraceVisible = true; break; @@ -134,7 +135,7 @@ export const ChatFlyout: React.FC = (props) => { className={cs({ 'llm-chat-hidden': !chatHistoryPageVisible })} /> )} - {chatTraceVisible && chatContext.traceId && } + {chatTraceVisible && chatContext.traceId && } diff --git a/public/chat_header_button.tsx b/public/chat_header_button.tsx index 74d48cc3..78d09eb5 100644 --- a/public/chat_header_button.tsx +++ b/public/chat_header_button.tsx @@ -15,6 +15,7 @@ import { ChatStateProvider } from './hooks/use_chat_state'; import './index.scss'; import chatIcon from './assets/chat.svg'; import { ActionExecutor, AssistantActions, ContentRenderer, UserAccount, TabId } from './types'; +import { TAB_ID } from './utils/constants'; interface HeaderChatButtonProps { application: ApplicationStart; @@ -33,7 +34,7 @@ export const HeaderChatButton: React.FC = (props) => { const [title, setTitle] = useState(); const [flyoutVisible, setFlyoutVisible] = useState(false); const [flyoutComponent, setFlyoutComponent] = useState(null); - const [selectedTabId, setSelectedTabId] = useState('chat'); + const [selectedTabId, setSelectedTabId] = useState(TAB_ID.CHAT); const [preSelectedTabId, setPreSelectedTabId] = useState(undefined); const [traceId, setTraceId] = useState(undefined); const [chatSize, setChatSize] = useState('dock-right'); @@ -41,6 +42,9 @@ export const HeaderChatButton: React.FC = (props) => { const [inputFocus, setInputFocus] = useState(false); const flyoutFullScreen = chatSize === 'fullscreen'; const inputRef = useRef(null); + const [rootAgentId, setRootAgentId] = useState( + new URL(window.location.href).searchParams.get('agent_id') || '' + ); if (!flyoutLoaded && flyoutVisible) flyoutLoaded = true; @@ -76,6 +80,7 @@ export const HeaderChatButton: React.FC = (props) => { setTitle, traceId, setTraceId, + rootAgentId, }), [ appId, diff --git a/public/components/agent_framework_traces.tsx b/public/components/agent_framework_traces.tsx new file mode 100644 index 00000000..903b494a --- /dev/null +++ b/public/components/agent_framework_traces.tsx @@ -0,0 +1,92 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiAccordion, + EuiCodeBlock, + EuiEmptyPrompt, + EuiLoadingContent, + EuiSpacer, + EuiText, + EuiMarkdownFormat, + EuiHorizontalRule, +} from '@elastic/eui'; +import React from 'react'; +import { useFetchAgentFrameworkTraces } from '../hooks/use_fetch_agentframework_traces'; + +interface AgentFrameworkTracesProps { + traceId: string; +} + +export const AgentFrameworkTraces: React.FC = (props) => { + const { data: traces, loading, error } = useFetchAgentFrameworkTraces(props.traceId); + + if (loading) { + return ( + <> + Loading... + + + ); + } + if (error) { + return ( + Error loading details} + body={error.toString()} + /> + ); + } + if (!traces?.length) { + return Data not available.; + } + + const question = traces[traces.length - 1].input; + const result = traces[traces.length - 1].output; + const questionAndResult = `# How was this generated +#### Question +${question} +#### Result +${result} +`; + + return ( + <> + {questionAndResult} + + + + +

Response

+
+ {traces + // if origin exists, it indicates that the trace was generated by a tool, we only show the non-empty traces of tools + .filter((trace) => trace.origin && (trace.input || trace.output)) + .map((trace, i) => { + const stepContent = `Step ${i + 1}`; + return ( +
+ + + {trace.input && ( + + Input: {trace.input} + + )} + {trace.output && ( + + Output: {trace.output} + + )} + + +
+ ); + })} + + ); +}; diff --git a/public/components/agent_framework_traces_flyout_body.tsx b/public/components/agent_framework_traces_flyout_body.tsx new file mode 100644 index 00000000..3aefdbfd --- /dev/null +++ b/public/components/agent_framework_traces_flyout_body.tsx @@ -0,0 +1,73 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButtonEmpty, + EuiFlyoutBody, + EuiPage, + EuiPageBody, + EuiPageContentBody, + EuiPageHeader, + EuiButtonIcon, + EuiPageHeaderSection, +} from '@elastic/eui'; +import React from 'react'; +import { useChatContext } from '../contexts/chat_context'; +import { AgentFrameworkTraces } from './agent_framework_traces'; +import { TAB_ID } from '../utils/constants'; + +export const AgentFrameworkTracesFlyoutBody: React.FC = () => { + const chatContext = useChatContext(); + const traceId = chatContext.traceId; + if (!traceId) { + return null; + } + + // docked right or fullscreen with history open + const showBack = !chatContext.flyoutFullScreen || chatContext.preSelectedTabId === TAB_ID.HISTORY; + + return ( + + + + + + {showBack && ( + { + chatContext.setSelectedTabId( + chatContext.flyoutFullScreen ? TAB_ID.HISTORY : TAB_ID.CHAT + ); + }} + iconType="arrowLeft" + > + Back + + )} + + + {!showBack && ( + { + chatContext.setSelectedTabId(TAB_ID.CHAT); + }} + /> + )} + + + + + + + + + ); +}; diff --git a/public/contexts/chat_context.tsx b/public/contexts/chat_context.tsx index c0807be3..24d3a0f7 100644 --- a/public/contexts/chat_context.tsx +++ b/public/contexts/chat_context.tsx @@ -25,6 +25,7 @@ export interface IChatContext { setTitle: React.Dispatch>; traceId?: string; setTraceId: React.Dispatch>; + rootAgentId?: string; } export const ChatContext = React.createContext(null); diff --git a/public/contexts/index.ts b/public/contexts/index.ts new file mode 100644 index 00000000..469c4a1c --- /dev/null +++ b/public/contexts/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { useChatContext } from './chat_context'; +export { useCore } from './core_context'; diff --git a/public/hooks/index.ts b/public/hooks/index.ts new file mode 100644 index 00000000..ebd4d336 --- /dev/null +++ b/public/hooks/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { useSaveChat } from './use_save_chat'; diff --git a/public/hooks/use_chat_actions.tsx b/public/hooks/use_chat_actions.tsx index 864bf757..e1975b46 100644 --- a/public/hooks/use_chat_actions.tsx +++ b/public/hooks/use_chat_actions.tsx @@ -3,8 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TAB_ID } from '../utils/constants'; import { ASSISTANT_API } from '../../common/constants/llm'; -import { IMessage, ISuggestedAction } from '../../common/types/chat_saved_object_attributes'; +import { + IMessage, + ISuggestedAction, + Interaction, +} from '../../common/types/chat_saved_object_attributes'; import { useChatContext } from '../contexts/chat_context'; import { useCore } from '../contexts/core_context'; import { AssistantActions } from '../types'; @@ -14,6 +19,7 @@ interface SendResponse { sessionId: string; title: string; messages: IMessage[]; + interactions: Interaction[]; } interface SetParagraphResponse { @@ -36,6 +42,7 @@ export const useChatActions = (): AssistantActions => { // do not send abort signal to http client to allow LLM call run in background body: JSON.stringify({ sessionId: chatContext.sessionId, + rootAgentId: chatContext.rootAgentId, ...(!chatContext.sessionId && { messages: chatState.messages }), // include all previous messages for new chats input, }), @@ -46,7 +53,7 @@ export const useChatActions = (): AssistantActions => { !chatContext.sessionId && response.sessionId && core.services.sessions.options?.page === 1 && - chatContext.selectedTabId === 'history' + chatContext.selectedTabId === TAB_ID.HISTORY ) { core.services.sessions.reload(); } @@ -55,7 +62,13 @@ export const useChatActions = (): AssistantActions => { if (!chatContext.title) { chatContext.setTitle(response.title); } - chatStateDispatch({ type: 'receive', payload: response.messages }); + chatStateDispatch({ + type: 'receive', + payload: { + messages: response.messages, + interactions: response.interactions, + }, + }); } catch (error) { if (abortController.signal.aborted) return; chatStateDispatch({ type: 'error', payload: error }); @@ -69,7 +82,7 @@ export const useChatActions = (): AssistantActions => { chatContext.setTitle(title); // Chat page will always visible in fullscreen mode, we don't need to change the tab anymore if (!chatContext.flyoutFullScreen) { - chatContext.setSelectedTabId('chat'); + chatContext.setSelectedTabId(TAB_ID.CHAT); } chatContext.setFlyoutComponent(null); if (!sessionId) { @@ -78,13 +91,19 @@ export const useChatActions = (): AssistantActions => { } const session = await core.services.sessionLoad.load(sessionId); if (session) { - chatStateDispatch({ type: 'receive', payload: session.messages }); + chatStateDispatch({ + type: 'receive', + payload: { + messages: session.messages, + interactions: session.interactions, + }, + }); } }; const openChatUI = () => { chatContext.setFlyoutVisible(true); - chatContext.setSelectedTabId('chat'); + chatContext.setSelectedTabId(TAB_ID.CHAT); }; const executeAction = async (suggestedAction: ISuggestedAction, message: IMessage) => { @@ -155,7 +174,13 @@ export const useChatActions = (): AssistantActions => { if (abortController.signal.aborted) { return; } - chatStateDispatch({ type: 'receive', payload: response.messages }); + chatStateDispatch({ + type: 'receive', + payload: { + messages: response.messages, + interactions: response.interactions, + }, + }); } catch (error) { if (abortController.signal.aborted) { return; diff --git a/public/hooks/use_chat_state.tsx b/public/hooks/use_chat_state.tsx index 9f3a66f5..13bb8542 100644 --- a/public/hooks/use_chat_state.tsx +++ b/public/hooks/use_chat_state.tsx @@ -5,10 +5,11 @@ import { produce } from 'immer'; import React, { useContext, useMemo, useReducer } from 'react'; -import { IMessage } from '../../common/types/chat_saved_object_attributes'; +import { IMessage, Interaction } from '../../common/types/chat_saved_object_attributes'; interface ChatState { messages: IMessage[]; + interactions: Interaction[]; llmResponding: boolean; llmError?: Error; } @@ -18,7 +19,13 @@ type ChatStateAction = | { type: 'abort' } | { type: 'reset' } | { type: 'send'; payload: IMessage } - | { type: 'receive'; payload: ChatState['messages'] } + | { + type: 'receive'; + payload: { + messages: ChatState['messages']; + interactions: ChatState['interactions']; + }; + } | { type: 'error'; payload: NonNullable | { body: NonNullable }; @@ -31,6 +38,7 @@ interface IChatStateContext { const ChatStateContext = React.createContext(null); const initialState: ChatState = { + interactions: [], messages: [], llmResponding: false, }; @@ -48,7 +56,8 @@ const chatStateReducer: React.Reducer = (state, acti break; case 'receive': - draft.messages = action.payload; + draft.messages = action.payload.messages; + draft.interactions = action.payload.interactions; draft.llmResponding = false; draft.llmError = undefined; break; diff --git a/public/hooks/use_fetch_agentframework_traces.ts b/public/hooks/use_fetch_agentframework_traces.ts new file mode 100644 index 00000000..443e0960 --- /dev/null +++ b/public/hooks/use_fetch_agentframework_traces.ts @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useReducer } from 'react'; +import { ASSISTANT_API } from '../../common/constants/llm'; +import { AgentFrameworkTrace } from '../../common/utils/llm_chat/traces'; +import { useCore } from '../contexts/core_context'; +import { GenericReducer, genericReducer } from './fetch_reducer'; + +export const useFetchAgentFrameworkTraces = (traceId: string) => { + const core = useCore(); + const reducer: GenericReducer = genericReducer; + const [state, dispatch] = useReducer(reducer, { loading: false }); + + useEffect(() => { + const abortController = new AbortController(); + dispatch({ type: 'request' }); + if (!traceId) { + dispatch({ type: 'success', payload: undefined }); + return; + } + + core.services.http + .get(`${ASSISTANT_API.TRACE}/${traceId}`) + .then((payload) => + dispatch({ + type: 'success', + payload, + }) + ) + .catch((error) => dispatch({ type: 'failure', error })); + + return () => abortController.abort(); + }, [traceId]); + + return { ...state }; +}; diff --git a/public/hooks/use_sessions.ts b/public/hooks/use_sessions.ts index 1f0789bd..8c863683 100644 --- a/public/hooks/use_sessions.ts +++ b/public/hooks/use_sessions.ts @@ -42,9 +42,9 @@ export const usePatchSession = () => { dispatch({ type: 'request', abortController }); return core.services.http .put(`${ASSISTANT_API.SESSION}/${sessionId}`, { - query: { + body: JSON.stringify({ title, - }, + }), signal: abortController.signal, }) .then((payload) => dispatch({ type: 'success', payload })) diff --git a/public/tabs/chat/chat_page.tsx b/public/tabs/chat/chat_page.tsx index 6ca8893f..025ed2b1 100644 --- a/public/tabs/chat/chat_page.tsx +++ b/public/tabs/chat/chat_page.tsx @@ -6,11 +6,11 @@ import { EuiFlyoutBody, EuiFlyoutFooter, EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useState } from 'react'; import cs from 'classnames'; +import { useObservable } from 'react-use'; import { useChatContext } from '../../contexts/chat_context'; import { useChatState } from '../../hooks/use_chat_state'; import { ChatPageContent } from './chat_page_content'; import { ChatInputControls } from './controls/chat_input_controls'; -import { useObservable } from 'react-use'; import { useCore } from '../../contexts/core_context'; interface ChatPageProps { @@ -31,7 +31,13 @@ export const ChatPage: React.FC = (props) => { } const session = await core.services.sessionLoad.load(chatContext.sessionId); if (session) { - chatStateDispatch({ type: 'receive', payload: session.messages }); + chatStateDispatch({ + type: 'receive', + payload: { + messages: session.messages, + interactions: session.interactions, + }, + }); } }, [chatContext.sessionId, chatStateDispatch]); diff --git a/public/tabs/chat/chat_page_content.tsx b/public/tabs/chat/chat_page_content.tsx index 16ebc5b7..359318db 100644 --- a/public/tabs/chat/chat_page_content.tsx +++ b/public/tabs/chat/chat_page_content.tsx @@ -14,7 +14,11 @@ import { EuiText, } from '@elastic/eui'; import React, { useLayoutEffect, useRef } from 'react'; -import { IMessage, ISuggestedAction } from '../../../common/types/chat_saved_object_attributes'; +import { + IMessage, + ISuggestedAction, + Interaction, +} from '../../../common/types/chat_saved_object_attributes'; import { TermsAndConditions } from '../../components/terms_and_conditions'; import { useChatContext } from '../../contexts/chat_context'; import { useChatState } from '../../hooks/use_chat_state'; @@ -120,6 +124,13 @@ export const ChatPageContent: React.FC = React.memo((props // Only show suggestion on llm outputs after last user input const showSuggestions = i > lastInputIndex; + let interaction: Interaction | undefined; + if (message.type === 'output' && message.traceId) { + interaction = chatState.interactions.find( + (item) => item.interaction_id === message.traceId + ); + } + return ( @@ -129,6 +140,7 @@ export const ChatPageContent: React.FC = React.memo((props showRegenerate={isLatestOutput} shouldActionBarVisibleOnHover={!isLatestOutput} onRegenerate={chatActions.regenerate} + interaction={interaction} > {/* */} diff --git a/public/tabs/chat/messages/message_bubble.tsx b/public/tabs/chat/messages/message_bubble.tsx index 451e36af..096e0f60 100644 --- a/public/tabs/chat/messages/message_bubble.tsx +++ b/public/tabs/chat/messages/message_bubble.tsx @@ -19,7 +19,11 @@ import React, { useCallback } from 'react'; import { IconType } from '@elastic/eui/src/components/icon/icon'; import cx from 'classnames'; import chatIcon from '../../../assets/chat.svg'; -import { IMessage, IOutput } from '../../../../common/types/chat_saved_object_attributes'; +import { + IMessage, + IOutput, + Interaction, +} from '../../../../common/types/chat_saved_object_attributes'; import { useFeedback } from '../../../hooks/use_feed_back'; type MessageBubbleProps = { @@ -30,6 +34,7 @@ type MessageBubbleProps = { } & ( | { message: IMessage; + interaction?: Interaction; } | { loading: boolean; @@ -183,6 +188,7 @@ export const MessageBubble: React.FC = React.memo((props) => {feedbackResult !== false ? ( feedbackOutput(true, feedbackResult)} @@ -192,6 +198,7 @@ export const MessageBubble: React.FC = React.memo((props) => {feedbackResult !== true ? ( feedbackOutput(false, feedbackResult)} diff --git a/public/tabs/chat/messages/message_footer.tsx b/public/tabs/chat/messages/message_footer.tsx index aa00fa89..8fcdd24b 100644 --- a/public/tabs/chat/messages/message_footer.tsx +++ b/public/tabs/chat/messages/message_footer.tsx @@ -7,9 +7,9 @@ import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@e import React from 'react'; import { IMessage } from '../../../../common/types/chat_saved_object_attributes'; import { FeedbackModal } from '../../../components/feedback_modal'; -import { LangchainTracesFlyoutBody } from '../../../components/langchain_traces_flyout_body'; import { useChatContext } from '../../../contexts/chat_context'; import { useCore } from '../../../contexts/core_context'; +import { AgentFrameworkTracesFlyoutBody } from '../../../components/agent_framework_traces_flyout_body'; interface MessageFooterProps { message: IMessage; @@ -31,12 +31,7 @@ export const MessageFooter: React.FC = React.memo((props) => size="xs" flush="left" onClick={() => { - chatContext.setFlyoutComponent( - chatContext.setFlyoutComponent(null)} - traceId={traceId} - /> - ); + chatContext.setFlyoutComponent(); }} > How was this generated? diff --git a/public/tabs/chat_window_header.tsx b/public/tabs/chat_window_header.tsx index 8ec18cdc..82cc32b5 100644 --- a/public/tabs/chat_window_header.tsx +++ b/public/tabs/chat_window_header.tsx @@ -22,6 +22,7 @@ import { useCore } from '../contexts/core_context'; import { useChatState } from '../hooks/use_chat_state'; import { useSaveChat } from '../hooks/use_save_chat'; import chatIcon from '../assets/chat.svg'; +import { TAB_ID } from '../utils/constants'; interface ChatWindowHeaderProps { flyoutFullScreen: boolean; toggleFlyoutFullScreen: () => void; @@ -157,10 +158,10 @@ export const ChatWindowHeader: React.FC = React.memo((pro chatContext.setFlyoutComponent(undefined); // Back to chat tab if history page already visible chatContext.setSelectedTabId( - chatContext.selectedTabId === 'history' ? 'chat' : 'history' + chatContext.selectedTabId === TAB_ID.HISTORY ? TAB_ID.CHAT : TAB_ID.HISTORY ); }} - display={chatContext.selectedTabId === 'history' ? 'fill' : undefined} + display={chatContext.selectedTabId === TAB_ID.HISTORY ? 'fill' : undefined} /> diff --git a/public/tabs/history/chat_history_page.tsx b/public/tabs/history/chat_history_page.tsx index 3dc8fefd..c013204f 100644 --- a/public/tabs/history/chat_history_page.tsx +++ b/public/tabs/history/chat_history_page.tsx @@ -20,9 +20,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { FormattedMessage } from '@osd/i18n/react'; import { useDebounce, useObservable } from 'react-use'; import cs from 'classnames'; +import { TAB_ID } from 'public/utils/constants'; import { useChatActions } from '../../hooks/use_chat_actions'; -import { useChatContext } from '../../contexts/chat_context'; -import { useCore } from '../../contexts/core_context'; +import { useChatContext, useCore } from '../../contexts'; import { ChatHistorySearchList } from './chat_history_search_list'; interface ChatHistoryPageProps { @@ -64,7 +64,7 @@ export const ChatHistoryPage: React.FC = React.memo((props }, []); const handleBack = useCallback(() => { - setSelectedTabId('chat'); + setSelectedTabId(TAB_ID.CHAT); }, [setSelectedTabId]); const handleHistoryDeleted = useCallback( @@ -105,7 +105,11 @@ export const ChatHistoryPage: React.FC = React.memo((props {flyoutFullScreen ? ( - + ) : ( diff --git a/public/utils/constants.ts b/public/utils/constants.ts new file mode 100644 index 00000000..9c35746e --- /dev/null +++ b/public/utils/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum TAB_ID { + CHAT = 'chat', + COMPOSE = 'compose', + INSIGHTS = 'insights', + HISTORY = 'history', + TRACE = 'trace', +} diff --git a/server/README.md b/server/README.md new file mode 100644 index 00000000..b7905c80 --- /dev/null +++ b/server/README.md @@ -0,0 +1,27 @@ +# `registerMessageParser` — Register your customized parser logic into Chatbot. + +**Interaction** refers to a question-answer pair in Chatbot application. In most cases, an interaction consists of two messages: an `Input` message and an `Output` message. However, as the Chatbot evolves to become more powerful, it may display new messages such as visualizations, data explorers, or data grids. Therefore, it is crucial to implement a mechanism that allows other plugins to register their customized parser logic based on each interaction body. + +![message parser](https://github.com/opensearch-project/dashboards-assistant/assets/13493605/b4ec1ff8-5339-4119-ad20-b2c31057bb0b) + +## API + +### registerMessageParser + +```typescript +dashboardAssistant.registerMessageParser({ + id: 'foo_parser', + parserProvider: async (interaction) => { + if (interaction.input) { + return [ + { + type: 'input', + contentType: 'text', + content: interaction.input, + }, + ]; + } + return []; + }, +}); +``` diff --git a/server/olly/utils/output_builders/__tests__/build_outputs.test.ts b/server/olly/utils/output_builders/__tests__/build_outputs.test.ts index c796f35a..77815a06 100644 --- a/server/olly/utils/output_builders/__tests__/build_outputs.test.ts +++ b/server/olly/utils/output_builders/__tests__/build_outputs.test.ts @@ -35,7 +35,7 @@ describe('build outputs', () => { it('sanitizes markdown outputs', () => { const outputs = buildOutputs( 'test question', - 'normal text image !!!!!!![](https://badurl) ![image](https://badurl) [good link](https://link)', + 'normal text image !!!!!!![](http://evil.com/) ![image](http://evil.com/) [good link](https://link)', 'test-session', {}, [] @@ -43,7 +43,7 @@ describe('build outputs', () => { expect(outputs).toEqual([ { content: - 'normal text [](https://badurl) [image](https://badurl) [good link](https://link)', + 'normal text [](http://evil.com/) [image](http://evil.com/) [good link](https://link)', contentType: 'markdown', traceId: 'test-session', suggestedActions: [], diff --git a/server/parsers/basic_input_output_parser.test.ts b/server/parsers/basic_input_output_parser.test.ts new file mode 100644 index 00000000..6570cc47 --- /dev/null +++ b/server/parsers/basic_input_output_parser.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BasicInputOutputParser } from './basic_input_output_parser'; + +describe('BasicInputOutputParser', () => { + it('return input and output', async () => { + expect( + await BasicInputOutputParser.parserProvider({ + input: 'input', + response: 'response', + conversation_id: '', + interaction_id: 'interaction_id', + create_time: '', + }) + ).toEqual([ + { + type: 'input', + contentType: 'text', + content: 'input', + }, + { + type: 'output', + contentType: 'markdown', + content: 'response', + traceId: 'interaction_id', + }, + ]); + }); + + it('sanitizes markdown outputs', async () => { + const outputs = await BasicInputOutputParser.parserProvider({ + input: 'test question', + response: + 'normal text image !!!!!!![](http://evil.com/) ![image](http://evil.com/) [good link](https://link)', + conversation_id: 'test-session', + interaction_id: 'interaction_id', + create_time: '', + }); + + expect(outputs).toEqual([ + { + type: 'input', + contentType: 'text', + content: 'test question', + }, + { + content: + 'normal text [](http://evil.com/) [image](http://evil.com/) [good link](https://link)', + contentType: 'markdown', + traceId: 'interaction_id', + type: 'output', + }, + ]); + }); +}); diff --git a/server/parsers/basic_input_output_parser.ts b/server/parsers/basic_input_output_parser.ts new file mode 100644 index 00000000..c1769059 --- /dev/null +++ b/server/parsers/basic_input_output_parser.ts @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import createDOMPurify from 'dompurify'; +import { JSDOM } from 'jsdom'; +import { IInput, IOutput, Interaction } from '../../common/types/chat_saved_object_attributes'; + +const sanitize = (content: string) => { + const window = new JSDOM('').window; + const DOMPurify = createDOMPurify((window as unknown) as Window); + return DOMPurify.sanitize(content, { FORBID_TAGS: ['img'] }).replace(/!+\[/g, '['); +}; + +export const BasicInputOutputParser = { + order: 0, + id: 'output_message', + async parserProvider(interaction: Interaction) { + const inputItem: IInput = { + type: 'input', + contentType: 'text', + content: interaction.input, + }; + const outputItems: IOutput[] = [ + { + type: 'output', + contentType: 'markdown', + content: sanitize(interaction.response), + traceId: interaction.interaction_id, + }, + ]; + return [inputItem, ...outputItems]; + }, +}; diff --git a/server/parsers/visualization_card_parser.test.ts b/server/parsers/visualization_card_parser.test.ts new file mode 100644 index 00000000..63fd66fe --- /dev/null +++ b/server/parsers/visualization_card_parser.test.ts @@ -0,0 +1,116 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VisualizationCardParser } from './visualization_card_parser'; + +describe('VisualizationCardParser', () => { + it('return visualizations when there is VisualizationTool.output', async () => { + expect( + await VisualizationCardParser.parserProvider({ + input: 'input', + response: 'response', + conversation_id: '', + interaction_id: 'interaction_id', + create_time: '', + additional_info: { + 'VisualizationTool.output': [ + 'row_number,Id,title\n' + + '1,id1,[Flights] Total Flights\n' + + '2,id2,[Flights] Controls\n' + + '3,id3,[Flights] Airline Carrier', + ], + }, + }) + ).toEqual([ + { + content: 'id1', + contentType: 'visualization', + suggestedActions: [{ actionType: 'view_in_dashboards', message: 'View in Visualize' }], + type: 'output', + }, + { + content: 'id2', + contentType: 'visualization', + suggestedActions: [{ actionType: 'view_in_dashboards', message: 'View in Visualize' }], + type: 'output', + }, + { + content: 'id3', + contentType: 'visualization', + suggestedActions: [{ actionType: 'view_in_dashboards', message: 'View in Visualize' }], + type: 'output', + }, + ]); + }); + + it('return visualizations when there are multiple VisualizationTool.outputs', async () => { + expect( + await VisualizationCardParser.parserProvider({ + input: 'input', + response: 'response', + conversation_id: '', + interaction_id: 'interaction_id', + create_time: '', + additional_info: { + 'VisualizationTool.output': [ + 'row_number,Id,title\n' + '1,id1,[Flights] Total Flights\n', + 'row_number,Id,title\n' + '2,id2,[Flights] Controls\n', + ], + }, + }) + ).toEqual([ + { + content: 'id1', + contentType: 'visualization', + suggestedActions: [{ actionType: 'view_in_dashboards', message: 'View in Visualize' }], + type: 'output', + }, + { + content: 'id2', + contentType: 'visualization', + suggestedActions: [{ actionType: 'view_in_dashboards', message: 'View in Visualize' }], + type: 'output', + }, + ]); + }); + + it('do not return visualizations when VisualizationTool.output is null', async () => { + expect( + await VisualizationCardParser.parserProvider({ + input: 'input', + response: 'response', + conversation_id: '', + interaction_id: 'interaction_id', + create_time: '', + additional_info: {}, + }) + ).toEqual([]); + }); + + it('do not return visualizations when VisualizationTool.output is not in correct format', async () => { + expect( + await VisualizationCardParser.parserProvider({ + input: 'input', + response: 'response', + conversation_id: '', + interaction_id: 'interaction_id', + create_time: '', + additional_info: { + 'VisualizationTool.output': [ + 'row_number\n' + '1', + 'row_number,Id,title\n' + '2,id2,[Flights] Controls\n', + ], + }, + }) + ).toEqual([ + { + content: 'id2', + contentType: 'visualization', + suggestedActions: [{ actionType: 'view_in_dashboards', message: 'View in Visualize' }], + type: 'output', + }, + ]); + }); +}); diff --git a/server/parsers/visualization_card_parser.ts b/server/parsers/visualization_card_parser.ts new file mode 100644 index 00000000..b6afb731 --- /dev/null +++ b/server/parsers/visualization_card_parser.ts @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IMessage, Interaction } from '../../common/types/chat_saved_object_attributes'; +import { getJsonFromString } from '../utils/csv-parser-helper'; + +const extractIdsFromCsvString = async (csv: string) => { + const lines = (await getJsonFromString(csv)) as Array<{ Id: string }>; + return lines + .map((line) => line.Id) + .filter((v: T | null | undefined): v is T => v !== null && v !== undefined); +}; + +export const VisualizationCardParser = { + id: 'core_visualization', + async parserProvider(interaction: Interaction) { + const visualizationOutputs = interaction.additional_info?.['VisualizationTool.output'] as + | string[] + | undefined; + if (!visualizationOutputs) { + return []; + } + const visualizationIds = ( + await Promise.all(visualizationOutputs.map((output) => extractIdsFromCsvString(output))) + ).flatMap((id) => id); + + const visOutputs: IMessage[] = visualizationIds + /** + * Empty id will be filtered + */ + .filter((id) => id) + .map((id) => ({ + type: 'output', + content: id, + contentType: 'visualization', + suggestedActions: [ + { + message: 'View in Visualize', + actionType: 'view_in_dashboards', + }, + ], + })); + + return visOutputs; + }, +}; diff --git a/server/plugin.ts b/server/plugin.ts index b0d095cc..521d855c 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -17,15 +17,17 @@ import { import { OpenSearchAlertingPlugin } from './adaptors/opensearch_alerting_plugin'; import { OpenSearchObservabilityPlugin } from './adaptors/opensearch_observability_plugin'; import { PPLPlugin } from './adaptors/ppl_plugin'; -import { AssistantServerConfig } from './config/schema'; import './fetch-polyfill'; import { setupRoutes } from './routes/index'; import { chatSavedObject } from './saved_objects/chat_saved_object'; -import { AssistantPluginSetup, AssistantPluginStart } from './types'; +import { AssistantPluginSetup, AssistantPluginStart, MessageParser } from './types'; import { chatConfigSavedObject } from './saved_objects/chat_config_saved_object'; +import { BasicInputOutputParser } from './parsers/basic_input_output_parser'; +import { VisualizationCardParser } from './parsers/visualization_card_parser'; export class AssistantPlugin implements Plugin { private readonly logger: Logger; + private messageParsers: MessageParser[] = []; constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); @@ -54,7 +56,9 @@ export class AssistantPlugin implements Plugin { + const findItem = this.messageParsers.find((item) => item.id === messageParser.id); + if (findItem) { + throw new Error(`There is already a messageParser whose id is ${messageParser.id}`); + } + + this.messageParsers.push(messageParser); + }; + + registerMessageParser(BasicInputOutputParser); + registerMessageParser(VisualizationCardParser); + + return { + registerMessageParser, + removeMessageParser: (parserId: MessageParser['id']) => { + const findIndex = this.messageParsers.findIndex((item) => item.id === parserId); + if (findIndex < 0) { + this.logger.error(`There is not a messageParser whose id is ${parserId}`); + } + + this.messageParsers.splice(findIndex, 1); + }, + }; } public start(core: CoreStart) { diff --git a/server/routes/chat_routes.ts b/server/routes/chat_routes.ts index 7e63804b..2d557236 100644 --- a/server/routes/chat_routes.ts +++ b/server/routes/chat_routes.ts @@ -13,8 +13,9 @@ import { } from '../../../../src/core/server'; import { ASSISTANT_API } from '../../common/constants/llm'; import { OllyChatService } from '../services/chat/olly_chat_service'; -import { SavedObjectsStorageService } from '../services/storage/saved_objects_storage_service'; import { IMessage, IInput } from '../../common/types/chat_saved_object_attributes'; +import { AgentFrameworkStorageService } from '../services/storage/agent_framework_storage_service'; +import { RoutesOptions } from '../types'; const llmRequestRoute = { path: ASSISTANT_API.SEND_MESSAGE, @@ -22,6 +23,7 @@ const llmRequestRoute = { body: schema.object({ sessionId: schema.maybe(schema.string()), messages: schema.maybe(schema.arrayOf(schema.any())), + rootAgentId: schema.string(), input: schema.object({ type: schema.literal('input'), context: schema.object({ @@ -60,6 +62,7 @@ const regenerateRoute = { validate: { body: schema.object({ sessionId: schema.string(), + rootAgentId: schema.string(), }), }, }; @@ -96,15 +99,27 @@ const updateSessionRoute = { params: schema.object({ sessionId: schema.string(), }), - query: schema.object({ + body: schema.object({ title: schema.string(), }), }, }; -export function registerChatRoutes(router: IRouter) { +const getTracesRoute = { + path: `${ASSISTANT_API.TRACE}/{traceId}`, + validate: { + params: schema.object({ + traceId: schema.string(), + }), + }, +}; + +export function registerChatRoutes(router: IRouter, routeOptions: RoutesOptions) { const createStorageService = (context: RequestHandlerContext) => - new SavedObjectsStorageService(context.core.savedObjects.client); + new AgentFrameworkStorageService( + context.core.opensearch.client.asCurrentUser, + routeOptions.messageParsers + ); const createChatService = () => new OllyChatService(); router.post( @@ -114,34 +129,25 @@ export function registerChatRoutes(router: IRouter) { request, response ): Promise> => { - const { sessionId, input, messages = [] } = request.body; + const { messages = [], input, sessionId: sessionIdInRequestBody, rootAgentId } = request.body; const storageService = createStorageService(context); const chatService = createChatService(); - // get history from the chat object for existing chats - if (sessionId && messages.length === 0) { - try { - const session = await storageService.getSession(sessionId); - messages.push(...session.messages); - } catch (error) { - return response.custom({ statusCode: error.statusCode || 500, body: error.message }); - } - } - try { const outputs = await chatService.requestLLM( - { messages, input, sessionId }, - context, - request - ); - const title = input.content.substring(0, 50); - const saveMessagesResponse = await storageService.saveMessages( - title, - sessionId, - [...messages, input, ...outputs].filter((message) => message.content !== 'AbortError') + { messages, input, sessionId: sessionIdInRequestBody, rootAgentId }, + context ); + const sessionId = outputs.memoryId; + const finalMessage = await storageService.getSession(sessionId); + return response.ok({ - body: { ...saveMessagesResponse, title }, + body: { + messages: finalMessage.messages, + sessionId: outputs.memoryId, + title: finalMessage.title, + interactions: finalMessage.interactions, + }, }); } catch (error) { context.assistant_plugin.logger.warn(error); @@ -219,7 +225,7 @@ export function registerChatRoutes(router: IRouter) { try { const getResponse = await storageService.updateSession( request.params.sessionId, - request.query.title + request.body.title ); return response.ok({ body: getResponse }); } catch (error) { @@ -229,6 +235,25 @@ export function registerChatRoutes(router: IRouter) { } ); + router.get( + getTracesRoute, + async ( + context, + request, + response + ): Promise> => { + const storageService = createStorageService(context); + + try { + const getResponse = await storageService.getTraces(request.params.traceId); + return response.ok({ body: getResponse }); + } catch (error) { + context.assistant_plugin.logger.error(error); + return response.custom({ statusCode: error.statusCode || 500, body: error.message }); + } + } + ); + router.post( abortAgentExecutionRoute, async ( @@ -256,7 +281,7 @@ export function registerChatRoutes(router: IRouter) { request, response ): Promise> => { - const { sessionId } = request.body; + const { sessionId, rootAgentId } = request.body; const storageService = createStorageService(context); let messages: IMessage[] = []; const chatService = createChatService(); @@ -276,15 +301,16 @@ export function registerChatRoutes(router: IRouter) { try { const outputs = await chatService.requestLLM( - { messages, input, sessionId }, - context, - request + { messages, input, sessionId, rootAgentId }, + context ); const title = input.content.substring(0, 50); const saveMessagesResponse = await storageService.saveMessages( title, sessionId, - [...messages, input, ...outputs].filter((message) => message.content !== 'AbortError') + [...messages, input, ...outputs.messages].filter( + (message) => message.content !== 'AbortError' + ) ); return response.ok({ body: { ...saveMessagesResponse, title }, diff --git a/server/routes/index.ts b/server/routes/index.ts index ae33e1c3..093bb313 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -3,11 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { RoutesOptions } from '../types'; import { IRouter } from '../../../../src/core/server'; import { registerChatRoutes } from './chat_routes'; import { registerLangchainRoutes } from './langchain_routes'; -export function setupRoutes(router: IRouter) { - registerChatRoutes(router); +export function setupRoutes(router: IRouter, routeOptions: RoutesOptions) { + registerChatRoutes(router, routeOptions); registerLangchainRoutes(router); } diff --git a/server/services/chat/chat_service.ts b/server/services/chat/chat_service.ts index 92d2ec89..e1e81b9a 100644 --- a/server/services/chat/chat_service.ts +++ b/server/services/chat/chat_service.ts @@ -13,7 +13,10 @@ export interface ChatService { payload: { messages: IMessage[]; input: IInput; sessionId?: string }, context: RequestHandlerContext, request: OpenSearchDashboardsRequest - ): Promise; + ): Promise<{ + messages: IMessage[]; + memoryId: string; + }>; generatePPL( context: RequestHandlerContext, request: OpenSearchDashboardsRequest diff --git a/server/services/chat/olly_chat_service.ts b/server/services/chat/olly_chat_service.ts index 6c2f09c2..93e08f9c 100644 --- a/server/services/chat/olly_chat_service.ts +++ b/server/services/chat/olly_chat_service.ts @@ -3,89 +3,86 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Run } from 'langchain/callbacks'; import { v4 as uuid } from 'uuid'; +import { ApiResponse } from '@opensearch-project/opensearch'; import { OpenSearchDashboardsRequest, RequestHandlerContext } from '../../../../../src/core/server'; import { IMessage, IInput } from '../../../common/types/chat_saved_object_attributes'; -import { convertToTraces } from '../../../common/utils/llm_chat/traces'; -import { chatAgentInit } from '../../olly/agents/agent_helpers'; import { OpenSearchTracer } from '../../olly/callbacks/opensearch_tracer'; -import { requestSuggestionsChain } from '../../olly/chains/suggestions_generator'; -import { memoryInit } from '../../olly/memory/chat_agent_memory'; import { LLMModelFactory } from '../../olly/models/llm_model_factory'; -import { initTools } from '../../olly/tools/tools_helper'; import { PPLTools } from '../../olly/tools/tool_sets/ppl'; -import { buildOutputs } from '../../olly/utils/output_builders/build_outputs'; -import { AbortAgentExecutionSchema, LLMRequestSchema } from '../../routes/chat_routes'; import { PPLGenerationRequestSchema } from '../../routes/langchain_routes'; import { ChatService } from './chat_service'; +import { ML_COMMONS_BASE_API } from '../../olly/models/constants'; + +const MEMORY_ID_FIELD = 'memory_id'; export class OllyChatService implements ChatService { static abortControllers: Map = new Map(); public async requestLLM( - payload: { messages: IMessage[]; input: IInput; sessionId?: string }, - context: RequestHandlerContext, - request: OpenSearchDashboardsRequest - ): Promise { - const traceId = uuid(); - const observabilityClient = context.assistant_plugin.observabilityClient.asScoped(request); + payload: { messages: IMessage[]; input: IInput; sessionId?: string; rootAgentId: string }, + context: RequestHandlerContext + ): Promise<{ + messages: IMessage[]; + memoryId: string; + }> { + const { input, sessionId, rootAgentId } = payload; const opensearchClient = context.core.opensearch.client.asCurrentUser; - const savedObjectsClient = context.core.savedObjects.client; if (payload.sessionId) { OllyChatService.abortControllers.set(payload.sessionId, new AbortController()); } try { - const runs: Run[] = []; - const callbacks = [new OpenSearchTracer(opensearchClient, traceId, runs)]; - const model = LLMModelFactory.createModel({ client: opensearchClient }); - const embeddings = LLMModelFactory.createEmbeddings({ client: opensearchClient }); - const pluginTools = initTools( - model, - embeddings, - opensearchClient, - observabilityClient, - savedObjectsClient, - callbacks - ); - const memory = memoryInit(payload.messages); - const chatAgent = chatAgentInit( - model, - pluginTools.flatMap((tool) => tool.toolsList), - callbacks, - memory - ); - const agentResponse = await chatAgent.run( - payload.input.content, - payload.sessionId ? OllyChatService.abortControllers.get(payload.sessionId) : undefined - ); - - const suggestions = await requestSuggestionsChain( - model, - pluginTools.flatMap((tool) => tool.toolsList), - memory, - callbacks - ); + /** + * Wait for an API to fetch root agent id. + */ + const parametersPayload: { + question: string; + verbose?: boolean; + memory_id?: string; + } = { + question: input.content, + verbose: true, + }; + if (sessionId) { + parametersPayload.memory_id = sessionId; + } + const agentFrameworkResponse = (await opensearchClient.transport.request({ + method: 'POST', + path: `${ML_COMMONS_BASE_API}/agents/${rootAgentId}/_execute`, + body: { + parameters: parametersPayload, + }, + })) as ApiResponse<{ + inference_results: Array<{ + output: Array<{ name: string; result?: string }>; + }>; + }>; + const outputBody = agentFrameworkResponse.body.inference_results?.[0]?.output; + const memoryIdItem = outputBody?.find((item) => item.name === MEMORY_ID_FIELD); - return buildOutputs( - payload.input.content, - agentResponse, - traceId, - suggestions, - convertToTraces(runs) - ); + return { + /** + * Interactions will be stored in Agent framework, + * thus we do not need to return the latest message back. + */ + messages: [], + memoryId: memoryIdItem?.result || '', + }; } catch (error) { context.assistant_plugin.logger.error(error); - return [ - { - type: 'output', - traceId, - contentType: 'error', - content: error.message, - }, - ]; + return { + messages: [ + { + type: 'output', + traceId: '', + contentType: 'error', + content: error.message, + }, + ], + memoryId: '', + }; } finally { if (payload.sessionId) { OllyChatService.abortControllers.delete(payload.sessionId); diff --git a/server/services/storage/agent_framework_storage_service.ts b/server/services/storage/agent_framework_storage_service.ts new file mode 100644 index 00000000..bb01126d --- /dev/null +++ b/server/services/storage/agent_framework_storage_service.ts @@ -0,0 +1,219 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TransportRequestPromise, ApiResponse } from '@opensearch-project/opensearch/lib/Transport'; +import { AgentFrameworkTrace } from '../../../common/utils/llm_chat/traces'; +import { OpenSearchClient } from '../../../../../src/core/server'; +import { + IMessage, + ISession, + ISessionFindResponse, + Interaction, +} from '../../../common/types/chat_saved_object_attributes'; +import { GetSessionsSchema } from '../../routes/chat_routes'; +import { StorageService } from './storage_service'; +import { MessageParser } from '../../types'; +import { MessageParserRunner } from '../../utils/message_parser_runner'; +import { ML_COMMONS_BASE_API } from '../../olly/models/constants'; + +export interface SessionOptResponse { + success: boolean; + statusCode?: number | null; + message?: string; +} + +export class AgentFrameworkStorageService implements StorageService { + constructor( + private readonly client: OpenSearchClient, + private readonly messageParsers: MessageParser[] = [] + ) {} + async getSession(sessionId: string): Promise { + const [interactionsResp, conversation] = await Promise.all([ + this.client.transport.request({ + method: 'GET', + path: `${ML_COMMONS_BASE_API}/memory/conversation/${sessionId}/_list`, + }) as TransportRequestPromise< + ApiResponse<{ + interactions: Interaction[]; + }> + >, + this.client.transport.request({ + method: 'GET', + path: `${ML_COMMONS_BASE_API}/memory/conversation/${sessionId}`, + }) as TransportRequestPromise< + ApiResponse<{ + conversation_id: string; + create_time: string; + updated_time: string; + name: string; + }> + >, + ]); + const messageParserRunner = new MessageParserRunner(this.messageParsers); + const finalInteractions = interactionsResp.body.interactions; + + let finalMessages: IMessage[] = []; + for (const interaction of finalInteractions) { + finalMessages = [...finalMessages, ...(await messageParserRunner.run(interaction))]; + } + return { + title: conversation.body.name, + createdTimeMs: +new Date(conversation.body.create_time), + updatedTimeMs: +new Date(conversation.body.updated_time), + messages: finalMessages, + interactions: finalInteractions, + }; + } + + // TODO: return real update_time in the response once the agent framework supports update_time field + async getSessions(query: GetSessionsSchema): Promise { + let sortField = ''; + if (query.sortField === 'updatedTimeMs') { + sortField = 'create_time'; + } + let searchFields: string[] = []; + if (query.search && query.searchFields) { + if (typeof query.searchFields === 'string') { + searchFields = [...searchFields, query.searchFields.replace('title', 'name')]; + } else { + searchFields = query.searchFields.map((item) => item.replace('title', 'name')); + } + } + + const requestParams = { + from: (query.page - 1) * query.perPage, + size: query.perPage, + ...(searchFields.length > 0 && { + query: { + multi_match: { + query: query.search, + fields: searchFields, + }, + }, + }), + ...(searchFields.length === 0 && { + query: { + match_all: {}, + }, + }), + ...(sortField && query.sortOrder && { sort: [{ [sortField]: query.sortOrder }] }), + }; + + const sessions = await this.client.transport.request({ + method: 'GET', + path: `${ML_COMMONS_BASE_API}/memory/conversation/_search`, + body: requestParams, + }); + + return { + objects: sessions.body.hits.hits + .filter( + (hit: { + _source: { name: string; create_time: string }; + }): hit is RequiredKey => + hit._source !== null && hit._source !== undefined + ) + .map((item: { _id: string; _source: { name: string; create_time: string } }) => ({ + id: item._id, + title: item._source.name, + version: 1, + createdTimeMs: Date.parse(item._source.create_time), + updatedTimeMs: Date.parse(item._source.create_time), + messages: [] as IMessage[], + })), + total: + typeof sessions.body.hits.total === 'number' + ? sessions.body.hits.total + : sessions.body.hits.total.value, + }; + } + + async saveMessages( + title: string, + sessionId: string | undefined, + messages: IMessage[] + ): Promise<{ sessionId: string; messages: IMessage[] }> { + throw new Error('Method is not needed'); + } + + async deleteSession(sessionId: string): Promise { + try { + const response = await this.client.transport.request({ + method: 'DELETE', + path: `${ML_COMMONS_BASE_API}/memory/conversation/${sessionId}/_delete`, + }); + if (response.statusCode === 200) { + return { + success: true, + }; + } else { + return { + success: false, + statusCode: response.statusCode, + message: JSON.stringify(response.body), + }; + } + } catch (error) { + throw new Error('delete converstaion failed, reason:' + JSON.stringify(error.meta?.body)); + } + } + + async updateSession(sessionId: string, title: string): Promise { + try { + const response = await this.client.transport.request({ + method: 'PUT', + path: `${ML_COMMONS_BASE_API}/memory/conversation/${sessionId}/_update`, + body: { + name: title, + }, + }); + if (response.statusCode === 200) { + return { + success: true, + }; + } else { + return { + success: false, + statusCode: response.statusCode, + message: JSON.stringify(response.body), + }; + } + } catch (error) { + throw new Error('update converstaion failed, reason:' + JSON.stringify(error.meta?.body)); + } + } + + async getTraces(interactionId: string): Promise { + try { + const response = (await this.client.transport.request({ + method: 'GET', + path: `${ML_COMMONS_BASE_API}/memory/trace/${interactionId}/_list`, + })) as ApiResponse<{ + traces: Array<{ + conversation_id: string; + interaction_id: string; + create_time: string; + input: string; + response: string; + origin: string; + parent_interaction_id: string; + trace_number: number; + }>; + }>; + + return response.body.traces.map((item) => ({ + interactionId: item.interaction_id, + parentInteractionId: item.parent_interaction_id, + input: item.input, + output: item.response, + createTime: item.create_time, + origin: item.origin, + traceNumber: item.trace_number, + })); + } catch (error) { + throw new Error('get traces failed, reason:' + JSON.stringify(error.meta?.body)); + } + } +} diff --git a/server/services/storage/saved_objects_storage_service.ts b/server/services/storage/saved_objects_storage_service.ts index 78fcffb4..f85bba48 100644 --- a/server/services/storage/saved_objects_storage_service.ts +++ b/server/services/storage/saved_objects_storage_service.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { MessageParser } from '../../types'; import { SavedObjectsClientContract } from '../../../../../src/core/server'; import { CHAT_SAVED_OBJECT, @@ -15,7 +16,10 @@ import { GetSessionsSchema } from '../../routes/chat_routes'; import { StorageService } from './storage_service'; export class SavedObjectsStorageService implements StorageService { - constructor(private readonly client: SavedObjectsClientContract) {} + constructor( + private readonly client: SavedObjectsClientContract, + private readonly messageParsers: MessageParser[] + ) {} private convertUpdatedTimeField(updatedAt: string | undefined) { return updatedAt ? new Date(updatedAt).getTime() : undefined; diff --git a/server/services/storage/storage_service.ts b/server/services/storage/storage_service.ts index 8d676c5e..0fe27df6 100644 --- a/server/services/storage/storage_service.ts +++ b/server/services/storage/storage_service.ts @@ -18,4 +18,6 @@ export interface StorageService { sessionId: string | undefined, messages: IMessage[] ): Promise<{ sessionId: string; messages: IMessage[] }>; + deleteSession(sessionId: string): Promise<{}>; + updateSession(sessionId: string, title: string): Promise<{}>; } diff --git a/server/types.ts b/server/types.ts index bb72cc4b..5b692036 100644 --- a/server/types.ts +++ b/server/types.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { IMessage, Interaction } from '../common/types/chat_saved_object_attributes'; import { ILegacyClusterClient, Logger } from '../../../src/core/server'; // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -10,6 +11,28 @@ export interface AssistantPluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface AssistantPluginStart {} +export interface MessageParser { + /** + * The id of the parser, should be unique among the parsers. + */ + id: string; + /** + * Order field declares the order message parser will be execute. + * parser with order 2 will be executed after parser with order 1. + * If not specified, the default order will be 999. + * @default 999 + */ + order?: number; + /** + * parserProvider is the callback that will be triggered in each message + */ + parserProvider: (interaction: Interaction) => Promise; +} + +export interface RoutesOptions { + messageParsers: MessageParser[]; +} + declare module '../../../src/core/server' { interface RequestHandlerContext { assistant_plugin: { diff --git a/server/utils/csv-parser-helper.test.ts b/server/utils/csv-parser-helper.test.ts new file mode 100644 index 00000000..6671ae39 --- /dev/null +++ b/server/utils/csv-parser-helper.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getJsonFromString } from './csv-parser-helper'; + +describe('getJsonFromString', () => { + it('return correct answer', async () => { + expect(await getJsonFromString('title,id\n1,2')).toEqual([ + { + title: '1', + id: '2', + }, + ]); + }); + + it('return empty array when string is not in correct format', async () => { + expect(await getJsonFromString('1,2')).toEqual([]); + }); +}); diff --git a/server/utils/csv-parser-helper.ts b/server/utils/csv-parser-helper.ts new file mode 100644 index 00000000..690d4aee --- /dev/null +++ b/server/utils/csv-parser-helper.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Readable } from 'stream'; +import csvParser from 'csv-parser'; + +export const getJsonFromString = ( + csvString: string, + options?: csvParser.Options +): Promise> | string[][]> => { + const results: string[][] | Array> = []; + return new Promise((resolve, reject) => { + Readable.from(csvString) + .pipe(csvParser(options)) + .on('data', (data) => results.push(data)) + .on('end', () => { + resolve(results); + }) + .on('error', (err) => { + reject(err); + }); + }); +}; diff --git a/server/utils/message_parser_runner.test.ts b/server/utils/message_parser_runner.test.ts new file mode 100644 index 00000000..ca4032a6 --- /dev/null +++ b/server/utils/message_parser_runner.test.ts @@ -0,0 +1,165 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MessageParserRunner } from './message_parser_runner'; + +describe('MessageParserRunner', () => { + it('run with correct result', async () => { + const messageParserRunner = new MessageParserRunner([ + { + id: 'test', + parserProvider(interaction) { + return Promise.resolve([ + { + type: 'output', + contentType: 'markdown', + content: interaction.response, + }, + ]); + }, + }, + ]); + + expect( + await messageParserRunner.run({ + response: 'output', + input: 'input', + conversation_id: '', + interaction_id: '', + create_time: '', + additional_info: {}, + parent_interaction_id: '' + }) + ).toEqual([ + { + type: 'output', + contentType: 'markdown', + content: 'output', + }, + ]); + }); + + it('run with correct result when different order is present', async () => { + const messageParserRunner = new MessageParserRunner([ + { + id: 'testA', + order: 2, + parserProvider() { + return Promise.resolve([ + { + type: 'output', + contentType: 'markdown', + content: 'A', + }, + ]); + }, + }, + { + id: 'testOrder1000', + order: 1000, + parserProvider() { + return Promise.resolve([ + { + type: 'output', + contentType: 'markdown', + content: 'order1000', + }, + ]); + }, + }, + { + id: 'testNoOrder', + parserProvider(interaction) { + return Promise.resolve([ + { + type: 'output', + contentType: 'markdown', + content: 'NoOrder', + }, + ]); + }, + }, + { + id: 'testB', + order: 1, + parserProvider() { + return Promise.resolve([ + { + type: 'output', + contentType: 'markdown', + content: 'B', + }, + ]); + }, + }, + ]); + + expect( + await messageParserRunner.run({ + response: 'output', + input: 'input', + conversation_id: '', + interaction_id: '', + create_time: '', + additional_info: {}, + parent_interaction_id: '' + }) + ).toEqual([ + { + type: 'output', + contentType: 'markdown', + content: 'B', + }, + { + type: 'output', + contentType: 'markdown', + content: 'A', + }, + { + type: 'output', + contentType: 'markdown', + content: 'NoOrder', + }, + { + type: 'output', + contentType: 'markdown', + content: 'order1000', + }, + ]); + }); + + it('Do not append messages that are throwed with error or not an array', async () => { + const messageParserRunner = new MessageParserRunner([ + { + id: 'test_with_error', + parserProvider() { + throw new Error('error'); + }, + }, + { + id: 'test_with_incorrect_format_of_return', + parserProvider() { + return Promise.resolve({ + type: 'output', + contentType: 'markdown', + content: 'order1000', + }); + }, + }, + ]); + + expect( + await messageParserRunner.run({ + response: 'output', + input: 'input', + conversation_id: '', + interaction_id: '', + create_time: '', + additional_info: {}, + parent_interaction_id: '' + }) + ).toEqual([]); + }); +}); diff --git a/server/utils/message_parser_runner.ts b/server/utils/message_parser_runner.ts new file mode 100644 index 00000000..60534247 --- /dev/null +++ b/server/utils/message_parser_runner.ts @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IMessage, Interaction } from '../../common/types/chat_saved_object_attributes'; +import { MessageParser } from '../types'; + +export class MessageParserRunner { + constructor(private readonly messageParsers: MessageParser[]) {} + async run(interaction: Interaction): Promise { + const sortedParsers = [...this.messageParsers]; + sortedParsers.sort((parserA, parserB) => { + const { order: orderA = 999 } = parserA; + const { order: orderB = 999 } = parserB; + return orderA - orderB; + }); + let results: IMessage[] = []; + for (const messageParser of sortedParsers) { + let tempResult: IMessage[] = []; + try { + tempResult = await messageParser.parserProvider(interaction); + /** + * Make sure the tempResult is an array. + */ + if (!Array.isArray(tempResult)) { + tempResult = []; + } + } catch (e) { + tempResult = []; + } + results = [...results, ...tempResult]; + } + return results; + } +} diff --git a/test/jest.config.js b/test/jest.config.js index 50752886..3a5ab955 100644 --- a/test/jest.config.js +++ b/test/jest.config.js @@ -22,11 +22,8 @@ module.exports = { '/public/requests/', '/__utils__/', ], - transform: { - '^.+\\.tsx?$': ['ts-jest', { diagnostics: false }], - 'node_modules/(langchain|langsmith)/.+\\.js$': ['ts-jest', { diagnostics: false }], - }, - transformIgnorePatterns: ['/node_modules/(?!langchain|langsmith)'], + // https://github.com/jestjs/jest/issues/6229#issuecomment-403539460 + transformIgnorePatterns: ['node_modules/(?!langchain|langsmith)'], moduleNameMapper: { '\\.(css|less|sass|scss)$': '/test/__mocks__/styleMock.js', '\\.(gif|ttf|eot|svg|png)$': '/test/__mocks__/fileMock.js', diff --git a/yarn.lock b/yarn.lock index 3f22bbf1..290e4f09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -545,6 +545,13 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== +csv-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/csv-parser/-/csv-parser-3.0.0.tgz#b88a6256d79e090a97a1b56451f9327b01d710e7" + integrity sha512-s6OYSXAK3IdKqYO33y09jhypG/bSDHPuyCme/IdEHfWpLf/jKcpitVFyOC6UemgGk8v7Q5u2XE0vvwmanxhGlQ== + dependencies: + minimist "^1.2.0" + data-urls@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-4.0.0.tgz#333a454eca6f9a5b7b0f1013ff89074c3f522dd4" @@ -1354,7 +1361,7 @@ minimatch@^3.0.4, minimatch@^3.1.1: dependencies: brace-expansion "^1.1.7" -minimist@^1.2.5, minimist@^1.2.6: +minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==