Add common code for AI workflows (#34381)

* Add common /ai endpoints for agents and services and common component for agent selection

* Fix vet api

* Add a bunch of redux stuff

* Fixes

* Missed an add

* fix types

* Add a hook to determine if bridge is enabled

* Add debounce to hook to prevent double fetches from PLUGIN_* and CONFIG_CHANGED event both firing when a plugin state is changed

* Fix i18n

* Rename to remove 'AI' (#34393)

---------

Co-authored-by: Christopher Speller <crspeller@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Nick Misasi
2025-11-04 10:16:43 -05:00
committed by GitHub
co-authored by Christopher Speller Mattermost Build
parent 3b250ba5c4
commit 1ba3535a0e
25 changed files with 948 additions and 25 deletions
+1
View File
@@ -62,6 +62,7 @@ build-v4: node_modules playbooks
@cat $(V4_SRC)/audit_logging.yaml >> $(V4_YAML)
@cat $(V4_SRC)/access_control.yaml >> $(V4_YAML)
@cat $(V4_SRC)/content_flagging.yaml >> $(V4_YAML)
@cat $(V4_SRC)/agents.yaml >> $(V4_YAML)
@if [ -r $(PLAYBOOKS_SRC)/paths.yaml ]; then cat $(PLAYBOOKS_SRC)/paths.yaml >> $(V4_YAML); fi
@if [ -r $(PLAYBOOKS_SRC)/merged-definitions.yaml ]; then cat $(PLAYBOOKS_SRC)/merged-definitions.yaml >> $(V4_YAML); else cat $(V4_SRC)/definitions.yaml >> $(V4_YAML); fi
@echo Extracting code samples
+54
View File
@@ -0,0 +1,54 @@
/api/v4/agents:
get:
tags:
- agents
summary: Get available agents
description: >
Retrieve all available agents from the plugin's bridge API.
If a user ID is provided, only agents accessible to that user are returned.
##### Permissions
Must be authenticated.
__Minimum server version__: 11.2
operationId: GetAgents
responses:
"200":
description: Agents retrieved successfully
content:
application/json:
schema:
$ref: "#/components/schemas/AgentsResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/InternalServerError"
/api/v4/llmservices:
get:
tags:
- agents
summary: Get available LLM services
description: >
Retrieve all available LLM services from the plugin's bridge API.
If a user ID is provided, only services accessible to that user
(via their permitted bots) are returned.
##### Permissions
Must be authenticated.
__Minimum server version__: 11.2
operationId: GetLLMServices
responses:
"200":
description: LLM services retrieved successfully
content:
application/json:
schema:
$ref: "#/components/schemas/ServicesResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/InternalServerError"
+46
View File
@@ -3884,6 +3884,52 @@ components:
bytes:
type: number
description: Total file storage usage for the instance in bytes rounded down to the most significant digit
BridgeAgentInfo:
type: object
properties:
id:
type: string
description: Unique identifier for the agent
displayName:
type: string
description: Human-readable name for the agent
username:
type: string
description: Username associated with the agent bot
service_id:
type: string
description: ID of the service providing this agent
service_type:
type: string
description: Type of the service (e.g., openai, anthropic)
BridgeServiceInfo:
type: object
properties:
id:
type: string
description: Unique identifier for the LLM service
name:
type: string
description: Name of the LLM service
type:
type: string
description: Type of the service (e.g., openai, anthropic, azure)
AgentsResponse:
type: object
properties:
agents:
type: array
items:
$ref: "#/components/schemas/BridgeAgentInfo"
description: List of available agents
ServicesResponse:
type: object
properties:
services:
type: array
items:
$ref: "#/components/schemas/BridgeServiceInfo"
description: List of available LLM services
PostAcknowledgement:
type: object
properties:
+2
View File
@@ -462,6 +462,8 @@ tags:
description: Endpoints related to metrics, including the Client Performance Monitoring feature.
- name: audit_logs
description: Endpoints for managing audit log certificates and configuration.
- name: ai
description: Endpoints for interacting with AI agents and services.
servers:
- url: "{your-mattermost-url}"
variables:
+55
View File
@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitAgents() {
// GET /api/v4/agents
api.BaseRoutes.Agents.Handle("", api.APISessionRequired(getAgents)).Methods(http.MethodGet)
// GET /api/v4/llmservices
api.BaseRoutes.LLMServices.Handle("", api.APISessionRequired(getLLMServices)).Methods(http.MethodGet)
}
func getAgents(c *Context, w http.ResponseWriter, r *http.Request) {
agents, appErr := c.App.GetAgents(c.AppContext, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = model.NewAppError("Api4.getAgents", "app.agents.get_agents.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
jsonData, err := json.Marshal(agents)
if err != nil {
c.Err = model.NewAppError("Api4.getAgents", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(jsonData); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getLLMServices(c *Context, w http.ResponseWriter, r *http.Request) {
services, appErr := c.App.GetLLMServices(c.AppContext, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = model.NewAppError("Api4.getLLMServices", "app.agents.get_services.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
jsonData, err := json.Marshal(services)
if err != nil {
c.Err = model.NewAppError("Api4.getLLMServices", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(jsonData); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
+7
View File
@@ -162,6 +162,9 @@ type Routes struct {
AccessControlPolicy *mux.Router // 'api/v4/access_control_policies/{policy_id:[A-Za-z0-9]+}'
ContentFlagging *mux.Router // 'api/v4/content_flagging'
Agents *mux.Router // 'api/v4/agents'
LLMServices *mux.Router // 'api/v4/llmservices'
}
type API struct {
@@ -311,6 +314,9 @@ func Init(srv *app.Server) (*API, error) {
api.BaseRoutes.ContentFlagging = api.BaseRoutes.APIRoot.PathPrefix("/content_flagging").Subrouter()
api.BaseRoutes.Agents = api.BaseRoutes.APIRoot.PathPrefix("/agents").Subrouter()
api.BaseRoutes.LLMServices = api.BaseRoutes.APIRoot.PathPrefix("/llmservices").Subrouter()
api.InitUser()
api.InitBot()
api.InitTeam()
@@ -364,6 +370,7 @@ func Init(srv *app.Server) (*API, error) {
api.InitAuditLogging()
api.InitAccessControlPolicy()
api.InitContentFlagging()
api.InitAgents()
// If we allow testing then listen for manual testing URL hits
if *srv.Config().ServiceSettings.EnableTesting {
+58
View File
@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// getBridgeClient returns a bridge client for making requests to the plugin bridge API
func (a *App) getBridgeClient(userID string) *agentclient.Client {
return agentclient.NewClientFromApp(a, userID)
}
// GetAgents retrieves all available agents from the bridge API
func (a *App) GetAgents(rctx request.CTX, userID string) (*agentclient.AgentsResponse, *model.AppError) {
// Create bridge client
sessionUserID := ""
if session := rctx.Session(); session != nil {
sessionUserID = session.UserId
}
client := a.getBridgeClient(sessionUserID)
agents, err := client.GetAgents(userID)
if err != nil {
rctx.Logger().Error("Failed to get agents from bridge",
mlog.Err(err),
mlog.String("user_id", userID),
)
return nil, model.NewAppError("GetAgents", "app.agents.get_agents.bridge_call_failed", nil, err.Error(), 500)
}
return &agentclient.AgentsResponse{Agents: agents}, nil
}
// GetLLMServices retrieves all available LLM services from the bridge API
func (a *App) GetLLMServices(rctx request.CTX, userID string) (*agentclient.ServicesResponse, *model.AppError) {
// Create bridge client
sessionUserID := ""
if session := rctx.Session(); session != nil {
sessionUserID = session.UserId
}
client := a.getBridgeClient(sessionUserID)
services, err := client.GetServices(userID)
if err != nil {
rctx.Logger().Error("Failed to get LLM services from bridge",
mlog.Err(err),
mlog.String("user_id", userID),
)
return nil, model.NewAppError("GetLLMServices", "app.agents.get_services.bridge_call_failed", nil, err.Error(), 500)
}
return &agentclient.ServicesResponse{Services: services}, nil
}
-25
View File
@@ -1,25 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient"
)
// getAIClient returns an AI client for making requests to the AI plugin
func (a *App) getAIClient(userID string) *agentclient.Client {
return agentclient.NewClientFromApp(a, userID)
}
// Placeholder - NewClientFromApp needs to be called to initialize the AI client in order to ensure everything lines up from a build perspective, and getAIClient can't be uncalled because of linter
// TODO: Remove once a proper feature actually uses the AI Client
func (a *App) AIClient() error {
aiClient := a.getAIClient("")
if aiClient == nil {
return errors.New("failed to get AI client")
}
return nil
}
+16
View File
@@ -4654,6 +4654,22 @@
"id": "app.admin.test_site_url.failure",
"translation": "This is not a valid live URL"
},
{
"id": "app.agents.get_agents.app_error",
"translation": "Failed to get agents."
},
{
"id": "app.agents.get_agents.bridge_call_failed",
"translation": "Bridge call failed."
},
{
"id": "app.agents.get_services.app_error",
"translation": "Failed to get LLM services."
},
{
"id": "app.agents.get_services.bridge_call_failed",
"translation": "Bridge call failed."
},
{
"id": "app.analytics.getanalytics.internal_error",
"translation": "Unable to get the analytics."
@@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.agent-dropdown {
display: flex;
align-items: center;
gap: 8px;
.agent-dropdown-label {
color: rgba(var(--center-channel-color-rgb), 0.56);
font-family: 'Open Sans', sans-serif;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.48px;
line-height: 16px;
text-transform: uppercase;
}
.agent-dropdown-button {
display: flex;
align-items: center;
padding: 2px 4px 2px 6px;
border: none;
border-radius: 4px;
background-color: rgba(var(--center-channel-color-rgb), 0.08);
cursor: pointer;
gap: 4px;
transition: background-color 0.15s ease;
&:hover:not(:disabled) {
background-color: rgba(var(--center-channel-color-rgb), 0.12);
}
&:active:not(:disabled) {
background-color: rgba(var(--button-bg-rgb), 0.12);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.agent-dropdown-button-text {
color: var(--center-channel-color);
font-family: 'Open Sans', sans-serif;
font-size: 11px;
font-weight: 600;
line-height: 16px;
white-space: nowrap;
}
svg {
color: var(--center-channel-color);
}
}
}
// Menu header - rendered in MUI Popover, needs global scope
.agent-dropdown-menu-header {
padding: 6px 20px;
background-color: var(--center-channel-bg);
color: rgba(var(--center-channel-color-rgb), 0.56);
font-family: 'Open Sans', sans-serif;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.48px;
line-height: 16px;
text-transform: uppercase;
}
// Menu items - rendered in MUI Popover, needs global scope targeting menu ID
#agent-dropdown-menu {
padding: 8px 0;
.MuiMenuItem-root {
min-height: 40px;
padding: 8px 16px !important;
.leading-element {
width: 24px;
height: 24px;
padding: 0 4px;
margin-inline-end: 8px !important;
}
.label-elements {
padding: 2px 4px;
gap: 0 !important;
span {
color: var(--center-channel-color) !important;
font-family: 'Open Sans', sans-serif;
font-size: 14px !important;
font-weight: 400;
line-height: 20px;
}
}
.trailing-elements {
margin-inline-start: 0 !important;
svg {
width: 16px;
height: 16px;
color: var(--button-bg) !important;
}
}
}
}
@@ -0,0 +1,257 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Agent} from '@mattermost/types/agents';
import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
import AgentDropdown from './agent_dropdown';
describe('AgentDropdown', () => {
const mockBots: Agent[] = [
{
id: 'bot1',
displayName: 'Copilot',
username: 'copilot',
service_id: 'service1',
service_type: 'copilot',
},
{
id: 'bot2',
displayName: 'OpenAI',
username: 'openai',
service_id: 'service2',
service_type: 'openai',
},
{
id: 'bot3',
displayName: 'Azure OpenAI',
username: 'azureopenai',
service_id: 'service3',
service_type: 'azure',
},
];
const defaultProps = {
selectedBotId: 'bot1',
onBotSelect: jest.fn(),
bots: mockBots,
defaultBotId: 'bot1',
};
beforeEach(() => {
jest.clearAllMocks();
});
test('should render with selected bot name', () => {
renderWithContext(<AgentDropdown {...defaultProps}/>);
expect(screen.getByText('Copilot')).toBeInTheDocument();
});
test('should show label when showLabel is true', () => {
renderWithContext(
<AgentDropdown
{...defaultProps}
showLabel={true}
/>,
);
expect(screen.getByText('GENERATE WITH:')).toBeInTheDocument();
expect(screen.getByText('Copilot')).toBeInTheDocument();
});
test('should not show label by default', () => {
renderWithContext(<AgentDropdown {...defaultProps}/>);
expect(screen.queryByText('GENERATE WITH:')).not.toBeInTheDocument();
});
test('should render placeholder when no bot is selected', () => {
renderWithContext(
<AgentDropdown
{...defaultProps}
selectedBotId={null}
/>,
);
expect(screen.getByText('Select a bot')).toBeInTheDocument();
});
test('should open menu when button is clicked', async () => {
renderWithContext(<AgentDropdown {...defaultProps}/>);
const button = screen.getByLabelText('Agent selector');
await userEvent.click(button);
expect(screen.getByText('CHOOSE A BOT')).toBeInTheDocument();
expect(screen.getByText('Copilot (default)')).toBeInTheDocument();
expect(screen.getByText('OpenAI')).toBeInTheDocument();
expect(screen.getByText('Azure OpenAI')).toBeInTheDocument();
});
test('should display default label for default bot', async () => {
renderWithContext(<AgentDropdown {...defaultProps}/>);
const button = screen.getByLabelText('Agent selector');
await userEvent.click(button);
expect(screen.getByText('Copilot (default)')).toBeInTheDocument();
});
test('should not display default label for non-default bots', async () => {
renderWithContext(<AgentDropdown {...defaultProps}/>);
const button = screen.getByLabelText('Agent selector');
await userEvent.click(button);
expect(screen.getByText('OpenAI')).toBeInTheDocument();
expect(screen.queryByText('OpenAI (default)')).not.toBeInTheDocument();
});
test('should call onBotSelect when a bot is clicked', async () => {
const onBotSelect = jest.fn();
renderWithContext(
<AgentDropdown
{...defaultProps}
onBotSelect={onBotSelect}
/>,
);
const button = screen.getByLabelText('Agent selector');
await userEvent.click(button);
const openAIOption = screen.getByTestId('agent-option-bot2');
await userEvent.click(openAIOption);
// Wait for callback to be called after menu closes
await waitFor(() => expect(onBotSelect).toHaveBeenCalledTimes(1));
expect(onBotSelect).toHaveBeenCalledWith('bot2');
});
test('should show checkmark for selected bot', async () => {
renderWithContext(
<AgentDropdown
{...defaultProps}
selectedBotId='bot2'
/>,
);
const button = screen.getByLabelText('Agent selector');
await userEvent.click(button);
const selectedOption = screen.getByTestId('agent-option-bot2');
const checkIcon = selectedOption.querySelector('svg');
expect(checkIcon).toBeInTheDocument();
});
test('should not show checkmark for non-selected bots', async () => {
renderWithContext(
<AgentDropdown
{...defaultProps}
selectedBotId='bot1'
/>,
);
const button = screen.getByLabelText('Agent selector');
await userEvent.click(button);
const nonSelectedOption = screen.getByTestId('agent-option-bot2');
const trailingElements = nonSelectedOption.querySelector('.trailing-elements');
expect(trailingElements).not.toBeInTheDocument();
});
test('should be disabled when disabled prop is true', () => {
renderWithContext(
<AgentDropdown
{...defaultProps}
disabled={true}
/>,
);
const button = screen.getByLabelText('Agent selector');
expect(button).toBeDisabled();
});
test('should render all bots in the list', async () => {
renderWithContext(<AgentDropdown {...defaultProps}/>);
const button = screen.getByLabelText('Agent selector');
await userEvent.click(button);
mockBots.forEach((bot) => {
const isDefault = bot.id === defaultProps.defaultBotId;
const expectedText = isDefault ? `${bot.displayName} (default)` : bot.displayName;
expect(screen.getByText(expectedText)).toBeInTheDocument();
});
});
test('should handle keyboard navigation', async () => {
renderWithContext(<AgentDropdown {...defaultProps}/>);
// Tab to the button
await userEvent.tab();
const button = screen.getByLabelText('Agent selector');
expect(button).toHaveFocus();
// Open menu with Enter key
await userEvent.keyboard('{enter}');
expect(screen.getByText('CHOOSE A BOT')).toBeInTheDocument();
});
test('should update displayed name when selectedBotId changes', () => {
const {rerender} = renderWithContext(
<AgentDropdown
{...defaultProps}
selectedBotId='bot1'
/>,
);
expect(screen.getByText('Copilot')).toBeInTheDocument();
rerender(
<AgentDropdown
{...defaultProps}
selectedBotId='bot2'
/>,
);
expect(screen.getByText('OpenAI')).toBeInTheDocument();
});
test('should render with no default bot', async () => {
renderWithContext(
<AgentDropdown
{...defaultProps}
defaultBotId={undefined}
/>,
);
const button = screen.getByLabelText('Agent selector');
await userEvent.click(button);
// All bots should be rendered without "(default)" label
const copilotOption = screen.getByTestId('agent-option-bot1');
expect(copilotOption).toHaveTextContent('Copilot');
expect(screen.queryByText('Copilot (default)')).not.toBeInTheDocument();
});
test('should handle empty bots array', () => {
renderWithContext(
<AgentDropdown
{...defaultProps}
bots={[]}
selectedBotId={null}
showLabel={true}
/>,
);
expect(screen.getByText('GENERATE WITH:')).toBeInTheDocument();
expect(screen.getByText('Select a bot')).toBeInTheDocument();
});
});
@@ -0,0 +1,126 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {CheckIcon, ChevronDownIcon} from '@mattermost/compass-icons/components';
import type {Agent} from '@mattermost/types/agents';
import {Client4} from 'mattermost-redux/client';
import * as Menu from 'components/menu';
import Avatar from 'components/widgets/users/avatar';
import './agent_dropdown.scss';
type Props = {
selectedBotId: string | null;
onBotSelect: (botId: string) => void;
bots: Agent[];
defaultBotId?: string;
disabled?: boolean;
showLabel?: boolean;
// When inside a GenericModal, we need to communicate with the parent to set enforceFocus off when this menu is open
// Otherwise the underlying mui popover and GenericModal will exhaust the call stack trying to set focus when this closes
onMenuToggle?: (isOpen: boolean) => void;
};
const AgentDropdown = ({
selectedBotId,
onBotSelect,
bots,
defaultBotId,
disabled = false,
showLabel = false,
onMenuToggle,
}: Props) => {
const {formatMessage} = useIntl();
const selectedBot = bots.find((bot) => bot.id === selectedBotId);
const displayName = selectedBot?.displayName || formatMessage({id: 'agent.selectBot', defaultMessage: 'Select a bot'});
const handleBotClick = useCallback((botId: string) => {
return () => {
onBotSelect(botId);
};
}, [onBotSelect]);
const getBotAvatarUrl = (botId: string) => {
return Client4.getProfilePictureUrl(botId, 0);
};
const getBotUsername = (bot: Agent) => {
return bot.username;
};
const menuConfig = useMemo(() => ({
id: 'agent-dropdown-menu',
'aria-label': formatMessage({id: 'agent.menuAriaLabel', defaultMessage: 'Select agent'}),
width: '240px',
onToggle: onMenuToggle,
}), [formatMessage, onMenuToggle]);
const menuButtonConfig = useMemo(() => ({
id: 'agent-dropdown-button',
'aria-label': formatMessage({id: 'agent.buttonAriaLabel', defaultMessage: 'Agent selector'}),
disabled,
class: 'agent-dropdown-button',
children: (
<>
<span className='agent-dropdown-button-text'>{displayName}</span>
<ChevronDownIcon size={12}/>
</>
),
}), [formatMessage, disabled, displayName]);
const menuHeaderElement = useMemo(() => (
<div className='agent-dropdown-menu-header'>
{formatMessage({id: 'agent.chooseBot', defaultMessage: 'CHOOSE A BOT'})}
</div>
), [formatMessage]);
return (
<div className='agent-dropdown'>
{showLabel && (
<span className='agent-dropdown-label'>
{formatMessage({id: 'agent.generateWith', defaultMessage: 'GENERATE WITH:'})}
</span>
)}
<Menu.Container
menu={menuConfig}
menuButton={menuButtonConfig}
menuHeader={menuHeaderElement}
>
{bots.map((bot) => {
const isDefault = bot.id === defaultBotId;
const isSelected = bot.id === selectedBotId;
const label = isDefault ? `${bot.displayName} (${formatMessage({id: 'agent.default', defaultMessage: 'default'})})` : bot.displayName;
return (
<Menu.Item
key={bot.id}
id={`agent-option-${bot.id}`}
data-testid={`agent-option-${bot.id}`}
leadingElement={
<Avatar
url={getBotAvatarUrl(bot.id)}
username={getBotUsername(bot)}
size='sm'
alt=''
/>
}
labels={<span>{label}</span>}
trailingElements={isSelected ? <CheckIcon size={16}/> : undefined}
onClick={handleBotClick(bot.id)}
/>
);
})}
</Menu.Container>
</div>
);
};
export default AgentDropdown;
@@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default as AgentDropdown} from './agent_dropdown';
@@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useEffect, useRef} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import type {WebSocketMessage} from '@mattermost/client';
import {getAgents as getAgentsAction} from 'mattermost-redux/actions/agents';
import {getAgents} from 'mattermost-redux/selectors/entities/agents';
import {SocketEvents} from 'utils/constants';
import {useWebSocket} from 'utils/use_websocket/hooks';
const AI_PLUGIN_ID = 'mattermost-ai';
const DEBOUNCE_DELAY_MS = 100; // Debounce refetches within 100ms
/**
* Hook to determine if the bridge is enabled by checking if there are available agents.
* This hook:
* - Fetches agents on mount
* - Returns true if agents are available, false otherwise
* - Listens to plugin enabled/disabled websocket events for the mattermost-ai plugin
* - Refetches agents when the mattermost-ai plugin is enabled or disabled
* - Refetches agents when the config changes to account for new agents being added
*/
export default function useGetAgentsBridgeEnabled(): boolean {
const dispatch = useDispatch();
const agents = useSelector(getAgents);
const hasFetchedRef = useRef(false);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
// Fetch agents on mount
useEffect(() => {
if (!hasFetchedRef.current) {
hasFetchedRef.current = true;
dispatch(getAgentsAction());
}
}, [dispatch]);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
// Debounced refetch to avoid duplicate fetches when multiple events fire in quick succession
const debouncedRefetch = useCallback(() => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
dispatch(getAgentsAction());
}, DEBOUNCE_DELAY_MS);
}, [dispatch]);
// Handle websocket events for plugin enabled/disabled and config changes
const handleWebSocketMessage = useCallback((msg: WebSocketMessage) => {
// Refetch on plugin enabled/disabled for mattermost-ai, or on any config change
// Note: When a plugin is enabled/disabled, the backend fires CONFIG_CHANGED first,
// then PLUGIN_ENABLED/PLUGIN_DISABLED. We debounce to avoid duplicate fetches.
const isPluginEvent =
(msg.event === SocketEvents.PLUGIN_ENABLED || msg.event === SocketEvents.PLUGIN_DISABLED) &&
msg.data?.manifest?.id === AI_PLUGIN_ID;
const isConfigChange = msg.event === SocketEvents.CONFIG_CHANGED;
if (isPluginEvent || isConfigChange) {
debouncedRefetch();
}
}, [debouncedRefetch]);
useWebSocket({handler: handleWebSocketMessage});
// Return true if agents list is not empty, false otherwise
return Boolean(agents && agents.length > 0);
}
+6
View File
@@ -3207,6 +3207,12 @@
"advanced_text_editor.remote_user_hour": "The time for {user} is {time}",
"advanced_textbox.max_length_error": "Text exceeds the maximum character limit of {maxLength} characters.",
"advanced_textbox.min_length_error": "Text must be at least {minLength} characters.",
"agent.buttonAriaLabel": "Agent selector",
"agent.chooseBot": "CHOOSE A BOT",
"agent.default": "default",
"agent.generateWith": "GENERATE WITH:",
"agent.menuAriaLabel": "Select agent",
"agent.selectBot": "Select a bot",
"air_gapped_contact_sales_modal.body": "Please access the link below to contact sales.",
"air_gapped_contact_sales_modal.title": "Looks like you do not have access to the internet",
"air_gapped_modal.close": "Close",
@@ -0,0 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import keyMirror from 'mattermost-redux/utils/key_mirror';
export default keyMirror({
RECEIVED_AGENTS: null,
AGENTS_REQUEST: null,
AGENTS_FAILURE: null,
});
@@ -4,6 +4,7 @@
import type {AnyAction} from 'redux';
import AdminTypes from './admin';
import AgentTypes from './agents';
import AppsTypes from './apps';
import BotTypes from './bots';
import ChannelBookmarkTypes from './channel_bookmarks';
@@ -65,6 +66,7 @@ export {
ScheduledPostTypes,
SharedChannelTypes,
ContentFlaggingTypes,
AgentTypes,
};
/**
@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {bindClientFunc} from './helpers';
import {AgentTypes} from '../action_types';
import {Client4} from '../client';
export function getAgents() {
return bindClientFunc({
clientFunc: Client4.getAgents,
onSuccess: [AgentTypes.RECEIVED_AGENTS],
onFailure: AgentTypes.AGENTS_FAILURE,
onRequest: AgentTypes.AGENTS_REQUEST,
});
}
@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {combineReducers} from 'redux';
import type {Agent} from '@mattermost/types/agents';
import type {MMReduxAction} from 'mattermost-redux/action_types';
import {AgentTypes} from '../../action_types';
export interface AgentsState {
agents: Agent[];
}
function agents(state: Agent[] = [], action: MMReduxAction): Agent[] {
switch (action.type) {
case AgentTypes.RECEIVED_AGENTS:
return action.data || [];
default:
return state;
}
}
export default combineReducers({
agents,
});
@@ -4,6 +4,7 @@
import {combineReducers} from 'redux';
import admin from './admin';
import agents from './agents';
import apps from './apps';
import bots from './bots';
import channelBookmarks from './channel_bookmarks';
@@ -34,6 +35,7 @@ import users from './users';
export default combineReducers({
general,
agents,
users,
limits,
teams,
@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Agent} from '@mattermost/types/agents';
import type {GlobalState} from '@mattermost/types/store';
export function getAgents(state: GlobalState): Agent[] {
return state.entities.agents?.agents;
}
export function getAgent(state: GlobalState, agentId: string): Agent | undefined {
const agents = getAgents(state);
return agents.find((agent) => agent.id === agentId);
}
@@ -7,6 +7,9 @@ import {zeroStateLimitedViews} from '../reducers/entities/posts';
const state: GlobalState = {
entities: {
agents: {
agents: [],
},
general: {
config: {},
license: {},
+17
View File
@@ -5,6 +5,7 @@
import type {AccessControlPolicy, CELExpressionError, AccessControlTestResult, AccessControlPoliciesResult, AccessControlPolicyChannelsResult, AccessControlVisualAST, AccessControlAttributes} from '@mattermost/types/access_control';
import type {ClusterInfo, AnalyticsRow, SchemaMigration, LogFilterQuery} from '@mattermost/types/admin';
import type {AgentsResponse} from '@mattermost/types/agents';
import type {AppBinding, AppCallRequest, AppCallResponse} from '@mattermost/types/apps';
import type {Audit} from '@mattermost/types/audits';
import type {UserAutocomplete, AutocompleteSuggestion} from '@mattermost/types/autocomplete';
@@ -455,6 +456,14 @@ export default class Client4 {
return `${this.getBaseRoute()}/plugins`;
}
getAgentsRoute() {
return `${this.getBaseRoute()}/agents`;
}
getLLMServicesRoute() {
return `${this.getBaseRoute()}/llmservices`;
}
getPluginRoute(pluginId: string) {
return `${this.getPluginsRoute()}/${pluginId}`;
}
@@ -3283,6 +3292,14 @@ export default class Client4 {
);
};
// Agent Routes
getAgents = () => {
return this.doFetch<AgentsResponse>(
`${this.getAgentsRoute()}`,
{method: 'get'},
);
};
getEnvironmentConfig = () => {
return this.doFetch<EnvironmentConfig>(
`${this.getBaseRoute()}/config/environment`,
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type Agent = {
id: string;
displayName: string;
username: string;
service_id: string;
service_type: string;
};
export type AgentsResponse = {
agents: Agent[];
};
export type LLMService = {
id: string;
name: string;
type: string;
};
export type ServicesResponse = {
services: LLMService[];
};
+9
View File
@@ -45,6 +45,15 @@ export type GlobalState = {
channelBookmarks: ChannelBookmarksState;
posts: PostsState;
threads: ThreadsState;
agents: {
agents: Array<{
id: string;
displayName: string;
username: string;
service_id: string;
service_type: string;
}>;
};
bots: {
accounts: Record<string, Bot>;
};