own provider update + retry buggy response
This commit is contained in:
@@ -505,6 +505,23 @@ export function App() {
|
||||
await refreshConvs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings "Providers" changed (key saved/removed): reload the model menu
|
||||
* so connected providers appear (or disappear) at the top without a
|
||||
* page refresh. The current pick survives when still available.
|
||||
*/
|
||||
async function handleProvidersChanged() {
|
||||
try {
|
||||
const m = await api.models();
|
||||
setModels(m.models);
|
||||
setModel((cur) =>
|
||||
cur && m.models.some((x) => x.id === cur) ? cur : (m.models[0]?.id ?? ""),
|
||||
);
|
||||
} catch {
|
||||
// ignore (session errors reload the page via api layer)
|
||||
}
|
||||
}
|
||||
|
||||
// Auth pages render instantly (no splash); the platform boots behind one.
|
||||
if (path === "/login") {
|
||||
if (user) return null; // bounce to /chat imminent
|
||||
@@ -630,6 +647,7 @@ export function App() {
|
||||
}}
|
||||
onViewArchived={openArchived}
|
||||
onArchivedChanged={handleArchivedChanged}
|
||||
onProvidersChanged={handleProvidersChanged}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -379,7 +379,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-3 flex items-center justify-between gap-2 text-sm">
|
||||
<div className="mt-4 flex items-center justify-between gap-2 pt-1 text-sm">
|
||||
<span className="opacity-60">{total === 1 ? t("admin.countOne", { total, page, pages }) : t("admin.countMany", { total, page, pages })}</span>
|
||||
{pages > 1 && (
|
||||
<div className="flex gap-2.5">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, X } from "lucide-react";
|
||||
import { api } from "../lib/api.ts";
|
||||
import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, Plug, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, X } from "lucide-react";
|
||||
import { api, type UserProvider } from "../lib/api.ts";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts";
|
||||
import { FEATURES, setFeature, useFeatures } from "../lib/features.ts";
|
||||
@@ -262,6 +262,7 @@ export function SettingsModal({
|
||||
onKeyRotated,
|
||||
onViewArchived,
|
||||
onArchivedChanged,
|
||||
onProvidersChanged,
|
||||
}: {
|
||||
user: User | null;
|
||||
onClose: () => void;
|
||||
@@ -269,6 +270,7 @@ export function SettingsModal({
|
||||
onKeyRotated?: () => void;
|
||||
onViewArchived: (c: Conversation) => void;
|
||||
onArchivedChanged: (deletedId?: string) => void;
|
||||
onProvidersChanged?: () => void;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
@@ -301,6 +303,7 @@ export function SettingsModal({
|
||||
{ id: "appearance", title: t("settings.appearance"), icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette", "language", "langue", "idioma", "lingua", "язык"] },
|
||||
{ id: "features", title: t("settings.features"), icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] },
|
||||
{ id: "security", title: t("settings.security"), icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] },
|
||||
{ id: "providers", title: t("settings.providers"), icon: Plug, keywords: ["providers", "provider", "api", "keys", "openai", "anthropic", "deepseek", "gemini", "byok", "model", "fournisseur", "proveedor", "провайдер"] },
|
||||
{ id: "archived", title: t("archived.title"), icon: Archive, keywords: ["archived", "archive", "old", "hidden", "read-only", "readonly", "archiv"] },
|
||||
] as const;
|
||||
type CatId = (typeof CATS)[number]["id"];
|
||||
@@ -546,6 +549,12 @@ export function SettingsModal({
|
||||
</SettingsPane>
|
||||
)}
|
||||
|
||||
{activeCat === "providers" && (
|
||||
<SettingsPane>
|
||||
<ProvidersSection onChanged={onProvidersChanged} />
|
||||
</SettingsPane>
|
||||
)}
|
||||
|
||||
{activeCat === "archived" && (
|
||||
<SettingsPane>
|
||||
<ArchivedSection onView={onViewArchived} onChanged={onArchivedChanged} />
|
||||
@@ -996,3 +1005,169 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Providers (settings): personal API keys (BYOK) ---------- */
|
||||
|
||||
const PROVIDER_META: { id: string; label: string; docs: string }[] = [
|
||||
{ id: "openai", label: "OpenAI", docs: "https://platform.openai.com/api-keys" },
|
||||
{ id: "anthropic", label: "Anthropic", docs: "https://console.anthropic.com/settings/keys" },
|
||||
{ id: "deepseek", label: "DeepSeek", docs: "https://platform.deepseek.com/api_keys" },
|
||||
{ id: "gemini", label: "Gemini", docs: "https://aistudio.google.com/apikey" },
|
||||
];
|
||||
|
||||
function ProvidersSection({ onChanged }: { onChanged?: () => void }) {
|
||||
const { t } = useT();
|
||||
const [list, setList] = useState<UserProvider[] | null>(null);
|
||||
const [keys, setKeys] = useState<Record<string, string>>({});
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await api.providers();
|
||||
setList(res.providers);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("providers.loadFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function save(id: string) {
|
||||
const v = (keys[id] ?? "").trim();
|
||||
if (v.length < 8 || v.length > 256 || /\s/.test(v)) {
|
||||
setError(t("providers.invalidKey"));
|
||||
return;
|
||||
}
|
||||
setBusyId(id);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const res = await api.setProvider(id, v);
|
||||
setList(res.providers);
|
||||
setKeys((k) => ({ ...k, [id]: "" }));
|
||||
setNotice(t("providers.keySaved"));
|
||||
onChanged?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("common.saveFailed"));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
setBusyId(id);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const res = await api.deleteProvider(id);
|
||||
setList(res.providers);
|
||||
setNotice(t("providers.keyRemoved"));
|
||||
onChanged?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("common.deleteFailed"));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (list === null && !error) {
|
||||
return <p className="flex items-center gap-2 py-4 text-sm opacity-60"><Spinner size={15} /> {t("common.loading")}</p>;
|
||||
}
|
||||
|
||||
const byId = new Map((list ?? []).map((p) => [p.provider, p]));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs leading-relaxed opacity-60">{t("providers.intro")}</p>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
{notice && <p className="rounded-2xl bg-emerald-500/10 px-4 py-2.5 text-sm text-emerald-700 dark:text-emerald-300">{notice}</p>}
|
||||
<ul className="space-y-2">
|
||||
{PROVIDER_META.map((m) => {
|
||||
const st = byId.get(m.id);
|
||||
const connected = !!st?.hasKey;
|
||||
const busy = busyId === m.id;
|
||||
return (
|
||||
<li key={m.id} className="space-y-2.5 rounded-2xl border border-stone-200/70 p-3.5 dark:border-zinc-800">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0 rounded-full",
|
||||
connected ? "bg-emerald-500" : "bg-stone-300 dark:bg-zinc-600",
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">{m.label}</span>
|
||||
<span className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs",
|
||||
connected
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "bg-stone-200/70 opacity-60 dark:bg-zinc-800",
|
||||
)}>
|
||||
{connected ? t("providers.connected") : t("providers.notConnected")}
|
||||
</span>
|
||||
{connected && st?.last4 && (
|
||||
<code className="rounded-lg bg-stone-100 px-2 py-0.5 font-mono text-xs opacity-70 dark:bg-zinc-800">
|
||||
••••{st.last4}
|
||||
</code>
|
||||
)}
|
||||
<a
|
||||
href={m.docs}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto shrink-0 text-xs text-accent-700 underline-offset-2 hover:underline dark:text-accent-400"
|
||||
>
|
||||
{t("providers.getKey")}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={keys[m.id] ?? ""}
|
||||
onChange={(e) => setKeys((k) => ({ ...k, [m.id]: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
save(m.id);
|
||||
}
|
||||
}}
|
||||
placeholder={connected ? `${t("providers.keyPh")} (${t("providers.connected").toLowerCase()})` : t("providers.keyPh")}
|
||||
aria-label={`${m.label} API key`}
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy || !(keys[m.id] ?? "").trim()}
|
||||
onClick={() => save(m.id)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{busy ? <Spinner size={15} /> : t("providers.saveKey")}
|
||||
</Button>
|
||||
</div>
|
||||
{connected && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => remove(m.id)}
|
||||
className="!text-red-600 dark:!text-red-400"
|
||||
>
|
||||
<Trash2 size={14} /> {t("providers.removeKey")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-xs leading-relaxed opacity-60">{t("providers.hint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,14 @@ export interface ModelPolicyEntry {
|
||||
daily: number;
|
||||
}
|
||||
|
||||
export interface UserProvider {
|
||||
provider: string;
|
||||
label: string;
|
||||
hasKey: boolean;
|
||||
last4: string | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface PolicyModel extends DriverModel {
|
||||
enabled: boolean;
|
||||
hourly: number;
|
||||
@@ -291,6 +299,16 @@ export const api = {
|
||||
/** Admin: delete a local Ollama model (frees disk on the Ollama host). */
|
||||
ollamaDelete: (name: string) => req<{ ok: boolean }>(`/api/admin/ollama/models/${encodeURIComponent(name)}`, { method: "DELETE" }),
|
||||
|
||||
/** Personal LLM providers (BYOK): presence + last4 only, keys never leave the server. */
|
||||
providers: () => req<{ providers: UserProvider[] }>("/api/me/providers"),
|
||||
setProvider: (provider: string, apiKey: string) =>
|
||||
req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apiKey }),
|
||||
}),
|
||||
deleteProvider: (provider: string) =>
|
||||
req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, { method: "DELETE" }),
|
||||
|
||||
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[] }>("/api/tools"),
|
||||
|
||||
/** Send an image to the platform OCR tool. Returns editable text (never the raw image). */
|
||||
|
||||
@@ -183,6 +183,22 @@ const en = {
|
||||
"settings.accentAria": "Accent: {label}",
|
||||
"settings.featHint":
|
||||
"Turn composer capabilities on or off. Applied instantly, saved on this device.",
|
||||
"settings.providers": "Providers",
|
||||
"providers.intro":
|
||||
"Connect your own API keys. Models from connected providers show up in the model menu at the top.",
|
||||
"providers.hint":
|
||||
"Keys stay on this server and are never shown again — only the last 4 characters. Usage is billed to your own provider account, and the admin model policy still applies.",
|
||||
"providers.connected": "Connected",
|
||||
"providers.notConnected": "Not connected",
|
||||
"providers.keyPh": "Paste API key…",
|
||||
"providers.saveKey": "Save key",
|
||||
"providers.removeKey": "Remove",
|
||||
"providers.replaceKey": "Replace",
|
||||
"providers.getKey": "Get a key",
|
||||
"providers.keySaved": "Key saved — its models are now in the menu.",
|
||||
"providers.keyRemoved": "Key removed.",
|
||||
"providers.invalidKey": "That key looks invalid (8–256 characters, no spaces).",
|
||||
"providers.loadFailed": "Could not load providers.",
|
||||
|
||||
"accent.preset.emerald": "Emerald",
|
||||
"accent.preset.rose": "Rose",
|
||||
@@ -705,6 +721,22 @@ const es: Dict = {
|
||||
"settings.accentAria": "Acento: {label}",
|
||||
"settings.featHint":
|
||||
"Activa funciones del editor. Al instante, en este dispositivo.",
|
||||
"settings.providers": "Proveedores",
|
||||
"providers.intro":
|
||||
"Conecta tus propias claves API. Los modelos de los proveedores conectados aparecen en el menú de modelos de arriba.",
|
||||
"providers.hint":
|
||||
"Las claves quedan en este servidor y no se muestran de nuevo — solo los últimos 4 caracteres. El uso se factura a tu propia cuenta y la política de modelos del admin sigue vigente.",
|
||||
"providers.connected": "Conectado",
|
||||
"providers.notConnected": "Sin conectar",
|
||||
"providers.keyPh": "Pega la clave API…",
|
||||
"providers.saveKey": "Guardar clave",
|
||||
"providers.removeKey": "Quitar",
|
||||
"providers.replaceKey": "Reemplazar",
|
||||
"providers.getKey": "Obtener clave",
|
||||
"providers.keySaved": "Clave guardada — sus modelos ya están en el menú.",
|
||||
"providers.keyRemoved": "Clave eliminada.",
|
||||
"providers.invalidKey": "Esa clave no parece válida (8–256 caracteres, sin espacios).",
|
||||
"providers.loadFailed": "No se pudieron cargar los proveedores.",
|
||||
|
||||
"accent.preset.emerald": "Esmeralda",
|
||||
"accent.preset.rose": "Rosa",
|
||||
@@ -1220,6 +1252,22 @@ const fr: Dict = {
|
||||
"settings.accentAria": "Accent : {label}",
|
||||
"settings.featHint":
|
||||
"Activez les fonctions du composer. Aussitôt, sur cet appareil.",
|
||||
"settings.providers": "Fournisseurs",
|
||||
"providers.intro":
|
||||
"Connectez vos propres clés API. Les modèles des fournisseurs connectés apparaissent dans le menu des modèles en haut.",
|
||||
"providers.hint":
|
||||
"Les clés restent sur ce serveur et ne sont plus jamais affichées — seuls les 4 derniers caractères. L'usage est facturé sur votre propre compte, et la politique de modèles de l'admin reste en vigueur.",
|
||||
"providers.connected": "Connecté",
|
||||
"providers.notConnected": "Non connecté",
|
||||
"providers.keyPh": "Collez la clé API…",
|
||||
"providers.saveKey": "Enregistrer",
|
||||
"providers.removeKey": "Retirer",
|
||||
"providers.replaceKey": "Remplacer",
|
||||
"providers.getKey": "Obtenir une clé",
|
||||
"providers.keySaved": "Clé enregistrée — ses modèles sont dans le menu.",
|
||||
"providers.keyRemoved": "Clé retirée.",
|
||||
"providers.invalidKey": "Cette clé semble invalide (8–256 caractères, sans espaces).",
|
||||
"providers.loadFailed": "Impossible de charger les fournisseurs.",
|
||||
|
||||
"accent.preset.emerald": "Émeraude",
|
||||
"accent.preset.rose": "Rose",
|
||||
@@ -1730,6 +1778,22 @@ const ru: Dict = {
|
||||
"settings.accentAria": "Акцент: {label}",
|
||||
"settings.featHint":
|
||||
"Включайте возможности редактора. Сразу, на этом устройстве.",
|
||||
"settings.providers": "Провайдеры",
|
||||
"providers.intro":
|
||||
"Подключите свои API-ключи. Модели подключённых провайдеров появятся в меню моделей сверху.",
|
||||
"providers.hint":
|
||||
"Ключи хранятся на этом сервере и больше не показываются — только последние 4 символа. Оплата идёт на ваш счёт у провайдера, политика моделей админа действует.",
|
||||
"providers.connected": "Подключено",
|
||||
"providers.notConnected": "Не подключено",
|
||||
"providers.keyPh": "Вставьте API-ключ…",
|
||||
"providers.saveKey": "Сохранить",
|
||||
"providers.removeKey": "Удалить",
|
||||
"providers.replaceKey": "Заменить",
|
||||
"providers.getKey": "Получить ключ",
|
||||
"providers.keySaved": "Ключ сохранён — модели уже в меню.",
|
||||
"providers.keyRemoved": "Ключ удалён.",
|
||||
"providers.invalidKey": "Похоже, ключ неверный (8–256 символов, без пробелов).",
|
||||
"providers.loadFailed": "Не удалось загрузить провайдеров.",
|
||||
|
||||
"accent.preset.emerald": "Изумруд",
|
||||
"accent.preset.rose": "Роза",
|
||||
@@ -2232,6 +2296,22 @@ const it: Dict = {
|
||||
"settings.accentAria": "Accento: {label}",
|
||||
"settings.featHint":
|
||||
"Attiva funzioni del composer. Subito, su questo dispositivo.",
|
||||
"settings.providers": "Provider",
|
||||
"providers.intro":
|
||||
"Collega le tue chiavi API. I modelli dei provider collegati appaiono nel menu dei modelli in alto.",
|
||||
"providers.hint":
|
||||
"Le chiavi restano su questo server e non vengono più mostrate — solo gli ultimi 4 caratteri. L'uso è addebitato al tuo account e resta valida la policy modelli dell'admin.",
|
||||
"providers.connected": "Collegato",
|
||||
"providers.notConnected": "Non collegato",
|
||||
"providers.keyPh": "Incolla la chiave API…",
|
||||
"providers.saveKey": "Salva",
|
||||
"providers.removeKey": "Rimuovi",
|
||||
"providers.replaceKey": "Sostituisci",
|
||||
"providers.getKey": "Ottieni chiave",
|
||||
"providers.keySaved": "Chiave salvata — i modelli sono nel menu.",
|
||||
"providers.keyRemoved": "Chiave rimossa.",
|
||||
"providers.invalidKey": "Chiave non valida (8–256 caratteri, senza spazi).",
|
||||
"providers.loadFailed": "Impossibile caricare i provider.",
|
||||
|
||||
"accent.preset.emerald": "Smeraldo",
|
||||
"accent.preset.rose": "Rosa",
|
||||
@@ -2737,6 +2817,22 @@ const pt: Dict = {
|
||||
"settings.customAria": "Cor de destaque personalizada",
|
||||
"settings.accentAria": "Destaque: {label}",
|
||||
"settings.featHint": "Liga funções do editor. Na hora, neste dispositivo.",
|
||||
"settings.providers": "Provedores",
|
||||
"providers.intro":
|
||||
"Ligue as suas chaves API. Os modelos dos provedores ligados aparecem no menu de modelos em cima.",
|
||||
"providers.hint":
|
||||
"As chaves ficam neste servidor e nunca mais são mostradas — só os últimos 4 caracteres. O uso é cobrado na sua conta e a política de modelos do admin continua válida.",
|
||||
"providers.connected": "Ligado",
|
||||
"providers.notConnected": "Desligado",
|
||||
"providers.keyPh": "Cole a chave API…",
|
||||
"providers.saveKey": "Guardar",
|
||||
"providers.removeKey": "Remover",
|
||||
"providers.replaceKey": "Substituir",
|
||||
"providers.getKey": "Obter chave",
|
||||
"providers.keySaved": "Chave guardada — os modelos já estão no menu.",
|
||||
"providers.keyRemoved": "Chave removida.",
|
||||
"providers.invalidKey": "Essa chave parece inválida (8–256 caracteres, sem espaços).",
|
||||
"providers.loadFailed": "Não foi possível carregar os provedores.",
|
||||
|
||||
"accent.preset.emerald": "Esmeralda",
|
||||
"accent.preset.rose": "Rosa",
|
||||
|
||||
Reference in New Issue
Block a user