1
0
Fork 0

Image model selection

This commit is contained in:
Pabloader 2026-04-14 16:14:54 +00:00
parent c2ad2cfae4
commit 5f83ca7cc2
6 changed files with 112 additions and 17 deletions

View File

@ -161,6 +161,16 @@ export const formatTime = (seconds: number): string => {
return parts.join(' '); return parts.join(' ');
}; };
export const fuzzyMatch = (target: string, query: string): boolean => {
const t = target.toLowerCase();
const q = query.toLowerCase();
let qi = 0;
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) qi++;
}
return qi === q.length;
};
export const extractString = (e: Event | string): string => { export const extractString = (e: Event | string): string => {
if (typeof e === 'string') { if (typeof e === 'string') {
return e; return e;

View File

@ -1,4 +1,4 @@
import { WorkerData } from "../utils/api"; import { type WorkerData } from "../utils/api";
/** /**
* Calculate total kudos/hour across a set of workers. * Calculate total kudos/hour across a set of workers.

View File

@ -2,6 +2,7 @@ import { useBool } from "@common/hooks/useBool";
import { useQuery } from "@common/hooks/useAsyncState"; import { useQuery } from "@common/hooks/useAsyncState";
import { useInputState } from "@common/hooks/useInputState"; import { useInputState } from "@common/hooks/useInputState";
import { useUpdate } from "@common/hooks/useUpdate"; import { useUpdate } from "@common/hooks/useUpdate";
import { fuzzyMatch } from "@common/utils";
import clsx from "clsx"; import clsx from "clsx";
import { useMemo, useRef } from "preact/hooks"; import { useMemo, useRef } from "preact/hooks";
import styles from "../../assets/settings-modal.module.css"; import styles from "../../assets/settings-modal.module.css";
@ -9,10 +10,11 @@ import { useAppState } from "../../contexts/state";
import LLM from "../../utils/llm"; import LLM from "../../utils/llm";
export const ConnectionSettings = () => { export const ConnectionSettings = () => {
const { connection, model, dispatch } = useAppState(); const { connection, model, imageModel, dispatch } = useAppState();
const [url, setUrl] = useInputState(connection?.url ?? ""); const [url, setUrl] = useInputState(connection?.url ?? "");
const [apiKey, setApiKey] = useInputState(connection?.apiKey ?? ""); const [apiKey, setApiKey] = useInputState(connection?.apiKey ?? "");
const [selectedModel, setSelectedModel] = useInputState(model?.id ?? ""); const [selectedModel, setSelectedModel] = useInputState(model?.id ?? "");
const [selectedImageModel, setSelectedImageModel] = useInputState(imageModel?.id ?? "");
const [update, triggerFetch] = useUpdate(); const [update, triggerFetch] = useUpdate();
const showPassword = useBool(false); const showPassword = useBool(false);
@ -34,7 +36,14 @@ export const ConnectionSettings = () => {
return r.data; return r.data;
}, []); }, []);
const fetchImageModels = useMemo(() => async (conn: LLM.Connection | null) => {
if (!conn) return [];
const r = await LLM.getImageModels(conn);
return r.data;
}, []);
const modelsData = useQuery(fetchModels, connectionToFetch); const modelsData = useQuery(fetchModels, connectionToFetch);
const imageModelsData = useQuery(fetchImageModels, connectionToFetch);
const isLoadingModels = connectionToFetch != null && modelsData == undefined; const isLoadingModels = connectionToFetch != null && modelsData == undefined;
const [modelFilter, setModelFilter] = useInputState(""); const [modelFilter, setModelFilter] = useInputState("");
@ -55,23 +64,20 @@ export const ConnectionSettings = () => {
const filteredGroupedModels = useMemo(() => { const filteredGroupedModels = useMemo(() => {
if (!modelFilter) return groupedModels; 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 return groupedModels
.map(({ context, models }) => ({ .map(({ context, models }) => ({
context, context,
models: models.filter(m => m.id === selectedModel || fuzzyMatch(m.id)), models: models.filter(m => m.id === selectedModel || fuzzyMatch(m.id, modelFilter)),
})) }))
.filter(({ models }) => models.length > 0); .filter(({ models }) => models.length > 0);
}, [groupedModels, modelFilter, selectedModel]); }, [groupedModels, modelFilter, selectedModel]);
const filteredImageModels = useMemo(() => {
const sorted = [...(imageModelsData ?? [])].sort((a, b) => a.id.localeCompare(b.id));
if (!modelFilter) return sorted;
return sorted.filter(m => m.id === selectedImageModel || fuzzyMatch(m.id, modelFilter));
}, [imageModelsData, modelFilter, selectedImageModel]);
const handleBlur = () => { const handleBlur = () => {
if (url && apiKey) { if (url && apiKey) {
dispatch({ type: "SET_CONNECTION", connection: { url, apiKey } }); dispatch({ type: "SET_CONNECTION", connection: { url, apiKey } });
@ -93,6 +99,13 @@ export const ConnectionSettings = () => {
dispatch({ type: "SET_MODEL", model: selectedModelInfo }); dispatch({ type: "SET_MODEL", model: selectedModelInfo });
}; };
const handleImageModelChange = (e: Event) => {
setSelectedImageModel(e);
const target = e.target as HTMLSelectElement;
const selectedModelInfo = imageModelsData?.find(m => m.id === target.value) ?? null;
dispatch({ type: "SET_IMAGE_MODEL", model: selectedModelInfo });
};
const connectionToTest = url && apiKey ? { url, apiKey } : null; const connectionToTest = url && apiKey ? { url, apiKey } : null;
return ( return (
@ -173,6 +186,30 @@ export const ConnectionSettings = () => {
<p>Enter connection details to load models</p> <p>Enter connection details to load models</p>
)} )}
</div> </div>
<div class={clsx(styles.formGroup, styles.formGroupFill)}>
<label class={styles.label}>Image Model</label>
{connectionToTest ? (
imageModelsData == undefined ? (
<p>Loading models...</p>
) : imageModelsData.length > 0 ? (
<select
value={selectedImageModel}
onChange={handleImageModelChange}
class={clsx(styles.select, styles.selectMultiline)}
size={3}
>
<option value=""> none </option>
{filteredImageModels.map(m => (
<option key={m.id} value={m.id}>{m.id}</option>
))}
</select>
) : (
<p>No image models available</p>
)
) : (
<p>Enter connection details to load models</p>
)}
</div>
</div> </div>
); );
}; };

View File

@ -145,7 +145,8 @@ interface IState {
currentTab: Tab; currentTab: Tab;
chatOpen: boolean; chatOpen: boolean;
connection: LLM.Connection | null; connection: LLM.Connection | null;
model: LLM.ModelInfo | null; model: LLM.ModelInfoText | null;
imageModel: LLM.ModelInfoImage | null;
enableThinking: boolean; enableThinking: boolean;
bannedTokens: string[]; bannedTokens: string[];
systemInstruction: string; systemInstruction: string;
@ -193,7 +194,8 @@ type Action =
| { type: 'EDIT_CHAT_MESSAGE'; worldId: string; storyId: string; messageId: string; content: string } | { type: 'EDIT_CHAT_MESSAGE'; worldId: string; storyId: string; messageId: string; content: string }
// Connection // Connection
| { type: 'SET_CONNECTION'; connection: LLM.Connection | null } | { type: 'SET_CONNECTION'; connection: LLM.Connection | null }
| { type: 'SET_MODEL'; model: LLM.ModelInfo | null } | { type: 'SET_MODEL'; model: LLM.ModelInfoText | null }
| { type: 'SET_IMAGE_MODEL'; model: LLM.ModelInfoImage | null }
| { type: 'SET_ENABLE_THINKING'; enable: boolean } | { type: 'SET_ENABLE_THINKING'; enable: boolean }
| { type: 'SET_BANNED_TOKENS'; tokens: string[] } | { type: 'SET_BANNED_TOKENS'; tokens: string[] }
// Characters // Characters
@ -253,6 +255,7 @@ const DEFAULT_STATE: IState = {
chatOpen: false, chatOpen: false,
connection: null, connection: null,
model: null, model: null,
imageModel: null,
enableThinking: false, enableThinking: false,
bannedTokens: [], bannedTokens: [],
userName: 'User', userName: 'User',
@ -500,6 +503,9 @@ function reducer(state: IState, action: Action): IState {
case 'SET_MODEL': { case 'SET_MODEL': {
return { ...state, model: action.model }; return { ...state, model: action.model };
} }
case 'SET_IMAGE_MODEL': {
return { ...state, imageModel: action.model };
}
case 'SET_ENABLE_THINKING': { case 'SET_ENABLE_THINKING': {
return { ...state, enableThinking: action.enable }; return { ...state, enableThinking: action.enable };
} }
@ -647,7 +653,8 @@ export interface AppState {
currentTab: Tab; currentTab: Tab;
chatOpen: boolean; chatOpen: boolean;
connection: LLM.Connection | null; connection: LLM.Connection | null;
model: LLM.ModelInfo | null; model: LLM.ModelInfoText | null;
imageModel: LLM.ModelInfoImage | null;
enableThinking: boolean; enableThinking: boolean;
bannedTokens: string[]; bannedTokens: string[];
systemInstruction: string; systemInstruction: string;
@ -702,6 +709,7 @@ export const StateContextProvider = ({ children }: { children?: any }) => {
chatOpen: state.chatOpen, chatOpen: state.chatOpen,
connection: state.connection, connection: state.connection,
model: state.model, model: state.model,
imageModel: state.imageModel ?? null,
enableThinking: state.enableThinking, enableThinking: state.enableThinking,
bannedTokens: state.bannedTokens ?? [], bannedTokens: state.bannedTokens ?? [],
systemInstruction, systemInstruction,

View File

@ -132,7 +132,7 @@ namespace LLM {
}; };
} }
interface ModelInfoText extends BaseModelInfo { export interface ModelInfoText extends BaseModelInfo {
context_length: number; context_length: number;
top_provider: { top_provider: {
context_length: number; context_length: number;
@ -141,7 +141,7 @@ namespace LLM {
}; };
} }
interface ModelInfoImage extends BaseModelInfo { export interface ModelInfoImage extends BaseModelInfo {
} }
export type ModelInfo = ModelInfoText | ModelInfoImage; export type ModelInfo = ModelInfoText | ModelInfoImage;

View File

@ -18,6 +18,7 @@ import {
callUpdater, callUpdater,
formatTime, formatTime,
formatNumber, formatNumber,
fuzzyMatch,
} from '@common/utils'; } from '@common/utils';
describe('utils', () => { describe('utils', () => {
@ -365,6 +366,45 @@ describe('utils', () => {
}); });
}); });
describe('fuzzyMatch', () => {
it('returns true for exact match', () => {
expect(fuzzyMatch('hello', 'hello')).toBe(true);
});
it('returns true when query chars appear in order', () => {
expect(fuzzyMatch('Deliberate', 'dlt')).toBe(true);
});
it('returns true for non-contiguous subsequence', () => {
expect(fuzzyMatch('Analog Diffusion', 'andif')).toBe(true);
});
it('returns false when query chars are out of order', () => {
expect(fuzzyMatch('abc', 'ca')).toBe(false);
});
it('returns false when a query char is missing from target', () => {
expect(fuzzyMatch('Deliberate', 'dltz')).toBe(false);
});
it('returns true for empty query', () => {
expect(fuzzyMatch('anything', '')).toBe(true);
});
it('returns false for empty target with non-empty query', () => {
expect(fuzzyMatch('', 'a')).toBe(false);
});
it('returns false when query is longer than target', () => {
expect(fuzzyMatch('ab', 'abc')).toBe(false);
});
it('is case-insensitive', () => {
expect(fuzzyMatch('DreamShaper', 'dreamshaper')).toBe(true);
expect(fuzzyMatch('dreamshaper', 'DREAM')).toBe(true);
});
});
describe('formatTime', () => { describe('formatTime', () => {
it('should return 0:00 for zero seconds', () => { it('should return 0:00 for zero seconds', () => {
expect(formatTime(0)).toBe('0:00'); expect(formatTime(0)).toBe('0:00');