diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 909bd079..4b0a8065 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -10,6 +10,31 @@ interface WorkspaceOption { tier: number; } +interface HermesProvider { + id: string; + label: string; + envVar: string; +} + +// All providers supported by Hermes runtime via providers.resolve_provider() +export const HERMES_PROVIDERS: HermesProvider[] = [ + { id: "anthropic", label: "Anthropic (Claude)", envVar: "ANTHROPIC_API_KEY" }, + { id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" }, + { id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" }, + { id: "xai", label: "xAI (Grok)", envVar: "XAI_API_KEY" }, + { id: "gemini", label: "Google Gemini", envVar: "GEMINI_API_KEY" }, + { id: "qwen", label: "Qwen (Alibaba)", envVar: "QWEN_API_KEY" }, + { id: "glm", label: "GLM (Zhipu AI)", envVar: "GLM_API_KEY" }, + { id: "kimi", label: "Kimi (Moonshot)", envVar: "KIMI_API_KEY" }, + { id: "minimax", label: "MiniMax", envVar: "MINIMAX_API_KEY" }, + { id: "deepseek", label: "DeepSeek", envVar: "DEEPSEEK_API_KEY" }, + { id: "groq", label: "Groq", envVar: "GROQ_API_KEY" }, + { id: "mistral", label: "Mistral", envVar: "MISTRAL_API_KEY" }, + { id: "together", label: "Together AI", envVar: "TOGETHER_API_KEY" }, + { id: "fireworks", label: "Fireworks AI", envVar: "FIREWORKS_API_KEY" }, + { id: "hermes", label: "Hermes / Nous (legacy)", envVar: "HERMES_API_KEY" }, +]; + export function CreateWorkspaceButton() { const [open, setOpen] = useState(false); const [name, setName] = useState(""); @@ -21,6 +46,12 @@ export function CreateWorkspaceButton() { const [error, setError] = useState(null); const [workspaces, setWorkspaces] = useState([]); + // Hermes-specific state + const [hermesProvider, setHermesProvider] = useState("anthropic"); + const [hermesApiKey, setHermesApiKey] = useState(""); + + const isHermes = template.trim().toLowerCase() === "hermes"; + // Reset form and load workspaces whenever dialog opens useEffect(() => { if (!open) return; @@ -30,6 +61,8 @@ export function CreateWorkspaceButton() { setTemplate(""); setParentId(""); setError(null); + setHermesProvider("anthropic"); + setHermesApiKey(""); api .get("/workspaces") .then((ws) => setWorkspaces(ws)) @@ -41,8 +74,17 @@ export function CreateWorkspaceButton() { setError("Name is required"); return; } + if (isHermes && !hermesApiKey.trim()) { + setError("API key is required for Hermes workspaces"); + return; + } setCreating(true); setError(null); + + const provider = isHermes + ? HERMES_PROVIDERS.find((p) => p.id === hermesProvider) + : undefined; + try { await api.post("/workspaces", { name: name.trim(), @@ -51,6 +93,9 @@ export function CreateWorkspaceButton() { tier, parent_id: parentId || undefined, canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 }, + ...(isHermes && provider + ? { secrets: { [provider.envVar]: hermesApiKey.trim() } } + : {}), }); setOpen(false); } catch (e) { @@ -86,7 +131,7 @@ export function CreateWorkspaceButton() { @@ -173,6 +218,67 @@ export function CreateWorkspaceButton() { + {/* Hermes provider configuration — shown only when template === "hermes" */} + {isHermes && ( +
+

+ Hermes Provider +

+

+ Choose the AI provider and paste your API key. The key is + stored as an encrypted workspace secret. +

+ +
+ + +
+ +
+ + setHermesApiKey(e.target.value)} + placeholder="sk-…" + aria-label="Hermes API key" + autoComplete="off" + className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono" + /> +
+
+ )} + {error && (
({ api: { @@ -40,6 +40,13 @@ async function openDialog() { await waitFor(() => expect(screen.getByText("Create Workspace")).toBeTruthy()); } +async function setTemplate(value: string) { + fireEvent.change( + screen.getByPlaceholderText("e.g. seo-agent (from workspace-configs-templates/)"), + { target: { value } } + ); +} + describe("CreateWorkspaceDialog", () => { it("opens the dialog when New Workspace button is clicked", async () => { await openDialog(); @@ -128,3 +135,167 @@ describe("CreateWorkspaceDialog", () => { }); }); }); + +// --------------------------------------------------------------------------- +// Hermes provider picker tests +// --------------------------------------------------------------------------- + +describe("CreateWorkspaceDialog — Hermes provider picker", () => { + it("does NOT show hermes provider section for non-hermes templates", async () => { + await openDialog(); + await setTemplate("seo-agent"); + expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeNull(); + }); + + it("shows hermes provider section when template is 'hermes'", async () => { + await openDialog(); + await setTemplate("hermes"); + await waitFor(() => + expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() + ); + }); + + it("shows hermes provider section for template 'HERMES' (case-insensitive)", async () => { + await openDialog(); + await setTemplate("HERMES"); + await waitFor(() => + expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() + ); + }); + + it("hermes provider dropdown defaults to 'anthropic'", async () => { + await openDialog(); + await setTemplate("hermes"); + await waitFor(() => + expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() + ); + const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement; + expect(providerSelect).toBeTruthy(); + expect(providerSelect.value).toBe("anthropic"); + }); + + it("hermes provider dropdown lists all 15 providers", async () => { + await openDialog(); + await setTemplate("hermes"); + await waitFor(() => + expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() + ); + const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement; + expect(providerSelect.options.length).toBe(HERMES_PROVIDERS.length); + const ids = Array.from(providerSelect.options).map((o) => o.value); + expect(ids).toContain("anthropic"); + expect(ids).toContain("openai"); + expect(ids).toContain("gemini"); + expect(ids).toContain("deepseek"); + expect(ids).toContain("hermes"); + }); + + it("hermes API key field is a password input (masked)", async () => { + await openDialog(); + await setTemplate("hermes"); + await waitFor(() => + expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() + ); + const keyInput = document.getElementById("hermes-api-key-input") as HTMLInputElement; + expect(keyInput).toBeTruthy(); + expect(keyInput.type).toBe("password"); + }); + + it("shows an error if hermes template is set but API key is empty on submit", async () => { + await openDialog(); + fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { + target: { value: "Hermes Agent" }, + }); + await setTemplate("hermes"); + await waitFor(() => + expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() + ); + + // Submit without API key + const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create"); + fireEvent.click(createBtn!); + + await waitFor(() => { + const alert = screen.getByRole("alert"); + expect(alert.textContent).toContain("API key"); + }); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("includes secrets in POST body with correct env var for selected provider", async () => { + await openDialog(); + fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { + target: { value: "Hermes Agent" }, + }); + await setTemplate("hermes"); + await waitFor(() => + expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() + ); + + // Fill in the API key + const keyInput = document.getElementById("hermes-api-key-input") as HTMLInputElement; + fireEvent.change(keyInput, { target: { value: "sk-test-anthropic-key" } }); + + const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create"); + fireEvent.click(createBtn!); + + await waitFor(() => expect(mockPost).toHaveBeenCalled()); + const body = mockPost.mock.calls[0][1] as Record; + expect(body.secrets).toEqual({ ANTHROPIC_API_KEY: "sk-test-anthropic-key" }); + expect(body.template).toBe("hermes"); + }); + + it("uses the correct env var when a non-default provider is selected", async () => { + await openDialog(); + fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { + target: { value: "Hermes OpenAI" }, + }); + await setTemplate("hermes"); + await waitFor(() => + expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() + ); + + // Switch to openai + const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement; + fireEvent.change(providerSelect, { target: { value: "openai" } }); + + const keyInput = document.getElementById("hermes-api-key-input") as HTMLInputElement; + fireEvent.change(keyInput, { target: { value: "sk-openai-test" } }); + + const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create"); + fireEvent.click(createBtn!); + + await waitFor(() => expect(mockPost).toHaveBeenCalled()); + const body = mockPost.mock.calls[0][1] as Record; + expect(body.secrets).toEqual({ OPENAI_API_KEY: "sk-openai-test" }); + }); + + it("does NOT include secrets field when template is not hermes", async () => { + await openDialog(); + fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { + target: { value: "Normal Agent" }, + }); + await setTemplate("seo-agent"); + + const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create"); + fireEvent.click(createBtn!); + + await waitFor(() => expect(mockPost).toHaveBeenCalled()); + const body = mockPost.mock.calls[0][1] as Record; + expect(body.secrets).toBeUndefined(); + }); + + it("hides hermes section and resets state when template is cleared", async () => { + await openDialog(); + await setTemplate("hermes"); + await waitFor(() => + expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() + ); + + // Clear template + await setTemplate(""); + await waitFor(() => + expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeNull() + ); + }); +});