Settings Modal
This commit is contained in:
parent
3fd2b60a10
commit
1973b4dc83
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { useEffect, useState, type Dispatch, type StateUpdater } from "preact/hooks";
|
||||||
|
import { useUpdate } from "./useUpdate";
|
||||||
|
|
||||||
|
type Query<T, Args extends any[]> = (...args: Args) => (Promise<T> | T);
|
||||||
|
type Return<T> = [T | undefined, Dispatch<StateUpdater<T | undefined>>, () => void];
|
||||||
|
|
||||||
|
export const useAsyncState = <T, Args extends any[]>(query: Query<T, Args>, ...args: Args): Return<T> => {
|
||||||
|
const [data, setData] = useState<T>();
|
||||||
|
const [update, request] = useUpdate();
|
||||||
|
|
||||||
|
useEffect(() => void Promise.resolve(query?.(...args))
|
||||||
|
?.then(d => setData(d))
|
||||||
|
.catch(() => setData(undefined)), [query, ...args, update]);
|
||||||
|
|
||||||
|
return [data, setData, request];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useQuery = <T, Args extends any[]>(query: Query<T, Args>, ...args: Args): T | undefined => (
|
||||||
|
useAsyncState(query, ...args)[0]
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { useEffect, useReducer, useState, type Dispatch, type Reducer, type StateUpdater } from "preact/hooks";
|
||||||
|
|
||||||
|
export const useStoredState = <T>(key: string, initialValue: T) => {
|
||||||
|
const storedKey = `useStoredState.${key}`;
|
||||||
|
|
||||||
|
const [value, setValue] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const storedValue = localStorage.getItem(storedKey);
|
||||||
|
return storedValue ? JSON.parse(storedValue) : initialValue;
|
||||||
|
} catch {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(storedKey, JSON.stringify(value));
|
||||||
|
}, [storedKey, value]);
|
||||||
|
|
||||||
|
return [value, setValue] as [T, Dispatch<StateUpdater<T>>];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useStoredReducer = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T) => {
|
||||||
|
const storedKey = `useStoredReducer.${key}`;
|
||||||
|
|
||||||
|
const getInitialValue = (): T => {
|
||||||
|
try {
|
||||||
|
const storedValue = localStorage.getItem(storedKey);
|
||||||
|
return storedValue ? JSON.parse(storedValue) : initialValue;
|
||||||
|
} catch {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [state, dispatch] = useReducer(reducer, undefined, getInitialValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(storedKey, JSON.stringify(state));
|
||||||
|
}, [storedKey, state]);
|
||||||
|
|
||||||
|
return [state, dispatch] as [T, Dispatch<A>];
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { useCallback, useMemo, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
export const useUpdate = (): [unknown, () => void] => {
|
||||||
|
const [value, setValue] = useState(0);
|
||||||
|
|
||||||
|
const update = useCallback(() => setValue(v => v + 1), [setValue]);
|
||||||
|
|
||||||
|
return useMemo(() => [value, update], [value, update]);
|
||||||
|
};
|
||||||
|
|
@ -61,3 +61,56 @@
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonSecondary {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonPrimary {
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,74 @@
|
||||||
import styles from '../assets/settings-modal.module.css';
|
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 {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsModal = ({ onClose }: Props) => {
|
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 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 (
|
return (
|
||||||
<div class={styles.overlay} onClick={onClose}>
|
<div class={styles.overlay} onClick={onClose}>
|
||||||
<div class={styles.modal} onClick={(e) => e.stopPropagation()}>
|
<div class={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||||
|
|
@ -15,7 +79,73 @@ export const SettingsModal = ({ onClose }: Props) => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.content}>
|
<div class={styles.content}>
|
||||||
<p>Settings plceholder</p>
|
<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>
|
||||||
|
) : models.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={setSelectedModel}
|
||||||
|
class={styles.select}
|
||||||
|
>
|
||||||
|
<option value="">Select a model</option>
|
||||||
|
{models.map(m => (
|
||||||
|
<option key={m.id} value={m.id}>{m.id}</option>
|
||||||
|
))}
|
||||||
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { createContext } from "preact";
|
import { createContext } from "preact";
|
||||||
import { useContext, useMemo, useReducer } from "preact/hooks";
|
import { useContext, useMemo, useReducer } from "preact/hooks";
|
||||||
|
|
||||||
|
import LLM from "../utils/llm";
|
||||||
|
import { useStoredReducer } from "@common/hooks/useStoredState";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
|
|
@ -21,6 +24,8 @@ export interface Story {
|
||||||
interface IState {
|
interface IState {
|
||||||
stories: Story[];
|
stories: Story[];
|
||||||
currentStoryId: string | null;
|
currentStoryId: string | null;
|
||||||
|
connection: LLM.Connection | null;
|
||||||
|
model: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Actions ─────────────────────────────────────────────────────────────────
|
// ─── Actions ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -32,13 +37,17 @@ type Action =
|
||||||
| { type: 'DELETE_STORY'; id: string }
|
| { type: 'DELETE_STORY'; id: string }
|
||||||
| { type: 'SELECT_STORY'; id: string }
|
| { type: 'SELECT_STORY'; id: string }
|
||||||
| { type: 'ADD_CHAT_MESSAGE'; storyId: string; message: ChatMessage }
|
| { type: 'ADD_CHAT_MESSAGE'; storyId: string; message: ChatMessage }
|
||||||
| { type: 'CLEAR_CHAT'; storyId: string };
|
| { type: 'CLEAR_CHAT'; storyId: string }
|
||||||
|
| { type: 'SET_CONNECTION'; connection: LLM.Connection | null }
|
||||||
|
| { type: 'SET_MODEL'; model: string | null };
|
||||||
|
|
||||||
// ─── Initial State ───────────────────────────────────────────────────────────
|
// ─── Initial State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_STATE: IState = {
|
const DEFAULT_STATE: IState = {
|
||||||
stories: [],
|
stories: [],
|
||||||
currentStoryId: null,
|
currentStoryId: null,
|
||||||
|
connection: null,
|
||||||
|
model: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Reducer ─────────────────────────────────────────────────────────────────
|
// ─── Reducer ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -105,8 +114,18 @@ function reducer(state: IState, action: Action): IState {
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
default:
|
case 'SET_CONNECTION': {
|
||||||
return state;
|
return {
|
||||||
|
...state,
|
||||||
|
connection: action.connection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'SET_MODEL': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
model: action.model,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,6 +134,8 @@ function reducer(state: IState, action: Action): IState {
|
||||||
interface IStateContext {
|
interface IStateContext {
|
||||||
stories: Story[];
|
stories: Story[];
|
||||||
currentStory: Story | null;
|
currentStory: Story | null;
|
||||||
|
connection: LLM.Connection | null;
|
||||||
|
model: string | null;
|
||||||
dispatch: (action: Action) => void;
|
dispatch: (action: Action) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,11 +146,13 @@ export const useAppState = () => useContext(StateContext);
|
||||||
// ─── Provider ────────────────────────────────────────────────────────────────
|
// ─── Provider ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const StateContextProvider = ({ children }: { children?: any }) => {
|
export const StateContextProvider = ({ children }: { children?: any }) => {
|
||||||
const [state, dispatch] = useReducer(reducer, DEFAULT_STATE);
|
const [state, dispatch] = useStoredReducer('storywriter.state', reducer, DEFAULT_STATE);
|
||||||
|
|
||||||
const value = useMemo<IStateContext>(() => ({
|
const value = useMemo<IStateContext>(() => ({
|
||||||
stories: state.stories,
|
stories: state.stories,
|
||||||
currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null,
|
currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null,
|
||||||
|
connection: state.connection,
|
||||||
|
model: state.model,
|
||||||
dispatch,
|
dispatch,
|
||||||
}), [state]);
|
}), [state]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue