mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
feat(desktop): show the AI assistant in SaaS mode via the cloud kill switch (#6666)
## What & why Chained on top of #6649 (the `cloud/` refactor). The AI assistant was effectively dead on desktop: 1. **Hidden** — `ChatFAB` gates on `aiEngineEnabled`, which desktop reads from the **local** bundled backend's `/api/v1/config/app-config`. The local backend has no AI engine, so the flag is always `false` and the FAB never renders. 2. **Mis-routed** — even if shown, AI calls used `getApiBaseUrl()`, which is empty/local on desktop, so the orchestrate stream and AI result-file download missed the engine (which only runs in the cloud). This PR wires AI properly **without hardcoding it on**, so the cloud keeps the kill switch: flip `aiEngineEnabled` server-side and the desktop FAB disappears on the next load — no desktop release required. (Deliberately *not* assume-on, so a future "turn AI off" doesn't strand shipped versions.) ## Changes **General SaaS app-config service** (reusable for any cloud flag, not just AI): - `desktop/services/saasAppConfigService.ts` — SaaS-mode-only fetch + 5-min cache of the **public** `/api/v1/config/app-config` from the **SaaS** backend over native HTTP (`@tauri-apps/plugin-http`, no CORS). Returns `null` outside SaaS mode. - `desktop/hooks/useSaasAppConfig.ts` — hook over it; reloads on connection-mode change. **AI gating + routing seams:** - `useAiEngineEnabled()` — core reads `useAppConfig()` (web), desktop reads `useSaasAppConfig()`. `ChatFAB` consumes it. - `getAiBaseUrl()` — core uses the normal API base (web), desktop points AI calls at the SaaS backend. `ChatContext` uses it for the orchestrate stream + result-file download. - `operationRouter` — route `/api/v1/ai/*` to the SaaS backend (cloud-only prefix). **Docs:** AGENTS.md gains a short "cloud feature flags on desktop" note so the pattern is maintained. ## Verification - `tsc --noEmit` green for saas / desktop / cloud flavors - `eslint --max-warnings=0` clean (cloud-layer guardrail respected — the platform-coupled bits live in `desktop/`) - New `saasAppConfigService.test.ts` (3 tests) + existing `operationRouter` / `tauriHttpClient` / `httpErrorHandler` suites green - 0 stray compiled artifacts ## Not headlessly verifiable — needs a live Tauri smoke The orchestrate **SSE stream** uses the webview's global `fetch` (native HTTP can't stream the body the same way), so it's subject to browser CORS to the SaaS backend. The `SupabaseSecurityConfig` tauri-origin allowance (from #6649) covers it, but please confirm on a real build: open the FAB in SaaS mode, run an agent task, watch the stream + a result-file download succeed.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
/**
|
||||
* Whether the AI engine is enabled, per the backend's app-config.
|
||||
*
|
||||
* Web builds read it straight from the app-config (which already comes from the
|
||||
* backend the app talks to). Desktop shadows this (desktop/hooks/useAiEngineEnabled)
|
||||
* to read it from the SaaS backend in SaaS mode instead of the local bundled one.
|
||||
*/
|
||||
export function useAiEngineEnabled(): boolean {
|
||||
const { config } = useAppConfig();
|
||||
return Boolean(config?.aiEngineEnabled);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getApiBaseUrl } from "@app/services/apiClientConfig";
|
||||
|
||||
/**
|
||||
* Base URL for AI-engine calls (orchestrate stream, AI result-file download).
|
||||
*
|
||||
* Web builds talk to whichever backend the app is served from, so the normal API
|
||||
* base is correct. Desktop shadows this (desktop/services/aiBaseUrl) to point at
|
||||
* the SaaS backend, since the AI engine only runs in the cloud.
|
||||
*/
|
||||
export function getAiBaseUrl(): string {
|
||||
return getApiBaseUrl();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useSaasAppConfig } from "@app/hooks/useSaasAppConfig";
|
||||
|
||||
/**
|
||||
* Desktop: the AI engine runs on the SaaS backend, so its enabled flag must come
|
||||
* from the SaaS app-config (not the local bundled backend, which never has the
|
||||
* engine). useSaasAppConfig() returns null outside SaaS mode, so AI is implicitly
|
||||
* hidden in local/self-hosted — and the cloud retains the on/off switch (flip
|
||||
* aiEngineEnabled server-side and the desktop FAB disappears on next load, no
|
||||
* release required).
|
||||
*/
|
||||
export function useAiEngineEnabled(): boolean {
|
||||
return Boolean(useSaasAppConfig()?.aiEngineEnabled);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { saasAppConfigService } from "@app/services/saasAppConfigService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import type { AppConfig } from "@app/types/appConfig";
|
||||
|
||||
/**
|
||||
* The SaaS backend's app-config while in desktop SaaS mode, or null otherwise.
|
||||
*
|
||||
* General-purpose: read any cloud feature flag from it (e.g.
|
||||
* `useSaasAppConfig()?.aiEngineEnabled`, `?.premiumEnabled`). Reloads when the
|
||||
* connection mode changes, so switching into/out of SaaS updates the flags
|
||||
* (and a server-side flag flip is picked up on the next load).
|
||||
*/
|
||||
export function useSaasAppConfig(): AppConfig | null {
|
||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => {
|
||||
void saasAppConfigService.getConfig().then((c) => {
|
||||
if (alive) setConfig(c);
|
||||
});
|
||||
};
|
||||
load();
|
||||
const unsubscribe = connectionModeService.subscribeToModeChanges(() => {
|
||||
saasAppConfigService.clearCache();
|
||||
load();
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection";
|
||||
|
||||
/**
|
||||
* Desktop: AI-engine calls must hit the SaaS backend (the local bundled backend
|
||||
* has no AI engine). The AI surface is only shown in SaaS mode (gated by
|
||||
* useAiEngineEnabled), so returning the SaaS base unconditionally is correct —
|
||||
* when an AI call is made, the app is in SaaS mode.
|
||||
*
|
||||
* Used for the orchestrate stream (a raw fetch) and the AI result-file download,
|
||||
* which would otherwise resolve to the empty/local base and miss the engine.
|
||||
*/
|
||||
export function getAiBaseUrl(): string {
|
||||
return (STIRLING_SAAS_BACKEND_API_URL ?? "").replace(/\/$/, "");
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import { expectConsole } from "@app/tests/failOnConsole";
|
||||
// Exercise the error-interceptor logic against a hand-rolled axios-like
|
||||
// client; transitive imports are mocked out so this is a pure unit test.
|
||||
|
||||
const { getAccessTokenMock } = vi.hoisted(() => ({
|
||||
getAccessTokenMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@app/auth/session", () => ({ getAccessToken: getAccessTokenMock }));
|
||||
vi.mock("@app/components/toast", () => ({ alert: vi.fn() }));
|
||||
vi.mock("@core/services/apiClientSetup", () => ({
|
||||
setupApiInterceptors: vi.fn(),
|
||||
@@ -50,7 +55,11 @@ vi.mock("@app/i18n", () => ({
|
||||
default: { t: (_key: string, fallback: string) => fallback || _key },
|
||||
}));
|
||||
|
||||
import { setupApiInterceptors } from "@app/services/apiClientSetup";
|
||||
import {
|
||||
setupApiInterceptors,
|
||||
getAuthHeaders,
|
||||
} from "@app/services/apiClientSetup";
|
||||
import { operationRouter } from "@app/services/operationRouter";
|
||||
|
||||
type Interceptor = (value: unknown) => unknown;
|
||||
|
||||
@@ -177,3 +186,66 @@ describe("desktop apiClientSetup - 401 silent-path", () => {
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop request interceptor - auth for SaaS-backend requests", () => {
|
||||
type ReqConfig = {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
async function runRequestInterceptor(config: ReqConfig): Promise<ReqConfig> {
|
||||
const { client, handlers } = makeMockClient();
|
||||
setupApiInterceptors(client as unknown as AxiosInstance);
|
||||
const handler = handlers.request[0];
|
||||
expect(handler).toBeTypeOf("function");
|
||||
return (await handler(config)) as ReqConfig;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getAccessTokenMock.mockReset();
|
||||
// SaaS mode (not self-hosted), local-first routing for tool paths.
|
||||
vi.mocked(operationRouter.isSelfHostedMode).mockResolvedValue(false);
|
||||
vi.mocked(operationRouter.isSaaSMode).mockResolvedValue(true);
|
||||
vi.mocked(operationRouter.getBaseUrl).mockResolvedValue(
|
||||
"http://localhost:8080",
|
||||
);
|
||||
vi.mocked(operationRouter.shouldSkipBackendReadyCheck).mockResolvedValue(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("attaches the Bearer token to an absolute SaaS-backend URL (AI file download)", async () => {
|
||||
getAccessTokenMock.mockResolvedValue("jwt-123");
|
||||
const result = await runRequestInterceptor({
|
||||
url: "https://api.saas.test/api/v1/general/files/abc",
|
||||
method: "get",
|
||||
headers: {},
|
||||
});
|
||||
expect(result.headers.Authorization).toBe("Bearer jwt-123");
|
||||
});
|
||||
|
||||
test("does NOT attach a token to a relative, local-routed tool request", async () => {
|
||||
getAccessTokenMock.mockResolvedValue("jwt-123");
|
||||
const result = await runRequestInterceptor({
|
||||
url: "/api/v1/general/merge-pdfs",
|
||||
method: "get",
|
||||
headers: {},
|
||||
});
|
||||
expect(result.headers.Authorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop getAuthHeaders (raw fetch / AI SSE stream)", () => {
|
||||
beforeEach(() => getAccessTokenMock.mockReset());
|
||||
|
||||
test("attaches the Tauri-store JWT as a Bearer header", async () => {
|
||||
getAccessTokenMock.mockResolvedValue("jwt-123");
|
||||
expect(await getAuthHeaders()).toEqual({ Authorization: "Bearer jwt-123" });
|
||||
});
|
||||
|
||||
test("returns no header when there is no token", async () => {
|
||||
getAccessTokenMock.mockResolvedValue(null);
|
||||
expect(await getAuthHeaders()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
|
||||
import { alert } from "@app/components/toast";
|
||||
import {
|
||||
setupApiInterceptors as coreSetup,
|
||||
getAuthHeaders,
|
||||
} from "@core/services/apiClientSetup";
|
||||
|
||||
export { getAuthHeaders };
|
||||
import { setupApiInterceptors as coreSetup } from "@core/services/apiClientSetup";
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { createBackendNotReadyError } from "@app/constants/backendErrors";
|
||||
import { operationRouter } from "@app/services/operationRouter";
|
||||
@@ -19,6 +14,21 @@ import {
|
||||
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
||||
import i18n from "@app/i18n";
|
||||
|
||||
/**
|
||||
* Auth headers for raw fetch() calls (the AI SSE stream) — desktop variant.
|
||||
*
|
||||
* Core's getAuthHeaders returns {} and proprietary's reads the JWT from
|
||||
* localStorage, but desktop keeps its JWT in the Tauri secure store. So this
|
||||
* pulls the token via getAccessToken() (mirroring the axios request interceptor,
|
||||
* including waiting out an in-flight refresh) — without it the orchestrate
|
||||
* stream hits the SaaS backend with no Authorization header and 401s.
|
||||
*/
|
||||
export async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||
await authService.awaitRefreshIfInProgress();
|
||||
const token = await getAccessToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
||||
let lastBackendToast = 0;
|
||||
|
||||
@@ -71,7 +81,17 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
// - SaaS backend: Needs auth token
|
||||
// - Self-hosted backend: Needs auth token
|
||||
const isRemote = await operationRouter.isSelfHostedMode();
|
||||
const isSaaSBackendRequest = baseUrl === STIRLING_SAAS_BACKEND_API_URL;
|
||||
// A request targets the SaaS backend either because operationRouter
|
||||
// routed a relative path there, OR because the caller passed an absolute
|
||||
// SaaS URL. The latter matters for the AI result-file download: it hits
|
||||
// ${getAiBaseUrl()}/api/v1/general/files/..., and /api/v1/general/*
|
||||
// normally routes local-first — so without this it would reach the cloud
|
||||
// engine with no token and 401. Check the FINAL url (post-prefixing).
|
||||
const saasBase = STIRLING_SAAS_BACKEND_API_URL ?? "";
|
||||
const finalUrl = extendedConfig.url ?? "";
|
||||
const isSaaSBackendRequest =
|
||||
baseUrl === STIRLING_SAAS_BACKEND_API_URL ||
|
||||
(saasBase !== "" && finalUrl.startsWith(saasBase));
|
||||
const needsAuth = isRemote || isSaaSBackendRequest;
|
||||
|
||||
// Tag request so error handler can identify SaaS backend errors without URL matching
|
||||
|
||||
@@ -60,6 +60,7 @@ export class OperationRouter {
|
||||
/^\/api\/v1\/auth\//, // Supabase auth (SaaS mode)
|
||||
/^\/api\/v1\/payg\//, // PAYG wallet / spend-cap / billing
|
||||
/^\/api\/v1\/policies(?:\/|$)/, // Policy runs — must bill via the cloud
|
||||
/^\/api\/v1\/ai\//, // AI engine (orchestrate, etc.) — runs in the cloud
|
||||
// Add more cloud-only feature prefixes here as they land.
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
const { fetchMock, getModeMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getModeMock: vi.fn(),
|
||||
}));
|
||||
vi.mock("@tauri-apps/plugin-http", () => ({ fetch: fetchMock }));
|
||||
vi.mock("@app/constants/connection", () => ({
|
||||
STIRLING_SAAS_BACKEND_API_URL: "https://api.saas.test",
|
||||
}));
|
||||
vi.mock("@app/services/connectionModeService", () => ({
|
||||
connectionModeService: { getCurrentMode: getModeMock },
|
||||
}));
|
||||
|
||||
import { saasAppConfigService } from "@app/services/saasAppConfigService";
|
||||
|
||||
function okConfig(body: Record<string, unknown>) {
|
||||
return { ok: true, status: 200, json: async () => body };
|
||||
}
|
||||
|
||||
describe("saasAppConfigService", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
getModeMock.mockReset();
|
||||
saasAppConfigService.clearCache();
|
||||
});
|
||||
|
||||
test("returns null outside SaaS mode without fetching", async () => {
|
||||
getModeMock.mockResolvedValue("local");
|
||||
expect(await saasAppConfigService.getConfig()).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fetches the SaaS app-config in SaaS mode and caches it", async () => {
|
||||
getModeMock.mockResolvedValue("saas");
|
||||
fetchMock.mockResolvedValue(
|
||||
okConfig({ aiEngineEnabled: true, premiumEnabled: false }),
|
||||
);
|
||||
|
||||
const first = await saasAppConfigService.getConfig();
|
||||
expect(first?.aiEngineEnabled).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
"https://api.saas.test/api/v1/config/app-config",
|
||||
);
|
||||
|
||||
// Second read is served from cache (no extra fetch).
|
||||
const second = await saasAppConfigService.getConfig();
|
||||
expect(second?.aiEngineEnabled).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("returns null on a non-ok response", async () => {
|
||||
getModeMock.mockResolvedValue("saas");
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
});
|
||||
expectConsole.warn(/SaaS app-config fetch failed: 500/);
|
||||
expect(await saasAppConfigService.getConfig()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import { STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import type { AppConfig } from "@app/types/appConfig";
|
||||
|
||||
/**
|
||||
* General fetch+cache of the SaaS backend's app-config for the desktop app.
|
||||
*
|
||||
* The local AppConfigContext reads /api/v1/config/app-config from the LOCAL
|
||||
* bundled backend (that's what apiClient routes to on desktop), so cloud-only
|
||||
* feature flags (aiEngineEnabled, premiumEnabled, paygEnabled, ...) are never
|
||||
* seen. This service fetches the SAME endpoint from the SaaS backend so desktop
|
||||
* SaaS mode can read the cloud's view of those flags. It is intentionally
|
||||
* generic — not AI-specific — so other desktop SaaS features can consume it.
|
||||
*
|
||||
* - Only returns config in SaaS connection mode; null in local/self-hosted (so
|
||||
* callers naturally treat cloud features as off outside SaaS, and a server-side
|
||||
* flag flip — e.g. turning AI off — propagates without a desktop release).
|
||||
* - The endpoint is public on the SaaS backend (/api/v1/config/** is permitAll),
|
||||
* so no auth is required.
|
||||
* - Native HTTP (@tauri-apps/plugin-http) — no CORS, mirrors endpointAvailabilityService.
|
||||
*/
|
||||
const CACHE_MS = 5 * 60 * 1000;
|
||||
|
||||
class SaasAppConfigService {
|
||||
private cache: AppConfig | null = null;
|
||||
private cacheExpiry = 0;
|
||||
private inFlight: Promise<AppConfig | null> | null = null;
|
||||
|
||||
/** The SaaS backend's app-config, or null outside SaaS mode / on failure. */
|
||||
async getConfig(force = false): Promise<AppConfig | null> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode !== "saas" || !STIRLING_SAAS_BACKEND_API_URL) {
|
||||
this.clearCache();
|
||||
return null;
|
||||
}
|
||||
if (!force && this.cache && Date.now() < this.cacheExpiry) {
|
||||
return this.cache;
|
||||
}
|
||||
if (this.inFlight) return this.inFlight;
|
||||
this.inFlight = this.fetchConfig();
|
||||
try {
|
||||
return await this.inFlight;
|
||||
} finally {
|
||||
this.inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchConfig(): Promise<AppConfig | null> {
|
||||
try {
|
||||
const base = STIRLING_SAAS_BACKEND_API_URL.replace(/\/$/, "");
|
||||
const response = await fetch(`${base}/api/v1/config/app-config`, {
|
||||
method: "GET",
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`[saasAppConfigService] SaaS app-config fetch failed: ${response.status}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const data = (await response.json()) as AppConfig;
|
||||
this.cache = data;
|
||||
this.cacheExpiry = Date.now() + CACHE_MS;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[saasAppConfigService] SaaS app-config fetch error:",
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the cache (e.g. on connection-mode change) so the next read re-fetches. */
|
||||
clearCache(): void {
|
||||
this.cache = null;
|
||||
this.cacheExpiry = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const saasAppConfigService = new SaasAppConfigService();
|
||||
@@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { generateId } from "@app/utils/generateId";
|
||||
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { getApiBaseUrl } from "@app/services/apiClientConfig";
|
||||
import { getAiBaseUrl } from "@app/services/aiBaseUrl";
|
||||
import { getAuthHeaders } from "@app/services/apiClientSetup";
|
||||
import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge";
|
||||
import { createChildStub } from "@app/contexts/file/fileActions";
|
||||
@@ -407,8 +407,10 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
// Download a File from the Stirling files endpoint.
|
||||
const downloadFile = useCallback(
|
||||
async (descriptor: AiWorkflowResultFile): Promise<File> => {
|
||||
// AI result files live on the backend that ran the workflow (the SaaS
|
||||
// engine on desktop), so fetch from the AI base, not the local backend.
|
||||
const response = await apiClient.get<Blob>(
|
||||
`/api/v1/general/files/${descriptor.fileId}`,
|
||||
`${getAiBaseUrl()}/api/v1/general/files/${descriptor.fileId}`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return new File([response.data], descriptor.fileName, {
|
||||
@@ -520,7 +522,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
formData.append(`conversationHistory[${i}].content`, message.content);
|
||||
});
|
||||
const response = await fetch(
|
||||
`${getApiBaseUrl()}/api/v1/ai/orchestrate/stream`,
|
||||
`${getAiBaseUrl()}/api/v1/ai/orchestrate/stream`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ChatFABButton } from "@shared/components/ChatFABButton";
|
||||
import { ChatFABWindow } from "@shared/components/ChatFABWindow";
|
||||
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
||||
import { useChat } from "@app/components/chat/ChatContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled";
|
||||
import { Z_INDEX_CHAT_FAB_OVERLAY } from "@app/styles/zIndex";
|
||||
import "@app/components/chat/ChatFAB.css";
|
||||
|
||||
@@ -53,8 +53,9 @@ export function ChatFAB() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasUnviewedResult, setHasUnviewedResult] = useState(false);
|
||||
const { isLoading } = useChat();
|
||||
const { config } = useAppConfig();
|
||||
const enabled = Boolean(config?.aiEngineEnabled);
|
||||
// Desktop sources this from the SaaS backend (cloud kill switch); web reads it
|
||||
// from the local app-config. Either way the AI engine drives FAB visibility.
|
||||
const enabled = useAiEngineEnabled();
|
||||
|
||||
// Detect loading → done transition. If the FAB is closed when the agent
|
||||
// finishes, show the tick badge until the user opens the panel.
|
||||
|
||||
Reference in New Issue
Block a user