mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 02:16:56 +00:00
feat(bgalert): surface non-retryable background worker errors to admin UI
- Add bgalert package: classify provider errors (auth, billing, model_not_found), store alert in system_configs, broadcast WS event - Wire AlertDeps into consolidation (episodic, semantic, dreaming) and vault enrich workers to report failures after retry exhaustion - Auto-clear alert when admin changes provider-related system configs - Add BackgroundErrorBanner component with dismiss + "Fix in Settings" - Lift settings modal state to AppLayout so banner can open it - Add EventBackgroundError to admin-only WS event filter - Add i18n translations (en/vi/zh) for alert messages and reasons
This commit is contained in:
1 parent
21cc208813
commit
1a471cbde3
16 files changed
+309
-8
No files matched your search
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/cache"
|
||||
@@ -201,6 +202,7 @@ func runGateway() {
|
||||
SystemConfigs: pgStores.SystemConfigs,
|
||||
Registry: providerRegistry,
|
||||
Extractor: kgExtractor,
|
||||
AlertDeps: bgalert.AlertDeps{SystemConfigs: pgStores.SystemConfigs, MsgBus: msgBus},
|
||||
AgentStore: pgStores.Agents,
|
||||
})
|
||||
defer cleanupConsolidation()
|
||||
@@ -222,6 +224,7 @@ func runGateway() {
|
||||
EventBus: domainBus,
|
||||
MsgBus: msgBus,
|
||||
TeamStore: pgStores.Teams,
|
||||
AlertDeps: bgalert.AlertDeps{SystemConfigs: pgStores.SystemConfigs, MsgBus: msgBus},
|
||||
})
|
||||
enrichProgress = ep
|
||||
enrichWorker = ew
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Package bgalert reports non-retryable LLM errors from background workers
|
||||
// to the web UI via system_configs + WS events.
|
||||
package bgalert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// AlertKeyProviderError is the system_configs key for background provider errors.
|
||||
const AlertKeyProviderError = "alert.background.provider_error"
|
||||
|
||||
// AlertDeps bundles dependencies for error reporting.
|
||||
type AlertDeps struct {
|
||||
SystemConfigs store.SystemConfigStore
|
||||
MsgBus bus.EventPublisher
|
||||
}
|
||||
|
||||
// ProviderErrorPayload is stored in system_configs and sent via WS event.
|
||||
type ProviderErrorPayload struct {
|
||||
Reason string `json:"reason"`
|
||||
Worker string `json:"worker"`
|
||||
Message string `json:"message"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// alertableReasons are non-retryable errors that should surface to the user.
|
||||
var alertableReasons = map[providers.FailoverReason]bool{
|
||||
providers.FailoverAuth: true,
|
||||
providers.FailoverAuthPermanent: true,
|
||||
providers.FailoverBilling: true,
|
||||
providers.FailoverModelNotFound: true,
|
||||
}
|
||||
|
||||
// IsAlertableError returns true if the error is non-retryable and should
|
||||
// be surfaced to the user (auth, billing, model_not_found).
|
||||
func IsAlertableError(err error) (providers.FailoverReason, bool) {
|
||||
if err == nil {
|
||||
return "", false
|
||||
}
|
||||
cls := providers.ClassifyHTTPError(providers.NewDefaultClassifier(), err)
|
||||
if cls.Kind != "reason" {
|
||||
return "", false
|
||||
}
|
||||
return cls.Reason, alertableReasons[cls.Reason]
|
||||
}
|
||||
|
||||
// ReportProviderError stores an alert in system_configs and broadcasts a
|
||||
// WS event when the error is non-retryable (auth/billing/model_not_found).
|
||||
// Safe to call with nil deps or for retryable errors — returns silently.
|
||||
func ReportProviderError(ctx context.Context, deps AlertDeps, workerName string, err error) {
|
||||
if deps.SystemConfigs == nil || err == nil {
|
||||
return
|
||||
}
|
||||
reason, alertable := IsAlertableError(err)
|
||||
if !alertable {
|
||||
return
|
||||
}
|
||||
|
||||
payload := ProviderErrorPayload{
|
||||
Reason: string(reason),
|
||||
Worker: workerName,
|
||||
Message: sanitizeErrorMessage(err.Error()),
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
data, jsonErr := json.Marshal(payload)
|
||||
if jsonErr != nil {
|
||||
slog.Warn("bgalert: marshal failed", "err", jsonErr)
|
||||
return
|
||||
}
|
||||
|
||||
if setErr := deps.SystemConfigs.Set(ctx, AlertKeyProviderError, string(data)); setErr != nil {
|
||||
slog.Warn("bgalert: store alert failed", "err", setErr)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Warn("bgalert: provider error stored",
|
||||
"worker", workerName, "reason", reason, "err", err)
|
||||
|
||||
if deps.MsgBus != nil {
|
||||
tenantID := store.TenantIDFromContext(ctx)
|
||||
deps.MsgBus.Broadcast(bus.Event{
|
||||
Name: protocol.EventBackgroundError,
|
||||
TenantID: tenantID,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ClearProviderError removes the alert key from system_configs.
|
||||
// Ignores "not found" errors. Safe to call when no alert exists.
|
||||
func ClearProviderError(ctx context.Context, configs store.SystemConfigStore) {
|
||||
if configs == nil {
|
||||
return
|
||||
}
|
||||
_ = configs.Delete(ctx, AlertKeyProviderError)
|
||||
}
|
||||
|
||||
// apiKeyPattern matches common API key formats in error messages.
|
||||
var apiKeyPattern = regexp.MustCompile(`(?i)(sk-|Bearer\s+|api[_-]?key[=:]\s*)[a-zA-Z0-9_-]{4,}`)
|
||||
|
||||
// sanitizeErrorMessage strips potentially sensitive content from error messages.
|
||||
// Keeps the message useful for diagnostics while removing API keys and long bodies.
|
||||
func sanitizeErrorMessage(msg string) string {
|
||||
msg = apiKeyPattern.ReplaceAllString(msg, "${1}****")
|
||||
// Rune-safe truncation to avoid corrupting multi-byte UTF-8 characters.
|
||||
const maxLen = 200
|
||||
if runes := []rune(msg); len(runes) > maxLen {
|
||||
msg = string(runes[:maxLen]) + "..."
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providerresolve"
|
||||
@@ -29,6 +30,7 @@ type dreamingWorker struct {
|
||||
memoryStore store.MemoryStore
|
||||
systemConfigs store.SystemConfigStore // per-tenant provider config
|
||||
registry *providers.Registry // provider resolution
|
||||
alertDeps bgalert.AlertDeps
|
||||
|
||||
// threshold/debounce are the global defaults. Per-agent overrides come
|
||||
// from resolveConfig which reads the agent's MemoryConfig.Dreaming JSONB.
|
||||
@@ -160,6 +162,7 @@ func (w *dreamingWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
|
||||
// Build LLM prompt and call provider.
|
||||
synthesis, err := w.synthesize(ctx, provider, model, entries)
|
||||
if err != nil {
|
||||
bgalert.ReportProviderError(ctx, w.alertDeps, "dreaming", err)
|
||||
slog.Warn("dreaming: LLM synthesis failed", "err", err, "agent", agentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providerresolve"
|
||||
@@ -21,6 +22,7 @@ type episodicWorker struct {
|
||||
systemConfigs store.SystemConfigStore // per-tenant provider config
|
||||
registry *providers.Registry // provider resolution
|
||||
eventBus eventbus.DomainEventBus
|
||||
alertDeps bgalert.AlertDeps
|
||||
}
|
||||
|
||||
// resolveProvider delegates to shared background provider resolution.
|
||||
@@ -45,6 +47,8 @@ func (w *episodicWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("episodic: invalid tenant_id %q: %w", event.TenantID, err)
|
||||
}
|
||||
// Inject tenant context so store queries and bgalert scope correctly.
|
||||
ctx = store.WithTenantID(ctx, tenantUUID)
|
||||
agentUUID, err := uuid.Parse(event.AgentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("episodic: invalid agent_id %q: %w", event.AgentID, err)
|
||||
@@ -68,6 +72,7 @@ func (w *episodicWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
|
||||
if provider != nil {
|
||||
summary, err = w.summarizeSession(ctx, provider, model, payload)
|
||||
if err != nil {
|
||||
bgalert.ReportProviderError(ctx, w.alertDeps, "episodic", err)
|
||||
return fmt.Errorf("episodic: summarize: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
@@ -15,6 +17,7 @@ type semanticWorker struct {
|
||||
kgStore store.KnowledgeGraphStore
|
||||
extractor EntityExtractor
|
||||
eventBus eventbus.DomainEventBus
|
||||
alertDeps bgalert.AlertDeps
|
||||
}
|
||||
|
||||
// Handle extracts entities and relations from an episodic summary.
|
||||
@@ -24,6 +27,13 @@ func (w *semanticWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
|
||||
return fmt.Errorf("semantic: unexpected payload type %T", event.Payload)
|
||||
}
|
||||
|
||||
// Inject tenant context so bgalert scopes correctly.
|
||||
if event.TenantID != "" {
|
||||
if tid, err := uuid.Parse(event.TenantID); err == nil {
|
||||
ctx = store.WithTenantID(ctx, tid)
|
||||
}
|
||||
}
|
||||
|
||||
if w.extractor == nil || payload.Summary == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -31,6 +41,7 @@ func (w *semanticWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
|
||||
// Extract entities/relations from summary (much cheaper than full session)
|
||||
result, err := w.extractor.Extract(ctx, payload.Summary)
|
||||
if err != nil {
|
||||
bgalert.ReportProviderError(ctx, w.alertDeps, "kg_extraction", err)
|
||||
slog.Warn("semantic: extraction failed", "episodic_id", payload.EpisodicID, "err", err)
|
||||
return nil // non-fatal: extraction failure doesn't block pipeline
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
@@ -24,6 +25,7 @@ type ConsolidationDeps struct {
|
||||
SystemConfigs store.SystemConfigStore // per-tenant provider config
|
||||
Registry *providers.Registry // provider resolution
|
||||
Extractor EntityExtractor
|
||||
AlertDeps bgalert.AlertDeps // for reporting non-retryable LLM errors
|
||||
// AgentStore is optional: when present, the dreaming worker reads
|
||||
// per-agent overrides from MemoryConfig.Dreaming. If nil, the worker
|
||||
// uses its built-in defaults for every agent.
|
||||
@@ -39,11 +41,13 @@ func Register(deps ConsolidationDeps) func() {
|
||||
systemConfigs: deps.SystemConfigs,
|
||||
registry: deps.Registry,
|
||||
eventBus: deps.EventBus,
|
||||
alertDeps: deps.AlertDeps,
|
||||
}
|
||||
semantic := &semanticWorker{
|
||||
kgStore: deps.KGStore,
|
||||
extractor: deps.Extractor,
|
||||
eventBus: deps.EventBus,
|
||||
alertDeps: deps.AlertDeps,
|
||||
}
|
||||
dedup := &dedupWorker{
|
||||
kgStore: deps.KGStore,
|
||||
@@ -54,6 +58,7 @@ func Register(deps ConsolidationDeps) func() {
|
||||
memoryStore: deps.MemoryStore,
|
||||
systemConfigs: deps.SystemConfigs,
|
||||
registry: deps.Registry,
|
||||
alertDeps: deps.AlertDeps,
|
||||
threshold: dreamingDefaultThreshold,
|
||||
debounce: dreamingDefaultDebounce,
|
||||
resolveConfig: newAgentStoreResolver(deps.AgentStore),
|
||||
|
||||
@@ -147,7 +147,8 @@ func isAdminOnlyEvent(name string) bool {
|
||||
case protocol.EventNodePairRequested, protocol.EventNodePairResolved,
|
||||
protocol.EventDevicePairReq, protocol.EventDevicePairRes,
|
||||
protocol.EventAgentLinkCreated, protocol.EventAgentLinkUpdated, protocol.EventAgentLinkDeleted,
|
||||
protocol.EventWorkspaceFileChanged:
|
||||
protocol.EventWorkspaceFileChanged,
|
||||
protocol.EventBackgroundError:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
@@ -24,6 +25,17 @@ func NewSystemConfigsHandler(s store.SystemConfigStore, msgBus *bus.MessageBus)
|
||||
// validKeyRe allows alphanumeric, dots, underscores, hyphens (1-100 chars).
|
||||
var validKeyRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,100}$`)
|
||||
|
||||
// providerRelatedKeys lists system config keys whose changes should clear
|
||||
// background worker error alerts (user is likely fixing the root cause).
|
||||
var providerRelatedKeys = map[string]bool{
|
||||
"background.provider": true,
|
||||
"background.model": true,
|
||||
"agent.default_provider": true,
|
||||
"agent.default_model": true,
|
||||
"embedding.provider": true,
|
||||
"embedding.model": true,
|
||||
}
|
||||
|
||||
// RegisterRoutes registers system config endpoints on the given mux.
|
||||
func (h *SystemConfigsHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /v1/system-configs", requireAuth("", h.handleList))
|
||||
@@ -74,6 +86,11 @@ func (h *SystemConfigsHandler) handleSet(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-clear background error alert when provider settings change.
|
||||
if providerRelatedKeys[key] {
|
||||
bgalert.ClearProviderError(r.Context(), h.store)
|
||||
}
|
||||
|
||||
// Broadcast change with tenant context so in-memory config refreshes for the right tenant.
|
||||
// Use fresh context with tenant ID — request context may be canceled before subscriber runs.
|
||||
if h.msgBus != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
@@ -45,6 +46,7 @@ type EnrichWorkerDeps struct {
|
||||
EventBus eventbus.DomainEventBus
|
||||
MsgBus bus.EventPublisher // for WS event broadcast
|
||||
TeamStore store.TaskCommentStore // for Phase 2.5 task-based auto-linking (nil-safe)
|
||||
AlertDeps bgalert.AlertDeps // for reporting non-retryable LLM errors
|
||||
}
|
||||
|
||||
// RegisterEnrichWorker subscribes the enrichment worker to vault doc events.
|
||||
@@ -57,6 +59,7 @@ func RegisterEnrichWorker(deps EnrichWorkerDeps) (func(), *EnrichProgress, *Enri
|
||||
systemConfigs: deps.SystemConfigs,
|
||||
registry: deps.Registry,
|
||||
msgBus: deps.MsgBus,
|
||||
alertDeps: deps.AlertDeps,
|
||||
dedup: make(map[string]string),
|
||||
sem: semaphore.NewWeighted(enrichMaxConcurrent),
|
||||
progress: progress,
|
||||
@@ -75,6 +78,7 @@ type EnrichWorker struct {
|
||||
systemConfigs store.SystemConfigStore // per-tenant provider config
|
||||
registry *providers.Registry // provider resolution
|
||||
msgBus bus.EventPublisher // for error event broadcast
|
||||
alertDeps bgalert.AlertDeps // for reporting non-retryable LLM errors
|
||||
queue enrichBatchQueue
|
||||
progress *EnrichProgress
|
||||
|
||||
@@ -201,6 +205,11 @@ func (w *EnrichWorker) Handle(ctx context.Context, event eventbus.DomainEvent) e
|
||||
return nil // another goroutine already processing this agent's queue
|
||||
}
|
||||
|
||||
// Inject tenant context so store queries and bgalert scope correctly.
|
||||
if tid, parseErr := uuid.Parse(payload.TenantID); parseErr == nil {
|
||||
ctx = store.WithTenantID(ctx, tid)
|
||||
}
|
||||
|
||||
// Create per-tenant cancel context for stop capability.
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
w.cancelFuncs.Store(payload.TenantID, cancel)
|
||||
@@ -517,6 +526,7 @@ func (w *EnrichWorker) chatWithRetry(ctx context.Context, provider providers.Pro
|
||||
}
|
||||
return strings.TrimSpace(resp.Content), nil
|
||||
}
|
||||
bgalert.ReportProviderError(ctx, w.alertDeps, "vault_enrich", lastErr)
|
||||
return "", fmt.Errorf("%s exhausted %d retries: %w", logPrefix, enrichMaxRetries, lastErr)
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,9 @@ const (
|
||||
|
||||
// Vault enrichment pipeline progress.
|
||||
EventVaultEnrichProgress = "vault.enrich.progress"
|
||||
|
||||
// Background worker alerts (non-retryable LLM errors).
|
||||
EventBackgroundError = "background.error"
|
||||
)
|
||||
|
||||
// Agent event subtypes (in payload.type)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet, useLocation } from "react-router";
|
||||
import { WifiOff } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { Topbar } from "./topbar";
|
||||
import { BackgroundErrorBanner } from "./background-error-banner";
|
||||
import { ErrorBoundary } from "@/components/shared/error-boundary";
|
||||
import { useUiStore } from "@/stores/use-ui-store";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
@@ -29,6 +30,7 @@ export function AppLayout() {
|
||||
const setMobileSidebarOpen = useUiStore((s) => s.setMobileSidebarOpen);
|
||||
const connected = useAuthStore((s) => s.connected);
|
||||
const isMobile = useIsTablet();
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
// Close mobile sidebar on route change (e.g. programmatic navigation, back button)
|
||||
useEffect(() => {
|
||||
@@ -62,7 +64,8 @@ export function AppLayout() {
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
)}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Topbar />
|
||||
<Topbar settingsOpen={settingsOpen} onSettingsOpenChange={setSettingsOpen} />
|
||||
<BackgroundErrorBanner settingsOpen={settingsOpen} onOpenSettings={() => setSettingsOpen(true)} />
|
||||
{!connected && (
|
||||
<div className="flex items-center gap-2 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||
<WifiOff className="h-4 w-4 shrink-0" />
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, X, Settings2 } from "lucide-react";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { useWsEvent } from "@/hooks/use-ws-event";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ProviderErrorPayload {
|
||||
reason: string;
|
||||
worker: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface BackgroundErrorBannerProps {
|
||||
settingsOpen: boolean;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export function BackgroundErrorBanner({ settingsOpen, onOpenSettings }: BackgroundErrorBannerProps) {
|
||||
const { t } = useTranslation("system-settings");
|
||||
const http = useHttp();
|
||||
const role = useAuthStore((s) => s.role);
|
||||
const isAdmin = role === "admin" || role === "owner";
|
||||
const [error, setError] = useState<ProviderErrorPayload | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
// Fetch alert state from the specific system config key.
|
||||
const fetchAlert = useCallback(() => {
|
||||
if (!isAdmin) return;
|
||||
http.get<{ key: string; value: string }>("/v1/system-configs/alert.background.provider_error")
|
||||
.then((res) => {
|
||||
if (res.value) {
|
||||
try { setError(JSON.parse(res.value)); } catch { /* ignore */ }
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch(() => { setError(null); }); // 404 = no alert
|
||||
}, [http, isAdmin]);
|
||||
|
||||
// Fetch on mount
|
||||
useEffect(() => { fetchAlert(); }, [fetchAlert]);
|
||||
|
||||
// Re-fetch when settings modal closes (user may have fixed the issue)
|
||||
const prevSettingsOpen = useRef(settingsOpen);
|
||||
useEffect(() => {
|
||||
if (prevSettingsOpen.current && !settingsOpen) fetchAlert();
|
||||
prevSettingsOpen.current = settingsOpen;
|
||||
}, [settingsOpen, fetchAlert]);
|
||||
|
||||
// Real-time WS event
|
||||
useWsEvent("background.error", useCallback((payload: unknown) => {
|
||||
setError(payload as ProviderErrorPayload);
|
||||
setDismissed(false);
|
||||
}, []));
|
||||
|
||||
if (!isAdmin || !error || dismissed) return null;
|
||||
|
||||
const reasonKey = `alert.reason.${error.reason}`;
|
||||
const reasonText = t(reasonKey, { defaultValue: t("alert.reason.unknown") });
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1">
|
||||
{t("alert.message", { reason: reasonText })}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={onOpenSettings} className="shrink-0 gap-1.5 h-7 text-xs">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
{t("alert.openSettings")}
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="shrink-0 rounded p-1 hover:bg-destructive/20"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,12 @@ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { AboutDialog } from "./about-dialog";
|
||||
import { SystemSettingsModal } from "./system-settings-modal";
|
||||
|
||||
export function Topbar() {
|
||||
interface TopbarProps {
|
||||
settingsOpen: boolean;
|
||||
onSettingsOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function Topbar({ settingsOpen, onSettingsOpenChange }: TopbarProps) {
|
||||
const { t } = useTranslation("topbar");
|
||||
const theme = useUiStore((s) => s.theme);
|
||||
const setTheme = useUiStore((s) => s.setTheme);
|
||||
@@ -29,7 +34,7 @@ export function Topbar() {
|
||||
const isMobile = useIsMobile();
|
||||
const isDark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const { status: embStatus } = useEmbeddingStatus();
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const setSettingsOpen = onSettingsOpenChange;
|
||||
const role = useAuthStore((s) => s.role);
|
||||
const isAdmin = role === "admin" || role === "owner";
|
||||
|
||||
|
||||
@@ -76,5 +76,16 @@
|
||||
"maxTokensHint": "Token limit for compacted summary.",
|
||||
"info": "When a group chat exceeds the threshold, the LLM automatically summarizes older messages into a single compact entry, keeping the most recent ones intact for context."
|
||||
},
|
||||
"moreConfig": "More Config"
|
||||
"moreConfig": "More Config",
|
||||
"alert": {
|
||||
"message": "Background workers failed: {{reason}}. Features like memory consolidation and vault enrichment may not work.",
|
||||
"openSettings": "Fix in Settings",
|
||||
"reason": {
|
||||
"auth": "Authentication failed (invalid or expired API key)",
|
||||
"auth_permanent": "API key revoked or disabled",
|
||||
"billing": "Billing issue (credits exhausted or payment required)",
|
||||
"model_not_found": "Model not found (invalid model name or not available)",
|
||||
"unknown": "Unknown provider error"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,5 +76,16 @@
|
||||
"maxTokensHint": "Giới hạn token cho bản tóm tắt.",
|
||||
"info": "Khi nhóm chat vượt ngưỡng, LLM tự động tóm tắt các tin nhắn cũ thành một mục gọn, giữ nguyên các tin gần nhất cho ngữ cảnh."
|
||||
},
|
||||
"moreConfig": "Cấu hình thêm"
|
||||
"moreConfig": "Cấu hình thêm",
|
||||
"alert": {
|
||||
"message": "Tác vụ nền gặp lỗi: {{reason}}. Các tính năng như tổng hợp bộ nhớ và làm giàu vault có thể không hoạt động.",
|
||||
"openSettings": "Sửa trong Cài đặt",
|
||||
"reason": {
|
||||
"auth": "Xác thực thất bại (API key không hợp lệ hoặc hết hạn)",
|
||||
"auth_permanent": "API key đã bị thu hồi hoặc vô hiệu hóa",
|
||||
"billing": "Lỗi thanh toán (hết credit hoặc cần thanh toán)",
|
||||
"model_not_found": "Không tìm thấy model (tên model không hợp lệ)",
|
||||
"unknown": "Lỗi nhà cung cấp không xác định"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,5 +76,16 @@
|
||||
"maxTokensHint": "压缩摘要的Token限制。",
|
||||
"info": "当群聊超过阈值时,LLM自动将旧消息总结为一条紧凑记录,保留最近的消息以保持上下文。"
|
||||
},
|
||||
"moreConfig": "更多配置"
|
||||
"moreConfig": "更多配置",
|
||||
"alert": {
|
||||
"message": "后台任务失败:{{reason}}。记忆整合和知识库增强等功能可能无法正常工作。",
|
||||
"openSettings": "前往设置修复",
|
||||
"reason": {
|
||||
"auth": "认证失败(API密钥无效或已过期)",
|
||||
"auth_permanent": "API密钥已被吊销或禁用",
|
||||
"billing": "计费问题(余额不足或需要付款)",
|
||||
"model_not_found": "模型未找到(模型名称无效或不可用)",
|
||||
"unknown": "未知的提供商错误"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user