1
0
Fork 0

Compare commits

..

2 Commits

Author SHA1 Message Date
Pabloader d9dac5c97f Settings refactor 2026-04-08 07:59:40 +00:00
Pabloader 446cda6502 Regen 2026-04-08 07:39:57 +00:00
13 changed files with 386 additions and 393 deletions

View File

@ -15,8 +15,8 @@
background: var(--bg);
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 80vh;
max-width: 720px;
height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
@ -56,25 +56,35 @@
}
}
.tabs {
.body {
display: flex;
border-bottom: 1px solid var(--border);
padding: 0 20px;
gap: 8px;
flex: 1;
overflow: hidden;
}
.tab {
.sidebar {
display: flex;
flex-direction: column;
width: 160px;
flex-shrink: 0;
border-right: 1px solid var(--border);
padding: 8px;
gap: 2px;
}
.menuItem {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 16px;
width: 100%;
padding: 10px 12px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
text-align: left;
transition: all 0.15s;
&:hover {
color: var(--text);
@ -83,7 +93,8 @@
&.active {
color: var(--accent);
border-bottom-color: var(--accent);
background: var(--bg-active);
font-weight: 500;
}
}
@ -91,12 +102,15 @@
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
}
.formGroup {
@ -104,6 +118,10 @@
flex-direction: column;
}
.formGroupFill {
flex: 1;
}
.label {
display: block;
margin-bottom: 4px;
@ -122,10 +140,11 @@
}
.textarea {
resize: vertical;
resize: none;
font-family: inherit;
font-size: inherit;
line-height: 1.5;
flex: 1;
}
.footer {

View File

@ -186,8 +186,8 @@ export const ChatPanel = () => {
const content = delta?.content;
if (content) {
accumulatedContent += content;
if (currentWorld?.chatOnly && accumulatedContent.startsWith(prefix)) {
accumulatedContent = accumulatedContent.slice(prefix.length);
if (currentWorld?.chatOnly && accumulatedContent.trimStart().startsWith(prefix)) {
accumulatedContent = accumulatedContent.trimStart().slice(prefix.length).trimStart();
}
}
const reasoningContent = delta?.reasoning_content;
@ -258,8 +258,44 @@ export const ChatPanel = () => {
}
}, [currentStory, connection, model]);
const handleRegenerate = useCallback(async () => {
if (!currentStory || !connection || !model || isLoading) return;
// Only regenerate if last message is assistant
const lastMessage = currentStory.chatMessages.at(-1);
const isAssistant = lastMessage?.role === 'assistant';
setIsLoading(true);
setError(null);
abortControllerRef.current = new AbortController();
const excludedMessages: string[] = [];
try {
if (isAssistant) {
// Delete the last assistant message and regenerate
dispatch({
type: 'DELETE_CHAT_MESSAGES_FROM',
worldId: currentWorld!.id,
storyId: currentStory.id,
messageId: lastMessage.id,
});
excludedMessages.push(lastMessage.id);
}
await sendMessage([], excludedMessages);
} finally {
setIsLoading(false);
}
}, [currentStory, currentWorld, connection, model, isLoading, sendMessage]);
const handleSendMessage = useCallback(async () => {
if (!currentStory || !input.trim() || !connection || !model || isLoading) return;
if (!currentStory || !connection || !model || isLoading) return;
if (!input.trim()) {
if (lastMessage?.role === 'user') {
handleRegenerate();
}
return;
}
setInput('');
setIsLoading(true);
@ -275,7 +311,7 @@ export const ChatPanel = () => {
} finally {
setIsLoading(false);
}
}, [currentStory, input, connection, model, isLoading, sendMessage]);
}, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate]);
const handleContinue = useCallback(async () => {
if (!currentStory || !connection || !model || isLoading) return;
@ -352,32 +388,6 @@ export const ChatPanel = () => {
setEditingContent('');
}, [setEditingContent]);
const handleRegenerate = useCallback(async () => {
if (!currentStory || !connection || !model || isLoading) return;
// Only regenerate if last message is assistant
const lastMessage = currentStory.chatMessages.at(-1);
if (!lastMessage || lastMessage.role !== 'assistant') return;
setIsLoading(true);
setError(null);
abortControllerRef.current = new AbortController();
try {
// Delete the last assistant message and regenerate
dispatch({
type: 'DELETE_CHAT_MESSAGES_FROM',
worldId: currentWorld!.id,
storyId: currentStory.id,
messageId: lastMessage.id,
});
await sendMessage([], [lastMessage.id]);
} finally {
setIsLoading(false);
}
}, [currentStory, currentWorld, connection, model, isLoading, dispatch, sendMessage]);
const isDisabled = !currentStory || !connection || !model || isLoading;
return (
@ -501,9 +511,9 @@ export const ChatPanel = () => {
)}
{currentStory && (
<div class={styles.inputContainer}>
{!currentWorld?.chatOnly && (
<div class={styles.optionsRow}>
<label class={styles.toggleContainer}>
{!currentWorld?.chatOnly && (<>
<input
type="checkbox"
checked={enableThinking}
@ -514,19 +524,21 @@ export const ChatPanel = () => {
disabled={isDisabled}
/>
<span>Enable thinking</span>
</>)}
</label>
<div class={styles.tokenCounter}>
{tokenCount && <span>{tokenCount.taken} / {tokenCount.total} tokens</span>}
<button
{!currentWorld?.chatOnly && (<button
class={styles.summarizeButton}
onClick={summarizeAll}
disabled={isSummarizing || !currentStory || !connection || !model}
title={isSummarizing ? 'Summarizing...' : 'Summarize'}>
<Sparkles size={14} />
</button>
</div>
</div>
)}
</div>
</div>
<ContentEditable
autoLines
class={styles.input}
@ -553,7 +565,7 @@ export const ChatPanel = () => {
<button
class={styles.sendButton}
onClick={handleSendMessage}
disabled={isDisabled || !input.trim()}
disabled={isDisabled}
>
Send
</button>
@ -567,7 +579,6 @@ export const ChatPanel = () => {
<ChevronsRight size={14} />
</button>
)}
{lastMessage?.role === 'assistant' && (
<button
class={styles.regenerateButton}
onClick={handleRegenerate}
@ -576,7 +587,6 @@ export const ChatPanel = () => {
>
<RefreshCw size={14} />
</button>
)}
</div>
)}
</div>

View File

@ -1,185 +0,0 @@
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";
import { X } from "lucide-preact";
interface Props {
onClose: () => void;
}
export const ConnectionSettingsModal = ({ onClose }: Props) => {
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 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) => {
const aWeight = Number(a.support_tools) * 2 + Number(a.support_thinking);
const bWeight = Number(b.support_tools) * 2 + Number(b.support_thinking);
if (aWeight !== bWeight) {
return bWeight - aWeight;
}
const aContext = a.max_context ?? 0;
const bContext = b.max_context ?? 0;
if (aContext !== bContext) {
return bContext - aContext;
}
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,
});
const selectedModelInfo = modelsData?.find(m => m.id === selectedModel) ?? null;
dispatch({
type: 'SET_MODEL',
model: selectedModelInfo,
});
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}>Connection Settings</h2>
<button class={styles.closeButton} onClick={onClose}>
<X size={20} />
</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.support_thinking ? '🧠' : ''}{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>
);
};

View File

@ -4,10 +4,10 @@ import { useAppState, type Tab } from "../contexts/state";
import styles from '../assets/editor.module.css';
import { useMemo, useRef, useEffect } from "preact/hooks";
import clsx from "clsx";
import { CharacterEditor } from "./character-editor";
import { LocationEditor } from "./location-editor";
import { ChaptersEditor } from "./chapters-editor";
import { LoreEditor } from "./lore-editor";
import { CharacterEditor } from "./editors/character";
import { LocationEditor } from "./editors/location";
import { ChaptersEditor } from "./editors/chapters";
import { LoreEditor } from "./editors/lore";
import { Menu } from "./menu";
import { ChatPanel } from "./chat-sidebar";
import { useInputCallback } from "@common/hooks/useInputCallback";

View File

@ -1,9 +1,9 @@
import { useMemo } from "preact/hooks";
import { useAppState } from "../contexts/state";
import Chapters from "../utils/chapters";
import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight";
import styles from "../assets/chapters-editor.module.css";
import { useAppState } from "../../contexts/state";
import Chapters from "../../utils/chapters";
import styles from "../../assets/chapters-editor.module.css";
export const ChaptersEditor = () => {
const { currentWorld, currentStory, dispatch } = useAppState();

View File

@ -1,8 +1,8 @@
import { CharacterRole, useAppState, type Character } from "../contexts/state";
import { useState } from "preact/hooks";
import styles from '../assets/character-editor.module.css';
import LLM from "../utils/llm";
import { ContentEditable } from "@common/components/ContentEditable";
import { ContentEditable } from "@common/components/ContentEditable";import { CharacterRole, useAppState, type Character } from "../../contexts/state";
import styles from '../../assets/character-editor.module.css';
import LLM from "../../utils/llm";
export const CharacterEditor = () => {
const { currentWorld, currentStory, mergedCharacters, dispatch, connection, model } = useAppState();

View File

@ -1,8 +1,8 @@
import { useAppState, type Location, LocationScale } from "../contexts/state";
import { useState } from "preact/hooks";
import styles from '../assets/location-editor.module.css';
import LLM from "../utils/llm";
import { ContentEditable } from "@common/components/ContentEditable";
import { useAppState, type Location, LocationScale } from "../../contexts/state";
import { useState } from "preact/hooks";
import styles from '../../assets/location-editor.module.css';
import LLM from "../../utils/llm";
export const LocationEditor = () => {
const { currentWorld, currentStory, dispatch, connection, model } = useAppState();

View File

@ -1,7 +1,7 @@
import { useAppState, type LoreEntry } from "../contexts/state";
import styles from '../assets/lore-editor.module.css';
import { ContentEditable } from "@common/components/ContentEditable";
import { useState } from "preact/hooks";
import { useAppState, type LoreEntry } from "../../contexts/state";
import styles from '../../assets/lore-editor.module.css';
export const LoreEditor = () => {
const { currentWorld, currentStory, dispatch } = useAppState();

View File

@ -1,5 +1,4 @@
import clsx from "clsx";
import { ConnectionSettingsModal } from "./connection-settings-modal";
import { SettingsModal } from "./settings-modal";
import { useAppState } from "../contexts/state";
import { useBool } from "@common/hooks/useBool";
@ -8,7 +7,7 @@ import type { World, Story } from "../contexts/state";
import { isWorld } from "../contexts/state";
import CharacterCard from "../utils/character-card";
import styles from '../assets/menu.module.css';
import { Pencil, X, Plus, Plug, Settings, Copy, ChevronRight, ChevronDown, Globe, Download, Upload, MessagesSquare, MessageSquarePlus } from "lucide-preact";
import { Pencil, X, Plus, Settings, Copy, ChevronRight, ChevronDown, Globe, Download, Upload, MessagesSquare, MessageSquarePlus } from "lucide-preact";
// ─── Inline Rename Input ──────────────────────────────────────────────────────
@ -191,7 +190,6 @@ const WorldItem = ({
export const Menu = () => {
const { worlds, currentWorld, currentStory, dispatch } = useAppState();
const isConnectionSettingsOpen = useBool(false);
const isSettingsOpen = useBool(false);
const handleCreateWorld = () => {
@ -323,16 +321,10 @@ export const Menu = () => {
<button class={styles.settingsButton} onClick={isSettingsOpen.toggle}>
<Settings size={16} /> Settings
</button>
<button class={styles.settingsButton} onClick={isConnectionSettingsOpen.toggle}>
<Plug size={16} /> Connection Settings
</button>
</div>
{isSettingsOpen.value && (
<SettingsModal onClose={isSettingsOpen.toggle} />
)}
{isConnectionSettingsOpen.value && (
<ConnectionSettingsModal onClose={isConnectionSettingsOpen.toggle} />
)}
</div>
);
};

View File

@ -1,61 +1,20 @@
import clsx from "clsx";
import { useMemo, useState } from "preact/hooks";
import { useState } from "preact/hooks";
import { useInputState } from "@common/hooks/useInputState";
import { useAppState } from "../contexts/state";
import styles from "../assets/settings-modal.module.css";
import { X } from "lucide-preact";
import { useInputCallback } from "@common/hooks/useInputCallback";
import { BannedTokensSettings } from "./settings/banned-tokens";
import { SystemInstructionSettings } from "./settings/system-instruction";
import { ConnectionSettings } from "./settings/connection";
interface Props {
onClose: () => void;
}
type Tab = "banned-tokens" | "system-instruction";
type Tab = "banned-tokens" | "system-instruction" | "connection";
export const SettingsModal = ({ onClose }: Props) => {
const { bannedTokens, systemInstruction, dispatch } = useAppState();
const [inputValue, setInputValue] = useInputState();
const [activeTab, setActiveTab] = useState<Tab>("banned-tokens");
// Save system instruction on every change
const setInstructionValue = useInputCallback((instructionValue) => {
dispatch({
type: "SET_SYSTEM_INSTRUCTION",
systemInstruction: instructionValue,
});
}, []);
const handleAdd = () => {
const trimmed = inputValue.trim();
if (trimmed && !bannedTokens.includes(trimmed)) {
dispatch({
type: "SET_BANNED_TOKENS",
tokens: [...bannedTokens, trimmed],
});
setInputValue("");
}
};
const handleRemove = (token: string) => {
dispatch({
type: "SET_BANNED_TOKENS",
tokens: bannedTokens.filter((t) => t !== token),
});
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
handleAdd();
} else if (e.key === "Escape") {
onClose();
}
};
const sortedTokens = [...bannedTokens].sort((a, b) =>
a.trim().toLowerCase().localeCompare(b.trim().toLowerCase())
);
const [activeTab, setActiveTab] = useState<Tab>("connection");
return (
<div class={styles.overlay} onClick={onClose}>
@ -66,72 +25,32 @@ export const SettingsModal = ({ onClose }: Props) => {
<X size={20} />
</button>
</div>
<div class={styles.tabs}>
<div class={styles.body}>
<nav class={styles.sidebar}>
<button
class={clsx(styles.tab, activeTab === "banned-tokens" && styles.active)}
class={clsx(styles.menuItem, activeTab === "connection" && styles.active)}
onClick={() => setActiveTab("connection")}
>
Connection
</button>
<button
class={clsx(styles.menuItem, activeTab === "banned-tokens" && styles.active)}
onClick={() => setActiveTab("banned-tokens")}
>
Banned Tokens
</button>
<button
class={clsx(styles.tab, activeTab === "system-instruction" && styles.active)}
class={clsx(styles.menuItem, activeTab === "system-instruction" && styles.active)}
onClick={() => setActiveTab("system-instruction")}
>
System Instruction
</button>
</div>
</nav>
<div class={styles.content}>
{activeTab === "banned-tokens" ? (
<>
<div class={styles.inputRow}>
<input
type="text"
value={inputValue}
onInput={setInputValue}
onKeyDown={handleKeyDown}
placeholder="Token to ban"
class={styles.input}
autoFocus
/>
<button onClick={handleAdd} class={clsx(styles.button, styles.buttonPrimary)}>
Add
</button>
{activeTab === "banned-tokens" && <BannedTokensSettings onClose={onClose} />}
{activeTab === "system-instruction" && <SystemInstructionSettings />}
{activeTab === "connection" && <ConnectionSettings />}
</div>
<div class={styles.divider} />
<div class={styles.tokenList}>
{sortedTokens.length === 0 ? (
<p class={styles.emptyText}>No banned tokens</p>
) : (
sortedTokens.map((token) => (
<div key={token} class={styles.tokenItem}>
<span>{token}</span>
<button
onClick={() => handleRemove(token)}
class={styles.tokenRemoveButton}
>
<X size={12} />
</button>
</div>
))
)}
</div>
</>
) : (
<div class={styles.form}>
<div class={styles.formGroup}>
<label class={styles.label}>
System Instruction
</label>
<textarea
value={systemInstruction}
onInput={setInstructionValue}
placeholder="Enter system instruction for the AI assistant..."
class={clsx(styles.input, styles.textarea)}
rows={10}
/>
</div>
</div>
)}
</div>
<div class={styles.footer}>
<button onClick={onClose} class={clsx(styles.button, styles.buttonSecondary)}>

View File

@ -0,0 +1,71 @@
import clsx from "clsx";
import { useState } from "preact/hooks";
import { useInputState } from "@common/hooks/useInputState";
import { useAppState } from "../../contexts/state";
import styles from "../../assets/settings-modal.module.css";
import { X } from "lucide-preact";
interface Props {
onClose: () => void;
}
export const BannedTokensSettings = ({ onClose }: Props) => {
const { bannedTokens, dispatch } = useAppState();
const [inputValue, setInputValue] = useInputState();
const sortedTokens = [...bannedTokens].sort((a, b) =>
a.trim().toLowerCase().localeCompare(b.trim().toLowerCase())
);
const handleAdd = () => {
const trimmed = inputValue.trim();
if (trimmed && !bannedTokens.includes(trimmed)) {
dispatch({ type: "SET_BANNED_TOKENS", tokens: [...bannedTokens, trimmed] });
setInputValue("");
}
};
const handleRemove = (token: string) => {
dispatch({ type: "SET_BANNED_TOKENS", tokens: bannedTokens.filter((t) => t !== token) });
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") handleAdd();
else if (e.key === "Escape") onClose();
};
return (
<>
<div class={styles.inputRow}>
<input
type="text"
value={inputValue}
onInput={setInputValue}
onKeyDown={handleKeyDown}
placeholder="Token to ban"
class={styles.input}
autoFocus
/>
<button onClick={handleAdd} class={clsx(styles.button, styles.buttonPrimary)}>
Add
</button>
</div>
<div class={styles.divider} />
<div class={styles.tokenList}>
{sortedTokens.length === 0 ? (
<p class={styles.emptyText}>No banned tokens</p>
) : (
sortedTokens.map((token) => (
<div key={token} class={styles.tokenItem}>
<span>{token}</span>
<button onClick={() => handleRemove(token)} class={styles.tokenRemoveButton}>
<X size={12} />
</button>
</div>
))
)}
</div>
</>
);
};

View File

@ -0,0 +1,138 @@
import { useMemo, useRef } from "preact/hooks";
import { useInputState } from "@common/hooks/useInputState";
import { useQuery } from "@common/hooks/useAsyncState";
import { useUpdate } from "@common/hooks/useUpdate";
import { useAppState } from "../../contexts/state";
import styles from "../../assets/settings-modal.module.css";
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 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) => {
const aWeight = Number(a.support_tools) * 2 + Number(a.support_thinking);
const bWeight = Number(b.support_tools) * 2 + Number(b.support_thinking);
if (aWeight !== bWeight) return bWeight - aWeight;
const aContext = a.max_context ?? 0;
const bContext = b.max_context ?? 0;
if (aContext !== bContext) return bContext - aContext;
return a.id.localeCompare(b.id);
});
const groups = Map.groupBy(sorted, m => m.max_context ?? 0);
return Array.from(groups.entries())
.sort((a, b) => b[0] - a[0])
.map(([context, models]) => ({ context, models }));
}, [modelsData]);
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>
<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={handleModelChange}
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.support_thinking ? '🧠' : ''}{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>
);
};

View File

@ -0,0 +1,29 @@
import clsx from "clsx";
import { useInputCallback } from "@common/hooks/useInputCallback";
import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight";
import { useAppState } from "../../contexts/state";
import styles from "../../assets/settings-modal.module.css";
export const SystemInstructionSettings = () => {
const { systemInstruction, dispatch } = useAppState();
const setInstructionValue = useInputCallback((value) => {
dispatch({ type: "SET_SYSTEM_INSTRUCTION", systemInstruction: value });
}, []);
return (
<div class={styles.form}>
<div class={clsx(styles.formGroup, styles.formGroupFill)}>
<label class={styles.label}>System Instruction</label>
<ContentEditable
value={highlight(systemInstruction)}
onInput={setInstructionValue}
placeholder="Enter system instruction for the AI assistant..."
class={clsx(styles.input, styles.textarea)}
/>
</div>
</div>
);
};