From 21a26859da755cc7c8db69c3632cf3e867255cee Mon Sep 17 00:00:00 2001 From: Pabloader Date: Tue, 17 Feb 2026 09:42:27 +0000 Subject: [PATCH] Remote storage for saves --- .../components/header/connectionEditor.tsx | 54 +++++++++---------- .../ai-story/components/header/header.tsx | 8 +-- src/games/ai-story/contexts/llm.tsx | 13 +++-- src/games/ai-story/contexts/state.tsx | 24 +++------ src/games/ai-story/tools/connection.ts | 30 +++++------ src/games/ai-story/tools/huggingface.ts | 25 ++------- src/games/ai-story/tools/storage.ts | 44 +++++++++++++++ 7 files changed, 110 insertions(+), 88 deletions(-) create mode 100644 src/games/ai-story/tools/storage.ts diff --git a/src/games/ai-story/components/header/connectionEditor.tsx b/src/games/ai-story/components/header/connectionEditor.tsx index d69cd2e..35860b8 100644 --- a/src/games/ai-story/components/header/connectionEditor.tsx +++ b/src/games/ai-story/components/header/connectionEditor.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; import styles from './header.module.css'; -import { Connection, HORDE_ANON_KEY, isHordeConnection, isKoboldConnection, type IConnection, type IHordeModel } from '../../tools/connection'; +import { Connection, HORDE_ANON_KEY, type IConnection, type IHordeModel } from '../../tools/connection'; import { Instruct } from '../../contexts/state'; import { useInputState } from '@common/hooks/useInputState'; import { useInputCallback } from '@common/hooks/useInputCallback'; @@ -23,24 +23,17 @@ export const ConnectionEditor = ({ connection, setConnection }: IProps) => { const [hordeModels, setHordeModels] = useState([]); const [contextLength, setContextLength] = useState(0); - const backendType = useMemo(() => { - if (isKoboldConnection(connection)) return 'kobold'; - if (isHordeConnection(connection)) return 'horde'; - return 'unknown'; - }, [connection]); - const isOnline = useMemo(() => contextLength > 0, [contextLength]); useEffect(() => { setInstruct(connection.instruct); + connection.url && setConnectionUrl(connection.url); + connection.model && setModelName(connection.model); + setApiKey(connection.apiKey || HORDE_ANON_KEY); - if (isKoboldConnection(connection)) { - setConnectionUrl(connection.url); + if (connection.type === 'kobold') { Connection.getContextLength(connection).then(setContextLength); - } else if (isHordeConnection(connection)) { - setModelName(connection.model); - setApiKey(connection.apiKey || HORDE_ANON_KEY); - + } else if (connection.type === 'horde') { Connection.getHordeModels() .then(m => setHordeModels(Array.from(m.values()).sort((a, b) => a.name.localeCompare(b.name)))); } @@ -59,22 +52,22 @@ export const ConnectionEditor = ({ connection, setConnection }: IProps) => { }, [modelName]); const setBackendType = useInputCallback((type) => { - if (type === 'kobold') { - setConnection({ - instruct, - url: connectionUrl, - }); - } else if (type === 'horde') { - setConnection({ - instruct, - apiKey, - model: modelName, - }); + switch (type) { + case 'kobold': + case 'horde': + setConnection({ + type, + instruct, + url: connectionUrl, + apiKey, + model: modelName, + }); + break; } }, [setConnection, connectionUrl, apiKey, modelName, instruct]); const handleSetInstruct = useInputCallback((instruct: string) => { - setConnection({...connection, instruct}); + setConnection({ ...connection, instruct }); }, [setConnection, connection]); const handleBlurUrl = useCallback(() => { @@ -82,14 +75,19 @@ export const ConnectionEditor = ({ connection, setConnection }: IProps) => { const url = connectionUrl.replace(regex, 'http$1://$2'); setConnection({ + type: 'kobold', instruct, url, + apiKey, + model: modelName, }); }, [connectionUrl, instruct, setConnection]); const handleBlurHorde = useCallback(() => { setConnection({ + type: 'horde', instruct, + url: connectionUrl, apiKey, model: modelName, }); @@ -97,7 +95,7 @@ export const ConnectionEditor = ({ connection, setConnection }: IProps) => { return (
- @@ -116,13 +114,13 @@ export const ConnectionEditor = ({ connection, setConnection }: IProps) => { } - {isKoboldConnection(connection) && } - {isHordeConnection(connection) && <> + {connection.type === 'horde' && <> {
-
{modelName} 📃{promptTokens}/{contextLength} - 💲{spentKudos} - 💰{totalSpentKudos} + {connection.type === 'horde' ? <> + 💲{spentKudos} + 💰{totalSpentKudos} + : null}
diff --git a/src/games/ai-story/contexts/llm.tsx b/src/games/ai-story/contexts/llm.tsx index 3580c89..08576a3 100644 --- a/src/games/ai-story/contexts/llm.tsx +++ b/src/games/ai-story/contexts/llm.tsx @@ -189,15 +189,22 @@ export const LLMContextProvider = ({ children }: { children?: any }) => { } } }, - summarize: async (message) => { + summarize: async (message) => { try { const content = Huggingface.applyTemplate(summarizePrompt, { message }); const prompt = Huggingface.applyChatTemplate(connection.instruct, [{ role: 'user', content }]); console.log('[LLM.summarize]', prompt); - const tokens = await Array.fromAsync(Connection.generate(connection, prompt, {})); + const tokens = await Array.fromAsync(Connection.generate(connection, prompt)); + const summary = tokens.reduce((sum, token) => ({ + text: sum.text + token.text, + cost: sum.cost + token.cost, + }), { text: '', cost: 0 }); - return MessageTools.trimSentence(tokens.join('')); + setSpentKudos(sk => sk + summary.cost); + setTotalSpentKudos(sk => sk + summary.cost); + + return MessageTools.trimSentence(summary.text); } catch (e) { console.error('Error summarizing:', e); return ''; diff --git a/src/games/ai-story/contexts/state.tsx b/src/games/ai-story/contexts/state.tsx index 893ecfe..0045933 100644 --- a/src/games/ai-story/contexts/state.tsx +++ b/src/games/ai-story/contexts/state.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState, type Dispatch, type StateUpd 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"; interface IContext { currentConnection: number; @@ -72,8 +73,9 @@ export enum Instruct { const DEFAULT_CONTEXT: IContext = { currentConnection: 0, availableConnections: [{ + type: 'kobold', url: 'http://localhost:5001', - instruct: Instruct.CHATML, + instruct: Instruct.MISTRAL, }], 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.', @@ -98,33 +100,21 @@ Make sure to follow the world description and rules exactly. Avoid cliffhangers continueLast: false, }; -export const saveContext = (context: IContext) => { +export const saveContext = async (context: IContext) => { const contextToSave: Partial = { ...context }; delete contextToSave.triggerNext; delete contextToSave.continueLast; - localStorage.setItem(SAVE_KEY, JSON.stringify(contextToSave)); -} - -export const loadContext = (): IContext => { - let loadedContext: Partial = {}; - - try { - const json = localStorage.getItem(SAVE_KEY); - if (json) { - loadedContext = JSON.parse(json); - } - } catch { } - - return { ...DEFAULT_CONTEXT, ...loadedContext }; + return saveObject(SAVE_KEY, contextToSave); } export type IStateContext = IContext & IActions & IComputableContext; export const StateContext = createContext({} as IStateContext); +const loadedContext = await loadObject(SAVE_KEY, DEFAULT_CONTEXT); + export const StateContextProvider = ({ children }: { children?: any }) => { - const loadedContext = useMemo(() => loadContext(), []); const [currentConnection, setCurrentConnection] = useState(loadedContext.currentConnection); const [availableConnections, setAvailableConnections] = useState(loadedContext.availableConnections); const [input, setInput] = useInputState(loadedContext.input); diff --git a/src/games/ai-story/tools/connection.ts b/src/games/ai-story/tools/connection.ts index b24acf8..179480e 100644 --- a/src/games/ai-story/tools/connection.ts +++ b/src/games/ai-story/tools/connection.ts @@ -6,26 +6,24 @@ import { Huggingface } from "./huggingface"; import { approximateTokens, normalizeModel } from "./model"; interface IBaseConnection { + type: 'kobold' | 'horde'; instruct: string; + url?: string; + apiKey?: string; + model?: string; } interface IKoboldConnection extends IBaseConnection { + type: 'kobold'; url: string; } interface IHordeConnection extends IBaseConnection { + type: 'horde'; apiKey?: string; model: string; } -export const isKoboldConnection = (obj: unknown): obj is IKoboldConnection => ( - obj != null && typeof obj === 'object' && 'url' in obj && typeof obj.url === 'string' -); - -export const isHordeConnection = (obj: unknown): obj is IHordeConnection => ( - obj != null && typeof obj === 'object' && 'model' in obj && typeof obj.model === 'string' -); - export type IConnection = IKoboldConnection | IHordeConnection; interface IHordeWorker { @@ -264,9 +262,9 @@ export namespace Connection { } export async function* generate(connection: IConnection, prompt: string, extraSettings: IGenerationSettings = {}) { - if (isKoboldConnection(connection)) { + if (connection.type === 'kobold') { yield* generateKobold(connection.url, prompt, extraSettings); - } else if (isHordeConnection(connection)) { + } else if (connection.type === 'horde') { yield* generateHorde(connection, prompt, extraSettings); } } @@ -331,7 +329,7 @@ export namespace Connection { export const getHordeModels = throttle(requestHordeModels, 10000); export async function getModelName(connection: IConnection): Promise { - if (isKoboldConnection(connection)) { + if (connection.type === 'kobold') { try { const response = await fetch(`${connection.url}/api/v1/model`); if (response.ok) { @@ -341,7 +339,7 @@ export namespace Connection { } catch (e) { console.error('Error getting max tokens', e); } - } else if (isHordeConnection(connection)) { + } else if (connection.type === 'horde') { return connection.model; } @@ -349,7 +347,7 @@ export namespace Connection { } export async function getContextLength(connection: IConnection): Promise { - if (isKoboldConnection(connection)) { + if (connection.type === 'kobold') { try { const response = await fetch(`${connection.url}/api/extra/true_max_context_length`); if (response.ok) { @@ -359,7 +357,7 @@ export namespace Connection { } catch (e) { console.error('Error getting max tokens', e); } - } else if (isHordeConnection(connection) && connection.model) { + } else if (connection.type === 'horde' && connection.model) { const models = await getHordeModels(); const model = models.get(connection.model); if (model) { @@ -371,7 +369,7 @@ export namespace Connection { } export async function countTokens(connection: IConnection, prompt: string) { - if (isKoboldConnection(connection)) { + if (connection.type === 'kobold') { try { const response = await fetch(`${connection.url}/api/extra/tokencount`, { body: JSON.stringify({ prompt }), @@ -385,7 +383,7 @@ export namespace Connection { } catch (e) { console.error('Error counting tokens:', e); } - } else { + } else if (connection.type === 'horde') { const model = await getModelName(connection); const tokenizer = await Huggingface.findTokenizer(model); if (tokenizer) { diff --git a/src/games/ai-story/tools/huggingface.ts b/src/games/ai-story/tools/huggingface.ts index ff76f00..bc8da91 100644 --- a/src/games/ai-story/tools/huggingface.ts +++ b/src/games/ai-story/tools/huggingface.ts @@ -3,6 +3,7 @@ import * as hub from '@huggingface/hub'; import { Template } from '@huggingface/jinja'; import { AutoTokenizer, PreTrainedTokenizer } from '@huggingface/transformers'; import { normalizeModel } from './model'; +import { loadObject, saveObject } from './storage'; export namespace Huggingface { export interface ITemplateMessage { @@ -60,27 +61,9 @@ export namespace Huggingface { const TEMPLATE_CACHE_KEY = 'ai_game_template_cache'; - const loadCache = (): Record => { - const json = localStorage.getItem(TEMPLATE_CACHE_KEY); + const templateCache: Record = {}; + loadObject(TEMPLATE_CACHE_KEY, {}).then(c => Object.assign(templateCache, c)); - try { - if (json) { - const cache = JSON.parse(json); - if (cache && typeof cache === 'object') { - return cache - } - } - } catch { } - - return {}; - }; - - const saveCache = (cache: Record) => { - const json = JSON.stringify(cache); - localStorage.setItem(TEMPLATE_CACHE_KEY, json); - }; - - const templateCache: Record = loadCache(); const compiledTemplates = new Map(); const tokenizerCache = new Map(); @@ -261,7 +244,7 @@ export namespace Huggingface { } templateCache[modelName] = template; - saveCache(templateCache); + saveObject(TEMPLATE_CACHE_KEY, templateCache); return template; } diff --git a/src/games/ai-story/tools/storage.ts b/src/games/ai-story/tools/storage.ts new file mode 100644 index 0000000..03802e1 --- /dev/null +++ b/src/games/ai-story/tools/storage.ts @@ -0,0 +1,44 @@ +const API_KEY = 'awoorwa32'; + +export const loadObject = async (key: string, defaultObject: T): Promise => { + let localObject: Partial = {}; + + try { + const json = localStorage.getItem(key); + if (json) { + localObject = JSON.parse(json); + } + } catch { } + + let remoteObject: Partial = {}; + try { + const response = await fetch(`https://demo.pabloader.ru/storage/${key}`); + if (response.ok) { + remoteObject = await response.json(); + } + } catch { } + + return { ...defaultObject, ...localObject, ...remoteObject }; +} + +export const saveObject = async (key: string, obj: T) => { + const saveData = JSON.stringify(obj); + + localStorage.setItem(key, saveData); + try { + const url = new URL('https://demo.pabloader.ru/storage/index.php'); + url.searchParams.set('filename', key); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${API_KEY}`, + }, + body: saveData, + }); + if (!response.ok) { + throw new Error('Failed to save context'); + } + } catch { + } +} \ No newline at end of file