diff --git a/src/games/ai-story/components/header/header.module.css b/src/games/ai-story/components/header/header.module.css index 134acd7..13d8aa6 100644 --- a/src/games/ai-story/components/header/header.module.css +++ b/src/games/ai-story/components/header/header.module.css @@ -64,4 +64,28 @@ flex-direction: row; gap: 8px; flex-wrap: wrap; +} + +.lore { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 80dvh; + + .currentStory { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 10px; + + .storiesSelector { + height: 24px; + flex-grow: 1; + } + } + + .loreText { + flex-grow: 1; + } } \ No newline at end of file diff --git a/src/games/ai-story/components/header/header.tsx b/src/games/ai-story/components/header/header.tsx index 85cf96a..0bd4e3c 100644 --- a/src/games/ai-story/components/header/header.tsx +++ b/src/games/ai-story/components/header/header.tsx @@ -1,8 +1,9 @@ import { useCallback, useContext, useMemo } from "preact/hooks"; import { useBool } from "@common/hooks/useBool"; import { Modal } from "@common/components/modal/Modal"; +import { useInputCallback } from "@common/hooks/useInputCallback"; -import { StateContext } from "../../contexts/state"; +import { DEFAULT_STORY, StateContext } from "../../contexts/state"; import { LLMContext } from "../../contexts/llm"; import { MiniChat } from "../minichat/minichat"; import { AutoTextarea } from "../autoTextarea"; @@ -14,8 +15,29 @@ import styles from './header.module.css'; export const Header = () => { const { contextLength, promptTokens, modelName, spentKudos } = useContext(LLMContext); const { - messages, connection, systemPrompt, lore, userPrompt, bannedWords, summarizePrompt, summaryEnabled, totalSpentKudos, - setSystemPrompt, setLore, setUserPrompt, addSwipe, setBannedWords, setInstruct, setSummarizePrompt, setSummaryEnabled, setConnection, + messages, + connection, + systemPrompt, + lore, + userPrompt, + bannedWords, + summarizePrompt, + summaryEnabled, + totalSpentKudos, + stories, + currentStory, + setSystemPrompt, + setLore, + setUserPrompt, + addSwipe, + setBannedWords, + setInstruct, + setSummarizePrompt, + setSummaryEnabled, + setConnection, + setCurrentStory, + createStory, + deleteStory, } = useContext(StateContext); const connectionsOpen = useBool(); @@ -53,6 +75,24 @@ export const Header = () => { } }, [setSummaryEnabled]); + const handleChangeStory = useInputCallback((story) => { + if (story === '@new') { + const id = prompt('Story id'); + if (id) { + createStory(id); + setCurrentStory(id); + } + } else { + setCurrentStory(story); + } + }, []); + + const handleDeleteStory = useCallback(() => { + if (confirm(`Delete story "${currentStory}"?`)) { + deleteStory(currentStory); + } + }, [currentStory]); + return (
@@ -90,12 +130,26 @@ export const Header = () => {

Connection settings

- +

Lore Editor

+
+ + {currentStory !== DEFAULT_STORY + ? + : null} +
diff --git a/src/games/ai-story/contexts/state.tsx b/src/games/ai-story/contexts/state.tsx index 0045933..973b956 100644 --- a/src/games/ai-story/contexts/state.tsx +++ b/src/games/ai-story/contexts/state.tsx @@ -4,19 +4,25 @@ import { MessageTools, type IMessage } from "../tools/messages"; import { useInputState } from "@common/hooks/useInputState"; import { type IConnection } from "../tools/connection"; import { loadObject, saveObject } from "../tools/storage"; +import { useInputCallback } from "@common/hooks/useInputCallback"; + +interface IStory { + lore: string; + messages: IMessage[]; +} interface IContext { currentConnection: number; availableConnections: IConnection[]; input: string; systemPrompt: string; - lore: string; userPrompt: string; summarizePrompt: string; summaryEnabled: boolean; bannedWords: string[]; - messages: IMessage[]; totalSpentKudos: number; + stories: Record; + currentStory: string; // triggerNext: boolean; continueLast: boolean; @@ -24,6 +30,8 @@ interface IContext { interface IComputableContext { connection: IConnection; + lore: string; + messages: IMessage[]; } interface IActions { @@ -52,9 +60,14 @@ interface IActions { addSwipe: (index: number, content: string) => void; continueMessage: (continueLast?: boolean) => void; + + setCurrentStory: (id: string) => void; + createStory: (id: string) => void; + deleteStory: (id: string) => void; } const SAVE_KEY = 'ai_game_save_state'; +export const DEFAULT_STORY = 'default'; export enum Instruct { CHATML = `{% for message in messages %}{{'<|im_start|>' + message['role'] + '\\n\\n' + message['content'] + '<|im_end|>' + '\\n'}}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\\n\\n' }}{% endif %}`, @@ -79,7 +92,8 @@ const DEFAULT_CONTEXT: IContext = { }], input: '', systemPrompt: 'You are a creative writer. Write a story based on the world description below. Story should be adult and mature; and could include swearing, violence and unfairness. Portray characters realistically and stay in the lore.', - lore: '', + stories: {}, + currentStory: DEFAULT_STORY, userPrompt: `{% if isStart -%} Write a novel using information above as a reference. {%- else -%} @@ -94,16 +108,23 @@ Make sure to follow the world description and rules exactly. Avoid cliffhangers summarizePrompt: 'Summarize following text in one paragraph:\n\n{{ message }}\n\nAnswer with shortened text only.', summaryEnabled: true, bannedWords: [], - messages: [], totalSpentKudos: 0, triggerNext: false, continueLast: false, }; -export const saveContext = async (context: IContext) => { - const contextToSave: Partial = { ...context }; +const EMPTY_STORY: IStory = { + lore: '', + messages: [], +}; + +const saveContext = async (context: IContext & IComputableContext) => { + const contextToSave: Partial = { ...context }; + delete contextToSave.connection; delete contextToSave.triggerNext; delete contextToSave.continueLast; + delete contextToSave.lore; + delete contextToSave.messages; return saveObject(SAVE_KEY, contextToSave); } @@ -118,12 +139,12 @@ export const StateContextProvider = ({ children }: { children?: any }) => { const [currentConnection, setCurrentConnection] = useState(loadedContext.currentConnection); const [availableConnections, setAvailableConnections] = useState(loadedContext.availableConnections); const [input, setInput] = useInputState(loadedContext.input); - const [lore, setLore] = useInputState(loadedContext.lore); + const [stories, setStories] = useState(loadedContext.stories); + const [currentStory, setCurrentStory] = useState(loadedContext.currentStory); const [systemPrompt, setSystemPrompt] = useInputState(loadedContext.systemPrompt); const [userPrompt, setUserPrompt] = useInputState(loadedContext.userPrompt); const [summarizePrompt, setSummarizePrompt] = useInputState(loadedContext.summarizePrompt); const [bannedWords, setBannedWords] = useState(loadedContext.bannedWords); - const [messages, setMessages] = useState(loadedContext.messages); const [summaryEnabled, setSummaryEnabled] = useState(loadedContext.summaryEnabled); const [totalSpentKudos, setTotalSpentKudos] = useState(loadedContext.totalSpentKudos); @@ -145,6 +166,35 @@ export const StateContextProvider = ({ children }: { children?: any }) => { useEffect(() => setConnection({ ...connection, instruct }), [instruct]); + const setLore = useInputCallback((lore) => { + if (!currentStory) return; + setStories(ss => ({ + ...ss, + [currentStory]: { + ...EMPTY_STORY, + ...stories[currentStory], + lore, + } + })); + }, [currentStory]); + + const setMessages = useCallback((msg: StateUpdater) => { + if (!currentStory) return; + + let messages = (typeof msg === 'function') + ? msg(stories[currentStory]?.messages ?? EMPTY_STORY.messages) + : msg; + + setStories(ss => ({ + ...ss, + [currentStory]: { + ...EMPTY_STORY, + ...stories[currentStory], + messages, + } + })); + }, [currentStory]); + const actions: IActions = useMemo(() => ({ setConnection, setCurrentConnection, @@ -159,6 +209,7 @@ export const StateContextProvider = ({ children }: { children?: any }) => { setTriggerNext, setContinueLast, setTotalSpentKudos, + setCurrentStory, setBannedWords: (words) => setBannedWords(words.slice()), setAvailableConnections: (connections) => setAvailableConnections(connections.slice()), @@ -233,7 +284,19 @@ export const StateContextProvider = ({ children }: { children?: any }) => { setTriggerNext(true); setContinueLast(c); }, - }), []); + createStory: (id: string) => { + setStories(ss => ({ + ...ss, + [id]: { ...EMPTY_STORY } + })) + }, + deleteStory: (id: string) => { + if (id === DEFAULT_STORY) return; + + setStories(ss => Object.fromEntries(Object.entries(ss).filter(([k]) => k !== id))); + setCurrentStory(cs => cs === id ? DEFAULT_STORY : cs); + } + }), [setLore, setMessages]); const rawContext: IContext & IComputableContext = { connection, @@ -241,16 +304,18 @@ export const StateContextProvider = ({ children }: { children?: any }) => { availableConnections, input, systemPrompt, - lore, userPrompt, summarizePrompt, summaryEnabled, bannedWords, - messages, totalSpentKudos, + stories, + currentStory, // triggerNext, continueLast, + lore: stories[currentStory]?.lore ?? '', + messages: stories[currentStory]?.messages ?? [], }; const context = useMemo(() => rawContext, Object.values(rawContext));