Bring-your-own keys for cloud providers (OpenAI-compatible)
Settings -> Providers takes keys for OpenAI, Anthropic (their official OpenAI-compatibility layer), DeepSeek, Groq, OpenRouter and any custom OpenAI-compatible endpoint. One adapter (lib/openai-compat) covers listing, streaming chat, JSON generation and vision, and every model flow can run on a provider: the chat picker lists each enabled provider's models (labelled, remembered), and a "Study generation" choice moves decks, homework, grading and attachment transcription onto a provider model. Keys stay in this browser exactly like the Ollama one (freezmo.providers.v1): never stored server-side, never in a backup or the sync payload, forwarded only with the request that needs them. The provider test asserts the key reaches the provider while the routing header stays on the app side. New suite tests/providers-test.mjs (13 checks) stubs both protocols: model listing, chat streaming, study generation and the Settings flow. Privacy policy updated (en+cs). Contrast 0 findings, axe clean, mobile 126/126, chat 102/102, wizard 95/95, bubbles 34/34, regression 44/44, i18n 13/13, native 33/33. Version 0.4.7, SW -> freezmo-v72.
This commit is contained in:
parent
37f7d22041
commit
4372423167
28 changed files with 1538 additions and 150 deletions
|
|
@ -11,6 +11,8 @@ import { HistorySheet } from "./HistoryPanel";
|
|||
import { MessageRow } from "./MessageRow";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { ChatIcon, ChatPlusIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, RefreshIcon } from "@/components/icons";
|
||||
import { PROVIDERS, providerInfo } from "@/lib/providers";
|
||||
import { modelsFor, useProviders } from "@/lib/providers-client";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { Button } from "@/components/ui";
|
||||
|
|
@ -24,7 +26,9 @@ import { chatHistoryStore, conversationMessages, deleteConversation, ensureChats
|
|||
import {
|
||||
chatMessageId,
|
||||
readStoredChatModel,
|
||||
readStoredChatProvider,
|
||||
storeChatModel,
|
||||
storeChatProvider,
|
||||
titleFromMessage,
|
||||
type ChatAttachment,
|
||||
type ChatMessage,
|
||||
|
|
@ -75,6 +79,7 @@ export function ChatScreen() {
|
|||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [attachBusy, setAttachBusy] = useState(false);
|
||||
const [model, setModel] = useState("");
|
||||
const [provider, setProvider] = useState("ollama");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pinned, setPinned] = useState(true);
|
||||
const [scopeOpen, setScopeOpen] = useState(false);
|
||||
|
|
@ -106,6 +111,7 @@ export function ChatScreen() {
|
|||
|
||||
useEffect(() => {
|
||||
setModel(readStoredChatModel() ?? "");
|
||||
setProvider(readStoredChatProvider());
|
||||
}, []);
|
||||
|
||||
/* History: hydrate once, then open ?c=<id> conversations. */
|
||||
|
|
@ -124,6 +130,7 @@ export function ChatScreen() {
|
|||
setDraft("");
|
||||
setError(null);
|
||||
if (found.model) setModel(found.model);
|
||||
if ((found as { provider?: string }).provider) setProvider((found as { provider?: string }).provider ?? "ollama");
|
||||
loadedRef.current = found.id;
|
||||
chatActive.set(found.id);
|
||||
}, []);
|
||||
|
|
@ -154,6 +161,7 @@ export function ChatScreen() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const providerState = useProviders();
|
||||
const chatModels = useMemo<ChatModelChoice[]>(() => {
|
||||
const fromHealth = health?.chatModels ?? [];
|
||||
const list =
|
||||
|
|
@ -163,16 +171,30 @@ export function ChatScreen() {
|
|||
{ name: health?.text?.configured ?? "", badge: "text" as const, available: health?.text?.available ?? null },
|
||||
{ name: health?.vision?.configured ?? "", badge: "vision" as const, available: health?.vision?.available ?? null },
|
||||
];
|
||||
return list.filter((entry) => entry.name);
|
||||
}, [health]);
|
||||
const ollama = list.filter((entry) => entry.name);
|
||||
// Every enabled cloud provider contributes its models (live listing first,
|
||||
// the built-in seeds otherwise), labelled with the provider.
|
||||
const cloud: ChatModelChoice[] = [];
|
||||
for (const info of PROVIDERS) {
|
||||
if (!providerState.keys[info.id]) continue;
|
||||
const models = modelsFor(providerState, info.id).slice(0, 400);
|
||||
if (models.length === 0) continue;
|
||||
for (const name of models) {
|
||||
cloud.push({ name, badge: "text", available: true, provider: info.id });
|
||||
}
|
||||
}
|
||||
return [...ollama, ...cloud];
|
||||
}, [health, providerState]);
|
||||
|
||||
const activeModel = useMemo(() => {
|
||||
if (model && chatModels.some((entry) => entry.name === model)) return model;
|
||||
if (model && chatModels.some((entry) => entry.name === model && (entry.provider ?? "ollama") === provider)) return model;
|
||||
const sameProvider = chatModels.find((entry) => (entry.provider ?? "ollama") === provider && entry.available !== false);
|
||||
if (model && sameProvider?.name === model) return model;
|
||||
// The server puts the default first, but a different server may not have
|
||||
// it — fall back to the first model that is actually available.
|
||||
const firstAvailable = chatModels.find((entry) => entry.available !== false);
|
||||
return (firstAvailable ?? chatModels[0])?.name ?? model;
|
||||
}, [model, chatModels]);
|
||||
const firstAvailable = chatModels.find((entry) => (entry.provider ?? "ollama") === provider && entry.available !== false);
|
||||
return (firstAvailable ?? chatModels.find((entry) => (entry.provider ?? "ollama") === provider) ?? chatModels[0])?.name ?? model;
|
||||
}, [model, chatModels, provider]);
|
||||
|
||||
const scopeText = useMemo(() => {
|
||||
if (scope.kind === "all") return t("chat.scope.all");
|
||||
|
|
@ -297,6 +319,7 @@ export function ChatScreen() {
|
|||
try {
|
||||
await streamChat({
|
||||
model: activeModel || undefined,
|
||||
provider,
|
||||
messages: history.map((message) => ({ role: message.role, content: message.content })),
|
||||
context: context.chunks.map(({ deckId, deckTitle, section, text }) => ({ deckId, deckTitle, section, text })),
|
||||
signal: controller.signal,
|
||||
|
|
@ -689,8 +712,14 @@ export function ChatScreen() {
|
|||
busy={busy}
|
||||
online={online}
|
||||
scopeLabel={scopeText}
|
||||
modelLabel={activeModel ? prettyModelName(activeModel) : t("chat.modelTitle")}
|
||||
modelTitle={activeModel || undefined}
|
||||
modelLabel={
|
||||
activeModel
|
||||
? provider !== "ollama"
|
||||
? `${providerInfo(provider)?.label ?? provider} · ${prettyModelName(activeModel)}`
|
||||
: prettyModelName(activeModel)
|
||||
: t("chat.modelTitle")
|
||||
}
|
||||
modelTitle={activeModel ? `${activeModel} · ${providerInfo(provider)?.label ?? provider}` : undefined}
|
||||
scopeOpen={scopeOpen}
|
||||
modelOpen={modelOpen}
|
||||
// The chips toggle their own popover (and open one at a time), so a
|
||||
|
|
@ -713,7 +742,10 @@ export function ChatScreen() {
|
|||
onClose={() => setModelOpen(false)}
|
||||
models={chatModels}
|
||||
value={activeModel}
|
||||
onChange={(next) => {
|
||||
valueProvider={provider}
|
||||
onChange={(next, nextProvider) => {
|
||||
setProvider(nextProvider ?? "ollama");
|
||||
storeChatProvider(nextProvider ?? "ollama");
|
||||
setModel(next);
|
||||
storeChatModel(next);
|
||||
if (messages.length > 0) persist(messages, { model: next });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue