From 1cbf3b7722d45365c1251190473d400102cc5782 Mon Sep 17 00:00:00 2001 From: Anup Cowkur Date: Fri, 26 Sep 2025 22:12:30 +0530 Subject: [PATCH] feat(frontend): implement Measure AI chat & MCP server closes #2725 --- backend/api/main.go | 35 + backend/api/measure/auth.go | 167 + backend/api/measure/event.go | 145 +- backend/api/measure/mcp_apikey.go | 406 + backend/api/measure/session.go | 19 +- backend/api/measure/usage.go | 184 +- docs/CONTRIBUTING.md | 3 + docs/README.md | 1 + docs/api/dashboard/README.md | 261 +- docs/features/feature-ai.md | 28 + docs/hosting/README.md | 6 + docs/hosting/ai.md | 36 + .../danger_confirmation_dialog_test.tsx.snap | 107 +- .../danger_confirmation_dialog_test.tsx | 108 +- .../__tests__/pages/alerts_overview_test.tsx | 30 +- .../pages/bug_reports_overview_test.tsx | 30 +- .../pages/exception_details_test.tsx | 412 + .../pages/exceptions_overview_test.tsx | 58 +- .../__tests__/pages/overview_test.tsx | 14 + .../pages/sessions_overview_test.tsx | 31 +- .../__tests__/pages/traces_overview_test.tsx | 30 +- .../__tests__/utils/number_utils.test.ts | 8 +- .../dashboard/app/[teamId]/alerts/page.tsx | 38 +- frontend/dashboard/app/[teamId]/apps/page.tsx | 1 + .../[appId]/[bugReportId]/page.tsx | 43 +- .../app/[teamId]/bug_reports/page.tsx | 46 +- .../dashboard/app/[teamId]/journeys/page.tsx | 1 + frontend/dashboard/app/[teamId]/layout.tsx | 246 +- frontend/dashboard/app/[teamId]/mcp/page.tsx | 154 + .../dashboard/app/[teamId]/overview/page.tsx | 13 + .../sessions/[appId]/[sessionId]/page.tsx | 20 +- .../dashboard/app/[teamId]/sessions/page.tsx | 45 +- frontend/dashboard/app/[teamId]/team/page.tsx | 1 + .../traces/[appId]/[traceId]/page.tsx | 39 +- .../dashboard/app/[teamId]/traces/page.tsx | 53 +- .../dashboard/app/[teamId]/usage/page.tsx | 59 +- frontend/dashboard/app/ai/chat/route.ts | 182 + .../app/ai/embeddings/docs-embeddings.json | 1 + .../dashboard/app/ai/mcp/[transport]/route.ts | 67 + .../dashboard/app/ai/mcp/report_ai_usage.ts | 46 + frontend/dashboard/app/ai/mcp/tools.ts | 1108 ++ .../dashboard/app/ai/mcp/validate_mcp_key.ts | 19 + frontend/dashboard/app/ai/rag.ts | 95 + frontend/dashboard/app/api/api_calls.ts | 139 +- .../app/components/ai-elements/actions.tsx | 60 + .../app/components/ai-elements/artifact.tsx | 143 + .../app/components/ai-elements/branch.tsx | 213 + .../ai-elements/chain-of-thought.tsx | 221 + .../app/components/ai-elements/code-block.tsx | 148 + .../app/components/ai-elements/context.tsx | 403 + .../components/ai-elements/conversation.tsx | 97 + .../app/components/ai-elements/image.tsx | 24 + .../ai-elements/inline-citation.tsx | 278 + .../app/components/ai-elements/loader.tsx | 96 + .../app/components/ai-elements/message.tsx | 77 + .../components/ai-elements/open-in-chat.tsx | 318 + .../components/ai-elements/prompt-input.tsx | 718 + .../app/components/ai-elements/reasoning.tsx | 173 + .../app/components/ai-elements/response.tsx | 22 + .../app/components/ai-elements/sources.tsx | 73 + .../app/components/ai-elements/suggestion.tsx | 53 + .../app/components/ai-elements/task.tsx | 83 + .../app/components/ai-elements/tool.tsx | 152 + .../components/ai-elements/web-preview.tsx | 257 + frontend/dashboard/app/components/ai_chat.tsx | 439 + frontend/dashboard/app/components/avatar.tsx | 53 + frontend/dashboard/app/components/badge.tsx | 46 + .../dashboard/app/components/carousel.tsx | 240 + .../dashboard/app/components/collapsible.tsx | 33 + .../app/components/copy_ai_context.tsx | 68 +- .../app/components/create_mcp_key.tsx | 91 + .../components/danger_confirmation_dialog.tsx | 39 +- .../app/components/dropdown_menu.tsx | 14 +- .../app/components/exceptions_details.tsx | 65 +- .../app/components/exceptions_overview.tsx | 41 +- .../dashboard/app/components/hover-card.tsx | 45 + frontend/dashboard/app/components/journey.tsx | 18 +- .../dashboard/app/components/progress.tsx | 31 + .../dashboard/app/components/scroll-area.tsx | 58 + frontend/dashboard/app/components/select.tsx | 186 + .../dashboard/app/components/textarea.tsx | 18 + .../dashboard/app/context/ai_chat_context.tsx | 41 + frontend/dashboard/app/posthog-server.js | 1 - frontend/dashboard/app/utils/number_utils.ts | 18 +- frontend/dashboard/package-lock.json | 14908 +++++++++++----- frontend/dashboard/package.json | 34 +- .../scripts/generate-docs-embeddings.ts | 183 + ...20251006095203_create_ai_metrics_table.sql | 18 + self-host/compose.yml | 3 +- self-host/config.sh | 16 + .../20251024141518_create_mcp_keys_table.sql | 27 + self-host/sessionator/cmd/rm.go | 8 + 92 files changed, 19733 insertions(+), 5153 deletions(-) create mode 100644 backend/api/measure/mcp_apikey.go create mode 100644 docs/features/feature-ai.md create mode 100644 docs/hosting/ai.md create mode 100644 frontend/dashboard/app/[teamId]/mcp/page.tsx create mode 100644 frontend/dashboard/app/ai/chat/route.ts create mode 100644 frontend/dashboard/app/ai/embeddings/docs-embeddings.json create mode 100644 frontend/dashboard/app/ai/mcp/[transport]/route.ts create mode 100644 frontend/dashboard/app/ai/mcp/report_ai_usage.ts create mode 100644 frontend/dashboard/app/ai/mcp/tools.ts create mode 100644 frontend/dashboard/app/ai/mcp/validate_mcp_key.ts create mode 100644 frontend/dashboard/app/ai/rag.ts create mode 100644 frontend/dashboard/app/components/ai-elements/actions.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/artifact.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/branch.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/chain-of-thought.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/code-block.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/context.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/conversation.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/image.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/inline-citation.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/loader.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/message.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/open-in-chat.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/prompt-input.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/reasoning.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/response.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/sources.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/suggestion.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/task.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/tool.tsx create mode 100644 frontend/dashboard/app/components/ai-elements/web-preview.tsx create mode 100644 frontend/dashboard/app/components/ai_chat.tsx create mode 100644 frontend/dashboard/app/components/avatar.tsx create mode 100644 frontend/dashboard/app/components/badge.tsx create mode 100644 frontend/dashboard/app/components/carousel.tsx create mode 100644 frontend/dashboard/app/components/collapsible.tsx create mode 100644 frontend/dashboard/app/components/create_mcp_key.tsx create mode 100644 frontend/dashboard/app/components/hover-card.tsx create mode 100644 frontend/dashboard/app/components/progress.tsx create mode 100644 frontend/dashboard/app/components/scroll-area.tsx create mode 100644 frontend/dashboard/app/components/select.tsx create mode 100644 frontend/dashboard/app/components/textarea.tsx create mode 100644 frontend/dashboard/app/context/ai_chat_context.tsx create mode 100644 frontend/dashboard/scripts/generate-docs-embeddings.ts create mode 100644 self-host/clickhouse/20251006095203_create_ai_metrics_table.sql create mode 100644 self-host/postgres/20251024141518_create_mcp_keys_table.sql diff --git a/backend/api/main.go b/backend/api/main.go index f6ad25fa1..09f889981 100644 --- a/backend/api/main.go +++ b/backend/api/main.go @@ -137,6 +137,41 @@ func main() { teams.PATCH(":id/slack/status", measure.UpdateTeamSlackStatus) } + mcp := r.Group("/mcp") + { + mcp.GET("teams/:id/keys", measure.ValidateAccessToken(), measure.GetMcpKeys) + mcp.POST("teams/:id/keys", measure.ValidateAccessToken(), measure.CreateMcpKey) + mcp.PATCH("teams/:id/keys/:keyId/revoke", measure.ValidateAccessToken(), measure.RevokeMcpKey) + mcp.GET("keys/teamId", measure.ValidateMcpKey(), measure.GetUserAndTeamIdForMcpKey) + mcp.POST("teams/:id/usage/ai", measure.ValidateAccessTokenOrMcpKey(), measure.ReportAiUsage) + mcp.GET("user", measure.ValidateAccessTokenOrMcpKey(), measure.GetUser) + mcp.GET("teams/:id/apps", measure.ValidateAccessTokenOrMcpKey(), measure.GetTeamApps) + mcp.GET("apps/:id/journey", measure.ValidateAccessTokenOrMcpKey(), measure.GetAppJourney) + mcp.GET("apps/:id/metrics", measure.ValidateAccessTokenOrMcpKey(), measure.GetAppMetrics) + mcp.GET("apps/:id/filters", measure.ValidateAccessTokenOrMcpKey(), measure.GetAppFilters) + mcp.GET("apps/:id/crashGroups", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashOverview) + mcp.GET("apps/:id/crashGroups/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashOverviewPlotInstances) + mcp.GET("apps/:id/crashGroups/:crashGroupId/crashes", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashDetailCrashes) + mcp.GET("apps/:id/crashGroups/:crashGroupId/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashDetailPlotInstances) + mcp.GET("apps/:id/crashGroups/:crashGroupId/plots/distribution", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashDetailAttributeDistribution) + mcp.GET("apps/:id/anrGroups", measure.ValidateAccessTokenOrMcpKey(), measure.GetANROverview) + mcp.GET("apps/:id/anrGroups/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetANROverviewPlotInstances) + mcp.GET("apps/:id/anrGroups/:anrGroupId/anrs", measure.ValidateAccessTokenOrMcpKey(), measure.GetANRDetailANRs) + mcp.GET("apps/:id/anrGroups/:anrGroupId/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetANRDetailPlotInstances) + mcp.GET("apps/:id/anrGroups/:anrGroupId/plots/distribution", measure.ValidateAccessTokenOrMcpKey(), measure.GetANRDetailAttributeDistribution) + mcp.GET("apps/:id/sessions", measure.ValidateAccessTokenOrMcpKey(), measure.GetSessionsOverview) + mcp.GET("apps/:id/sessions/:sessionId", measure.ValidateAccessTokenOrMcpKey(), measure.GetSession) + mcp.GET("apps/:id/sessions/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetSessionsOverviewPlotInstances) + mcp.GET("apps/:id/spans/roots/names", measure.ValidateAccessTokenOrMcpKey(), measure.GetRootSpanNames) + mcp.GET("apps/:id/spans", measure.ValidateAccessTokenOrMcpKey(), measure.GetSpansForSpanName) + mcp.GET("apps/:id/spans/plots/metrics", measure.ValidateAccessTokenOrMcpKey(), measure.GetMetricsPlotForSpanName) + mcp.GET("apps/:id/traces/:traceId", measure.ValidateAccessTokenOrMcpKey(), measure.GetTrace) + mcp.GET("apps/:id/bugReports", measure.ValidateAccessTokenOrMcpKey(), measure.GetBugReportsOverview) + mcp.GET("apps/:id/bugReports/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetBugReportsInstancesPlot) + mcp.GET("apps/:id/bugReports/:bugReportId", measure.ValidateAccessTokenOrMcpKey(), measure.GetBugReport) + mcp.GET("apps/:id/alerts", measure.ValidateAccessTokenOrMcpKey(), measure.GetAlertsOverview) + } + slack := r.Group("/slack") { slack.POST("/connect", measure.ConnectTeamSlack) diff --git a/backend/api/measure/auth.go b/backend/api/measure/auth.go index 3a454d6db..3a932a087 100644 --- a/backend/api/measure/auth.go +++ b/backend/api/measure/auth.go @@ -56,6 +56,27 @@ func extractToken(c *gin.Context) (token string) { return } +// extractMcpKey extracts the MCP key +// from the Authorization header. +func extractMcpKey(c *gin.Context) (token string) { + authHeader := c.GetHeader("Authorization") + splitToken := strings.Split(authHeader, "Bearer ") + + if len(splitToken) != 2 { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + token = strings.TrimSpace(splitToken[1]) + + if token == "" { + c.AbortWithStatus((http.StatusUnauthorized)) + return + } + + return +} + // extractRefreshToken extracts the refresh token // from the cookie or Authorization header func extractRefreshToken(c *gin.Context) (token string) { @@ -145,6 +166,100 @@ func ValidateAccessToken() gin.HandlerFunc { } } +// ValidateMcpKey validates the Measure MCP key. +func ValidateMcpKey() gin.HandlerFunc { + return func(c *gin.Context) { + key := extractMcpKey(c) + + userId, teamId, err := DecodeMcpKey(c, key) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "invalid MCP key", + }) + return + } + + if userId == nil { + msg := "no user found for this MCP key" + fmt.Println(msg) + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": msg}) + return + } + + if teamId == nil { + msg := "no team found for this MCP key" + fmt.Println(msg) + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": msg}) + return + } + + c.Set("userId", userId.String()) + c.Set("teamId", teamId.String()) + c.Next() + } +} + +// ValidateAccessTokenOrMcpKey validates the Measure access token or MCP key. If either one succeeds, the request is allowed. +func ValidateAccessTokenOrMcpKey() gin.HandlerFunc { + return func(c *gin.Context) { + token := extractToken(c) + + // Try access token validation first + accessToken, err := jwt.Parse(token, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + err := fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + return nil, err + } + + return server.Server.Config.AccessTokenSecret, nil + }) + + // If access token is valid, use it + if err == nil { + if claims, ok := accessToken.Claims.(jwt.MapClaims); ok { + sessionId := claims["jti"] + c.Set("sessionId", sessionId) + + userId := claims["sub"] + c.Set("userId", userId) + + c.Next() + return + } + } + + // Access token failed, try MCP key + key := extractMcpKey(c) + userId, teamId, err := DecodeMcpKey(c, key) + if err != nil { + // Both validations failed + fmt.Println("both access token and MCP key validation failed") + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "invalid access token or MCP key", + }) + return + } + + if userId == nil { + msg := "no user found for this MCP key" + fmt.Println(msg) + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": msg}) + return + } + + if teamId == nil { + msg := "no team found for this MCP key" + fmt.Println(msg) + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": msg}) + return + } + + c.Set("userId", userId.String()) + c.Set("teamId", teamId.String()) + c.Next() + } +} + // ValidateRefreshToken validates the Measure refresh token. func ValidateRefreshToken() gin.HandlerFunc { return func(c *gin.Context) { @@ -967,6 +1082,58 @@ func RefreshToken(c *gin.Context) { }) } +// GetUser returns the current user information +func GetUser(c *gin.Context) { + userId := c.GetString("userId") + + ctx := c.Request.Context() + + if userId == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "Not authenticated", + }) + return + } + + user := &User{ + ID: &userId, + } + + ownTeam, err := user.getOwnTeam(ctx) + if err != nil { + msg := "Unable to get user's team" + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": msg, + }) + return + } + + err = user.getUserDetails(ctx) + + if err != nil { + msg := "Unable to get user details" + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": msg, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "user": gin.H{ + "id": userId, + "own_team_id": ownTeam.ID, + "name": user.Name, + "email": user.Email, + "confirmed_at": user.ConfirmedAt, + "last_sign_in_at": user.LastSignInAt, + "created_at": user.CreatedAt, + "updated_at": user.UpdatedAt, + }, + }) +} + // GetSession returns the current session information func GetAuthSession(c *gin.Context) { userId := c.GetString("userId") diff --git a/backend/api/measure/event.go b/backend/api/measure/event.go index 6a7542266..5b1586cd7 100644 --- a/backend/api/measure/event.go +++ b/backend/api/measure/event.go @@ -1527,16 +1527,6 @@ func GetExceptionsWithFilter(ctx context.Context, group *group.ExceptionGroup, a return } - selectedVersions, err := af.VersionPairs() - if err != nil { - return - } - - selectedOSVersions, err := af.OSVersionPairs() - if err != nil { - return - } - timeformat := "2006-01-02T15:04:05.000" var keyTimestamp string if !af.KeyTimestamp.IsZero() { @@ -1560,19 +1550,56 @@ func GetExceptionsWithFilter(ctx context.Context, group *group.ExceptionGroup, a Select("exception.framework framework"). Select("attachments"). Select(fmt.Sprintf("row_number() over (order by timestamp %s, id) as row_num", order)). - Clause(prewhere, af.AppID, group.ID). - Where("(attribute.app_version, attribute.app_build) in (?)", selectedVersions.Parameterize()). - Where("(attribute.os_name, attribute.os_version) in (?)", selectedOSVersions.Parameterize()). - Where("type = ?", event.TypeException). - Where("exception.handled = false"). - Where("inet.country_code in ?", af.Countries). - Where("attribute.device_name in ?", af.DeviceNames). - Where("attribute.device_manufacturer in ?", af.DeviceManufacturers). - Where("attribute.device_locale in ?", af.Locales). - Where("attribute.network_type in ?", af.NetworkTypes). - Where("attribute.network_provider in ?", af.NetworkProviders). - Where("attribute.network_generation in ?", af.NetworkGenerations). - Where("timestamp >= ? and timestamp <= ?", af.From, af.To) + Clause(prewhere, af.AppID, group.ID) + + if len(af.Versions) > 0 { + substmt.Where("attribute.app_version").In(af.Versions) + } + + if len(af.VersionCodes) > 0 { + substmt.Where("attribute.app_build").In(af.VersionCodes) + } + + if len(af.OsNames) > 0 { + substmt.Where("attribute.os_name").In(af.OsNames) + } + + if len(af.OsVersions) > 0 { + substmt.Where("attribute.os_version").In(af.OsVersions) + } + + substmt.Where("type = ?", event.TypeException). + Where("exception.handled = false") + + if len(af.Countries) > 0 { + substmt.Where("inet.country_code in ?", af.Countries) + } + + if len(af.DeviceNames) > 0 { + substmt.Where("attribute.device_name in ?", af.DeviceNames) + } + + if len(af.DeviceManufacturers) > 0 { + substmt.Where("attribute.device_manufacturer in ?", af.DeviceManufacturers) + } + + if len(af.Locales) > 0 { + substmt.Where("attribute.device_locale in ?", af.Locales) + } + + if len(af.NetworkTypes) > 0 { + substmt.Where("attribute.network_type in ?", af.NetworkTypes) + } + + if len(af.NetworkProviders) > 0 { + substmt.Where("attribute.network_provider in ?", af.NetworkProviders) + } + + if len(af.NetworkGenerations) > 0 { + substmt.Where("attribute.network_generation in ?", af.NetworkGenerations) + } + + substmt.Where("timestamp >= ? and timestamp <= ?", af.From, af.To) if af.HasUDExpression() && !af.UDExpression.Empty() { subQuery := sqlf.From("user_def_attrs"). @@ -1800,16 +1827,6 @@ func GetANRsWithFilter(ctx context.Context, group *group.ANRGroup, af *filter.Ap return } - selectedVersions, err := af.VersionPairs() - if err != nil { - return - } - - selectedOSVersions, err := af.OSVersionPairs() - if err != nil { - return - } - timeformat := "2006-01-02T15:04:05.000" var keyTimestamp string if !af.KeyTimestamp.IsZero() { @@ -1832,18 +1849,56 @@ func GetANRsWithFilter(ctx context.Context, group *group.ANRGroup, af *filter.Ap Select("anr.threads threads"). Select("attachments"). Select(fmt.Sprintf("row_number() over (order by timestamp %s, id) as row_num", order)). - Clause(prewhere, af.AppID, group.ID). - Where("(attribute.app_version, attribute.app_build) in (?)", selectedVersions.Parameterize()). - Where("(attribute.os_name, attribute.os_version) in (?)", selectedOSVersions.Parameterize()). - Where("type = ?", event.TypeANR). - Where("inet.country_code in ?", af.Countries). - Where("attribute.device_name in ?", af.DeviceNames). - Where("attribute.device_manufacturer in ?", af.DeviceManufacturers). - Where("attribute.device_locale in ?", af.Locales). - Where("attribute.network_type in ?", af.NetworkTypes). - Where("attribute.network_provider in ?", af.NetworkProviders). - Where("attribute.network_generation in ?", af.NetworkGenerations). - Where("timestamp >= ? and timestamp <= ?", af.From, af.To) + Clause(prewhere, af.AppID, group.ID) + + if len(af.Versions) > 0 { + substmt.Where("attribute.app_version").In(af.Versions) + } + + if len(af.VersionCodes) > 0 { + substmt.Where("attribute.app_build").In(af.VersionCodes) + } + + if len(af.OsNames) > 0 { + substmt.Where("attribute.os_name").In(af.OsNames) + } + + if len(af.OsVersions) > 0 { + substmt.Where("attribute.os_version").In(af.OsVersions) + } + + substmt.Where("type = ?", event.TypeANR). + Where("anr.handled = false") + + if len(af.Countries) > 0 { + substmt.Where("inet.country_code in ?", af.Countries) + } + + if len(af.DeviceNames) > 0 { + substmt.Where("attribute.device_name in ?", af.DeviceNames) + } + + if len(af.DeviceManufacturers) > 0 { + substmt.Where("attribute.device_manufacturer in ?", af.DeviceManufacturers) + } + + if len(af.Locales) > 0 { + substmt.Where("attribute.device_locale in ?", af.Locales) + } + + if len(af.NetworkTypes) > 0 { + substmt.Where("attribute.network_type in ?", af.NetworkTypes) + } + + if len(af.NetworkProviders) > 0 { + substmt.Where("attribute.network_provider in ?", af.NetworkProviders) + } + + if len(af.NetworkGenerations) > 0 { + substmt.Where("attribute.network_generation in ?", af.NetworkGenerations) + } + + substmt.Where("timestamp >= ? and timestamp <= ?", af.From, af.To) if af.HasUDExpression() && !af.UDExpression.Empty() { subQuery := sqlf.From("user_def_attrs"). diff --git a/backend/api/measure/mcp_apikey.go b/backend/api/measure/mcp_apikey.go new file mode 100644 index 000000000..8be1ca5e4 --- /dev/null +++ b/backend/api/measure/mcp_apikey.go @@ -0,0 +1,406 @@ +package measure + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "backend/api/chrono" + "backend/api/cipher" + "backend/api/server" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/leporo/sqlf" +) + +const McpKeyPrefix = "msrsh_mcp" + +type McpKey struct { + Id uuid.UUID + userId uuid.UUID + teamId uuid.UUID + name string + keyPrefix string + keyValue string + checksum string + revoked bool + lastSeen time.Time + createdAt time.Time +} + +func (a McpKey) MarshalJSON() ([]byte, error) { + apiMap := make(map[string]any) + + apiMap["id"] = a.Id + apiMap["key"] = a.String() + apiMap["name"] = a.name + apiMap["revoked"] = a.revoked + apiMap["created_at"] = a.createdAt.Format(chrono.ISOFormatJS) + if a.lastSeen.IsZero() { + apiMap["last_seen"] = nil + } else { + apiMap["last_seen"] = a.lastSeen.Format(chrono.ISOFormatJS) + } + return json.Marshal(apiMap) +} + +func (a *McpKey) String() string { + return fmt.Sprintf("%s_%s_%s", a.keyPrefix, a.keyValue, a.checksum) +} + +func newMcpKey(userId uuid.UUID, teamId uuid.UUID, name string) (*McpKey, error) { + bytes := make([]byte, 32) + _, err := rand.Read(bytes) + if err != nil { + fmt.Println(err) + return nil, err + } + + byteString := hex.EncodeToString(bytes) + + checksum, err := cipher.ComputeChecksum([]byte(byteString)) + if err != nil { + return nil, err + } + + return &McpKey{ + userId: userId, + teamId: teamId, + name: name, + keyPrefix: McpKeyPrefix, + keyValue: byteString, + checksum: *checksum, + revoked: false, + createdAt: time.Now(), + }, nil +} + +func fetchMcpKeys(ctx context.Context, userId uuid.UUID, teamId uuid.UUID) ([]*McpKey, error) { + stmt := sqlf.PostgreSQL.Select("id, name, key_prefix, key_value, checksum, revoked, last_seen, created_at"). + From("mcp_keys"). + Where("user_id = ?", userId). + Where("team_id = ?", teamId) + defer stmt.Close() + + var mcpKeys []*McpKey + rows, err := server.Server.PgPool.Query(ctx, stmt.String(), userId, teamId) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var id uuid.UUID + var name string + var keyPrefix string + var keyValue string + var checksum string + var revoked bool + var lastSeen *time.Time + var createdAt time.Time + + if err := rows.Scan(&id, &name, &keyPrefix, &keyValue, &checksum, &revoked, &lastSeen, &createdAt); err != nil { + return nil, err + } + + mcpKey := &McpKey{ + Id: id, + userId: userId, + teamId: teamId, + name: name, + keyPrefix: keyPrefix, + keyValue: keyValue, + checksum: checksum, + revoked: revoked, + createdAt: createdAt, + } + + // Only set lastSeen if it's not null + if lastSeen != nil { + mcpKey.lastSeen = *lastSeen + } + + mcpKeys = append(mcpKeys, mcpKey) + } + + return mcpKeys, nil +} + +func insertMcpKey(ctx context.Context, mcpKey *McpKey) error { + stmt := sqlf.PostgreSQL.InsertInto("mcp_keys"). + Set("user_id", mcpKey.userId). + Set("team_id", mcpKey.teamId). + Set("name", mcpKey.name). + Set("key_prefix", mcpKey.keyPrefix). + Set("key_value", mcpKey.keyValue). + Set("checksum", mcpKey.checksum). + Set("revoked", false). + Set("created_at", mcpKey.createdAt) + + // Only set last_seen if it's not zero + if !mcpKey.lastSeen.IsZero() { + stmt.Set("last_seen", mcpKey.lastSeen) + } + defer stmt.Close() + + args := []interface{}{mcpKey.userId, mcpKey.teamId, mcpKey.name, mcpKey.keyPrefix, mcpKey.keyValue, mcpKey.checksum, false, mcpKey.createdAt} + if !mcpKey.lastSeen.IsZero() { + args = append(args, mcpKey.lastSeen) + } + _, err := server.Server.PgPool.Exec(ctx, stmt.String(), args...) + return err +} + +func revokeMcpKey(ctx context.Context, mcpKeyId uuid.UUID) error { + stmt := sqlf.PostgreSQL.Update("mcp_keys"). + Set("revoked", true). + Where("id = ?", mcpKeyId) + defer stmt.Close() + + _, err := server.Server.PgPool.Exec(ctx, stmt.String(), stmt.Args()...) + return err +} + +func updateLastSeenForMcpKey(ctx context.Context, mcpKeyId uuid.UUID) error { + stmt := sqlf.PostgreSQL.Update("mcp_keys"). + Set("last_seen", time.Now()). + Where("id = ?", mcpKeyId) + defer stmt.Close() + + _, err := server.Server.PgPool.Exec(ctx, stmt.String(), stmt.Args()...) + return err +} + +func GetMcpKeys(c *gin.Context) { + userId, err := uuid.Parse(c.GetString("userId")) + if err != nil { + msg := `user id invalid or missing` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + teamId, err := uuid.Parse(c.Param("id")) + if err != nil { + msg := `team id invalid or missing` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + _, err = PerformAuthz(userId.String(), teamId.String(), *ScopeTeamRead) + if err != nil { + msg := `you are not authorized to access this team` + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + mcpKeys, err := fetchMcpKeys(c.Request.Context(), userId, teamId) + if err != nil { + msg := "failed to fetch mcp keys" + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + c.JSON(http.StatusOK, mcpKeys) +} + +func GetUserAndTeamIdForMcpKey(c *gin.Context) { + userId, err := uuid.Parse(c.GetString("userId")) + if err != nil { + msg := `user id invalid or missing` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + teamId, err := uuid.Parse(c.GetString("teamId")) + if err != nil { + msg := `team id invalid or missing` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + _, err = PerformAuthz(userId.String(), teamId.String(), *ScopeTeamRead) + if err != nil { + msg := `you are not authorized to access this team` + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "user_id": userId, + "team_id": teamId, + }) +} + +func CreateMcpKey(c *gin.Context) { + userId, err := uuid.Parse(c.GetString("userId")) + if err != nil { + msg := `user id invalid or missing` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + teamId, err := uuid.Parse(c.Param("id")) + if err != nil { + msg := `team id invalid or missing` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + _, err = PerformAuthz(userId.String(), teamId.String(), *ScopeTeamRead) + if err != nil { + msg := `you are not authorized to access this team` + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + var req struct { + Name string `json:"name" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + msg := "invalid request body" + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + mcpKey, err := newMcpKey(userId, teamId, req.Name) + if err != nil { + msg := "failed to create MCP key" + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + err = insertMcpKey(c.Request.Context(), mcpKey) + if err != nil { + msg := "failed to save MCP key" + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + c.JSON(http.StatusCreated, mcpKey) +} + +func RevokeMcpKey(c *gin.Context) { + userId, err := uuid.Parse(c.GetString("userId")) + if err != nil { + msg := `user id invalid or missing` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + teamId, err := uuid.Parse(c.Param("id")) + if err != nil { + msg := `team id invalid or missing` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + _, err = PerformAuthz(userId.String(), teamId.String(), *ScopeTeamRead) + if err != nil { + msg := `you are not authorized to access this team` + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + mcpKeyId, err := uuid.Parse(c.Param("keyId")) + if err != nil { + msg := `invalid MCP key id` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + err = revokeMcpKey(c.Request.Context(), mcpKeyId) + if err != nil { + msg := "failed to revoke MCP key" + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": "done"}) +} + +func DecodeMcpKey(c *gin.Context, key string) (*uuid.UUID, *uuid.UUID, error) { + defaultErr := errors.New("invalid MCP key") + + if len(key) < 1 { + return nil, nil, defaultErr + } + + parts := strings.Split(key, "_") + + if len(parts) != 4 { + return nil, nil, defaultErr + } + + prefix := parts[0] + "_" + parts[1] + value := parts[2] + checksum := parts[3] + + if prefix != McpKeyPrefix { + return nil, nil, defaultErr + } + + computedChecksum, err := cipher.ComputeChecksum([]byte(value)) + if err != nil { + return nil, nil, err + } + + if checksum != *computedChecksum { + return nil, nil, defaultErr + } + + stmt := sqlf.PostgreSQL. + Select("id"). + Select("user_id"). + Select("team_id"). + From("mcp_keys"). + Where("key_value = ? ", value). + Where("checksum = ? ", checksum). + Where("revoked = ?", false). + Limit(1) + defer stmt.Close() + + var id uuid.UUID + var userId uuid.UUID + var teamId uuid.UUID + + if err := server.Server.PgPool.QueryRow(context.Background(), stmt.String(), stmt.Args()...).Scan(&id, &userId, &teamId); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil, defaultErr + } + return nil, nil, err + } + + err = updateLastSeenForMcpKey(c, id) + if err != nil { + msg := "failed to update MCP key last seen value" + fmt.Println(msg, err) + } + + return &userId, &teamId, nil +} diff --git a/backend/api/measure/session.go b/backend/api/measure/session.go index cd34ca3bd..fa407dc0a 100644 --- a/backend/api/measure/session.go +++ b/backend/api/measure/session.go @@ -379,21 +379,9 @@ func GetSessionsWithFilter(ctx context.Context, af *filter.AppFilter) (sessions stmt.SubQuery("session_id in (", ")", subQuery) } - applyGroupBy := af.Crash || - af.ANR || - af.HasCountries() || - af.HasNetworkProviders() || - af.HasNetworkTypes() || - af.HasNetworkGenerations() || - af.HasDeviceLocales() || - af.HasDeviceManufacturers() || - af.HasDeviceNames() - - if applyGroupBy { - stmt.GroupBy("session_id") - stmt.GroupBy("first_event_timestamp") - stmt.GroupBy("last_event_timestamp") - } + stmt.GroupBy("session_id") + stmt.GroupBy("first_event_timestamp") + stmt.GroupBy("last_event_timestamp") defer stmt.Close() @@ -499,6 +487,7 @@ func GetSessionsWithFilter(ctx context.Context, af *filter.AppFilter) (sessions // set matched free text results sess.MatchedFreeText = session.ExtractMatches(af.FreeText, sess.Attribute.UserID, sess.SessionID.String(), uniqueTypes, uniqueStrings, uniqueViewClassnames, uniqueSubviewClassnames, uniqueExceptions, uniqueANRs, uniqueClickTargets, uniqueLongclickTargets, uniqueScrollTargets) + fmt.Println("Matched Free Text:", sess.MatchedFreeText) sessions = append(sessions, sess) } diff --git a/backend/api/measure/usage.go b/backend/api/measure/usage.go index ce074d85c..619008bbb 100644 --- a/backend/api/measure/usage.go +++ b/backend/api/measure/usage.go @@ -11,6 +11,25 @@ import ( "github.com/leporo/sqlf" ) +type AiUsageReport struct { + TeamId string `json:"team_id"` + UserId string `json:"user_id"` + Source string `json:"source"` + Model string `json:"model"` + InputTokens uint64 `json:"input_tokens"` + OutputTokens uint64 `json:"output_tokens"` +} + +type AiUsage struct { + TeamId string `json:"team_id"` + MonthlyAiUsage []MonthlyAiUsage `json:"monthly_ai_usage"` +} + +type MonthlyAiUsage struct { + MonthName string `json:"month_year"` + TotalTokenCount uint64 `json:"total_token_count"` +} + type AppUsage struct { AppId string `json:"app_id"` AppName string `json:"app_name"` @@ -159,6 +178,61 @@ func GetUsage(c *gin.Context) { return } + // Query ai metrics for team + aiMetricsStmt := sqlf. + From(`ai_metrics`). + Select("team_id"). + Select("formatDateTime(toStartOfMonth(timestamp), '%b %Y') AS month_year"). + Select("sumMerge(input_token_count) + sumMerge(output_token_count) AS total_token_count"). + Where("timestamp >= addMonths(toStartOfMonth(?), -2) AND timestamp < toStartOfMonth(addMonths(?, 1))", now, now). + GroupBy("team_id, toStartOfMonth(timestamp)"). + OrderBy("team_id, toStartOfMonth(timestamp) DESC") + + defer aiMetricsStmt.Close() + + aiMetricsRows, err := server.Server.ChPool.Query(ctx, aiMetricsStmt.String(), aiMetricsStmt.Args()...) + if err != nil { + msg := fmt.Sprintf("error occurred while querying AI metrics for team: %s", teamId) + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + aiUsageMap := make(map[string]*AiUsage) + + // Initialize aiUsageMap + aiUsageMap[teamId.String()] = &AiUsage{ + TeamId: teamId.String(), + MonthlyAiUsage: make([]MonthlyAiUsage, 0, 3), + } + + // Populate aiUsageMap with metrics rows from DB + for aiMetricsRows.Next() { + var teamId, monthYear string + var totalTokenCount uint64 + + if err := aiMetricsRows.Scan(&teamId, &monthYear, &totalTokenCount); err != nil { + msg := fmt.Sprintf("error occurred while scanning AI metrics row for team: %s", teamId) + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + if aiUsage, exists := aiUsageMap[teamId]; exists { + aiUsage.MonthlyAiUsage = append(aiUsage.MonthlyAiUsage, MonthlyAiUsage{ + MonthName: monthYear, + TotalTokenCount: totalTokenCount, + }) + } + } + + if err := aiMetricsRows.Err(); err != nil { + msg := fmt.Sprintf("error occurred while iterating AI metrics rows for team: %s", teamId) + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + // Ensure all apps have entries for all three months by adding 0 values for missing months for _, appUsage := range appUsageMap { monthDataMap := make(map[string]MonthlyAppUsage) @@ -183,11 +257,117 @@ func GetUsage(c *gin.Context) { appUsage.MonthlyAppUsage = newMonthlyAppUsage } + // Ensure all AI usages have entries for all three months by adding 0 values for missing months + for _, aiUsage := range aiUsageMap { + monthDataMap := make(map[string]MonthlyAiUsage) + for _, usage := range aiUsage.MonthlyAiUsage { + monthDataMap[usage.MonthName] = usage + } + + newMonthlyAiUsage := make([]MonthlyAiUsage, 0, 3) + for _, monthName := range monthNames { + if usage, exists := monthDataMap[monthName]; exists { + newMonthlyAiUsage = append(newMonthlyAiUsage, usage) + } else { + newMonthlyAiUsage = append(newMonthlyAiUsage, MonthlyAiUsage{ + MonthName: monthName, + TotalTokenCount: 0, + }) + } + } + aiUsage.MonthlyAiUsage = newMonthlyAiUsage + } + // Convert map to slice for JSON response - var result []AppUsage + var appUsageResult []AppUsage for _, appUsage := range appUsageMap { - result = append(result, *appUsage) + appUsageResult = append(appUsageResult, *appUsage) + } + + var aiUsageResult []AiUsage + for _, aiUsage := range aiUsageMap { + aiUsageResult = append(aiUsageResult, *aiUsage) + } + + result := gin.H{ + "app_usage": appUsageResult, + "ai_usage": aiUsageResult, } c.JSON(http.StatusOK, result) } + +func ReportAiUsage(c *gin.Context) { + ctx := c.Request.Context() + userId := c.GetString("userId") + teamId, err := uuid.Parse(c.Param("id")) + if err != nil { + msg := `team id invalid or missing` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + var aiUsageReport AiUsageReport + + if err := c.ShouldBindJSON(&aiUsageReport); err != nil { + msg := "failed to parse AI usage payload" + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{ + "error": msg, + }) + return + } + + if ok, err := PerformAuthz(userId, teamId.String(), *ScopeTeamRead); err != nil { + msg := `couldn't perform authorization checks` + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } else if !ok { + msg := fmt.Sprintf(`you don't have permissions for team [%s]`, teamId) + c.JSON(http.StatusForbidden, gin.H{"error": msg}) + return + } + + if ok, err := PerformAuthz(userId, teamId.String(), *ScopeAppRead); err != nil { + msg := `couldn't perform authorization checks` + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } else if !ok { + msg := fmt.Sprintf(`you don't have permissions to read apps in team [%s]`, teamId) + c.JSON(http.StatusForbidden, gin.H{"error": msg}) + return + } + + var team = new(Team) + team.ID = &teamId + + // insert metrics into clickhouse table + metricsSelectStmt := sqlf. + Select("? AS team_id", team.ID). + Select("? AS timestamp", time.Now()). + Select("? AS user_id", aiUsageReport.UserId). + Select("? AS source", aiUsageReport.Source). + Select("? AS model", aiUsageReport.Model). + Select("sumState(CAST(? AS UInt32)) AS input_token_count", aiUsageReport.InputTokens). + Select("sumState(CAST(? AS UInt32)) AS output_token_count", aiUsageReport.OutputTokens) + selectSQL := metricsSelectStmt.String() + args := metricsSelectStmt.Args() + defer metricsSelectStmt.Close() + metricsInsertStmt := "INSERT INTO ai_metrics " + selectSQL + + if err := server.Server.ChPool.Exec(ctx, metricsInsertStmt, args...); err != nil { + msg := `failed to insert ai metrics into clickhouse` + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": msg, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "ok": "done", + }) +} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index adec91e85..71bb6f9bd 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -347,3 +347,6 @@ set VERSION $(git cliff --bumped-version) && git tag -s $VERSION -m $VERSION && - Public facing docs should be in [docs](../README.md) folder - API requests & responses, self host guide, SDK guides and so on - Main folder of subproject should link to main guide. ex: [frontend README](../../frontend/README.md) has link to self hosting and local dev guide - Non public facing docs can stay in sub folder. ex: [backend benchmarking README](../../backend/benchmarking/README.md) which describes its purpose + +### Updating Documentation +- When updating docs in the `docs` folder, the `docs-embeddings.json` file needs to be updated so that the AI assistant can reference them. To update the embedding, you should run `npm run generate-docs-embeddings` from inside the `frontend/dashboard` folder. You will need to have [setup AI Integration](../hosting/ai.md) before running the command so that it can access the embedding model and update the embedding file. This file needs to be committed as part of the same PR that updates the docs. diff --git a/docs/README.md b/docs/README.md index 3d9bb7089..7240651e8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,6 +49,7 @@ feature's documentation to understand its underlying mechanism and enhance your happens * [**App Size Monitoring**](features/feature-app-size-monitoring.md) — Monitor app size changes * [**Alert Notifications**](features/feature-alerts.md) — Receive Crash & ANR spike alerts and Daily Summaries for core app metrics. +* [**Measure AI**](features/feature-ai.md) — Debug Crashes & ANRs with AI assistance. # Configuration Options diff --git a/docs/api/dashboard/README.md b/docs/api/dashboard/README.md index 128261e50..f0cd1e4b9 100644 --- a/docs/api/dashboard/README.md +++ b/docs/api/dashboard/README.md @@ -285,12 +285,29 @@ Find all the endpoints, resources and detailed documentation for Measure Dashboa - [Authorization \& Content Type](#authorization--content-type-49) - [Response Body](#response-body-52) - [Status Codes \& Troubleshooting](#status-codes--troubleshooting-52) +- [MCP](#mcp) + - [POST `/mcp/teams/:id/keys`](#post-mcpteamsidkeys) + - [Usage Notes](#usage-notes-52) + - [Request Body](#request-body-14) + - [Authorization \& Content Type](#authorization--content-type-50) + - [Response Body](#response-body-53) + - [Status Codes \& Troubleshooting](#status-codes--troubleshooting-53) + - [GET `/mcp/teams/:id/keys`](#get-mcpteamsidkeys) + - [Usage Notes](#usage-notes-53) + - [Authorization \& Content Type](#authorization--content-type-51) + - [Response Body](#response-body-54) + - [Status Codes \& Troubleshooting](#status-codes--troubleshooting-54) + - [PATCH `/mcp/teams/:id/keys/:id/revoke`](#patch-mcpteamsidkeysidrevoke) + - [Usage Notes](#usage-notes-54) + - [Authorization \& Content Type](#authorization--content-type-52) + - [Response Body](#response-body-55) + - [Status Codes \& Troubleshooting](#status-codes--troubleshooting-55) ## Auth - [**POST `/auth/github`**](#post-authgithub) - Sign in with Github. - [**POST `/auth/google`**](#post-authgoogle) - Sign in with Google. -- [**POST `/auth/validateInvite`**](#post-validateInvite) - Validate invite. +- [**POST `/auth/validateInvite`**](#post-validateinvite) - Validate invite. - [**POST `/auth/refresh`**](#post-authrefresh) - Refresh session. - [**GET `/auth/session`**](#get-authsession) - Fetch session. - [**DELETE `/auth/signout`**](#delete-authsignout) - Sign out. @@ -6842,4 +6859,246 @@ List of HTTP status codes for success and failures. | `429 Too Many Requests` | Rate limit of the requester has crossed maximum limits. | | `500 Internal Server Error` | Measure server encountered an unfortunate error. Report this to your server administrator. | + + +## MCP + +- [**POST `/mcp/teams/:id/keys`**](#post-mcpteamsidkeys) - Create MCP key for current user and given team. +- [**GET `/mcp/teams/:id/keys`**](#get-mcpteamsidkeys) - Fetch MCP keys for current user and given team. +- [**PATCH `/mcp/teams/:id/keys/:id/revoke`**](#patch-mcpteamsidkeysidrevoke) - Revoke MCP key defind by key id for current user and given team. + + +### POST `/mcp/teams/:id/keys` + +Create MCP key for current user and given team. + +#### Usage Notes + +- Team ID must be passed in the URI as the first ID + +#### Request body + +```json +{ + "name": "sample key" +} +``` + +#### Authorization & Content Type + +1. (Optional) Set the sessions's access token in `Authorization: Bearer ` format unless you are using cookies to send access tokens. + +2. Set content type as `Content-Type: application/json; charset=utf-8` + +The required headers must be present in each request. + +
+ Request Headers - Click to expand + +| **Name** | **Value** | +| --------------- | -------------------------------- | +| `Authorization` | Bearer <user-access-token> | +| `Content-Type` | application/json; charset=utf-8 | +
+ +#### Response Body + +- Response + +
+ Click to expand + + ```json + { + "created_at": "2025-10-26T14:15:01.425Z", + "id": "67968cfc-b0a7-4278-9ce4-2f2d5a19kjsd3", + "key": "msrsh_mcp_6775a3ee3f960b19eb6030046a68f10f955a8f1f4e46728eb0279d2aa8993ac0_6abcdefg", + "last_seen": null, + "name": "sample key", + "revoked": false + } + ``` + +
+ +- Failed requests have the following response shape + + ```json + { + "error": "Error message" + } + ``` + +#### Status Codes & Troubleshooting + +List of HTTP status codes for success and failures. + +
+ Status Codes - Click to expand + +| **Status** | **Meaning** | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `200 Ok` | Successful response, no errors. | +| `400 Bad Request` | Request URI is malformed or does not meet one or more acceptance criteria. Check the `"error"` field for more details. | +| `401 Unauthorized` | Either the user's access token is invalid or has expired. | +| `403 Forbidden` | Requester does not have access to this resource. | +| `429 Too Many Requests` | Rate limit of the requester has crossed maximum limits. | +| `500 Internal Server Error` | Measure server encountered an unfortunate error. Report this to your server administrator. | + +
+ +### GET `/mcp/teams/:id/keys` + +Fetch MCP keys for current user and given team. + +#### Usage Notes + +- Team ID must be passed in the URI as the first ID + +#### Authorization & Content Type + +1. (Optional) Set the sessions's access token in `Authorization: Bearer ` format unless you are using cookies to send access tokens. + +2. Set content type as `Content-Type: application/json; charset=utf-8` + +The required headers must be present in each request. + +
+ Request Headers - Click to expand + +| **Name** | **Value** | +| --------------- | -------------------------------- | +| `Authorization` | Bearer <user-access-token> | +| `Content-Type` | application/json; charset=utf-8 | +
+ +#### Response Body + +- Response + +
+ Click to expand + + ```json + [ + { + "created_at": "2025-10-25T16:16:40.014Z", + "id": "67968cfc-b0a7-4278-9ce4-2f2d5a19657d", + "key": "msrsh_mcp_c0f2cf10453edb152782b8b2f961e02dd5ee36aa467fdd82b15baf925151ab3e_abcdefgh", + "last_seen": null, + "name": "sample key 1", + "revoked": false + }, + { + "created_at": "2025-10-25T16:16:07.343Z", + "id": "ccc97123-288a-4cfc-8850-33584592e464", + "key": "msrsh_mcp_2c15f59ced926f9f3906731857704faa129ceb0b900ecb9bdebdc67b4dd68b68_ijklmnop", + "last_seen": null, + "name": "sample key 2", + "revoked": false + }, + { + "created_at": "2025-10-26T14:15:01.425Z", + "id": "59e3b97f-0167-41c9-948b-a52ef637b18e", + "key": "msrsh_mcp_6775a3ee3f960b19eb6030046a68f10f955a8f1f4e46728eb0279d2aa8993ac0_qrstuvwx", + "last_seen": null, + "name": "sample key 3", + "revoked": false + } + ] + ``` + +
+ +- Failed requests have the following response shape + + ```json + { + "error": "Error message" + } + ``` + +#### Status Codes & Troubleshooting + +List of HTTP status codes for success and failures. + +
+ Status Codes - Click to expand + +| **Status** | **Meaning** | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `200 Ok` | Successful response, no errors. | +| `400 Bad Request` | Request URI is malformed or does not meet one or more acceptance criteria. Check the `"error"` field for more details. | +| `401 Unauthorized` | Either the user's access token is invalid or has expired. | +| `403 Forbidden` | Requester does not have access to this resource. | +| `429 Too Many Requests` | Rate limit of the requester has crossed maximum limits. | +| `500 Internal Server Error` | Measure server encountered an unfortunate error. Report this to your server administrator. | + +
+ +### PATCH `/mcp/teams/:id/keys/:id/revoke` + +Revoke MCP key defind by key id for current user and given team. + +#### Usage Notes + +- Team ID must be passed in the URI as the first ID +- Key ID must be passed in the URI as the second ID + +#### Authorization & Content Type + +1. (Optional) Set the sessions's access token in `Authorization: Bearer ` format unless you are using cookies to send access tokens. + +2. Set content type as `Content-Type: application/json; charset=utf-8` + +The required headers must be present in each request. + +
+ Request Headers - Click to expand + +| **Name** | **Value** | +| --------------- | -------------------------------- | +| `Authorization` | Bearer <user-access-token> | +| `Content-Type` | application/json; charset=utf-8 | +
+ +#### Response Body + +- Response + +
+ Click to expand + + ```json + { + "ok": "done", + } + ``` + +
+ +- Failed requests have the following response shape + + ```json + { + "error": "Error message" + } + ``` + +#### Status Codes & Troubleshooting + +List of HTTP status codes for success and failures. + +
+ Status Codes - Click to expand + +| **Status** | **Meaning** | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `200 Ok` | Successful response, no errors. | +| `400 Bad Request` | Request URI is malformed or does not meet one or more acceptance criteria. Check the `"error"` field for more details. | +| `401 Unauthorized` | Either the user's access token is invalid or has expired. | +| `403 Forbidden` | Requester does not have access to this resource. | +| `429 Too Many Requests` | Rate limit of the requester has crossed maximum limits. | +| `500 Internal Server Error` | Measure server encountered an unfortunate error. Report this to your server administrator. | +
\ No newline at end of file diff --git a/docs/features/feature-ai.md b/docs/features/feature-ai.md new file mode 100644 index 000000000..8bbd3e9ed --- /dev/null +++ b/docs/features/feature-ai.md @@ -0,0 +1,28 @@ +# AI + +Measure makes it easy to debug Crashes and ANRs with AI assistance. + +* [**Copy AI Context**](#copy-ai-context) +* [**AI Integration**](#ai-integration) + * [**Setting Up AI integration**](#setting-up-ai-integration) + * [**Debugging with AI**](#debugging-with-ai) + +## Copy AI Context +On Crash and ANR details pages, you will see a `Copy AI Context` button. This allows you to copy the relevant stacktrace and session timeline to the copy clipboard for easy pasting into an external LLM interface of your choice. + +This feature does not require setting up the AI Assitant and can be used directly. + +## AI Assistant Integration +This integration enables developers to get help with Crash/ANR debugging via our AI assitant. + +### Setting up AI integration +If you are a self hosted user, please set up AI Assistant integration if you haven't done so using this [guide](/docs/hosting/ai.md). + +### Debugging with AI +You will be able to see a `Debug With AI` button on Crash and ANR details pages. You can click it to open the assitant interface and start chatting. + +You can attach the current exception context as well as the corresponding session timeline using the chat input toolbar. + +You can also attach code files and images to help in your debugging workflow. + + diff --git a/docs/hosting/README.md b/docs/hosting/README.md index 7a000badb..1430ff665 100644 --- a/docs/hosting/README.md +++ b/docs/hosting/README.md @@ -159,6 +159,12 @@ Optionally, you can set up a Slack app if you want to receive alert notification Once your slack integration is set up, copy the values and enter in the relevant prompts. If you wish to ignore it, enter empty values and proceed. +Additionally, you can set up AI integration if you want to use the AI assistant features. Follow the below link to set it up: + +- [Set up AI Assistant Integration](./ai.md) + +Once your AI integration is set up, copy the values and enter in the relevant prompts. If you wish to ignore it, enter empty values and proceed. + Once completed, the install script will attempt to start all the Measure docker compose services. You should see a similar output.

diff --git a/docs/hosting/ai.md b/docs/hosting/ai.md new file mode 100644 index 000000000..4ee375d9c --- /dev/null +++ b/docs/hosting/ai.md @@ -0,0 +1,36 @@ +# Set up AI Gateway API key + +Measure uses Vercel's AI Gateway to implement LLM based features. + +You can sign up for an account and get your API key [here](https://vercel.com/ai-gateway). + + +## Configure SMTP email settings for existing users + +If you are upgrading from v0.8.x, you would need to manually configure the API key. + +1. Edit the `self-host/.env` file. + +2. Add the following environment variables as obtained from Vercel. + + ```sh + AI_GATEWAY_API_KEY=your-key # change this + ``` + +3. Run the following command to shutdown all services. + + ```sh + sudo docker compose \ + -f compose.yml \ + -f compose.prod.yml \ + --profile migrate \ + down + ``` + +4. Finally, run the `install.sh` script for the configuration to take effect. + + ```sh + sudo ./install.sh + ``` + +[Go back to self host guide](./README.md) diff --git a/frontend/dashboard/__tests__/components/__snapshots__/danger_confirmation_dialog_test.tsx.snap b/frontend/dashboard/__tests__/components/__snapshots__/danger_confirmation_dialog_test.tsx.snap index d8f8a6eca..f3f9137bd 100644 --- a/frontend/dashboard/__tests__/components/__snapshots__/danger_confirmation_dialog_test.tsx.snap +++ b/frontend/dashboard/__tests__/components/__snapshots__/danger_confirmation_dialog_test.tsx.snap @@ -4,112 +4,11 @@ exports[`Danger Confirmation Dialog renders correctly in closed state 1`] = ` { "asFragment": [Function], "baseElement": -

{ - if (!item.external) { - e.preventDefault() - handleNavClick(item.url) - router.push( - `/${selectedTeam?.id}/${item.url}`, - ) - } - }} + {teamsApiStatus === TeamsApiStatus.Error && ( + + Error fetching teams. Please refresh page to try again. + + )} + {teamsApiStatus === TeamsApiStatus.Success && + navData.navMain.map((item) => ( + +

{item.title}

+ {item.items?.length ? ( + + {item.items.map((item) => ( + + - {item.title} -
- - - ))} - - ) : null} - - ))} + { + if (!item.external) { + e.preventDefault() + handleNavClick(item.url) + router.push( + `/${selectedTeam?.id}/${item.url}`, + ) + } + }} + > + {item.title} + + + + ))} + + ) : null} + + ))} + + + + + + + logoutUser()} /> + - - - - - - logoutUser()} /> - - - - - -
- - -
+
+ + +
+ + + {hasAiChat && } +
+ +
+
+
{children}
+
-
-
{children}
-
- - + {selectedTeam && setIsChatOpen(false)} />} +
+
+ + ) } diff --git a/frontend/dashboard/app/[teamId]/mcp/page.tsx b/frontend/dashboard/app/[teamId]/mcp/page.tsx new file mode 100644 index 000000000..b3d2070da --- /dev/null +++ b/frontend/dashboard/app/[teamId]/mcp/page.tsx @@ -0,0 +1,154 @@ +"use client" + +import { FetchMcpKeysApiStatus, fetchMcpKeysFromServer, McpKey, RevokeMcpKeyApiStatus, revokeMcpKeyFromServer } from "@/app/api/api_calls" +import { Button } from "@/app/components/button" +import CreateMcpKey from "@/app/components/create_mcp_key" +import DangerConfirmationModal from "@/app/components/danger_confirmation_dialog" +import LoadingSpinner from "@/app/components/loading_spinner" +import { formatDateToHumanReadableDateTime } from "@/app/utils/time_utils" +import { toastNegative, toastPositive } from "@/app/utils/use_toast" +import { useEffect, useState } from 'react' + +export default function MCP({ params }: { params: { teamId: string } }) { + + const [fetchMcpKeysApiStatus, setFetchMcpKeysApiStatus] = useState(FetchMcpKeysApiStatus.Loading) + const [revokeMcpKeyApiStatus, setRevokeMcpKeyApiStatus] = useState(RevokeMcpKeyApiStatus.Init) + + const [mcpKeys, setMcpKeys] = useState([]) + const [revokeConfirmationModalOpen, setRevokeConfirmationModalOpen] = useState(false) + const [revokeKeyId, setRevokeKeyId] = useState(null) + const [revokeKeyName, setRevokeKeyName] = useState(null) + + + const getMcpKeys = async () => { + setFetchMcpKeysApiStatus(FetchMcpKeysApiStatus.Loading) + + const result = await fetchMcpKeysFromServer(params.teamId) + + switch (result.status) { + case FetchMcpKeysApiStatus.Error: + setFetchMcpKeysApiStatus(FetchMcpKeysApiStatus.Error) + break + case FetchMcpKeysApiStatus.NoKeys: + setFetchMcpKeysApiStatus(FetchMcpKeysApiStatus.NoKeys) + break + case FetchMcpKeysApiStatus.Success: + setFetchMcpKeysApiStatus(FetchMcpKeysApiStatus.Success) + setMcpKeys(result.data) + break + } + } + + useEffect(() => { + getMcpKeys() + }, []) + + const revokeMcpKey = async () => { + setRevokeMcpKeyApiStatus(RevokeMcpKeyApiStatus.Loading) + + const result = await revokeMcpKeyFromServer(params.teamId, revokeKeyId!) + + switch (result.status) { + + case RevokeMcpKeyApiStatus.Error: + setRevokeMcpKeyApiStatus(RevokeMcpKeyApiStatus.Error) + toastNegative("Error revoking MCP key") + break + case RevokeMcpKeyApiStatus.Success: + setRevokeMcpKeyApiStatus(RevokeMcpKeyApiStatus.Success) + setMcpKeys((prevKeys) => prevKeys.map((key) => key.id === revokeKeyId ? { ...key, revoked: true } : key)) + toastPositive("MCP key revoked successfully") + break + } + } + + return ( +
+
+

MCP Keys

+ { + setFetchMcpKeysApiStatus(FetchMcpKeysApiStatus.Success) + setMcpKeys((prevKeys) => [...prevKeys, key]) + }} /> +
+ + {/* Main UI*/} +
+ {/* Modal for confirming Key revoke */} + Are you sure you want to revoke MCP key {revokeKeyName}? +

All services using this key will lose access immediately. +

+ } + open={revokeConfirmationModalOpen} + affirmativeText="Yes, I'm sure" + cancelText="Cancel" + confirmationText={revokeKeyName!} + onAffirmativeAction={() => { + setRevokeConfirmationModalOpen(false) + revokeMcpKey() + }} + onCancelAction={() => setRevokeConfirmationModalOpen(false)} + /> + +
+ {/* Loading message for fetch mcp keys */} + {fetchMcpKeysApiStatus === FetchMcpKeysApiStatus.Loading && } + {/* Error message for fetch mcp keys */} + {fetchMcpKeysApiStatus === FetchMcpKeysApiStatus.Error &&

Error fetching MCP keys, please refresh page to try again

} + {/* No keys message for fetch mcp keys */} + {fetchMcpKeysApiStatus === FetchMcpKeysApiStatus.NoKeys &&

Looks like you don't have any MCP keys yet. Get started by creating one!

} + + {fetchMcpKeysApiStatus === FetchMcpKeysApiStatus.Success && +
+ {mcpKeys?.map(({ id, name, key, revoked, created_at, last_seen }, index) => ( +
+
{name}
+
+

{revoked ? 'Revoked' : 'Active'}

+
+
Created At: {created_at ? formatDateToHumanReadableDateTime(created_at) : "N/A"}
+
+
Last seen: {last_seen ? formatDateToHumanReadableDateTime(last_seen) : "N/A"}
+
+
+ +
+ +
+ +
+
+ ))} +
+ } + +
+
+
+ ) +} diff --git a/frontend/dashboard/app/[teamId]/overview/page.tsx b/frontend/dashboard/app/[teamId]/overview/page.tsx index 78e82707c..084d8dcb4 100644 --- a/frontend/dashboard/app/[teamId]/overview/page.tsx +++ b/frontend/dashboard/app/[teamId]/overview/page.tsx @@ -4,6 +4,7 @@ import { FilterSource } from '@/app/api/api_calls' import Filters, { AppVersionsInitialSelectionType, defaultFilters } from '@/app/components/filters' import MetricsOverview from '@/app/components/metrics_overview' import SessionsVsExceptionsPlot from '@/app/components/sessions_vs_exceptions_overview_plot' +import { useAIChatContext } from '@/app/context/ai_chat_context' import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' @@ -13,6 +14,7 @@ interface PageState { export default function Overview({ params }: { params: { teamId: string } }) { const router = useRouter() + const { setPageContext } = useAIChatContext() const initialState: PageState = { filters: defaultFilters, @@ -34,6 +36,16 @@ export default function Overview({ params }: { params: { teamId: string } }) { filters: updatedFilters }) } + + if (updatedFilters.app?.id) { + setPageContext({ + appId: updatedFilters.app!.id, + enable: false, + fileName: "", + action: "", + content: "" + }) + } } useEffect(() => { @@ -84,6 +96,7 @@ export default function Overview({ params }: { params: { teamId: string } }) { filters={pageState.filters} />} )} +
) } \ No newline at end of file diff --git a/frontend/dashboard/app/[teamId]/sessions/[appId]/[sessionId]/page.tsx b/frontend/dashboard/app/[teamId]/sessions/[appId]/[sessionId]/page.tsx index e1b918920..99dfa4e93 100644 --- a/frontend/dashboard/app/[teamId]/sessions/[appId]/[sessionId]/page.tsx +++ b/frontend/dashboard/app/[teamId]/sessions/[appId]/[sessionId]/page.tsx @@ -3,10 +3,13 @@ import { SessionTimelineApiStatus, emptySessionTimeline, fetchSessionTimelineFromServer } from "@/app/api/api_calls" import LoadingSpinner from "@/app/components/loading_spinner" import SessionTimeline from "@/app/components/session_timeline" +import { useAIChatContext } from "@/app/context/ai_chat_context" import { formatMillisToHumanReadable } from "@/app/utils/time_utils" import { useEffect, useState } from "react" export default function Session({ params }: { params: { teamId: string, appId: string, sessionId: string } }) { + const { setPageContext } = useAIChatContext() + const [sessionTimeline, setSessionTimeline] = useState(emptySessionTimeline) const [sessionTimelineApiStatus, setSessionTimelineApiStatus] = useState(SessionTimelineApiStatus.Loading) @@ -18,10 +21,24 @@ export default function Session({ params }: { params: { teamId: string, appId: s switch (result.status) { case SessionTimelineApiStatus.Error: setSessionTimelineApiStatus(SessionTimelineApiStatus.Error) + setPageContext({ + appId: params.appId, + enable: false, + fileName: "", + action: "", + content: "" + }) break case SessionTimelineApiStatus.Success: setSessionTimelineApiStatus(SessionTimelineApiStatus.Success) setSessionTimeline(result.data) + setPageContext({ + appId: params.appId, + enable: true, + fileName: 'session_timeline', + action: `Attach Session Details`, + content: "sessionTimeline:" + JSON.stringify(sessionTimeline) + }) break } } @@ -49,7 +66,8 @@ export default function Session({ params }: { params: { teamId: string, appId: s
} -
+
+
) } diff --git a/frontend/dashboard/app/[teamId]/sessions/page.tsx b/frontend/dashboard/app/[teamId]/sessions/page.tsx index 1f4eb85e3..805ef4f59 100644 --- a/frontend/dashboard/app/[teamId]/sessions/page.tsx +++ b/frontend/dashboard/app/[teamId]/sessions/page.tsx @@ -6,7 +6,9 @@ import LoadingBar from '@/app/components/loading_bar' import Paginator from '@/app/components/paginator' import SessionsOverviewPlot from '@/app/components/sessions_overview_plot' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/app/components/table' +import { useAIChatContext } from '@/app/context/ai_chat_context' import { formatDateToHumanReadableDate, formatDateToHumanReadableTime, formatMillisToHumanReadable } from '@/app/utils/time_utils' +import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' @@ -23,6 +25,7 @@ const paginationOffsetUrlKey = "po" export default function SessionsOverview({ params }: { params: { teamId: string } }) { const router = useRouter() const searchParams = useSearchParams() + const { setPageContext } = useAIChatContext() const initialState: PageState = { sessionsOverviewApiStatus: SessionsOverviewApiStatus.Loading, @@ -68,6 +71,16 @@ export default function SessionsOverview({ params }: { params: { teamId: string paginationOffset: pageState.filters.serialisedFilters && searchParams.get(paginationOffsetUrlKey) ? 0 : pageState.paginationOffset }) } + + if (updatedFilters.app?.id) { + setPageContext({ + appId: updatedFilters.app!.id, + enable: false, + fileName: "", + action: "", + content: "" + }) + } } const handleNextPage = () => { @@ -156,22 +169,13 @@ export default function SessionsOverview({ params }: { params: { teamId: string return ( { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - router.push(sessionHref) - } - }} + className="font-body hover:bg-yellow-200 focus-visible:border-yellow-200 select-none cursor-pointer" > -

ID: {session_id}

@@ -181,12 +185,10 @@ export default function SessionsOverview({ params }: { params: { teamId: string
-

ID: {trace_id}

@@ -183,12 +187,10 @@ export default function TracesOverview({ params }: { params: { teamId: string }
-
+ {providers.claude.icon} + {providers.claude.title} + + + + ); +}; + +export type OpenInT3Props = ComponentProps; + +export const OpenInT3 = (props: OpenInT3Props) => { + const { query } = useOpenInContext(); + return ( + + + {providers.t3.icon} + {providers.t3.title} + + + + ); +}; + +export type OpenInSciraProps = ComponentProps; + +export const OpenInScira = (props: OpenInSciraProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.scira.icon} + {providers.scira.title} + + + + ); +}; + +export type OpenInv0Props = ComponentProps; + +export const OpenInv0 = (props: OpenInv0Props) => { + const { query } = useOpenInContext(); + return ( + + + {providers.v0.icon} + {providers.v0.title} + + + + ); +}; \ No newline at end of file diff --git a/frontend/dashboard/app/components/ai-elements/prompt-input.tsx b/frontend/dashboard/app/components/ai-elements/prompt-input.tsx new file mode 100644 index 000000000..cb24b0e29 --- /dev/null +++ b/frontend/dashboard/app/components/ai-elements/prompt-input.tsx @@ -0,0 +1,718 @@ +"use client"; + +import { cn } from "@/app/utils/shadcn_utils"; +import type { ChatStatus, FileUIPart } from "ai"; +import { + ImageIcon, + Loader2Icon, + PaperclipIcon, + PlusIcon, + SendIcon, + SquareIcon, + XIcon, +} from "lucide-react"; +import { nanoid } from "nanoid"; +import React, { + type ChangeEventHandler, + Children, + ClipboardEventHandler, + type ComponentProps, + createContext, + type FormEvent, + type FormEventHandler, + Fragment, + type HTMLAttributes, + type KeyboardEventHandler, + type RefObject, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Button } from "../button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../dropdown_menu"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../select"; +import { Textarea } from "../textarea"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip"; + +type AttachmentsContext = { + files: (FileUIPart & { id: string })[]; + add: (files: File[] | FileList) => void; + remove: (id: string) => void; + clear: () => void; + openFileDialog: () => void; + fileInputRef: RefObject; +}; + +const AttachmentsContext = createContext(null); + +export const usePromptInputAttachments = () => { + const context = useContext(AttachmentsContext); + + if (!context) { + throw new Error( + "usePromptInputAttachments must be used within a PromptInput" + ); + } + + return context; +}; + +export type PromptInputAttachmentProps = HTMLAttributes & { + data: FileUIPart & { id: string }; + className?: string; +}; + +export function PromptInputAttachment({ + data, + className, + ...props +}: PromptInputAttachmentProps) { + const attachments = usePromptInputAttachments(); + + return ( + + +
+ {data.mediaType?.startsWith("image/") && data.url ? ( + {data.filename + ) : ( +
+ +

{data.filename}

+
+ )} + +
+
+ + {data.filename} + +
+ ); +} + +export type PromptInputAttachmentsProps = Omit< + HTMLAttributes, + "children" +> & { + children: (attachment: FileUIPart & { id: string }) => React.ReactNode; +}; + +export function PromptInputAttachments({ + className, + children, + ...props +}: PromptInputAttachmentsProps) { + const attachments = usePromptInputAttachments(); + const [height, setHeight] = useState(0); + const contentRef = useRef(null); + + useLayoutEffect(() => { + const el = contentRef.current; + if (!el) { + return; + } + const ro = new ResizeObserver(() => { + setHeight(el.getBoundingClientRect().height); + }); + ro.observe(el); + setHeight(el.getBoundingClientRect().height); + return () => ro.disconnect(); + }, []); + + return ( +
+
+ {attachments.files.map((file) => ( + {children(file)} + ))} +
+
+ ); +} + +export type PromptInputActionAddAttachmentsProps = ComponentProps< + typeof DropdownMenuItem +> & { + label?: string; +}; + +export const PromptInputActionAddAttachments = ({ + label = "Add photos or files", + ...props +}: PromptInputActionAddAttachmentsProps) => { + const attachments = usePromptInputAttachments(); + + return ( + { + attachments.openFileDialog(); + }} + > + {label} + + ); +}; + +export type PromptInputMessage = { + text?: string; + files?: FileUIPart[]; +}; + +export type PromptInputProps = Omit< + HTMLAttributes, + "onSubmit" +> & { + accept?: string; // e.g., "image/*" or leave undefined for any + multiple?: boolean; + // When true, accepts drops anywhere on document. Default false (opt-in). + globalDrop?: boolean; + // Render a hidden input with given name and keep it in sync for native form posts. Default false. + syncHiddenInput?: boolean; + // Minimal constraints + maxFiles?: number; + maxFileSize?: number; // bytes + onError?: (err: { + code: "max_files" | "max_file_size" | "accept"; + message: string; + }) => void; + onSubmit: ( + message: PromptInputMessage, + event: FormEvent + ) => void; +}; + +export const PromptInput = ({ + className, + accept, + multiple, + globalDrop, + syncHiddenInput, + maxFiles, + maxFileSize, + onError, + onSubmit, + ...props +}: PromptInputProps) => { + const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const inputRef = useRef(null); + const anchorRef = useRef(null); + const formRef = useRef(null); + + // Find nearest form to scope drag & drop + useEffect(() => { + const root = anchorRef.current?.closest("form"); + if (root instanceof HTMLFormElement) { + formRef.current = root; + } + }, []); + + const openFileDialog = useCallback(() => { + inputRef.current?.click(); + }, []); + + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === "") { + return true; + } + // Simple check: if accept includes "image/*", filter to images; otherwise allow. + if (accept.includes("image/*")) { + return f.type.startsWith("image/"); + } + return true; + }, + [accept] + ); + + const add = useCallback( + (files: File[] | FileList) => { + const incoming = Array.from(files); + const accepted = incoming.filter((f) => matchesAccept(f)); + if (accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (sized.length === 0 && accepted.length > 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } + setItems((prev) => { + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - prev.length) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + const next: (FileUIPart & { id: string })[] = []; + for (const file of capped) { + next.push({ + id: nanoid(), + type: "file", + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + }); + } + return prev.concat(next); + }); + }, + [matchesAccept, maxFiles, maxFileSize, onError] + ); + + const remove = useCallback((id: string) => { + setItems((prev) => { + const found = prev.find((file) => file.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((file) => file.id !== id); + }); + }, []); + + const clear = useCallback(() => { + setItems((prev) => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }); + }, []); + + // Note: File input cannot be programmatically set for security reasons + // The syncHiddenInput prop is no longer functional + useEffect(() => { + if (syncHiddenInput && inputRef.current) { + // Clear the input when items are cleared + if (items.length === 0) { + inputRef.current.value = ""; + } + } + }, [items, syncHiddenInput]); + + // Attach drop handlers on nearest form and document (opt-in) + useEffect(() => { + const form = formRef.current; + if (!form) { + return; + } + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + form.addEventListener("dragover", onDragOver); + form.addEventListener("drop", onDrop); + return () => { + form.removeEventListener("dragover", onDragOver); + form.removeEventListener("drop", onDrop); + }; + }, [add]); + + useEffect(() => { + if (!globalDrop) { + return; + } + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + document.addEventListener("dragover", onDragOver); + document.addEventListener("drop", onDrop); + return () => { + document.removeEventListener("dragover", onDragOver); + document.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + const handleChange: ChangeEventHandler = (event) => { + if (event.currentTarget.files) { + add(event.currentTarget.files); + } + }; + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + const files: FileUIPart[] = items.map(({ ...item }) => ({ + ...item, + })); + + onSubmit({ text: event.currentTarget.message.value, files }, event); + }; + + const ctx = useMemo( + () => ({ + files: items.map((item) => ({ ...item, id: item.id })), + add, + remove, + clear, + openFileDialog, + fileInputRef: inputRef, + }), + [items, add, remove, clear, openFileDialog] + ); + + return ( + +