1
0
Fork 0

Remote storage for saves

This commit is contained in:
Pabloader 2026-02-17 09:42:27 +00:00
parent a0b73eb306
commit 21a26859da
7 changed files with 110 additions and 88 deletions

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
import styles from './header.module.css'; 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 { Instruct } from '../../contexts/state';
import { useInputState } from '@common/hooks/useInputState'; import { useInputState } from '@common/hooks/useInputState';
import { useInputCallback } from '@common/hooks/useInputCallback'; import { useInputCallback } from '@common/hooks/useInputCallback';
@ -23,24 +23,17 @@ export const ConnectionEditor = ({ connection, setConnection }: IProps) => {
const [hordeModels, setHordeModels] = useState<IHordeModel[]>([]); const [hordeModels, setHordeModels] = useState<IHordeModel[]>([]);
const [contextLength, setContextLength] = useState<number>(0); const [contextLength, setContextLength] = useState<number>(0);
const backendType = useMemo(() => {
if (isKoboldConnection(connection)) return 'kobold';
if (isHordeConnection(connection)) return 'horde';
return 'unknown';
}, [connection]);
const isOnline = useMemo(() => contextLength > 0, [contextLength]); const isOnline = useMemo(() => contextLength > 0, [contextLength]);
useEffect(() => { useEffect(() => {
setInstruct(connection.instruct); setInstruct(connection.instruct);
connection.url && setConnectionUrl(connection.url);
if (isKoboldConnection(connection)) { connection.model && setModelName(connection.model);
setConnectionUrl(connection.url);
Connection.getContextLength(connection).then(setContextLength);
} else if (isHordeConnection(connection)) {
setModelName(connection.model);
setApiKey(connection.apiKey || HORDE_ANON_KEY); setApiKey(connection.apiKey || HORDE_ANON_KEY);
if (connection.type === 'kobold') {
Connection.getContextLength(connection).then(setContextLength);
} else if (connection.type === 'horde') {
Connection.getHordeModels() Connection.getHordeModels()
.then(m => setHordeModels(Array.from(m.values()).sort((a, b) => a.name.localeCompare(b.name)))); .then(m => setHordeModels(Array.from(m.values()).sort((a, b) => a.name.localeCompare(b.name))));
} }
@ -59,17 +52,17 @@ export const ConnectionEditor = ({ connection, setConnection }: IProps) => {
}, [modelName]); }, [modelName]);
const setBackendType = useInputCallback((type) => { const setBackendType = useInputCallback((type) => {
if (type === 'kobold') { switch (type) {
case 'kobold':
case 'horde':
setConnection({ setConnection({
type,
instruct, instruct,
url: connectionUrl, url: connectionUrl,
});
} else if (type === 'horde') {
setConnection({
instruct,
apiKey, apiKey,
model: modelName, model: modelName,
}); });
break;
} }
}, [setConnection, connectionUrl, apiKey, modelName, instruct]); }, [setConnection, connectionUrl, apiKey, modelName, instruct]);
@ -82,14 +75,19 @@ export const ConnectionEditor = ({ connection, setConnection }: IProps) => {
const url = connectionUrl.replace(regex, 'http$1://$2'); const url = connectionUrl.replace(regex, 'http$1://$2');
setConnection({ setConnection({
type: 'kobold',
instruct, instruct,
url, url,
apiKey,
model: modelName,
}); });
}, [connectionUrl, instruct, setConnection]); }, [connectionUrl, instruct, setConnection]);
const handleBlurHorde = useCallback(() => { const handleBlurHorde = useCallback(() => {
setConnection({ setConnection({
type: 'horde',
instruct, instruct,
url: connectionUrl,
apiKey, apiKey,
model: modelName, model: modelName,
}); });
@ -97,7 +95,7 @@ export const ConnectionEditor = ({ connection, setConnection }: IProps) => {
return ( return (
<div class={styles.connectionEditor}> <div class={styles.connectionEditor}>
<select value={backendType} onChange={setBackendType}> <select value={connection.type} onChange={setBackendType}>
<option value='kobold'>Kobold CPP</option> <option value='kobold'>Kobold CPP</option>
<option value='horde'>Horde</option> <option value='horde'>Horde</option>
</select> </select>
@ -116,13 +114,13 @@ export const ConnectionEditor = ({ connection, setConnection }: IProps) => {
<option value={connection.instruct}>Custom</option> <option value={connection.instruct}>Custom</option>
</optgroup>} </optgroup>}
</select> </select>
{isKoboldConnection(connection) && <input {connection.type === 'kobold' && <input
value={connectionUrl} value={connectionUrl}
onInput={setConnectionUrl} onInput={setConnectionUrl}
onBlur={handleBlurUrl} onBlur={handleBlurUrl}
class={isOnline ? styles.valid : styles.invalid} class={isOnline ? styles.valid : styles.invalid}
/>} />}
{isHordeConnection(connection) && <> {connection.type === 'horde' && <>
<input <input
placeholder='Horde API key' placeholder='Horde API key'
title='Horde API key' title='Horde API key'

View File

@ -64,8 +64,10 @@ export const Header = () => {
<div class={styles.info}> <div class={styles.info}>
<span>{modelName}</span> <span>{modelName}</span>
<span>📃{promptTokens}/{contextLength}</span> <span>📃{promptTokens}/{contextLength}</span>
{connection.type === 'horde' ? <>
<span>💲{spentKudos}</span> <span>💲{spentKudos}</span>
<span>💰{totalSpentKudos}</span> <span>💰{totalSpentKudos}</span>
</> : null}
</div> </div>
</div> </div>
<div class={styles.buttons}> <div class={styles.buttons}>

View File

@ -195,9 +195,16 @@ export const LLMContextProvider = ({ children }: { children?: any }) => {
const prompt = Huggingface.applyChatTemplate(connection.instruct, [{ role: 'user', content }]); const prompt = Huggingface.applyChatTemplate(connection.instruct, [{ role: 'user', content }]);
console.log('[LLM.summarize]', prompt); 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) { } catch (e) {
console.error('Error summarizing:', e); console.error('Error summarizing:', e);
return ''; return '';

View File

@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState, type Dispatch, type StateUpd
import { MessageTools, type IMessage } from "../tools/messages"; import { MessageTools, type IMessage } from "../tools/messages";
import { useInputState } from "@common/hooks/useInputState"; import { useInputState } from "@common/hooks/useInputState";
import { type IConnection } from "../tools/connection"; import { type IConnection } from "../tools/connection";
import { loadObject, saveObject } from "../tools/storage";
interface IContext { interface IContext {
currentConnection: number; currentConnection: number;
@ -72,8 +73,9 @@ export enum Instruct {
const DEFAULT_CONTEXT: IContext = { const DEFAULT_CONTEXT: IContext = {
currentConnection: 0, currentConnection: 0,
availableConnections: [{ availableConnections: [{
type: 'kobold',
url: 'http://localhost:5001', url: 'http://localhost:5001',
instruct: Instruct.CHATML, instruct: Instruct.MISTRAL,
}], }],
input: '', 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.', 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, continueLast: false,
}; };
export const saveContext = (context: IContext) => { export const saveContext = async (context: IContext) => {
const contextToSave: Partial<IContext> = { ...context }; const contextToSave: Partial<IContext> = { ...context };
delete contextToSave.triggerNext; delete contextToSave.triggerNext;
delete contextToSave.continueLast; delete contextToSave.continueLast;
localStorage.setItem(SAVE_KEY, JSON.stringify(contextToSave)); return saveObject(SAVE_KEY, contextToSave);
}
export const loadContext = (): IContext => {
let loadedContext: Partial<IContext> = {};
try {
const json = localStorage.getItem(SAVE_KEY);
if (json) {
loadedContext = JSON.parse(json);
}
} catch { }
return { ...DEFAULT_CONTEXT, ...loadedContext };
} }
export type IStateContext = IContext & IActions & IComputableContext; export type IStateContext = IContext & IActions & IComputableContext;
export const StateContext = createContext<IStateContext>({} as IStateContext); export const StateContext = createContext<IStateContext>({} as IStateContext);
const loadedContext = await loadObject(SAVE_KEY, DEFAULT_CONTEXT);
export const StateContextProvider = ({ children }: { children?: any }) => { export const StateContextProvider = ({ children }: { children?: any }) => {
const loadedContext = useMemo(() => loadContext(), []);
const [currentConnection, setCurrentConnection] = useState<number>(loadedContext.currentConnection); const [currentConnection, setCurrentConnection] = useState<number>(loadedContext.currentConnection);
const [availableConnections, setAvailableConnections] = useState<IConnection[]>(loadedContext.availableConnections); const [availableConnections, setAvailableConnections] = useState<IConnection[]>(loadedContext.availableConnections);
const [input, setInput] = useInputState(loadedContext.input); const [input, setInput] = useInputState(loadedContext.input);

View File

@ -6,26 +6,24 @@ import { Huggingface } from "./huggingface";
import { approximateTokens, normalizeModel } from "./model"; import { approximateTokens, normalizeModel } from "./model";
interface IBaseConnection { interface IBaseConnection {
type: 'kobold' | 'horde';
instruct: string; instruct: string;
url?: string;
apiKey?: string;
model?: string;
} }
interface IKoboldConnection extends IBaseConnection { interface IKoboldConnection extends IBaseConnection {
type: 'kobold';
url: string; url: string;
} }
interface IHordeConnection extends IBaseConnection { interface IHordeConnection extends IBaseConnection {
type: 'horde';
apiKey?: string; apiKey?: string;
model: 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; export type IConnection = IKoboldConnection | IHordeConnection;
interface IHordeWorker { interface IHordeWorker {
@ -264,9 +262,9 @@ export namespace Connection {
} }
export async function* generate(connection: IConnection, prompt: string, extraSettings: IGenerationSettings = {}) { export async function* generate(connection: IConnection, prompt: string, extraSettings: IGenerationSettings = {}) {
if (isKoboldConnection(connection)) { if (connection.type === 'kobold') {
yield* generateKobold(connection.url, prompt, extraSettings); yield* generateKobold(connection.url, prompt, extraSettings);
} else if (isHordeConnection(connection)) { } else if (connection.type === 'horde') {
yield* generateHorde(connection, prompt, extraSettings); yield* generateHorde(connection, prompt, extraSettings);
} }
} }
@ -331,7 +329,7 @@ export namespace Connection {
export const getHordeModels = throttle(requestHordeModels, 10000); export const getHordeModels = throttle(requestHordeModels, 10000);
export async function getModelName(connection: IConnection): Promise<string> { export async function getModelName(connection: IConnection): Promise<string> {
if (isKoboldConnection(connection)) { if (connection.type === 'kobold') {
try { try {
const response = await fetch(`${connection.url}/api/v1/model`); const response = await fetch(`${connection.url}/api/v1/model`);
if (response.ok) { if (response.ok) {
@ -341,7 +339,7 @@ export namespace Connection {
} catch (e) { } catch (e) {
console.error('Error getting max tokens', e); console.error('Error getting max tokens', e);
} }
} else if (isHordeConnection(connection)) { } else if (connection.type === 'horde') {
return connection.model; return connection.model;
} }
@ -349,7 +347,7 @@ export namespace Connection {
} }
export async function getContextLength(connection: IConnection): Promise<number> { export async function getContextLength(connection: IConnection): Promise<number> {
if (isKoboldConnection(connection)) { if (connection.type === 'kobold') {
try { try {
const response = await fetch(`${connection.url}/api/extra/true_max_context_length`); const response = await fetch(`${connection.url}/api/extra/true_max_context_length`);
if (response.ok) { if (response.ok) {
@ -359,7 +357,7 @@ export namespace Connection {
} catch (e) { } catch (e) {
console.error('Error getting max tokens', 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 models = await getHordeModels();
const model = models.get(connection.model); const model = models.get(connection.model);
if (model) { if (model) {
@ -371,7 +369,7 @@ export namespace Connection {
} }
export async function countTokens(connection: IConnection, prompt: string) { export async function countTokens(connection: IConnection, prompt: string) {
if (isKoboldConnection(connection)) { if (connection.type === 'kobold') {
try { try {
const response = await fetch(`${connection.url}/api/extra/tokencount`, { const response = await fetch(`${connection.url}/api/extra/tokencount`, {
body: JSON.stringify({ prompt }), body: JSON.stringify({ prompt }),
@ -385,7 +383,7 @@ export namespace Connection {
} catch (e) { } catch (e) {
console.error('Error counting tokens:', e); console.error('Error counting tokens:', e);
} }
} else { } else if (connection.type === 'horde') {
const model = await getModelName(connection); const model = await getModelName(connection);
const tokenizer = await Huggingface.findTokenizer(model); const tokenizer = await Huggingface.findTokenizer(model);
if (tokenizer) { if (tokenizer) {

View File

@ -3,6 +3,7 @@ import * as hub from '@huggingface/hub';
import { Template } from '@huggingface/jinja'; import { Template } from '@huggingface/jinja';
import { AutoTokenizer, PreTrainedTokenizer } from '@huggingface/transformers'; import { AutoTokenizer, PreTrainedTokenizer } from '@huggingface/transformers';
import { normalizeModel } from './model'; import { normalizeModel } from './model';
import { loadObject, saveObject } from './storage';
export namespace Huggingface { export namespace Huggingface {
export interface ITemplateMessage { export interface ITemplateMessage {
@ -60,27 +61,9 @@ export namespace Huggingface {
const TEMPLATE_CACHE_KEY = 'ai_game_template_cache'; const TEMPLATE_CACHE_KEY = 'ai_game_template_cache';
const loadCache = (): Record<string, string> => { const templateCache: Record<string, string> = {};
const json = localStorage.getItem(TEMPLATE_CACHE_KEY); 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<string, string>) => {
const json = JSON.stringify(cache);
localStorage.setItem(TEMPLATE_CACHE_KEY, json);
};
const templateCache: Record<string, string> = loadCache();
const compiledTemplates = new Map<string, Template>(); const compiledTemplates = new Map<string, Template>();
const tokenizerCache = new Map<string, PreTrainedTokenizer | null>(); const tokenizerCache = new Map<string, PreTrainedTokenizer | null>();
@ -261,7 +244,7 @@ export namespace Huggingface {
} }
templateCache[modelName] = template; templateCache[modelName] = template;
saveCache(templateCache); saveObject(TEMPLATE_CACHE_KEY, templateCache);
return template; return template;
} }

View File

@ -0,0 +1,44 @@
const API_KEY = 'awoorwa32';
export const loadObject = async <T>(key: string, defaultObject: T): Promise<T> => {
let localObject: Partial<T> = {};
try {
const json = localStorage.getItem(key);
if (json) {
localObject = JSON.parse(json);
}
} catch { }
let remoteObject: Partial<T> = {};
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 <T>(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 {
}
}