diff --git a/README.md b/README.md index 6722b882..8baa3ac1 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,56 @@ import { useFlag } from '@openshift-console/dynamic-plugin-sdk'; const isLightspeedRunning = useFlag('LIGHTSPEED_CONSOLE'); ``` +## Providing tool visualization from an external plugin + +Other plugins can define a visualization for a specific MCP tool. + +In order to do so, they need to: + +1. annotate the particular MCP tool inside the MCP server: + +``` json +_meta: { + additionalFields: { + olsUi: { + id: 'my-mcp/my-tool', + }, + } +} +``` + +2. define an extension of type `ols.tool-ui` inside the plugin, connecting the tool (using the annotated id) +with the particular component: + +``` json +{ + "type": "ols.tool-ui", + "properties": { + "id": "my-obs/my-tool", + "component": { + "$codeRef": "MyToolUI" + } + } +} +``` + +This needs to follow the standard `openshift-console/dynamic-plugin-sdk` practices +of exporting the referenced component. + +3. Once the MCP tool gets called, OLS passes the tool details to the ToolUI component in the `tool` argument: + +``` typescript +type MyTool = { + name: 'my-tool'; + args: object, + // ... +}; + +export const MyToolUI React.FC<{ tool: MyTool }> = ({ tool }) => { + // component implementation +} +``` + ## References - [Console Plugin SDK README](https://github.com/openshift/console/tree/main/frontend/packages/console-dynamic-plugin-sdk) diff --git a/src/components/OlsToolUIs.tsx b/src/components/OlsToolUIs.tsx new file mode 100644 index 00000000..3c85bd9e --- /dev/null +++ b/src/components/OlsToolUIs.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { Map as ImmutableMap } from 'immutable'; +import { useSelector } from 'react-redux'; +import { State } from '../redux-reducers'; +import { useToolUIMapping } from '../hooks/useToolUIMapping'; +import type { OlsToolUIComponent, Tool } from '../types'; +import ErrorBoundary from './ErrorBoundary'; + +type OlsToolUIProps = { + tool: Tool; + toolUIComponent: OlsToolUIComponent; +}; + +export const OlsToolUI: React.FC = ({ tool, toolUIComponent: ToolComponent }) => ( + + + +); + +type OlsUIToolsProps = { + entryIndex: number; +}; + +export const OlsToolUIs: React.FC = ({ entryIndex }) => { + const [toolUIMapping] = useToolUIMapping(); + + const toolsData: ImmutableMap> = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'tools']), + ); + + const olsToolsWithUI = toolsData + .map((value) => { + const tool = value.toJS() as Tool; + const toolUIComponent = tool.olsToolUiID && toolUIMapping[tool.olsToolUiID]; + return { tool, toolUIComponent }; + }) + .filter(({ tool, toolUIComponent }) => tool.status !== 'error' && !!toolUIComponent); + + return ( + <> + {olsToolsWithUI + .map(({ tool, toolUIComponent }, toolID) => ( + + )) + .valueSeq()} + + ); +}; + +export default OlsToolUIs; diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index ae5c135f..7b964f68 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -623,6 +623,7 @@ const Prompt: React.FC = ({ scrollIntoView }) => { tool_meta: toolMeta, } = json.data; const uiResourceUri = toolMeta?.ui?.resourceUri as string | undefined; + const olsToolUiID = toolMeta?.olsUi?.id as string | undefined; dispatch( chatHistoryUpdateTool(chatEntryID, id, { content, @@ -630,6 +631,7 @@ const Prompt: React.FC = ({ scrollIntoView }) => { ...(uiResourceUri && { uiResourceUri }), ...(serverName && { serverName }), ...(structuredContent && { structuredContent }), + ...(olsToolUiID && { olsToolUiID }), }), ); } else if (json.event === 'error') { diff --git a/src/components/ResponseTools.tsx b/src/components/ResponseTools.tsx index 221e30da..8a9f6515 100644 --- a/src/components/ResponseTools.tsx +++ b/src/components/ResponseTools.tsx @@ -8,6 +8,7 @@ import { openToolSet } from '../redux-actions'; import { State } from '../redux-reducers'; import { Tool } from '../types'; import MCPApp from './MCPApp'; +import OlsToolUIs from './OlsToolUIs'; type ToolProps = { entryIndex: number; @@ -64,6 +65,7 @@ const ResponseTools: React.FC = ({ entryIndex }) => { .map((toolID) => ( ))} + {tools .keySeq() diff --git a/src/hooks/useToolUIMapping.ts b/src/hooks/useToolUIMapping.ts new file mode 100644 index 00000000..209e9f46 --- /dev/null +++ b/src/hooks/useToolUIMapping.ts @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { useResolvedExtensions } from '@openshift-console/dynamic-plugin-sdk'; +import type { + CodeRef, + Extension, + ExtensionDeclaration, +} from '@openshift-console/dynamic-plugin-sdk/lib/types'; +import type { OlsToolUIComponent } from '../types'; + +type ToolUIExtensionProperties = { + /** ID of the component (as referenced by the MCP tool) */ + id: string; + /** The component to be rendered when the MCP tool matches. */ + component: CodeRef; +}; + +type ToolUIExtension = ExtensionDeclaration<'ols.tool-ui', ToolUIExtensionProperties>; + +const isToolUIExtension = (e: Extension): e is ToolUIExtension => e.type === 'ols.tool-ui'; + +export const useToolUIExtensions = () => useResolvedExtensions(isToolUIExtension); + +export const useToolUIMapping = (): [Record, boolean] => { + const [extensions, resolved] = useToolUIExtensions(); + + const mapping = React.useMemo(() => { + const result: Record = {}; + extensions.forEach((extension) => { + const { id, component } = extension.properties as { + id: string; + component: OlsToolUIComponent; + }; + result[id] = component; + }); + return result; + }, [extensions]); + + return [mapping, resolved]; +}; diff --git a/src/types.ts b/src/types.ts index 000f7db2..40410ae3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ +import * as React from 'react'; import { Map as ImmutableMap } from 'immutable'; import { ErrorType } from './error'; @@ -27,15 +28,18 @@ export type ReferencedDoc = { }; export type Tool = { - args: { [key: string]: Array }; + args: { [key: string]: string }; content: string; name: string; status: 'error' | 'success' | 'truncated'; uiResourceUri?: string; serverName?: string; structuredContent?: Record; + olsToolUiID: string; }; +export type OlsToolUIComponent = React.ComponentType<{ tool: Tool }>; + type ChatEntryUser = { attachments: { [key: string]: Attachment }; hidden?: boolean;