diff --git a/src/games/storywriter/assets/settings-modal.module.css b/src/games/storywriter/assets/settings-modal.module.css index 952f2a4..babe6a1 100644 --- a/src/games/storywriter/assets/settings-modal.module.css +++ b/src/games/storywriter/assets/settings-modal.module.css @@ -111,7 +111,8 @@ } .input, -.select { +.select, +.textarea { width: 100%; padding: 8px; border-radius: 4px; @@ -120,6 +121,13 @@ color: var(--text); } +.textarea { + resize: vertical; + font-family: inherit; + font-size: inherit; + line-height: 1.5; +} + .footer { padding: 16px 20px; border-top: 1px solid var(--border); diff --git a/src/games/storywriter/components/banned-tokens-modal.tsx b/src/games/storywriter/components/banned-tokens-modal.tsx deleted file mode 100644 index 763ba44..0000000 --- a/src/games/storywriter/components/banned-tokens-modal.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import clsx from "clsx"; - -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 BannedTokensModal = ({ onClose }: Props) => { - const { bannedTokens, dispatch } = useAppState(); - const [inputValue, setInputValue] = useInputState(); - - 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()) - ); - - return ( -
-
e.stopPropagation()}> -
-

Banned Tokens

- -
-
-
- - -
-
-
- {sortedTokens.length === 0 ? ( -

No banned tokens

- ) : ( - sortedTokens.map((token) => ( -
- {token} - -
- )) - )} -
-
-
- -
-
-
- ); -}; diff --git a/src/games/storywriter/components/connection-settings-modal.tsx b/src/games/storywriter/components/connection-settings-modal.tsx new file mode 100644 index 0000000..5f23328 --- /dev/null +++ b/src/games/storywriter/components/connection-settings-modal.tsx @@ -0,0 +1,185 @@ +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(() => { + 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 ( +
+
e.stopPropagation()}> +
+

Connection Settings

+ +
+
+
+
+ + +
+
+ + +
+
+ + {connectionToTest ? ( + isLoadingModels ? ( +

Loading models...

+ ) : groupedModels.length > 0 ? ( + + ) : ( +

No models available

+ ) + ) : ( +

Enter connection details to load models

+ )} +
+
+
+
+ + +
+
+
+ ); +}; diff --git a/src/games/storywriter/components/menu-sidebar.tsx b/src/games/storywriter/components/menu-sidebar.tsx index 63d7370..1e43545 100644 --- a/src/games/storywriter/components/menu-sidebar.tsx +++ b/src/games/storywriter/components/menu-sidebar.tsx @@ -1,13 +1,13 @@ import clsx from "clsx"; import { Sidebar } from "./sidebar"; +import { ConnectionSettingsModal } from "./connection-settings-modal"; import { SettingsModal } from "./settings-modal"; -import { BannedTokensModal } from "./banned-tokens-modal"; import { useAppState } from "../contexts/state"; import { useBool } from "@common/hooks/useBool"; import type { Story } from "../contexts/state"; import styles from '../assets/menu-sidebar.module.css'; import { useState } from "preact/hooks"; -import { Pencil, X, Plus, Settings, Ban } from "lucide-preact"; +import { Pencil, X, Plus, Plug, Settings } from "lucide-preact"; // ─── Story Item ─────────────────────────────────────────────────────────────── @@ -82,8 +82,8 @@ const StoryItem = ({ story, active, onSelect, onRename, onDelete }: StoryItemPro export const MenuSidebar = () => { const { stories, currentStory, dispatch } = useAppState(); + const isConnectionSettingsOpen = useBool(false); const isSettingsOpen = useBool(false); - const isBannedTokensOpen = useBool(false); const handleCreate = () => { dispatch({ type: 'CREATE_STORY', title: 'New Story' }); @@ -124,20 +124,20 @@ export const MenuSidebar = () => { ))}
- +
- {isBannedTokensOpen.value && ( - - )} {isSettingsOpen.value && ( )} + {isConnectionSettingsOpen.value && ( + + )} ); }; diff --git a/src/games/storywriter/components/settings-modal.tsx b/src/games/storywriter/components/settings-modal.tsx index c27e484..2912141 100644 --- a/src/games/storywriter/components/settings-modal.tsx +++ b/src/games/storywriter/components/settings-modal.tsx @@ -1,99 +1,61 @@ -import { useMemo, useRef } from "preact/hooks"; +import clsx from "clsx"; +import { useMemo, useState } 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"; +import { useInputCallback } from "@common/hooks/useInputCallback"; interface Props { onClose: () => void; } +type Tab = "banned-tokens" | "system-instruction"; + 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?.id ?? ""); - const [update, triggerFetch] = useUpdate(); + const { bannedTokens, systemInstruction, dispatch } = useAppState(); + const [inputValue, setInputValue] = useInputState(); + const [activeTab, setActiveTab] = useState("banned-tokens"); - const urlRef = useRef(url); - const apiKeyRef = useRef(apiKey); - - urlRef.current = url; - apiKeyRef.current = apiKey; - - const connectionToFetch = useMemo(() => { - 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; + // Save system instruction on every change + const setInstructionValue = useInputCallback((instructionValue) => { + dispatch({ + type: "SET_SYSTEM_INSTRUCTION", + systemInstruction: instructionValue, + }); }, []); - 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 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' && url && apiKey) { - triggerFetch(); + if (e.key === "Enter") { + handleAdd(); + } else if (e.key === "Escape") { + onClose(); } }; - 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; + const sortedTokens = [...bannedTokens].sort((a, b) => + a.trim().toLowerCase().localeCompare(b.trim().toLowerCase()) + ); return (
@@ -104,79 +66,76 @@ export const SettingsModal = ({ onClose }: Props) => {
+
+ + +
-
-
- - -
-
- - -
-
- - {connectionToTest ? ( - isLoadingModels ? ( -

Loading models...

- ) : groupedModels.length > 0 ? ( - + {activeTab === "banned-tokens" ? ( + <> +
+ + +
+
+
+ {sortedTokens.length === 0 ? ( +

No banned tokens

) : ( -

No models available

- ) - ) : ( -

Enter connection details to load models

- )} + sortedTokens.map((token) => ( +
+ {token} + +
+ )) + )} +
+ + ) : ( +
+
+ +