Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions api/v4/source/agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,32 @@
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/InternalServerError"
/api/v4/agents/status:
get:
tags:
- agents
summary: Get agents bridge status
description: >
Retrieve the status of the AI plugin bridge.
Returns availability boolean and a reason code if unavailable.

##### Permissions

Must be authenticated.

__Minimum server version__: 11.2
operationId: GetAgentsStatus
responses:
"200":
description: Status retrieved successfully
content:
application/json:
schema:
$ref: "#/components/schemas/AgentsIntegrityResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/InternalServerError"
/api/v4/llmservices:
get:
tags:
Expand Down
9 changes: 9 additions & 0 deletions api/v4/source/definitions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4048,6 +4048,15 @@ components:
items:
$ref: "#/components/schemas/BridgeServiceInfo"
description: List of available LLM services
AgentsIntegrityResponse:
type: object
properties:
available:
type: boolean
description: Whether the AI plugin bridge is available
reason:
type: string
description: Reason code if not available (translation ID)
PostAcknowledgement:
type: object
properties:
Expand Down
11 changes: 6 additions & 5 deletions e2e-tests/playwright/lib/src/server/default_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -844,14 +844,15 @@ const defaultServerConfig: AdminConfig = {
AutoTranslationSettings: {
Enable: false,
Provider: '',
TimeoutsMs: {
NewPost: 800,
Fetch: 2000,
Notification: 300,
},
LibreTranslate: {
URL: '',
APIKey: '',
},
TimeoutsMs: {
Short: 1200,
Medium: 2500,
Long: 6000,
Notification: 300,
},
},
};
21 changes: 21 additions & 0 deletions server/channels/api4/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,31 @@ import (
func (api *API) InitAgents() {
// GET /api/v4/agents
api.BaseRoutes.Agents.Handle("", api.APISessionRequired(getAgents)).Methods(http.MethodGet)
// GET /api/v4/agents/status
api.BaseRoutes.Agents.Handle("/status", api.APISessionRequired(getAgentsStatus)).Methods(http.MethodGet)
// GET /api/v4/llmservices
api.BaseRoutes.LLMServices.Handle("", api.APISessionRequired(getLLMServices)).Methods(http.MethodGet)
}

func getAgentsStatus(c *Context, w http.ResponseWriter, r *http.Request) {
available, reason := c.App.GetAIPluginBridgeStatus(c.AppContext)

resp := &model.AgentsIntegrityResponse{
Available: available,
Reason: reason,
}

jsonData, err := json.Marshal(resp)
if err != nil {
c.Err = model.NewAppError("Api4.getAgentsStatus", "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 getAgents(c *Context, w http.ResponseWriter, r *http.Request) {
agents, appErr := c.App.GetAgents(c.AppContext, c.AppContext.Session().UserId)
if appErr != nil {
Expand Down
31 changes: 16 additions & 15 deletions server/channels/app/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,26 @@ const (
minAIPluginVersionForBridge = "1.5.0"
)

// getBridgeClient returns a bridge client for making requests to the plugin bridge API
func (a *App) getBridgeClient(userID string) *agentclient.Client {
// 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)
}

// isAIPluginBridgeAvailable checks if the mattermost-ai plugin is active and supports the bridge API (v1.5.0+)
func (a *App) isAIPluginBridgeAvailable(rctx request.CTX) bool {
// GetAIPluginBridgeStatus checks if the mattermost-ai plugin is active and supports the bridge API (v1.5.0+)
// It returns a boolean indicating availability, and a reason string (translation ID) if unavailable.
func (a *App) GetAIPluginBridgeStatus(rctx request.CTX) (bool, string) {
pluginsEnvironment := a.GetPluginsEnvironment()
if pluginsEnvironment == nil {
rctx.Logger().Debug("AI plugin bridge not available - plugin environment not initialized")
return false
return false, "app.agents.bridge.not_available.plugin_env_not_initialized"
}

// Check if plugin is active
if !pluginsEnvironment.IsActive(aiPluginID) {
rctx.Logger().Debug("AI plugin bridge not available - plugin is not active or not installed",
mlog.String("plugin_id", aiPluginID),
)
return false
return false, "app.agents.bridge.not_available.plugin_not_active"
}

// Get the plugin's manifest to check version
Expand All @@ -51,12 +52,12 @@ func (a *App) isAIPluginBridgeAvailable(rctx request.CTX) bool {
mlog.String("version", plugin.Manifest.Version),
mlog.Err(err),
)
return false
return false, "app.agents.bridge.not_available.plugin_version_parse_failed"
}

minVersion, err := semver.Parse(minAIPluginVersionForBridge)
if err != nil {
return false
return false, "app.agents.bridge.not_available.min_version_parse_failed"
}

if pluginVersion.LT(minVersion) {
Expand All @@ -65,20 +66,20 @@ func (a *App) isAIPluginBridgeAvailable(rctx request.CTX) bool {
mlog.String("current_version", plugin.Manifest.Version),
mlog.String("minimum_version", minAIPluginVersionForBridge),
)
return false
return false, "app.agents.bridge.not_available.plugin_version_too_old"
}

return true
return true, ""
}
}

return false
return false, "app.agents.bridge.not_available.plugin_not_registered"
}

// GetAgents retrieves all available agents from the bridge API
func (a *App) GetAgents(rctx request.CTX, userID string) ([]agentclient.BridgeAgentInfo, *model.AppError) {
// Check if the AI plugin is active and supports the bridge API (v1.5.0+)
if !a.isAIPluginBridgeAvailable(rctx) {
if available, _ := a.GetAIPluginBridgeStatus(rctx); !available {
return []agentclient.BridgeAgentInfo{}, nil
}

Expand All @@ -87,7 +88,7 @@ func (a *App) GetAgents(rctx request.CTX, userID string) ([]agentclient.BridgeAg
if session := rctx.Session(); session != nil {
sessionUserID = session.UserId
}
client := a.getBridgeClient(sessionUserID)
client := a.GetBridgeClient(sessionUserID)

agents, err := client.GetAgents(userID)
if err != nil {
Expand Down Expand Up @@ -133,7 +134,7 @@ func (a *App) GetUsersForAgents(rctx request.CTX, userID string) ([]*model.User,
// GetLLMServices retrieves all available LLM services from the bridge API
func (a *App) GetLLMServices(rctx request.CTX, userID string) ([]agentclient.BridgeServiceInfo, *model.AppError) {
// Check if the AI plugin is active and supports the bridge API (v1.5.0+)
if !a.isAIPluginBridgeAvailable(rctx) {
if available, _ := a.GetAIPluginBridgeStatus(rctx); !available {
return []agentclient.BridgeServiceInfo{}, nil
}

Expand All @@ -142,7 +143,7 @@ func (a *App) GetLLMServices(rctx request.CTX, userID string) ([]agentclient.Bri
if session := rctx.Session(); session != nil {
sessionUserID = session.UserId
}
client := a.getBridgeClient(sessionUserID)
client := a.GetBridgeClient(sessionUserID)

services, err := client.GetServices(userID)
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions server/channels/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ func (a *App) Metrics() einterfaces.MetricsInterface {
func (a *App) Notification() einterfaces.NotificationInterface {
return a.ch.Notification
}
func (a *App) AutoTranslation() einterfaces.AutoTranslationInterface {
return a.Srv().AutoTranslation
}
func (a *App) Saml() einterfaces.SamlInterface {
return a.ch.Saml
}
Expand Down
9 changes: 9 additions & 0 deletions server/channels/app/enterprise.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ func RegisterPushProxyInterface(f func(*App) einterfaces.PushProxyInterface) {
pushProxyInterface = f
}

var autoTranslationInterface func(*Server) einterfaces.AutoTranslationInterface

func RegisterAutoTranslationInterface(f func(*Server) einterfaces.AutoTranslationInterface) {
autoTranslationInterface = f
}

var intuneInterface func(*App) einterfaces.IntuneInterface

func RegisterIntuneInterface(f func(*App) einterfaces.IntuneInterface) {
Expand All @@ -126,4 +132,7 @@ func (s *Server) initEnterprise() {
if cloudInterface != nil {
s.Cloud = cloudInterface(s)
}
if autoTranslationInterface != nil {
s.AutoTranslation = autoTranslationInterface(s)
}
}
50 changes: 49 additions & 1 deletion server/channels/app/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,30 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan
// so we just return the one that was passed with post
rpost = a.PreparePostForClient(rctx, rpost, &model.PreparePostForClientOpts{IsEditPost: true})

// Initialize translations for the post before sending WebSocket events
// This ensures translation metadata is included in the 'posted' event
// Check if auto-translation is available before making database calls
if a.AutoTranslation() != nil && a.AutoTranslation().IsFeatureAvailable() {
enabled, atErr := a.AutoTranslation().IsChannelEnabled(rpost.ChannelId)
if atErr == nil && enabled {
_, translateErr := a.AutoTranslation().Translate(rctx.Context(), model.TranslationObjectTypePost, rpost.Id, rpost.ChannelId, rpost.UserId, rpost)
if translateErr != nil {
var notAvailErr *model.ErrAutoTranslationNotAvailable
if errors.As(translateErr, &notAvailErr) {
// Feature not available - log at debug level and continue
rctx.Logger().Debug("Auto-translation feature not available", mlog.String("post_id", rpost.Id), mlog.Err(translateErr))
} else if translateErr.Id == "ent.autotranslation.no_translatable_content" {
// No translatable content (only URLs/mentions) - this is expected, don't log
} else {
// Unexpected error - log at warn level but don't fail post creation
rctx.Logger().Warn("Failed to translate post", mlog.String("post_id", rpost.Id), mlog.Err(translateErr))
}
}
} else if atErr != nil {
rctx.Logger().Warn("Failed to check if channel is enabled for auto-translation", mlog.String("channel_id", rpost.ChannelId), mlog.Err(atErr))
}
}

a.applyPostWillBeConsumedHook(&rpost)

if rpost.RootId != "" {
Expand Down Expand Up @@ -890,6 +914,30 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda
return nil, false, model.NewAppError("UpdatePost", "app.post.update.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}

// Re-translate post if content changed
// Our updated Translate() function detects content changes via NormHash comparison
// and automatically re-initializes translations for all configured languages
if a.AutoTranslation() != nil && a.AutoTranslation().IsFeatureAvailable() {
enabled, atErr := a.AutoTranslation().IsChannelEnabled(rpost.ChannelId)
if atErr == nil && enabled {
_, translateErr := a.AutoTranslation().Translate(rctx.Context(), model.TranslationObjectTypePost, rpost.Id, rpost.ChannelId, rpost.UserId, rpost)
if translateErr != nil {
var notAvailErr *model.ErrAutoTranslationNotAvailable
if errors.As(translateErr, &notAvailErr) {
// Feature not available - log at debug level and continue
rctx.Logger().Debug("Auto-translation feature not available for edited post", mlog.String("post_id", rpost.Id), mlog.Err(translateErr))
} else if translateErr.Id == "ent.autotranslation.no_translatable_content" {
// No translatable content (only URLs/mentions) - this is expected, don't log
} else {
// Unexpected error - log at warn level but don't fail post update
rctx.Logger().Warn("Failed to translate edited post", mlog.String("post_id", rpost.Id), mlog.Err(translateErr))
}
}
} else if atErr != nil {
rctx.Logger().Warn("Failed to check if channel is enabled for auto-translation", mlog.String("channel_id", rpost.ChannelId), mlog.Err(atErr))
}
}

message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", rpost.ChannelId, "", nil, "")

appErr = a.publishWebsocketEventForPost(rctx, rpost, message)
Expand Down Expand Up @@ -3046,7 +3094,7 @@ func (a *App) RewriteMessage(
}

// Prepare completion request in the format expected by the client
client := a.getBridgeClient(rctx.Session().UserId)
client := a.GetBridgeClient(rctx.Session().UserId)
completionRequest := agentclient.CompletionRequest{
Posts: []agentclient.Post{
{Role: "system", Message: model.RewriteSystemPrompt},
Expand Down
71 changes: 71 additions & 0 deletions server/channels/app/post_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,80 @@ func (a *App) PreparePostListForClient(rctx request.CTX, originalList *model.Pos
}
}

a.populatePostListTranslations(rctx, list)

return list
}

// populatePostListTranslations fetches and populates translation metadata for posts that don't already have it.
// Posts from WebSocket broadcasts (post_created) already have translations populated.
// This function handles API requests (GetPostsForChannel, etc.) by fetching only the user's language.
func (a *App) populatePostListTranslations(rctx request.CTX, list *model.PostList) {
// Check if auto-translation is available before making database calls
if a.AutoTranslation() == nil || !a.AutoTranslation().IsFeatureAvailable() {
return
}

userID := rctx.Session().UserId

// Check which posts need translation data populated
postsNeedingTranslations := make(map[string][]string) // channelID -> postIDs

for _, post := range list.Posts {
// Skip if translations already populated (e.g., from CreatePost)
if post.Metadata != nil && len(post.Metadata.Translations) > 0 {
continue
}

postsNeedingTranslations[post.ChannelId] = append(postsNeedingTranslations[post.ChannelId], post.Id)
}

if len(postsNeedingTranslations) == 0 {
return // All posts already have translations
}

// For API requests, fetch only the user's language translation
for channelID, postIDs := range postsNeedingTranslations {
userLang, err := a.AutoTranslation().GetUserLanguage(userID, channelID)
if err != nil {
var notAvailErr *model.ErrAutoTranslationNotAvailable
if !errors.As(err, &notAvailErr) {
// Log non-availability errors
rctx.Logger().Warn("Failed to get user language for auto-translation", mlog.String("channel_id", channelID), mlog.Err(err))
}
continue
}
if userLang == "" {
continue
}

translationsMap, err := a.AutoTranslation().GetBatch(postIDs, userLang)
if err != nil {
var notAvailErr *model.ErrAutoTranslationNotAvailable
if errors.As(err, &notAvailErr) {
rctx.Logger().Debug("Auto-translation feature not available during GetBatch", mlog.Err(err))
} else {
// Real error - log it
rctx.Logger().Warn("Failed to fetch translations batch", mlog.Err(err))
}
continue
}

// Populate each post's metadata
for postID, t := range translationsMap {
post := list.Posts[postID]
if post.Metadata == nil {
post.Metadata = &model.PostMetadata{}
}
if post.Metadata.Translations == nil {
post.Metadata.Translations = make(map[string]*model.PostTranslation)
}

post.Metadata.Translations[t.Lang] = t.ToPostTranslation()
}
}
}

// OverrideIconURLIfEmoji changes the post icon override URL prop, if it has an emoji icon,
// so that it points to the URL (relative) of the emoji - static if emoji is default, /api if custom.
func (a *App) OverrideIconURLIfEmoji(rctx request.CTX, post *model.Post) {
Expand Down
Loading
Loading