Bläddra i källkod

feat(notifications): dedicate AI Failure Detection notification event (#1794)

  Split Obico failure-detection dispatch out of the multiplexed
  on_printer_error event onto its own on_ai_failure_detection event so
  users can subscribe to AI alerts without also enabling HMS hardware-
  error pages, and so the discoverable label "AI Failure Detection" is
  what subscribes them rather than the unrelated "Printer Error" toggle.

  New column on notification_providers (default False, branched
  SQLite/Postgres migration), new notification_service.on_ai_failure_detection
  method, new ai_failure_detection template, obico_actions._notify swap.
  Frontend gets a summary badge, a toggle row with description, and ntfy
  priority surfacing. 14 new tests pin the routing + the regression guard
  ("Printer Error" alone must NOT receive AI notifications now). 11 locales
  covered.

  Existing providers keep working: HMS hardware errors continue to ride
  on_printer_error unchanged; users who want spaghetti alerts opt in via
  the new toggle.
maziggy 2 månader sedan
förälder
incheckning
4206d675eb
33 ändrade filer med 564 tillägg och 10 borttagningar
  1. 0 0
      CHANGELOG.md
  2. 2 0
      backend/app/api/routes/notifications.py
  3. 15 0
      backend/app/core/database.py
  4. 1 0
      backend/app/models/notification.py
  5. 6 0
      backend/app/models/notification_template.py
  6. 5 0
      backend/app/schemas/notification.py
  7. 39 0
      backend/app/services/notification_service.py
  8. 10 7
      backend/app/services/obico_actions.py
  9. 143 0
      backend/tests/unit/services/test_notification_service.py
  10. 113 0
      backend/tests/unit/services/test_obico_actions.py
  11. 50 0
      frontend/src/__tests__/components/AddNotificationModal.test.tsx
  12. 1 0
      frontend/src/__tests__/components/NotificationProviderCard.test.tsx
  13. 128 0
      frontend/src/__tests__/components/NotificationProviderCardAiFailureDetection.test.tsx
  14. 1 0
      frontend/src/__tests__/components/NotificationProviderCardStockAlerts.test.tsx
  15. 1 0
      frontend/src/__tests__/mocks/handlers.ts
  16. 3 0
      frontend/src/api/client.ts
  17. 7 0
      frontend/src/components/AddNotificationModal.tsx
  18. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  19. 2 0
      frontend/src/i18n/locales/de.ts
  20. 2 0
      frontend/src/i18n/locales/en.ts
  21. 2 0
      frontend/src/i18n/locales/es.ts
  22. 2 0
      frontend/src/i18n/locales/fr.ts
  23. 2 0
      frontend/src/i18n/locales/it.ts
  24. 2 0
      frontend/src/i18n/locales/ja.ts
  25. 2 0
      frontend/src/i18n/locales/ko.ts
  26. 2 0
      frontend/src/i18n/locales/pt-BR.ts
  27. 2 0
      frontend/src/i18n/locales/tr.ts
  28. 2 0
      frontend/src/i18n/locales/zh-CN.ts
  29. 2 0
      frontend/src/i18n/locales/zh-TW.ts
  30. 0 0
      static/assets/index-CksvU0PF.js
  31. 0 1
      static/assets/index-D9kvaB_m.css
  32. 1 0
      static/assets/index-DNavQjwR.css
  33. 2 2
      static/index.html

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
CHANGELOG.md


+ 2 - 0
backend/app/api/routes/notifications.py

@@ -47,6 +47,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         # Printer status events
         # Printer status events
         "on_printer_offline": provider.on_printer_offline,
         "on_printer_offline": provider.on_printer_offline,
         "on_printer_error": provider.on_printer_error,
         "on_printer_error": provider.on_printer_error,
+        "on_ai_failure_detection": provider.on_ai_failure_detection,
         "on_filament_low": provider.on_filament_low,
         "on_filament_low": provider.on_filament_low,
         "on_maintenance_due": provider.on_maintenance_due,
         "on_maintenance_due": provider.on_maintenance_due,
         # AMS environmental alarms (regular AMS)
         # AMS environmental alarms (regular AMS)
@@ -127,6 +128,7 @@ async def create_notification_provider(
         # Printer status events
         # Printer status events
         on_printer_offline=provider_data.on_printer_offline,
         on_printer_offline=provider_data.on_printer_offline,
         on_printer_error=provider_data.on_printer_error,
         on_printer_error=provider_data.on_printer_error,
+        on_ai_failure_detection=provider_data.on_ai_failure_detection,
         on_filament_low=provider_data.on_filament_low,
         on_filament_low=provider_data.on_filament_low,
         on_maintenance_due=provider_data.on_maintenance_due,
         on_maintenance_due=provider_data.on_maintenance_due,
         # AMS environmental alarms (regular AMS)
         # AMS environmental alarms (regular AMS)

+ 15 - 0
backend/app/core/database.py

@@ -3064,6 +3064,21 @@ async def run_migrations(conn):
             orphan_count,
             orphan_count,
         )
         )
 
 
+    # Migration: Add on_ai_failure_detection column to notification_providers (#1794).
+    # Splits Obico AI failure detection out of the multiplexed on_printer_error
+    # event so users can subscribe to spaghetti alerts independently of HMS
+    # hardware-error alerts. Postgres rejects `DEFAULT 0` for BOOLEAN columns.
+    if is_sqlite():
+        await _safe_execute(
+            conn,
+            "ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT 0",
+        )
+    else:
+        await _safe_execute(
+            conn,
+            "ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT false",
+        )
+
 
 
 async def seed_notification_templates():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""

+ 1 - 0
backend/app/models/notification.py

@@ -70,6 +70,7 @@ class NotificationProvider(Base):
     # Event triggers - printer status
     # Event triggers - printer status
     on_printer_offline = Column(Boolean, default=False)
     on_printer_offline = Column(Boolean, default=False)
     on_printer_error = Column(Boolean, default=False)  # AMS issues, etc.
     on_printer_error = Column(Boolean, default=False)  # AMS issues, etc.
+    on_ai_failure_detection = Column(Boolean, default=False)  # Obico spaghetti / failure detection (#1794)
     on_filament_low = Column(Boolean, default=False)
     on_filament_low = Column(Boolean, default=False)
     on_maintenance_due = Column(Boolean, default=False)  # Maintenance reminder
     on_maintenance_due = Column(Boolean, default=False)  # Maintenance reminder
 
 

+ 6 - 0
backend/app/models/notification_template.py

@@ -73,6 +73,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Printer Error: {error_type}",
         "title_template": "Printer Error: {error_type}",
         "body_template": "{printer}\n{error_detail}",
         "body_template": "{printer}\n{error_detail}",
     },
     },
+    {
+        "event_type": "ai_failure_detection",
+        "name": "AI Failure Detection",
+        "title_template": "Possible Print Failure Detected",
+        "body_template": "{printer}: {task_name}\nConfidence: {confidence}\nAction taken: {action}",
+    },
     {
     {
         "event_type": "plate_not_empty",
         "event_type": "plate_not_empty",
         "name": "Plate Not Empty",
         "name": "Plate Not Empty",

+ 5 - 0
backend/app/schemas/notification.py

@@ -43,6 +43,10 @@ class NotificationProviderBase(BaseModel):
     # Event triggers - printer status
     # Event triggers - printer status
     on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
     on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
     on_printer_error: bool = Field(default=False, description="Notify on printer errors (AMS, etc.)")
     on_printer_error: bool = Field(default=False, description="Notify on printer errors (AMS, etc.)")
+    on_ai_failure_detection: bool = Field(
+        default=False,
+        description="Notify when Obico AI detects a possible print failure (spaghetti)",
+    )
     on_filament_low: bool = Field(default=False, description="Notify when filament is running low")
     on_filament_low: bool = Field(default=False, description="Notify when filament is running low")
     on_maintenance_due: bool = Field(default=False, description="Notify when maintenance is due")
     on_maintenance_due: bool = Field(default=False, description="Notify when maintenance is due")
 
 
@@ -128,6 +132,7 @@ class NotificationProviderUpdate(BaseModel):
     # Event triggers - printer status
     # Event triggers - printer status
     on_printer_offline: bool | None = None
     on_printer_offline: bool | None = None
     on_printer_error: bool | None = None
     on_printer_error: bool | None = None
+    on_ai_failure_detection: bool | None = None
     on_filament_low: bool | None = None
     on_filament_low: bool | None = None
     on_maintenance_due: bool | None = None
     on_maintenance_due: bool | None = None
 
 

+ 39 - 0
backend/app/services/notification_service.py

@@ -1145,6 +1145,45 @@ class NotificationService:
             variables=variables,
             variables=variables,
         )
         )
 
 
+    async def on_ai_failure_detection(
+        self,
+        printer_id: int,
+        printer_name: str,
+        task_name: str,
+        confidence: float,
+        action: str,
+        db: AsyncSession,
+        image_data: bytes | None = None,
+    ):
+        """Handle AI failure-detection event (Obico spaghetti / print-failure ML).
+
+        Split out of on_printer_error (#1794) so a user can subscribe to AI
+        alerts without also being paged for every HMS hardware code.
+        """
+        providers = await self._get_providers_for_event(db, "on_ai_failure_detection", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "task_name": task_name or "current job",
+            "confidence": f"{confidence:.2f}",
+            "action": action,
+        }
+
+        title, message = await self._build_message_from_template(db, "ai_failure_detection", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "ai_failure_detection",
+            printer_id,
+            printer_name,
+            image_data=image_data,
+            variables=variables,
+        )
+
     async def on_plate_not_empty(
     async def on_plate_not_empty(
         self,
         self,
         printer_id: int,
         printer_id: int,

+ 10 - 7
backend/app/services/obico_actions.py

@@ -64,20 +64,23 @@ async def _turn_off_linked_plugs(printer_id: int) -> None:
 
 
 
 
 async def _notify(printer_id: int, printer_name: str, task_name: str, score: float, action: str) -> None:
 async def _notify(printer_id: int, printer_name: str, task_name: str, score: float, action: str) -> None:
+    """Fire the AI Failure Detection notification (#1794).
+
+    Routed to its own event in 0.2.5b1; previously rode the multiplexed
+    on_printer_error toggle, which made it indistinguishable from HMS
+    hardware errors in the UI.
+    """
     from backend.app.services.notification_service import notification_service
     from backend.app.services.notification_service import notification_service
 
 
-    detail = (
-        f"Possible print failure detected on '{task_name or 'current job'}' "
-        f"(confidence {score:.2f}). Action taken: {action}."
-    )
     async with async_session() as db:
     async with async_session() as db:
         try:
         try:
-            await notification_service.on_printer_error(
+            await notification_service.on_ai_failure_detection(
                 printer_id=printer_id,
                 printer_id=printer_id,
                 printer_name=printer_name,
                 printer_name=printer_name,
-                error_type="ai_failure_detection",
+                task_name=task_name,
+                confidence=score,
+                action=action,
                 db=db,
                 db=db,
-                error_detail=detail,
             )
             )
         except Exception as e:
         except Exception as e:
             logger.error("Obico notify failed for printer %s: %s", printer_id, e)
             logger.error("Obico notify failed for printer %s: %s", printer_id, e)

+ 143 - 0
backend/tests/unit/services/test_notification_service.py

@@ -1706,6 +1706,149 @@ class TestPrinterErrorNotifications:
             assert captured_variables["error_detail"] == "No details available"
             assert captured_variables["error_detail"] == "No details available"
 
 
 
 
+class TestAIFailureDetectionNotifications:
+    """Tests for the AI failure-detection event (#1794 — split out of on_printer_error).
+
+    Pins that Obico failure-detection dispatches go through the dedicated
+    on_ai_failure_detection event field, not the multiplexed printer-error
+    field. Mirrors the printer-error coverage above so a regression on either
+    surface fails its own case.
+    """
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.fixture
+    def mock_provider(self):
+        provider = MagicMock()
+        provider.id = 1
+        provider.name = "Test Provider"
+        provider.provider_type = "webhook"
+        provider.enabled = True
+        provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
+        provider.on_ai_failure_detection = True
+        provider.on_printer_error = False  # disabled — the regression guard
+        provider.quiet_hours_enabled = False
+        provider.daily_digest_enabled = False
+        provider.printer_id = None
+        return provider
+
+    @pytest.fixture
+    def mock_db(self):
+        db = AsyncMock()
+        db.commit = AsyncMock()
+        return db
+
+    @pytest.mark.asyncio
+    async def test_dispatch_uses_ai_failure_detection_event_not_printer_error(self, service, mock_provider, mock_db):
+        """Regression guard: provider subscribed only to AI alerts must receive
+        the Obico notification."""
+        captured_event = []
+
+        async def capture(db, event_field, printer_id):
+            captured_event.append(event_field)
+            return [mock_provider]
+
+        with (
+            patch.object(service, "_get_providers_for_event", side_effect=capture),
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
+            mock_build.return_value = ("Possible Print Failure Detected", "details")
+
+            await service.on_ai_failure_detection(
+                printer_id=1,
+                printer_name="X1 Carbon",
+                task_name="benchy.3mf",
+                confidence=0.87,
+                action="notify",
+                db=mock_db,
+            )
+
+            assert captured_event == ["on_ai_failure_detection"]
+            mock_send.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_skipped_when_only_printer_error_is_enabled(self, service, mock_provider, mock_db):
+        """Pre-#1794 behaviour MUST NOT survive: a provider with only the
+        legacy on_printer_error toggle should NOT receive AI notifications now."""
+        mock_provider.on_ai_failure_detection = False
+        mock_provider.on_printer_error = True
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+        ):
+            mock_get.return_value = []  # the event-field filter excludes the provider
+
+            await service.on_ai_failure_detection(
+                printer_id=1,
+                printer_name="X1 Carbon",
+                task_name="benchy.3mf",
+                confidence=0.87,
+                action="notify",
+                db=mock_db,
+            )
+
+            mock_send.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_variables_include_task_name_confidence_action(self, service, mock_provider, mock_db):
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_ai_failure_detection(
+                printer_id=1,
+                printer_name="X1 Carbon",
+                task_name="benchy.3mf",
+                confidence=0.873,
+                action="pause_and_off",
+                db=mock_db,
+            )
+
+            assert captured_variables["printer"] == "X1 Carbon"
+            assert captured_variables["task_name"] == "benchy.3mf"
+            assert captured_variables["confidence"] == "0.87"  # 2-decimal format
+            assert captured_variables["action"] == "pause_and_off"
+
+    @pytest.mark.asyncio
+    async def test_task_name_fallback_when_unknown(self, service, mock_provider, mock_db):
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_ai_failure_detection(
+                printer_id=1,
+                printer_name="Test",
+                task_name="",  # empty
+                confidence=0.5,
+                action="notify",
+                db=mock_db,
+            )
+
+            assert captured_variables["task_name"] == "current job"
+
+
 class TestPlateNotEmptyNotifications:
 class TestPlateNotEmptyNotifications:
     """Tests for plate not empty (build plate detection) notifications."""
     """Tests for plate not empty (build plate detection) notifications."""
 
 

+ 113 - 0
backend/tests/unit/services/test_obico_actions.py

@@ -0,0 +1,113 @@
+"""Regression tests for obico_actions (#1794).
+
+Before #1794, `obico_actions._notify` routed AI failure-detection events
+through `notification_service.on_printer_error`, multiplexing them with
+HMS hardware errors. Users couldn't subscribe to one without the other,
+and the reporter on #1794 found that turning OFF the "Printer Error"
+toggle on a Discord provider silently disabled spaghetti alerts too.
+
+This file pins the post-#1794 wiring: `execute_action` calls
+`on_ai_failure_detection`, not `on_printer_error`.
+"""
+
+from contextlib import asynccontextmanager
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.obico_actions import execute_action
+
+
+@asynccontextmanager
+async def _fake_session(printer):
+    result = SimpleNamespace(scalar_one_or_none=lambda: printer)
+    session = SimpleNamespace(execute=AsyncMock(return_value=result))
+    yield session
+
+
+@pytest.fixture
+def fake_printer():
+    return SimpleNamespace(id=7, name="X1 Carbon")
+
+
+@pytest.fixture(autouse=True)
+def _patch_session(fake_printer):
+    with patch("backend.app.services.obico_actions.async_session", lambda: _fake_session(fake_printer)):
+        yield
+
+
+async def test_notify_routes_to_on_ai_failure_detection(fake_printer):
+    """Regression guard for #1794: action='notify' must call
+    on_ai_failure_detection, not on_printer_error. If anyone reverts the
+    handoff, the reporter's symptom (Discord silent when "Printer Error"
+    is OFF and "AI Failure Detection" is ON) returns."""
+    with (
+        patch(
+            "backend.app.services.notification_service.notification_service.on_ai_failure_detection",
+            new_callable=AsyncMock,
+        ) as mock_ai,
+        patch(
+            "backend.app.services.notification_service.notification_service.on_printer_error",
+            new_callable=AsyncMock,
+        ) as mock_err,
+    ):
+        await execute_action(
+            printer_id=fake_printer.id,
+            action="notify",
+            task_name="benchy.3mf",
+            score=0.91,
+        )
+
+        mock_ai.assert_awaited_once()
+        mock_err.assert_not_awaited()  # the bug the user reported
+
+        call_kwargs = mock_ai.await_args.kwargs
+        assert call_kwargs["printer_id"] == fake_printer.id
+        assert call_kwargs["printer_name"] == fake_printer.name
+        assert call_kwargs["task_name"] == "benchy.3mf"
+        assert call_kwargs["confidence"] == 0.91
+        assert call_kwargs["action"] == "notify"
+
+
+async def test_pause_action_still_pauses_and_notifies(fake_printer):
+    """`pause` calls pause_print AND fires the AI notification — the
+    notification fan-out shape isn't different for the pause action."""
+    fake_client = SimpleNamespace(pause_print=lambda: True)
+
+    with (
+        patch(
+            "backend.app.services.printer_manager.printer_manager.get_client",
+            return_value=fake_client,
+        ),
+        patch(
+            "backend.app.services.notification_service.notification_service.on_ai_failure_detection",
+            new_callable=AsyncMock,
+        ) as mock_ai,
+    ):
+        await execute_action(
+            printer_id=fake_printer.id,
+            action="pause",
+            task_name="benchy.3mf",
+            score=0.5,
+        )
+
+        mock_ai.assert_awaited_once()
+        assert mock_ai.await_args.kwargs["action"] == "pause"
+
+
+async def test_notify_swallows_notification_service_exceptions(fake_printer):
+    """Notification failure must not propagate — Obico's detection loop
+    keeps polling; one transient Discord blip shouldn't kill it."""
+    with patch(
+        "backend.app.services.notification_service.notification_service.on_ai_failure_detection",
+        new_callable=AsyncMock,
+        side_effect=RuntimeError("discord 502"),
+    ):
+        # Must not raise.
+        await execute_action(
+            printer_id=fake_printer.id,
+            action="notify",
+            task_name="benchy.3mf",
+            score=0.91,
+        )

+ 50 - 0
frontend/src/__tests__/components/AddNotificationModal.test.tsx

@@ -40,6 +40,7 @@ function buildProvider(overrides: Partial<NotificationProvider> = {}): Notificat
     on_print_missing_spool_assignment: false,
     on_print_missing_spool_assignment: false,
     on_printer_offline: false,
     on_printer_offline: false,
     on_printer_error: false,
     on_printer_error: false,
+    on_ai_failure_detection: false,
     on_filament_low: false,
     on_filament_low: false,
     on_maintenance_due: false,
     on_maintenance_due: false,
     on_ams_humidity_high: false,
     on_ams_humidity_high: false,
@@ -336,3 +337,52 @@ describe('AddNotificationModal — stock alert toggles', () => {
     void user; // referenced to avoid unused-var lint warning
     void user; // referenced to avoid unused-var lint warning
   });
   });
 });
 });
+
+describe('AddNotificationModal — AI Failure Detection toggle (#1794)', () => {
+  it('renders the toggle in the Printer Status section', async () => {
+    render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
+
+    expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
+  });
+
+  it('persists on_ai_failure_detection on save (and does NOT touch on_printer_error)', async () => {
+    let captured: Record<string, unknown> | null = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
+
+    const label = await screen.findByText('AI Failure Detection');
+    const row = label.closest('div.flex')!;
+    const toggle = within(row).getByRole('switch');
+    await user.click(toggle);
+
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+
+    expect(captured).not.toBeNull();
+    expect(captured!.on_ai_failure_detection).toBe(true);
+    // Critical regression guard: don't accidentally flip the legacy multiplexed field.
+    expect(captured!.on_printer_error).toBe(false);
+  });
+
+  it('AI Failure Detection appears in ntfy priority section when enabled', async () => {
+    render(
+      <AddNotificationModal
+        provider={buildProvider({ on_ai_failure_detection: true })}
+        onClose={() => undefined}
+      />,
+    );
+
+    const priorityHeader = await screen.findByText(/ntfy priority/i);
+    const priorityRoot = priorityHeader.closest('div')!;
+
+    expect(within(priorityRoot).getByText('AI Failure Detection')).toBeInTheDocument();
+  });
+});

+ 1 - 0
frontend/src/__tests__/components/NotificationProviderCard.test.tsx

@@ -57,6 +57,7 @@ const createMockProvider = (
   on_print_progress: false,
   on_print_progress: false,
   on_printer_offline: false,
   on_printer_offline: false,
   on_printer_error: false,
   on_printer_error: false,
+  on_ai_failure_detection: false,
   on_filament_low: false,
   on_filament_low: false,
   on_maintenance_due: false,
   on_maintenance_due: false,
   on_ams_humidity_high: false,
   on_ams_humidity_high: false,

+ 128 - 0
frontend/src/__tests__/components/NotificationProviderCardAiFailureDetection.test.tsx

@@ -0,0 +1,128 @@
+/**
+ * Tests for the AI Failure Detection toggle on NotificationProviderCard (#1794).
+ *
+ * Before #1794, Obico failure detection rode the multiplexed
+ * on_printer_error toggle so users couldn't subscribe to one without the
+ * other. These tests pin the standalone toggle:
+ *  - Summary badge renders when enabled.
+ *  - The toggle row appears in the expanded settings panel.
+ *  - Flipping the toggle PATCHes the correct field.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { NotificationProviderCard } from '../../components/NotificationProviderCard';
+import type { NotificationProvider } from '../../api/client';
+
+afterEach(() => {
+  server.resetHandlers();
+  vi.restoreAllMocks();
+});
+
+function buildProvider(overrides: Partial<NotificationProvider> = {}): NotificationProvider {
+  return {
+    id: 1,
+    name: 'Test Provider',
+    provider_type: 'ntfy',
+    enabled: true,
+    config: { server: 'https://ntfy.sh', topic: 'bambuddy' },
+    on_print_start: false,
+    on_print_complete: false,
+    on_print_failed: false,
+    on_print_stopped: false,
+    on_print_progress: false,
+    on_print_missing_spool_assignment: false,
+    on_printer_offline: false,
+    on_printer_error: false,
+    on_ai_failure_detection: false,
+    on_filament_low: false,
+    on_maintenance_due: false,
+    on_ams_humidity_high: false,
+    on_ams_temperature_high: false,
+    on_ams_ht_humidity_high: false,
+    on_ams_ht_temperature_high: false,
+    on_plate_not_empty: false,
+    on_bed_cooled: false,
+    on_first_layer_complete: false,
+    on_queue_job_added: false,
+    on_queue_job_assigned: false,
+    on_queue_job_started: false,
+    on_queue_job_waiting: false,
+    on_queue_job_skipped: false,
+    on_queue_job_failed: false,
+    on_queue_completed: false,
+    on_stock_reorder_alert: false,
+    on_stock_break_alert: false,
+    quiet_hours_enabled: false,
+    quiet_hours_start: null,
+    quiet_hours_end: null,
+    daily_digest_enabled: false,
+    daily_digest_time: null,
+    printer_id: null,
+    last_success: null,
+    last_error: null,
+    last_error_at: null,
+    created_at: '2026-06-22T00:00:00Z',
+    updated_at: '2026-06-22T00:00:00Z',
+    ...overrides,
+  };
+}
+
+describe('NotificationProviderCard — AI Failure Detection badge', () => {
+  it('renders the badge when on_ai_failure_detection is true', async () => {
+    render(
+      <NotificationProviderCard
+        provider={buildProvider({ on_ai_failure_detection: true })}
+        onEdit={vi.fn()}
+      />,
+    );
+    expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
+  });
+
+  it('omits the badge when on_ai_failure_detection is false', async () => {
+    render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
+    await screen.findByText('Test Provider');
+    expect(screen.queryByText('AI Failure Detection')).not.toBeInTheDocument();
+  });
+});
+
+describe('NotificationProviderCard — AI Failure Detection toggle', () => {
+  it('renders the toggle in the expanded settings panel', async () => {
+    const user = userEvent.setup();
+    render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
+
+    await user.click(await screen.findByText(/event settings/i));
+
+    expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
+  });
+
+  it('PATCHes on_ai_failure_detection (NOT on_printer_error) when toggled on — #1794 regression guard', async () => {
+    let captured: Record<string, unknown> | null = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json(buildProvider({ on_ai_failure_detection: true }));
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
+
+    await user.click(await screen.findByText(/event settings/i));
+
+    // The toggle label "AI Failure Detection" is unique to this row.
+    const label = await screen.findByText('AI Failure Detection');
+    const row = label.closest('div.flex')!;
+    const toggle = within(row).getByRole('switch');
+    await user.click(toggle);
+
+    await waitFor(() => expect(captured).not.toBeNull());
+    expect(captured).toMatchObject({ on_ai_failure_detection: true });
+    // Critical: must NOT also flip the legacy multiplexed field.
+    expect(captured).not.toHaveProperty('on_printer_error');
+  });
+});

+ 1 - 0
frontend/src/__tests__/components/NotificationProviderCardStockAlerts.test.tsx

@@ -37,6 +37,7 @@ function buildProvider(overrides: Partial<NotificationProvider> = {}): Notificat
     on_print_missing_spool_assignment: false,
     on_print_missing_spool_assignment: false,
     on_printer_offline: false,
     on_printer_offline: false,
     on_printer_error: false,
     on_printer_error: false,
+    on_ai_failure_detection: false,
     on_filament_low: false,
     on_filament_low: false,
     on_maintenance_due: false,
     on_maintenance_due: false,
     on_ams_humidity_high: false,
     on_ams_humidity_high: false,

+ 1 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -50,6 +50,7 @@ const mockNotificationProviders = [
     on_print_progress: false,
     on_print_progress: false,
     on_printer_offline: false,
     on_printer_offline: false,
     on_printer_error: false,
     on_printer_error: false,
+    on_ai_failure_detection: false,
     on_filament_low: false,
     on_filament_low: false,
     on_maintenance_due: false,
     on_maintenance_due: false,
     on_ams_humidity_high: false,
     on_ams_humidity_high: false,

+ 3 - 0
frontend/src/api/client.ts

@@ -2172,6 +2172,7 @@ export interface NotificationProvider {
   // Printer status events
   // Printer status events
   on_printer_offline: boolean;
   on_printer_offline: boolean;
   on_printer_error: boolean;
   on_printer_error: boolean;
+  on_ai_failure_detection: boolean;
   on_filament_low: boolean;
   on_filament_low: boolean;
   on_maintenance_due: boolean;
   on_maintenance_due: boolean;
   // AMS environmental alarms (regular AMS)
   // AMS environmental alarms (regular AMS)
@@ -2230,6 +2231,7 @@ export interface NotificationProviderCreate {
   // Printer status events
   // Printer status events
   on_printer_offline?: boolean;
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
   on_printer_error?: boolean;
+  on_ai_failure_detection?: boolean;
   on_filament_low?: boolean;
   on_filament_low?: boolean;
   on_maintenance_due?: boolean;
   on_maintenance_due?: boolean;
   // AMS environmental alarms (regular AMS)
   // AMS environmental alarms (regular AMS)
@@ -2281,6 +2283,7 @@ export interface NotificationProviderUpdate {
   // Printer status events
   // Printer status events
   on_printer_offline?: boolean;
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
   on_printer_error?: boolean;
+  on_ai_failure_detection?: boolean;
   on_filament_low?: boolean;
   on_filament_low?: boolean;
   on_maintenance_due?: boolean;
   on_maintenance_due?: boolean;
   // AMS environmental alarms (regular AMS)
   // AMS environmental alarms (regular AMS)

+ 7 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -38,6 +38,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
   const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
   const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
   const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
   const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
   const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
+  const [onAiFailureDetection, setOnAiFailureDetection] = useState(provider?.on_ai_failure_detection ?? false);
   const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);
   const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);
   const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
   const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
   const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
   const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
@@ -167,6 +168,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       on_print_progress: onPrintProgress,
       on_print_progress: onPrintProgress,
       on_printer_offline: onPrinterOffline,
       on_printer_offline: onPrinterOffline,
       on_printer_error: onPrinterError,
       on_printer_error: onPrinterError,
+      on_ai_failure_detection: onAiFailureDetection,
       on_filament_low: onFilamentLow,
       on_filament_low: onFilamentLow,
       on_maintenance_due: onMaintenanceDue,
       on_maintenance_due: onMaintenanceDue,
       on_stock_reorder_alert: onStockReorderAlert,
       on_stock_reorder_alert: onStockReorderAlert,
@@ -547,6 +549,10 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                   <span className="text-sm text-white">{t('notifications.error')}</span>
                   <span className="text-sm text-white">{t('notifications.error')}</span>
                   <Toggle checked={onPrinterError} onChange={setOnPrinterError} />
                   <Toggle checked={onPrinterError} onChange={setOnPrinterError} />
                 </div>
                 </div>
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">{t('notifications.aiFailureDetection')}</span>
+                  <Toggle checked={onAiFailureDetection} onChange={setOnAiFailureDetection} />
+                </div>
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">
                   <span className="text-sm text-white">{t('notifications.lowFilament')}</span>
                   <span className="text-sm text-white">{t('notifications.lowFilament')}</span>
                   <Toggle checked={onFilamentLow} onChange={setOnFilamentLow} />
                   <Toggle checked={onFilamentLow} onChange={setOnFilamentLow} />
@@ -591,6 +597,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
               if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
               if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
               if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
               if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
               if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
               if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
+              if (onAiFailureDetection) enabledEvents.push({ key: 'on_ai_failure_detection', label: t('notifications.aiFailureDetection') });
               if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') });
               if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') });
               if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') });
               if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') });
               if (onStockReorderAlert) enabledEvents.push({ key: 'on_stock_reorder_alert', label: t('notifications.stockReorderAlert') });
               if (onStockReorderAlert) enabledEvents.push({ key: 'on_stock_reorder_alert', label: t('notifications.stockReorderAlert') });

+ 14 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -138,6 +138,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_printer_error && (
             {provider.on_printer_error && (
               <span className="px-2 py-0.5 bg-rose-500/20 text-rose-400 text-xs rounded">{t('notifications.error')}</span>
               <span className="px-2 py-0.5 bg-rose-500/20 text-rose-400 text-xs rounded">{t('notifications.error')}</span>
             )}
             )}
+            {provider.on_ai_failure_detection && (
+              <span className="px-2 py-0.5 bg-fuchsia-500/20 text-fuchsia-300 text-xs rounded">{t('notifications.aiFailureDetection')}</span>
+            )}
             {provider.on_filament_low && (
             {provider.on_filament_low && (
               <span className="px-2 py-0.5 bg-cyan-500/20 text-cyan-400 text-xs rounded">{t('notifications.lowFilament')}</span>
               <span className="px-2 py-0.5 bg-cyan-500/20 text-cyan-400 text-xs rounded">{t('notifications.lowFilament')}</span>
             )}
             )}
@@ -366,6 +369,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                   />
                 </div>
                 </div>
 
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.aiFailureDetection')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.aiFailureDetectionDescription')}</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_ai_failure_detection ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_ai_failure_detection: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">{t('notifications.lowFilamentLabel')}</p>
                   <p className="text-sm text-white">{t('notifications.lowFilamentLabel')}</p>
 <Toggle
 <Toggle

+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -5018,6 +5018,8 @@ export default {
     progressMilestonesDescription: 'Benachrichtigung bei 25%, 50%, 75%',
     progressMilestonesDescription: 'Benachrichtigung bei 25%, 50%, 75%',
     printerOffline: 'Drucker offline',
     printerOffline: 'Drucker offline',
     printerError: 'Druckerfehler',
     printerError: 'Druckerfehler',
+    aiFailureDetection: 'KI-Fehlererkennung',
+    aiFailureDetectionDescription: 'Benachrichtigen, wenn die Obico-KI einen möglichen Druckfehler erkennt',
     lowFilamentLabel: 'Filament niedrig',
     lowFilamentLabel: 'Filament niedrig',
     maintenanceDue: 'Wartung fällig',
     maintenanceDue: 'Wartung fällig',
     maintenanceDueDescription: 'Benachrichtigen, wenn Wartung erforderlich ist',
     maintenanceDueDescription: 'Benachrichtigen, wenn Wartung erforderlich ist',

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -5043,6 +5043,8 @@ export default {
     progressMilestonesDescription: 'Notify at 25%, 50%, 75%',
     progressMilestonesDescription: 'Notify at 25%, 50%, 75%',
     printerOffline: 'Printer Offline',
     printerOffline: 'Printer Offline',
     printerError: 'Printer Error',
     printerError: 'Printer Error',
+    aiFailureDetection: 'AI Failure Detection',
+    aiFailureDetectionDescription: 'Notify when Obico AI detects a possible print failure',
     lowFilamentLabel: 'Low Filament',
     lowFilamentLabel: 'Low Filament',
     maintenanceDue: 'Maintenance Due',
     maintenanceDue: 'Maintenance Due',
     maintenanceDueDescription: 'Notify when maintenance is needed',
     maintenanceDueDescription: 'Notify when maintenance is needed',

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -5027,6 +5027,8 @@ export default {
     progressMilestonesDescription: 'Notificar al 25%, 50% y 75%',
     progressMilestonesDescription: 'Notificar al 25%, 50% y 75%',
     printerOffline: 'Impresora desconectada',
     printerOffline: 'Impresora desconectada',
     printerError: 'Error de la impresora',
     printerError: 'Error de la impresora',
+    aiFailureDetection: 'Detección de fallos por IA',
+    aiFailureDetectionDescription: 'Notificar cuando la IA de Obico detecte un posible fallo de impresión',
     lowFilamentLabel: 'Filamento bajo',
     lowFilamentLabel: 'Filamento bajo',
     maintenanceDue: 'Mantenimiento pendiente',
     maintenanceDue: 'Mantenimiento pendiente',
     maintenanceDueDescription: 'Notificar cuando se necesite mantenimiento',
     maintenanceDueDescription: 'Notificar cuando se necesite mantenimiento',

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -5008,6 +5008,8 @@ export default {
     progressMilestonesDescription: 'Notifier à 25 %, 50 %, 75 %',
     progressMilestonesDescription: 'Notifier à 25 %, 50 %, 75 %',
     printerOffline: 'Imprimante hors ligne',
     printerOffline: 'Imprimante hors ligne',
     printerError: 'Erreur de l\'imprimante',
     printerError: 'Erreur de l\'imprimante',
+    aiFailureDetection: 'Détection de défaillance par IA',
+    aiFailureDetectionDescription: 'Notifier lorsque l\'IA Obico détecte une défaillance d\'impression possible',
     lowFilamentLabel: 'Filament bas',
     lowFilamentLabel: 'Filament bas',
     maintenanceDue: 'Maintenance requise',
     maintenanceDue: 'Maintenance requise',
     maintenanceDueDescription: 'Notifier lorsqu\'une maintenance est nécessaire',
     maintenanceDueDescription: 'Notifier lorsqu\'une maintenance est nécessaire',

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -5007,6 +5007,8 @@ export default {
     progressMilestonesDescription: 'Notifica al 25%, 50%, 75%',
     progressMilestonesDescription: 'Notifica al 25%, 50%, 75%',
     printerOffline: 'Stampante offline',
     printerOffline: 'Stampante offline',
     printerError: 'Errore stampante',
     printerError: 'Errore stampante',
+    aiFailureDetection: 'Rilevamento guasti IA',
+    aiFailureDetectionDescription: 'Notifica quando l\'IA di Obico rileva un possibile guasto di stampa',
     lowFilamentLabel: 'Filamento scarso',
     lowFilamentLabel: 'Filamento scarso',
     maintenanceDue: 'Manutenzione necessaria',
     maintenanceDue: 'Manutenzione necessaria',
     maintenanceDueDescription: 'Notifica quando è necessaria la manutenzione',
     maintenanceDueDescription: 'Notifica quando è necessaria la manutenzione',

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -5019,6 +5019,8 @@ export default {
     progressMilestonesDescription: '25%、50%、75%で通知',
     progressMilestonesDescription: '25%、50%、75%で通知',
     printerOffline: 'プリンターオフライン',
     printerOffline: 'プリンターオフライン',
     printerError: 'プリンターエラー',
     printerError: 'プリンターエラー',
+    aiFailureDetection: 'AI 故障検出',
+    aiFailureDetectionDescription: 'Obico AI が印刷の不具合の可能性を検出したときに通知',
     lowFilamentLabel: 'フィラメント残量低下',
     lowFilamentLabel: 'フィラメント残量低下',
     maintenanceDue: 'メンテナンス期限',
     maintenanceDue: 'メンテナンス期限',
     maintenanceDueDescription: 'メンテナンスが必要な場合に通知',
     maintenanceDueDescription: 'メンテナンスが必要な場合に通知',

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -4740,6 +4740,8 @@ export default {
     progressMilestonesDescription: '25%, 50%, 75%에서 알림',
     progressMilestonesDescription: '25%, 50%, 75%에서 알림',
     printerOffline: '프린터 오프라인',
     printerOffline: '프린터 오프라인',
     printerError: '프린터 오류',
     printerError: '프린터 오류',
+    aiFailureDetection: 'AI 실패 감지',
+    aiFailureDetectionDescription: 'Obico AI가 인쇄 실패 가능성을 감지하면 알림',
     lowFilamentLabel: '필라멘트 부족',
     lowFilamentLabel: '필라멘트 부족',
     maintenanceDue: '유지 관리 필요',
     maintenanceDue: '유지 관리 필요',
     maintenanceDueDescription: '유지 관리가 필요할 때 알림',
     maintenanceDueDescription: '유지 관리가 필요할 때 알림',

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -5007,6 +5007,8 @@ export default {
     progressMilestonesDescription: 'Notificar em 25%, 50%, 75%',
     progressMilestonesDescription: 'Notificar em 25%, 50%, 75%',
     printerOffline: 'Impressora Offline',
     printerOffline: 'Impressora Offline',
     printerError: 'Erro da Impressora',
     printerError: 'Erro da Impressora',
+    aiFailureDetection: 'Detecção de Falhas por IA',
+    aiFailureDetectionDescription: 'Notificar quando a IA do Obico detectar uma possível falha de impressão',
     lowFilamentLabel: 'Filamento Baixo',
     lowFilamentLabel: 'Filamento Baixo',
     maintenanceDue: 'Manutenção Necessária',
     maintenanceDue: 'Manutenção Necessária',
     maintenanceDueDescription: 'Notificar quando manutenção for necessária',
     maintenanceDueDescription: 'Notificar quando manutenção for necessária',

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -4974,6 +4974,8 @@ export default {
     progressMilestonesDescription: '%25, %50, %75\'te bildir',
     progressMilestonesDescription: '%25, %50, %75\'te bildir',
     printerOffline: 'Yazıcı Çevrimdışı',
     printerOffline: 'Yazıcı Çevrimdışı',
     printerError: 'Yazıcı Hatası',
     printerError: 'Yazıcı Hatası',
+    aiFailureDetection: 'AI Hata Tespiti',
+    aiFailureDetectionDescription: 'Obico AI olası bir baskı hatası tespit ettiğinde bildir',
     lowFilamentLabel: 'Az Filament',
     lowFilamentLabel: 'Az Filament',
     maintenanceDue: 'Bakım Zamanı',
     maintenanceDue: 'Bakım Zamanı',
     maintenanceDueDescription: 'Bakım gerektiğinde bildir',
     maintenanceDueDescription: 'Bakım gerektiğinde bildir',

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -5007,6 +5007,8 @@ export default {
     progressMilestonesDescription: '在 25%、50%、75% 时通知',
     progressMilestonesDescription: '在 25%、50%、75% 时通知',
     printerOffline: '打印机离线',
     printerOffline: '打印机离线',
     printerError: '打印机错误',
     printerError: '打印机错误',
+    aiFailureDetection: 'AI 失败检测',
+    aiFailureDetectionDescription: '当 Obico AI 检测到可能的打印失败时通知',
     lowFilamentLabel: '耗材不足',
     lowFilamentLabel: '耗材不足',
     maintenanceDue: '需要维护',
     maintenanceDue: '需要维护',
     maintenanceDueDescription: '需要维护时通知',
     maintenanceDueDescription: '需要维护时通知',

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5007,6 +5007,8 @@ export default {
     progressMilestonesDescription: '在 25%、50%、75% 時通知',
     progressMilestonesDescription: '在 25%、50%、75% 時通知',
     printerOffline: '印表機離線',
     printerOffline: '印表機離線',
     printerError: '印表機錯誤',
     printerError: '印表機錯誤',
+    aiFailureDetection: 'AI 失敗偵測',
+    aiFailureDetectionDescription: '當 Obico AI 偵測到可能的列印失敗時通知',
     lowFilamentLabel: '耗材不足',
     lowFilamentLabel: '耗材不足',
     maintenanceDue: '需要維護',
     maintenanceDue: '需要維護',
     maintenanceDueDescription: '需要維護時通知',
     maintenanceDueDescription: '需要維護時通知',

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/index-CksvU0PF.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 1
static/assets/index-D9kvaB_m.css


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 1 - 0
static/assets/index-DNavQjwR.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-ef6eQr6-.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-D9kvaB_m.css">
+    <script type="module" crossorigin src="/assets/index-CksvU0PF.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DNavQjwR.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Vissa filer visades inte eftersom för många filer har ändrats