diff --git a/README.md b/README.md index aeaed4c..1f9055b 100644 --- a/README.md +++ b/README.md @@ -154,11 +154,11 @@ The default model is `chat-latest`, OpenAI’s documented alias for the latest C ## Use your own API keys -Open **API keys** in the chat header, enter an OpenAI key and a Jev key, then choose **Test connection** and **Save keys**. The connection test makes a small request to each provider. Your keys use `chat-latest` for answers and `jev-latest` for visualization routing, with usage billed to your provider accounts. +The chat asks for both keys before sending your first message or example prompt and keeps your draft while you set them up. Open **API keys** in the chat header, enter an OpenAI key and a Jev key, then choose **Test connection** and **Save keys**. The connection test makes a small request to each provider. Your keys use `chat-latest` for answers and `jev-latest` for visualization routing, with usage billed to your provider accounts. Keys stay in browser memory until refresh or **Clear keys**; the app does not save them in browser storage. Requests send them through the application server to their respective providers. They are kept out of chat state and checkpoints, and hosted LangSmith tracing is disabled for requests using your keys. Only use this feature on a deployment whose operator you trust. Saving or clearing keys starts a new chat; **New chat** keeps your keys for the current session. -The agent can run without shared provider keys. Visitors must then supply both keys before chatting. Configured server credentials remain available when visitors have not supplied their own. +The agent can run without shared provider keys. Visitors must then supply both keys before chatting. Configured server credentials remain available to direct API clients that omit visitor keys; the chat interface requires visitors to add their own keys. ## How it works diff --git a/apps/app/src/app/__tests__/shared-thread.test.tsx b/apps/app/src/app/__tests__/shared-thread.test.tsx index 2a174ae..d6a600c 100644 --- a/apps/app/src/app/__tests__/shared-thread.test.tsx +++ b/apps/app/src/app/__tests__/shared-thread.test.tsx @@ -42,6 +42,7 @@ function EntryAndChat() { } beforeEach(() => { + HTMLDialogElement.prototype.showModal = function () { this.setAttribute("open", ""); }; vi.stubGlobal( "ResizeObserver", class { @@ -61,6 +62,7 @@ function mockRuntime() { vi.stubGlobal( "fetch", vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + if (String(_url) === "/api/provider-keys") return Response.json({ ok: true }); const body = typeof init?.body === "string" ? JSON.parse(init.body) : {}; if (body.method === "info" || String(_url).endsWith("/info")) { return Response.json({ @@ -149,6 +151,17 @@ it("submits through the real chat-only page composer and preserves its message", key: "Enter", code: "Enter", }); + expect(await screen.findByRole("dialog", { name: "Add API keys to start" })).toBeVisible(); + expect(runInputs).toHaveLength(0); + fireEvent.change(screen.getByLabelText("OpenAI API key"), { target: { value: "test-openai" } }); + fireEvent.change(screen.getByLabelText("Jev API key"), { target: { value: "test-jev" } }); + fireEvent.click(screen.getByRole("button", { name: "Test connection" })); + await screen.findByText("Both keys work. Save to start a new chat."); + fireEvent.click(screen.getByRole("button", { name: "Save keys" })); + await waitFor(() => expect(screen.getByTestId("runtime-status")).toHaveTextContent("connected")); + expect(screen.getByRole("textbox")).toHaveValue("Explain bicycle gears"); + expect(runInputs).toHaveLength(0); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter", code: "Enter" }); await waitFor(() => expect(runInputs).toHaveLength(1)); expect(await screen.findByText("Explain bicycle gears")).toBeVisible(); }); diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 62b6ff1..3d82645 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { ProviderKeysDialog } from "@/components/chat/provider-keys-dialog"; import { useProviderKeys } from "@/components/chat/provider-keys"; +import { KeyGatedChatView } from "@/components/chat/key-gated-chat-view"; import Image from "next/image"; import { ReaderScrollView } from "@/components/chat/reader-scroll-view"; import { AnswerMarkdown } from "@/components/chat/answer-markdown"; @@ -17,8 +18,7 @@ export default function HomePage() { useGenerativeUIExamples(); useExampleSuggestions(); const { agent } = useAgent(); - const { newChat, hasKeys } = useProviderKeys(); - const [keysOpen, setKeysOpen] = useState(false); + const { newChat, hasKeys, keysOpen, setKeysOpen } = useProviderKeys(); const { copilotkit } = useCopilotKit(); const [error, setError] = useState(null); const [retrying, setRetrying] = useState(false); @@ -129,6 +129,7 @@ export default function HomePage() { )}
diff --git a/apps/app/src/components/chat/key-gated-chat-view.test.tsx b/apps/app/src/components/chat/key-gated-chat-view.test.tsx new file mode 100644 index 0000000..a1556d3 --- /dev/null +++ b/apps/app/src/components/chat/key-gated-chat-view.test.tsx @@ -0,0 +1,59 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import type { ComponentProps } from "react"; +import type { CopilotChatView } from "@copilotkit/react-core/v2"; +import { ProviderKeysProvider, useProviderKeys } from "./provider-keys"; +import { KeyGatedChatView } from "./key-gated-chat-view"; + +vi.mock("@copilotkit/react-core/v2", () => ({ + CopilotChatView: (props: ComponentProps) => <> + props.onInputChange?.(event.target.value)} /> + + + , +})); +const submit = vi.fn(); +const select = vi.fn(); +function Harness() { + const { session, keysOpen, setKeysOpen, save, newChat } = useProviderKeys(); + return <> + + {keysOpen &&
+ + +
} + + ; +} +afterEach(() => { cleanup(); vi.clearAllMocks(); }); + +it("opens setup before sending and preserves the message across cancel and the key-save remount", () => { + render(); + fireEvent.change(screen.getByLabelText("Message"), { target: { value: "Show a map" } }); + fireEvent.click(screen.getByText("Send")); + expect(submit).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Cancel")); + expect(screen.getByLabelText("Message")).toHaveValue("Show a map"); + fireEvent.click(screen.getByText("Send")); + fireEvent.click(screen.getByText("Save tested keys")); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Message")).toHaveValue("Show a map"); + expect(submit).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText("Send")); + expect(submit).toHaveBeenCalledExactlyOnceWith("Show a map"); + expect(screen.getByLabelText("Message")).toHaveValue(""); +}); + +it("gates example prompts and clears the preserved draft when starting a new chat", () => { + render(); + fireEvent.click(screen.getByText("Example")); + expect(select).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Message")).toHaveValue("Explain flight"); + fireEvent.click(screen.getByText("Save tested keys")); + fireEvent.click(screen.getByText("New chat")); + expect(screen.getByLabelText("Message")).toHaveValue(""); + fireEvent.click(screen.getByText("Example")); + expect(select).toHaveBeenCalledOnce(); +}); diff --git a/apps/app/src/components/chat/key-gated-chat-view.tsx b/apps/app/src/components/chat/key-gated-chat-view.tsx new file mode 100644 index 0000000..da813d7 --- /dev/null +++ b/apps/app/src/components/chat/key-gated-chat-view.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { CopilotChatView } from "@copilotkit/react-core/v2"; +import type { ComponentProps } from "react"; +import { useProviderKeys } from "./provider-keys"; + +/** Gate submission before CopilotKit adds a message or starts a provider request. */ +function KeyGatedChatViewComponent(props: ComponentProps) { + const { hasKeys, draft, setDraft, setKeysOpen } = useProviderKeys(); + return { + if (!hasKeys) { + setDraft(value); + setKeysOpen(true); + return; + } + setDraft(""); + props.onSubmitMessage?.(value); + }} + onSelectSuggestion={(suggestion, index) => { + if (!hasKeys) { + setDraft(suggestion.message); + setKeysOpen(true); + return; + } + setDraft(""); + props.onSelectSuggestion?.(suggestion, index); + }} + />; +} + +// Keep the compound-component slots expected by CopilotKit’s chatView contract. +export const KeyGatedChatView = Object.assign(KeyGatedChatViewComponent, CopilotChatView); diff --git a/apps/app/src/components/chat/provider-keys-dialog.tsx b/apps/app/src/components/chat/provider-keys-dialog.tsx index 8f1651e..1743e30 100644 --- a/apps/app/src/components/chat/provider-keys-dialog.tsx +++ b/apps/app/src/components/chat/provider-keys-dialog.tsx @@ -59,7 +59,8 @@ export function ProviderKeysDialog({ onClose, disabled = false }: { onClose: () return ( { event.preventDefault(); onClose(); }}> -

API keys

+

{hasKeys ? "API keys" : "Add API keys to start"}

+ {!hasKeys &&

Connect OpenAI for answers and Jev for choosing visualizations. Your message will stay ready to send.

}

Keys stay only in this browser’s memory until you refresh or clear them. They are sent through this app’s server to their respective providers. Provider usage is charged to your accounts.

Saving or clearing keys starts a new chat.

{ event.preventDefault(); void testConnection(); }}> @@ -68,7 +69,7 @@ export function ProviderKeysDialog({ onClose, disabled = false }: { onClose: () edit(setJev, event.target.value)} disabled={pending || disabled} /> {error &&

{error}

} -

{pending ? "Checking both providers…" : validated ? "Both keys work. Save to start a new chat." : hasKeys ? "Your own keys are active for this session." : "Using the app’s shared configuration."}

+

{pending ? "Checking both providers…" : validated ? "Both keys work. Save to start a new chat." : hasKeys ? "Your own keys are active for this session." : "Both keys are required to start chatting."}

{hasKeys && } diff --git a/apps/app/src/components/chat/provider-keys.test.tsx b/apps/app/src/components/chat/provider-keys.test.tsx index 10ea040..6b69843 100644 --- a/apps/app/src/components/chat/provider-keys.test.tsx +++ b/apps/app/src/components/chat/provider-keys.test.tsx @@ -36,7 +36,7 @@ describe("memory-only provider keys", () => { const storage = vi.spyOn(Storage.prototype, "setItem"); const cookie = vi.spyOn(document, "cookie", "set"); setup(); - expect(screen.getByRole("dialog")).toHaveAccessibleName("API keys"); + expect(screen.getByRole("dialog")).toHaveAccessibleName("Add API keys to start"); expect(screen.getByLabelText("OpenAI API key")).toHaveAttribute("type", "password"); expect(screen.getByRole("button", { name: "Save keys" })).toBeDisabled(); enterKeys(); diff --git a/apps/app/src/components/chat/provider-keys.tsx b/apps/app/src/components/chat/provider-keys.tsx index 6bc0f0b..8840ee6 100644 --- a/apps/app/src/components/chat/provider-keys.tsx +++ b/apps/app/src/components/chat/provider-keys.tsx @@ -10,6 +10,10 @@ type ProviderKeysContextValue = { save: (keys: ProviderKeys) => void; clear: () => void; newChat: () => void; + draft: string; + setDraft: (value: string) => void; + keysOpen: boolean; + setKeysOpen: (value: boolean) => void; }; const ProviderKeysContext = createContext(null); @@ -17,15 +21,17 @@ export function ProviderKeysProvider({ children }: { children: ReactNode }) { // Deliberately memory-only: never put credentials in browser storage or agent state. const [keys, setKeys] = useState(null); const [session, setSession] = useState(0); - const newChat = () => setSession((value) => value + 1); + const [draft, setDraft] = useState(""); + const [keysOpen, setKeysOpen] = useState(false); + const newChat = () => { setDraft(""); setSession((value) => value + 1); }; return ( { setKeys(value); newChat(); }, + save: (value) => { setKeys(value); setKeysOpen(false); setSession((value) => value + 1); }, clear: () => { setKeys(null); newChat(); }, - newChat, + newChat, draft, setDraft, keysOpen, setKeysOpen, }}> {children} diff --git a/docs/getting-started.md b/docs/getting-started.md index 0633134..73fa6fc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,7 +16,7 @@ make setup You can start the agent without shared provider keys and use **API keys** in the chat header to test and save your own OpenAI and Jev keys for the current browser session. Refreshing clears the keys. -To provide shared credentials instead, edit `apps/agent/.env`: +The chat interface asks for visitor keys before sending a message and preserves the draft during setup. To also provide shared credentials for direct API clients, edit `apps/agent/.env`: ```dotenv OPENAI_API_KEY=your-provider-key