From f41690ff30c391edfff33b2b66248a1e3212f10e Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 13:20:14 +0000 Subject: [PATCH] feat(nexus): add LocalAI and CloudProviders settings cards (phase 13) LocalAISection surfaces the Hermes adapter tier, Whisper/Piper voice availability, and the global voice toggle wired through nexus-settings. CloudProvidersSection lists Anthropic and OpenAI API key slots; keys are set via the existing /api-keys/store endpoint and rendered as a password-type Input so values are masked on display and never logged. Presence is derived from the workspace secret vault. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../settings/CloudProvidersSection.test.tsx | 139 +++++++++++++++ .../settings/CloudProvidersSection.tsx | 166 ++++++++++++++++++ .../settings/LocalAISection.test.tsx | 87 +++++++++ ui/src/components/settings/LocalAISection.tsx | 125 +++++++++++++ ui/src/pages/PersonalAssistant.tsx | 86 +++++++-- 5 files changed, 588 insertions(+), 15 deletions(-) create mode 100644 ui/src/components/settings/CloudProvidersSection.test.tsx create mode 100644 ui/src/components/settings/CloudProvidersSection.tsx create mode 100644 ui/src/components/settings/LocalAISection.test.tsx create mode 100644 ui/src/components/settings/LocalAISection.tsx diff --git a/ui/src/components/settings/CloudProvidersSection.test.tsx b/ui/src/components/settings/CloudProvidersSection.test.tsx new file mode 100644 index 00000000..a01dd4de --- /dev/null +++ b/ui/src/components/settings/CloudProvidersSection.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { storeApiKeySpy, pushToastSpy } = vi.hoisted(() => ({ + storeApiKeySpy: vi.fn(async () => ({})), + pushToastSpy: vi.fn(), +})); + +vi.mock("@/api/secrets", () => ({ + secretsApi: { + list: vi.fn(async () => [ + { + id: "s1", + companyId: "c1", + name: "anthropic_api_key", + provider: "local_encrypted", + description: "API key for anthropic", + }, + ]), + }, +})); + +vi.mock("@/api/puter-proxy", () => ({ + puterProxyApi: { + storeApiKey: storeApiKeySpy, + }, +})); + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [], + selectedCompanyId: "c1", + selectedCompany: { id: "c1", name: "Test" }, + selectionSource: "manual" as const, + loading: false, + error: null, + setSelectedCompanyId: () => {}, + reloadCompanies: async () => {}, + createCompany: async () => { + throw new Error("not implemented"); + }, + }), +})); + +vi.mock("@/context/ToastContext", () => ({ + useToast: () => ({ + pushToast: pushToastSpy, + dismissToast: () => {}, + clearToasts: () => {}, + toasts: [], + }), +})); + +import { CloudProvidersSection } from "./CloudProvidersSection"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("CloudProvidersSection", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + let queryClient: QueryClient; + + beforeEach(() => { + storeApiKeySpy.mockClear(); + pushToastSpy.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + + afterEach(() => { + if (root) { + act(() => { + root!.unmount(); + }); + root = null; + } + if (container.parentNode) container.remove(); + queryClient.clear(); + }); + + async function renderAndFlush() { + root = createRoot(container); + act(() => { + root!.render( + + + , + ); + }); + // Allow the secrets query to resolve across several microtask ticks. + for (let i = 0; i < 10; i++) { + await act(async () => { + await Promise.resolve(); + }); + } + } + + it("renders the Cloud providers section header", async () => { + await renderAndFlush(); + expect(container.querySelector("h2")?.textContent).toBe("Cloud providers"); + }); + + it("shows 'set' for a provider whose secret exists and 'not set' otherwise", async () => { + await renderAndFlush(); + // Both providers are listed as row labels + expect(container.textContent).toContain("Anthropic API key"); + expect(container.textContent).toContain("OpenAI API key"); + // anthropic has a secret in the mock + const setBadges = Array.from(container.querySelectorAll("span")).filter( + (s) => s.textContent === "set", + ); + const notSetBadges = Array.from(container.querySelectorAll("span")).filter( + (s) => s.textContent === "not set", + ); + expect(setBadges.length).toBe(1); + expect(notSetBadges.length).toBe(1); + }); + + it("input rendered for editing masks its value (type=password)", async () => { + await renderAndFlush(); + const editButtons = Array.from(container.querySelectorAll("button")).filter( + (b) => b.textContent === "Set key" || b.textContent === "Replace", + ); + expect(editButtons.length).toBe(2); + act(() => { + editButtons[0]!.click(); + }); + const input = container.querySelector("input"); + expect(input).not.toBeNull(); + expect(input?.getAttribute("type")).toBe("password"); + }); +}); diff --git a/ui/src/components/settings/CloudProvidersSection.tsx b/ui/src/components/settings/CloudProvidersSection.tsx new file mode 100644 index 00000000..09b232a7 --- /dev/null +++ b/ui/src/components/settings/CloudProvidersSection.tsx @@ -0,0 +1,166 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { secretsApi } from "@/api/secrets"; +import { puterProxyApi } from "@/api/puter-proxy"; +import { useCompany } from "@/context/CompanyContext"; +import { useToast } from "@/context/ToastContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { SettingsSection, SettingsRow } from "./SettingsSection"; + +const PROVIDERS: ReadonlyArray<{ id: "anthropic" | "openai"; label: string }> = [ + { id: "anthropic", label: "Anthropic API key" }, + { id: "openai", label: "OpenAI API key" }, +]; + +interface ProviderRowProps { + id: "anthropic" | "openai"; + label: string; + hasKey: boolean; + companyId: string | null; +} + +function ProviderRow({ id, label, hasKey, companyId }: ProviderRowProps) { + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(""); + const queryClient = useQueryClient(); + const { pushToast } = useToast(); + + const saveMutation = useMutation({ + mutationFn: async (apiKey: string) => { + if (!companyId) throw new Error("No workspace selected"); + if (!apiKey.trim()) throw new Error("API key cannot be empty"); + await puterProxyApi.storeApiKey(companyId, id, apiKey.trim()); + }, + onSuccess: async () => { + setEditing(false); + setValue(""); + pushToast({ type: "success", message: `${label} saved.` }); + if (companyId) { + await queryClient.invalidateQueries({ + queryKey: queryKeys.secrets.list(companyId), + }); + } + }, + onError: (error) => { + pushToast({ + type: "error", + message: error instanceof Error ? error.message : `Failed to save ${label}.`, + }); + }, + }); + + if (editing) { + return ( + +
{ + e.preventDefault(); + saveMutation.mutate(value); + }} + > + setValue(e.target.value)} + placeholder="sk-..." + autoComplete="off" + aria-label={`${label} input`} + className="h-8 w-56 text-xs focus-visible:ring-offset-background" + /> + + +
+
+ ); + } + + return ( + +
+ + {hasKey ? "set" : "not set"} + + +
+
+ ); +} + +export function CloudProvidersSection() { + const { selectedCompanyId } = useCompany(); + + const secretsQuery = useQuery({ + queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"], + queryFn: () => (selectedCompanyId ? secretsApi.list(selectedCompanyId) : Promise.resolve([])), + enabled: Boolean(selectedCompanyId), + }); + + const secretNames = new Set( + (secretsQuery.data ?? []).map((s) => s.name.toLowerCase()), + ); + + return ( + + {PROVIDERS.map((provider) => ( + + ))} + + + Enabled + + + {!selectedCompanyId ? ( +
+ Select a workspace to configure API keys. +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/settings/LocalAISection.test.tsx b/ui/src/components/settings/LocalAISection.test.tsx new file mode 100644 index 00000000..69a20b3b --- /dev/null +++ b/ui/src/components/settings/LocalAISection.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/useHardwareInfo", () => ({ + useHardwareInfo: () => ({ + data: { + totalGb: 32, + freeGb: 16, + usableGb: 24, + platform: "linux", + gpuName: "NVIDIA RTX 4090", + gpuVramGb: 24, + unifiedMemory: false, + hardwareTier: "gpu" as const, + cpuModel: "AMD Ryzen 9", + voiceCapability: { + whisperAvailable: true, + piperAvailable: false, + voiceTierSufficient: true, + }, + }, + error: null, + isLoading: false, + }), +})); + +vi.mock("@/api/hardware", () => ({ + fetchNexusSettings: vi.fn(async () => ({ mode: "both", voiceEnabled: false })), + updateNexusSettings: vi.fn(async (patch: unknown) => patch), +})); + +import { LocalAISection } from "./LocalAISection"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("LocalAISection", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + let queryClient: QueryClient; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + + afterEach(() => { + if (root) { + act(() => { + root!.unmount(); + }); + root = null; + } + if (container.parentNode) container.remove(); + queryClient.clear(); + }); + + function render() { + root = createRoot(container); + act(() => { + root!.render( + + + , + ); + }); + } + + it("renders Local AI section header and hardware tier", () => { + render(); + expect(container.querySelector("h2")?.textContent).toBe("Local AI"); + expect(container.textContent).toContain("GPU"); + expect(container.textContent).toContain("NVIDIA RTX 4090"); + }); + + it("shows Whisper as installed and Piper as not installed based on voice capability", () => { + render(); + expect(container.textContent).toContain("Installed"); + expect(container.textContent).toContain("Not installed"); + }); +}); diff --git a/ui/src/components/settings/LocalAISection.tsx b/ui/src/components/settings/LocalAISection.tsx new file mode 100644 index 00000000..6fa13a15 --- /dev/null +++ b/ui/src/components/settings/LocalAISection.tsx @@ -0,0 +1,125 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useHardwareInfo } from "@/hooks/useHardwareInfo"; +import { fetchNexusSettings, updateNexusSettings } from "@/api/hardware"; +import { queryKeys } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import { SettingsSection, SettingsRow } from "./SettingsSection"; + +function formatTier(tier: string | undefined) { + switch (tier) { + case "gpu": + return "GPU"; + case "apple_silicon": + return "Apple Silicon"; + case "cpu_only": + return "CPU only"; + default: + return "Unknown"; + } +} + +export function LocalAISection() { + const queryClient = useQueryClient(); + const hardwareQuery = useHardwareInfo(true); + const settingsQuery = useQuery({ + queryKey: queryKeys.nexus.settings, + queryFn: fetchNexusSettings, + }); + + const updateSettingsMutation = useMutation({ + mutationFn: updateNexusSettings, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.nexus.settings }); + }, + }); + + const hardware = hardwareQuery.data; + const voice = hardware?.voiceCapability; + const voiceEnabled = settingsQuery.data?.voiceEnabled === true; + + const hermesProviderLabel = + hardware?.gpuName + ? `${hardware.gpuName}${hardware.gpuVramGb ? ` · ${hardware.gpuVramGb}GB VRAM` : ""}` + : hardware?.cpuModel ?? "Detecting..."; + + return ( + + + + {formatTier(hardware?.hardwareTier)} + {hardware ? ` · ${hermesProviderLabel}` : ""} + + + + + + {voice?.whisperAvailable ? "Installed" : "Not installed"} + + + + + + {voice?.piperAvailable ? "Installed" : "Not installed"} + + + + + + + + {hardwareQuery.error ? ( +
+ {hardwareQuery.error instanceof Error + ? hardwareQuery.error.message + : "Failed to load hardware info."} +
+ ) : null} +
+ ); +} diff --git a/ui/src/pages/PersonalAssistant.tsx b/ui/src/pages/PersonalAssistant.tsx index 9520bc70..42830b34 100644 --- a/ui/src/pages/PersonalAssistant.tsx +++ b/ui/src/pages/PersonalAssistant.tsx @@ -17,6 +17,7 @@ import { useNexusMode } from "../hooks/useNexusMode"; import { useCompany } from "../context/CompanyContext"; import { useChatPanel } from "../context/ChatPanelContext"; import { useToast } from "../context/ToastContext"; +import { useVoice } from "../context/VoiceContext"; import { chatApi } from "../api/chat"; import { useStreamingChat } from "../hooks/useStreamingChat"; import { @@ -28,6 +29,9 @@ import { AssistantInputBar } from "../components/assistant/AssistantInputBar"; import { ActionStrip } from "../components/assistant/ActionStrip"; import { HistorySheet } from "../components/assistant/HistorySheet"; import { MemorySheet } from "../components/assistant/MemorySheet"; +import { BrainstormerPanel } from "../components/assistant/BrainstormerPanel"; +import { PromoteTransition } from "../components/assistant/PromoteTransition"; +import { usePromoteToProject } from "../hooks/usePromoteToProject"; import { normalizeCompanyPrefix } from "../lib/company-routes"; export function PersonalAssistant() { @@ -170,24 +174,31 @@ export function PersonalAssistant() { [companyId, queryClient, pushToast, selectedConvId, setActiveConversationId, startStream], ); - const handlePromote = useCallback(async () => { - if (!selectedConvId || !canPromote) return; - try { - await chatApi.assistantHandoff(selectedConvId); + // Phase 12 — promote-to-project state machine drives the 700ms + // compress-and-rise animation + brainstormer form. Replaces the + // previous direct `chatApi.assistantHandoff()` call. + const promote = usePromoteToProject({ + conversationId: selectedConvId, + companyId, + }); + + // Surface promote errors as toasts and reset the machine. + useEffect(() => { + if (promote.state.kind === "error") { pushToast({ - title: "Conversation handed off to PM", - tone: "success", - }); - } catch { - pushToast({ - title: "Handoff failed", - body: "Could not create project conversation.", + title: "Promote failed", + body: promote.state.message, tone: "error", }); + promote.reset(); } - // Phase 12 will replace this direct handoff call with the 700ms - // compress-and-rise animated transition to the brainstormer panel. - }, [selectedConvId, canPromote, pushToast]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [promote.state.kind]); + + const handlePromote = useCallback(() => { + if (!selectedConvId || !canPromote) return; + promote.startPrompting(); + }, [selectedConvId, canPromote, promote]); const handleAttach = useCallback(() => { fileInputRef.current?.click(); @@ -228,11 +239,31 @@ export function PersonalAssistant() { // there's no selected id — the user can always start one by typing. const showHomeGreeting = !hasActiveConversation; + const promoteActive = + promote.state.kind === "prompting" || promote.state.kind === "creating"; + return (
+ {/* Phase 12 — persistent project banner when the current chat has + been promoted. */} + {promote.state.kind === "done" && companyPrefix && ( + + )} + {/* Conversation thread — full-bleed, max-width 760px centered */}
@@ -259,6 +290,31 @@ export function PersonalAssistant() {
+ {/* Phase 12 — promote-to-project overlay. Absolutely positioned so + the chat canvas is covered during the animation while keeping + the anchored input bar visible underneath. */} + {promoteActive && selectedConvId && ( + + } + > + {hasActiveConversation && selectedConvId && ( + + )} + + )} + {/* Anchored input + action strip */}
@@ -268,7 +324,7 @@ export function PersonalAssistant() { enableVoiceInput /> setMemoryOpen(true)}