Files

118 lines
5.8 KiB
TypeScript

"use client";
import { useI18n } from "@/components/i18n";
/**
* The zero-keys surfaces (zero-keys wave, 2026-09-25, owner-locked over
* Platform's 66dbea9 key-presence gate): when NO model-provider key
* exists, chat / home / /chat/new hoist a composer pill — "Add a
* provider key" — that takes PRECEDENCE over the models setup CTA
* (keys come first in the chain: the model pickers list only what
* keys unlock). Click opens the SetupDialog, whose stage derives to
* the first-key screen on its own.
*
* Truth: GET /api/keys — a PROVIDER key present (env or file; tool keys
* like Deepgram do not count — they unlock no models). Live updates ride
* the lh:settings-saved "keys" signal (keys-section dispatches after
* save AND delete; the setup dialog's first-key screen dispatches on
* its own save), the same re-read pattern chat.tsx uses for "models".
*/
import { useEffect, useState } from "react";
import { CUSTOM_PROVIDER, MODEL_SLOTS, type AuthKeyStatus, type ModelCatalogResponse } from "@/src/core";
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
import { useModelCatalog } from "@/components/model-catalog";
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
* classifyKeys (the keys section's own classification — no drift):
* a status whose id is a provider env-var (or the pre-interlude
* fallback shape) with present=true counts, present or file-sourced,
* env-owned included (env wins but it EXISTS). Exported for pins. */
export function hasProviderKey(
statuses: readonly AuthKeyStatus[],
providers: readonly ProviderKeyInfo[] | undefined,
): boolean {
// A stored custom-endpoint key counts too: classifyKeys drops it from
// providerRows (it has its own dedicated row), but a connected custom
// endpoint IS a way to run inference, so it clears the zero-keys gate.
return (
classifyKeys(statuses, providers).providerRows.some((s) => s.present) ||
statuses.some((s) => s.id === CUSTOM_PROVIDER.envVar && s.present)
);
}
/** Completeness is saved identity, not current provider availability. */
export function savedSlotsConfigured(slots: ModelCatalogResponse["slots"] | undefined): boolean {
return MODEL_SLOTS.every((slot) => !!slots?.[slot]?.model);
}
/** Keep the last known completeness during connection invalidation, but
* follow ordinary slot repairs as soon as their shared snapshot arrives. */
export function useModelSetup(initialConfigured: boolean) {
const { catalog } = useModelCatalog();
const [lastConfigured, setLastConfigured] = useState(initialConfigured);
const configured = catalog ? savedSlotsConfigured(catalog.slots) : lastConfigured;
useEffect(() => { if (catalog) setLastConfigured(savedSlotsConfigured(catalog.slots)); }, [catalog]);
return { catalog, configured, zeroKeys: catalog ? !catalog.providers.some((p) => p.keyPresent) : false };
}
/** A known unavailable route needs an explicit replacement, never an
* automatic provider/model substitution. Unknown metadata defers to the
* server's admission gate instead of declaring a route disconnected. */
export function selectedModelUnavailable(catalog: ModelCatalogResponse | undefined, selected: string | undefined): boolean {
return !!catalog && !!selected && !catalog.models.some((m) => `${m.provider}:${m.id}` === selected);
}
/** Reuse the ordinary control (also opened by the existing keyboard action). */
export function openModelPicker(): void {
window.dispatchEvent(new CustomEvent("lh:kbd-open-model-picker"));
}
/** The zero-keys truth for the calling page: TRUE only after a
* successful read says no provider key exists (pending and failed
* reads render nothing — the pill must never flash on a gap, and a
* failed read claiming "zero keys" would be a lie the CTA flow then
* leans on). Re-reads on the keys signal. */
export function useZeroKeys(): boolean {
const { catalog } = useModelCatalog();
return catalog ? !catalog.providers.some((provider) => provider.keyPresent) : false;
}
/** The pill itself — the models CTA's chrome and placement, the keys
* chain's copy. testid "setup-cta-keys" (the models CTA keeps
* "setup-cta"; probes can tell them apart). */
export function ZeroKeysPill({ onOpen, inline = false }: { onOpen: () => void; inline?: boolean }) {
const { t } = useI18n();
const button = (
<button
type="button"
data-testid="setup-cta-keys"
onClick={onOpen}
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
>
{t("Add a provider key")}</button>
);
return inline ? button : <div className="pointer-events-auto mb-2 flex justify-center">{button}</div>;
}
/** Pure: the strong slot is pinned to a provider whose key is GONE (owner
* interlude, 2026-09-25 — the original deleted-key scenario). The catalog
* already knows both halves: slots.strong is the resolved pin, providers[]
* lists only PRESENT-key providers (keyless ones are absent, the same
* discipline as the search engines). False when the strong slot is unset
* (the models guard owns that case — this check only runs when slots ARE
* configured) or the catalog hasn't loaded (the server gate remains the
* belt). Exported for pins. */
export function strongKeyless(catalog: ModelCatalogResponse | undefined): boolean {
const strong = catalog?.slots.strong;
if (!strong) return false;
return !catalog.providers.some((p) => p.id === strong.model.provider && p.keyPresent);
}
/** The strong-slot keyless truth for the PRE-SEND guard (home / /chat/new):
* the catalog seam with re-reads on BOTH signals that can flip it — "keys"
* (a provider key saved or deleted) and "models" (the strong slot
* re-pinned). Live-updates with the page, never mid-send. */
export function useStrongKeyless(): boolean {
const { catalog } = useModelCatalog();
return strongKeyless(catalog);
}