183 lines
7.3 KiB
TypeScript
183 lines
7.3 KiB
TypeScript
import { useMemo, useRef } from "preact/hooks";
|
||
|
||
import { useQuery } from "@common/hooks/useAsyncState";
|
||
import { useInputState } from "@common/hooks/useInputState";
|
||
import { useUpdate } from "@common/hooks/useUpdate";
|
||
|
||
import { useAppState } from "../contexts/state";
|
||
import LLM from "../utils/llm";
|
||
import styles from "../assets/settings-modal.module.css";
|
||
|
||
interface Props {
|
||
onClose: () => void;
|
||
}
|
||
|
||
export const SettingsModal = ({ onClose }: Props) => {
|
||
const { connection, model, dispatch } = useAppState();
|
||
const [url, setUrl] = useInputState(connection?.url ?? "");
|
||
const [apiKey, setApiKey] = useInputState(connection?.apiKey ?? "");
|
||
const [selectedModel, setSelectedModel] = useInputState(model ?? "");
|
||
const [update, triggerFetch] = useUpdate();
|
||
|
||
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 groupedModels = useMemo(() => {
|
||
const sorted = (modelsData ?? []).sort((a, b) => {
|
||
// Sort by tool support first (true before false)
|
||
if (a.support_tools !== b.support_tools) {
|
||
return a.support_tools ? -1 : 1;
|
||
}
|
||
// Then by max context (bigger first, undefined treated as 0)
|
||
const aContext = a.max_context ?? 0;
|
||
const bContext = b.max_context ?? 0;
|
||
if (aContext !== bContext) {
|
||
return bContext - aContext;
|
||
}
|
||
// Then by name (alphabetically)
|
||
return a.id.localeCompare(b.id);
|
||
});
|
||
|
||
// Group by context size
|
||
const groups = Map.groupBy(sorted, m => m.max_context ?? 0);
|
||
|
||
// Convert to array sorted by context size (bigger first)
|
||
return Array.from(groups.entries())
|
||
.sort((a, b) => b[0] - a[0])
|
||
.map(([context, models]) => ({ context, models }));
|
||
}, [modelsData]);
|
||
|
||
const handleBlur = () => {
|
||
if (url && apiKey) {
|
||
triggerFetch();
|
||
}
|
||
};
|
||
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === 'Enter' && url && apiKey) {
|
||
triggerFetch();
|
||
}
|
||
};
|
||
|
||
const handleConfirm = () => {
|
||
dispatch({
|
||
type: 'SET_CONNECTION',
|
||
connection: connectionToFetch,
|
||
});
|
||
dispatch({
|
||
type: 'SET_MODEL',
|
||
model: selectedModel || null,
|
||
});
|
||
onClose();
|
||
};
|
||
|
||
const connectionToTest = url && apiKey ? { url, apiKey } : null;
|
||
|
||
return (
|
||
<div class={styles.overlay} onClick={onClose}>
|
||
<div class={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||
<div class={styles.header}>
|
||
<h2 class={styles.title}>Settings</h2>
|
||
<button class={styles.closeButton} onClick={onClose}>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div class={styles.content}>
|
||
<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>
|
||
<input
|
||
type="password"
|
||
value={apiKey}
|
||
onInput={setApiKey}
|
||
onBlur={handleBlur}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="your-api-key"
|
||
class={styles.input}
|
||
autocomplete="new-password"
|
||
name="api-key-random"
|
||
/>
|
||
</div>
|
||
<div class={styles.formGroup}>
|
||
<label class={styles.label}>
|
||
Model
|
||
</label>
|
||
{connectionToTest ? (
|
||
isLoadingModels ? (
|
||
<p>Loading models...</p>
|
||
) : groupedModels.length > 0 ? (
|
||
<select
|
||
value={selectedModel}
|
||
onChange={setSelectedModel}
|
||
class={styles.select}
|
||
>
|
||
<option value="">Select a model</option>
|
||
{groupedModels.map(({ context, models }) => (
|
||
<optgroup key={context} label={`${context} context`}>
|
||
{models.map(m => (
|
||
<option key={m.id} value={m.id}>
|
||
{m.support_tools ? '🔧 ' : ''}{m.id} {m.max_length ? `(len: ${m.max_length})` : ''}
|
||
</option>
|
||
))}
|
||
</optgroup>
|
||
))}
|
||
</select>
|
||
) : (
|
||
<p>No models available</p>
|
||
)
|
||
) : (
|
||
<p>Enter connection details to load models</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class={styles.footer}>
|
||
<button onClick={onClose} class={`${styles.button} ${styles.buttonSecondary}`}>
|
||
Cancel
|
||
</button>
|
||
<button onClick={handleConfirm} class={`${styles.button} ${styles.buttonPrimary}`}>
|
||
Confirm
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|