1
0
Fork 0
tsgames/src/games/storywriter/components/settings/connection.tsx

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>
);
};