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) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 13:20:14 +00:00
parent b277527510
commit f41690ff30
5 changed files with 588 additions and 15 deletions

View file

@ -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<typeof createRoot> | 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(
<QueryClientProvider client={queryClient}>
<CloudProvidersSection />
</QueryClientProvider>,
);
});
// 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");
});
});

View file

@ -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 (
<SettingsRow label={label} description={`Paste your ${label.toLowerCase()} to store it encrypted.`}>
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
saveMutation.mutate(value);
}}
>
<Input
type="password"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="sk-..."
autoComplete="off"
aria-label={`${label} input`}
className="h-8 w-56 text-xs focus-visible:ring-offset-background"
/>
<Button
type="submit"
variant="outline"
size="sm"
disabled={saveMutation.isPending || !value.trim() || !companyId}
>
{saveMutation.isPending ? "Saving..." : "Save"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setEditing(false);
setValue("");
}}
>
Cancel
</Button>
</form>
</SettingsRow>
);
}
return (
<SettingsRow
label={label}
description={
hasKey
? "Key is stored encrypted in the workspace secret vault."
: "Not set — the workspace will fall back to Puter.js free tier when available."
}
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">
{hasKey ? "set" : "not set"}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setEditing(true)}
disabled={!companyId}
>
{hasKey ? "Replace" : "Set key"}
</Button>
</div>
</SettingsRow>
);
}
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 (
<SettingsSection
title="Cloud providers"
description="Bring your own API keys. Values are masked on display and never logged."
>
{PROVIDERS.map((provider) => (
<ProviderRow
key={provider.id}
id={provider.id}
label={provider.label}
hasKey={secretNames.has(`${provider.id}_api_key`)}
companyId={selectedCompanyId}
/>
))}
<SettingsRow
label="Puter.js"
description="Zero-config free tier that routes through the Puter cloud when no API key is set."
>
<span className="text-xs text-muted-foreground">Enabled</span>
</SettingsRow>
{!selectedCompanyId ? (
<div className="rounded-md border border-border/60 bg-transparent px-3 py-2 text-xs text-muted-foreground">
Select a workspace to configure API keys.
</div>
) : null}
</SettingsSection>
);
}

View file

@ -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<typeof createRoot> | 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(
<QueryClientProvider client={queryClient}>
<LocalAISection />
</QueryClientProvider>,
);
});
}
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");
});
});

View file

@ -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 (
<SettingsSection title="Local AI">
<SettingsRow
label="Hermes provider"
description="Local inference adapter auto-selected based on your hardware tier."
>
<span className="text-sm text-muted-foreground">
{formatTier(hardware?.hardwareTier)}
{hardware ? ` · ${hermesProviderLabel}` : ""}
</span>
</SettingsRow>
<SettingsRow
label="Whisper STT"
description="Speech-to-text. Runs entirely on your device when available."
>
<span
className={cn(
"text-sm",
voice?.whisperAvailable ? "text-primary" : "text-muted-foreground",
)}
>
{voice?.whisperAvailable ? "Installed" : "Not installed"}
</span>
</SettingsRow>
<SettingsRow
label="Piper TTS"
description="Text-to-speech voice model for reading assistant replies aloud."
>
<span
className={cn(
"text-sm",
voice?.piperAvailable ? "text-primary" : "text-muted-foreground",
)}
>
{voice?.piperAvailable ? "Installed" : "Not installed"}
</span>
</SettingsRow>
<SettingsRow
label="Voice features"
description="Enable speaking to the assistant and hearing responses. Requires Whisper and Piper installed."
>
<button
type="button"
data-slot="toggle"
aria-label="Toggle voice features"
aria-pressed={voiceEnabled}
disabled={
updateSettingsMutation.isPending ||
settingsQuery.isLoading ||
!voice?.voiceTierSufficient
}
onClick={() => updateSettingsMutation.mutate({ voiceEnabled: !voiceEnabled })}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
voiceEnabled ? "bg-success" : "bg-muted",
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
voiceEnabled ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</SettingsRow>
{hardwareQuery.error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{hardwareQuery.error instanceof Error
? hardwareQuery.error.message
: "Failed to load hardware info."}
</div>
) : null}
</SettingsSection>
);
}

View file

@ -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 (
<div
className="relative flex h-full min-h-0 flex-col bg-background -m-4 md:-m-6"
data-pathname={location.pathname}
>
{/* Phase 12 persistent project banner when the current chat has
been promoted. */}
{promote.state.kind === "done" && companyPrefix && (
<div
data-testid="assistant-project-banner"
className="border-l-2 border-primary bg-card px-4 py-2 text-[12px] text-foreground"
>
{" "}
<a
href={`/${companyPrefix}/projects/${promote.state.projectSlug}/overview`}
className="font-semibold uppercase tracking-[0.12em] text-primary hover:underline"
>
Project: {promote.state.projectName}
</a>
</div>
)}
{/* Conversation thread — full-bleed, max-width 760px centered */}
<div className="flex-1 min-h-0 overflow-auto">
<div className="mx-auto w-full max-w-[760px] px-6 py-8">
@ -259,6 +290,31 @@ export function PersonalAssistant() {
</div>
</div>
{/* 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 && (
<PromoteTransition
state={promote.state.kind}
panelChildren={
<BrainstormerPanel
conversationId={selectedConvId}
onConfirm={promote.confirm}
onCancel={promote.cancel}
isCreating={promote.state.kind === "creating"}
/>
}
>
{hasActiveConversation && selectedConvId && (
<ChatMessageList
conversationId={selectedConvId}
streamingContent={streamingContent}
isStreaming={isStreaming}
/>
)}
</PromoteTransition>
)}
{/* Anchored input + action strip */}
<div className="border-t border-border bg-background">
<div className="mx-auto w-full max-w-[760px] px-6 py-4">
@ -268,7 +324,7 @@ export function PersonalAssistant() {
enableVoiceInput
/>
<ActionStrip
canPromote={canPromote}
canPromote={canPromote && promote.state.kind === "idle"}
onPromote={handlePromote}
onAttach={handleAttach}
onOpenMemory={() => setMemoryOpen(true)}