diff --git a/.gitignore b/.gitignore index 59605d83..15343072 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,10 @@ stories/* *.bak miniconda3/* *.settings -__pycache__ \ No newline at end of file +__pycache__ + +# Ignore PyCharm project files. +.idea + +# Ignore compiled Python files. +*.pyc diff --git a/aiserver.py b/aiserver.py index 9b4c4c44..36b11bdc 100644 --- a/aiserver.py +++ b/aiserver.py @@ -10,6 +10,9 @@ import re import tkinter as tk from tkinter import messagebox import json +import collections +from typing import Literal, Union + import requests import html import argparse @@ -21,6 +24,7 @@ import fileops import gensettings from utils import debounce import utils +import structures import breakmodel #==================================================================# @@ -76,7 +80,7 @@ class vars: memory = "" # Text submitted to memory field authornote = "" # Text submitted to Author's Note field andepth = 3 # How far back in history to append author's note - actions = [] # Array of actions submitted by user and AI + actions = structures.KoboldStoryRegister() # Actions submitted by user and AI worldinfo = [] # Array of World Info key/value objects badwords = [] # Array of str/chr values that should be removed from output badwordsids = [] # Tokenized array of badwords @@ -706,7 +710,6 @@ def get_message(msg): vars.adventure = msg['data'] settingschanged() refresh_settings() - refresh_story() elif(msg['cmd'] == 'importwi'): wiimportrequest() @@ -865,13 +868,13 @@ def actionsubmit(data, actionmode=0): data = applyinputformatting(data) # Store the result in the Action log vars.actions.append(data) - + update_story_chunk('last') + if(not vars.noai): # Off to the tokenizer! calcsubmit(data) emit('from_server', {'cmd': 'scrolldown', 'data': ''}, broadcast=True) else: - refresh_story() set_aibusy(0) emit('from_server', {'cmd': 'scrolldown', 'data': ''}, broadcast=True) @@ -888,9 +891,10 @@ def actionretry(data): if(vars.gamestarted if vars.useprompt else len(vars.actions) > 0): set_aibusy(1) if(not vars.recentback and len(vars.actions) != 0 and len(vars.genseqs) == 0): # Don't pop if we're in the "Select sequence to keep" menu or if there are no non-prompt actions + last_key = vars.actions.get_last_key() vars.actions.pop() + remove_story_chunk(last_key + 1) vars.genseqs = [] - refresh_story() calcsubmit('') vars.recentback = False elif(not vars.useprompt): @@ -904,9 +908,10 @@ def actionback(): return # Remove last index of actions and refresh game screen if(len(vars.genseqs) == 0 and len(vars.actions) > 0): + last_key = vars.actions.get_last_key() vars.actions.pop() vars.recentback = True - refresh_story() + remove_story_chunk(last_key + 1) elif(len(vars.genseqs) == 0): emit('from_server', {'cmd': 'errmsg', 'data': "Cannot delete the prompt."}) else: @@ -977,11 +982,13 @@ def calcsubmit(txt): forceanote = True # Get most recent action tokens up to our budget - for n in range(actionlen): + n = 0 + for key in reversed(vars.actions): + chunk = vars.actions[key] if(budget <= 0): break - acttkns = tokenizer.encode(vars.actions[(-1-n)]) + acttkns = tokenizer.encode(chunk) tknlen = len(acttkns) if(tknlen < budget): tokens = acttkns + tokens @@ -997,6 +1004,7 @@ def calcsubmit(txt): if(anotetxt != ""): tokens = anotetkns + tokens # A.N. len already taken from bdgt anoteadded = True + n += 1 # If we're not using the prompt every time and there's still budget left, # add some prompt. @@ -1051,17 +1059,19 @@ def calcsubmit(txt): subtxt = "" prompt = vars.prompt - for n in range(actionlen): + n = 0 + for key in reversed(vars.actions): + chunk = vars.actions[key] if(budget <= 0): break - actlen = len(vars.actions[(-1-n)]) + actlen = len(chunk) if(actlen < budget): - subtxt = vars.actions[(-1-n)] + subtxt + subtxt = chunk + subtxt budget -= actlen else: count = budget * -1 - subtxt = vars.actions[(-1-n)][count:] + subtxt + subtxt = chunk[count:] + subtxt budget = 0 break @@ -1078,6 +1088,7 @@ def calcsubmit(txt): if(anotetxt != ""): subtxt = anotetxt + subtxt # A.N. len already taken from bdgt anoteadded = True + n += 1 # Did we get to add the A.N.? If not, do it here if(anotetxt != ""): @@ -1169,8 +1180,8 @@ def genresult(genout): # Add formatted text to Actions array and refresh the game screen vars.actions.append(genout) - refresh_story() - emit('from_server', {'cmd': 'texteffect', 'data': len(vars.actions)}, broadcast=True) + update_story_chunk('last') + emit('from_server', {'cmd': 'texteffect', 'data': vars.actions.get_last_key() if len(vars.actions) else 0}, broadcast=True) #==================================================================# # Send generator sequences to the UI for selection @@ -1188,9 +1199,6 @@ def genselect(genout): # Send sequences to UI for selection emit('from_server', {'cmd': 'genseqs', 'data': genout}, broadcast=True) - - # Refresh story for any input text - refresh_story() #==================================================================# # Send selected sequence to action log and refresh UI @@ -1199,8 +1207,8 @@ def selectsequence(n): if(len(vars.genseqs) == 0): return vars.actions.append(vars.genseqs[int(n)]["generated_text"]) - refresh_story() - emit('from_server', {'cmd': 'texteffect', 'data': len(vars.actions)}, broadcast=True) + update_story_chunk('last') + emit('from_server', {'cmd': 'texteffect', 'data': vars.actions.get_last_key() if len(vars.actions) else 0}, broadcast=True) emit('from_server', {'cmd': 'hidegenseqs', 'data': ''}, broadcast=True) vars.genseqs = [] @@ -1259,7 +1267,7 @@ def sendtocolab(txt, min, max): # Add formatted text to Actions array and refresh the game screen #vars.actions.append(genout) #refresh_story() - #emit('from_server', {'cmd': 'texteffect', 'data': len(vars.actions)}) + #emit('from_server', {'cmd': 'texteffect', 'data': vars.actions.get_last_key() if len(vars.actions) else 0}) set_aibusy(0) else: @@ -1330,13 +1338,51 @@ def applyoutputformatting(txt): # Sends the current story content to the Game Screen #==================================================================# def refresh_story(): - text_parts = ['', html.escape(vars.prompt), ''] - for idx, item in enumerate(vars.actions, start=1): - if vars.adventure: # Add special formatting to adventure actions - item = vars.acregex_ui.sub('\\1', html.escape(item)) - text_parts.extend(('', item, '')) + text_parts = ['', html.escape(vars.prompt), ''] + for idx in vars.actions: + item = vars.actions[idx] + idx += 1 + item = html.escape(item) + item = vars.acregex_ui.sub('\\1', item) # Add special formatting to adventure actions + text_parts.extend(('', item, '')) emit('from_server', {'cmd': 'updatescreen', 'gamestarted': vars.gamestarted, 'data': formatforhtml(''.join(text_parts))}, broadcast=True) + +#==================================================================# +# Signals the Game Screen to update one of the chunks +#==================================================================# +def update_story_chunk(idx: Union[int, Literal['last']]): + if idx == 'last': + if len(vars.actions) <= 1: + # In this case, we are better off just refreshing the whole thing as the + # prompt might not have been shown yet (with a "Generating story..." + # message instead). + refresh_story() + return + + idx = (vars.actions.get_last_key() if len(vars.actions) else 0) + 1 + + if idx == 0: + text = vars.prompt + else: + # Actions are 0 based, but in chunks 0 is the prompt. + # So the chunk index is one more than the corresponding action index. + text = vars.actions[idx - 1] + + item = html.escape(text) + item = vars.acregex_ui.sub('\\1', item) # Add special formatting to adventure actions + + chunk_text = f'{formatforhtml(item)}' + emit('from_server', {'cmd': 'updatechunk', 'data': {'index': idx, 'html': chunk_text, 'last': (idx == (vars.actions.get_last_key() if len(vars.actions) else 0))}}, broadcast=True) + + +#==================================================================# +# Signals the Game Screen to remove one of the chunks +#==================================================================# +def remove_story_chunk(idx: int): + emit('from_server', {'cmd': 'removechunk', 'data': idx}, broadcast=True) + + #==================================================================# # Sends the current generator settings to the Game Menu #==================================================================# @@ -1405,7 +1451,7 @@ def editsubmit(data): vars.actions[vars.editln-1] = data vars.mode = "play" - refresh_story() + update_story_chunk(vars.editln) emit('from_server', {'cmd': 'texteffect', 'data': vars.editln}, broadcast=True) emit('from_server', {'cmd': 'editmode', 'data': 'false'}) @@ -1420,7 +1466,7 @@ def deleterequest(): else: del vars.actions[vars.editln-1] vars.mode = "play" - refresh_story() + remove_story_chunk(vars.editln) emit('from_server', {'cmd': 'editmode', 'data': 'false'}) #==================================================================# @@ -1433,7 +1479,7 @@ def inlineedit(chunk, data): else: vars.actions[chunk-1] = data - refresh_story() + update_story_chunk(chunk) emit('from_server', {'cmd': 'texteffect', 'data': chunk}, broadcast=True) emit('from_server', {'cmd': 'editmode', 'data': 'false'}, broadcast=True) @@ -1445,12 +1491,12 @@ def inlinedelete(chunk): # Don't delete prompt if(chunk == 0): # Send error message - refresh_story() + update_story_chunk(chunk) emit('from_server', {'cmd': 'errmsg', 'data': "Cannot delete the prompt."}) emit('from_server', {'cmd': 'editmode', 'data': 'false'}, broadcast=True) else: del vars.actions[chunk-1] - refresh_story() + remove_story_chunk(chunk) emit('from_server', {'cmd': 'editmode', 'data': 'false'}, broadcast=True) #==================================================================# @@ -1580,10 +1626,19 @@ def checkworldinfo(txt): txt = "" depth += 1 + if(ln > 0): + chunks = collections.deque() + i = 0 + for key in reversed(vars.actions): + chunk = vars.actions[key] + chunks.appendleft(chunk) + if(i == depth): + break + if(ln >= depth): - txt = "".join(vars.actions[(depth*-1):]) + txt = "".join(chunks) elif(ln > 0): - txt = vars.prompt + "".join(vars.actions[(depth*-1):]) + txt = vars.prompt + "".join(chunks) elif(ln == 0): txt = vars.prompt @@ -1679,8 +1734,8 @@ def ikrequest(txt): genout = req.json()["data"]["text"] print("{0}{1}{2}".format(colors.CYAN, genout, colors.END)) vars.actions.append(genout) - refresh_story() - emit('from_server', {'cmd': 'texteffect', 'data': len(vars.actions)}, broadcast=True) + update_story_chunk('last') + emit('from_server', {'cmd': 'texteffect', 'data': vars.actions.get_last_key() if len(vars.actions) else 0}, broadcast=True) set_aibusy(0) else: @@ -1729,8 +1784,8 @@ def oairequest(txt, min, max): genout = req.json()["choices"][0]["text"] print("{0}{1}{2}".format(colors.CYAN, genout, colors.END)) vars.actions.append(genout) - refresh_story() - emit('from_server', {'cmd': 'texteffect', 'data': len(vars.actions)}, broadcast=True) + update_story_chunk('last') + emit('from_server', {'cmd': 'texteffect', 'data': vars.actions.get_last_key() if len(vars.actions) else 0}, broadcast=True) set_aibusy(0) else: @@ -1808,7 +1863,7 @@ def saveRequest(savpath): js["prompt"] = vars.prompt js["memory"] = vars.memory js["authorsnote"] = vars.authornote - js["actions"] = vars.actions + js["actions"] = tuple(vars.actions.values()) js["worldinfo"] = [] # Extract only the important bits of WI @@ -1860,10 +1915,14 @@ def loadRequest(loadpath): vars.gamestarted = js["gamestarted"] vars.prompt = js["prompt"] vars.memory = js["memory"] - vars.actions = js["actions"] vars.worldinfo = [] vars.lastact = "" vars.lastctx = "" + + del vars.actions + vars.actions = structures.KoboldStoryRegister() + for s in js["actions"]: + vars.actions.append(s) # Try not to break older save files if("authorsnote" in js): @@ -1973,7 +2032,7 @@ def importgame(): vars.prompt = "" vars.memory = ref["memory"] vars.authornote = ref["authorsNote"] if type(ref["authorsNote"]) is str else "" - vars.actions = [] + vars.actions = structures.KoboldStoryRegister() vars.worldinfo = [] vars.lastact = "" vars.lastctx = "" @@ -2033,7 +2092,7 @@ def importAidgRequest(id): vars.prompt = js["promptContent"] vars.memory = js["memory"] vars.authornote = js["authorsNote"] - vars.actions = [] + vars.actions = structures.KoboldStoryRegister() vars.worldinfo = [] vars.lastact = "" vars.lastctx = "" @@ -2101,7 +2160,7 @@ def newGameRequest(): vars.gamestarted = False vars.prompt = "" vars.memory = "" - vars.actions = [] + vars.actions = structures.KoboldStoryRegister() vars.authornote = "" vars.worldinfo = [] diff --git a/static/application.js b/static/application.js index 5fda5b37..185c7448 100644 --- a/static/application.js +++ b/static/application.js @@ -643,6 +643,11 @@ function setmodevisibility(state) { function setadventure(state) { adventure = state; + if(state) { + game_text.addClass("adventure"); + } else { + game_text.removeClass("adventure"); + } if(!memorymode){ setmodevisibility(state); } @@ -758,19 +763,12 @@ function submitEditedChunk(event) { return; } - show([$('#curtain')]); - setTimeout(function () { - if(document.activeElement.tagName == "CHUNK") { - document.activeElement.blur(); - } - }, 5); - chunk = current_editing_chunk; current_editing_chunk = null; // Submit the edited chunk if it's not empty, otherwise delete it if(chunk.innerText.length) { - socket.send({'cmd': 'inlineedit', 'chunk': chunk.getAttribute("n"), 'data': chunk.innerText}); + socket.send({'cmd': 'inlineedit', 'chunk': chunk.getAttribute("n"), 'data': chunk.innerText.replace(/\u00a0/g, " ")}); } else { socket.send({'cmd': 'inlinedelete', 'data': chunk.getAttribute("n")}); } @@ -841,11 +839,11 @@ $(document).ready(function(){ seqselmenu = $("#seqselmenu"); seqselcontents = $("#seqselcontents"); - // Connect to SocketIO server - socket = io.connect(window.document.origin); + // Connect to SocketIO server + socket = io.connect(window.document.origin); socket.on('from_server', function(msg) { - if(msg.cmd == "connected") { + if(msg.cmd == "connected") { // Connected to Server Actions connected = true; connect_status.html("Connected to KoboldAI Process!"); @@ -876,9 +874,7 @@ $(document).ready(function(){ } game_text.html(msg.data); // Make content editable if need be - $("chunk").attr('tabindex', -1) $('chunk').attr('contenteditable', allowedit); - hide([$('#curtain')]); // Scroll to bottom of text if(newly_loaded) { setTimeout(function () { @@ -891,6 +887,33 @@ $(document).ready(function(){ setTimeout(function () { $('#gamescreen').animate({scrollTop: $('#gamescreen').prop('scrollHeight')}, 1000); }, 5); + } else if(msg.cmd == "updatechunk") { + hideMessage(); + const {index, html, last} = msg.data; + const existingChunk = game_text.children(`#n${index}`) + const newChunk = $(html); + if (existingChunk.length > 0) { + // Update existing chunk + existingChunk.before(newChunk); + existingChunk.remove(); + } else { + // Append at the end + game_text.append(newChunk); + } + newChunk.attr('contenteditable', allowedit); + hide([$('#curtain')]); + if(last) { + // Scroll to bottom of text if it's the last element + setTimeout(function () { + $('#gamescreen').animate({scrollTop: $('#gamescreen').prop('scrollHeight')}, 1000); + }, 5); + } + } else if(msg.cmd == "removechunk") { + hideMessage(); + let index = msg.data; + // Remove the chunk + game_text.children(`#n${index}`).remove() + hide([$('#curtain')]); } else if(msg.cmd == "setgamestate") { // Enable or Disable buttons if(msg.data == "ready") { @@ -1088,7 +1111,7 @@ $(document).ready(function(){ } else if(msg.cmd == "runs_remotely") { hide([button_loadfrfile, button_savetofile, button_import, button_importwi]); } - }); + }); socket.on('disconnect', function() { connected = false; diff --git a/static/custom.css b/static/custom.css index 58bfc37c..19349702 100644 --- a/static/custom.css +++ b/static/custom.css @@ -6,14 +6,14 @@ chunk { color: #ffffff; } -action { +#gametext.adventure action { color: #9ff7fa; font-weight: bold; } chunk[contenteditable="true"]:focus, chunk[contenteditable="true"]:focus * { - color: #cdf; - font-weight: normal; + color: #cdf !important; + font-weight: normal !important; } chunk, chunk * { @@ -355,8 +355,8 @@ chunk, chunk * { .colorfade, .colorfade * { -moz-transition:color 1s ease-in; - -o-transition:color 1s ease-in; - -webkit-transition:color 1s ease-in; + -o-transition:color 1s ease-in; + -webkit-transition:color 1s ease-in; transition:color 1s ease-in; } @@ -398,7 +398,7 @@ chunk, chunk * { } .edit-flash, .edit-flash * { - color: #3bf723; + color: #3bf723 !important; } .flex { @@ -443,21 +443,21 @@ chunk, chunk * { } .helpicon { - display: inline-block; - font-family: sans-serif; - font-weight: bold; - text-align: center; - width: 2.2ex; - height: 2.4ex; - font-size: 1.4ex; - line-height: 1.8ex; - border-radius: 1.2ex; - margin-right: 4px; - padding: 1px; - color: #295071; - background: #ffffff; - border: 1px solid white; - text-decoration: none; + display: inline-block; + font-family: sans-serif; + font-weight: bold; + text-align: center; + width: 2.2ex; + height: 2.4ex; + font-size: 1.4ex; + line-height: 1.8ex; + border-radius: 1.2ex; + margin-right: 4px; + padding: 1px; + color: #295071; + background: #ffffff; + border: 1px solid white; + text-decoration: none; } .helpicon:hover { @@ -569,8 +569,8 @@ chunk, chunk * { color: #ffffff; -moz-transition: background-color 0.25s ease-in; - -o-transition: background-color 0.25s ease-in; - -webkit-transition: background-color 0.25s ease-in; + -o-transition: background-color 0.25s ease-in; + -webkit-transition: background-color 0.25s ease-in; transition: background-color 0.25s ease-in; } @@ -580,12 +580,12 @@ chunk, chunk * { } .navbar .navbar-nav .nav-link:hover { - border-radius: 5px; + border-radius: 5px; background-color: #98bcdb; } .navbar .navbar-nav .nav-link:focus { - border-radius: 5px; + border-radius: 5px; background-color: #98bcdb; } @@ -651,8 +651,8 @@ chunk, chunk * { color: #ffffff; -moz-transition: background-color 0.25s ease-in; - -o-transition: background-color 0.25s ease-in; - -webkit-transition: background-color 0.25s ease-in; + -o-transition: background-color 0.25s ease-in; + -webkit-transition: background-color 0.25s ease-in; transition: background-color 0.25s ease-in; } @@ -702,8 +702,8 @@ chunk, chunk * { padding: 5px; color: #ffffff; -moz-transition: all 0.15s ease-in; - -o-transition: all 0.15s ease-in; - -webkit-transition: all 0.15s ease-in; + -o-transition: all 0.15s ease-in; + -webkit-transition: all 0.15s ease-in; transition: all 0.15s ease-in; } diff --git a/structures.py b/structures.py new file mode 100644 index 00000000..287f92c1 --- /dev/null +++ b/structures.py @@ -0,0 +1,40 @@ +import collections +from typing import Iterable, Tuple + + +class KoboldStoryRegister(collections.OrderedDict): + ''' + Complexity-optimized class for keeping track of story chunks + ''' + + def __init__(self, sequence: Iterable[Tuple[int, str]] = ()): + super().__init__(sequence) + self.__next_id: int = len(sequence) + + def append(self, v: str) -> None: + self[self.__next_id] = v + self.increment_id() + + def pop(self) -> str: + return self.popitem()[1] + + def get_first_key(self) -> int: + return next(iter(self)) + + def get_last_key(self) -> int: + return next(reversed(self)) + + def __getitem__(self, k: int) -> str: + return super().__getitem__(k) + + def __setitem__(self, k: int, v: str) -> None: + return super().__setitem__(k, v) + + def increment_id(self) -> None: + self.__next_id += 1 + + def get_next_id(self) -> int: + return self.__next_id + + def set_next_id(self, x: int) -> None: + self.__next_id = x diff --git a/templates/index.html b/templates/index.html index 6f4a4248..085d9245 100644 --- a/templates/index.html +++ b/templates/index.html @@ -247,7 +247,7 @@ Unsaved data will be lost.

Below you can input a genre suggestion for the AI to loosely base the story on (For example Horror or Cowboy).
-
+