Skip to content
Open
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
6 changes: 6 additions & 0 deletions backend/src/lib/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
Expand Down
10 changes: 10 additions & 0 deletions backend/src/lib/llm/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
137 changes: 137 additions & 0 deletions backend/src/lib/llm/providers/demo.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<StreamChatResult> {
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: [] },
});
}
39 changes: 37 additions & 2 deletions backend/src/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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, string | null | undefined>,
): 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<typeof createServerSupabase>;
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -588,7 +623,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
write,
workflowStore,
includeResearchTools: legalResearchUs,
model,
model: effectiveModel,
apiKeys,
signal: streamAbort.signal,
projectId: resolvedProjectId,
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/(pages)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -103,6 +104,7 @@ export default function MikeLayout({
}}
>
<div className="h-dvh flex flex-col bg-app-background">
<ApiKeyBanner />
<div className="flex-1 flex min-w-0 overflow-visible">
<AppSidebar
isOpen={isSidebarOpen}
Expand Down
27 changes: 8 additions & 19 deletions frontend/src/app/components/assistant/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,10 @@ import { UploadOverlay } from "./UploadOverlay";
import { FileTypeIcon } from "../shared/FileTypeIcon";
import { AddDocumentsModal } from "../modals/AddDocumentsModal";
import { AssistantWorkflowModal } from "./AssistantWorkflowModal";
import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup";
import { ModelToggle } from "./ModelToggle";
import { ModelToggle, DEMO_MODEL_ID } from "./ModelToggle";
import { useSelectedModel } from "@/app/hooks/useSelectedModel";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import {
getModelProvider,
isModelAvailable,
type ModelProvider,
} from "@/app/lib/modelAvailability";
import { isModelAvailable } from "@/app/lib/modelAvailability";
import type { Document, Message } from "../shared/types";
import type { DirectoryTab } from "../shared/useDirectoryData";
import { cn } from "@/app/lib/utils";
Expand Down Expand Up @@ -94,8 +89,6 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
const [docSelectorInitialTab, setDocSelectorInitialTab] =
useState<DirectoryTab>("files");
const [workflowModalOpen, setWorkflowModalOpen] = useState(false);
const [apiKeyModalProvider, setApiKeyModalProvider] =
useState<ModelProvider | null>(null);
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
const [uploadingFilenames, setUploadingFilenames] = useState<string[]>([]);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
Expand Down Expand Up @@ -253,10 +246,11 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(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";
Expand All @@ -275,7 +269,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
content: query,
files: files.length > 0 ? files : undefined,
workflow: wf ?? undefined,
model,
model: effectiveModel,
});
};

Expand Down Expand Up @@ -485,11 +479,6 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
projectName={projectName}
projectCmNumber={projectCmNumber}
/>
<ApiKeyMissingPopup
open={apiKeyModalProvider !== null}
provider={apiKeyModalProvider}
onClose={() => setApiKeyModalProvider(null)}
/>
<UploadOverlay
open={isDraggingFiles}
warning={uploadWarning}
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/app/components/assistant/ModelToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ import type { ApiKeyState } from "@/app/lib/mikeApi";
export interface ModelOption {
id: string;
label: string;
group: "Anthropic" | "Google" | "OpenAI";
group: "Anthropic" | "Google" | "OpenAI" | "Demo";
}

/** Keyless built-in model — always available, returns a placeholder answer.
* Mirrors DEMO_MODEL in backend/src/lib/llm/models.ts. */
export const DEMO_MODEL_ID = "mike-demo";

export const MODELS: ModelOption[] = [
{ id: "claude-fable-5", label: "Claude Fable 5", group: "Anthropic" },
{ id: "claude-opus-4-8", label: "Claude Opus 4.8", group: "Anthropic" },
Expand All @@ -31,6 +35,7 @@ export const MODELS: ModelOption[] = [
{ id: "gemini-3-flash-preview", label: "Gemini 3 Flash", group: "Google" },
{ id: "gpt-5.5", label: "GPT-5.5", group: "OpenAI" },
{ id: "gpt-5.4", label: "GPT-5.4", group: "OpenAI" },
{ id: DEMO_MODEL_ID, label: "Demo (no key needed)", group: "Demo" },
];

export const SETTINGS_MODELS: ModelOption[] = [
Expand All @@ -48,7 +53,7 @@ export const DEFAULT_MODEL_ID = "gemini-3-flash-preview";

export const ALLOWED_MODEL_IDS = new Set(MODELS.map((m) => 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";

Expand Down
Loading