diff --git a/aiserver.py b/aiserver.py index b0b1d396..ad4798c9 100644 --- a/aiserver.py +++ b/aiserver.py @@ -7,6 +7,7 @@ # External packages from dataclasses import dataclass +import shutil import eventlet eventlet.monkey_patch(all=True, thread=False, os=False) import os, inspect @@ -509,6 +510,9 @@ class ImportBuffer: refresh_story() import_buffer = ImportBuffer() + +with open("data/genres.json", "r") as file: + genre_list = json.load(file) # Set logging level to reduce chatter from Flask import logging @@ -7232,28 +7236,34 @@ def loadfromfile(): def loadRequest(loadpath, filename=None): logger.debug("Load Request") logger.debug("Called from {}".format(inspect.stack()[1].function)) + + if not loadpath: + return + + if os.path.isdir(loadpath): + if not valid_v3_story(loadpath): + raise RuntimeError(f"Tried to load {loadpath}, a non-save directory.") + loadpath = os.path.join(loadpath, "story.json") + start_time = time.time() - if(loadpath): - # Leave Edit/Memory mode before continuing - exitModes() - - - # Read file contents into JSON object - start_time = time.time() - if(isinstance(loadpath, str)): - with open(loadpath, "r") as file: - js = json.load(file) - if(filename is None): - filename = path.basename(loadpath) - else: - js = loadpath - if(filename is None): - filename = "untitled.json" - js['v1_loadpath'] = loadpath - js['v1_filename'] = filename - logger.debug("Loading JSON data took {}s".format(time.time()-start_time)) - loadJSON(js) - logger.debug("Time to load story: {}s".format(time.time()-start_time)) + # Leave Edit/Memory mode before continuing + exitModes() + + # Read file contents into JSON object + start_time = time.time() + if(isinstance(loadpath, str)): + with open(loadpath, "r") as file: + js = json.load(file) + if(filename is None): + filename = path.basename(loadpath) + else: + js = loadpath + if(filename is None): + filename = "untitled.json" + js['v1_loadpath'] = loadpath + js['v1_filename'] = filename + logger.debug("Loading JSON data took {}s".format(time.time()-start_time)) + loadJSON(js) def loadJSON(json_text_or_dict): logger.debug("Loading JSON Story") @@ -7925,6 +7935,31 @@ def upload_file(data): f.write(data['data']) get_files_folders(session['current_folder']) +@app.route("/upload_kai_story/", methods=["POST"]) +@logger.catch +def UI_2_upload_kai_story(file_name: str): + + assert "/" not in file_name + + raw_folder_name = file_name.replace(".kaistory", "") + folder_path = path.join("stories", raw_folder_name) + disambiguator = 0 + + while path.exists(folder_path): + disambiguator += 1 + folder_path = path.join("stories", f"{raw_folder_name} ({disambiguator})") + + buffer = BytesIO() + dat = request.get_data() + with open("debug.zip", "wb") as file: + file.write(dat) + buffer.write(dat) + + with zipfile.ZipFile(buffer, "r") as zipf: + zipf.extractall(folder_path) + + return ":)" + @socketio.on('popup_change_folder') @logger.catch def popup_change_folder(data): @@ -7965,38 +8000,46 @@ def popup_rename(data): @logger.catch def popup_rename_story(data): if 'popup_renameable' not in session: - print("Someone is trying to rename a file in your server. Blocked.") + logger.warning("Someone is trying to rename a file in your server. Blocked.") return if not session['popup_renameable']: - print("Someone is trying to rename a file in your server. Blocked.") + logger.warning("Someone is trying to rename a file in your server. Blocked.") return - - if session['popup_jailed_dir'] is None: - #if we're using a v2 file we can't just rename the file as the story name is in the file - with open(data['file'], 'r') as f: - json_data = json.load(f) - if 'story_name' in json_data: - json_data['story_name'] = data['new_name'] - - new_filename = os.path.join(os.path.dirname(os.path.abspath(data['file'])), data['new_name']+".json") - os.remove(data['file']) - with open(new_filename, "w") as f: - json.dump(json_data, f) - get_files_folders(os.path.dirname(data['file'])) - elif session['popup_jailed_dir'] in data['file']: - #if we're using a v2 file we can't just rename the file as the story name is in the file - with open(data['file'], 'r') as f: - json_data = json.load(f) - if 'story_name' in json_data: - json_data['story_name'] = data['new_name'] - - new_filename = os.path.join(os.path.dirname(os.path.abspath(data['file'])), data['new_name']+".json") - os.remove(data['file']) - with open(new_filename, "w") as f: - json.dump(json_data, f) - get_files_folders(os.path.dirname(data['file'])) + if session['popup_jailed_dir'] and session["popup_jailed_dir"] not in data["file"]: + logger.warning("User is trying to rename files in your server outside the jail. Blocked. Jailed Dir: {} Requested Dir: {}".format(session['popup_jailed_dir'], data['file'])) + return + + path = data["file"] + new_name = data["new_name"] + json_path = path + is_v3 = False + + # Handle directory for v3 save + if os.path.isdir(path): + if not valid_v3_story(path): + return + is_v3 = True + json_path = os.path.join(path, "story.json") + + #if we're using a v2 file we can't just rename the file as the story name is in the file + with open(json_path, 'r') as f: + json_data = json.load(f) + if 'story_name' in json_data: + json_data['story_name'] = new_name + + # For v3 we move the directory, not the json file. + if is_v3: + target = os.path.join(os.path.dirname(path), new_name) + shutil.move(path, target) + + with open(os.path.join(target, "story.json"), "w") as file: + json.dump(json_data, file) else: - print("User is trying to rename files in your server outside the jail. Blocked. Jailed Dir: {} Requested Dir: {}".format(session['popup_jailed_dir'], data['file'])) + new_filename = os.path.join(os.path.dirname(os.path.abspath(data['file'])), new_name+".json") + os.remove(data['file']) + with open(new_filename, "w") as f: + json.dump(json_data, f) + get_files_folders(os.path.dirname(path)) @socketio.on('popup_delete') @logger.catch @@ -8159,10 +8202,12 @@ def get_files_folders(starting_folder): folders = [] files = [] base_path = os.path.abspath(starting_folder).replace("\\", "/") + if advanced_sort is not None: files_to_check = advanced_sort(base_path, desc=desc) else: files_to_check = get_files_sorted(base_path, sort, desc=desc) + for item in files_to_check: item_full_path = os.path.join(base_path, item).replace("\\", "/") if hasattr(os.stat(item_full_path), "st_file_attributes"): @@ -8179,8 +8224,15 @@ def get_files_folders(starting_folder): extra_parameters = extra_parameter_function(item_full_path, item, valid_selection) if (show_hidden and hidden) or not hidden: - if os.path.isdir(os.path.join(base_path, item)): - folders.append([True, item_full_path, item, valid_selection, extra_parameters]) + if os.path.isdir(item_full_path): + folders.append([ + # While v3 saves are directories, we should not show them as such. + not valid_v3_story(item_full_path), + item_full_path, + item, + valid_selection, + extra_parameters + ]) else: if hide_extention: item = ".".join(item.split(".")[:-1]) @@ -8320,18 +8372,55 @@ def UI_2_save_story(data): else: #We have an ack that it's OK to save over the file if one exists koboldai_vars.save_story() - + +def directory_to_zip_data(directory: str, overrides: Optional[dict]) -> bytes: + overrides = overrides or {} + buffer = BytesIO() + + with zipfile.ZipFile(buffer, "w") as zipf: + for root, _, files in os.walk(directory): + for file in files: + p = os.path.join(root, file) + z_path = os.path.join(*p.split(os.path.sep)[2:]) + + if z_path in overrides: + continue + + zipf.write(p, z_path) + + for path, contents in overrides.items(): + zipf.writestr(path, contents) + + return buffer.getvalue() + #==================================================================# # Save story to json #==================================================================# -@app.route("/json") +@app.route("/story_download") @logger.catch -def UI_2_save_to_json(): +def UI_2_download_story(): + save_exists = path.exists(koboldai_vars.save_paths.base) + if koboldai_vars.gamesaved and save_exists: + # Disk is up to date; download from disk + data = directory_to_zip_data(koboldai_vars.save_paths.base) + elif save_exists: + # We aren't up to date but we are saved; patch what disk gives us + data = directory_to_zip_data( + koboldai_vars.save_paths.base, + {"story.json": koboldai_vars.to_json("story_settings")} + ) + else: + # We are not saved; send json in zip from memory + buffer = BytesIO() + with zipfile.ZipFile(buffer, "w") as zipf: + zipf.writestr("story.json", koboldai_vars.to_json("story_settings")) + data = buffer.getvalue() + return Response( - koboldai_vars.to_json('story_settings'), - mimetype="application/json", - headers={"Content-disposition": - "attachment; filename={}.v2.json".format(koboldai_vars.story_name)}) + data, + mimetype="application/octet-stream", + headers={"Content-disposition": f"attachment; filename={koboldai_vars.story_name}.kaistory"} + ) #==================================================================# @@ -8523,6 +8612,11 @@ def get_story_listing_data(item_full_path, item, valid_selection): if not valid_selection: return [title, action_count, last_loaded] + + if os.path.isdir(item_full_path): + if not valid_v3_story(item_full_path): + return [title, action_count, last_loaded] + item_full_path = os.path.join(item_full_path, "story.json") with open(item_full_path, 'rb') as f: parse_event = ijson.parse(f) @@ -8574,38 +8668,68 @@ def get_story_listing_data(item_full_path, item, valid_selection): return [title, action_count, last_loaded] @logger.catch -def valid_story(file): - if file.endswith(".json"): - try: - valid = False - with open(file, 'rb') as f: - parser = ijson.parse(f) - for prefix, event, value in parser: - if prefix == 'memory': - valid=True - break - except: - pass - return valid +def valid_story(path: str): + if os.path.isdir(path): + return valid_v3_story(path) + + if not path.endswith(".json"): + return False + + try: + with open(path, 'rb') as file: + parser = ijson.parse(file) + for prefix, event, value in parser: + if prefix == 'memory': + return True + except: + pass + return False + +@logger.catch +def valid_v3_story(path: str) -> bool: + if not os.path.exists(path): return False + if not os.path.isdir(path): return False + if not os.path.exists(os.path.join(path, "story.json")): return False + return True @logger.catch def story_sort(base_path, desc=False): files = {} for file in os.scandir(path=base_path): - if file.name.endswith(".json"): - filename = os.path.join(base_path, file.name).replace("\\", "/") - if os.path.getsize(filename) < 2*1024*1024: #2MB - with open(filename, "r") as f: - try: - js = json.load(f) - if 'story_name' in js and js['story_name'] in koboldai_vars.story_loads: - files[file.name] = datetime.datetime.strptime(koboldai_vars.story_loads[js['story_name']], "%m/%d/%Y, %H:%M:%S") - else: - files[file.name] = datetime.datetime.fromtimestamp(file.stat().st_mtime) - except: - pass + if file.is_dir(): + if not valid_v3_story(file.path): + continue + + story_path = os.path.join(file.path, "story.json") + story_stat = os.stat(story_path) + + if os.path.getsize(story_path) < 2*1024*1024: #2MB + with open(story_path, "r") as f: + j = json.load(f) + if j.get("story_name") in koboldai_vars.story_loads: + files[file.name] = datetime.datetime.strptime(koboldai_vars.story_loads[j["story_name"]], "%m/%d/%Y, %H:%M:%S") + else: + files[file.name] = datetime.datetime.fromtimestamp(story_stat.st_mtime) else: - files[file.name] = datetime.datetime.fromtimestamp(file.stat().st_mtime) + files[file.name] = datetime.datetime.fromtimestamp(story_stat.st_mtime) + continue + + if not file.name.endswith(".json"): + continue + + filename = os.path.join(base_path, file.name).replace("\\", "/") + if os.path.getsize(filename) < 2*1024*1024: #2MB + with open(filename, "r") as f: + try: + js = json.load(f) + if 'story_name' in js and js['story_name'] in koboldai_vars.story_loads: + files[file.name] = datetime.datetime.strptime(koboldai_vars.story_loads[js['story_name']], "%m/%d/%Y, %H:%M:%S") + else: + files[file.name] = datetime.datetime.fromtimestamp(file.stat().st_mtime) + except: + pass + else: + files[file.name] = datetime.datetime.fromtimestamp(file.stat().st_mtime) return [key[0] for key in sorted(files.items(), key=lambda kv: (kv[1], kv[0]), reverse=desc)] @@ -8843,6 +8967,7 @@ def UI_2_set_wi_image(uid): else: # Otherwise assign image koboldai_vars.worldinfo_v2.image_store[uid] = data + koboldai_vars.gamesaved = False return ":)" @app.route("/get_wi_image/", methods=["GET"]) @@ -9189,8 +9314,10 @@ def UI_2_generate_image_from_story(data): #get latest action if len(koboldai_vars.actions) > 0: action = koboldai_vars.actions[-1] + action_id = len(koboldai_vars.actions) - 1 else: action = koboldai_vars.prompt + action_id = -1 #Get matching world info entries keys = [] for wi in koboldai_vars.worldinfo_v2: @@ -9231,64 +9358,122 @@ def UI_2_generate_image_from_story(data): keys = [summarize(text, max_length=max_length)] logger.debug("Text from summarizer: {}".format(keys[0])) - generate_story_image(", ".join(keys), art_guide=art_guide) + prompt = ", ".join(keys) + generate_story_image( + ", ".join([part for part in [prompt, art_guide] if part]), + file_prefix=f"action_{action_id}", + display_prompt=prompt, + log_data={"actionId": action_id} + ) @socketio.on("generate_image_from_prompt") @logger.catch def UI_2_generate_image_from_prompt(prompt: str): eventlet.sleep(0) - generate_story_image(prompt) + generate_story_image(prompt, file_prefix="prompt", generation_type="direct_prompt") -def generate_story_image(prompt: str, art_guide: str = "") -> None: +def log_image_generation( + prompt: str, + display_prompt: str, + file_name: str, + generation_type: str, + other_data: Optional[dict] = None +) -> None: + # In the future it might be nice to have some UI where you can search past + # generations or something like that + db_path = os.path.join(koboldai_vars.save_paths.generated_images, "db.json") + + try: + with open(db_path, "r") as file: + j = json.load(file) + except FileNotFoundError: + j = [] + + if not isinstance(j, list): + logger.warning("Image database is corrupted! Will not add new entry.") + return + + + log_data = { + "prompt": prompt, + "fileName": file_name, + "type": generation_type or None, + "displayPrompt": display_prompt + } + log_data.update(other_data or {}) + j.append(log_data) + + with open(db_path, "w") as file: + json.dump(j, file) + +def generate_story_image( + prompt: str, + file_prefix: str = "image", + generation_type: str = "", + display_prompt: Optional[str] = None, + log_data: Optional[dict] = None + +) -> None: # This function is a wrapper around generate_image() that integrates the # result with the story (read: puts it in the corner of the screen). + if not display_prompt: + display_prompt = prompt + koboldai_vars.picture_prompt = display_prompt + start_time = time.time() koboldai_vars.generating_image = True - b64_data = generate_image(prompt, art_guide=art_guide) + image = generate_image(prompt) + koboldai_vars.generating_image = False + + if not image: + return + + if os.path.exists(koboldai_vars.save_paths.generated_images): + # Only save image if this is a saved story + file_name = f"{file_prefix}_{int(time.time())}.png" + image.save(os.path.join(koboldai_vars.save_paths.generated_images, file_name)) + log_image_generation(prompt, display_prompt, file_name, generation_type, log_data) logger.debug("Time to Generate Image {}".format(time.time()-start_time)) - koboldai_vars.picture = b64_data - koboldai_vars.picture_prompt = prompt - koboldai_vars.generating_image = False + buffer = BytesIO() + image.save(buffer, format="JPEG") + b64_data = base64.b64encode(buffer.getvalue()).decode("ascii") -def generate_image(prompt: str, art_guide: str = "") -> Optional[str]: + koboldai_vars.picture = b64_data + + +def generate_image(prompt: str) -> Optional[Image.Image]: if koboldai_vars.img_gen_priority == 4: # Check if stable-diffusion-webui API option selected and use that if found. - return text2img_api(prompt, art_guide=art_guide) + return text2img_api(prompt) elif ((not koboldai_vars.hascuda or not os.path.exists("models/stable-diffusion-v1-4")) and koboldai_vars.img_gen_priority != 0) or koboldai_vars.img_gen_priority == 3: # If we don't have a GPU, use horde if we're allowed to - return text2img_horde(prompt, art_guide=art_guide) + return text2img_horde(prompt) memory = torch.cuda.get_device_properties(0).total_memory # We aren't being forced to use horde, so now let's figure out if we should use local if memory - torch.cuda.memory_reserved(0) >= 6000000000: # We have enough vram, just do it locally - return text2img_local(prompt, art_guide=art_guide) + return text2img_local(prompt) elif memory > 6000000000 and koboldai_vars.img_gen_priority <= 1: # We could do it locally by swapping the model out print("Could do local or online") - return text2img_horde(prompt, art_guide=art_guide) + return text2img_horde(prompt) elif koboldai_vars.img_gen_priority != 0: - return text2img_horde(prompt, art_guide=art_guide) + return text2img_horde(prompt) raise RuntimeError("Unable to decide image generation backend. Please report this.") @logger.catch -def text2img_local(prompt, - art_guide="", - filename="new.png"): +def text2img_local(prompt: str) -> Optional[Image.Image]: start_time = time.time() logger.debug("Generating Image") - koboldai_vars.aibusy = True - koboldai_vars.generating_image = True from diffusers import StableDiffusionPipeline - import base64 - from io import BytesIO if koboldai_vars.image_pipeline is None: pipe = tpool.execute(StableDiffusionPipeline.from_pretrained, "CompVis/stable-diffusion-v1-4", revision="fp16", torch_dtype=torch.float16, cache="models/stable-diffusion-v1-4").to("cuda") else: @@ -9301,9 +9486,6 @@ def text2img_local(prompt, with autocast("cuda"): return pipe(prompt, num_inference_steps=num_inference_steps).images[0] image = tpool.execute(get_image, pipe, prompt, num_inference_steps=koboldai_vars.img_gen_steps) - buffered = BytesIO() - image.save(buffered, format="JPEG") - img_str = base64.b64encode(buffered.getvalue()).decode('ascii') logger.debug("time to generate: {}".format(time.time() - start_time)) start_time = time.time() if koboldai_vars.keep_img_gen_in_memory: @@ -9314,61 +9496,52 @@ def text2img_local(prompt, koboldai_vars.image_pipeline = None del pipe torch.cuda.empty_cache() - koboldai_vars.generating_image = False - koboldai_vars.aibusy = False logger.debug("time to unload: {}".format(time.time() - start_time)) - return img_str + return image @logger.catch -def text2img_horde(prompt, - art_guide = "", - filename = "story_art.png"): +def text2img_horde(prompt: str) -> Optional[Image.Image]: logger.debug("Generating Image using Horde") - koboldai_vars.generating_image = True - final_submit_dict = { - "prompt": "{}, {}".format(prompt, art_guide), + "prompt": prompt, "trusted_workers": False, "models": [ "stable_diffusion" ], "params": { - "n":1, + "n": 1, "nsfw": True, "sampler_name": "k_euler_a", "karras": True, "cfg_scale": koboldai_vars.img_gen_cfg_scale, - "steps":koboldai_vars.img_gen_steps, - "width":512, - "height":512} + "steps": koboldai_vars.img_gen_steps, + "width": 512, + "height": 512 + } } cluster_headers = {'apikey': koboldai_vars.sh_apikey if koboldai_vars.sh_apikey != '' else "0000000000",} logger.debug(final_submit_dict) - submit_req = requests.post('https://stablehorde.net/api/v2/generate/sync', json = final_submit_dict, headers=cluster_headers) - if submit_req.ok: - results = submit_req.json() - for iter in range(len(results['generations'])): - b64img = results['generations'][iter]["img"] - base64_bytes = b64img.encode('utf-8') - img_bytes = base64.b64decode(base64_bytes) - img = Image.open(BytesIO(img_bytes)) - if len(results) > 1: - final_filename = f"{iter}_{filename}" - else: - final_filename = filename - img.save(final_filename) - logger.debug("Saved Image") - koboldai_vars.generating_image = False - return(b64img) - else: - koboldai_vars.generating_image = False + submit_req = requests.post('https://stablehorde.net/api/v2/generate/sync', json=final_submit_dict, headers=cluster_headers) + + if not submit_req.ok: logger.error(submit_req.text) + return + + results = submit_req.json() + if len(results["generations"]) > 1: + logger.warning(f"Got too many generations, discarding extras. Got {len(results['generations'])}, expected 1.") + + b64img = results["generations"][0]["img"] + base64_bytes = b64img.encode("utf-8") + img_bytes = base64.b64decode(base64_bytes) + img = Image.open(BytesIO(img_bytes)) + return img @logger.catch -def text2img_api(prompt, art_guide=""): +def text2img_api(prompt, art_guide="") -> Image.Image: logger.debug("Generating Image using Local SD-WebUI API") koboldai_vars.generating_image = True #The following list are valid properties with their defaults, to add/modify in final_imgen_params. Will refactor configuring values into UI element in future. @@ -9402,7 +9575,7 @@ def text2img_api(prompt, art_guide=""): #"override_settings": {}, #"sampler_index": "Euler" final_imgen_params = { - "prompt": ", ".join(filter(bool, [prompt, art_guide])), + "prompt": prompt, "n_iter": 1, "width": 512, "height": 512, @@ -9451,7 +9624,7 @@ def text2img_api(prompt, art_guide=""): show_error_notification("SD Web API Failure", "SD Web API returned no images", do_log=True) return None - return base64_image + return Image.open(BytesIO(base64.b64decode(base64_image))) @socketio.on("clear_generated_image") @logger.catch @@ -9630,6 +9803,15 @@ def UI_2_privacy_mode(data): koboldai_vars.privacy_mode = False #==================================================================# +# Genres +#==================================================================# +@app.route("/genre_data.json", methods=["GET"]) +def UI_2_get_applicable_genres(): + return Response(json.dumps({ + "list": genre_list, + "init": koboldai_vars.genres, + })) +#==================================================================# # Soft Prompt Tuning #==================================================================# @socketio.on("create_new_softprompt") @@ -9701,7 +9883,7 @@ def UI_2_test_match(): @logger.catch def UI_2_audio(): action_id = int(request.args['id']) if 'id' in request.args else len(koboldai_vars.actions) - filename="stories/{}/{}.ogg".format(koboldai_vars.story_id, action_id) + filename = os.path.join(koboldai_vars.save_paths.generated_audio, f"{action_id}.ogg") if not os.path.exists(filename): koboldai_vars.actions.gen_audio(action_id) return send_file( diff --git a/data/genres.json b/data/genres.json new file mode 100644 index 00000000..23ea0f77 --- /dev/null +++ b/data/genres.json @@ -0,0 +1,307 @@ +[ + "Absurdist", + "Action & Adventure", + "Adaptations & Pastiche", + "African American & Black/General", + "African American & Black/Christian", + "African American & Black/Erotica", + "African American & Black/Historical", + "African American & Black/Mystery & Detective", + "African American & Black/Urban & Street Lit", + "African American & Black/Women", + "Alternative History", + "Amish & Mennonite", + "Animals", + "Anthologies (multiple authors)", + "Asian American", + "Biographical", + "Buddhist", + "Christian/General", + "Christian/Biblical", + "Christian/Classic & Allegory", + "Christian/Collections & Anthologies", + "Christian/Contemporary", + "Christian/Fantasy", + "Christian/Futuristic", + "Christian/Historical", + "Christian/Romance/General", + "Christian/Romance/Historical", + "Christian/Romance/Suspense", + "Christian/Suspense", + "Christian/Western", + "City Life", + "Classics", + "Coming of Age", + "Crime", + "Cultural Heritage", + "Disabilities & Special Needs", + "Disaster", + "Dystopian", + "Epistolary", + "Erotica/General", + "Erotica/BDSM", + "Erotica/Collections & Anthologies", + "Erotica/Historical", + "Erotica/LGBTQ+/General", + "Erotica/LGBTQ+/Bisexual", + "Erotica/LGBTQ+/Gay", + "Erotica/LGBTQ+/Lesbian", + "Erotica/LGBTQ+/Transgender", + "Erotica/Science Fiction, Fantasy & Horror", + "Fairy Tales, Folk Tales, Legends & Mythology", + "Family Life/General", + "Family Life/Marriage & Divorce", + "Family Life/Siblings", + "Fantasy/General", + "Fantasy/Action & Adventure", + "Fantasy/Arthurian", + "Fantasy/Collections & Anthologies", + "Fantasy/Contemporary", + "Fantasy/Dark Fantasy", + "Fantasy/Dragons & Mythical Creatures", + "Fantasy/Epic", + "Fantasy/Gaslamp", + "Fantasy/Historical", + "Fantasy/Humorous", + "Fantasy/Military", + "Fantasy/Paranormal", + "Fantasy/Romance", + "Fantasy/Urban", + "Feminist", + "Friendship", + "Ghost", + "Gothic", + "Hispanic & Latino", + "Historical/General", + "Historical/Ancient", + "Historical/Civil War Era", + "Historical/Colonial America & Revolution", + "Historical/Medieval", + "Historical/Renaissance", + "Historical/World War I", + "Historical/World War II", + "Holidays", + "Horror", + "Humorous/General", + "Humorous/Black Humor", + "Indigenous", + "Jewish", + "Legal", + "LGBTQ+/General", + "LGBTQ+/Bisexual", + "LGBTQ+/Gay", + "LGBTQ+/Lesbian", + "LGBTQ+/Transgender", + "Literary", + "LitRPG (Literary Role-Playing Game)", + "Magical Realism", + "Mashups", + "Media Tie-In", + "Medical", + "Multiple Timelines", + "Muslim", + "Mystery & Detective/General", + "Mystery & Detective/Amateur Sleuth", + "Mystery & Detective/Collections & Anthologies", + "Mystery & Detective/Cozy/General", + "Mystery & Detective/Cozy/Animals", + "Mystery & Detective/Cozy/Crafts", + "Mystery & Detective/Cozy/Culinary", + "Mystery & Detective/Cozy/Holidays & Vacation", + "Mystery & Detective/Cozy/Paranormal", + "Mystery & Detective/Hard-Boiled", + "Mystery & Detective/Historical", + "Mystery & Detective/International Crime & Mystery", + "Mystery & Detective/Jewish", + "Mystery & Detective/Police Procedural", + "Mystery & Detective/Private Investigators", + "Mystery & Detective/Traditional", + "Mystery & Detective/Women Sleuths", + "Nature & the Environment", + "Noir", + "Occult & Supernatural", + "Own Voices", + "Political", + "Psychological", + "Religious", + "Romance/General", + "Romance/Action & Adventure", + "Romance/African American & Black", + "Romance/Billionaires", + "Romance/Clean & Wholesome", + "Romance/Collections & Anthologies", + "Romance/Contemporary", + "Romance/Erotic", + "Romance/Fantasy", + "Romance/Firefighters", + "Romance/Historical/General", + "Romance/Historical/American", + "Romance/Historical/Ancient World", + "Romance/Historical/Gilded Age", + "Romance/Historical/Medieval", + "Romance/Historical/Regency", + "Romance/Historical/Renaissance", + "Romance/Historical/Scottish", + "Romance/Historical/Tudor", + "Romance/Historical/20th Century", + "Romance/Historical/Victorian", + "Romance/Historical/Viking", + "Romance/Holiday", + "Romance/Later in Life", + "Romance/LGBTQ+/General", + "Romance/LGBTQ+/Bisexual", + "Romance/LGBTQ+/Gay", + "Romance/LGBTQ+/Lesbian", + "Romance/LGBTQ+/Transgender", + "Romance/Medical", + "Romance/Military", + "Romance/Multicultural & Interracial", + "Romance/New Adult", + "Romance/Paranormal/General", + "Romance/Paranormal/Shifters", + "Romance/Paranormal/Vampires", + "Romance/Paranormal/Witches", + "Romance/Police & Law Enforcement", + "Romance/Polyamory", + "Romance/Rock Stars", + "Romance/Romantic Comedy", + "Romance/Royalty", + "Romance/Science Fiction", + "Romance/Sports", + "Romance/Suspense", + "Romance/Time Travel", + "Romance/Western", + "Romance/Workplace", + "Sagas", + "Satire", + "Science Fiction/General", + "Science Fiction/Action & Adventure", + "Science Fiction/Alien Contact", + "Science Fiction/Apocalyptic & Post-Apocalyptic", + "Science Fiction/Collections & Anthologies", + "Science Fiction/Crime & Mystery", + "Science Fiction/Cyberpunk", + "Science Fiction/Genetic Engineering", + "Science Fiction/Hard Science Fiction", + "Science Fiction/Humorous", + "Science Fiction/Military", + "Science Fiction/Space Exploration", + "Science Fiction/Space Opera", + "Science Fiction/Steampunk", + "Science Fiction/Time Travel", + "Sea Stories", + "Short Stories (single author)", + "Small Town & Rural", + "Southern", + "Sports", + "Superheroes", + "Thrillers/General", + "Thrillers/Crime", + "Thrillers/Domestic", + "Thrillers/Espionage", + "Thrillers/Historical", + "Thrillers/Legal", + "Thrillers/Medical", + "Thrillers/Military", + "Thrillers/Political", + "Thrillers/Psychological", + "Thrillers/Supernatural", + "Thrillers/Suspense", + "Thrillers/Technological", + "Thrillers/Terrorism", + "Urban & Street Lit", + "Visionary & Metaphysical", + "War & Military", + "Westerns", + "Women", + "World Literature/Africa/General", + "World Literature/Africa/East Africa", + "World Literature/Africa/Nigeria", + "World Literature/Africa/Southern Africa", + "World Literature/Africa/West Africa", + "World Literature/American/General", + "World Literature/American/Colonial & Revolutionary Periods", + "World Literature/American/19th Century", + "World Literature/American/20th Century", + "World Literature/American/21st Century", + "World Literature/Argentina", + "World Literature/Asia (General)", + "World Literature/Australia", + "World Literature/Austria", + "World Literature/Brazil", + "World Literature/Canada/General", + "World Literature/Canada/Colonial & 19th Century", + "World Literature/Canada/20th Century", + "World Literature/Canada/21st Century", + "World Literature/Caribbean & West Indies", + "World Literature/Central America", + "World Literature/Chile", + "World Literature/China/General", + "World Literature/China/19th Century", + "World Literature/China/20th Century", + "World Literature/China/21st Century", + "World Literature/Colombia", + "World Literature/Czech Republic", + "World Literature/Denmark", + "World Literature/England/General", + "World Literature/England/Early & Medieval Periods", + "World Literature/England/16th & 17th Century", + "World Literature/England/18th Century", + "World Literature/England/19th Century", + "World Literature/England/20th Century", + "World Literature/England/21st Century", + "World Literature/Europe (General)", + "World Literature/Finland", + "World Literature/France/General", + "World Literature/France/18th Century", + "World Literature/France/19th Century", + "World Literature/France/20th Century", + "World Literature/France/21st Century", + "World Literature/Germany/General", + "World Literature/Germany/20th Century", + "World Literature/Germany/21st Century", + "World Literature/Greece", + "World Literature/Hungary", + "World Literature/India/General", + "World Literature/India/19th Century", + "World Literature/India/20th Century", + "World Literature/India/21st Century", + "World Literature/Ireland/General", + "World Literature/Ireland/19th Century", + "World Literature/Ireland/20th Century", + "World Literature/Ireland/21st Century", + "World Literature/Italy", + "World Literature/Japan", + "World Literature/Korea", + "World Literature/Mexico", + "World Literature/Middle East/General", + "World Literature/Middle East/Arabian Peninsula", + "World Literature/Middle East/Egypt & North Africa", + "World Literature/Middle East/Israel", + "World Literature/Netherlands", + "World Literature/New Zealand", + "World Literature/Norway", + "World Literature/Oceania", + "World Literature/Pakistan", + "World Literature/Peru", + "World Literature/Poland", + "World Literature/Portugal", + "World Literature/Russia/General", + "World Literature/Russia/19th Century", + "World Literature/Russia/20th Century", + "World Literature/Russia/21st Century", + "World Literature/Scotland/General", + "World Literature/Scotland/19th Century", + "World Literature/Scotland/20th Century", + "World Literature/Scotland/21st Century", + "World Literature/South America (General)", + "World Literature/Southeast Asia", + "World Literature/Spain/General", + "World Literature/Spain/19th Century", + "World Literature/Spain/20th Century", + "World Literature/Spain/21st Century", + "World Literature/Sweden", + "World Literature/Turkey", + "World Literature/Uruguay", + "World Literature/Wales" +] \ No newline at end of file diff --git a/koboldai_settings.py b/koboldai_settings.py index 17678b36..202b9e94 100644 --- a/koboldai_settings.py +++ b/koboldai_settings.py @@ -1,6 +1,8 @@ +from __future__ import annotations from dataclasses import dataclass import importlib import os, re, time, threading, json, pickle, base64, copy, tqdm, datetime, sys +import shutil from typing import Union from io import BytesIO from flask import has_request_context, session @@ -248,6 +250,28 @@ class koboldai_vars(object): return False return True + # Add Genres # + if self.genres: + # Erebus, Nerys, Janeway, Picard (probably) + genre_template = "[Genre: %s]" + model_name = self.model.lower() + if "skein" in model_name or "adventure" in model_name: + genre_template = "[Themes: %s]" + elif "shinen" in model_name: + genre_template = "[Theme: %s]" + + genre_text = genre_template % (", ".join(self.genres)) + genre_tokens = self.tokenizer.encode(genre_text) + genre_data = [[x, self.tokenizer.decode(x)] for x in genre_tokens] + + context.append({ + "type": "genre", + "text": genre_text, + "tokens": genre_data, + }) + used_tokens += len(genre_tokens) + + ######################################### Add memory ######################################################## memory_text = self.memory if memory_text != "": @@ -785,8 +809,8 @@ class model_settings(settings): process_variable_changes(self.socketio, self.__class__.__name__.replace("_settings", ""), name, value, old_value) class story_settings(settings): - local_only_variables = ['socketio', 'tokenizer', 'koboldai_vars', 'no_save', 'revisions', 'prompt'] - no_save_variables = ['socketio', 'tokenizer', 'koboldai_vars', 'context', 'no_save', 'prompt_in_ai', 'authornote_length', 'prompt_length', 'memory_length'] + local_only_variables = ['socketio', 'tokenizer', 'koboldai_vars', 'no_save', 'revisions', 'prompt', 'save_paths'] + no_save_variables = ['socketio', 'tokenizer', 'koboldai_vars', 'context', 'no_save', 'prompt_in_ai', 'authornote_length', 'prompt_length', 'memory_length', 'save_paths'] settings_name = "story" def __init__(self, socketio, koboldai_vars, tokenizer=None): self.socketio = socketio @@ -864,42 +888,67 @@ class story_settings(settings): # {"target": "(tm)", "substitution": "™", "enabled": False}, ] self.gen_audio = False + + # It's important to only use "=" syntax on this to ensure syncing; no + # .append() or the like + self.genres = [] # bias experiment self.memory_attn_bias = 1 self.an_attn_bias = 1 self.chat_style = 0 - + self.save_paths = SavePaths(os.path.join("stories", self.story_name or "Untitled")) + ################### must be at bottom ######################### self.no_save = False #Temporary disable save (doesn't save with the file) - - - - def save_story(self): - if not self.no_save: - if self.prompt != "" or self.memory != "" or self.authornote != "" or len(self.actions) > 0 or len(self.worldinfo_v2) > 0: - logger.debug("Saving story from koboldai_vars.story_settings.save_story()") - logger.info("Saving") - save_name = self.story_name if self.story_name != "" else "untitled" - adder = "" - while True: - if os.path.exists("stories/{}{}_v2.json".format(save_name, adder)): - with open("stories/{}{}_v2.json".format(save_name, adder), "r") as f: - temp = json.load(f) - if 'story_id' in temp: - if self.story_id != temp['story_id']: - adder = 0 if adder == "" else adder+1 - else: - break - else: - adder = 0 if adder == "" else adder+1 - else: - break - with open("stories/{}{}_v2.json".format(save_name, adder), "w") as settings_file: - settings_file.write(self.to_json()) - self.gamesaved = True + def save_story(self) -> None: + if self.no_save: + return + + if not any([self.prompt, self.memory, self.authornote, len(self.actions), len(self.worldinfo_v2)]): + return + + logger.info("Saving") + + save_name = self.story_name or "Untitled" + + # Disambiguate stories by adding (n) if needed + disambiguator = 0 + self.save_paths.base = os.path.join("stories", save_name) + while os.path.exists(self.save_paths.base): + try: + # If the stories share a story id, overwrite the existing one. + with open(self.save_paths.story, "r") as file: + j = json.load(file) + if self.story_id == j["story_id"]: + break + except FileNotFoundError: + raise FileNotFoundError("Malformed save file: Missing story.json") + + disambiguator += 1 + self.save_paths.base = os.path.join("stories", save_name + (f" ({disambiguator})" if disambiguator else "")) + + if not os.path.exists(self.save_paths.base): + # We are making the story for the first time. Setup the directory structure. + os.mkdir(self.save_paths.base) + os.mkdir(self.save_paths.generated_audio) + os.mkdir(self.save_paths.generated_images) + + # Convert v2 if applicable + v2_path = os.path.join("stories", f"{self.story_name}_v2.json") + if os.path.exists(v2_path): + logger.info("Migrating v2 save") + with open(v2_path, "r") as file: + v2j = json.load(file) + assert v2j["story_id"] == self.story_id + shutil.move(v2_path, os.path.join(self.save_paths.base, ".v2_old.json")) + + with open(self.save_paths.story, "w") as file: + file.write(self.to_json()) + self.gamesaved = True + def save_revision(self): game = json.loads(self.to_json()) del game['revisions'] @@ -974,6 +1023,7 @@ class story_settings(settings): if name == "gamesaved" and value == False and self.autosave: logger.debug("Saving story from gamesaved change and on autosave") self.save_story() + if not new_variable and old_value != value: #Change game save state if name in ['story_name', 'prompt', 'memory', 'authornote', 'authornotetemplate', 'andepth', 'chatname', 'actionmode', 'dynamicscan', 'notes', 'biases']: @@ -990,6 +1040,9 @@ class story_settings(settings): elif name == 'story_name': #reset the story id if we change the name self.story_id = int.from_bytes(os.urandom(16), 'little', signed=True) + + # Story name influences save base + self.save_paths.base = os.path.join("stories", self.story_name or "Untitled") #Recalc AI Text elif name == 'authornote': @@ -998,6 +1051,8 @@ class story_settings(settings): ignore = self.koboldai_vars.calc_ai_text() elif name == 'memory': ignore = self.koboldai_vars.calc_ai_text() + elif name == "genres": + self.koboldai_vars.calc_ai_text() elif name == 'prompt': self.prompt_wi_highlighted_text = [{"text": self.prompt, "WI matches": None, "WI Text": ""}] self.assign_world_info_to_actions(action_id=-1, wuid=None) @@ -1813,9 +1868,7 @@ class KoboldStoryRegister(object): #self.tts_model.to(torch.device(0)) # gpu or cpu self.tts_model.to(torch.device("cpu")) # gpu or cpu - filename="stories/{}/{}.ogg".format(self.story_settings.story_id, action_id) - if not os.path.exists("stories/{}".format(self.story_settings.story_id)): - os.mkdir("stories/{}".format(self.story_settings.story_id)) + filename = os.path.join(self.koboldai_vars.save_paths.generated_audio, f"{action_id}.ogg") if overwrite or not os.path.exists(filename): self.make_audio_queue.put((self.actions[action_id]['Selected Text'], filename)) @@ -2255,6 +2308,22 @@ class KoboldWorldInfo(object): def get_used_wi(self): return [x['content'] for x in self.world_info if x['used_in_game']] + +@dataclass +class SavePaths: + base: str + + @property + def story(self) -> str: + return os.path.join(self.base, "story.json") + + @property + def generated_audio(self) -> str: + return os.path.join(self.base, "generated_audio") + + @property + def generated_images(self) -> str: + return os.path.join(self.base, "generated_images") default_rand_range = [0.1, 1, 2] default_creativity_range = [0.8, 1] @@ -2286,5 +2355,4 @@ default_preset = { } badwordsids_default = [[13460], [6880], [50256], [42496], [4613], [17414], [22039], [16410], [27], [29], [38430], [37922], [15913], [24618], [28725], [58], [47175], [36937], [26700], [12878], [16471], [37981], [5218], [29795], [13412], [45160], [3693], [49778], [4211], [20598], [36475], [33409], [44167], [32406], [29847], [29342], [42669], [685], [25787], [7359], [3784], [5320], [33994], [33490], [34516], [43734], [17635], [24293], [9959], [23785], [21737], [28401], [18161], [26358], [32509], [1279], [38155], [18189], [26894], [6927], [14610], [23834], [11037], [14631], [26933], [46904], [22330], [25915], [47934], [38214], [1875], [14692], [41832], [13163], [25970], [29565], [44926], [19841], [37250], [49029], [9609], [44438], [16791], [17816], [30109], [41888], [47527], [42924], [23984], [49074], [33717], [31161], [49082], [30138], [31175], [12240], [14804], [7131], [26076], [33250], [3556], [38381], [36338], [32756], [46581], [17912], [49146]] # Tokenized array of badwords used to prevent AI artifacting badwordsids_neox = [[0], [1], [44162], [9502], [12520], [31841], [36320], [49824], [34417], [6038], [34494], [24815], [26635], [24345], [3455], [28905], [44270], [17278], [32666], [46880], [7086], [43189], [37322], [17778], [20879], [49821], [3138], [14490], [4681], [21391], [26786], [43134], [9336], [683], [48074], [41256], [19181], [29650], [28532], [36487], [45114], [46275], [16445], [15104], [11337], [1168], [5647], [29], [27482], [44965], [43782], [31011], [42944], [47389], [6334], [17548], [38329], [32044], [35487], [2239], [34761], [7444], [1084], [12399], [18990], [17636], [39083], [1184], [35830], [28365], [16731], [43467], [47744], [1138], [16079], [40116], [45564], [18297], [42368], [5456], [18022], [42696], [34476], [23505], [23741], [39334], [37944], [45382], [38709], [33440], [26077], [43600], [34418], [36033], [6660], [48167], [48471], [15775], [19884], [41533], [1008], [31053], [36692], [46576], [20095], [20629], [31759], [46410], [41000], [13488], [30952], [39258], [16160], [27655], [22367], [42767], [43736], [49694], [13811], [12004], [46768], [6257], [37471], [5264], [44153], [33805], [20977], [21083], [25416], [14277], [31096], [42041], [18331], [33376], [22372], [46294], [28379], [38475], [1656], [5204], [27075], [50001], [16616], [11396], [7748], [48744], [35402], [28120], [41512], [4207], [43144], [14767], [15640], [16595], [41305], [44479], [38958], [18474], [22734], [30522], [46267], [60], [13976], [31830], [48701], [39822], [9014], [21966], [31422], [28052], [34607], [2479], [3851], [32214], [44082], [45507], [3001], [34368], [34758], [13380], [38363], [4299], [46802], [30996], [12630], [49236], [7082], [8795], [5218], [44740], [9686], [9983], [45301], [27114], [40125], [1570], [26997], [544], [5290], [49193], [23781], [14193], [40000], [2947], [43781], [9102], [48064], [42274], [18772], [49384], [9884], [45635], [43521], [31258], [32056], [47686], [21760], [13143], [10148], [26119], [44308], [31379], [36399], [23983], [46694], [36134], [8562], [12977], [35117], [28591], [49021], [47093], [28653], [29013], [46468], [8605], [7254], [25896], [5032], [8168], [36893], [38270], [20499], [27501], [34419], [29547], [28571], [36586], [20871], [30537], [26842], [21375], [31148], [27618], [33094], [3291], [31789], [28391], [870], [9793], [41361], [47916], [27468], [43856], [8850], [35237], [15707], [47552], [2730], [41449], [45488], [3073], [49806], [21938], [24430], [22747], [20924], [46145], [20481], [20197], [8239], [28231], [17987], [42804], [47269], [29972], [49884], [21382], [46295], [36676], [34616], [3921], [26991], [27720], [46265], [654], [9855], [40354], [5291], [34904], [44342], [2470], [14598], [880], [19282], [2498], [24237], [21431], [16369], [8994], [44524], [45662], [13663], [37077], [1447], [37786], [30863], [42854], [1019], [20322], [4398], [12159], [44072], [48664], [31547], [18736], [9259], [31], [16354], [21810], [4357], [37982], [5064], [2033], [32871], [47446], [62], [22158], [37387], [8743], [47007], [17981], [11049], [4622], [37916], [36786], [35138], [29925], [14157], [18095], [27829], [1181], [22226], [5709], [4725], [30189], [37014], [1254], [11380], [42989], [696], [24576], [39487], [30119], [1092], [8088], [2194], [9899], [14412], [21828], [3725], [13544], [5180], [44679], [34398], [3891], [28739], [14219], [37594], [49550], [11326], [6904], [17266], [5749], [10174], [23405], [9955], [38271], [41018], [13011], [48392], [36784], [24254], [21687], [23734], [5413], [41447], [45472], [10122], [17555], [15830], [47384], [12084], [31350], [47940], [11661], [27988], [45443], [905], [49651], [16614], [34993], [6781], [30803], [35869], [8001], [41604], [28118], [46462], [46762], [16262], [17281], [5774], [10943], [5013], [18257], [6750], [4713], [3951], [11899], [38791], [16943], [37596], [9318], [18413], [40473], [13208], [16375]] -badwordsids_opt = [[44717], [46613], [48513], [49923], [50185], [48755], [8488], [43303], [49659], [48601], [49817], [45405], [48742], [49925], [47720], [11227], [48937], [48784], [50017], [42248], [49310], [48082], [49895], [50025], [49092], [49007], [8061], [44226], [0], [742], [28578], [15698], [49784], [46679], [39365], [49281], [49609], [48081], [48906], [46161], [48554], [49670], [48677], [49721], [49632], [48610], [48462], [47457], [10975], [46077], [28696], [48709], [43839], [49798], [49154], [48203], [49625], [48395], [50155], [47161], [49095], [48833], [49420], [49666], [48443], [22176], [49242], [48651], [49138], [49750], [40389], [48021], [21838], [49070], [45333], [40862], [1], [49915], [33525], [49858], [50254], [44403], [48992], [48872], [46117], [49853], [47567], [50206], [41552], [50068], [48999], [49703], [49940], [49329], [47620], [49868], [49962], [2], [44082], [50236], [31274], [50260], [47052], [42645], [49177], [17523], [48691], [49900], [49069], [49358], [48794], [47529], [46479], [48457], [646], [49910], [48077], [48935], [46386], [48902], [49151], [48759], [49803], [45587], [48392], [47789], [48654], [49836], [49230], [48188], [50264], [46844], [44690], [48505], [50161], [27779], [49995], [41833], [50154], [49097], [48520], [50018], [8174], [50084], [49366], [49526], [50193], [7479], [49982], [3]] -genres = ['Absurdist', 'Action & Adventure', 'Adaptations & Pastiche', 'African American & Black/General', 'African American & Black/Christian', 'African American & Black/Erotica', 'African American & Black/Historical', 'African American & Black/Mystery & Detective', 'African American & Black/Urban & Street Lit', 'African American & Black/Women', 'Alternative History', 'Amish & Mennonite', 'Animals', 'Anthologies (multiple authors)', 'Asian American', 'Biographical', 'Buddhist', 'Christian/General', 'Christian/Biblical', 'Christian/Classic & Allegory', 'Christian/Collections & Anthologies', 'Christian/Contemporary', 'Christian/Fantasy', 'Christian/Futuristic', 'Christian/Historical', 'Christian/Romance/General', 'Christian/Romance/Historical', 'Christian/Romance/Suspense', 'Christian/Suspense', 'Christian/Western', 'City Life', 'Classics', 'Coming of Age', 'Crime', 'Cultural Heritage', 'Disabilities & Special Needs', 'Disaster', 'Dystopian', 'Epistolary', 'Erotica/General', 'Erotica/BDSM', 'Erotica/Collections & Anthologies', 'Erotica/Historical', 'Erotica/LGBTQ+/General', 'Erotica/LGBTQ+/Bisexual', 'Erotica/LGBTQ+/Gay', 'Erotica/LGBTQ+/Lesbian', 'Erotica/LGBTQ+/Transgender', 'Erotica/Science Fiction, Fantasy & Horror', 'Fairy Tales, Folk Tales, Legends & Mythology', 'Family Life/General', 'Family Life/Marriage & Divorce', 'Family Life/Siblings', 'Fantasy/General', 'Fantasy/Action & Adventure', 'Fantasy/Arthurian', 'Fantasy/Collections & Anthologies', 'Fantasy/Contemporary', 'Fantasy/Dark Fantasy', 'Fantasy/Dragons & Mythical Creatures', 'Fantasy/Epic', 'Fantasy/Gaslamp', 'Fantasy/Historical', 'Fantasy/Humorous', 'Fantasy/Military', 'Fantasy/Paranormal', 'Fantasy/Romance', 'Fantasy/Urban', 'Feminist', 'Friendship', 'Ghost', 'Gothic', 'Hispanic & Latino', 'Historical/General', 'Historical/Ancient', 'Historical/Civil War Era', 'Historical/Colonial America & Revolution', 'Historical/Medieval', 'Historical/Renaissance', 'Historical/World War I', 'Historical/World War II', 'Holidays', 'Horror', 'Humorous/General', 'Humorous/Black Humor', 'Indigenous', 'Jewish', 'Legal', 'LGBTQ+/General', 'LGBTQ+/Bisexual', 'LGBTQ+/Gay', 'LGBTQ+/Lesbian', 'LGBTQ+/Transgender', 'Literary', 'LitRPG (Literary Role-Playing Game) *', 'Magical Realism', 'Mashups', 'Media Tie-In', 'Medical', 'Multiple Timelines', 'Muslim', 'Mystery & Detective/General', 'Mystery & Detective/Amateur Sleuth', 'Mystery & Detective/Collections & Anthologies', 'Mystery & Detective/Cozy/General', 'Mystery & Detective/Cozy/Animals', 'Mystery & Detective/Cozy/Crafts', 'Mystery & Detective/Cozy/Culinary', 'Mystery & Detective/Cozy/Holidays & Vacation *', 'Mystery & Detective/Cozy/Paranormal *', 'Mystery & Detective/Hard-Boiled', 'Mystery & Detective/Historical', 'Mystery & Detective/International Crime & Mystery', 'Mystery & Detective/Jewish *', 'Mystery & Detective/Police Procedural', 'Mystery & Detective/Private Investigators', 'Mystery & Detective/Traditional', 'Mystery & Detective/Women Sleuths', 'Nature & the Environment', 'Noir', 'Occult & Supernatural', 'Own Voices', 'Political', 'Psychological', 'Religious', 'Romance/General', 'Romance/Action & Adventure', 'Romance/African American & Black', 'Romance/Billionaires', 'Romance/Clean & Wholesome', 'Romance/Collections & Anthologies', 'Romance/Contemporary', 'Romance/Erotic', 'Romance/Fantasy', 'Romance/Firefighters', 'Romance/Historical/General', 'Romance/Historical/American', 'Romance/Historical/Ancient World', 'Romance/Historical/Gilded Age', 'Romance/Historical/Medieval', 'Romance/Historical/Regency', 'Romance/Historical/Renaissance', 'Romance/Historical/Scottish', 'Romance/Historical/Tudor', 'Romance/Historical/20th Century', 'Romance/Historical/Victorian', 'Romance/Historical/Viking', 'Romance/Holiday', 'Romance/Later in Life', 'Romance/LGBTQ+/General', 'Romance/LGBTQ+/Bisexual', 'Romance/LGBTQ+/Gay', 'Romance/LGBTQ+/Lesbian', 'Romance/LGBTQ+/Transgender', 'Romance/Medical', 'Romance/Military', 'Romance/Multicultural & Interracial', 'Romance/New Adult', 'Romance/Paranormal/General', 'Romance/Paranormal/Shifters', 'Romance/Paranormal/Vampires', 'Romance/Paranormal/Witches', 'Romance/Police & Law Enforcement', 'Romance/Polyamory', 'Romance/Rock Stars', 'Romance/Romantic Comedy', 'Romance/Royalty', 'Romance/Science Fiction', 'Romance/Sports', 'Romance/Suspense', 'Romance/Time Travel', 'Romance/Western', 'Romance/Workplace', 'Sagas', 'Satire', 'Science Fiction/General', 'Science Fiction/Action & Adventure', 'Science Fiction/Alien Contact', 'Science Fiction/Apocalyptic & Post-Apocalyptic', 'Science Fiction/Collections & Anthologies', 'Science Fiction/Crime & Mystery', 'Science Fiction/Cyberpunk', 'Science Fiction/Genetic Engineering', 'Science Fiction/Hard Science Fiction', 'Science Fiction/Humorous', 'Science Fiction/Military', 'Science Fiction/Space Exploration', 'Science Fiction/Space Opera', 'Science Fiction/Steampunk', 'Science Fiction/Time Travel', 'Sea Stories', 'Short Stories (single author)', 'Small Town & Rural', 'Southern', 'Sports', 'Superheroes', 'Thrillers/General', 'Thrillers/Crime', 'Thrillers/Domestic', 'Thrillers/Espionage', 'Thrillers/Historical', 'Thrillers/Legal', 'Thrillers/Medical', 'Thrillers/Military', 'Thrillers/Political', 'Thrillers/Psychological', 'Thrillers/Supernatural', 'Thrillers/Suspense', 'Thrillers/Technological', 'Thrillers/Terrorism', 'Urban & Street Lit', 'Visionary & Metaphysical', 'War & Military', 'Westerns', 'Women', 'World Literature/Africa/General', 'World Literature/Africa/East Africa', 'World Literature/Africa/Nigeria', 'World Literature/Africa/Southern Africa', 'World Literature/Africa/West Africa', 'World Literature/American/General', 'World Literature/American/Colonial & Revolutionary Periods', 'World Literature/American/19th Century', 'World Literature/American/20th Century', 'World Literature/American/21st Century', 'World Literature/Argentina', 'World Literature/Asia (General)', 'World Literature/Australia', 'World Literature/Austria', 'World Literature/Brazil', 'World Literature/Canada/General', 'World Literature/Canada/Colonial & 19th Century', 'World Literature/Canada/20th Century', 'World Literature/Canada/21st Century', 'World Literature/Caribbean & West Indies', 'World Literature/Central America', 'World Literature/Chile', 'World Literature/China/General', 'World Literature/China/19th Century', 'World Literature/China/20th Century', 'World Literature/China/21st Century', 'World Literature/Colombia', 'World Literature/Czech Republic', 'World Literature/Denmark', 'World Literature/England/General', 'World Literature/England/Early & Medieval Periods', 'World Literature/England/16th & 17th Century', 'World Literature/England/18th Century', 'World Literature/England/19th Century', 'World Literature/England/20th Century', 'World Literature/England/21st Century', 'World Literature/Europe (General)', 'World Literature/Finland', 'World Literature/France/General', 'World Literature/France/18th Century', 'World Literature/France/19th Century', 'World Literature/France/20th Century', 'World Literature/France/21st Century', 'World Literature/Germany/General', 'World Literature/Germany/20th Century', 'World Literature/Germany/21st Century', 'World Literature/Greece', 'World Literature/Hungary', 'World Literature/India/General', 'World Literature/India/19th Century', 'World Literature/India/20th Century', 'World Literature/India/21st Century', 'World Literature/Ireland/General', 'World Literature/Ireland/19th Century', 'World Literature/Ireland/20th Century', 'World Literature/Ireland/21st Century', 'World Literature/Italy', 'World Literature/Japan', 'World Literature/Korea', 'World Literature/Mexico', 'World Literature/Middle East/General', 'World Literature/Middle East/Arabian Peninsula', 'World Literature/Middle East/Egypt & North Africa', 'World Literature/Middle East/Israel', 'World Literature/Netherlands', 'World Literature/New Zealand', 'World Literature/Norway', 'World Literature/Oceania', 'World Literature/Pakistan', 'World Literature/Peru', 'World Literature/Poland', 'World Literature/Portugal', 'World Literature/Russia/General', 'World Literature/Russia/19th Century', 'World Literature/Russia/20th Century', 'World Literature/Russia/21st Century', 'World Literature/Scotland/General', 'World Literature/Scotland/19th Century', 'World Literature/Scotland/20th Century', 'World Literature/Scotland/21st Century', 'World Literature/South America (General)', 'World Literature/Southeast Asia', 'World Literature/Spain/General', 'World Literature/Spain/19th Century', 'World Literature/Spain/20th Century', 'World Literature/Spain/21st Century', 'World Literature/Sweden', 'World Literature/Turkey', 'World Literature/Uruguay', 'World Literature/Wales'] +badwordsids_opt = [[44717], [46613], [48513], [49923], [50185], [48755], [8488], [43303], [49659], [48601], [49817], [45405], [48742], [49925], [47720], [11227], [48937], [48784], [50017], [42248], [49310], [48082], [49895], [50025], [49092], [49007], [8061], [44226], [0], [742], [28578], [15698], [49784], [46679], [39365], [49281], [49609], [48081], [48906], [46161], [48554], [49670], [48677], [49721], [49632], [48610], [48462], [47457], [10975], [46077], [28696], [48709], [43839], [49798], [49154], [48203], [49625], [48395], [50155], [47161], [49095], [48833], [49420], [49666], [48443], [22176], [49242], [48651], [49138], [49750], [40389], [48021], [21838], [49070], [45333], [40862], [1], [49915], [33525], [49858], [50254], [44403], [48992], [48872], [46117], [49853], [47567], [50206], [41552], [50068], [48999], [49703], [49940], [49329], [47620], [49868], [49962], [2], [44082], [50236], [31274], [50260], [47052], [42645], [49177], [17523], [48691], [49900], [49069], [49358], [48794], [47529], [46479], [48457], [646], [49910], [48077], [48935], [46386], [48902], [49151], [48759], [49803], [45587], [48392], [47789], [48654], [49836], [49230], [48188], [50264], [46844], [44690], [48505], [50161], [27779], [49995], [41833], [50154], [49097], [48520], [50018], [8174], [50084], [49366], [49526], [50193], [7479], [49982], [3]] \ No newline at end of file diff --git a/static/koboldai.css b/static/koboldai.css index 07f96011..6f68db4e 100644 --- a/static/koboldai.css +++ b/static/koboldai.css @@ -1975,6 +1975,7 @@ body { .context-sp {background-color: var(--context_colors_soft_prompt);} +.context-genre {background-color: var(--context_colors_genre);} .context-prompt {background-color: var(--context_colors_prompt);} .context-wi {background-color: var(--context_colors_world_info);} .context-memory {background-color: var(--context_colors_memory);} @@ -2718,6 +2719,71 @@ body { max-width: 99%; } +/* Genres */ +#genre-input { + height: 32px; + width: 100%; +} + +#genre-suggestion-super-container { position: relative; } +#genre-suggestion-container { + position: absolute; + width: 100%; + left: 0px; + top: 0px; + background-color: var(--input_background); + z-index: 2; + max-height: calc((30px + 2px) * 5); + overflow-y: auto; +} + +.genre-suggestion { + display: block; + text-align: center; + padding: 5px 0px; + background-color: var(--wi_tag_color); + margin-bottom: 2px; +} + +.genre-suggestion.highlighted { + background-color: #151e28; +} + +#genre-container { + max-height: 50vh; + overflow-y: auto; + + background-color: #151e28; +} + +.genre { + display: inline-flex; + align-items: center; + background-color: var(--wi_tag_color); + border-radius: var(--radius_wi_card); + padding: 4px 0px; + margin: 2px 2px; +} + +.genre-inner { + display: flex; + justify-content: center; + position: relative; + width: 100%; + margin: 0px 10px; + top: 1px; + right: 2px; +} + +.genre-inner > .x { + font-size: 16px; + cursor: pointer; + opacity: 0.7; +} + +.genre-inner > .x:hover { opacity: 1; } + + /*---------------------------------- Global ------------------------------------------------*/ .hidden { display: none; diff --git a/static/koboldai.js b/static/koboldai.js index fe5ee9ce..590f3c02 100644 --- a/static/koboldai.js +++ b/static/koboldai.js @@ -70,12 +70,16 @@ var scroll_trigger_element = undefined; //undefined means not currently set. If var drag_id = null; const on_colab = $el("#on_colab").textContent == "true"; +// Each entry into this array should be an object that looks like: +// {class: "class", key: "key", func: callback} +let sync_hooks = []; + // name, desc, icon, func var finder_actions = [ {name: "Load Model", icon: "folder_open", type: "action", func: function() { socket.emit('load_model_button', {}); }}, {name: "New Story", icon: "description", type: "action", func: function() { socket.emit('new_story', ''); }}, - {name: "Load Story", icon: "folder_open", type: "action", func: function() { socket.emit('load_story_list', ''); }}, - {name: "Save Story", icon: "save", type: "action", func: function() { socket.emit("save_story", null, (response) => {save_as_story(response);}); }}, + {name: "Load Story", icon: "folder_open", type: "action", func: load_story_list}, + {name: "Save Story", icon: "save", type: "action", func: save_story}, {name: "Download Story", icon: "file_download", type: "action", func: function() { document.getElementById('download_iframe').src = 'json'; }}, {name: "Import Story", icon: "file_download", desc: "Import a prompt from aetherroom.club, formerly prompts.aidg.club", type: "action", func: openClubImport }, @@ -119,6 +123,7 @@ const context_menu_actions = { "generated-image": [ {label: "View", icon: "search", enabledOn: "ALWAYS", click: imgGenView}, {label: "Download", icon: "download", enabledOn: "ALWAYS", click: imgGenDownload}, + {label: "Retry", icon: "refresh", enabledOn: "ALWAYS", click: imgGenRetry}, {label: "Clear", icon: "clear", enabledOn: "ALWAYS", click: imgGenClear}, ], "wi-img-upload-button": [ @@ -129,8 +134,8 @@ const context_menu_actions = { // CTRL-[X] const shortcuts = [ - {key: "k", desc: "Finder", func: open_finder}, - {key: "/", desc: "Help screen", func: () => openPopup("shortcuts-popup")}, + {key: "s", desc: "Save Story", func: save_story}, + {key: "o", desc: "Open Story", func: load_story_list}, {key: "z", desc: "Undoes last story action", func: () => socket.emit("back", {}), criteria: canNavigateStoryHistory}, {key: "y", desc: "Redoes last story action", func: () => socket.emit("redo", {}), criteria: canNavigateStoryHistory}, {key: "e", desc: "Retries last story action", func: () => socket.emit("retry", {}), criteria: canNavigateStoryHistory}, @@ -138,6 +143,8 @@ const shortcuts = [ {key: "u", desc: "Focuses Author's Note", func: () => focusEl("#authors_notes")}, // CTRL-N is reserved :^( {key: "g", desc: "Focuses game text", func: () => focusEl("#input_text")}, {key: "l", desc: '"Lock" screen (Not secure)', func: () => socket.emit("privacy_mode", {'enabled': true})}, + {key: "k", desc: "Finder", func: open_finder}, + {key: "/", desc: "Help screen", func: () => openPopup("shortcuts-popup")}, ] const chat = { @@ -651,6 +658,9 @@ function do_probabilities(action) { } +function save_story() { socket.emit("save_story", null, response => save_as_story(response)); } +function load_story_list() { socket.emit("load_story_list", ""); } + function do_presets(data) { for (select of document.getElementsByClassName('presets')) { //clear out the preset list @@ -754,6 +764,12 @@ function var_changed(data) { //if (data.name == "sp") { // console.log({"name": data.name, "data": data}); //} + + for (const entry of sync_hooks) { + if (data.classname !== entry.class) continue; + if (data.name !== entry.name) continue; + entry.func(data.value); + } if (data.name in vars_sync_time) { if (vars_sync_time[data.name] > Date.parse(data.transmit_time)) { @@ -761,7 +777,7 @@ function var_changed(data) { } } vars_sync_time[data.name] = Date.parse(data.transmit_time); - + if ((data.classname == 'actions') && (data.name == 'Action Count')) { current_action = data.value; if (current_action <= 0) { @@ -1008,7 +1024,7 @@ function load_popup(data) { for (file of fileList) { reader = new FileReader(); reader.onload = function (event) { - socket.emit("upload_file", {'filename': file.name, "data": event.target.result}); + socket.emit("upload_file", {'filename': file.name, "data": event.target.result, 'upload_no_save': true}); }; reader.readAsArrayBuffer(file); } @@ -2612,80 +2628,86 @@ function create_new_softprompt() { async function download_story_to_json() { //document.getElementById('download_iframe').src = 'json'; downloaded = false; +async function download_story() { if (socket.connected) { try { - let r = await fetch("json"); - let j = await r.json(); - downloadString(JSON.stringify(j), j['story_name']+".json") - downloaded = true; + let name = $el(".var_sync_story_story_name").innerText; + let r = await fetch("story_download"); + downloadBlob(await r.blob(), `${name}.kaistory`); + return; } catch(err) { - downloaded = false; + console.error("Error in online download"); + console.error(err); } - } if (downloaded == false) { - //first we're going to find all the var_sync_story_ classes used in the document. - let allClasses = []; - const allElements = document.querySelectorAll('*'); + } - for (let i = 0; i < allElements.length; i++) { - let classes = allElements[i].classList; - for (let j = 0; j < classes.length; j++) { - if (!(allClasses.includes(classes[j].replace("var_sync_story_", ""))) && (classes[j].includes("var_sync_story_"))) { - allClasses.push(classes[j].replace("var_sync_story_", "")); - } - } + console.warn("Online download failed! Using offline download...") + + /* Offline Download - Compile JSON file from what we have in ram */ + + //first we're going to find all the var_sync_story_ classes used in the document. + let allClasses = []; + const allElements = document.querySelectorAll('*'); + + for (let i = 0; i < allElements.length; i++) { + let classes = allElements[i].classList; + for (let j = 0; j < classes.length; j++) { + if (!(allClasses.includes(classes[j].replace("var_sync_story_", ""))) && (classes[j].includes("var_sync_story_"))) { + allClasses.push(classes[j].replace("var_sync_story_", "")); } - - //OK, now we're going to go through each of those classes and get the values from the elements - let j = {} - for (class_name of allClasses) { - for (item of document.getElementsByClassName("var_sync_story_"+class_name)) { - if (['INPUT', 'TEXTAREA', 'SELECT'].includes(item.tagName)) { - if ((item.tagName == 'INPUT') && (item.type == "checkbox")) { - j[class_name] = item.checked; - } else { - j[class_name] = item.value; - } + } + } + + //OK, now we're going to go through each of those classes and get the values from the elements + let j = {} + for (class_name of allClasses) { + for (item of document.getElementsByClassName("var_sync_story_"+class_name)) { + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(item.tagName)) { + if ((item.tagName == 'INPUT') && (item.type == "checkbox")) { + j[class_name] = item.checked; } else { - j[class_name] = item.textContent; + j[class_name] = item.value; } - break; + } else { + j[class_name] = item.textContent; } + break; } - - //We'll add actions and world info data next - let temp = JSON.parse(JSON.stringify(actions_data)); - delete temp[-1]; - j['actions'] = {'action_count': document.getElementById('action_count').textContent, 'actions': temp}; - j['worldinfo_v2'] = {'entries': world_info_data, 'folders': world_info_folder_data}; - - //Biases - let bias = {}; - for (item of document.getElementsByClassName('bias')) { - let bias_phrase = item.querySelector(".bias_phrase").children[0].value; - let bias_score = parseInt(item.querySelector(".bias_score").querySelector(".bias_slider_cur").textContent); - let bias_comp_threshold = parseInt(item.querySelector(".bias_comp_threshold").querySelector(".bias_slider_cur").textContent); - if (bias_phrase != "") { - bias[bias_phrase] = [bias_score, bias_comp_threshold]; - } + } + + //We'll add actions and world info data next + let temp = JSON.parse(JSON.stringify(actions_data)); + delete temp[-1]; + j['actions'] = {'action_count': document.getElementById('action_count').textContent, 'actions': temp}; + j['worldinfo_v2'] = {'entries': world_info_data, 'folders': world_info_folder_data}; + + //Biases + let bias = {}; + for (item of document.getElementsByClassName('bias')) { + let bias_phrase = item.querySelector(".bias_phrase").children[0].value; + let bias_score = parseInt(item.querySelector(".bias_score").querySelector(".bias_slider_cur").textContent); + let bias_comp_threshold = parseInt(item.querySelector(".bias_comp_threshold").querySelector(".bias_slider_cur").textContent); + if (bias_phrase != "") { + bias[bias_phrase] = [bias_score, bias_comp_threshold]; } - j['biases'] = bias; - - //substitutions - substitutions = []; - for (item of document.getElementsByClassName('substitution-card')) { - let target = item.children[0].querySelector(".target").value; - let sub = item.children[1].querySelector(".target").value; - let enabled = (item.children[1].querySelector(".material-icons-outlined").getAttribute("title") == 'Enabled'); - substitutions.push({'target': target, 'substitution': sub, 'enabled': enabled}); - } - j['substitutions'] = substitutions; - - j['file_version'] = 2; - j['gamestarted'] = true; - - downloadString(JSON.stringify(j), j['story_name']+".json") - } + } + j['biases'] = bias; + + //substitutions + substitutions = []; + for (item of document.getElementsByClassName('substitution-card')) { + let target = item.children[0].querySelector(".target").value; + let sub = item.children[1].querySelector(".target").value; + let enabled = (item.children[1].querySelector(".material-icons-outlined").getAttribute("title") == 'Enabled'); + substitutions.push({'target': target, 'substitution': sub, 'enabled': enabled}); + } + j['substitutions'] = substitutions; + + j['file_version'] = 2; + j['gamestarted'] = true; + + downloadString(JSON.stringify(j), j['story_name']+".json") } function unload_userscripts() { @@ -3197,6 +3219,7 @@ function autoResize(element, min_size=200) { function calc_token_usage( soft_prompt_length, + genre_length, memory_length, authors_note_length, prompt_length, @@ -3209,6 +3232,7 @@ function calc_token_usage( const data = [ {id: "soft_prompt_tokens", tokenCount: soft_prompt_length, label: "Soft Prompt"}, + {id: "genre_tokens", tokenCount: genre_length, label: "Genre"}, {id: "memory_tokens", tokenCount: memory_length, label: "Memory"}, {id: "authors_notes_tokens", tokenCount: authors_note_length, label: "Author's Note"}, {id: "world_info_tokens", tokenCount: world_info_length, label: "World Info"}, @@ -3525,6 +3549,7 @@ function update_context(data) { $(".context-block").remove(); let memory_length = 0; + let genre_length = 0; let authors_notes_length = 0; let prompt_length = 0; let game_text_length = 0; @@ -3542,10 +3567,12 @@ function update_context(data) { for (const entry of data) { + console.info(entry) let contextClass = "context-" + ({ soft_prompt: "sp", prompt: "prompt", world_info: "wi", + genre: "genre", memory: "memory", authors_note: "an", action: "action", @@ -3592,6 +3619,9 @@ function update_context(data) { document.getElementById('world_info_'+entry.uid).classList.add("used_in_game"); } break; + case 'memory': + genre_length += entry.tokens.length; + break; case 'memory': memory_length += entry.tokens.length; break; @@ -3616,6 +3646,7 @@ function update_context(data) { calc_token_usage( soft_prompt_length, + genre_length, memory_length, authors_notes_length, prompt_length, @@ -4344,6 +4375,14 @@ function downloadString(string, fileName) { a.click(); } +function downloadBlob(blob, fileName) { + const a = $e("a", null, { + href: URL.createObjectURL(blob), + download: fileName + }); + a.click(); +} + function getRedactedValue(value) { if (typeof value === "string") return `[Redacted string with length ${value.length}]`; if (value instanceof Array) return `[Redacted array with length ${value.length}]`; @@ -4602,11 +4641,16 @@ async function loadNAILorebook(data, filename, image=null) { } } -async function loadKoboldData(data, filename) { +async function loadKoboldJSON(data, filename) { if (data.gamestarted !== undefined) { // Story - socket.emit("upload_file", {"filename": filename, "data": JSON.stringify(data)}); + socket.emit("upload_file", { + filename: filename, + data: new Blob([JSON.stringify(data)]), + upload_no_save: true + }); socket.emit("load_story_list", ""); + load_story_list(); } else if (data.folders !== undefined && data.entries !== undefined) { // World Info Folder await postWI(data); @@ -4685,9 +4729,16 @@ async function processDroppedFile(file) { readLoreCard(file); break; case "json": - // KoboldAI file + // KoboldAI file (old story, etc) data = JSON.parse(await file.text()); - loadKoboldData(data, file.name); + loadKoboldJSON(data, file.name); + break; + case "kaistory": + // KoboldAI story file + let r = await fetch(`/upload_kai_story/${file.name}`, { + method: "POST", + body: file + }); break; case "lorebook": // NovelAI lorebook, JSON encoded. @@ -5467,6 +5518,8 @@ process_cookies(); return; } + if (finder_mode !== "ui") return; + const actionsCount = actions.length; let future = finder_selection_index + delta; @@ -6426,4 +6479,152 @@ function imgGenClear() { const container = $el("#action\\ image"); container.removeAttribute("tooltip"); socket.emit("clear_generated_image", {}); -} \ No newline at end of file +} + +function imgGenRetry() { + const image = $el(".action_image"); + if (!image) return; + $el("#image-loading").classList.remove("hidden"); + socket.emit("retry_generated_image", {}); +} + +/* Genres */ +(async function() { + const genreContainer = $el("#genre-container"); + const genreInput = $el("#genre-input"); + const genreSuggestionContainer = $el("#genre-suggestion-container"); + let genreData = await (await fetch("/genre_data.json")).json(); + let allGenres = genreData.list; + let genres = genreData.init; + let highlightIndex = -1; + + sync_hooks.push({ + class: "story", + name: "genres", + func: function(passedGenres) { + genres = passedGenres; + $(".genre").remove(); + for (const g of genres) { + addGenreUI(g); + } + } + }) + + function addGenreUI(genre) { + let div = $e("div", genreContainer, {classes: ["genre"]}); + let inner = $e("div", div, {classes: ["genre-inner"]}); + let xIcon = $e("span", inner, {innerText: "clear", classes: ["x", "material-icons-outlined"]}); + let label = $e("span", inner, {innerText: genre, classes: ["genre-label"]}); + + xIcon.addEventListener("click", function() { + div.remove(); + genres = genres.filter(x => x !== genre); + socket.emit("var_change", {"ID": "story_genres", "value": genres}); + }); + } + + for (const initGenre of genreData.init) { + addGenreUI(initGenre); + } + + function addGenre(genre) { + if (genres.includes(genre)) return; + + addGenreUI(genre); + genreInput.value = ""; + nukeSuggestions(); + + genres.push(genre); + socket.emit("var_change", {"ID": "story_genres", "value": genres}); + } + + function nukeSuggestions() { + genreSuggestionContainer.innerHTML = ""; + highlightIndex = -1; + } + + document.addEventListener("click", function(event) { + // Listening for clicks all over the document kinda sucks but blur + // fires you can click a suggestion so... + if (!genreSuggestionContainer.children.length) return; + if (event.target === genreInput) return; + if (event.target.classList.contains("genre-suggestion")) return; + nukeSuggestions(); + }); + + genreInput.addEventListener("keydown", function(event) { + switch (event.key) { + case "ArrowUp": + highlightIndex--; + break; + case "Tab": + highlightIndex += event.shiftKey ? -1 : 1; + break; + case "ArrowDown": + highlightIndex++; + break; + case "Enter": + if (highlightIndex === -1) { + if (!genreInput.value.trim()) return; + addGenre(genreInput.value); + } else { + genreSuggestionContainer.children[highlightIndex].click(); + } + return; + case "Escape": + genreInput.value = ""; + nukeSuggestions(); + event.preventDefault(); + event.stopPropagation(); + return; + default: + return; + } + + event.preventDefault(); + + if (!genreSuggestionContainer.children.length) return; + + const oldHighlighted = $el(".genre-suggestion.highlighted"); + if (oldHighlighted) oldHighlighted.classList.remove("highlighted"); + + // Wrap around + let maxIndex = genreSuggestionContainer.children.length - 1; + if (highlightIndex < 0) highlightIndex = maxIndex; + if (highlightIndex > maxIndex) highlightIndex = 0; + + const highlighted = genreSuggestionContainer.children[highlightIndex]; + highlighted.classList.add("highlighted"); + highlighted.scrollIntoView({ + behavior: "auto", + block: "center", + inline: "center" + }); + }); + + genreInput.addEventListener("input", function() { + let showList = []; + let lowerMatch = genreInput.value.toLowerCase(); + + nukeSuggestions(); + if (!lowerMatch) return; + + for (const genre of allGenres) { + if (!genre.toLowerCase().includes(lowerMatch)) continue; + showList.push(genre); + } + + for (const genre of showList) { + let suggestion = $e("span", genreSuggestionContainer, { + innerText: genre, + classes: ["genre-suggestion"] + }); + + suggestion.addEventListener("click", function() { + addGenre(this.innerText); + }); + } + }); + + +})() \ No newline at end of file diff --git a/templates/popups.html b/templates/popups.html index e80d0b51..f4a3add0 100644 --- a/templates/popups.html +++ b/templates/popups.html @@ -196,10 +196,11 @@ Key:
Soft Prompt - Prompt - World Info + Genre Memory + World Info Author's Note + Prompt Action Submit
diff --git a/templates/settings flyout.html b/templates/settings flyout.html index 8afc9723..bb70db41 100644 --- a/templates/settings flyout.html +++ b/templates/settings flyout.html @@ -60,7 +60,7 @@ - file_download + file_download
@@ -71,11 +71,11 @@ description New Story - - diff --git a/templates/story flyout.html b/templates/story flyout.html index 41757f57..a85a90cd 100644 --- a/templates/story flyout.html +++ b/templates/story flyout.html @@ -50,6 +50,19 @@

+

Genre

+
Styles the AI will attempt to imitate. Effectiveness depends on model.
+ +
+
+ Animals + Amish + Asian + Buddhist +
+
+
+

See disclaimer in memory page. @@ -112,6 +125,7 @@