diff --git a/docs/mcp/mcp-api.md b/docs/mcp/mcp-api.md new file mode 100644 index 00000000..5b69d45f --- /dev/null +++ b/docs/mcp/mcp-api.md @@ -0,0 +1,72 @@ +# MCP & AI Provider Error Handling + +The LiveReview WebChat endpoint (`/api/v1/chat/send`) has multiple layers of resilience and error handling for the AI layer and the internal MCP server. + +To prevent user confusion when errors occur, the backend intercepts these failures and injects specific UI components into the response: +- **Blockquote Messages**: A stylized error message explaining what happened. +- **Action Cards**: A card with a button (e.g., "Configure AI Provider", "Try Again") directing the user on how to fix it. +- **Suggested Questions**: Helpful fallback questions (e.g., "Where do I get an AI API key?"). +- **Debug Artifacts**: The raw underlying error (e.g., HTTP 502, JSON parsing errors) stored in the debug logs for developers. + +## Error Handling Cases + +To make it easier to debug, we categorize errors into two main areas: **Internal MCP Issues (Our End)** and **LLM Provider Issues (Model Side)**. + +### 1. Internal MCP Issues (Our End) +These occur rarely, usually due to infrastructure or internal server problems. When these happen, we display a clear message indicating the problem is on our end, and provide debug logs that the user can share with us for troubleshooting. + +| Case | Trigger / Condition | Message Prefix | Action Card Title | Suggested Questions | Fallback Behavior | +|------|---------------------|----------------|-------------------|---------------------|-------------------| +| **MCP Server Offline** | Connection to the MCP server fails (e.g. `502 Bad Gateway`). | `> **Something went wrong on our end**` | **MCP Server Offline** (Button: Try Again) | None | Turn aborted. Debug log shows raw network error. | + +### 2. LLM Provider Issues (Model Side) +These are the most common issues. They occur due to provider rate limits, model deprecations, missing API keys, or hallucinated/truncated formatting. + +| Case | Trigger / Condition | Message Prefix | Action Card Title | Suggested Questions | Fallback Behavior | +|------|---------------------|----------------|-------------------|---------------------|-------------------| +| **Auth / Config Issue** | AI Provider API key is invalid or missing (`ErrCategoryAuth`). | `> **Action Required: AI Provider Issue**` | **Configuration Required** (Button: Configure AI Provider) | `DefaultAIErrorSuggestedQuestions` | Turn aborted. | +| **Provider Overloaded** | AI Provider returns 503 or 429 (`ErrCategoryOverload`). | `> **AI Provider is Busy**` | **High Traffic Detected** (Button: Configure AI Provider) | `DefaultAIErrorSuggestedQuestions` | **Graceful Degradation**: If this happens during `classify`, the backend degrades to `product_guidance` mode, attaches the Action Card, and successfully generates a helpful product answer. | +| **Model Deprecated** | The configured AI model was removed (`ErrCategoryDeprecated`). | `> **Model No Longer Available**` | **Model No Longer Available** (Button: Configure AI Provider) | `DefaultAIErrorSuggestedQuestions` | Turn aborted. | +| **Timeout Error** | AI Provider takes too long to respond (`ErrCategoryTimeout`). | `> **Analysis Took Too Long**` | **Timeout Error** (Button: Try Again) | `DefaultAIErrorSuggestedQuestions` | Turn aborted. | +| **Unexpected AI Error** | Any other unclassified LLM API error (`ErrCategoryUnknown`). | `> **Unexpected Error**` | **Unexpected Error** (Button: Try Again) | `DefaultAIErrorSuggestedQuestions` | Turn aborted. | +| **Model Output Error** | The LLM returns truncated or malformed JSON (e.g., `envelope unmarshal failed`). | `> **Model Output Error**` | **Formatting Error** (Button: Try Again) | `DefaultAIErrorSuggestedQuestions` | Turn aborted (often caused by `maxOutputTokens` limits). | +| **No Data Found** | An analytics query successfully completes but returns 0 rows. | (Context-specific text explaining no data) | None | `DefaultNoDataSuggestedQuestions` | Chart rendering is skipped, dynamic text explaining the empty result is returned. | + +## Flow Overview + +1. **Early Failures**: If the MCP connection fails before the agent loop starts, `webchat_handler.go` intercepts it and constructs a `HistoryEntry` containing the `ActionCard` and `DebugArtifacts`. +2. **During the Agent Loop**: If the LLM call fails, `agent.go` categorizes the error (using `aiconnectors.CategorizeLLMError`). + - For most errors, the loop aborts and returns an error template. + - For `classify` overload errors, it intercepts the error, appends the Action Card, and forces the agent to act as a `product_guidance` agent for that turn. +3. **Output Parsing**: If the LLM succeeds but its JSON is malformed (e.g., due to token limits), `analytics.go` returns `Model Output Error` and attaches the Formatting Error action card. +4. **Persistence**: All of these states correctly persist the `action_card` and `debug_artifacts` to the `chat_messages` table via `persistReply`, so that they reload flawlessly if the user refreshes the page. + +## API Response Structure + +The `/api/v1/chat/send` endpoint returns a structured JSON payload (`WebChatResponse`). To support dynamic UI error rendering without hardcoded string matching on the frontend, the backend explicitly sets the `is_error` flag and provides the `action_card` payload. + +```json +{ + "response": "> **Something went wrong on our end**\n> \n> I am having trouble connecting to my internal data tools right now. The technical details have been attached below for troubleshooting. Please try your request again in a few moments.", + "conversationId": 123, + "sessionId": "abc-123", + "is_error": true, + "action_card": { + "title": "MCP Server Offline", + "description": "The internal data server is unreachable (502 Bad Gateway).", + "button_text": "Try Again", + "action_url": "#retry" + }, + "debug_artifacts": { + "raw_llm_error": "MCP Connection Failed: 502 Bad Gateway" + }, + "suggested_questions": [ + { + "category": "Troubleshooting", + "questions": ["Where do I configure my AI provider?"] + } + ] +} +``` + +By passing `is_error: true`, the React frontend (`ChatConversation.tsx`) automatically wraps the response in a red error blockquote and displays the provided `action_card`. diff --git a/docs/mcp_error_handling.md b/docs/mcp_error_handling.md new file mode 100644 index 00000000..5675eda5 --- /dev/null +++ b/docs/mcp_error_handling.md @@ -0,0 +1,65 @@ +# MCP LLM Error Handling + +When interacting with multiple AI Providers (Google, OpenAI, Anthropic) via `langchaingo`, LLM calls can fail in messy, unpredictable ways. Providers may return HTTP 503s for overload, 429s for rate limits, or 401s for revoked keys. + +If we return these raw errors directly to the MCP client (the chat UI), the turn crashes and the user sees ugly stack traces (e.g., `googleapi: Error 503`). + +To solve this, we use a **centralized UI fallback mechanism** that traps raw SDK errors and gracefully degrades the UI without disrupting the core ReAct loop. + +## The Architecture + +The error handling logic lives in `internal/mcpagent/provider.go` and consists of two functions: + +### 1. `CategorizeLLMError(err error) LLMErrorCategory` +This function intercepts an error, inspects it for `langchaingo` normalizations, unwraps leaked provider-specific errors (like `*googleapi.Error`), and falls back to string matching. + +It maps the messy error into one of four clean categories: +* `ErrCategoryAuth` (401, 403, 404, invalid keys, revoked models) +* `ErrCategoryOverload` (429, 503, rate limits, high demand) +* `ErrCategoryTimeout` (504, context deadline exceeded) +* `ErrCategoryUnknown` (Prompt parsing errors, network disconnects) + +### 2. `GetLLMFallbackMessage(cat LLMErrorCategory) string` +This function takes a category and returns a beautifully formatted, non-emoji Markdown string to show the user. +* If it returns a non-empty string, **the turn is considered saved**. We show the user this message. +* If it returns `""` (for `ErrCategoryUnknown`), the error is fatal and should be bubbled up. + +## How to use it + +We deliberately **do not** wrap the original `a.provider.Complete(...)` function. We keep the core LLM signatures untouched. Instead, you just "pick up" the error immediately after the LLM call fails. + +### Example (from `agent.go`) + +```go +response, usage, err := a.provider.Complete(ctx, history, tools, completeOpts...) +if err != nil { + // 1. Pick up the error and categorize it + errCategory := CategorizeLLMError(err) + + // 2. Ask for a clean UI message + msg := GetLLMFallbackMessage(errCategory) + + // 3. If there is no clean fallback, bubble up the fatal error + if msg == "" { + return "", history, nil, nil, fmt.Errorf("llm completion failed: %w", err) + } + + // 4. Otherwise, inject the fallback message as an AI response and return a nil error! + // This gracefully completes the turn and renders the message in the UI. + history = append(history, HistoryEntry{ + "role": "assistant", + "content": msg, + "text": msg, + }) + return msg, history, nil, nil, nil +} +``` + +## Adding new Fallback Scenarios + +If you need to handle a new failure mode (e.g. `ContextTooLarge`): +1. Add `ErrCategoryContextSize` to the const block in `provider.go`. +2. Add the detection logic to `CategorizeLLMError`. +3. Add the exact Markdown message you want the user to see to the `switch` block in `GetLLMFallbackMessage`. + +This guarantees every MCP LLM call across the app degrades consistently. diff --git a/internal/aiconnectors/errors.go b/internal/aiconnectors/errors.go new file mode 100644 index 00000000..7223b3eb --- /dev/null +++ b/internal/aiconnectors/errors.go @@ -0,0 +1,94 @@ +package aiconnectors + +import ( + "errors" + "strings" + + "github.com/tmc/langchaingo/llms" +) + +type LLMErrorCategory string + +const ( + ErrCategoryAuth LLMErrorCategory = "auth_failed" + ErrCategoryOverload LLMErrorCategory = "overloaded" + ErrCategoryTimeout LLMErrorCategory = "timeout" + ErrCategoryDeprecated LLMErrorCategory = "model_deprecated" + ErrCategoryUnknown LLMErrorCategory = "unknown" +) + +// CategorizeLLMError maps raw SDK errors from Langchain or underlying clients +// into our standard failure categories for consistent UI fallback rendering. +func CategorizeLLMError(err error) LLMErrorCategory { + if err == nil { + return ErrCategoryUnknown + } + + + + // 1. Langchain-Go normalized errors + var llmsErr *llms.Error + if errors.As(err, &llmsErr) { + if llmsErr.Code == llms.ErrCodeAuthentication || llmsErr.Code == llms.ErrCodeResourceNotFound { + return ErrCategoryAuth + } + if llmsErr.Code == llms.ErrCodeRateLimit { + return ErrCategoryOverload + } + } + + // 2. Generic HTTP status codes + var scErr interface{ StatusCode() int } + if errors.As(err, &scErr) { + code := scErr.StatusCode() + if code == 401 || code == 403 || code == 404 { + return ErrCategoryAuth + } + if code == 429 || code == 500 || code == 502 || code == 503 || code == 504 { + return ErrCategoryOverload + } + } + + var hscErr interface{ HTTPStatusCode() int } + if errors.As(err, &hscErr) { + code := hscErr.HTTPStatusCode() + if code == 401 || code == 403 || code == 404 { + return ErrCategoryAuth + } + if code == 429 || code == 500 || code == 502 || code == 503 || code == 504 { + return ErrCategoryOverload + } + } + + // 3. Fallback string matching + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "deprecated") || strings.Contains(msg, "model not found") || + strings.Contains(msg, "model_not_found") || strings.Contains(msg, "no such model") || + strings.Contains(msg, "no longer available") { + return ErrCategoryDeprecated + } + + if strings.Contains(msg, "context deadline") || strings.Contains(msg, "timeout") { + return ErrCategoryTimeout + } + if strings.Contains(msg, "rate limit") || strings.Contains(msg, "too many requests") || + strings.Contains(msg, "status code: 429") || strings.Contains(msg, "status code: 500") || + strings.Contains(msg, "status code: 502") || strings.Contains(msg, "status code: 503") || + strings.Contains(msg, "status code: 504") || strings.Contains(msg, "error 429") || + strings.Contains(msg, "error 500") || strings.Contains(msg, "error 502") || + strings.Contains(msg, "error 503") || strings.Contains(msg, "error 504") || + strings.Contains(msg, "service unavailable") || strings.Contains(msg, "bad gateway") || + strings.Contains(msg, "high demand") { + return ErrCategoryOverload + } + if strings.Contains(msg, "status code: 401") || strings.Contains(msg, "status code: 403") || + strings.Contains(msg, "status code: 404") || strings.Contains(msg, "unauthorized") || + strings.Contains(msg, "error 401") || strings.Contains(msg, "error 403") || + strings.Contains(msg, "error 404") || strings.Contains(msg, "forbidden") { + return ErrCategoryAuth + } + + return ErrCategoryUnknown +} + + diff --git a/internal/aiconnectors/errors_test.go b/internal/aiconnectors/errors_test.go new file mode 100644 index 00000000..849b2dc2 --- /dev/null +++ b/internal/aiconnectors/errors_test.go @@ -0,0 +1,101 @@ +package aiconnectors + +import ( + "errors" + "net/http" + "testing" + + "github.com/tmc/langchaingo/llms" +) + +// mockHTTPError mocks an error that provides a StatusCode() method. +type mockHTTPError struct { + code int + msg string +} + +func (e *mockHTTPError) Error() string { + return e.msg +} + +func (e *mockHTTPError) StatusCode() int { + return e.code +} + +func TestCategorizeLLMError(t *testing.T) { + tests := []struct { + name string + err error + want LLMErrorCategory + }{ + { + name: "Nil error", + err: nil, + want: ErrCategoryUnknown, + }, + { + name: "Langchain Auth Error", + err: &llms.Error{Code: llms.ErrCodeAuthentication}, + want: ErrCategoryAuth, + }, + { + name: "Langchain Rate Limit Error", + err: &llms.Error{Code: llms.ErrCodeRateLimit}, + want: ErrCategoryOverload, + }, + { + name: "HTTP 401 via StatusCode()", + err: &mockHTTPError{code: http.StatusUnauthorized, msg: "unauthorized"}, + want: ErrCategoryAuth, + }, + { + name: "HTTP 503 via StatusCode()", + err: &mockHTTPError{code: http.StatusServiceUnavailable, msg: "service unavailable"}, + want: ErrCategoryOverload, + }, + { + name: "String Match: context deadline", + err: errors.New("operation failed: context deadline exceeded"), + want: ErrCategoryTimeout, + }, + { + name: "String Match: high demand", + err: errors.New("googleapi: Error 503: This model is currently experiencing high demand."), + want: ErrCategoryOverload, + }, + { + name: "String Match: error 429", + err: errors.New("provider returned error 429 too many requests"), + want: ErrCategoryOverload, + }, + { + name: "String Match: model not found", + err: errors.New("model not found or deprecated"), + want: ErrCategoryAuth, + }, + { + name: "String Match: error 404", + err: errors.New("error 404: resource not found"), + want: ErrCategoryAuth, + }, + { + name: "String Match: error 502", + err: errors.New("googleapi: error 502: bad gateway"), + want: ErrCategoryOverload, + }, + { + name: "Unknown Error", + err: errors.New("something went completely wrong"), + want: ErrCategoryUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CategorizeLLMError(tt.err) + if got != tt.want { + t.Errorf("CategorizeLLMError() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/api/chat_conversations_handler.go b/internal/api/chat_conversations_handler.go index 70fc99d7..6814aee9 100644 --- a/internal/api/chat_conversations_handler.go +++ b/internal/api/chat_conversations_handler.go @@ -27,13 +27,14 @@ type ChatConversationSummary struct { // ChatMessageOut is one persisted message, with any charts and file exports // it produced. type ChatMessageOut struct { - ID int64 `json:"id"` - Role string `json:"role"` - Content string `json:"content"` - Charts []WebChatChart `json:"charts,omitempty"` - Files []WebChatFile `json:"files,omitempty"` + ID int64 `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + Charts []WebChatChart `json:"charts,omitempty"` + Files []WebChatFile `json:"files,omitempty"` SuggestedQuestions []mcpagent.SuggestedQuestionCategory `json:"suggested_questions,omitempty"` - DebugArtifacts json.RawMessage `json:"debug_artifacts,omitempty"` + DebugArtifacts json.RawMessage `json:"debug_artifacts,omitempty"` + ActionCard *mcpagent.ActionCard `json:"action_card,omitempty"` } // ChatConversationDetail is a full conversation with its message history, for @@ -201,19 +202,29 @@ func (s *Server) GetConversation(c echo.Context) error { }) } var suggestedQuestions []mcpagent.SuggestedQuestionCategory + var actionCard *mcpagent.ActionCard for _, entry := range m.RawHistoryEntries { - if sq, ok := entry["suggested_questions"].([]mcpagent.SuggestedQuestionCategory); ok && len(sq) > 0 { - suggestedQuestions = sq - break - } else if rawSq, ok := entry["suggested_questions"]; ok && rawSq != nil { - if b, err := json.Marshal(rawSq); err == nil { - if err := json.Unmarshal(b, &suggestedQuestions); err != nil { - log.Warn().Err(err).Msg("failed to unmarshal raw suggested_questions") - } else if len(suggestedQuestions) > 0 { - break - } + // After JSONB round-trip from Postgres, all values come back as + // generic types ([]interface{}, map[string]interface{}, etc.), + // not as typed Go structs. Re-marshal each entry and read the + // fields we care about from a plain map so type assertions are safe. + entryBytes, err := json.Marshal(entry) + if err != nil { + continue + } + var plain map[string]json.RawMessage + if err := json.Unmarshal(entryBytes, &plain); err != nil { + continue + } + if ac, ok := plain["action_card"]; ok && actionCard == nil { + var card mcpagent.ActionCard + if err := json.Unmarshal(ac, &card); err == nil { + actionCard = &card } } + if sq, ok := plain["suggested_questions"]; ok && len(suggestedQuestions) == 0 { + _ = json.Unmarshal(sq, &suggestedQuestions) + } } out.Messages = append(out.Messages, ChatMessageOut{ ID: m.ID, @@ -223,6 +234,7 @@ func (s *Server) GetConversation(c echo.Context) error { Files: files, SuggestedQuestions: suggestedQuestions, DebugArtifacts: m.DebugArtifacts, + ActionCard: actionCard, }) } return c.JSON(http.StatusOK, out) diff --git a/internal/api/webchat_handler.go b/internal/api/webchat_handler.go index 6c873d2d..a591d75d 100644 --- a/internal/api/webchat_handler.go +++ b/internal/api/webchat_handler.go @@ -52,13 +52,17 @@ type WebChatChart struct { } type WebChatResponse struct { - Response string `json:"response"` - Charts []WebChatChart `json:"charts,omitempty"` - Files []WebChatFile `json:"files,omitempty"` + Response string `json:"response"` + Charts []WebChatChart `json:"charts,omitempty"` + Files []WebChatFile `json:"files,omitempty"` SuggestedQuestions []mcpagent.SuggestedQuestionCategory `json:"suggested_questions,omitempty"` - DebugArtifacts json.RawMessage `json:"debug_artifacts,omitempty"` - SessionID string `json:"sessionId,omitempty"` - ConversationID int64 `json:"conversationId"` + DebugArtifacts json.RawMessage `json:"debug_artifacts,omitempty"` + SessionID string `json:"sessionId,omitempty"` + ConversationID int64 `json:"conversationId"` + IsError bool `json:"is_error,omitempty"` + // ActionCard provides structured data for the frontend to render an action card + // (e.g. "Configuration Required") without hardcoding string matching in the UI. + ActionCard *mcpagent.ActionCard `json:"action_card,omitempty"` } // analyticsRoleFor maps permissions onto the SQL catalog's roles. Super admins @@ -197,9 +201,28 @@ func (s *Server) HandleWebChat(c echo.Context) error { mcpSession, err := mcpagent.ConnectMCP(ctx, mcpURL, mcpHeaders) if err != nil { log.Error().Err(err).Str("url", mcpURL).Msg("WebChat: failed to connect to MCP server") - errText := fmt.Sprintf("Internal AI Service Error: Unable to connect to MCP tools (%s).", err.Error()) - persistReply(errText, nil, nil, nil, "", nil) - return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": errText}) + userFriendlyErr, card, debugArt := mcpagent.NewMCPOfflineError(err, mcpURL) + debugArtJSON, _ := json.Marshal(debugArt) + + entry := mcpagent.HistoryEntry{ + "role": "assistant", + "content": userFriendlyErr, + "text": userFriendlyErr, + "action_card": &card, + "debug_artifacts": debugArt, + "is_error": true, + } + persistReply(userFriendlyErr, nil, nil, []mcpagent.HistoryEntry{entry}, string(debugArtJSON), debugArt) + resp := WebChatResponse{ + Response: userFriendlyErr, + SessionID: sessionID, + ConversationID: convID, + IsError: true, + ActionCard: &card, + DebugArtifacts: debugArtJSON, + } + // Return 200 OK so the frontend renders the action card and debug logs instead of a raw network error + return c.JSON(http.StatusOK, resp) } if pc.CurrentOrg != nil && pc.CurrentOrg.Name != "" { @@ -221,9 +244,14 @@ func (s *Server) HandleWebChat(c echo.Context) error { responseText, updatedHistory, artifacts, debugArt, err := agent.RunTurnWithArtifacts(ctx, history, req.Message, sessionID, "livi") if err != nil { log.Error().Err(err).Msg("WebChat: agent loop failed") - errText := fmt.Sprintf("Agent loop failed: %s", err.Error()) - persistReply(errText, nil, nil, nil, "", nil) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": errText}) + userFriendlyErr := "> **AI Processing Error**\n> \n> I encountered an unexpected error while processing your request. Please try asking again." + persistReply(userFriendlyErr, nil, nil, nil, "", nil) + resp := WebChatResponse{ + Response: userFriendlyErr, + SessionID: sessionID, + ConversationID: convID, + } + return c.JSON(http.StatusOK, resp) } // Whatever RunTurnWithArtifacts appended on top of the history we fed it // (possibly including a freshly swapped-in system prompt entry - see @@ -240,6 +268,29 @@ func (s *Server) HandleWebChat(c echo.Context) error { } for _, entry := range turnEntries { + if rawCard, ok := entry["action_card"]; ok && rawCard != nil && resp.ActionCard == nil { + log.Info().Interface("rawCard", rawCard).Msg("WebChat: found action_card in turnEntries") + if b, err := json.Marshal(rawCard); err == nil { + var ac mcpagent.ActionCard + if err := json.Unmarshal(b, &ac); err == nil { + acCopy := ac + resp.ActionCard = &acCopy + log.Info().Interface("actionCard", acCopy).Msg("WebChat: successfully unmarshaled action_card") + } else { + log.Error().Err(err).Msg("WebChat: failed to unmarshal action_card") + } + } else { + log.Error().Err(err).Msg("WebChat: failed to marshal rawCard") + } + } + if isError, ok := entry["is_error"].(bool); ok && isError { + resp.IsError = true + } + if rawDebug, ok := entry["debug_artifacts"]; ok && rawDebug != nil && resp.DebugArtifacts == nil { + if b, err := json.Marshal(rawDebug); err == nil { + resp.DebugArtifacts = b + } + } if sq, ok := entry["suggested_questions"].([]mcpagent.SuggestedQuestionCategory); ok && len(sq) > 0 { resp.SuggestedQuestions = sq break @@ -253,6 +304,10 @@ func (s *Server) HandleWebChat(c echo.Context) error { } } } + // If we have an action_card but no suggested questions were attached, add defaults. + if resp.ActionCard != nil && len(resp.SuggestedQuestions) == 0 { + resp.SuggestedQuestions = mcpagent.DefaultAIErrorSuggestedQuestions + } if vlrender.HasVegaLiteSpec(responseText) { charts, cleanText, err := extractChartsFromVega(responseText) @@ -324,6 +379,7 @@ func (s *Server) HandleWebChat(c echo.Context) error { resp.Files = append(resp.Files, chatFileFromArtifact(art, fileIDs[i])) } + // log.Info().Interface("finalResp", resp).Msg("WebChat: returning response to client") return c.JSON(http.StatusOK, resp) } @@ -356,13 +412,18 @@ func titleFromFirstMessage(message string) string { // (agent.go:148-166), so replaying persisted history without it is safe, and // simpler than tracking which prompt variant was live on a given turn. func persistAssistantMessage(ctx context.Context, chatStore *storagechat.Store, convID int64, userText, assistantText string, turnEntries []mcpagent.HistoryEntry, charts []WebChatChart, artifacts []mcpagent.Artifact, rawLLMOutput string, debugArtifacts json.RawMessage) ([]int64, error) { - assistantEntries := []mcpagent.HistoryEntry{{"role": "assistant", "content": assistantText}} + assistantEntries := turnEntries + hasUser := false for i, e := range turnEntries { if role, _ := e["role"].(string); role == "user" { assistantEntries = turnEntries[i+1:] + hasUser = true break } } + if !hasUser && len(assistantEntries) == 0 { + assistantEntries = []mcpagent.HistoryEntry{{"role": "assistant", "content": assistantText}} + } chartInputs := make([]storagechat.ChartInput, 0, len(charts)) for _, ch := range charts { diff --git a/internal/docindex/docs/.synced-commits.env b/internal/docindex/docs/.synced-commits.env index 94a33528..9dbe34ba 100644 --- a/internal/docindex/docs/.synced-commits.env +++ b/internal/docindex/docs/.synced-commits.env @@ -1,4 +1,4 @@ -GIT_LRC_COMMIT=33e6d7f97a14b906492694ce94961c3744e84700 +GIT_LRC_COMMIT=be23131559d27f870c9a2a1de5a4162756d83f0d GIT_LRC_WIKI_COMMIT=a1bb17c392a46c1f3bf6cfad6396813ccf80a1a9 -HEXMOSHOMEPAGE_DOCS_COMMIT=cd5d33575e10ebd6c7d128d49e8807cb04f2a2d9 +HEXMOSHOMEPAGE_DOCS_COMMIT=7af310a4a53ad928ba813b0488a5a4f96f4ef546 LIVEREVIEW_WIKI_COMMIT=58ee4a1060bf109d889dd2cc969a776fb337d2f7 diff --git a/internal/mcpagent/agent.go b/internal/mcpagent/agent.go index c55b7461..37988fac 100644 --- a/internal/mcpagent/agent.go +++ b/internal/mcpagent/agent.go @@ -3,17 +3,16 @@ package mcpagent import ( "context" "encoding/json" - "errors" "fmt" "strings" "time" + "github.com/livereview/internal/aiconnectors" "github.com/livereview/internal/docindex" "github.com/livereview/internal/logging" "github.com/livereview/internal/vlrender" "github.com/rs/zerolog/log" "github.com/tmc/langchaingo/llms" - "google.golang.org/api/googleapi" ) const ( @@ -153,6 +152,8 @@ func (a *Agent) RunTurnWithArtifacts(ctx context.Context, history []HistoryEntry } log.Warn().Str("failure_reason", schemaIndexFailureReason()).Msg("schema index not ready; refusing the turn before any LLM call") clog.FinalResponse(msg) + history = append(history, HistoryEntry{"role": "user", "content": userText}) + history = append(history, HistoryEntry{"role": "assistant", "content": msg, "text": msg}) return msg, history, nil, nil, nil } @@ -162,8 +163,10 @@ func (a *Agent) RunTurnWithArtifacts(ctx context.Context, history []HistoryEntry if a.analyticsEnabled() { res, err := a.classify(ctx, history, userText, clog) shape := res.Shape + var classifyErr error if err != nil { - log.Warn().Err(err).Msg("call #0 classify failed; degrading to product_guidance for this turn") + log.Warn().Err(err).Str("category", string(aiconnectors.CategorizeLLMError(err))).Msg("call #0 classify failed; degrading to product_guidance for this turn") + classifyErr = err shape = shapeProductGuidance } @@ -179,7 +182,10 @@ func (a *Agent) RunTurnWithArtifacts(ctx context.Context, history []HistoryEntry entry := HistoryEntry{"role": "assistant", "content": text, "text": text} if text == NoDataAnalyticsResponseText { entry["suggested_questions"] = DefaultNoDataSuggestedQuestions + } else { + InjectErrorState(entry, text, debugArt) } + history = append(history, entry) return text, history, artifacts, debugArt, nil } @@ -240,7 +246,48 @@ func (a *Agent) RunTurnWithArtifacts(ctx context.Context, history []HistoryEntry } clog.BranchSelected(string(shape), len(systemPrompt), len(tools)) - return a.runStepLoop(ctx, history, userText, systemPrompt, tools, callNumber, jsonMode, planRetried, "", clog) + text, history, artifacts, debugArt, err := a.runStepLoop(ctx, history, userText, systemPrompt, tools, callNumber, jsonMode, planRetried, "", clog) + + if classifyErr != nil && err == nil { + category := aiconnectors.CategorizeLLMError(classifyErr) + if tpl, ok := LookupErrorTemplate(category); ok { + if len(history) > 0 { + lastIdx := len(history) - 1 + fallbackText, _ := history[lastIdx]["text"].(string) + + // 1. Create the error entry with the Action Card and Debug Logs + errorEntry := HistoryEntry{ + "role": "assistant", + "text": tpl.Message, + "content": tpl.Message, + } + card := tpl.ActionCard + errorEntry["action_card"] = &card + + if debugArt == nil { + debugArt = &DebugArtifacts{} + } + debugArt.RawLLMError = classifyErr.Error() + errorEntry["debug_artifacts"] = debugArt + + // 2. Modify the last entry (the fallback guidance) + newFallbackText := "However, here is some general product guidance:\n\n---\n\n" + fallbackText + history[lastIdx]["text"] = newFallbackText + history[lastIdx]["content"] = newFallbackText + + // 3. Insert the error entry before the fallback guidance entry + var newHistory []HistoryEntry + newHistory = append(newHistory, history[:lastIdx]...) + newHistory = append(newHistory, errorEntry) + newHistory = append(newHistory, history[lastIdx]) + history = newHistory + + text = tpl.Message + "\n\n" + newFallbackText + } + } + } + + return text, history, artifacts, debugArt, err } // Non-analytics path (plain tool-only agent). @@ -295,27 +342,40 @@ func (a *Agent) runStepLoop( if jsonMode { completeOpts = append(completeOpts, llms.WithJSONMode()) } + response, usage, err := a.provider.Complete(ctx, history, tools, completeOpts...) aiElapsed := time.Since(aiStart) if err != nil { - log.Error().Err(err).Int("step", step).Msg("LLM completion failed") + errCategory := aiconnectors.CategorizeLLMError(err) + log.Error().Err(err).Int("step", step).Str("category", string(errCategory)).Msg("LLM completion failed") clog.AIError(callNumber, step, aiElapsed, err) - if isProviderAuthOrModelError(err) { - msg := fmt.Sprintf("> ⚠️ **Action Required: AI Provider Issue**\n> \n> The existing **%s**'s key seems expired/revoked or the model is no longer available.\n\nPlease configure a valid provider to continue: [btn:Configure AI Provider](/settings#ai)", a.provider.Describe()) - clog.FinalResponse(msg + " (stopped after provider auth/model error)") - - history = append(history, HistoryEntry{ - "role": "assistant", - "content": msg, - "text": msg, - "suggested_questions": DefaultAIErrorSuggestedQuestions, - }) - - return msg, history, nil, nil, nil + msg := "" + var card *ActionCard + if tpl, ok := LookupErrorTemplate(errCategory); ok { + msg = tpl.Message + card = &tpl.ActionCard + } + if msg == "" { + return "", history, nil, nil, fmt.Errorf("llm completion step %d: %w", step, err) } - return "", history, nil, nil, fmt.Errorf("llm completion step %d: %w", step, err) + clog.FinalResponse(msg + " (stopped after " + string(errCategory) + ")") + entry := HistoryEntry{ + "role": "assistant", + "content": msg, + "text": msg, + "suggested_questions": DefaultAIErrorSuggestedQuestions, + } + var debugArt *DebugArtifacts + if card != nil { + entry["action_card"] = card + debugArt = &DebugArtifacts{RawLLMError: err.Error()} + entry["debug_artifacts"] = debugArt + } + + history = append(history, entry) + return msg, history, nil, debugArt, nil } log.Debug().Int("step", step).Int("response_len", len(response)).Msg("LLM call succeeded") clog.AIResponse(callNumber, step, aiElapsed, usage.InputTokens, usage.OutputTokens, response) @@ -593,63 +653,12 @@ func (a *Agent) interpretUserMessage(clog *logging.ChatTurnLogger, userText stri return "", err } msg := fmt.Sprintf( - "User query: %s\nOrg context: org_id = %d (%s)\n\n--- dbctx schema context ---\n%s\n\n--- available chart types ---\n%s", + "User query: %s\nOrg context: org_id = %d (%s)\n\n--- dbctx schema context ---\n%s\n\n--- available chart types ---\n%s\n\nNow, generate the JSON object containing the interpretations as requested. Do NOT output any markdown, reasoning, or text outside of the JSON object.", userText, orgID, orgName, tableText, a.chartTypes, ) return msg, nil } -// isProviderAuthOrModelError reports whether an LLM completion error is caused -// by a revoked/expired AI provider key or a deprecated/unavailable model. -func isProviderAuthOrModelError(err error) bool { - if err == nil { - return false - } - - // 1. Check langchaingo standardized errors - var llmsErr *llms.Error - if errors.As(err, &llmsErr) { - if llmsErr.Code == llms.ErrCodeAuthentication || llmsErr.Code == llms.ErrCodeResourceNotFound { - return true - } - } - - // 2. Check Google API typed errors - var gErr *googleapi.Error - if errors.As(err, &gErr) { - if gErr.Code == 401 || gErr.Code == 403 || gErr.Code == 404 { - return true - } - } - - // 3. Check generic interface methods for HTTP status codes (used by OpenAI client wrapper, etc.) - var scErr interface{ StatusCode() int } - if errors.As(err, &scErr) { - code := scErr.StatusCode() - if code == 401 || code == 403 || code == 404 { - return true - } - } - - var hscErr interface{ HTTPStatusCode() int } - if errors.As(err, &hscErr) { - code := hscErr.HTTPStatusCode() - if code == 401 || code == 403 || code == 404 { - return true - } - } - - // 4. Fallback for non-HTTP API responses indicating model unavailability - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "deprecated") || - strings.Contains(msg, "model not found") || - strings.Contains(msg, "status code: 401") || - strings.Contains(msg, "status code: 403") || - strings.Contains(msg, "status code: 404") || - strings.Contains(msg, "unauthorized") || - strings.Contains(msg, "forbidden") -} - // isAuthError reports whether a tool result signals an expired/invalid // session token or missing auth - the exact messages LiveReview's own auth // middleware returns (internal/api/auth/middleware.go). Retrying with a diff --git a/internal/mcpagent/analytics.go b/internal/mcpagent/analytics.go index ee5bb439..8936d5e1 100644 --- a/internal/mcpagent/analytics.go +++ b/internal/mcpagent/analytics.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/livereview/internal/aiconnectors" "github.com/livereview/internal/chatstats" "github.com/livereview/internal/livisql" "github.com/livereview/internal/logging" @@ -1569,12 +1570,27 @@ type interpretEnvelope struct { func parseInterpretations(text string) ([]Interpretation, bool) { body := strings.TrimSpace(vlrender.ExtractJSONBlock(text)) if body == "" { + log.Warn().Msg("parseInterpretations: ExtractJSONBlock returned empty string") return nil, false } var env interpretEnvelope - if err := json.Unmarshal([]byte(body), &env); err == nil && len(env.Interpretations) > 0 { - return normalizeInterpretations(env.Interpretations) + if err := json.Unmarshal([]byte(body), &env); err == nil { + log.Debug().Int("interpretations_count", len(env.Interpretations)).Msg("parseInterpretations: envelope parsed") + if len(env.Interpretations) > 0 { + // Log first entry for debugging. + first := env.Interpretations[0] + log.Debug(). + Str("name", first.Name). + Str("chart_type", first.ChartType). + Int("sql_len", len(strings.TrimSpace(first.SQL))). + Msg("parseInterpretations: first interpretation") + return normalizeInterpretations(env.Interpretations) + } + log.Warn().Str("body_prefix", truncateContent(body, 300)).Msg("parseInterpretations: envelope parsed but interpretations array is empty") + return nil, false + } else { + log.Warn().Err(err).Str("body_prefix", truncateContent(body, 300)).Msg("parseInterpretations: envelope unmarshal failed") } // Bare array fallback. @@ -1590,8 +1606,9 @@ func parseInterpretations(text string) ([]Interpretation, bool) { func normalizeInterpretations(in []Interpretation) ([]Interpretation, bool) { out := make([]Interpretation, 0, len(in)) - for _, interp := range in { + for i, interp := range in { if strings.TrimSpace(interp.SQL) == "" { + log.Warn().Int("index", i).Str("name", interp.Name).Str("chart_type", interp.ChartType).Msg("normalizeInterpretations: skipping interpretation with empty sql") continue } if strings.TrimSpace(interp.ChartType) == "" { @@ -1604,6 +1621,7 @@ func normalizeInterpretations(in []Interpretation) ([]Interpretation, bool) { if strings.TrimSpace(interp.Title) == "" { interp.Title = interp.ChartType + " chart" } + log.Debug().Int("index", i).Str("name", interp.Name).Str("chart_type", interp.ChartType).Int("sql_len", len(interp.SQL)).Msg("normalizeInterpretations: accepted") out = append(out, interp) if len(out) >= maxInterpretations { break @@ -1650,25 +1668,29 @@ func (a *Agent) runMultiInterpret( debug.FullRequest = system + "\n---\n" + userMsg raw, err := a.completeOnce(ctx, clog, 2, "interpret", "multi", 1, system, userMsg) if err != nil { + debug.RawLLMError = err.Error() log.Error().Err(err).Msg("multi-interpret LLM call failed") - if isProviderAuthOrModelError(err) { - msg := fmt.Sprintf("> ⚠️ **Action Required: AI Provider Issue**\n> \n> The existing **%s**'s key seems expired/revoked or the model is no longer available.\n\nPlease configure a valid provider to continue: [btn:Configure AI Provider](/settings#ai)", a.provider.Describe()) - clog.FinalResponse(msg + " (stopped after provider auth/model error)") - return msg, nil, debug, nil + cat := aiconnectors.CategorizeLLMError(err) + msg := "" + if tpl, ok := LookupErrorTemplate(cat); ok { + msg = tpl.Message } - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { - // Intentionally return nil error here so the agent caller treats this turn as a success - // and persists the friendly timeout message to the database, rather than throwing it away. - return "The analysis took longer than expected. Please try asking a narrower or more specific question.", nil, debug, nil //nolint:nilerr + if msg != "" { + clog.FinalResponse(msg + " (stopped after " + string(cat) + ")") + return msg, nil, debug, nil } - return "I had trouble understanding that question. Please try rephrasing.", nil, debug, nil //nolint:nilerr + + log.Warn().Err(err).Str("category", string(cat)).Msg("multi-interpret LLM call failed; no known fallback") + return "I wasn't able to generate an answer this time. Please try rephrasing your question.", nil, debug, nil //nolint:nilerr } debug.LLMRawResponse = raw interps, ok := parseInterpretations(raw) if !ok { log.Warn().Str("raw_preview", truncateContent(raw, 200)).Msg("could not parse interpretations from LLM response") - return "I could not produce a valid analysis plan. Please try rephrasing.", nil, debug, nil + debug.RawLLMError = "Failed to parse LLM output as JSON (often caused by max token limits). Raw output:\n" + raw + formatMsg := "> **Model Output Error**\n> \n> The AI returned data in an unexpected format. Please try rephrasing your question." + return formatMsg, nil, debug, nil } debug.Interpretations = interps diff --git a/internal/mcpagent/error_handling.go b/internal/mcpagent/error_handling.go new file mode 100644 index 00000000..c3a2fdda --- /dev/null +++ b/internal/mcpagent/error_handling.go @@ -0,0 +1,124 @@ +package mcpagent + +import ( + "fmt" + "strings" + + "github.com/livereview/internal/aiconnectors" +) + +// LLMErrorTemplate defines the UI representation of an LLM error, +// including both the chat message text and the Action Card to render. +type LLMErrorTemplate struct { + Message string + ActionCard ActionCard +} + +// LLMErrorTemplates stores the fallback configurations for various LLM errors. +// This allows the frontend to automatically render the correct box without +// hardcoded display logic. +var LLMErrorTemplates = map[aiconnectors.LLMErrorCategory]LLMErrorTemplate{ + aiconnectors.ErrCategoryAuth: { + Message: "> **Action Required: AI Provider Issue**\n> \n> The AI Provider's API key is invalid or the model is missing. Please configure a valid provider in settings to continue.", + ActionCard: ActionCard{ + Title: "Configuration Required", + Description: "Please edit the current AI provider configuration to continue.", + ButtonText: "Configure AI Provider", + ActionURL: "/ai", + }, + }, + aiconnectors.ErrCategoryOverload: { + Message: "> **AI Provider is Busy**\n> \n> The selected model is currently experiencing high traffic. If you continue to see this issue, please edit your configuration to change to a different model or provider.", + ActionCard: ActionCard{ + Title: "High Traffic Detected", + Description: "Please select a different model in settings.", + ButtonText: "Configure AI Provider", + ActionURL: "/ai", + }, + }, + aiconnectors.ErrCategoryDeprecated: { + Message: "> **Model No Longer Available**\n> \n> The selected AI model has been deprecated or removed by the provider. Please update your AI provider configuration to use a different model.", + ActionCard: ActionCard{ + Title: "Model No Longer Available", + Description: "The selected model has been deprecated. Please update your AI provider configuration.", + ButtonText: "Configure AI Provider", + ActionURL: "/ai", + }, + }, + aiconnectors.ErrCategoryTimeout: { + Message: "> **Analysis Took Too Long**\n> \n> The AI took too long to generate your response and timed out. Try asking a narrower or more specific question.", + ActionCard: ActionCard{ + Title: "Timeout Error", + Description: "The request took too long to complete.", + ButtonText: "Try Again", + ActionURL: "#retry", + }, + }, + aiconnectors.ErrCategoryUnknown: { + Message: "> **Unexpected Error**\n> \n> An unexpected error occurred while communicating with the AI provider. The technical details have been attached below for troubleshooting.", + ActionCard: ActionCard{ + Title: "Unexpected Error", + Description: "An unknown error occurred during processing.", + ButtonText: "Try Again", + ActionURL: "#retry", + }, + }, +} + +const ModelOutputErrorText = "Model Output Error" + +// LookupErrorTemplate returns the error template for a given category. +func LookupErrorTemplate(category aiconnectors.LLMErrorCategory) (LLMErrorTemplate, bool) { + tpl, ok := LLMErrorTemplates[category] + return tpl, ok +} + +// MatchErrorTemplateByText returns the error template matching the provided response text. +func MatchErrorTemplateByText(text string) (LLMErrorTemplate, bool) { + for _, tpl := range LLMErrorTemplates { + if strings.HasPrefix(text, tpl.Message) { + return tpl, true + } + } + return LLMErrorTemplate{}, false +} + +// NewMCPOfflineError generates the error response components when the MCP server is unreachable. +func NewMCPOfflineError(err error, mcpURL string) (string, ActionCard, *DebugArtifacts) { + msg := "> **Something went wrong on our end**\n> \n> I am having trouble connecting to my internal data tools right now. The technical details have been attached below for troubleshooting. Please try your request again in a few moments." + + debugLog := fmt.Sprintf("MCP Connection Failed: %s\nURL: %s", err.Error(), mcpURL) + debugArt := &DebugArtifacts{RawLLMError: debugLog} + + card := ActionCard{ + Title: "MCP Server Offline", + Description: "The internal data server is unreachable (502 Bad Gateway).", + ButtonText: "Try Again", + ActionURL: "#retry", + } + + return msg, card, debugArt +} + +// InjectErrorState parses the text and populates the HistoryEntry with the appropriate ActionCard, +// DebugArtifacts, SuggestedQuestions, and sets is_error to true if it matches a known error state. +func InjectErrorState(entry HistoryEntry, text string, debugArt *DebugArtifacts) { + if tpl, ok := MatchErrorTemplateByText(text); ok { + entry["suggested_questions"] = DefaultAIErrorSuggestedQuestions + card := tpl.ActionCard // copy + entry["action_card"] = &card + entry["debug_artifacts"] = debugArt + entry["is_error"] = true + } else if strings.Contains(text, ModelOutputErrorText) { + // Also check for format errors, which are hallucinations, not provider errors + entry["suggested_questions"] = DefaultAIErrorSuggestedQuestions + entry["action_card"] = &ActionCard{ + Title: "Formatting Error", + Description: "The model failed to format the response correctly.", + ButtonText: "Try Again", + ActionURL: "#retry", + } + entry["debug_artifacts"] = debugArt + entry["is_error"] = true + } +} diff --git a/internal/mcpagent/provider.go b/internal/mcpagent/provider.go index 47263577..c2c958c0 100644 --- a/internal/mcpagent/provider.go +++ b/internal/mcpagent/provider.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "github.com/livereview/internal/aiconnectors" "github.com/rs/zerolog/log" "github.com/tmc/langchaingo/llms" @@ -186,3 +185,4 @@ func (p *Provider) historyToMessages(history []HistoryEntry) []llms.MessageConte } return messages } + diff --git a/internal/mcpagent/provider_test.go b/internal/mcpagent/provider_test.go new file mode 100644 index 00000000..d9aac34b --- /dev/null +++ b/internal/mcpagent/provider_test.go @@ -0,0 +1,53 @@ +package mcpagent + +import ( + "testing" + + "github.com/livereview/internal/aiconnectors" +) + +func TestLLMErrorTemplates(t *testing.T) { + tests := []struct { + name string + category aiconnectors.LLMErrorCategory + want string + }{ + { + name: "Auth Error", + category: aiconnectors.ErrCategoryAuth, + want: "**Action Required: AI Provider Issue**\n\nThe AI Provider's API key is invalid or the model is missing. Please configure a valid provider in settings to continue.", + }, + { + name: "Overload Error", + category: aiconnectors.ErrCategoryOverload, + want: "**AI Provider is Busy**\n\nThe selected model is currently experiencing high traffic. If you continue to see this issue, please edit your configuration to change to a different model or provider.", + }, + { + name: "Timeout Error", + category: aiconnectors.ErrCategoryTimeout, + want: "**Analysis Took Too Long**\n\nThe AI took too long to generate your response and timed out. Try asking a narrower or more specific question.", + }, + { + name: "Unknown Error", + category: aiconnectors.ErrCategoryUnknown, + want: "", + }, + { + name: "Unmapped Category", + category: aiconnectors.LLMErrorCategory("something_else"), + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := "" + if tpl, ok := LLMErrorTemplates[tt.category]; ok { + got = tpl.Message + } + if got != tt.want { + t.Errorf("LLMErrorTemplates[%v].Message = %q, want %q", tt.category, got, tt.want) + } + }) + } +} diff --git a/internal/mcpagent/types.go b/internal/mcpagent/types.go index e07130a3..77e2391f 100644 --- a/internal/mcpagent/types.go +++ b/internal/mcpagent/types.go @@ -117,6 +117,7 @@ type DebugArtifacts struct { FullRequest string `json:"full_request"` // system_prompt + schema_context sent to LLM Interpretations []Interpretation `json:"interpretations"` Results []DebugResultEntry `json:"results"` + RawLLMError string `json:"raw_llm_error,omitempty"` } // DebugResultEntry is one interpretation's outcome for the debug view. @@ -134,3 +135,12 @@ type DebugResultEntry struct { RetryCount int `json:"retry_count,omitempty"` Retries []RetryInfo `json:"retries,omitempty"` } + +// ActionCard represents an actionable message for the UI to render as a card +// instead of plain text, like when a configuration change is required. +type ActionCard struct { + Title string `json:"title"` + Description string `json:"description"` + ButtonText string `json:"button_text"` + ActionURL string `json:"action_url"` +} diff --git a/osv-scanner.toml b/osv-scanner.toml new file mode 100644 index 00000000..c31b1de5 --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,7 @@ +[[IgnoredVulns]] +id = "GHSA-vfj7-8cjw-p6xm" +reason = "braces DoS CVE is a dev dependency with no fix currently available. Safe to ignore." + +[[IgnoredVulns]] +id = "CVE-2026-93687" +reason = "braces DoS CVE is a dev dependency with no fix currently available. Safe to ignore." diff --git a/scripts/blast-radius-demo-export/static/components/SummarySlideshow/slideshowParser.js b/scripts/blast-radius-demo-export/static/components/SummarySlideshow/slideshowParser.js index 7127d4f3..cc411df6 100644 --- a/scripts/blast-radius-demo-export/static/components/SummarySlideshow/slideshowParser.js +++ b/scripts/blast-radius-demo-export/static/components/SummarySlideshow/slideshowParser.js @@ -91,13 +91,7 @@ function countWords(text) { function extractPlainText(html) { const raw = html || ''; - if (typeof document === 'undefined') { - return raw.replace(/<[^>]+>/g, ' '); - } - - const container = document.createElement('div'); - container.innerHTML = raw; - return container.textContent || ''; + return raw.replace(/<[^>]+>/g, ' '); } function estimateReadTimeSeconds(text, title) { diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index ed37dc04..c678129b 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "playwright>=1.55.0", "pytest-playwright>=0.7.1", "pytest>=8.4.2", - "urllib3>=2.6.3", + "urllib3>=2.8.0", "werkzeug>=3.1.6", ] diff --git a/scripts/uv.lock b/scripts/uv.lock index 3cc3ed7c..97be5e6a 100644 --- a/scripts/uv.lock +++ b/scripts/uv.lock @@ -954,7 +954,7 @@ requires-dist = [ { name = "playwright", specifier = ">=1.55.0" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "pytest-playwright", specifier = ">=0.7.1" }, - { name = "urllib3", specifier = ">=2.6.3" }, + { name = "urllib3", specifier = ">=2.8.0" }, { name = "werkzeug", specifier = ">=3.1.6" }, ] @@ -1060,11 +1060,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] diff --git a/tests/mcp/requirements.txt b/tests/mcp/requirements.txt index 160930ec..bb827b12 100644 --- a/tests/mcp/requirements.txt +++ b/tests/mcp/requirements.txt @@ -20,7 +20,7 @@ pycparser==3.0 pydantic==2.13.4 pydantic-settings==2.15.0 pydantic_core==2.46.4 -PyJWT==2.13.0 +PyJWT==2.15.0 python-dotenv==1.2.2 python-multipart==0.0.32 referencing==0.37.0 diff --git a/ui/src/api/chatConversations.ts b/ui/src/api/chatConversations.ts index 1cadb9f2..c40b0cd1 100644 --- a/ui/src/api/chatConversations.ts +++ b/ui/src/api/chatConversations.ts @@ -27,6 +27,13 @@ export interface ConversationMessage { files?: ChatFile[]; suggested_questions?: SuggestedQuestionCategory[]; debug_artifacts?: unknown; + is_error?: boolean; + action_card?: { + title: string; + description: string; + button_text: string; + action_url: string; + }; } export interface ConversationDetail { diff --git a/ui/src/api/chatbot.ts b/ui/src/api/chatbot.ts index f081e071..4fbb4e41 100644 --- a/ui/src/api/chatbot.ts +++ b/ui/src/api/chatbot.ts @@ -140,8 +140,16 @@ export interface ChatResponse { files?: ChatFile[]; suggested_questions?: SuggestedQuestionCategory[]; debug_artifacts?: unknown; + is_error?: boolean; sessionId?: string; conversationId: number; + /** 'ai_auth' | 'ai_busy' — set only when the backend hit a provider error. */ + action_card?: { + title: string; + description: string; + button_text: string; + action_url: string; + }; } // The backend now owns conversation history: it loads prior turns by diff --git a/ui/src/pages/Chatbot/ChatActionCard.tsx b/ui/src/pages/Chatbot/ChatActionCard.tsx new file mode 100644 index 00000000..7408c300 --- /dev/null +++ b/ui/src/pages/Chatbot/ChatActionCard.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ChatEntry } from './ChatConversation'; + +interface ChatActionCardProps { + msg: ChatEntry; + idx: number; + messages: ChatEntry[]; + handleSend: (text: string) => void; +} + +const ACTION_RETRY = '#retry'; + +export function ChatActionCard({ msg, idx, messages, handleSend }: ChatActionCardProps) { + const navigate = useNavigate(); + + if (!msg.actionCard) return null; + + return ( +
{msg.actionCard.description}
+
-
);
// Debug artifacts (SQL, CSV, Vega spec, schema context, system prompt, raw
@@ -107,9 +107,10 @@ interface DebugArtifacts {
repaired_sql?: string;
}>;
}>;
+ raw_llm_error?: string;
}
-interface ChatEntry {
+export interface ChatEntry {
id: string;
role: 'user' | 'assistant';
text: string;
@@ -117,6 +118,13 @@ interface ChatEntry {
files?: ChatFile[];
suggestedQuestions?: SuggestedQuestionCategory[];
debugArtifacts?: DebugArtifacts | null;
+ isError?: boolean;
+ actionCard?: {
+ title: string;
+ description: string;
+ button_text: string;
+ action_url: string;
+ };
}
function formatRowCount(rows?: number): string {
@@ -169,7 +177,7 @@ function extractTrailingDataDetails(text: string): { body: string; details: { la
return { body: lines.slice(0, end).join('\n'), details };
}
-function formatText(rawText: string): React.ReactNode[] {
+function formatText(rawText: string, isErrorMsg: boolean = false): React.ReactNode[] {
const { body: text, details } = extractTrailingDataDetails(rawText);
const parts: React.ReactNode[] = [];
// Ensure buttons are always inline with the preceding text by stripping newlines before them
@@ -217,22 +225,13 @@ function formatText(rawText: string): React.ReactNode[] {
}
i = currI - 1;
- const isError = blockquoteLines.length > 0 && blockquoteLines[0].includes('Action Required:');
-
- if (isError) {
+ if (isErrorMsg) {
parts.push(
- Please edit the current AI provider model to continue.
-++ ); +} diff --git a/ui/src/pages/Chatbot/ChatLayout.tsx b/ui/src/pages/Chatbot/ChatLayout.tsx index 1c93b936..cb06a7ab 100644 --- a/ui/src/pages/Chatbot/ChatLayout.tsx +++ b/ui/src/pages/Chatbot/ChatLayout.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Outlet } from 'react-router-dom'; import { ConversationSidebar } from './ConversationSidebar'; import { useChatSidebarReservedWidth, useChatSidebarResizing } from '../../store/chatSidebar'; @@ -16,6 +16,16 @@ export const ChatLayout: React.FC = () => { const reservedWidth = useChatSidebarReservedWidth(); const resizing = useChatSidebarResizing(); + useEffect(() => { + // Disable body scrolling on the chat layout to prevent a double scrollbar + // caused by subpixel layout overflows or banners pushing the layout down. + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = originalOverflow; + }; + }, []); + return (+ {formatLine(lines[0])} +++ {lines.slice(1).map((bLine, bIdx) => ( ++*:first-child]:mt-0'}> + {formatLine(bLine)} ++ ))} +