179 lines
7.6 KiB
TypeScript
179 lines
7.6 KiB
TypeScript
import { useBool } from "@common/hooks/useBool";
|
|
import { useQuery } from "@common/hooks/useAsyncState";
|
|
import { useInputState } from "@common/hooks/useInputState";
|
|
import { useUpdate } from "@common/hooks/useUpdate";
|
|
import clsx from "clsx";
|
|
import { useMemo, useRef } from "preact/hooks";
|
|
import styles from "../../assets/settings-modal.module.css";
|
|
import { useAppState } from "../../contexts/state";
|
|
import LLM from "../../utils/llm";
|
|
|
|
export const ConnectionSettings = () => {
|
|
const { connection, model, dispatch } = useAppState();
|
|
const [url, setUrl] = useInputState(connection?.url ?? "");
|
|
const [apiKey, setApiKey] = useInputState(connection?.apiKey ?? "");
|
|
const [selectedModel, setSelectedModel] = useInputState(model?.id ?? "");
|
|
const [update, triggerFetch] = useUpdate();
|
|
const showPassword = useBool(false);
|
|
|
|
const urlRef = useRef(url);
|
|
const apiKeyRef = useRef(apiKey);
|
|
urlRef.current = url;
|
|
apiKeyRef.current = apiKey;
|
|
|
|
const connectionToFetch = useMemo<LLM.Connection | null>(() => {
|
|
const currentUrl = urlRef.current;
|
|
const currentApiKey = apiKeyRef.current;
|
|
if (!currentUrl || !currentApiKey) return null;
|
|
return { url: currentUrl, apiKey: currentApiKey };
|
|
}, [update]);
|
|
|
|
const fetchModels = useMemo(() => async (conn: LLM.Connection | null) => {
|
|
if (!conn) return [];
|
|
const r = await LLM.getModels(conn);
|
|
return r.data;
|
|
}, []);
|
|
|
|
const modelsData = useQuery(fetchModels, connectionToFetch);
|
|
const isLoadingModels = connectionToFetch != null && modelsData == undefined;
|
|
|
|
const [modelFilter, setModelFilter] = useInputState("");
|
|
|
|
const groupedModels = useMemo(() => {
|
|
const sorted = (modelsData ?? []).sort((a, b) => {
|
|
const aWeight = Number(a.supported_parameters.includes('tools')) * 2 + Number(a.supported_parameters.includes('reasoning'));
|
|
const bWeight = Number(b.supported_parameters.includes('tools')) * 2 + Number(b.supported_parameters.includes('reasoning'));
|
|
if (aWeight !== bWeight) return bWeight - aWeight;
|
|
if (a.context_length !== b.context_length) return b.context_length - a.context_length;
|
|
return a.id.localeCompare(b.id);
|
|
});
|
|
const groups = Map.groupBy(sorted, m => m.context_length);
|
|
return Array.from(groups.entries())
|
|
.sort((a, b) => b[0] - a[0])
|
|
.map(([context, models]) => ({ context, models }));
|
|
}, [modelsData]);
|
|
|
|
const filteredGroupedModels = useMemo(() => {
|
|
if (!modelFilter) return groupedModels;
|
|
const query = modelFilter.toLowerCase();
|
|
const fuzzyMatch = (target: string) => {
|
|
const t = target.toLowerCase();
|
|
let qi = 0;
|
|
for (let ti = 0; ti < t.length && qi < query.length; ti++) {
|
|
if (t[ti] === query[qi]) qi++;
|
|
}
|
|
return qi === query.length;
|
|
};
|
|
return groupedModels
|
|
.map(({ context, models }) => ({
|
|
context,
|
|
models: models.filter(m => m.id === selectedModel || fuzzyMatch(m.id)),
|
|
}))
|
|
.filter(({ models }) => models.length > 0);
|
|
}, [groupedModels, modelFilter, selectedModel]);
|
|
|
|
const handleBlur = () => {
|
|
if (url && apiKey) {
|
|
dispatch({ type: "SET_CONNECTION", connection: { url, apiKey } });
|
|
triggerFetch();
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Enter" && url && apiKey) {
|
|
dispatch({ type: "SET_CONNECTION", connection: { url, apiKey } });
|
|
triggerFetch();
|
|
}
|
|
};
|
|
|
|
const handleModelChange = (e: Event) => {
|
|
setSelectedModel(e);
|
|
const target = e.target as HTMLSelectElement;
|
|
const selectedModelInfo = modelsData?.find(m => m.id === target.value) ?? null;
|
|
dispatch({ type: "SET_MODEL", model: selectedModelInfo });
|
|
};
|
|
|
|
const connectionToTest = url && apiKey ? { url, apiKey } : null;
|
|
|
|
return (
|
|
<div class={styles.form} autocomplete="off">
|
|
<div class={styles.formGroup}>
|
|
<label class={styles.label}>API URL</label>
|
|
<input
|
|
type="text"
|
|
value={url}
|
|
onInput={setUrl}
|
|
onBlur={handleBlur}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="http://localhost:1234"
|
|
class={styles.input}
|
|
autocomplete="off"
|
|
name="api-url-random"
|
|
/>
|
|
</div>
|
|
<div class={styles.formGroup}>
|
|
<label class={styles.label}>API Key</label>
|
|
<div class={styles.inputRow}>
|
|
<input
|
|
type={showPassword.value ? "text" : "password"}
|
|
value={apiKey}
|
|
onInput={setApiKey}
|
|
onBlur={handleBlur}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="your-api-key"
|
|
class={styles.input}
|
|
autocomplete="new-password"
|
|
name="api-key-random"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={showPassword.toggle}
|
|
class={styles.iconButton}
|
|
title={showPassword.value ? "Hide API key" : "Show API key"}
|
|
>
|
|
{showPassword.value ? "🙈" : "👁️"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class={clsx(styles.formGroup, styles.formGroupFill)}>
|
|
<label class={styles.label}>Model</label>
|
|
{connectionToTest ? (
|
|
isLoadingModels ? (
|
|
<p>Loading models...</p>
|
|
) : groupedModels.length > 0 ? (
|
|
<>
|
|
<input
|
|
type="text"
|
|
value={modelFilter}
|
|
onInput={setModelFilter}
|
|
placeholder="Filter models..."
|
|
class={styles.input}
|
|
/>
|
|
<select
|
|
value={selectedModel}
|
|
onChange={handleModelChange}
|
|
class={clsx(styles.select, styles.selectMultiline)}
|
|
size={3}
|
|
>
|
|
{filteredGroupedModels.map(({ context, models }) => (
|
|
<optgroup key={context} label={`${context} context`}>
|
|
{models.map(m => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.supported_parameters.includes('tools') ? '🔨' : ''}{m.supported_parameters.includes('reasoning') ? '🧠' : ''}{m.id} {m.top_provider.max_completion_tokens ? `(len: ${m.top_provider.max_completion_tokens})` : ''}
|
|
</option>
|
|
))}
|
|
</optgroup>
|
|
))}
|
|
</select>
|
|
</>
|
|
) : (
|
|
<p>No models available</p>
|
|
)
|
|
) : (
|
|
<p>Enter connection details to load models</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|