Spaces:
Sleeping
Sleeping
| // "Bring your own AI key" — the app works with NO server-side AI key: the visitor enters their own key | |
| // here, it's kept ONLY in this browser's localStorage, and rides along with every AI request as headers. | |
| // The server prefers a header key and falls back to its env key. Keys are never sent anywhere but the API, | |
| // never logged, never persisted server-side. This is the single source of truth for the client side. | |
| import { useEffect, useState } from "react"; | |
| // providers the modal offers. "custom" is any OpenAI-compatible endpoint (user supplies base URL + model). | |
| export interface AiProvider { id: string; label: string; blurb: string; placeholder: string; keyUrl?: string; custom?: boolean } | |
| export const AI_PROVIDERS: AiProvider[] = [ | |
| { id: "kimi", label: "Kimi (Moonshot)", blurb: "Reliable tool-calling chat — the app's primary model.", placeholder: "sk-…", keyUrl: "https://platform.moonshot.ai/console/api-keys" }, | |
| { id: "deepinfra", label: "DeepInfra", blurb: "Cheapest path — open Llama / Qwen, pay-per-token.", placeholder: "…", keyUrl: "https://deepinfra.com/dash/api_keys" }, | |
| { id: "groq", label: "Groq", blurb: "Fast chat + Whisper speech-to-text. Has a free tier.", placeholder: "gsk_…", keyUrl: "https://console.groq.com/keys" }, | |
| { id: "custom", label: "Custom (OpenAI-compatible)", blurb: "Any OpenAI-compatible base URL + model id.", placeholder: "sk-…", custom: true }, | |
| ]; | |
| const LS_KEYS = "eldouma.aiKeys"; // JSON map provider→key | |
| const LS_PROVIDER = "eldouma.aiProvider"; // the selected/active provider | |
| const LS_CUSTOM = "eldouma.aiCustom"; // { baseUrl, model } for the custom endpoint | |
| export const AI_KEYS_EVENT = "eldouma:ai-keys"; // fired whenever the config changes | |
| export interface AiConfig { keys: Record<string, string>; provider: string; baseUrl: string; model: string } | |
| const safeParse = <T,>(s: string | null, fallback: T): T => { try { return s ? (JSON.parse(s) as T) : fallback; } catch { return fallback; } }; | |
| export function getAiConfig(): AiConfig { | |
| if (typeof localStorage === "undefined") return { keys: {}, provider: "", baseUrl: "", model: "" }; | |
| const keys = safeParse<Record<string, string>>(localStorage.getItem(LS_KEYS), {}); | |
| const custom = safeParse<{ baseUrl?: string; model?: string }>(localStorage.getItem(LS_CUSTOM), {}); | |
| return { keys: keys || {}, provider: localStorage.getItem(LS_PROVIDER) || "", baseUrl: custom.baseUrl || "", model: custom.model || "" }; | |
| } | |
| // persist a change and broadcast it so every open AI surface re-reads (headers, switcher, banner) | |
| export function saveAiConfig(next: { provider?: string; key?: string; baseUrl?: string; model?: string }): void { | |
| if (typeof localStorage === "undefined") return; | |
| const cfg = getAiConfig(); | |
| const provider = next.provider ?? cfg.provider; | |
| const keys = { ...cfg.keys }; | |
| if (next.provider != null && typeof next.key === "string") { | |
| const k = next.key.trim(); | |
| if (k) keys[next.provider] = k; else delete keys[next.provider]; | |
| } | |
| localStorage.setItem(LS_KEYS, JSON.stringify(keys)); | |
| localStorage.setItem(LS_PROVIDER, provider); | |
| localStorage.setItem(LS_CUSTOM, JSON.stringify({ baseUrl: (next.baseUrl ?? cfg.baseUrl).trim(), model: (next.model ?? cfg.model).trim() })); | |
| window.dispatchEvent(new Event(AI_KEYS_EVENT)); | |
| } | |
| // forget the key for one provider (does not change the selection unless it was the only key) | |
| export function clearProviderKey(provider: string): void { | |
| if (typeof localStorage === "undefined") return; | |
| const cfg = getAiConfig(); | |
| const keys = { ...cfg.keys }; | |
| delete keys[provider]; | |
| localStorage.setItem(LS_KEYS, JSON.stringify(keys)); | |
| window.dispatchEvent(new Event(AI_KEYS_EVENT)); | |
| } | |
| // the headers to attach to EVERY AI request. Empty when the user hasn't connected a key → the server | |
| // then falls back to its own env key (if any). Centralized here so all call sites stay consistent. | |
| export function aiHeaders(): Record<string, string> { | |
| const cfg = getAiConfig(); | |
| const provider = cfg.provider; | |
| if (!provider) return {}; | |
| const key = (cfg.keys[provider] || "").trim(); | |
| if (!key) return {}; | |
| const h: Record<string, string> = { "x-ai-key": key, "x-ai-provider": provider }; | |
| if (provider === "custom") { | |
| if (cfg.baseUrl.trim()) h["x-ai-base-url"] = cfg.baseUrl.trim(); | |
| if (cfg.model.trim()) h["x-ai-model"] = cfg.model.trim(); | |
| } | |
| return h; | |
| } | |
| // does the user have a usable key configured right now? (custom also needs a base URL) | |
| export function hasUserKey(): boolean { | |
| const h = aiHeaders(); | |
| return !!h["x-ai-key"] && (h["x-ai-provider"] !== "custom" || !!h["x-ai-base-url"]); | |
| } | |
| export interface ModelInfo { id: string; label: string; role: string; reasoning?: boolean; tools?: boolean } | |
| // shared hook: the models the server reports as usable for THIS request (env keys + the user's own key). | |
| // Re-fetches whenever the key config changes, so connecting a key instantly lights up the switcher/banner. | |
| export function useAiModels(): { models: ModelInfo[]; defaults: Record<string, string>; loaded: boolean } { | |
| const [state, setState] = useState<{ models: ModelInfo[]; defaults: Record<string, string>; loaded: boolean }>({ models: [], defaults: {}, loaded: false }); | |
| useEffect(() => { | |
| let alive = true; | |
| const load = () => | |
| fetch("/api/models", { headers: aiHeaders() }) | |
| .then((r) => r.json()) | |
| .then((d) => { if (alive) setState({ models: (d.models as ModelInfo[]) || [], defaults: d.defaults || {}, loaded: true }); }) | |
| .catch(() => { if (alive) setState((s) => ({ ...s, loaded: true })); }); | |
| load(); | |
| window.addEventListener(AI_KEYS_EVENT, load); | |
| return () => { alive = false; window.removeEventListener(AI_KEYS_EVENT, load); }; | |
| }, []); | |
| return state; | |
| } | |