Skip to content
21 changes: 11 additions & 10 deletions src/app/(outerbase)/local-setting-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,12 @@ export const localSettingDialog = createDialog(({ close }) => {
return (
<>
<DialogHeader>
<DialogTitle>AI Assistant Setting</DialogTitle>
<DialogTitle>API Key</DialogTitle>
<DialogDescription>
Elegí un proveedor y pegá tu API key para habilitar el asistente
(text-to-SQL y explicaciones). La key se guarda solo en localStorage de
tu navegador. No la guardamos en ningún servidor ni se loguea.
tu navegador. No la guardamos en ningún servidor ni se loguea. El modelo lo
podés elegir desde la barra del chat; acá queda como override manual opcional.
</DialogDescription>
</DialogHeader>

Expand All @@ -78,14 +79,6 @@ export const localSettingDialog = createDialog(({ close }) => {
</select>
</div>

<LabelInput
label="Model"
placeholder="Model"
size="lg"
value={model}
onValueChange={setModel}
/>

<LabelInput
type="password"
label="API Key"
Expand All @@ -94,6 +87,14 @@ export const localSettingDialog = createDialog(({ close }) => {
value={token}
onValueChange={setToken}
/>

<LabelInput
label="Modelo (override opcional)"
placeholder="Dejalo vacío para usar el default del proveedor"
size="lg"
value={model}
onValueChange={setModel}
/>
</div>

<DialogFooter>
Expand Down
140 changes: 140 additions & 0 deletions src/app/(outerbase)/model-picker-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"use client";
import { createDialog } from "@/components/create-dialog";
import { Loader } from "@/components/orbit/loader";
import {
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { fetchProviderModels } from "@/lib/agent-models";
import {
getAgentFromLocalStorage,
patchAgentConfig,
} from "@/lib/ai-agent-storage";
import { cn } from "@/lib/utils";
import { Check, MagnifyingGlass } from "@phosphor-icons/react";
import { useEffect, useMemo, useState } from "react";

const PROVIDER_LABEL: Record<string, string> = {
anthropic: "Anthropic",
openai: "OpenAI",
gemini: "Gemini",
};

// Dialog para elegir el modelo activo. Fetchea la lista live del provider (con
// fallback curado) y muestra buscador + loader + badge "lista offline". Al elegir,
// hace un update PARCIAL de la config (solo model) reusando patchAgentConfig, que
// mutea el SWR `/local-agent-setting` → el header y el driver se re-arman solos.
export const modelPickerDialog = createDialog(({ close }) => {
// Snapshot de la config al abrir (provider + token para el fetch, model para marcar
// el activo). useMemo([]) → se lee una vez al montar el dialog.
const current = useMemo(() => getAgentFromLocalStorage(), []);

const [models, setModels] = useState<string[]>([]);
const [fromFallback, setFromFallback] = useState(false);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");

useEffect(() => {
if (!current) {
setLoading(false);
return;
}

// alive: evita el setState si el dialog se cierra mientras el fetch está en vuelo.
let alive = true;
setLoading(true);
fetchProviderModels(current.provider, current.token)
.then((res) => {
if (!alive) return;
setModels(res.models);
setFromFallback(res.fromFallback);
})
.finally(() => {
if (alive) setLoading(false);
});

return () => {
alive = false;
};
}, [current]);

const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return models;
return models.filter((m) => m.toLowerCase().includes(q));
}, [models, search]);

const onPick = (model: string) => {
patchAgentConfig({ model });
close(undefined);
};

return (
<>
<DialogHeader>
<DialogTitle>Elegí un modelo</DialogTitle>
<DialogDescription>
{current
? `Modelos de ${PROVIDER_LABEL[current.provider] ?? current.provider}.`
: "Configurá un proveedor y una API key primero."}
{fromFallback && (
<span className="ml-2 inline-flex items-center rounded bg-neutral-200 px-1.5 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
lista offline
</span>
)}
</DialogDescription>
</DialogHeader>

{current && (
<div className="flex flex-col gap-3">
<div className="border-input flex items-center gap-2 rounded-md border px-2">
<MagnifyingGlass className="h-4 w-4 opacity-50" />
<input
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
placeholder="Buscar modelo…"
autoFocus
className="h-9 grow bg-transparent text-sm outline-none"
/>
</div>

<div className="max-h-[320px] overflow-y-auto rounded-md border">
{loading ? (
<div className="flex items-center justify-center gap-2 p-6 text-sm opacity-70">
<Loader size={16} />
Cargando modelos…
</div>
) : filtered.length === 0 ? (
<div className="p-6 text-center text-sm opacity-60">
No hay modelos que coincidan con “{search}”.
</div>
) : (
filtered.map((model) => {
const active = model === current.model;
return (
<button
key={model}
onClick={() => onPick(model)}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-900",
active && "font-medium"
)}
>
<Check
className={cn(
"h-4 w-4 shrink-0",
active ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{model}</span>
</button>
);
})
)}
</div>
</div>
)}
</>
);
});
93 changes: 93 additions & 0 deletions src/app/(outerbase)/provider-picker-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"use client";
import { createDialog } from "@/components/create-dialog";
import { Button } from "@/components/orbit/button";
import {
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
AgentProvider,
DEFAULT_MODEL_BY_PROVIDER,
getAgentFromLocalStorage,
patchAgentConfig,
} from "@/lib/ai-agent-storage";
import { useMemo, useState } from "react";
import { localSettingDialog } from "./local-setting-dialog";

const PROVIDERS: { value: AgentProvider; label: string }[] = [
{ value: "anthropic", label: "Anthropic (Claude)" },
{ value: "openai", label: "OpenAI (ChatGPT)" },
{ value: "gemini", label: "Google Gemini" },
];

// Dialog para cambiar de proveedor. Al elegir uno distinto, el modelo se resetea al
// default de ESE provider (DEFAULT_MODEL_BY_PROVIDER) vía patchAgentConfig. Si no hay
// API key guardada, ofrece abrir el dialog de key (la key es por-provider).
export const providerPickerDialog = createDialog(({ close }) => {
const current = useMemo(() => getAgentFromLocalStorage(), []);
const [provider, setProvider] = useState<AgentProvider>(
current?.provider ?? "anthropic"
);

const changed = provider !== current?.provider;

const onSave = () => {
// Reset del modelo al default del provider elegido. Si CAMBIÓ el provider, además
// LIMPIAMOS el token: la config guarda una sola key y la anterior es de OTRO
// provider — dejarla colgada mandaría una key inválida (401 silencioso) y rompería
// tanto el chat como el Ctrl+B text-to-SQL, que comparten esta config. Mejor quedar
// sin key (estado "configurá IA" explícito) que con una key equivocada.
patchAgentConfig({
provider,
model: DEFAULT_MODEL_BY_PROVIDER[provider],
...(changed ? { token: "" } : {}),
});
close(undefined);

// Si cambió el provider (key vieja inservible) o no había key, abrimos el dialog
// para que el usuario pegue la key correcta del provider nuevo.
if (changed || !current?.token) {
localSettingDialog.show({}).then().catch();
}
};

return (
<>
<DialogHeader>
<DialogTitle>Elegí un proveedor</DialogTitle>
<DialogDescription>
Al cambiar de proveedor, el modelo se resetea al default. Si no tenés una
API key para ese proveedor, te la vamos a pedir.
</DialogDescription>
</DialogHeader>

<RadioGroup
value={provider}
onValueChange={(v) => setProvider(v as AgentProvider)}
className="flex flex-col gap-1"
>
{PROVIDERS.map((p) => (
<label
key={p.value}
className="flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2.5 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<RadioGroupItem value={p.value} />
<span>{p.label}</span>
{p.value === current?.provider && (
<span className="ml-auto text-xs opacity-50">actual</span>
)}
</label>
))}
</RadioGroup>

<DialogFooter>
<Button size="lg" variant="primary" onClick={onSave}>
{changed ? "Cambiar proveedor" : "Guardar"}
</Button>
</DialogFooter>
</>
);
});
47 changes: 47 additions & 0 deletions src/components/gui/tabs/chat-shimmer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use client";
import { Sparkle } from "@phosphor-icons/react";

// Loader estilo Gemini: un sparkle que pulsa + un texto con gradiente que barre
// (shimmer sweep). Se muestra mientras el stream todavía no emitió texto; el chat
// tab le pasa label="Razonando…" cuando llega reasoning. CSS puro (background-position
// animado) inline para que el componente sea autocontenido — sin tocar globals.css.
export default function ChatShimmer({
label = "Generando…",
}: {
label?: string;
}) {
return (
<div className="flex items-center gap-2" aria-live="polite">
<Sparkle className="h-4 w-4 animate-pulse opacity-80" weight="fill" />
<span className="chat-shimmer-text text-sm font-medium">{label}</span>
<style>{`
.chat-shimmer-text {
background: linear-gradient(
90deg,
var(--muted-foreground) 0%,
var(--foreground) 50%,
var(--muted-foreground) 100%
);
background-size: 200% auto;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
animation: chat-shimmer-sweep 1.4s linear infinite;
}
@keyframes chat-shimmer-sweep {
to {
background-position: -200% center;
}
}
@media (prefers-reduced-motion: reduce) {
.chat-shimmer-text {
animation: none;
color: var(--muted-foreground);
-webkit-text-fill-color: currentColor;
}
}
`}</style>
</div>
);
}
Loading
Loading