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:
@@ -193,6 +193,8 @@ What goes where:
|
|||||||
|
|
||||||
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
|
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
|
||||||
|
|
||||||
|
**Cloud feature flags on desktop.** The local `AppConfigContext` reads `/api/v1/config/app-config` from the LOCAL bundled backend, so cloud-only flags (`aiEngineEnabled`, `premiumEnabled`, …) are never seen on desktop. To read the cloud's view, use `useSaasAppConfig()` (`desktop/hooks/useSaasAppConfig.ts`, backed by the general `saasAppConfigService` — SaaS-mode-only, public endpoint, native HTTP, 5-min cache). It returns `null` outside SaaS mode, so cloud features stay off in local/self-hosted and the server keeps the on/off switch (no desktop release needed to flip a flag). Gate a feature behind a per-platform seam — e.g. `useAiEngineEnabled()` (core reads `useAppConfig()`, desktop reads `useSaasAppConfig()`) — rather than hardcoding the flag on.
|
||||||
|
|
||||||
#### Component Override Pattern (Stub/Shadow)
|
#### Component Override Pattern (Stub/Shadow)
|
||||||
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
|
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Exercise the error-interceptor logic against a hand-rolled axios-like
|
||||||
// client; transitive imports are mocked out so this is a pure unit test.
|
// 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("@app/components/toast", () => ({ alert: vi.fn() }));
|
||||||
vi.mock("@core/services/apiClientSetup", () => ({
|
vi.mock("@core/services/apiClientSetup", () => ({
|
||||||
setupApiInterceptors: vi.fn(),
|
setupApiInterceptors: vi.fn(),
|
||||||
@@ -50,7 +55,11 @@ vi.mock("@app/i18n", () => ({
|
|||||||
default: { t: (_key: string, fallback: string) => fallback || _key },
|
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;
|
type Interceptor = (value: unknown) => unknown;
|
||||||
|
|
||||||
@@ -177,3 +186,66 @@ describe("desktop apiClientSetup - 401 silent-path", () => {
|
|||||||
expect(events).toHaveLength(0);
|
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 type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
|
||||||
import { alert } from "@app/components/toast";
|
import { alert } from "@app/components/toast";
|
||||||
import {
|
import { setupApiInterceptors as coreSetup } from "@core/services/apiClientSetup";
|
||||||
setupApiInterceptors as coreSetup,
|
|
||||||
getAuthHeaders,
|
|
||||||
} from "@core/services/apiClientSetup";
|
|
||||||
|
|
||||||
export { getAuthHeaders };
|
|
||||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||||
import { createBackendNotReadyError } from "@app/constants/backendErrors";
|
import { createBackendNotReadyError } from "@app/constants/backendErrors";
|
||||||
import { operationRouter } from "@app/services/operationRouter";
|
import { operationRouter } from "@app/services/operationRouter";
|
||||||
@@ -19,6 +14,21 @@ import {
|
|||||||
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
||||||
import i18n from "@app/i18n";
|
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;
|
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
||||||
let lastBackendToast = 0;
|
let lastBackendToast = 0;
|
||||||
|
|
||||||
@@ -71,7 +81,17 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
|||||||
// - SaaS backend: Needs auth token
|
// - SaaS backend: Needs auth token
|
||||||
// - Self-hosted backend: Needs auth token
|
// - Self-hosted backend: Needs auth token
|
||||||
const isRemote = await operationRouter.isSelfHostedMode();
|
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;
|
const needsAuth = isRemote || isSaaSBackendRequest;
|
||||||
|
|
||||||
// Tag request so error handler can identify SaaS backend errors without URL matching
|
// 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\/auth\//, // Supabase auth (SaaS mode)
|
||||||
/^\/api\/v1\/payg\//, // PAYG wallet / spend-cap / billing
|
/^\/api\/v1\/payg\//, // PAYG wallet / spend-cap / billing
|
||||||
/^\/api\/v1\/policies(?:\/|$)/, // Policy runs — must bill via the cloud
|
/^\/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.
|
// 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 { generateId } from "@app/utils/generateId";
|
||||||
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
|
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
|
||||||
import apiClient from "@app/services/apiClient";
|
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 { getAuthHeaders } from "@app/services/apiClientSetup";
|
||||||
import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge";
|
import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge";
|
||||||
import { createChildStub } from "@app/contexts/file/fileActions";
|
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.
|
// Download a File from the Stirling files endpoint.
|
||||||
const downloadFile = useCallback(
|
const downloadFile = useCallback(
|
||||||
async (descriptor: AiWorkflowResultFile): Promise<File> => {
|
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>(
|
const response = await apiClient.get<Blob>(
|
||||||
`/api/v1/general/files/${descriptor.fileId}`,
|
`${getAiBaseUrl()}/api/v1/general/files/${descriptor.fileId}`,
|
||||||
{ responseType: "blob" },
|
{ responseType: "blob" },
|
||||||
);
|
);
|
||||||
return new File([response.data], descriptor.fileName, {
|
return new File([response.data], descriptor.fileName, {
|
||||||
@@ -520,7 +522,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
formData.append(`conversationHistory[${i}].content`, message.content);
|
formData.append(`conversationHistory[${i}].content`, message.content);
|
||||||
});
|
});
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getApiBaseUrl()}/api/v1/ai/orchestrate/stream`,
|
`${getAiBaseUrl()}/api/v1/ai/orchestrate/stream`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ChatFABButton } from "@shared/components/ChatFABButton";
|
|||||||
import { ChatFABWindow } from "@shared/components/ChatFABWindow";
|
import { ChatFABWindow } from "@shared/components/ChatFABWindow";
|
||||||
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
||||||
import { useChat } from "@app/components/chat/ChatContext";
|
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 { Z_INDEX_CHAT_FAB_OVERLAY } from "@app/styles/zIndex";
|
||||||
import "@app/components/chat/ChatFAB.css";
|
import "@app/components/chat/ChatFAB.css";
|
||||||
|
|
||||||
@@ -53,8 +53,9 @@ export function ChatFAB() {
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [hasUnviewedResult, setHasUnviewedResult] = useState(false);
|
const [hasUnviewedResult, setHasUnviewedResult] = useState(false);
|
||||||
const { isLoading } = useChat();
|
const { isLoading } = useChat();
|
||||||
const { config } = useAppConfig();
|
// Desktop sources this from the SaaS backend (cloud kill switch); web reads it
|
||||||
const enabled = Boolean(config?.aiEngineEnabled);
|
// 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
|
// Detect loading → done transition. If the FAB is closed when the agent
|
||||||
// finishes, show the tick badge until the user opens the panel.
|
// finishes, show the tick badge until the user opens the panel.
|
||||||
|
|||||||
Reference in New Issue
Block a user