mirror of
https://github.com/OpenWhispr/openwhispr.git
synced 2026-10-20 06:30:20 +08:00
Fix(tinfoil): Make the model list dynamic (#1115)
* fetch tinfoil model list from /v1/models instead of hardcoding * correct tinfoil seed comment: getModelProvider no longer reads the registry * don't resolve tinfoil token params through the runtime-fetched registry * fetch tinfoil model list in main and merge it into the registry Ship three models as an offline fallback; the /v1/models list is source of truth for everything but the curated descriptions. Only prune or auto-switch after a successful fetch. * keep the saved tinfoil list across launches and refresh it per request Seed the registry from the last list Tinfoil gave us rather than the bundled models, and pull a fresh one through the tinfoil client so users who never open settings still get an up-to-date registry. * refresh the tinfoil catalog in the background, not in the request path * switch off retired tinfoil models wherever the refresh lands, and say so The reconcile moves out of the settings picker into refreshTinfoilModels, so a background refresh no longer leaves a scope pointing at a model Tinfoil 404s on. Toast the switch instead of changing the user's model silently. * fetch tinfoil models only when tinfoil is selected, and default to glm-5-2 Gating the fetch on the picker makes the 'Fetching models...' state reachable instead of dead code. Picking a named default stops Tinfoil's list order and network timing from deciding which model a new user starts on. * persist when the tinfoil list was fetched, and show refreshes under the list The hour now lives with the cached list in localStorage rather than in a main-process variable, so a restart doesn't refetch a list pulled a minute ago. The picker renders the known models immediately and reports a refresh below them only when one actually runs. * clean up selecting default model * cleaner redraw code * attest on the model catalog * more idiomatic * clean * fix(tinfoil): route scoped requests to their configured provider - look up the model in the registry before falling back to the tinfoil cleanup provider in getModelProvider, so other scopes' known models keep their own provider - pass the resolved note-formatting provider (and custom base URL/key) through note actions and auto-title generation - surface the dictation panel before showing the model-retired toast - guard tinfoil model cache localStorage access behind isBrowser - trim redundant comments --------- Co-authored-by: Gabriel Stein <gabrielstein416@gmail.com>
This commit is contained in:
co-authored by
Gabriel Stein
parent
ce6bd29aa4
commit
593eb3cd7b
@@ -381,6 +381,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
proxyCortiTranscription: (data) => ipcRenderer.invoke("proxy-corti-transcription", data),
|
||||
getTinfoilKey: () => ipcRenderer.invoke("get-tinfoil-key"),
|
||||
saveTinfoilKey: (key) => ipcRenderer.invoke("save-tinfoil-key", key),
|
||||
getTinfoilChatModels: () => ipcRenderer.invoke("get-tinfoil-chat-models"),
|
||||
|
||||
// Custom endpoint API keys
|
||||
getCustomTranscriptionKey: () => ipcRenderer.invoke("get-custom-transcription-key"),
|
||||
|
||||
@@ -16,7 +16,9 @@ import { ProviderTabs } from "./ui/ProviderTabs";
|
||||
import OpenAICompatiblePanel from "./OpenAICompatiblePanel";
|
||||
import { API_ENDPOINTS } from "../config/constants";
|
||||
import logger from "../utils/logger";
|
||||
import { REASONING_PROVIDERS } from "../models/ModelRegistry";
|
||||
import { REASONING_PROVIDERS, toReasoningModel } from "../models/ModelRegistry";
|
||||
import { useTinfoilModels } from "../hooks/useTinfoilModels";
|
||||
import { pickDefaultTinfoilModel } from "../models/tinfoilModels";
|
||||
import { modelRegistry } from "../models/ModelRegistry";
|
||||
import { getProviderIcon, isMonochromeProvider } from "../utils/providerIcons";
|
||||
import { createExternalLinkHandler } from "../utils/externalLinks";
|
||||
@@ -325,6 +327,11 @@ export default function ReasoningModelSelector({
|
||||
const [selectedMode, setSelectedMode] = useState<"cloud" | "local">(mode || "cloud");
|
||||
const [selectedCloudProvider, setSelectedCloudProvider] = useState("openai");
|
||||
const [selectedLocalProvider, setSelectedLocalProvider] = useState("qwen");
|
||||
const {
|
||||
models: tinfoilModels,
|
||||
loading: tinfoilModelsLoading,
|
||||
error: tinfoilModelsError,
|
||||
} = useTinfoilModels(selectedCloudProvider === "tinfoil");
|
||||
|
||||
const effectiveMode = mode || selectedMode;
|
||||
|
||||
@@ -369,12 +376,17 @@ export default function ReasoningModelSelector({
|
||||
if (selectedCloudProvider === "openai") return openaiModelOptions;
|
||||
if (selectedCloudProvider === "custom") return [];
|
||||
|
||||
const provider = REASONING_PROVIDERS[selectedCloudProvider as keyof typeof REASONING_PROVIDERS];
|
||||
if (!provider?.models) return [];
|
||||
|
||||
const iconUrl = getProviderIcon(selectedCloudProvider);
|
||||
const invertInDark = isMonochromeProvider(selectedCloudProvider);
|
||||
return provider.models.map((model) => ({
|
||||
|
||||
const models =
|
||||
selectedCloudProvider === "tinfoil"
|
||||
? tinfoilModels.map(toReasoningModel)
|
||||
: REASONING_PROVIDERS[selectedCloudProvider as keyof typeof REASONING_PROVIDERS]?.models;
|
||||
|
||||
if (!models) return [];
|
||||
|
||||
return models.map((model) => ({
|
||||
...model,
|
||||
description: model.descriptionKey
|
||||
? t(model.descriptionKey, { defaultValue: model.description })
|
||||
@@ -382,7 +394,7 @@ export default function ReasoningModelSelector({
|
||||
icon: iconUrl,
|
||||
invertInDark,
|
||||
}));
|
||||
}, [selectedCloudProvider, openaiModelOptions, t]);
|
||||
}, [selectedCloudProvider, openaiModelOptions, tinfoilModels, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const localProviderIds = localProviders.map((p) => p.id);
|
||||
@@ -419,6 +431,21 @@ export default function ReasoningModelSelector({
|
||||
loadDownloadedModels();
|
||||
}, [loadDownloadedModels]);
|
||||
|
||||
const selectDefaultModelForProvider = (provider: string) => {
|
||||
if (provider === "custom") return;
|
||||
|
||||
if (provider === "tinfoil") {
|
||||
const defaultModel = pickDefaultTinfoilModel(tinfoilModels);
|
||||
if (defaultModel) setReasoningModel(defaultModel.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const providerData = REASONING_PROVIDERS[provider as keyof typeof REASONING_PROVIDERS];
|
||||
if (providerData?.models?.length > 0) {
|
||||
setReasoningModel(providerData.models[0].value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModeChange = async (newMode: "cloud" | "local") => {
|
||||
setSelectedMode(newMode);
|
||||
setReasoningModeProp?.(newMode === "local" ? "local" : "providers");
|
||||
@@ -426,14 +453,7 @@ export default function ReasoningModelSelector({
|
||||
if (newMode === "cloud") {
|
||||
window.electronAPI?.llamaServerStop?.();
|
||||
setLocalReasoningProvider(selectedCloudProvider);
|
||||
|
||||
if (selectedCloudProvider === "custom") return;
|
||||
|
||||
const provider =
|
||||
REASONING_PROVIDERS[selectedCloudProvider as keyof typeof REASONING_PROVIDERS];
|
||||
if (provider?.models?.length > 0) {
|
||||
setReasoningModel(provider.models[0].value);
|
||||
}
|
||||
selectDefaultModelForProvider(selectedCloudProvider);
|
||||
} else {
|
||||
setLocalReasoningProvider(selectedLocalProvider);
|
||||
const downloaded = await loadDownloadedModels();
|
||||
@@ -453,13 +473,7 @@ export default function ReasoningModelSelector({
|
||||
const handleCloudProviderChange = (provider: string) => {
|
||||
setSelectedCloudProvider(provider);
|
||||
setLocalReasoningProvider(provider);
|
||||
|
||||
if (provider === "custom") return;
|
||||
|
||||
const providerData = REASONING_PROVIDERS[provider as keyof typeof REASONING_PROVIDERS];
|
||||
if (providerData?.models?.length > 0) {
|
||||
setReasoningModel(providerData.models[0].value);
|
||||
}
|
||||
selectDefaultModelForProvider(provider);
|
||||
};
|
||||
|
||||
const handleLocalProviderChange = async (providerId: string) => {
|
||||
@@ -660,6 +674,20 @@ export default function ReasoningModelSelector({
|
||||
selectedModel={reasoningModel}
|
||||
onModelSelect={setReasoningModel}
|
||||
/>
|
||||
{selectedCloudProvider === "tinfoil" && (
|
||||
<>
|
||||
{tinfoilModelsLoading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("reasoning.tinfoil.refreshingModels")}
|
||||
</p>
|
||||
)}
|
||||
{!tinfoilModelsLoading && tinfoilModelsError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("reasoning.custom.unableToLoadModels")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "./ui/useToast";
|
||||
import {
|
||||
consumeTinfoilModelSwitches,
|
||||
useTinfoilModelSwitchStore,
|
||||
} from "../stores/tinfoilModelSwitchStore";
|
||||
|
||||
const isDictationPanelWindow = () => {
|
||||
const { search, pathname } = window.location;
|
||||
return !pathname.includes("control") && !search.includes("panel=true");
|
||||
};
|
||||
|
||||
/** Alerts the user when a retired Tinfoil model was switched out from under them. */
|
||||
export default function TinfoilModelSwitchToastListener() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const switchCount = useTinfoilModelSwitchStore((s) => s.events.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (switchCount === 0) return;
|
||||
// The panel may already be hidden after dictation; surface it so the toast is seen.
|
||||
if (isDictationPanelWindow()) {
|
||||
window.electronAPI?.showDictationPanel?.();
|
||||
}
|
||||
for (const event of consumeTinfoilModelSwitches()) {
|
||||
toast({
|
||||
title: t("reasoning.tinfoil.modelRetiredTitle"),
|
||||
description: t("reasoning.tinfoil.modelRetired", { from: event.from, to: event.to }),
|
||||
});
|
||||
}
|
||||
}, [switchCount, toast, t]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const CortiStreaming = require("./cortiStreaming");
|
||||
const OpenAIRealtimeStreaming = require("./openaiRealtimeStreaming");
|
||||
const { getCortiToken } = require("./cortiAuth");
|
||||
const { createTinfoilRealtimeSocket } = require("./tinfoilSecureClient");
|
||||
const { getTinfoilChatModels } = require("./tinfoilCatalog");
|
||||
const AudioStorageManager = require("./audioStorage");
|
||||
|
||||
// Tinfoil's only realtime STT model — fallback when the renderer omits one.
|
||||
@@ -2786,6 +2787,10 @@ class IPCHandlers {
|
||||
return this.environmentManager.saveTinfoilKey(key);
|
||||
});
|
||||
|
||||
ipcMain.handle("get-tinfoil-chat-models", async () => {
|
||||
return getTinfoilChatModels();
|
||||
});
|
||||
|
||||
ipcMain.handle("get-custom-transcription-key", async () => {
|
||||
return this.environmentManager.getCustomTranscriptionKey();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Tinfoil's model list comes from the attested endpoint.
|
||||
// Fetching from the main process rather than the renderer lets every window share one request.
|
||||
const debugLogger = require("./debugLogger");
|
||||
const { tinfoilSecureFetch } = require("./tinfoilSecureClient");
|
||||
|
||||
const TINFOIL_MODELS_PATH = "/v1/models";
|
||||
const RETRY_AFTER_FAILURE_MS = 60 * 1000;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
let lastFailureAt = 0;
|
||||
let inFlight = null;
|
||||
|
||||
/**
|
||||
* The list mixes chat, audio, tts, embedding and tool models, so narrow it to
|
||||
* the ones we can send a chat completion to.
|
||||
*/
|
||||
function isChatModel(model) {
|
||||
if (!model || model.type !== "chat") return false;
|
||||
return Array.isArray(model.endpoints) && model.endpoints.includes("/v1/chat/completions");
|
||||
}
|
||||
|
||||
function toCatalogModel(model) {
|
||||
return {
|
||||
id: model.id,
|
||||
name: typeof model.name === "string" && model.name ? model.name : model.id,
|
||||
description: typeof model.description === "string" ? model.description : "",
|
||||
supportsThinking: model.reasoning === true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseModels(payload) {
|
||||
const data = payload?.data;
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("Malformed models list");
|
||||
}
|
||||
return data
|
||||
.filter((model) => typeof model?.id === "string" && model.id && isChatModel(model))
|
||||
.map(toCatalogModel);
|
||||
}
|
||||
|
||||
async function fetchModels() {
|
||||
// The first call also attests the enclave, so allow for more than a plain GET.
|
||||
const response = await tinfoilSecureFetch(TINFOIL_MODELS_PATH, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Tinfoil models request failed: ${response.status}`);
|
||||
}
|
||||
return parseModels(await response.json());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves to Tinfoil's chat models. Rejects when
|
||||
* the fetch fails so callers can tell "Tinfoil says this model is gone" apart
|
||||
* from "we couldn't ask".
|
||||
*/
|
||||
async function getTinfoilChatModels() {
|
||||
if (Date.now() - lastFailureAt < RETRY_AFTER_FAILURE_MS) {
|
||||
throw new Error("Tinfoil models unavailable");
|
||||
}
|
||||
|
||||
if (!inFlight) {
|
||||
inFlight = fetchModels()
|
||||
.then((models) => {
|
||||
lastFailureAt = 0;
|
||||
return models;
|
||||
})
|
||||
.catch((error) => {
|
||||
lastFailureAt = Date.now();
|
||||
debugLogger.warn("Failed to fetch Tinfoil models", { error: error.message });
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
module.exports = { getTinfoilChatModels };
|
||||
@@ -1,6 +1,6 @@
|
||||
// WebSocket access to Tinfoil's realtime transcription endpoint. The SDK's
|
||||
// SecureClient attests the enclave and pins the socket's TLS connection to the
|
||||
// attested key; one client is held per session so attestation is paid once.
|
||||
// Attested access to Tinfoil.
|
||||
// The SDK's SecureClient verifies the enclave and pins the connection to the attested key;
|
||||
// one client is held per session so attestation is paid once and shared by every caller.
|
||||
let clientPromise = null;
|
||||
|
||||
function getSecureClient() {
|
||||
@@ -23,4 +23,10 @@ async function createTinfoilRealtimeSocket({ model, apiKey }) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createTinfoilRealtimeSocket };
|
||||
/** Fetches an enclave path over the attested transport. Needs no API key. */
|
||||
async function tinfoilSecureFetch(path, init) {
|
||||
const client = await getSecureClient();
|
||||
return client.fetch(path, init);
|
||||
}
|
||||
|
||||
module.exports = { createTinfoilRealtimeSocket, tinfoilSecureFetch };
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getTinfoilModels, type CloudModelDefinition } from "../models/ModelRegistry";
|
||||
import { isTinfoilListFresh, refreshTinfoilModels } from "../models/tinfoilModels";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
interface UseTinfoilModelsResult {
|
||||
models: CloudModelDefinition[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes Tinfoil's model list into the registry and re-renders with it. Pass
|
||||
* `enabled` only while the user is looking at Tinfoil, so nobody else's settings
|
||||
* visit reaches out to it. The registry keeps its last known list when the fetch
|
||||
* fails, so the picker stays usable offline.
|
||||
*/
|
||||
export function useTinfoilModels(enabled: boolean): UseTinfoilModelsResult {
|
||||
const [models, setModels] = useState<CloudModelDefinition[]>(() => getTinfoilModels());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
// A list we fetched within the hour resolves without touching the network,
|
||||
// so don't flash a "refreshing" state the user can't even read.
|
||||
const willFetch = !isTinfoilListFresh();
|
||||
|
||||
if (willFetch && isMountedRef.current) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
try {
|
||||
const fresh = await refreshTinfoilModels();
|
||||
if (isMountedRef.current) {
|
||||
setModels(fresh);
|
||||
}
|
||||
} catch (err) {
|
||||
// Leave the known list in place: without an answer we can't tell a
|
||||
// retired model from an unreachable endpoint.
|
||||
logger.error("Failed to load Tinfoil models", { error: err }, "models");
|
||||
if (isMountedRef.current) {
|
||||
setError((err as Error).message || "Unable to load models");
|
||||
}
|
||||
} finally {
|
||||
if (willFetch && isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
refresh();
|
||||
}, [enabled, refresh]);
|
||||
|
||||
return { models, loading, error, refresh };
|
||||
}
|
||||
@@ -498,6 +498,11 @@
|
||||
"adcHelp": "Verwendet Application Default Credentials. Führen Sie aus: gcloud auth application-default login",
|
||||
"apikeyHelp": "API-Schlüssel für Vertex AI Express Mode aus Google AI Studio."
|
||||
}
|
||||
},
|
||||
"tinfoil": {
|
||||
"modelRetiredTitle": "Modell nicht mehr verfügbar",
|
||||
"modelRetired": "Tinfoil hat {{from}} eingestellt. Zu {{to}} gewechselt.",
|
||||
"refreshingModels": "Modelle werden aktualisiert…"
|
||||
}
|
||||
},
|
||||
"transcription": {
|
||||
|
||||
@@ -498,6 +498,11 @@
|
||||
"adcHelp": "Uses Application Default Credentials. Run: gcloud auth application-default login",
|
||||
"apikeyHelp": "Vertex AI Express Mode API key from Google AI Studio."
|
||||
}
|
||||
},
|
||||
"tinfoil": {
|
||||
"modelRetiredTitle": "Model no longer available",
|
||||
"modelRetired": "Tinfoil retired {{from}}. Switched to {{to}}.",
|
||||
"refreshingModels": "Refreshing models…"
|
||||
}
|
||||
},
|
||||
"transcription": {
|
||||
|
||||
@@ -498,6 +498,11 @@
|
||||
"adcHelp": "Usa Application Default Credentials. Ejecuta: gcloud auth application-default login",
|
||||
"apikeyHelp": "Clave API de Vertex AI Express Mode obtenida en Google AI Studio."
|
||||
}
|
||||
},
|
||||
"tinfoil": {
|
||||
"modelRetiredTitle": "Modelo ya no disponible",
|
||||
"modelRetired": "Tinfoil retiró {{from}}. Se cambió a {{to}}.",
|
||||
"refreshingModels": "Actualizando modelos…"
|
||||
}
|
||||
},
|
||||
"transcription": {
|
||||
|
||||
@@ -498,6 +498,11 @@
|
||||
"adcHelp": "Utilise Application Default Credentials. Exécutez : gcloud auth application-default login",
|
||||
"apikeyHelp": "Clé API Vertex AI Express Mode obtenue depuis Google AI Studio."
|
||||
}
|
||||
},
|
||||
"tinfoil": {
|
||||
"modelRetiredTitle": "Modèle non disponible",
|
||||
"modelRetired": "Tinfoil a retiré {{from}}. Basculé vers {{to}}.",
|
||||
"refreshingModels": "Actualisation des modèles…"
|
||||
}
|
||||
},
|
||||
"transcription": {
|
||||
|
||||
@@ -498,6 +498,11 @@
|
||||
"adcHelp": "Usa Application Default Credentials. Esegui: gcloud auth application-default login",
|
||||
"apikeyHelp": "Chiave API Vertex AI Express Mode da Google AI Studio."
|
||||
}
|
||||
},
|
||||
"tinfoil": {
|
||||
"modelRetiredTitle": "Modello non più disponibile",
|
||||
"modelRetired": "Tinfoil ha ritirato {{from}}. Passato a {{to}}.",
|
||||
"refreshingModels": "Aggiornamento dei modelli…"
|
||||
}
|
||||
},
|
||||
"transcription": {
|
||||
|
||||
@@ -498,6 +498,11 @@
|
||||
"adcHelp": "Application Default Credentials を使用します。次を実行してください: gcloud auth application-default login",
|
||||
"apikeyHelp": "Google AI Studio で取得した Vertex AI Express Mode の API キー。"
|
||||
}
|
||||
},
|
||||
"tinfoil": {
|
||||
"modelRetiredTitle": "モデルが利用できません",
|
||||
"modelRetired": "Tinfoil は {{from}} を提供終了しました。{{to}} に切り替えました。",
|
||||
"refreshingModels": "モデルを更新しています…"
|
||||
}
|
||||
},
|
||||
"transcription": {
|
||||
|
||||
@@ -477,6 +477,11 @@
|
||||
"adcHelp": "Usa Application Default Credentials. Execute: gcloud auth application-default login",
|
||||
"apikeyHelp": "Chave API do Vertex AI Express Mode obtida no Google AI Studio."
|
||||
}
|
||||
},
|
||||
"tinfoil": {
|
||||
"modelRetiredTitle": "Modelo não disponível",
|
||||
"modelRetired": "A Tinfoil descontinuou {{from}}. Alterado para {{to}}.",
|
||||
"refreshingModels": "A atualizar modelos…"
|
||||
}
|
||||
},
|
||||
"transcription": {
|
||||
|
||||
@@ -498,6 +498,11 @@
|
||||
"adcHelp": "Использует Application Default Credentials. Выполните: gcloud auth application-default login",
|
||||
"apikeyHelp": "API-ключ Vertex AI Express Mode из Google AI Studio."
|
||||
}
|
||||
},
|
||||
"tinfoil": {
|
||||
"modelRetiredTitle": "Модель больше недоступна",
|
||||
"modelRetired": "Tinfoil прекратил поддержку {{from}}. Выполнен переход на {{to}}.",
|
||||
"refreshingModels": "Обновление моделей…"
|
||||
}
|
||||
},
|
||||
"transcription": {
|
||||
|
||||
@@ -498,6 +498,11 @@
|
||||
"adcHelp": "使用 Application Default Credentials。请执行:gcloud auth application-default login",
|
||||
"apikeyHelp": "从 Google AI Studio 获取的 Vertex AI Express Mode API 密钥。"
|
||||
}
|
||||
},
|
||||
"tinfoil": {
|
||||
"modelRetiredTitle": "模型已不可用",
|
||||
"modelRetired": "Tinfoil 已停用 {{from}},已切换至 {{to}}。",
|
||||
"refreshingModels": "正在刷新模型…"
|
||||
}
|
||||
},
|
||||
"transcription": {
|
||||
|
||||
@@ -498,6 +498,11 @@
|
||||
"adcHelp": "使用 Application Default Credentials。請執行:gcloud auth application-default login",
|
||||
"apikeyHelp": "從 Google AI Studio 取得的 Vertex AI Express Mode API 金鑰。"
|
||||
}
|
||||
},
|
||||
"tinfoil": {
|
||||
"modelRetiredTitle": "模型已無法使用",
|
||||
"modelRetired": "Tinfoil 已停用 {{from}},已切換至 {{to}}。",
|
||||
"refreshingModels": "正在重新整理模型…"
|
||||
}
|
||||
},
|
||||
"transcription": {
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import AppRouter from "./AppRouter.jsx";
|
||||
import ErrorBoundary from "./components/ErrorBoundary.tsx";
|
||||
import TinfoilModelSwitchToastListener from "./components/TinfoilModelSwitchToastListener.tsx";
|
||||
import { ToastProvider } from "./components/ui/Toast.tsx";
|
||||
import { SettingsProvider } from "./hooks/useSettings";
|
||||
|
||||
@@ -16,6 +17,7 @@ root.render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<SettingsProvider>
|
||||
<ToastProvider>
|
||||
<TinfoilModelSwitchToastListener />
|
||||
<AppRouter />
|
||||
</ToastProvider>
|
||||
</SettingsProvider>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import modelDataRaw from "./modelRegistryData.json";
|
||||
import { isCloudCleanupMode, getSettings } from "../stores/settingsStore";
|
||||
import { readCachedTinfoilModels } from "./tinfoilModelCache";
|
||||
|
||||
export interface ModelDefinition {
|
||||
id: string;
|
||||
@@ -114,6 +115,18 @@ interface ModelRegistryData {
|
||||
|
||||
const modelData: ModelRegistryData = modelDataRaw as ModelRegistryData;
|
||||
|
||||
function getTinfoilCloudProvider(): CloudProviderData | undefined {
|
||||
return modelData.cloudProviders.find((provider) => provider.id === "tinfoil");
|
||||
}
|
||||
|
||||
const cachedTinfoilModels = readCachedTinfoilModels().models;
|
||||
if (cachedTinfoilModels.length > 0) {
|
||||
const tinfoilProvider = getTinfoilCloudProvider();
|
||||
if (tinfoilProvider) {
|
||||
tinfoilProvider.models = cachedTinfoilModels;
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptFormatter(template: string): (text: string, systemPrompt: string) => string {
|
||||
return (text: string, systemPrompt: string) => {
|
||||
return template.replace("{system}", systemPrompt).replace("{user}", text);
|
||||
@@ -221,18 +234,22 @@ export function isEnterpriseProvider(value: unknown): value is EnterpriseProvide
|
||||
return typeof value === "string" && (ENTERPRISE_PROVIDERS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function toReasoningModel(m: CloudModelDefinition): ReasoningModel {
|
||||
return {
|
||||
value: m.id,
|
||||
label: m.name,
|
||||
description: m.description,
|
||||
descriptionKey: m.descriptionKey,
|
||||
};
|
||||
}
|
||||
|
||||
function buildReasoningProviders(): ReasoningProviders {
|
||||
const providers: ReasoningProviders = {};
|
||||
|
||||
for (const cloudProvider of modelRegistry.getCloudProviders()) {
|
||||
providers[cloudProvider.id] = {
|
||||
name: cloudProvider.name,
|
||||
models: cloudProvider.models.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.name,
|
||||
description: m.description,
|
||||
descriptionKey: m.descriptionKey,
|
||||
})),
|
||||
models: cloudProvider.models.map(toReasoningModel),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,6 +280,21 @@ function buildReasoningProviders(): ReasoningProviders {
|
||||
|
||||
export const REASONING_PROVIDERS = buildReasoningProviders();
|
||||
|
||||
export function getTinfoilModels(): CloudModelDefinition[] {
|
||||
return getTinfoilCloudProvider()?.models ?? [];
|
||||
}
|
||||
|
||||
export function applyTinfoilModels(models: CloudModelDefinition[]): void {
|
||||
const provider = getTinfoilCloudProvider();
|
||||
if (provider) {
|
||||
provider.models = models;
|
||||
}
|
||||
const reasoningProvider = REASONING_PROVIDERS.tinfoil;
|
||||
if (reasoningProvider) {
|
||||
reasoningProvider.models = models.map(toReasoningModel);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReasoningModelWithProvider extends ReasoningModel {
|
||||
provider: string;
|
||||
fullLabel: string;
|
||||
@@ -301,6 +333,9 @@ export function getModelProvider(modelId: string): string {
|
||||
const model = getAllReasoningModels().find((m) => m.value === modelId);
|
||||
|
||||
if (!model) {
|
||||
// An unknown model here is one Tinfoil retired or added since our last
|
||||
// sync — don't let the id heuristics misroute it.
|
||||
if (storedProvider === "tinfoil") return "tinfoil";
|
||||
if (modelId.includes("claude")) return "anthropic";
|
||||
if (modelId.includes("gemini") && !modelId.includes("gemma")) return "gemini";
|
||||
if ((modelId.includes("gpt-4") || modelId.includes("gpt-5")) && !modelId.includes("gpt-oss"))
|
||||
|
||||
@@ -457,12 +457,12 @@
|
||||
"name": "Tinfoil",
|
||||
"models": [
|
||||
{
|
||||
"id": "deepseek-v4-pro",
|
||||
"name": "DeepSeek V4 Pro",
|
||||
"description": "Private long-context reasoning in secure enclaves, 800K context",
|
||||
"id": "kimi-k2-6",
|
||||
"name": "Kimi K2.6",
|
||||
"description": "Private long-horizon coding and multimodal model, 256K context",
|
||||
"tokenParam": "max_tokens",
|
||||
"supportsTemperature": true,
|
||||
"descriptionKey": "models.descriptions.cloud.tinfoil_deepseek_v4_pro",
|
||||
"descriptionKey": "models.descriptions.cloud.tinfoil_kimi_k2_6",
|
||||
"supportsThinking": true
|
||||
},
|
||||
{
|
||||
@@ -475,12 +475,12 @@
|
||||
"supportsThinking": true
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2-6",
|
||||
"name": "Kimi K2.6",
|
||||
"description": "Private long-horizon coding and multimodal model, 256K context",
|
||||
"id": "gpt-oss-120b",
|
||||
"name": "GPT-OSS 120B",
|
||||
"description": "Private open-weight reasoning model with tool use, 131K context",
|
||||
"tokenParam": "max_tokens",
|
||||
"supportsTemperature": true,
|
||||
"descriptionKey": "models.descriptions.cloud.tinfoil_kimi_k2_6",
|
||||
"descriptionKey": "models.descriptions.cloud.tinfoil_gpt_oss_120b",
|
||||
"supportsThinking": true
|
||||
},
|
||||
{
|
||||
@@ -491,23 +491,6 @@
|
||||
"supportsTemperature": true,
|
||||
"descriptionKey": "models.descriptions.cloud.tinfoil_gemma4_31b",
|
||||
"supportsThinking": true
|
||||
},
|
||||
{
|
||||
"id": "gpt-oss-120b",
|
||||
"name": "GPT-OSS 120B",
|
||||
"description": "Private open-weight reasoning model with tool use, 131K context",
|
||||
"tokenParam": "max_tokens",
|
||||
"supportsTemperature": true,
|
||||
"descriptionKey": "models.descriptions.cloud.tinfoil_gpt_oss_120b",
|
||||
"supportsThinking": true
|
||||
},
|
||||
{
|
||||
"id": "llama3-3-70b",
|
||||
"name": "Llama 3.3 70B",
|
||||
"description": "Private conversational model with tool calling, 128K context",
|
||||
"tokenParam": "max_tokens",
|
||||
"supportsTemperature": true,
|
||||
"descriptionKey": "models.descriptions.cloud.tinfoil_llama3_3_70b"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { CloudModelDefinition } from "./ModelRegistry";
|
||||
|
||||
const CACHE_KEY = "tinfoilModels";
|
||||
const MAX_AGE_MS = 60 * 60 * 1000;
|
||||
|
||||
const isBrowser = typeof window !== "undefined";
|
||||
|
||||
export interface CachedTinfoilModels {
|
||||
models: CloudModelDefinition[];
|
||||
/** Epoch ms of the fetch that produced `models`, or 0 if we've never fetched. */
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
const EMPTY: CachedTinfoilModels = { models: [], fetchedAt: 0 };
|
||||
|
||||
export function readCachedTinfoilModels(): CachedTinfoilModels {
|
||||
if (!isBrowser) return EMPTY;
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
if (!raw) return EMPTY;
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed?.models) || typeof parsed.fetchedAt !== "number") return EMPTY;
|
||||
return { models: parsed.models, fetchedAt: parsed.fetchedAt };
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedTinfoilModels(models: CloudModelDefinition[]): void {
|
||||
if (!isBrowser) return;
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify({ models, fetchedAt: Date.now() }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function isCachedListFresh(cached: CachedTinfoilModels): boolean {
|
||||
return cached.models.length > 0 && Date.now() - cached.fetchedAt < MAX_AGE_MS;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { applyTinfoilModels, getTinfoilModels, type CloudModelDefinition } from "./ModelRegistry";
|
||||
import {
|
||||
isCachedListFresh,
|
||||
readCachedTinfoilModels,
|
||||
writeCachedTinfoilModels,
|
||||
} from "./tinfoilModelCache";
|
||||
import { INFERENCE_SCOPES } from "../config/inferenceScopes";
|
||||
import { getSettings, setStringSetting } from "../stores/settingsStore";
|
||||
import { recordTinfoilModelSwitch } from "../stores/tinfoilModelSwitchStore";
|
||||
|
||||
/** A chat model as reported by Tinfoil's /v1/models, narrowed by the main process. */
|
||||
export interface TinfoilCatalogModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
supportsThinking: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated, translated descriptions keyed by model id. The endpoint only returns
|
||||
* English copy.
|
||||
*/
|
||||
const DESCRIPTION_KEYS: Record<string, string> = {
|
||||
"deepseek-v4-pro": "models.descriptions.cloud.tinfoil_deepseek_v4_pro",
|
||||
"glm-5-2": "models.descriptions.cloud.tinfoil_glm_5_2",
|
||||
"kimi-k2-6": "models.descriptions.cloud.tinfoil_kimi_k2_6",
|
||||
"gemma4-31b": "models.descriptions.cloud.tinfoil_gemma4_31b",
|
||||
"gpt-oss-120b": "models.descriptions.cloud.tinfoil_gpt_oss_120b",
|
||||
"llama3-3-70b": "models.descriptions.cloud.tinfoil_llama3_3_70b",
|
||||
};
|
||||
|
||||
function toCloudModels(catalog: TinfoilCatalogModel[]): CloudModelDefinition[] {
|
||||
return catalog.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
descriptionKey: DESCRIPTION_KEYS[model.id],
|
||||
supportsThinking: model.supportsThinking,
|
||||
tokenParam: "max_tokens",
|
||||
supportsTemperature: true,
|
||||
}));
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL_ID = "glm-5-2";
|
||||
|
||||
export function pickDefaultTinfoilModel(
|
||||
models: CloudModelDefinition[]
|
||||
): CloudModelDefinition | undefined {
|
||||
return models.find((model) => model.id === DEFAULT_MODEL_ID) ?? models[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves any scope still pointing at a model Tinfoil has retired onto one it
|
||||
* still serves — otherwise the next request is a 404 the user can't diagnose.
|
||||
* Only ever called after a successful fetch, so a missing model really is gone.
|
||||
*/
|
||||
function reconcileSelectedModels(
|
||||
previous: CloudModelDefinition[],
|
||||
models: CloudModelDefinition[]
|
||||
): void {
|
||||
const available = new Set(models.map((model) => model.id));
|
||||
const settings = getSettings() as unknown as Record<string, unknown>;
|
||||
const replacement = pickDefaultTinfoilModel(models);
|
||||
if (!replacement) return;
|
||||
const announced = new Set<string>();
|
||||
|
||||
for (const scope of Object.values(INFERENCE_SCOPES)) {
|
||||
const { provider, model } = scope.storeKeys;
|
||||
if (settings[provider] !== "tinfoil") continue;
|
||||
|
||||
const selected = settings[model];
|
||||
if (typeof selected !== "string" || !selected || available.has(selected)) continue;
|
||||
|
||||
setStringSetting(model, replacement.id);
|
||||
// Several scopes can share a retired model; say so once.
|
||||
if (announced.has(selected)) continue;
|
||||
announced.add(selected);
|
||||
recordTinfoilModelSwitch({
|
||||
from: previous.find((m) => m.id === selected)?.name ?? selected,
|
||||
to: replacement.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let inFlight: Promise<CloudModelDefinition[]> | null = null;
|
||||
|
||||
async function fetchAndApply(): Promise<CloudModelDefinition[]> {
|
||||
const fetchModels = window.electronAPI?.getTinfoilChatModels;
|
||||
if (!fetchModels) {
|
||||
throw new Error("Tinfoil model list is unavailable");
|
||||
}
|
||||
|
||||
const models = toCloudModels(await fetchModels());
|
||||
if (models.length === 0) {
|
||||
// Far more likely something upstream broke, so keep what we already have.
|
||||
throw new Error("Tinfoil returned no chat models");
|
||||
}
|
||||
|
||||
const previous = getTinfoilModels();
|
||||
applyTinfoilModels(models);
|
||||
writeCachedTinfoilModels(models);
|
||||
reconcileSelectedModels(previous, models);
|
||||
return models;
|
||||
}
|
||||
|
||||
/** Whether refreshTinfoilModels would hit the network rather than short-circuit. */
|
||||
export function isTinfoilListFresh(): boolean {
|
||||
return isCachedListFresh(readCachedTinfoilModels());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls Tinfoil's model list into the registry, at most once an hour. Cheap to
|
||||
* call before every request: a list we fetched recently short-circuits without
|
||||
* touching the network, and the timestamp is persisted so a restart doesn't
|
||||
* refetch a list we pulled a minute ago. Rejects when Tinfoil can't be reached,
|
||||
* leaving the registry as it was.
|
||||
*/
|
||||
export function refreshTinfoilModels(): Promise<CloudModelDefinition[]> {
|
||||
const cached = readCachedTinfoilModels();
|
||||
if (isCachedListFresh(cached)) {
|
||||
// Another window may have fetched this; make sure our registry has it too.
|
||||
applyTinfoilModels(cached.models);
|
||||
return Promise.resolve(cached.models);
|
||||
}
|
||||
|
||||
if (!inFlight) {
|
||||
inFlight = fetchAndApply().finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
return inFlight;
|
||||
}
|
||||
@@ -591,11 +591,11 @@ class ReasoningService extends BaseReasoningService {
|
||||
baseURL =
|
||||
provider === "custom" ? config.baseUrl?.trim() || getConfiguredOpenAIBase() : undefined;
|
||||
}
|
||||
const apiConfig = getOpenAiApiConfig(model);
|
||||
|
||||
const aiProvider = isLocalProvider || isLanCleanup ? "local" : provider;
|
||||
// Resolving a Tinfoil model refreshes the registry, so read model config after it.
|
||||
const aiModel = await getAIModel(aiProvider, model, apiKey, baseURL);
|
||||
|
||||
const apiConfig = getOpenAiApiConfig(model);
|
||||
const modelDef = getCloudModel(model);
|
||||
const userSuppressesThinking = config.disableThinking === true && !!modelDef?.supportsThinking;
|
||||
const needsGroqDisableThinking =
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { InferenceProvider } from "./types";
|
||||
import { TOKEN_LIMITS } from "../../../config/constants";
|
||||
import { getOpenAiApiConfig } from "../../../models/ModelRegistry";
|
||||
import { withRetry, createApiRetryStrategy } from "../../../utils/retry";
|
||||
import logger from "../../../utils/logger";
|
||||
import { applyThinkingSuppression } from "../thinkingSuppression";
|
||||
@@ -26,7 +25,6 @@ export const tinfoilProvider: InferenceProvider = {
|
||||
{ role: "user", content: userContent },
|
||||
];
|
||||
|
||||
const apiConfig = getOpenAiApiConfig(model);
|
||||
const maxTokens =
|
||||
config.maxTokens ||
|
||||
Math.max(
|
||||
@@ -42,13 +40,10 @@ export const tinfoilProvider: InferenceProvider = {
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model,
|
||||
messages,
|
||||
[apiConfig.tokenParam]: maxTokens,
|
||||
max_tokens: maxTokens,
|
||||
temperature: config.temperature ?? (config.systemPrompt ? 0.3 : 0),
|
||||
};
|
||||
|
||||
if (apiConfig.supportsTemperature) {
|
||||
requestBody.temperature = config.temperature ?? (config.systemPrompt ? 0.3 : 0);
|
||||
}
|
||||
|
||||
applyThinkingSuppression(requestBody, model, "tinfoil", config);
|
||||
|
||||
// 30s per attempt like sibling providers; SDK-internal retries off so
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LanguageModel } from "ai";
|
||||
import type { TinfoilAI } from "tinfoil";
|
||||
import { refreshTinfoilModels } from "../../models/tinfoilModels";
|
||||
|
||||
type TinfoilModule = typeof import("tinfoil");
|
||||
type TinfoilAISDKProvider = Awaited<ReturnType<TinfoilModule["createTinfoilAI"]>>;
|
||||
@@ -28,8 +29,14 @@ function normalizeApiKey(apiKey: string): string {
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Tinfoil adds and retires models often; sync the registry in the background. */
|
||||
function syncTinfoilCatalog(): void {
|
||||
void refreshTinfoilModels().catch(() => {});
|
||||
}
|
||||
|
||||
export async function getTinfoilChatClient(apiKey: string): Promise<TinfoilAI> {
|
||||
const key = normalizeApiKey(apiKey);
|
||||
syncTinfoilCatalog();
|
||||
const cached = chatClientCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -50,6 +57,7 @@ export async function getTinfoilChatClient(apiKey: string): Promise<TinfoilAI> {
|
||||
|
||||
async function getTinfoilAISDKProvider(apiKey: string): Promise<TinfoilAISDKProvider> {
|
||||
const key = normalizeApiKey(apiKey);
|
||||
syncTinfoilCatalog();
|
||||
const cached = aiSdkProviderCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import reasoningService from "../services/ReasoningService";
|
||||
import { getSettings } from "./settingsStore";
|
||||
import { getSettings, selectResolvedNoteFormatting } from "./settingsStore";
|
||||
import { appendDictionarySuffix } from "../config/prompts";
|
||||
import { generateNoteTitle } from "../utils/generateTitle";
|
||||
import type { ActionItem } from "../types/electron";
|
||||
@@ -123,7 +123,18 @@ export function runBackgroundAction(
|
||||
try {
|
||||
const basePrompt = options.isMeetingNote ? MEETING_SYSTEM_PROMPT : BASE_SYSTEM_PROMPT;
|
||||
const settings = getSettings();
|
||||
const provider = options.isCloudMode ? "openwhispr" : undefined;
|
||||
const noteFormatting = selectResolvedNoteFormatting(settings);
|
||||
const provider = options.isCloudMode
|
||||
? "openwhispr"
|
||||
: noteFormatting.mode === "providers"
|
||||
? noteFormatting.provider || undefined
|
||||
: undefined;
|
||||
const isCustom = provider === "custom";
|
||||
const providerOverrides = {
|
||||
provider,
|
||||
baseUrl: isCustom ? noteFormatting.cloudBaseUrl || undefined : undefined,
|
||||
customApiKey: isCustom ? settings.noteFormattingCustomApiKey || undefined : undefined,
|
||||
};
|
||||
const systemPrompt = appendDictionarySuffix(
|
||||
basePrompt + action.prompt,
|
||||
options.isMeetingNote ? settings.customDictionary : undefined,
|
||||
@@ -133,14 +144,14 @@ export function runBackgroundAction(
|
||||
systemPrompt,
|
||||
temperature: 0.3,
|
||||
disableThinking: settings.noteFormattingDisableThinking,
|
||||
provider,
|
||||
...providerOverrides,
|
||||
});
|
||||
|
||||
if (cancelledFlags.get(noteId)) return;
|
||||
|
||||
let title: string | undefined;
|
||||
if (getSettings().autoGenerateNoteTitle) {
|
||||
const generated = await generateNoteTitle(enhanced, modelId, provider);
|
||||
const generated = await generateNoteTitle(enhanced, modelId, providerOverrides);
|
||||
if (generated) title = generated;
|
||||
}
|
||||
|
||||
|
||||
@@ -680,6 +680,11 @@ function createStringSetter(key: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes a string setting whose key is computed rather than known up front. */
|
||||
export function setStringSetting(key: keyof SettingsState, value: string): void {
|
||||
createStringSetter(key)(value);
|
||||
}
|
||||
|
||||
function createBooleanSetter(key: string) {
|
||||
return (value: boolean) => {
|
||||
if (isBrowser) localStorage.setItem(key, String(value));
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface TinfoilModelSwitch {
|
||||
/** Display name of the model Tinfoil retired. */
|
||||
from: string;
|
||||
/** Display name of the model we moved the user to. */
|
||||
to: string;
|
||||
}
|
||||
|
||||
interface TinfoilModelSwitchState {
|
||||
events: TinfoilModelSwitch[];
|
||||
}
|
||||
|
||||
export const useTinfoilModelSwitchStore = create<TinfoilModelSwitchState>(() => ({
|
||||
events: [],
|
||||
}));
|
||||
|
||||
export function recordTinfoilModelSwitch(event: TinfoilModelSwitch): void {
|
||||
useTinfoilModelSwitchStore.setState((state) => ({ events: [...state.events, event] }));
|
||||
}
|
||||
|
||||
export function consumeTinfoilModelSwitches(): TinfoilModelSwitch[] {
|
||||
const { events } = useTinfoilModelSwitchStore.getState();
|
||||
if (events.length > 0) {
|
||||
useTinfoilModelSwitchStore.setState({ events: [] });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TinfoilCatalogModel } from "../models/tinfoilModels";
|
||||
|
||||
export type LocalTranscriptionProvider = "whisper" | "nvidia";
|
||||
|
||||
export type InferenceMode = "openwhispr" | "providers" | "local" | "self-hosted" | "enterprise";
|
||||
@@ -1032,6 +1034,7 @@ declare global {
|
||||
}) => Promise<{ text: string }>;
|
||||
getTinfoilKey?: () => Promise<string | null>;
|
||||
saveTinfoilKey?: (key: string) => Promise<void>;
|
||||
getTinfoilChatModels?: () => Promise<TinfoilCatalogModel[]>;
|
||||
|
||||
// Custom endpoint API keys
|
||||
getCustomTranscriptionKey?: () => Promise<string | null>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import reasoningService from "../services/ReasoningService";
|
||||
import type { ReasoningConfig } from "../services/BaseReasoningService";
|
||||
import { getSettings } from "../stores/settingsStore";
|
||||
|
||||
const TITLE_SYSTEM_PROMPT =
|
||||
@@ -7,14 +8,14 @@ const TITLE_SYSTEM_PROMPT =
|
||||
export async function generateNoteTitle(
|
||||
text: string,
|
||||
modelId: string,
|
||||
provider?: string
|
||||
config?: Pick<ReasoningConfig, "provider" | "baseUrl" | "customApiKey">
|
||||
): Promise<string> {
|
||||
try {
|
||||
const raw = await reasoningService.processText(text.slice(0, 2000), modelId, null, {
|
||||
systemPrompt: TITLE_SYSTEM_PROMPT,
|
||||
temperature: 0.3,
|
||||
disableThinking: getSettings().noteFormattingDisableThinking,
|
||||
provider,
|
||||
...config,
|
||||
});
|
||||
const cleaned = raw.trim().replace(/^["']|["']$/g, "");
|
||||
return cleaned.length > 0 && cleaned.length < 100 ? cleaned : "";
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const Module = require("node:module");
|
||||
|
||||
const catalogModulePath = require.resolve("../../src/helpers/tinfoilCatalog.js");
|
||||
const originalLoad = Module._load;
|
||||
|
||||
function loadCatalog(fetchImpl) {
|
||||
delete require.cache[catalogModulePath];
|
||||
|
||||
Module._load = function loadWithMocks(request, parent, isMain) {
|
||||
if (request === "./tinfoilSecureClient") {
|
||||
return { tinfoilSecureFetch: fetchImpl };
|
||||
}
|
||||
if (request === "./debugLogger") {
|
||||
return { warn() {} };
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
try {
|
||||
return require(catalogModulePath);
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
}
|
||||
}
|
||||
|
||||
function okResponse(data) {
|
||||
return { ok: true, status: 200, json: async () => ({ object: "list", data }) };
|
||||
}
|
||||
|
||||
const CHAT_MODEL = {
|
||||
id: "glm-5-2",
|
||||
name: "GLM-5.2",
|
||||
description: "d",
|
||||
type: "chat",
|
||||
reasoning: true,
|
||||
endpoints: ["/v1/chat/completions"],
|
||||
};
|
||||
|
||||
test("keeps only chat models reachable at /v1/chat/completions", async () => {
|
||||
const { getTinfoilChatModels } = loadCatalog(async () =>
|
||||
okResponse([
|
||||
CHAT_MODEL,
|
||||
{ id: "voxtral", name: "Voxtral", type: "audio", endpoints: ["/v1/audio/transcriptions"] },
|
||||
{ id: "embed", name: "Embed", type: "embedding", endpoints: ["/v1/embeddings"] },
|
||||
{ id: "chat-no-endpoint", name: "Odd", type: "chat", endpoints: ["/v1/responses"] },
|
||||
])
|
||||
);
|
||||
|
||||
assert.deepEqual(await getTinfoilChatModels(), [
|
||||
{ id: "glm-5-2", name: "GLM-5.2", description: "d", supportsThinking: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports supportsThinking from the reasoning flag", async () => {
|
||||
const { getTinfoilChatModels } = loadCatalog(async () =>
|
||||
okResponse([{ ...CHAT_MODEL, id: "llama3-3-70b", name: "Llama", reasoning: false }])
|
||||
);
|
||||
|
||||
const [model] = await getTinfoilChatModels();
|
||||
assert.equal(model.supportsThinking, false);
|
||||
});
|
||||
|
||||
test("shares one in-flight request between concurrent callers", async () => {
|
||||
let calls = 0;
|
||||
const { getTinfoilChatModels } = loadCatalog(async () => {
|
||||
calls += 1;
|
||||
return okResponse([CHAT_MODEL]);
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([getTinfoilChatModels(), getTinfoilChatModels()]);
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(first, second);
|
||||
});
|
||||
|
||||
test("rejects rather than returning a list when the payload is malformed", async () => {
|
||||
const { getTinfoilChatModels } = loadCatalog(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ object: "list" }),
|
||||
}));
|
||||
|
||||
await assert.rejects(getTinfoilChatModels(), /Malformed models list/);
|
||||
});
|
||||
|
||||
test("rejects on a non-200 response", async () => {
|
||||
const { getTinfoilChatModels } = loadCatalog(async () => ({ ok: false, status: 503 }));
|
||||
|
||||
await assert.rejects(getTinfoilChatModels(), /503/);
|
||||
});
|
||||
|
||||
test("backs off after a failure instead of refetching on every call", async () => {
|
||||
let calls = 0;
|
||||
const { getTinfoilChatModels } = loadCatalog(async () => {
|
||||
calls += 1;
|
||||
throw new Error("ENOTFOUND");
|
||||
});
|
||||
|
||||
await assert.rejects(getTinfoilChatModels());
|
||||
await assert.rejects(getTinfoilChatModels(), /unavailable/);
|
||||
await assert.rejects(getTinfoilChatModels(), /unavailable/);
|
||||
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
Reference in New Issue
Block a user