mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-07 03:35:08 -05:00
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:
co-authored by
Christopher Speller
Mattermost Build
parent
3b250ba5c4
commit
1ba3535a0e
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user