Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions apps/app/src/app/__tests__/shared-thread.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function EntryAndChat() {
}

beforeEach(() => {
HTMLDialogElement.prototype.showModal = function () { this.setAttribute("open", ""); };
vi.stubGlobal(
"ResizeObserver",
class {
Expand All @@ -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({
Expand Down Expand Up @@ -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();
});
7 changes: 4 additions & 3 deletions apps/app/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string | null>(null);
const [retrying, setRetrying] = useState(false);
Expand Down Expand Up @@ -129,6 +129,7 @@ export default function HomePage() {
)}
<div className="chat-content" ref={chatRoot}>
<CopilotChat
chatView={KeyGatedChatView}
autoScroll={false}
scrollView={ReaderScrollView}
messageView={{ assistantMessage: { markdownRenderer: AnswerMarkdown } }}
Expand All @@ -141,7 +142,7 @@ export default function HomePage() {
welcomeMessageText: "What would you like to explore?",
chatInputPlaceholder: "Ask anything…",
chatDisclaimerText:
"AI can make mistakes. Check important details.",
hasKeys ? "AI can make mistakes. Check important details." : "Add your OpenAI and Jev API keys to start. Keys clear when you refresh.",
}}
/>
</div>
Expand Down
59 changes: 59 additions & 0 deletions apps/app/src/components/chat/key-gated-chat-view.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof CopilotChatView>) => <>
<input aria-label="Message" value={props.inputValue} onChange={(event) => props.onInputChange?.(event.target.value)} />
<button onClick={() => props.onSubmitMessage?.(props.inputValue ?? "")}>Send</button>
<button onClick={() => props.onSelectSuggestion?.({ title: "Example", message: "Explain flight", isLoading: false }, 0)}>Example</button>
</>,
}));
const submit = vi.fn();
const select = vi.fn();
function Harness() {
const { session, keysOpen, setKeysOpen, save, newChat } = useProviderKeys();
return <>
<KeyGatedChatView key={session} onSubmitMessage={submit} onSelectSuggestion={select} />
{keysOpen && <div role="dialog">
<button onClick={() => setKeysOpen(false)}>Cancel</button>
<button onClick={() => save({ openai: "test", jev: "test" })}>Save tested keys</button>
</div>}
<button onClick={newChat}>New chat</button>
</>;
}
afterEach(() => { cleanup(); vi.clearAllMocks(); });

it("opens setup before sending and preserves the message across cancel and the key-save remount", () => {
render(<ProviderKeysProvider><Harness /></ProviderKeysProvider>);
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(<ProviderKeysProvider><Harness /></ProviderKeysProvider>);
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();
});
36 changes: 36 additions & 0 deletions apps/app/src/components/chat/key-gated-chat-view.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof CopilotChatView>) {
const { hasKeys, draft, setDraft, setKeysOpen } = useProviderKeys();
return <CopilotChatView
{...props}
inputValue={draft}
onInputChange={setDraft}
onSubmitMessage={(value) => {
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);
5 changes: 3 additions & 2 deletions apps/app/src/components/chat/provider-keys-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ export function ProviderKeysDialog({ onClose, disabled = false }: { onClose: ()

return (
<dialog ref={dialog} className="provider-keys-dialog" aria-labelledby="provider-keys-title" aria-describedby="provider-keys-privacy" onCancel={(event) => { event.preventDefault(); onClose(); }}>
<h2 id="provider-keys-title">API keys</h2>
<h2 id="provider-keys-title">{hasKeys ? "API keys" : "Add API keys to start"}</h2>
{!hasKeys && <p>Connect OpenAI for answers and Jev for choosing visualizations. Your message will stay ready to send.</p>}
<p id="provider-keys-privacy">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.</p>
<p>Saving or clearing keys starts a new chat.</p>
<form onSubmit={(event) => { event.preventDefault(); void testConnection(); }}>
Expand All @@ -68,7 +69,7 @@ export function ProviderKeysDialog({ onClose, disabled = false }: { onClose: ()
<label htmlFor="provider-jev-key">Jev API key</label>
<input id="provider-jev-key" type="password" maxLength={4096} autoComplete="off" spellCheck={false} autoCapitalize="none" value={jev} onChange={(event) => edit(setJev, event.target.value)} disabled={pending || disabled} />
{error && <p role="alert">{error}</p>}
<p role="status" aria-live="polite">{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."}</p>
<p role="status" aria-live="polite">{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."}</p>
<div className="provider-keys-actions">
{hasKeys && <button type="button" disabled={pending || disabled} onClick={() => { clear(); onClose(); }}>Clear keys</button>}
<button type="button" onClick={onClose}>Cancel</button>
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/components/chat/provider-keys.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 9 additions & 3 deletions apps/app/src/components/chat/provider-keys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,28 @@ 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<ProviderKeysContextValue | null>(null);

export function ProviderKeysProvider({ children }: { children: ReactNode }) {
// Deliberately memory-only: never put credentials in browser storage or agent state.
const [keys, setKeys] = useState<ProviderKeys | null>(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 (
<ProviderKeysContext.Provider value={{
headers: keys ? { "x-openai-api-key": keys.openai, "x-jev-api-key": keys.jev } : undefined,
hasKeys: keys !== null,
session,
save: (value) => { setKeys(value); newChat(); },
save: (value) => { setKeys(value); setKeysOpen(false); setSession((value) => value + 1); },
clear: () => { setKeys(null); newChat(); },
newChat,
newChat, draft, setDraft, keysOpen, setKeysOpen,
}}>
{children}
</ProviderKeysContext.Provider>
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading