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:
ConnorYoh
2026-06-17 14:10:35 +00:00
committed by GitHub
parent df9dbc5179
commit 4f26fdeb5c
13 changed files with 346 additions and 14 deletions
@@ -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.