From 0cb9b3bc930f9dc801541c6c836ea3e97d78cdf5 Mon Sep 17 00:00:00 2001 From: Amal Date: Fri, 17 Jul 2026 00:11:20 -0700 Subject: [PATCH] feat: keyless demo mode (canned first-run answers instead of a 401) Add a built-in keyless "demo" provider (lib/llm/providers/demo.ts, DEMO_MODEL = "mike-demo") that answers with a canned, context-aware placeholder plus a nudge to configure a key. The chat stream route falls back to it automatically when the chosen model's provider has no env or per-user key (resolveDemoFallback in routes/chat.ts). Frontend: "Demo (no key needed)" entry in the model dropdown, silent demo fallback on send in ChatInput (replaces the blocking ApiKeyMissingPopup), and a dismissible global ApiKeyBanner while no provider key is configured. Ported from the amal66/mike monorepo fork (origin/main, b3166dd); mechanical translation into the backend/ + frontend/ layout. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC --- backend/src/lib/llm/index.ts | 6 + backend/src/lib/llm/models.ts | 10 ++ backend/src/lib/llm/providers/demo.ts | 137 ++++++++++++++++++ backend/src/routes/chat.ts | 39 ++++- frontend/src/app/(pages)/layout.tsx | 2 + .../app/components/assistant/ChatInput.tsx | 27 +--- .../app/components/assistant/ModelToggle.tsx | 9 +- .../app/components/shared/ApiKeyBanner.tsx | 71 +++++++++ frontend/src/app/lib/modelAvailability.ts | 11 +- 9 files changed, 287 insertions(+), 25 deletions(-) create mode 100644 backend/src/lib/llm/providers/demo.ts create mode 100644 frontend/src/app/components/shared/ApiKeyBanner.tsx diff --git a/backend/src/lib/llm/index.ts b/backend/src/lib/llm/index.ts index 3adc851f5..8bb07c36d 100644 --- a/backend/src/lib/llm/index.ts +++ b/backend/src/lib/llm/index.ts @@ -27,6 +27,7 @@ export * from "./models"; * registerProvider()/registerApiKeyProvider(), no core edits. */ export { registerProvider } from "./registry"; +import { setupDemo } from "./providers/demo"; // --------------------------------------------------------------------------- // Register built-in providers @@ -37,6 +38,11 @@ export { registerProvider } from "./registry"; /** Register the built-in LLM providers (claude/gemini/openai). */ export function registerBuiltinProviders(): void { + // Keyless demo model — always available. Lets a brand-new user get a + // response before any API key is configured, and backs the auto-fallback + // in routes/chat.ts. + setupDemo(); + registerProvider({ id: "claude", matchesModel: (m) => m.startsWith("claude"), diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index fe4e6f69a..29f592bc4 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -32,6 +32,16 @@ export const DEFAULT_MAIN_MODEL = "gemini-3-flash-preview"; export const DEFAULT_TITLE_MODEL = "gemini-3.1-flash-lite-preview"; export const DEFAULT_TABULAR_MODEL = "gemini-3-flash-preview"; +/** + * Built-in keyless "demo" model. Requires no API key and returns a canned, + * context-aware placeholder answer. Used as the automatic fallback when a + * request's chosen provider has no configured key, so a brand-new user still + * gets a response (and a nudge to add a real key) instead of a hard error. + * Also selectable directly in the model picker. Registered by + * providers/demo.ts. + */ +export const DEMO_MODEL = "mike-demo"; + // Derived (not hand-maintained) fallback set for resolveModel(). // Built by spreading the *_MODELS arrays above, so adding a model to any // of those arrays automatically includes it here — no second edit site. diff --git a/backend/src/lib/llm/providers/demo.ts b/backend/src/lib/llm/providers/demo.ts new file mode 100644 index 000000000..b52197a42 --- /dev/null +++ b/backend/src/lib/llm/providers/demo.ts @@ -0,0 +1,137 @@ +/** + * Demo provider — a built-in, keyless model that returns a canned but + * context-aware placeholder answer. + * + * Why this exists: a brand-new instance (or a self-hoster who hasn't added a + * key yet) would otherwise hit a raw provider auth error on their very first + * question. There is no reliable free hosted LLM we can call without a key, so + * instead of failing we answer locally in "demo mode": we acknowledge the + * question and any shared documents, describe what a real model would do, and + * point the user at Settings → API Keys. No network call, no key required, so + * it works offline and in air-gapped mode too. + * + * Registered unconditionally by registerBuiltinProviders(). The chat route also + * routes to DEMO_MODEL automatically when the chosen provider has no configured + * key (see routes/chat.ts). + */ + +import { registerProvider } from "../registry"; +import { registerApiKeyProvider } from "../../../core/apiKeyProviders"; +import { DEMO_MODEL } from "../models"; +import type { + StreamChatParams, + StreamChatResult, + CompleteTextParams, +} from "../types"; + +// Match filename-looking tokens (no spaces, so we don't swallow surrounding +// prose like "…terms in nda.pdf"). +const FILENAME_RE = /\b[\w()\-]+\.(?:pdf|docx?|txt|md|csv)\b/gi; + +/** Pull any document filenames mentioned in the prompt so the demo reply can + * name what the user shared. Best-effort — returns [] when nothing matches. */ +function extractSharedDocuments(params: StreamChatParams): string[] { + const haystack = [ + params.systemPrompt ?? "", + ...params.messages.map((m) => m.content ?? ""), + ].join("\n"); + const found = new Set(); + for (const match of haystack.matchAll(FILENAME_RE)) { + const name = match[0].trim(); + // Skip absurdly long "filenames" that are really prose containing a dot. + if (name.length <= 80) found.add(name); + } + return [...found]; +} + +function lastUserQuestion(params: StreamChatParams): string { + for (let i = params.messages.length - 1; i >= 0; i--) { + if (params.messages[i].role === "user") { + return (params.messages[i].content ?? "").trim(); + } + } + return ""; +} + +/** Build the demo answer. Kept deterministic and clearly labelled so no one + * mistakes it for real legal analysis. */ +export function buildDemoAnswer(params: StreamChatParams): string { + const question = lastUserQuestion(params); + const docs = extractSharedDocuments(params); + + const lines: string[] = []; + lines.push( + "**Demo mode** — no AI provider key is configured, so Mike is replying with a placeholder instead of real analysis.", + ); + lines.push(""); + if (question) { + const trimmed = + question.length > 300 ? `${question.slice(0, 300)}…` : question; + lines.push(`You asked: *"${trimmed}"*`); + lines.push(""); + } + if (docs.length > 0) { + const list = docs.slice(0, 8).join(", "); + lines.push( + `You've shared **${docs.length} document${docs.length === 1 ? "" : "s"}** (${list}). ` + + "With a configured model, Mike would read them in full and extract the parties, governing law, " + + "key dates, obligations, payment and liability terms, and flag risks — each answer cited back to the " + + "exact source text.", + ); + } else { + lines.push( + "With a configured model, Mike answers questions about your uploaded documents — extracting parties, " + + "governing law, key dates, obligations and risks, with every answer cited back to the source text.", + ); + } + lines.push(""); + lines.push("**To get real answers:**"); + lines.push("1. Open **Settings → API Keys**"); + lines.push( + "2. Add an Anthropic, Google, or OpenAI key — or point Mike at a local model", + ); + lines.push("3. Re-send your question"); + lines.push(""); + lines.push( + "_Your documents stay in your workspace — nothing is sent to an AI provider until you add a key._", + ); + return lines.join("\n"); +} + +/** Emit `text` through onContentDelta in small chunks so the UI renders it as a + * normal streamed answer. Honours the abort signal. */ +async function streamDemoText( + text: string, + params: StreamChatParams, +): Promise { + const signal = params.abortSignal; + const onDelta = params.callbacks?.onContentDelta; + if (!onDelta) return { fullText: text }; + + // Chunk on word boundaries; keep the whitespace attached to each token. + const tokens = text.match(/\S+\s*/g) ?? [text]; + for (const token of tokens) { + if (signal?.aborted) break; + onDelta(token); + } + return { fullText: text }; +} + +export function setupDemo(): void { + // No credentials required. Registering with an empty env-var list keeps the + // provider out of "server key configured" accounting without needing a key. + registerApiKeyProvider("demo", []); + + registerProvider({ + id: "demo", + matchesModel: (m) => m === DEMO_MODEL, + stream: (params: StreamChatParams) => + streamDemoText(buildDemoAnswer(params), params), + complete: async (params: CompleteTextParams) => { + // Used for lightweight jobs (e.g. title generation). Return a short, + // safe string rather than a paragraph. + return params.user.trim().slice(0, 60) || "Demo chat"; + }, + models: { main: [DEMO_MODEL], mid: [], low: [] }, + }); +} diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 2bb3dfda6..ce6d4fea3 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -17,7 +17,13 @@ import { parseAskInputsResponsePayload, type ChatMessage, } from "../lib/chat"; -import { completeText } from "../lib/llm"; +import { + completeText, + DEFAULT_MAIN_MODEL, + DEMO_MODEL, + providerForModel, + resolveModel, +} from "../lib/llm"; import { getUserModelSettings, } from "../lib/userSettings"; @@ -26,6 +32,29 @@ import { safeErrorLog, safeErrorMessage } from "../lib/safeError"; export const chatRouter = Router(); +/** + * Pick the model to actually run. If the requested model's provider has no + * usable key (env or per-user), fall back to the keyless demo model so the user + * gets a helpful placeholder instead of a raw provider auth error. An explicit + * demo request, or a provider with a key present, is returned unchanged. + * Exported for unit testing. + */ +export function resolveDemoFallback( + requestedModel: string | null | undefined, + apiKeys: Record, +): string { + const model = resolveModel(requestedModel, DEFAULT_MAIN_MODEL); + if (model === DEMO_MODEL) return model; + let provider: string; + try { + provider = providerForModel(model); + } catch { + return model; // unknown model — let the normal path surface the error + } + if (provider === "demo") return model; + return apiKeys[provider]?.trim() ? model : DEMO_MODEL; +} + type Db = ReturnType; const isDev = process.env.NODE_ENV !== "production"; const devLog = (...args: Parameters) => { @@ -557,6 +586,12 @@ chatRouter.post("/", requireAuth, async (req, res) => { const workflowStore = await buildWorkflowStore(userId, userEmail, db); + // Keyless-demo fallback: if the chosen model's provider has no configured + // key, answer in demo mode instead of failing with a raw provider auth + // error. An explicitly selected demo model is left as-is. This is what makes + // a brand-new instance usable before any key is set up. + const effectiveModel = resolveDemoFallback(model, apiKeys); + devLog("[chat/stream] starting LLM stream", { apiMessageCount: apiMessages.length, docCount: Object.keys(docIndex).length, @@ -588,7 +623,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { write, workflowStore, includeResearchTools: legalResearchUs, - model, + model: effectiveModel, apiKeys, signal: streamAbort.signal, projectId: resolvedProjectId, diff --git a/frontend/src/app/(pages)/layout.tsx b/frontend/src/app/(pages)/layout.tsx index 107c51440..bb312b675 100644 --- a/frontend/src/app/(pages)/layout.tsx +++ b/frontend/src/app/(pages)/layout.tsx @@ -8,6 +8,7 @@ import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext"; import { SidebarContext } from "@/app/contexts/SidebarContext"; import { PageChromeContext } from "@/app/contexts/PageChromeContext"; import { AppSidebar } from "@/app/components/shared/AppSidebar"; +import { ApiKeyBanner } from "@/app/components/shared/ApiKeyBanner"; export default function MikeLayout({ children, @@ -103,6 +104,7 @@ export default function MikeLayout({ }} >
+
(function ChatInput( const [docSelectorInitialTab, setDocSelectorInitialTab] = useState("files"); const [workflowModalOpen, setWorkflowModalOpen] = useState(false); - const [apiKeyModalProvider, setApiKeyModalProvider] = - useState(null); const [isDraggingFiles, setIsDraggingFiles] = useState(false); const [uploadingFilenames, setUploadingFilenames] = useState([]); const [uploadWarning, setUploadWarning] = useState(null); @@ -253,10 +246,11 @@ export const ChatInput = forwardRef(function ChatInput( const handleSubmit = () => { const query = value.trim(); if (!query || isLoading) return; - if (apiKeys && !isModelAvailable(model, apiKeys)) { - setApiKeyModalProvider(getModelProvider(model)); - return; - } + // If the chosen model has no configured key, fall back to the keyless + // demo model so the user still gets an answer. The demo reply and the + // global "set up API keys" banner both nudge them to add a real key. + const effectiveModel = + apiKeys && !isModelAvailable(model, apiKeys) ? DEMO_MODEL_ID : model; setValue(""); if (textareaRef.current) { textareaRef.current.style.height = "auto"; @@ -275,7 +269,7 @@ export const ChatInput = forwardRef(function ChatInput( content: query, files: files.length > 0 ? files : undefined, workflow: wf ?? undefined, - model, + model: effectiveModel, }); }; @@ -485,11 +479,6 @@ export const ChatInput = forwardRef(function ChatInput( projectName={projectName} projectCmNumber={projectCmNumber} /> - setApiKeyModalProvider(null)} - /> m.id)); -const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google", "OpenAI"]; +const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google", "OpenAI", "Demo"]; const itemClassName = "rounded-xl px-2.5 py-1.5 text-gray-700 focus:bg-app-surface-hover focus:text-gray-900 data-[highlighted]:bg-app-surface-hover data-[highlighted]:text-gray-900"; diff --git a/frontend/src/app/components/shared/ApiKeyBanner.tsx b/frontend/src/app/components/shared/ApiKeyBanner.tsx new file mode 100644 index 000000000..d41ba5c9a --- /dev/null +++ b/frontend/src/app/components/shared/ApiKeyBanner.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { KeyRound, X } from "lucide-react"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; + +const DISMISS_KEY = "apiKeyBannerDismissed"; + +// Providers that back a chat model. If none are configured the assistant can +// only answer in demo mode, so we surface a persistent setup nudge. +const MODEL_PROVIDERS = ["claude", "gemini", "openai"] as const; + +/** + * Global banner shown on every authenticated page when no AI provider key is + * configured. Dismissible for the current tab session (sessionStorage) so it + * returns on the next visit until a key is added. Hidden on the account/setup + * pages where it would be redundant. + */ +export function ApiKeyBanner() { + const { profile } = useUserProfile(); + const pathname = usePathname(); + const [dismissed, setDismissed] = useState(true); + + useEffect(() => { + setDismissed(sessionStorage.getItem(DISMISS_KEY) === "true"); + }, []); + + // Wait for the profile before deciding, to avoid a flash on load. + if (!profile) return null; + if (dismissed) return null; + if (pathname?.startsWith("/account")) return null; + + const anyConfigured = MODEL_PROVIDERS.some( + (p) => profile.apiKeys[p]?.configured, + ); + if (anyConfigured) return null; + + const handleDismiss = () => { + sessionStorage.setItem(DISMISS_KEY, "true"); + setDismissed(true); + }; + + return ( +
+ +

+ No AI provider key is set up.{" "} + + Mike is answering in demo mode — add a key to get real + document analysis. + +

+ + Set up API keys + + +
+ ); +} diff --git a/frontend/src/app/lib/modelAvailability.ts b/frontend/src/app/lib/modelAvailability.ts index fb0f09abf..ab4fef7cf 100644 --- a/frontend/src/app/lib/modelAvailability.ts +++ b/frontend/src/app/lib/modelAvailability.ts @@ -1,11 +1,15 @@ -import { SETTINGS_MODELS, type ModelOption } from "../components/assistant/ModelToggle"; +import { + SETTINGS_MODELS, + DEMO_MODEL_ID, + type ModelOption, +} from "../components/assistant/ModelToggle"; import type { ApiKeyState } from "@/app/lib/mikeApi"; export type ModelProvider = "claude" | "gemini" | "openai"; export function getModelProvider(modelId: string): ModelProvider | null { const model = SETTINGS_MODELS.find((m) => m.id === modelId); - if (!model) return null; + if (!model || model.group === "Demo") return null; return modelGroupToProvider(model.group); } @@ -13,6 +17,9 @@ export function isModelAvailable( modelId: string, apiKeys: ApiKeyState, ): boolean { + // The demo model is keyless — always available so a user with no keys can + // still send a message. + if (modelId === DEMO_MODEL_ID) return true; const provider = getModelProvider(modelId); if (!provider) return false; return isProviderAvailable(provider, apiKeys);