Files
2026-07-13 13:08:41 +08:00

431 lines
13 KiB
TypeScript

import { getApiUrl } from "@/utils/api";
import { LLMConfig } from "@/types/llm_config";
const LOCALHOST_OLLAMA_URL = "http://localhost:11434";
const DOCKER_HOST_OLLAMA_URL = "http://host.docker.internal:11434";
const OLLAMA_MODELS_CACHE_TTL_MS = 30_000;
type OllamaModelsCacheEntry = {
expiresAt: number;
promise: Promise<AvailableOllamaModel[]>;
};
let ollamaLibraryModelsPromise: Promise<OllamaLibraryModel[]> | null = null;
const ollamaModelsCache = new Map<string, OllamaModelsCacheEntry>();
export interface OllamaModel {
label: string;
value: string;
size: string;
}
export interface AvailableOllamaModel {
name: string;
parameters: string | null;
size: number | null;
}
export interface OllamaLibraryModel {
name: string;
description: string;
parameters: string;
size: string;
}
export interface OllamaPullProgressEvent {
type: "status" | "progress" | "complete" | "error";
status?: string;
total?: number;
completed?: number;
progress?: number;
model?: string;
detail?: string;
}
export interface OllamaModelsResult {
models: OllamaModel[];
updatedConfig?: LLMConfig;
}
export interface ReachableOllamaModelsResult {
models: AvailableOllamaModel[];
resolvedUrl: string;
usedFallback: boolean;
}
function isElectronRuntime(): boolean {
return typeof window !== "undefined" && !!window.electron;
}
function normalizeOllamaUrl(url?: string): string {
return (url || "").trim().replace(/\/+$/, "");
}
function getOllamaModelsCacheKey(ollamaUrl?: string): string {
return normalizeOllamaUrl(ollamaUrl) || "__default__";
}
export function clearOllamaModelsCache(ollamaUrl?: string) {
if (ollamaUrl === undefined) {
ollamaModelsCache.clear();
return;
}
ollamaModelsCache.delete(getOllamaModelsCacheKey(ollamaUrl));
}
export function getDefaultOllamaUrl(): string {
return isElectronRuntime() ? LOCALHOST_OLLAMA_URL : DOCKER_HOST_OLLAMA_URL;
}
/**
* Updates LLM configuration based on field changes
*/
export const updateLLMConfig = (
currentConfig: LLMConfig,
field: string,
value: string | boolean
): LLMConfig => {
const fieldMappings: Record<string, keyof LLMConfig> = {
openai_api_key: "OPENAI_API_KEY",
openai_model: "OPENAI_MODEL",
deepseek_api_key: "DEEPSEEK_API_KEY",
deepseek_model: "DEEPSEEK_MODEL",
deepseek_base_url: "DEEPSEEK_BASE_URL",
google_api_key: "GOOGLE_API_KEY",
google_model: "GOOGLE_MODEL",
vertex_api_key: "VERTEX_API_KEY",
vertex_model: "VERTEX_MODEL",
vertex_project: "VERTEX_PROJECT",
vertex_location: "VERTEX_LOCATION",
vertex_base_url: "VERTEX_BASE_URL",
azure_openai_api_key: "AZURE_OPENAI_API_KEY",
azure_openai_model: "AZURE_OPENAI_MODEL",
azure_openai_endpoint: "AZURE_OPENAI_ENDPOINT",
azure_openai_base_url: "AZURE_OPENAI_BASE_URL",
azure_openai_api_version: "AZURE_OPENAI_API_VERSION",
azure_openai_deployment: "AZURE_OPENAI_DEPLOYMENT",
bedrock_region: "BEDROCK_REGION",
bedrock_api_key: "BEDROCK_API_KEY",
bedrock_aws_access_key_id: "BEDROCK_AWS_ACCESS_KEY_ID",
bedrock_aws_secret_access_key: "BEDROCK_AWS_SECRET_ACCESS_KEY",
bedrock_aws_session_token: "BEDROCK_AWS_SESSION_TOKEN",
bedrock_profile_name: "BEDROCK_PROFILE_NAME",
bedrock_model: "BEDROCK_MODEL",
openrouter_api_key: "OPENROUTER_API_KEY",
openrouter_model: "OPENROUTER_MODEL",
openrouter_base_url: "OPENROUTER_BASE_URL",
fireworks_api_key: "FIREWORKS_API_KEY",
fireworks_model: "FIREWORKS_MODEL",
fireworks_base_url: "FIREWORKS_BASE_URL",
together_api_key: "TOGETHER_API_KEY",
together_model: "TOGETHER_MODEL",
together_base_url: "TOGETHER_BASE_URL",
cerebras_api_key: "CEREBRAS_API_KEY",
cerebras_model: "CEREBRAS_MODEL",
cerebras_base_url: "CEREBRAS_BASE_URL",
litellm_base_url: "LITELLM_BASE_URL",
litellm_api_key: "LITELLM_API_KEY",
litellm_model: "LITELLM_MODEL",
lmstudio_base_url: "LMSTUDIO_BASE_URL",
lmstudio_api_key: "LMSTUDIO_API_KEY",
lmstudio_model: "LMSTUDIO_MODEL",
anthropic_api_key: "ANTHROPIC_API_KEY",
anthropic_model: "ANTHROPIC_MODEL",
ollama_url: "OLLAMA_URL",
ollama_model: "OLLAMA_MODEL",
custom_llm_url: "CUSTOM_LLM_URL",
custom_llm_api_key: "CUSTOM_LLM_API_KEY",
custom_model: "CUSTOM_MODEL",
pexels_api_key: "PEXELS_API_KEY",
pixabay_api_key: "PIXABAY_API_KEY",
image_provider: "IMAGE_PROVIDER",
disable_image_generation: "DISABLE_IMAGE_GENERATION",
disable_thinking: "DISABLE_THINKING",
extended_reasoning: "EXTENDED_REASONING",
web_grounding: "WEB_GROUNDING",
web_search_provider: "WEB_SEARCH_PROVIDER",
web_search_max_results: "WEB_SEARCH_MAX_RESULTS",
searxng_base_url: "SEARXNG_BASE_URL",
tavily_api_key: "TAVILY_API_KEY",
exa_api_key: "EXA_API_KEY",
brave_search_api_key: "BRAVE_SEARCH_API_KEY",
serper_api_key: "SERPER_API_KEY",
comfyui_url: "COMFYUI_URL",
comfyui_workflow: "COMFYUI_WORKFLOW",
dall_e_3_quality: "DALL_E_3_QUALITY",
gpt_image_1_5_quality: "GPT_IMAGE_1_5_QUALITY",
open_webui_image_url: "OPEN_WEBUI_IMAGE_URL",
open_webui_image_api_key: "OPEN_WEBUI_IMAGE_API_KEY",
openai_compat_image_base_url: "OPENAI_COMPAT_IMAGE_BASE_URL",
openai_compat_image_api_key: "OPENAI_COMPAT_IMAGE_API_KEY",
openai_compat_image_model: "OPENAI_COMPAT_IMAGE_MODEL",
codex_model: "CODEX_MODEL",
};
const configKey = fieldMappings[field];
if (configKey) {
return { ...currentConfig, [configKey]: value };
}
return currentConfig;
};
/**
* Changes the provider and sets appropriate defaults
*/
export const changeProvider = (
currentConfig: LLMConfig,
provider: string
): LLMConfig => {
const newConfig = { ...currentConfig, LLM: provider };
if (provider === "ollama" && !newConfig.OLLAMA_URL?.trim()) {
newConfig.OLLAMA_URL = getDefaultOllamaUrl();
}
// Auto Select appropriate image provider based on the text models
if (provider === "openai") {
newConfig.IMAGE_PROVIDER = "gpt-image-1.5";
} else if (provider === "google") {
newConfig.IMAGE_PROVIDER = "gemini_flash";
} else {
newConfig.IMAGE_PROVIDER = "pexels";
}
return newConfig;
};
function getOllamaApiUrl(
path: string,
params: Record<string, string | undefined>
): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value?.trim()) searchParams.set(key, value.trim());
});
const query = searchParams.toString();
return getApiUrl(query ? `${path}?${query}` : path);
}
export const isOllamaModelAvailable = async (
ollamaModel: string,
ollamaUrl?: string
): Promise<boolean> => {
const { models } = await getReachableOllamaModels(ollamaUrl);
return models.some((model) => model.name === ollamaModel);
};
const fetchAvailableOllamaModels = async (
ollamaUrl?: string
): Promise<AvailableOllamaModel[]> => {
const normalizedUrl = normalizeOllamaUrl(ollamaUrl);
const response = await fetch(
getOllamaApiUrl("/api/v1/ppt/ollama/models/available", {
ollama_url: normalizedUrl,
})
);
if (!response.ok) {
throw new Error(await getApiErrorMessage(response, "Could not list Ollama models"));
}
const models: unknown = await response.json();
if (!Array.isArray(models)) {
throw new Error("Ollama returned an invalid model list");
}
return models.flatMap((model) => {
if (!model || typeof model !== "object" || !("name" in model)) return [];
const name = (model as { name?: unknown }).name;
const parameters = (model as { parameters?: unknown }).parameters;
const size = (model as { size?: unknown }).size;
if (typeof name !== "string") return [];
return [
{
name,
parameters:
typeof parameters === "string" &&
parameters.trim() &&
parameters.trim().toLowerCase() !== "unknown"
? parameters
: null,
size: typeof size === "number" ? size : null,
},
];
});
};
export const getAvailableOllamaModels = async (
ollamaUrl?: string
): Promise<AvailableOllamaModel[]> => {
const cacheKey = getOllamaModelsCacheKey(ollamaUrl);
const now = Date.now();
const cached = ollamaModelsCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.promise;
}
const promise = fetchAvailableOllamaModels(ollamaUrl);
ollamaModelsCache.set(cacheKey, {
expiresAt: now + OLLAMA_MODELS_CACHE_TTL_MS,
promise,
});
try {
return await promise;
} catch (error) {
ollamaModelsCache.delete(cacheKey);
throw error;
}
};
export const getReachableOllamaModels = async (
ollamaUrl?: string
): Promise<ReachableOllamaModelsResult> => {
const preferredUrl = normalizeOllamaUrl(ollamaUrl) || getDefaultOllamaUrl();
try {
const models = await getAvailableOllamaModels(preferredUrl);
return { models, resolvedUrl: preferredUrl, usedFallback: false };
} catch (error) {
const shouldTryLocalFallback =
!isElectronRuntime() && preferredUrl === DOCKER_HOST_OLLAMA_URL;
if (!shouldTryLocalFallback) {
throw error;
}
const models = await getAvailableOllamaModels(LOCALHOST_OLLAMA_URL);
return {
models,
resolvedUrl: LOCALHOST_OLLAMA_URL,
usedFallback: true,
};
}
};
async function getApiErrorMessage(
response: Response,
fallback: string
): Promise<string> {
try {
const body = await response.json();
if (typeof body?.detail === "string" && body.detail.trim()) {
return body.detail;
}
if (typeof body?.error === "string" && body.error.trim()) {
return body.error;
}
} catch {
}
return fallback;
}
export const getOllamaLibraryModels = async (): Promise<OllamaLibraryModel[]> => {
if (ollamaLibraryModelsPromise) {
return ollamaLibraryModelsPromise;
}
ollamaLibraryModelsPromise = fetchOllamaLibraryModels();
try {
return await ollamaLibraryModelsPromise;
} catch (error) {
ollamaLibraryModelsPromise = null;
throw error;
}
};
const fetchOllamaLibraryModels = async (): Promise<OllamaLibraryModel[]> => {
const response = await fetch(
getApiUrl("/api/v1/ppt/ollama/models/library")
);
if (!response.ok) {
throw new Error(await getApiErrorMessage(response, "Could not fetch Ollama library models"));
}
const models: unknown = await response.json();
if (!Array.isArray(models)) {
throw new Error("Invalid library model list");
}
return models.flatMap((model) => {
if (!model || typeof model !== "object" || !("name" in model)) return [];
const name = (model as { name?: unknown }).name;
const description = (model as { description?: unknown }).description;
const parameters = (model as { parameters?: unknown }).parameters;
const size = (model as { size?: unknown }).size;
if (typeof name !== "string") return [];
return [{
name,
description: typeof description === "string" ? description : "",
parameters:
typeof parameters === "string" &&
parameters.trim() &&
parameters.trim().toLowerCase() !== "unknown"
? parameters
: "",
size: typeof size === "string" ? size : "",
}];
});
};
export const pullOllamaModel = async (
modelName: string,
ollamaUrl: string,
onEvent: (event: OllamaPullProgressEvent) => void,
signal?: AbortSignal
): Promise<void> => {
const params = new URLSearchParams();
params.set("model_name", modelName.trim());
if (ollamaUrl.trim()) params.set("ollama_url", ollamaUrl.trim());
const response = await fetch(
getApiUrl(`/api/v1/ppt/ollama/models/pull?${params.toString()}`),
{ method: "POST", signal }
);
if (!response.ok) {
const msg = await getApiErrorMessage(response, "Failed to pull model");
onEvent({ type: "error", detail: msg });
return;
}
const reader = response.body?.getReader();
if (!reader) {
onEvent({ type: "error", detail: "No response stream" });
return;
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let currentEvent = "";
for (const line of lines) {
if (line.startsWith("event: ")) {
currentEvent = line.slice(7).trim();
} else if (line.startsWith("data: ")) {
const dataStr = line.slice(6);
try {
const data = JSON.parse(dataStr) as OllamaPullProgressEvent;
if (currentEvent === "error" || data.type === "error") {
onEvent({ type: "error", detail: data.detail || "Pull failed" });
return;
}
onEvent(data);
if (data.type === "complete") {
clearOllamaModelsCache(ollamaUrl);
return;
}
} catch {
}
}
}
}
} finally {
reader.releaseLock();
}
};