diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 227c87816..d860bc966 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -183,6 +183,11 @@ const Connections = lazyWithRetry(() => default: m.Connections, })), ); +const Onboarding = lazyWithRetry(() => + import("./components/features/onboarding").then((m) => ({ + default: m.Onboarding, + })), +); // Loading component for lazy-loaded routes function RouteLoader() { @@ -653,6 +658,18 @@ function AppRoutes() { } /> + {/* Onboarding renders its own slim header inside the sidebar shell, + so it deliberately does NOT wrap with AppLayout. */} + + }> + + + + } + /> ); diff --git a/dashboard/src/components/auth/RegisterForm.tsx b/dashboard/src/components/auth/RegisterForm.tsx index 6bec1842f..035b450f0 100644 --- a/dashboard/src/components/auth/RegisterForm.tsx +++ b/dashboard/src/components/auth/RegisterForm.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { Link, useSearchParams, useNavigate } from "react-router-dom"; import { Eye, EyeOff } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Label } from "../ui/label"; @@ -13,6 +14,8 @@ import { CardTitle, } from "../ui/card"; import { useAuth } from "../../contexts/auth"; +import { queryKeys } from "../../api/control-layer/keys"; +import type { UserResponse } from "../../api/control-layer/types"; import { toast } from "sonner"; export function RegisterForm() { @@ -24,6 +27,7 @@ export function RegisterForm() { const { register } = useAuth(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -43,9 +47,26 @@ export function RegisterForm() { password, displayName: undefined, // Let user set this later }); + // Post-signup landing logic, ordered by priority: + // + // 1. Explicit ?redirect= (e.g. org invite acceptance) — always wins. + // 2. Server-driven onboarding_redirect_url — AuthProvider triggers a + // hard navigation via window.location.href as part of register()'s + // checkAuthStatus() call. We must NOT call navigate() in that case + // or we race the pending hard navigation, briefly mounting the + // wrong route. We detect this by reading the just-populated user + // cache entry that AuthProvider writes before deciding to redirect. + // 3. Otherwise, default to /onboarding for the zero-friction flow. const redirect = searchParams.get("redirect"); if (redirect) { navigate(redirect); + } else { + const currentUser = queryClient.getQueryData( + queryKeys.users.byId("current", "organizations"), + ); + if (!currentUser?.onboarding_redirect_url) { + navigate("/onboarding"); + } } toast.success("Registration successful!"); } catch (error) { diff --git a/dashboard/src/components/features/index.ts b/dashboard/src/components/features/index.ts index ac50e2f93..e597403e8 100644 --- a/dashboard/src/components/features/index.ts +++ b/dashboard/src/components/features/index.ts @@ -2,6 +2,7 @@ export * from "./api-keys"; export * from "./cost-management"; export * from "./endpoints"; export * from "./models"; +export * from "./onboarding"; export * from "./playground"; export * from "./profile"; export * from "./requests"; diff --git a/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx b/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx new file mode 100644 index 000000000..2cf74f8e5 --- /dev/null +++ b/dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx @@ -0,0 +1,864 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + ArrowRight, + Check, + CheckCircle2, + Code2, + Copy, + FileJson, + KeyRound, + Loader2, + Play, + Sparkles, + Users, +} from "lucide-react"; +import { toast } from "sonner"; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/contexts/auth"; +import { + useCreateApiKey, + useCreateBatch, + useModels, + useUploadFileWithProgress, + useUser, +} from "@/api/control-layer/hooks"; +import { copyToClipboard as copyToClipboardUtil } from "@/utils/clipboard"; +import { AppSidebar } from "../../../layout/Sidebar/AppSidebar"; + +// Webhook used by the "invite a teammate" form. Configured per-environment +// via VITE_INVITE_WEBHOOK_URL (typically a Zapier Catch Hook). Posting here +// is best-effort under `no-cors`, so the host does not need CORS headers +// configured and we can't read the response. If the env var is unset the +// invite form is hidden — we'd rather not silently drop submissions. +const INVITE_WEBHOOK_URL: string | undefined = + import.meta.env.VITE_INVITE_WEBHOOK_URL; + +// Default catalog model used in the visible code samples. We swap this with the +// first available chat model alias from the user's catalog when one is found, +// but keep medgemma-4b as a fallback so the snippet always renders something +// concrete even before /models loads. +const FALLBACK_MODEL_ALIAS = "medgemma-4b"; + +const SUCCESS_REDIRECT_DELAY_MS = 2000; +const RUN_NOW_SIMULATED_DELAY_MS = 2500; +const TOAST_DURATION_MS = 6000; + +type WorkloadType = "async" | "batch"; +type ExecutionMode = "browser" | "cli"; +type Language = "python" | "curl"; +type RunState = "idle" | "running" | "success"; + +// Builds the inner JSON body shared by both the single-row async payload +// and each row of the JSONL batch payload. Model aliases are user-controlled +// (catalog metadata) and may legitimately contain ", \, or control chars, +// so we never interpolate them into JSON via template literals. +function buildChatBody(modelAlias: string) { + return { + model: modelAlias, + messages: [ + { role: "system", content: "Output only valid JSON." }, + { + role: "user", + content: + "Generate a synthetic patient profile (Age, Gender, Symptoms, Diagnosis).", + }, + ], + }; +} + +function buildAsyncPayloadObject(modelAlias: string) { + return { ...buildChatBody(modelAlias), tier: "async" }; +} + +function buildAsyncPayload(modelAlias: string): string { + return JSON.stringify(buildAsyncPayloadObject(modelAlias), null, 2); +} + +function buildJsonlPayload(modelAlias: string): string { + return ["row-1", "row-2", "row-3"] + .map((id) => + JSON.stringify({ + custom_id: id, + method: "POST", + url: "/v1/chat/completions", + body: buildChatBody(modelAlias), + }), + ) + .join("\n"); +} + +// Returns the alias formatted as an inner-string literal (no surrounding +// quotes), correctly escaping any chars that would break Python/cURL string +// literals or the embedded JSON in the cURL example. JS string literal +// escapes are a strict subset of Python's, so reusing the JSON encoding is +// safe for all three target languages. +function escapeForLiteral(value: string): string { + const json = JSON.stringify(value); + return json.slice(1, json.length - 1); +} + +function buildSnippets(apiKey: string, modelAlias: string) { + const safeKey = escapeForLiteral(apiKey); + const safeAlias = escapeForLiteral(modelAlias); + return { + batch: { + python: `from openai import OpenAI + +client = OpenAI( + api_key="${safeKey}", + base_url="https://api.doubleword.ai/v1" +) + +# 1. Upload your batch input file +batch_input_file = client.files.create( + file=open("patients.jsonl", "rb"), + purpose="batch" +) + +# 2. Start the batch job (~50% savings, 24h window) +batch = client.batches.create( + input_file_id=batch_input_file.id, + endpoint="/v1/chat/completions", + completion_window="24h" +) + +print(f"Batch started: {batch.id}")`, + curl: `curl -X POST https://api.doubleword.ai/v1/batches \\ + -H "Authorization: Bearer ${safeKey}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "${safeAlias}", + "priority": "standard", + "requests": [ + { + "custom_id": "row-1", + "method": "POST", + "url": "/v1/chat/completions", + "body": { + "model": "${safeAlias}", + "messages": [{"role": "user", "content": "Generate a synthetic patient profile."}] + } + } + ] + }'`, + }, + async: { + python: `from openai import OpenAI + +client = OpenAI( + api_key="${safeKey}", + base_url="https://api.doubleword.ai/v1" +) + +# Start an async job (~25% savings, minutes completion) +response = client.chat.completions.create( + model="${safeAlias}", + messages=[ + {"role": "user", "content": "Generate a synthetic patient profile."} + ], + extra_headers={"x-doubleword-tier": "async"} +) + +print(f"Async job queued!")`, + curl: `curl -X POST https://api.doubleword.ai/v1/chat/completions \\ + -H "Authorization: Bearer ${safeKey}" \\ + -H "Content-Type: application/json" \\ + -H "x-doubleword-tier: async" \\ + -d '{ + "model": "${safeAlias}", + "messages": [{"role": "user", "content": "Generate a synthetic patient profile."}] + }'`, + }, + } as const; +} + +export function Onboarding() { + const navigate = useNavigate(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const { data: currentUser } = useUser("current"); + + const [apiKey, setApiKey] = useState(null); + const [apiKeyError, setApiKeyError] = useState(null); + const [copiedKey, setCopiedKey] = useState(false); + const [copiedCode, setCopiedCode] = useState(false); + + const [workloadType, setWorkloadType] = useState("async"); + const [executionMode, setExecutionMode] = useState("browser"); + const [language, setLanguage] = useState("python"); + const [runState, setRunState] = useState("idle"); + const [listenerState, setListenerState] = useState<"waiting" | "success">( + "waiting", + ); + + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteSubmitting, setInviteSubmitting] = useState(false); + const [inviteSent, setInviteSent] = useState(false); + + const apiKeyRequestedRef = useRef(false); + const sampleBatchRequestedRef = useRef(false); + const redirectScheduledRef = useRef(false); + + const createApiKey = useCreateApiKey(); + const createBatch = useCreateBatch(); + const uploadFile = useUploadFileWithProgress(); + + // Pull the first chat model alias from the catalog so the rendered code + // samples reference something that will actually work for the user. Falls + // back to a hard-coded alias when the catalog query is still loading or + // empty. + const { data: modelsData } = useModels({ accessible: true, limit: 50 }); + const modelAlias = useMemo(() => { + const chat = modelsData?.data?.find( + (m) => (m.model_type ?? "CHAT") === "CHAT", + ); + return chat?.alias ?? FALLBACK_MODEL_ALIAS; + }, [modelsData]); + + // Mint a live API key on mount so step 1 has something concrete to show. + // We only do this once per visit and only when the user is authenticated. + useEffect(() => { + if (apiKeyRequestedRef.current) return; + if (authLoading || !isAuthenticated || !currentUser) return; + apiKeyRequestedRef.current = true; + + createApiKey + .mutateAsync({ + data: { + name: `Onboarding key (${new Date().toLocaleString()})`, + description: "Auto-generated during onboarding", + purpose: "realtime", + }, + userId: currentUser.id, + }) + .then((response) => { + setApiKey(response.key); + }) + .catch((err) => { + // Surface the failure but don't block the rest of the flow — the user + // can still see the snippets and copy a placeholder, and they can + // generate keys from /api-keys later. + console.error("Failed to create onboarding API key:", err); + setApiKeyError( + err instanceof Error ? err.message : "Failed to create API key", + ); + }); + }, [authLoading, isAuthenticated, currentUser, createApiKey]); + + // Fire the "Hello World" sample batch in the background on mount. This is + // best-effort: if the catalog has no chat model or the upload fails, we + // swallow the error and just hide the toast. The toast is shown + // optimistically so the user sees activity even if the model catalog is + // slow to load. + useEffect(() => { + if (sampleBatchRequestedRef.current) return; + if (authLoading || !isAuthenticated) return; + sampleBatchRequestedRef.current = true; + + toast("Sample Batch Started", { + description: + "We just fired off a 'Hello World' batch in the background so you have some data to look at when you visit the dashboard.", + duration: TOAST_DURATION_MS, + icon: , + }); + + void (async () => { + try { + // Wait one tick for the models query to resolve. If it hasn't, the + // fallback alias is fine — the batch creation will just fail silently + // server-side which is acceptable for this background "demo" job. + const helloPayload = `{"custom_id": "hello-1", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "${modelAlias}", "messages": [{"role": "user", "content": "Say hello."}]}}\n`; + const blob = new Blob([helloPayload], { type: "application/jsonl" }); + const file = new File([blob], `onboarding-hello-${Date.now()}.jsonl`, { + type: "application/jsonl", + }); + + const uploaded = await uploadFile.mutateAsync({ + data: { file, purpose: "batch" }, + }); + + await createBatch.mutateAsync({ + input_file_id: uploaded.id, + endpoint: "/v1/chat/completions", + completion_window: "24h", + }); + } catch (err) { + // Background task — keep the surface area quiet; only log. + console.warn("Background Hello World batch failed:", err); + } + })(); + // We intentionally only run this once after auth is resolved; modelAlias + // is read inside the IIFE so we don't need it as a dep. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authLoading, isAuthenticated]); + + const goToDashboard = useCallback(() => { + navigate("/models"); + }, [navigate]); + + // Auto-redirect after success in both browser and CLI modes. + useEffect(() => { + const succeeded = + runState === "success" || listenerState === "success"; + if (!succeeded || redirectScheduledRef.current) return; + redirectScheduledRef.current = true; + const timer = setTimeout(goToDashboard, SUCCESS_REDIRECT_DELAY_MS); + return () => clearTimeout(timer); + }, [runState, listenerState, goToDashboard]); + + const snippets = useMemo( + () => buildSnippets(apiKey ?? "", modelAlias), + [apiKey, modelAlias], + ); + + const browserPayload = + workloadType === "batch" + ? buildJsonlPayload(modelAlias) + : buildAsyncPayload(modelAlias); + const cliSnippet = snippets[workloadType][language]; + + const handleCopyKey = async () => { + if (!apiKey) return; + const ok = await copyToClipboardUtil(apiKey); + if (ok) { + setCopiedKey(true); + setTimeout(() => setCopiedKey(false), 2000); + } + }; + + const handleCopyCode = async () => { + const text = executionMode === "browser" ? browserPayload : cliSnippet; + const ok = await copyToClipboardUtil(text); + if (ok) { + setCopiedCode(true); + setTimeout(() => setCopiedCode(false), 2000); + } + }; + + const handleRunNow = async () => { + if (runState !== "idle") return; + setRunState("running"); + + // Fire the real batch creation in the background. We don't surface its + // success/failure to the run state machine since the spec asks for a + // simulated 2.5s "running" → "success" cycle that gives the user a + // predictable redirect experience, regardless of how fast the API + // responds. + void (async () => { + try { + // Always build the JSONL via the object helpers so we never round- + // trip through JSON.parse — model aliases can contain quotes/ + // backslashes that would have made the previous template-string + // payload invalid JSON and thrown here, while the simulated timer + // still flipped the UI to "success". + const payload = + workloadType === "batch" + ? buildJsonlPayload(modelAlias) + : `${JSON.stringify({ + custom_id: "row-1", + method: "POST", + url: "/v1/chat/completions", + body: buildAsyncPayloadObject(modelAlias), + })}\n`; + const blob = new Blob([payload], { type: "application/jsonl" }); + const file = new File( + [blob], + `onboarding-${workloadType}-${Date.now()}.jsonl`, + { type: "application/jsonl" }, + ); + const uploaded = await uploadFile.mutateAsync({ + data: { file, purpose: "batch" }, + }); + await createBatch.mutateAsync({ + input_file_id: uploaded.id, + endpoint: "/v1/chat/completions", + completion_window: workloadType === "batch" ? "24h" : "1h", + }); + } catch (err) { + console.warn("Onboarding run-now batch failed:", err); + } + })(); + + setTimeout(() => setRunState("success"), RUN_NOW_SIMULATED_DELAY_MS); + }; + + const handleSendInvite = async (e: React.FormEvent) => { + e.preventDefault(); + if (!inviteEmail.trim() || inviteSubmitting) return; + if (!INVITE_WEBHOOK_URL) { + // Defensive — the form is hidden when the env var is unset, but + // a stale build could still hit this path. + toast.error("Invites are not configured for this environment."); + return; + } + setInviteSubmitting(true); + try { + // The Zapier hook host doesn't return CORS headers so we have to + // POST in `no-cors` mode. That mode forbids non-safelisted + // Content-Types: setting `application/json` would be silently + // downgraded to `text/plain;charset=UTF-8`, which Zapier Catch Hooks + // do NOT auto-parse into fields — the Zap would still fire but + // `email`/`inviter_email`/etc. would all be empty. + // + // Using URLSearchParams produces an `application/x-www-form-urlencoded` + // body, which IS CORS-safelisted and IS parsed into structured fields + // by Zapier. We can't read the response either way (opaque), so the + // success toast is best-effort by design. + const params = new URLSearchParams(); + params.set("email", inviteEmail.trim()); + if (currentUser?.email) params.set("inviter_email", currentUser.email); + if (currentUser?.id) params.set("inviter_id", currentUser.id); + params.set("source", "onboarding"); + + await fetch(INVITE_WEBHOOK_URL, { + method: "POST", + mode: "no-cors", + body: params, + }); + setInviteSent(true); + setInviteEmail(""); + toast.success("Invite sent"); + setTimeout(() => setInviteSent(false), 3000); + } catch (err) { + console.error("Failed to send invite:", err); + toast.error("Could not send invite — please try again."); + } finally { + setInviteSubmitting(false); + } + }; + + const apiKeyDisplay = apiKey ?? (apiKeyError ? "—" : "Generating live key…"); + + return ( + +
+ + + {/* Slim header — onboarding intentionally only shows the skip + affordance, not the full app chrome. */} +
+
+ +
+ +
+ +
+
+
+

+ You're ready to run at scale. +

+

+ Your workspace is provisioned. Send your first API request + below to authenticate your setup. +

+
+ + {/* Step 1 — API Key */} +
+
+
+ +
+
+

+ 1. Your Live API Key +

+

+ This key will only be shown once. Please store it + securely. +

+
+
+ +
+
+ {apiKeyDisplay} +
+ +
+ {apiKeyError && ( +
+ {apiKeyError}. You can create a key from the API Keys page. +
+ )} +
+ + {/* Step 2 — Workload runner */} +
+
+
+
+
+ {executionMode === "browser" ? ( + + ) : ( + + )} +
+
+

+ 2. Run your first workload +

+

+ {executionMode === "browser" + ? "We've prepped an example payload. Run it directly from your browser." + : "Run this snippet from your terminal. Your key is already injected."} +

+
+
+ +
+ + +
+
+ +
+ + +
+
+ + {executionMode === "browser" ? ( + <> +
+
+                        
+                          {browserPayload}
+                        
+                      
+ +
+ +
+
+ Estimated cost for 10,000 records:{" "} + + {workloadType === "batch" ? "$1.25" : "$1.87"} + + + ( + {workloadType === "batch" + ? "50% less than real-time inference" + : "25% less than real-time inference"} + ) + +
+ +
+ +
+
+ + + Workload successfully received! Redirecting to + dashboard… + +
+
+ + ) : ( + <> +
+ + snippet.{language === "python" ? "py" : "sh"} + +
+ + +
+
+
+
+                        
+                          {cliSnippet}
+                        
+                      
+ +
+ + + + )} +
+ + {/* Step 3 — Team invite. Rendered only when the webhook is + configured; otherwise we'd be collecting invite emails + with nowhere to send them. */} + {INVITE_WEBHOOK_URL && ( +
+
+ )} +
+
+
+
+
+ ); +} + +export default Onboarding; diff --git a/dashboard/src/components/features/onboarding/Onboarding/index.ts b/dashboard/src/components/features/onboarding/Onboarding/index.ts new file mode 100644 index 000000000..a2a9d4090 --- /dev/null +++ b/dashboard/src/components/features/onboarding/Onboarding/index.ts @@ -0,0 +1 @@ +export { Onboarding, default } from "./Onboarding"; diff --git a/dashboard/src/components/features/onboarding/index.ts b/dashboard/src/components/features/onboarding/index.ts new file mode 100644 index 000000000..0b4fb367d --- /dev/null +++ b/dashboard/src/components/features/onboarding/index.ts @@ -0,0 +1 @@ +export * from "./Onboarding"; diff --git a/dashboard/src/vite-env.d.ts b/dashboard/src/vite-env.d.ts index 3cdaa28e9..14e85ab60 100644 --- a/dashboard/src/vite-env.d.ts +++ b/dashboard/src/vite-env.d.ts @@ -2,6 +2,11 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL?: string; + readonly VITE_BUILD_SHA?: string; + // URL of the Zapier (or other) webhook used by the onboarding "invite a + // teammate" form. Fire-and-forget POST under `no-cors`, so the host does + // not need CORS headers configured. Leave unset to disable the form. + readonly VITE_INVITE_WEBHOOK_URL?: string; } interface ImportMeta {