Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import LocalAiConnection from "@/models/localAiConnection";

export default function LocalAiConnectionSelector({
name = "chatConnectionId",
value = "",
onChange,
onConnectionChange,
required = false,
className = "border-none bg-theme-settings-input-bg text-white text-sm rounded-lg block w-full p-2.5",
}) {
const [connections, setConnections] = useState([]);

useEffect(() => {
LocalAiConnection.all().then(setConnections);
}, []);

function handleChange(event) {
const nextValue = event.target.value;
onChange?.(nextValue);
onConnectionChange?.(
connections.find((connection) => String(connection.id) === nextValue) ||
null
);
}

return (
<select
name={name}
value={value || ""}
onChange={handleChange}
className={className}
required={required}
>
<option value="">Use system LocalAI settings</option>
{connections.map((connection) => (
<option key={connection.id} value={connection.id}>
{connection.name}
</option>
))}
</select>
);
}
159 changes: 159 additions & 0 deletions frontend/src/components/LLMSelection/LocalAiOptions/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import System from "@/models/system";
import PreLoader from "@/components/Preloader";
import { LOCALAI_COMMON_URLS } from "@/utils/constants";
import useProviderEndpointAutoDiscovery from "@/hooks/useProviderEndpointAutoDiscovery";
import LocalAiConnection from "@/models/localAiConnection";
import showToast from "@/utils/toast";

export default function LocalAiOptions({ settings, showAlert = false }) {
const {
Expand All @@ -24,6 +26,7 @@ export default function LocalAiOptions({ settings, showAlert = false }) {

return (
<div className="w-full flex flex-col gap-y-7">
{!settings?.credentialsOnly && <LocalAiConnectionManager />}
{showAlert && (
<div className="flex flex-col md:flex-row md:items-center gap-x-2 text-white mb-6 bg-blue-800/30 w-fit rounded-lg px-4 py-2">
<div className="gap-x-2 flex items-center">
Expand Down Expand Up @@ -144,6 +147,162 @@ export default function LocalAiOptions({ settings, showAlert = false }) {
);
}

const EMPTY_CONNECTION = {
name: "",
base_url: "http://localhost:8080/v1",
api_key: "",
model: "",
token_limit: 4096,
};

function LocalAiConnectionManager() {
const [connections, setConnections] = useState([]);
const [selectedId, setSelectedId] = useState("");
const [draft, setDraft] = useState(EMPTY_CONNECTION);
const [saving, setSaving] = useState(false);

async function refresh() {
setConnections(await LocalAiConnection.all());
}

useEffect(() => {
refresh();
}, []);

function updateDraft(field, value) {
setDraft((current) => ({ ...current, [field]: value }));
}

function selectConnection(id) {
setSelectedId(id);
const connection = connections.find((item) => String(item.id) === id);
setDraft(
connection ? { ...connection, api_key: "" } : { ...EMPTY_CONNECTION }
);
}

async function saveConnection() {
setSaving(true);
const data = {
name: draft.name,
base_url: draft.base_url,
model: draft.model,
token_limit: Number(draft.token_limit),
...(draft.api_key ? { api_key: draft.api_key } : {}),
};
const result = selectedId
? await LocalAiConnection.update(selectedId, data)
: await LocalAiConnection.create({ ...data, api_key: draft.api_key });
setSaving(false);
if (result.error) return showToast(result.error, "error");

await refresh();
setSelectedId(String(result.connection.id));
setDraft({ ...result.connection, api_key: "" });
showToast("LocalAI connection saved.", "success");
}

async function deleteConnection() {
if (!selectedId || !window.confirm("Delete this LocalAI connection?"))
return;
const result = await LocalAiConnection.delete(selectedId);
if (!result.success) return showToast(result.error, "error");
await refresh();
setSelectedId("");
setDraft({ ...EMPTY_CONNECTION });
showToast("LocalAI connection deleted.", "success");
}

return (
<div className="w-full max-w-[760px] rounded-lg border border-white/10 p-4 flex flex-col gap-4">
<div>
<p className="text-white text-sm font-semibold">Saved connections</p>
<p className="text-white/60 text-xs mt-1">
Workspaces and model routers can use these LocalAI endpoints
concurrently.
</p>
</div>
<select
value={selectedId}
onChange={(event) => selectConnection(event.target.value)}
className="border-none bg-theme-settings-input-bg text-white text-sm rounded-lg block w-full p-2.5"
>
<option value="">Create a new connection</option>
{connections.map((connection) => (
<option key={connection.id} value={connection.id}>
{connection.name}
</option>
))}
</select>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<ConnectionInput
label="Connection name"
value={draft.name}
onChange={(value) => updateDraft("name", value)}
/>
<ConnectionInput
label="Base URL"
type="url"
value={draft.base_url}
onChange={(value) => updateDraft("base_url", value)}
/>
<ConnectionInput
label="Default model"
value={draft.model}
onChange={(value) => updateDraft("model", value)}
/>
<ConnectionInput
label="Context window"
type="number"
value={draft.token_limit}
onChange={(value) => updateDraft("token_limit", value)}
/>
<ConnectionInput
label={draft.hasApiKey ? "API key (leave blank to keep)" : "API key"}
type="password"
value={draft.api_key}
onChange={(value) => updateDraft("api_key", value)}
/>
</div>
<div className="flex gap-2">
<button
type="button"
disabled={saving}
onClick={saveConnection}
className="bg-primary-button px-3 py-2 rounded-lg text-sm font-medium"
>
{saving ? "Saving..." : "Save connection"}
</button>
{selectedId && (
<button
type="button"
onClick={deleteConnection}
className="bg-red-600 px-3 py-2 rounded-lg text-white text-sm font-medium"
>
Delete
</button>
)}
</div>
</div>
);
}

function ConnectionInput({ label, type = "text", value, onChange }) {
return (
<label className="flex flex-col gap-2 text-white text-sm font-semibold">
{label}
<input
type={type}
value={value}
min={type === "number" ? 1 : undefined}
onChange={(event) => onChange(event.target.value)}
className="border-none bg-theme-settings-input-bg text-white text-sm rounded-lg block w-full p-2.5"
autoComplete="off"
/>
</label>
);
}

function LocalAIModelSelection({ settings, basePath = null, apiKey = null }) {
const [customModels, setCustomModels] = useState([]);
const [loading, setLoading] = useState(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import useGetProviderModels, {

export default function ChatModelSelection({
provider,
connectionId,
setHasChanges,
selectedLLMModel,
setSelectedLLMModel,
}) {
const { defaultModels, customModels, loading, downloadedModels } =
useGetProviderModels(provider);
useGetProviderModels(provider, connectionId);
if (DISABLED_PROVIDERS.includes(provider)) return null;

if (loading) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { NoSetupWarning } from "./SetupProvider";
import showToast from "@/utils/toast";
import Workspace from "@/models/workspace";
import System from "@/models/system";
import LocalAiConnectionSelector from "@/components/LLMSelection/LocalAiConnectionSelector";

export default function LLMSelectorModal({
workspaceSlug = null,
Expand All @@ -28,6 +29,7 @@ export default function LLMSelectorModal({
const [settings, setSettings] = useState(null);
const [selectedLLMProvider, setSelectedLLMProvider] = useState(null);
const [selectedLLMModel, setSelectedLLMModel] = useState("");
const [selectedConnectionId, setSelectedConnectionId] = useState("");
const [selectedRouterId, setSelectedRouterId] = useState(null);
const [availableProviders, setAvailableProviders] = useState(
WORKSPACE_LLM_PROVIDERS
Expand All @@ -50,6 +52,7 @@ export default function LLMSelectorModal({
setSelectedLLMProvider(providerToSelect);
autoScrollToSelectedLLMProvider(providerToSelect);
setSelectedLLMModel(savedModel);
setSelectedConnectionId(workspace.chatConnectionId || "");
setSelectedRouterId(
workspace.router_id || systemSettings?.ModelRouterId || null
);
Expand Down Expand Up @@ -78,6 +81,7 @@ export default function LLMSelectorModal({
autoScrollToSelectedLLMProvider(provider, 50);
document.getElementById("llm-search-input").value = "";
setHasChanges(true);
if (provider !== "localai") setSelectedConnectionId("");
setMissingCredentials(hasMissingCredentials(settings, provider));
}

Expand All @@ -95,6 +99,10 @@ export default function LLMSelectorModal({
: {
chatProvider: selectedLLMProvider,
chatModel: validatedModelSelection(selectedLLMModel),
chatConnectionId:
selectedLLMProvider === "localai"
? selectedConnectionId || null
: null,
};

if (!isRouter && !updateData.chatModel)
Expand Down Expand Up @@ -160,12 +168,28 @@ export default function LLMSelectorModal({
setHasChanges={setHasChanges}
/>
) : (
<ChatModelSelection
provider={selectedLLMProvider}
setHasChanges={setHasChanges}
selectedLLMModel={selectedLLMModel}
setSelectedLLMModel={setSelectedLLMModel}
/>
<div className="flex flex-col gap-2.5">
{selectedLLMProvider === "localai" && (
<LocalAiConnectionSelector
value={selectedConnectionId}
onChange={(value) => {
setSelectedConnectionId(value);
setHasChanges(true);
}}
onConnectionChange={(connection) => {
if (connection) setSelectedLLMModel(connection.model);
}}
className="bg-zinc-900 light:bg-white text-white light:text-slate-900 text-sm rounded-lg h-8 w-full px-2.5 outline-none border border-zinc-900 light:border-slate-400 cursor-pointer"
/>
)}
<ChatModelSelection
provider={selectedLLMProvider}
connectionId={selectedConnectionId}
setHasChanges={setHasChanges}
selectedLLMModel={selectedLLMModel}
setSelectedLLMModel={setSelectedLLMModel}
/>
</div>
))}
</div>
<NoSetupWarning
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/hooks/useGetProvidersModels.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import System from "@/models/system";
import LocalAiConnection from "@/models/localAiConnection";
import { useEffect, useState } from "react";

// Providers which cannot use this feature for workspace<>model selection
Expand Down Expand Up @@ -50,7 +51,10 @@ const groupedProviders = [
"docker-model-runner",
"sambanova",
];
export default function useGetProviderModels(provider = null) {
export default function useGetProviderModels(
provider = null,
connectionId = null
) {
const [defaultModels, setDefaultModels] = useState([]);
const [customModels, setCustomModels] = useState([]);
const [loading, setLoading] = useState(true);
Expand All @@ -71,7 +75,10 @@ export default function useGetProviderModels(provider = null) {
async function fetchProviderModels() {
if (!provider) return;
setLoading(true);
const { models = [] } = await System.customModels(provider);
const { models = [] } =
provider === "localai" && connectionId
? await LocalAiConnection.models(connectionId)
: await System.customModels(provider);
if (
PROVIDER_DEFAULT_MODELS.hasOwnProperty(provider) &&
!groupedProviders.includes(provider)
Expand All @@ -87,7 +94,7 @@ export default function useGetProviderModels(provider = null) {
setLoading(false);
}
fetchProviderModels();
}, [provider]);
}, [provider, connectionId]);

return { defaultModels, customModels, loading, downloadedModels };
}
Loading