Merge branch 'staging' into staging

This commit is contained in:
Azariel Del Carmen 2024-04-27 21:49:02 +01:00 committed by GitHub
commit fc158ca176
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 2292 additions and 9256 deletions

2
.github/close-label.yml vendored Normal file
View File

@ -0,0 +1,2 @@
🐛 Bug: ✅ Fixed
🦄 Feature Request: ✅ Implemented

62
.github/issue-auto-comments.yml vendored Normal file
View File

@ -0,0 +1,62 @@
comment:
footer: |
---
> I am a bot, and this is an automated message 🤖
labels:
- name: ✖️ Invalid
labeled:
issue:
action: close
body: >
Hello @{{ issue.user.login }} your ticket has been marked as invalid.
Please ensure you follow the issue template, provide all requested info,
and be sure to check the docs + previous issues prior to raising tickets.
pr:
body: Thank you @{{ pull_request.user.login }} for suggesting this. Please follow the pull request templates.
action: close
- name: 👩‍💻 Good First Issue
labeled:
issue:
body: >
This issue has been marked as a good first issue for first-time contributors to implement!
This is a great way to support the project, while also improving your skills, you'll also be credited as a contributor once your PR is merged.
If you're new to SillyTavern [here are a collection of resources](https://docs.sillytavern.app/)
If you need any support at all, feel free to reach out via [Discord](https://discord.gg/sillytavern).
- name: ❌ wontfix
labeled:
issue:
action: close
body: >
This ticked has been marked as 'wontfix', which usually means it is out-of-scope, or not feasible at this time.
You can still fork the project and make the changes yourself.
- name: ✅ Fixed
labeled:
issue:
body: >
Hello @{{ issue.user.login }}! It looks like all or part of this issue has now been implemented.
- name: ‼️ High Priority
labeled:
issue:
body: >
This ticket has been marked as high priority, and has been bumped to the top of the priority list.
You should expect an implementation to be pushed out soon. Thank you for your patience.
- name: 💀 Spam
labeled:
issue:
action: close
locking: lock
lock_reason: spam
body: >
This issue has been identified as spam, and is now locked.
Users who repeatedly raise spam issues may be blocked or reported.
- name: ⛔ Don't Merge
labeled:
pr:
body: This PR has been temporarily blocked from merging.

View File

@ -0,0 +1,28 @@
# Based on a label applied to an issue, the bot will add a comment with some additional info
name: 🎯 Auto-Reply to Labeled Tickets
on:
issues:
types:
- labeled
- unlabeled
pull_request_target:
types:
- labeled
- unlabeled
permissions:
contents: read
issues: write
pull-requests: write
jobs:
comment:
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Label Commenter
uses: peaceiris/actions-label-commenter@v1
with:
config_file: .github/issue-auto-comments.yml
github_token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}

View File

@ -0,0 +1,16 @@
# Detect and label pull requests that have merge conflicts
name: 🏗️ Check Merge Conflicts
on:
push:
branches:
- staging
jobs:
check-conflicts:
runs-on: ubuntu-latest
steps:
- uses: mschilde/auto-label-merge-conflicts@master
with:
CONFLICT_LABEL_NAME: "🚫 Merge Conflicts"
GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
MAX_RETRIES: 5
WAIT_MS: 5000

View File

@ -0,0 +1,82 @@
# Closes any issues that no longer have user interaction
name: 🎯 Close Stale Issues
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * *' # Runs every day at midnight UTC
jobs:
stale:
runs-on: ubuntu-latest
steps:
# Comment on, then close issues that haven't been updated for ages
- name: Close Stale Issues
uses: actions/stale@v4
with:
repo-token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
days-before-stale: 360
days-before-close: 5
operations-per-run: 30
remove-stale-when-updated: true
enable-statistics: true
stale-issue-message: >
This issue has gone 3 months without an update. To keep the ticket open, please indicate that it is still relevant in a comment below.
Otherwise it will be closed in 5 working days.
stale-pr-message: >
This PR is stale because it has been open 6 weeks with no activity. Either remove the stale label or comment below with a short update,
otherwise this PR will be closed in 5 days.
close-issue-message: >
This issue was automatically closed because it has been stalled for over 1 year with no activity.
close-pr-message: >
This pull request was automatically closed because it has been stalled for over 1 year with no activity.
stale-issue-label: '⚰️ Stale'
close-issue-label: '🕸️ Inactive'
stale-pr-label: '⚰️ Stale'
close-pr-label: '🕸️ Inactive'
exempt-issue-labels: '📌 Keep Open'
exempt-pr-labels: '📌 Keep Open'
labels-to-add-when-unstale: '📌 Keep Open'
# Comment on, then close issues that required a response from the user, but didn't get one
- name: Close Issues without Response
uses: actions/stale@v4
with:
repo-token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
days-before-stale: 5
days-before-close: 3
operations-per-run: 30
remove-stale-when-updated: true
stale-issue-message: >
Hi! Looks like additional info is required for this issue to be addressed.
Don't forget to provide this within the next few days to keep your ticket open.
close-issue-message: 'Issue closed due to no response from user.'
only-labels: '🚏 Awaiting User Response'
labels-to-remove-when-unstale: '🚏 Awaiting User Response, 🛑 No Response'
stale-issue-label: '🛑 No Response'
close-issue-label: '🕸️ Inactive'
exempt-issue-labels: '📌 Keep Open'
exempt-pr-labels: '📌 Keep Open'
# Comment on issues that we should have replied to
- name: Notify Repo Owner to Respond
uses: actions/stale@v4
with:
repo-token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
days-before-stale: 7
days-before-close: 365
operations-per-run: 30
remove-stale-when-updated: true
stale-issue-message: Hey SillyTavern, - Don't forget to respond!
stale-pr-message: Hey SillyTavern, - Don't forget to respond!
only-labels: '👤 Awaiting Maintainer Response'
labels-to-remove-when-unstale: '👤 Awaiting Maintainer Response'
close-issue-message: 'Closed due to no response from repo author for over a year'
close-pr-message: 'Closed due to no response from repo author for over a year'
stale-issue-label: '👤 Awaiting Maintainer Response'
stale-pr-label: '👤 Awaiting Maintainer Response'
close-issue-label: '🕸️ Inactive'
close-pr-label: '🕸️ Inactive'
exempt-issue-labels: '📌 Keep Open'
exempt-pr-labels: '📌 Keep Open'

39
.github/workflows/get-pr-size.yml vendored Normal file
View File

@ -0,0 +1,39 @@
# Adds a comment to new PRs, showing the compressed size and size difference of new code
# And also labels the PR based on the number of lines changes
name: 🌈 Check PR Size
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
# Find and comment with compressed size
- name: Get Compressed Size
uses: preactjs/compressed-size-action@v2
with:
repo-token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
pattern: './dist/**/*.{js,css,html}'
strip-hash: '\\b\\w{8}\\.'
exclude: '**/node_modules/**'
minimum-change-threshold: 100
# Check number of lines of code added
- name: Label based on Lines of Code
uses: codelytv/pr-size-labeler@v1
with:
GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
xs_max_size: '10'
s_max_size: '100'
m_max_size: '500'
l_max_size: '1000'
s_label: '🟩 PR - Small'
m_label: '🟨 PR - Medium'
l_label: '🟧 PR - Large'
xl_label: '🟥 PR - XL'
fail_if_xl: 'false'
message_if_xl: >
It looks like this PR is very large (over 1000 lines).
Try to avoid addressing multiple issues in a single PR, and
in the future consider breaking large tasks down into smaller steps.
This it to make reviewing, testing, reverting and general quality management easier.

View File

@ -0,0 +1,17 @@
# When a new comment is added to an issue, if it had the Stale or Awaiting User Response labels, then those labels will be removed
name: 🎯 Remove Pending Labels on Close
on:
issues:
types: [closed]
jobs:
remove-labels:
runs-on: ubuntu-latest
steps:
- name: Remove Labels when Closed
uses: actions-cool/issues-helper@v2
with:
actions: remove-labels
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
labels: '🚏 Awaiting User Response,⚰️ Stale,👤 Awaiting Maintainer Response'

View File

@ -0,0 +1,42 @@
# When a new comment is added to an issue, if it had the Stale or Awaiting User Response labels, then those labels will be removed
name: 🎯 Add/ Remove Awaiting Response Labels
on:
issue_comment:
types: [created]
jobs:
remove-stale:
runs-on: ubuntu-latest
if: ${{ github.event.comment.author_association != 'COLLABORATOR' && github.event.comment.author_association != 'OWNER' }}
steps:
- name: Remove Stale labels when Updated
uses: actions-cool/issues-helper@v2
with:
actions: remove-labels
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
labels: '🚏 Awaiting User Response,⚰️ Stale'
add-awaiting-author:
runs-on: ubuntu-latest
if: ${{!github.event.issue.pull_request && github.event.comment.author_association != 'COLLABORATOR' && github.event.comment.author_association != 'OWNER' && github.event.issue.state == 'open' }}
steps:
- name: Add Awaiting Author labels when Updated
uses: actions-cool/issues-helper@v2
with:
actions: add-labels
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
labels: '👤 Awaiting Maintainer Response'
remove-awaiting-author:
runs-on: ubuntu-latest
if: ${{ github.event.comment.author_association == 'OWNER' }}
steps:
- name: Remove Awaiting Author labels when Updated
uses: actions-cool/issues-helper@v2
with:
actions: remove-labels
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
labels: '👤 Awaiting Maintainer Response'

View File

@ -539,6 +539,10 @@
"filename": "presets/context/Llama 3 Instruct.json",
"type": "context"
},
{
"filename": "presets/context/Phi.json",
"type": "context"
},
{
"filename": "presets/instruct/Adventure.json",
"type": "instruct"
@ -631,6 +635,10 @@
"filename": "presets/instruct/Llama 3 Instruct.json",
"type": "instruct"
},
{
"filename": "presets/instruct/Phi.json",
"type": "instruct"
},
{
"filename": "presets/moving-ui/Default.json",
"type": "moving_ui"
@ -642,5 +650,21 @@
{
"filename": "presets/quick-replies/Default.json",
"type": "quick_replies"
},
{
"filename": "presets/instruct/Llama-3-Instruct-Names.json",
"type": "instruct"
},
{
"filename": "presets/instruct/ChatML-Names.json",
"type": "instruct"
},
{
"filename": "presets/context/Llama-3-Instruct-Names.json",
"type": "context"
},
{
"filename": "presets/context/ChatML-Names.json",
"type": "context"
}
]

View File

@ -0,0 +1,12 @@
{
"story_string": "<|im_start|>system\n{{#if system}}{{system}}\n{{/if}}{{#if wiBefore}}{{wiBefore}}\n{{/if}}{{#if description}}{{description}}\n{{/if}}{{#if personality}}{{char}}'s personality: {{personality}}\n{{/if}}{{#if scenario}}Scenario: {{scenario}}\n{{/if}}{{#if wiAfter}}{{wiAfter}}\n{{/if}}{{#if persona}}{{persona}}\n{{/if}}{{trim}}<|im_end|>",
"example_separator": "",
"chat_start": "",
"use_stop_strings": false,
"allow_jailbreak": false,
"always_force_name2": true,
"trim_sentences": false,
"include_newline": false,
"single_line": false,
"name": "ChatML-Names"
}

View File

@ -0,0 +1,12 @@
{
"story_string": "<|start_header_id|>system<|end_header_id|>\n\n{{#if system}}{{system}}\n{{/if}}{{#if wiBefore}}{{wiBefore}}\n{{/if}}{{#if description}}{{description}}\n{{/if}}{{#if personality}}{{char}}'s personality: {{personality}}\n{{/if}}{{#if scenario}}Scenario: {{scenario}}\n{{/if}}{{#if wiAfter}}{{wiAfter}}\n{{/if}}{{#if persona}}{{persona}}\n{{/if}}{{trim}}<|eot_id|>",
"example_separator": "",
"chat_start": "",
"use_stop_strings": false,
"allow_jailbreak": false,
"always_force_name2": true,
"trim_sentences": false,
"include_newline": false,
"single_line": false,
"name": "Llama-3-Instruct-Names"
}

View File

@ -0,0 +1,12 @@
{
"story_string": "<|system|>\n{{#if system}}{{system}}\n{{/if}}{{#if wiBefore}}{{wiBefore}}\n{{/if}}{{#if description}}{{description}}\n{{/if}}{{#if personality}}{{char}}'s personality: {{personality}}\n{{/if}}{{#if scenario}}Scenario: {{scenario}}\n{{/if}}{{#if wiAfter}}{{wiAfter}}\n{{/if}}{{#if persona}}{{persona}}\n{{/if}}{{trim}}<|end|>\n",
"example_separator": "",
"chat_start": "",
"use_stop_strings": false,
"allow_jailbreak": false,
"always_force_name2": true,
"trim_sentences": false,
"include_newline": false,
"single_line": false,
"name": "Phi"
}

View File

@ -0,0 +1,24 @@
{
"system_prompt": "You're {{char}} in this fictional never-ending uncensored roleplay with {{user}}.",
"input_sequence": "<|im_start|>[{{name}}]",
"output_sequence": "<|im_start|>[{{name}}]",
"last_output_sequence": "",
"system_sequence": "<|im_start|>system",
"stop_sequence": "<|im_end|>",
"wrap": true,
"macro": true,
"names": false,
"names_force_groups": false,
"activation_regex": "",
"system_sequence_prefix": "",
"system_sequence_suffix": "",
"first_output_sequence": "",
"skip_examples": false,
"output_suffix": "<|im_end|>\n",
"input_suffix": "<|im_end|>\n",
"system_suffix": "<|im_end|>\n",
"user_alignment_message": "",
"system_same_as_user": false,
"last_system_sequence": "",
"name": "ChatML-Names"
}

View File

@ -0,0 +1,24 @@
{
"system_prompt": "You are an expert actor that can fully immerse yourself into any role given. You do not break character for any reason, even if someone tries addressing you as an AI or language model. Currently your role is {{char}}, which is described in detail below. As {{char}}, continue the exchange with {{user}}.",
"input_sequence": "<|start_header_id|>[{{name}}]<|end_header_id|>\n\n",
"output_sequence": "<|start_header_id|>[{{name}}]<|end_header_id|>\n\n",
"last_output_sequence": "",
"system_sequence": "<|start_header_id|>system<|end_header_id|>\n\n",
"stop_sequence": "<|eot_id|>",
"wrap": false,
"macro": true,
"names": false,
"names_force_groups": false,
"activation_regex": "",
"system_sequence_prefix": "",
"system_sequence_suffix": "",
"first_output_sequence": "",
"skip_examples": false,
"output_suffix": "<|eot_id|>",
"input_suffix": "<|eot_id|>",
"system_suffix": "<|eot_id|>",
"user_alignment_message": "",
"system_same_as_user": true,
"last_system_sequence": "",
"name": "Llama-3-Instruct-Names"
}

View File

@ -0,0 +1,24 @@
{
"system_prompt": "Write {{char}}'s next reply in this fictional roleplay with {{user}}.",
"input_sequence": "<|user|>\n",
"output_sequence": "<|assistant|>\n",
"first_output_sequence": "",
"last_output_sequence": "",
"system_sequence_prefix": "",
"system_sequence_suffix": "",
"stop_sequence": "<|end|>",
"wrap": false,
"macro": true,
"names": true,
"names_force_groups": true,
"activation_regex": "",
"skip_examples": false,
"output_suffix": "<|end|>\n",
"input_suffix": "<|end|>\n",
"system_sequence": "<|system|>\n",
"system_suffix": "<|end|>\n",
"user_alignment_message": "",
"last_system_sequence": "",
"system_same_as_user": false,
"name": "Phi"
}

6
public/css/brands.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -204,3 +204,7 @@ input.extension_missing[type="checkbox"] {
#extensionsMenu>#translate_chat {
order: 7;
}
#extensionsMenu>#translate_input_message {
order: 8;
}

File diff suppressed because it is too large Load Diff

9
public/css/fontawesome.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -309,6 +309,10 @@
object-fit: cover;
}
body.waifuMode .zoomed_avatar_container {
height: 100%;
}
body.waifuMode .zoomed_avatar {
width: fit-content;
max-height: calc(60vh - 60px);

View File

@ -1,24 +0,0 @@
:root,
:host {
--fa-style-family-classic: 'Font Awesome 6 Free';
--fa-font-solid: normal 900 1em/1 'Font Awesome 6 Free';
}
@font-face {
font-family: 'Font Awesome 6 Free';
font-style: normal;
font-weight: 900;
font-display: block;
src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype");
}
.fas,
.fa-solid {
font-weight: 900;
}
/*!
* Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2023 Fonticons, Inc.
*/

6
public/css/solid.min.css vendored Normal file
View File

@ -0,0 +1,6 @@
/*!
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2024 Fonticons, Inc.
*/
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}

View File

@ -19,7 +19,8 @@ body.no-timer .mes_timer,
body.no-timestamps .timestamp,
body.no-tokenCount .tokenCounterDisplay,
body.no-mesIDDisplay .mesIDDisplay,
body.no-modelIcons .icon-svg {
body.no-modelIcons .icon-svg,
body.hideChatAvatars .mesAvatarWrapper .avatar {
display: none !important;
}
@ -123,10 +124,16 @@ body.charListGrid #rm_print_characters_block .bogus_folder_select_back .avatar {
}
/* Hack for keeping the spacing */
/*
body.charListGrid #rm_print_characters_block .ch_add_placeholder {
display: flex !important;
opacity: 0;
}
*/
body.charListGrid #rm_print_characters_block .ch_additional_info {
display: none;
}
/*big avatars mode page-wide changes*/

File diff suppressed because it is too large Load Diff

View File

@ -38,7 +38,7 @@
"LLaMA / Mistral / Yi models only": "Только для моделей LLaMA / Mistral / Yi. Перед этим обязательно выберите подходящий токенизатор.\nПоследовательности, которых не должно быть на выходе.\nОдна на строку. Текст или [идентификаторы токенов].\nМногие токены имеют пробел впереди. Используйте счетчик токенов, если не уверены.",
"Example: some text [42, 69, 1337]": "Пример:\nкакой-то текст\n[42, 69, 1337]",
"Classifier Free Guidance. More helpful tip coming soon": "Classifier Free Guidance. Чуть позже опишем более подробно",
"Scale": "Масштаб",
"Scale": "Scale",
"GBNF Grammar": "Грамматика GBNF",
"Usage Stats": "Статистика исп.",
"Click for stats!": "Нажмите для получения статистики!",
@ -97,7 +97,7 @@
"Sequences you don't want to appear in the output. One per line.": "Строки, которых не должно быть в выходном тексте. По одной на строчку.",
"AI Module": "Модуль ИИ",
"Changes the style of the generated text.": "Изменяет стиль создаваемого текста.",
"Used if CFG Scale is unset globally, per chat or character": "Используется, если масштаб CFG не установлен глобально, для каждого чата или персонажа.",
"Used if CFG Scale is unset globally, per chat or character": "Используется, если CFG Scale не установлен глобально, для каждого чата или персонажа.",
"Inserts jailbreak as a last system message.": "Вставлять JailBreak последним системным сообщением.",
"This tells the AI to ignore its usual content restrictions.": "Сообщает AI о необходимости игнорировать стандартные ограничения контента.",
"NSFW Encouraged": "Поощрять NSFW",
@ -262,7 +262,7 @@
"Auto-Continue": "Авто-продолжение",
"Collapse Consecutive Newlines": "Сворачивать последовательные новые строки",
"Allow for Chat Completion APIs": "Разрешить для API Chat Completion",
"Target length (tokens)": "Целевая длина (токены)",
"Target length (tokens)": "Целевая длина (в токенах)",
"Keep Example Messages in Prompt": "Сохранять примеры сообщений в промпте",
"Remove Empty New Lines from Output": "Удалять пустые строчки из вывода",
"Disabled for all models": "Выключено для всех моделей",
@ -300,11 +300,11 @@
"Chat Style": "Стиль чата",
"Default": "По умолчанию",
"Bubbles": "Пузыри",
"No Blur Effect": "Отключить эффект размытия",
"No Text Shadows": "Отключить тень от текста",
"No Blur Effect": "Отключить размытие",
"No Text Shadows": "Отключить тень текста",
"Waifu Mode": "Рeжим Вайфу",
"Message Timer": "Таймер сообщений",
"Model Icon": "Показать значки модели",
"Model Icon": "Значки моделей",
"# of messages (0 = disabled)": "# сообщений (0 = отключено)",
"Advanced Character Search": "Расширенный поиск по персонажам",
"Allow {{char}}: in bot messages": "Показывать {{char}}: в ответах",
@ -314,7 +314,7 @@
"Lorebook Import Dialog": "Показывать окно импорта лорбука",
"MUI Preset": "Пресет MUI:",
"If set in the advanced character definitions, this field will be displayed in the characters list.": "Если это поле задано в расширенных параметрах персонажа, оно будет отображаться в списке персонажей.",
"Relaxed API URLS": "Смягченные URL-адреса API",
"Relaxed API URLS": "Смягчённые адреса API",
"Custom CSS": "Пользовательский CSS",
"Default (oobabooga)": "По умолчанию (oobabooga)",
"Mancer Model": "Модель Mancer",
@ -381,7 +381,7 @@
"text": "текст",
"Delete": "Удалить",
"Cancel": "Отменить",
"Advanced Defininitions": "Продвинутое описание",
"Advanced Defininitions": "Расширенное описание",
"Personality summary": "Сводка по личности",
"A brief description of the personality": "Краткое описание личности",
"Scenario": "Сценарий",
@ -431,7 +431,7 @@
"JSON": "JSON",
"presets": "Пресеты",
"Message Sound": "Звук сообщения",
"Author's Note": "Пометки автора",
"Author's Note": "Заметки автора",
"Send Jailbreak": "Отправлять джейлбрейк",
"Replace empty message": "Заменять пустые сообщения",
"Send this text instead of nothing when the text box is empty.": "Этот текст будет отправлен в случае отсутствия текста на отправку.",
@ -475,7 +475,7 @@
"--- Pick to Edit ---": "--- Выберите для редактирования ---",
"or": "или",
"New": "Новый",
"Priority": "Приритет",
"Priority": "Приоритет",
"Custom": "Пользовательский",
"Title A-Z": "Название от A до Z",
"Title Z-A": "Название от Z до A",
@ -528,7 +528,7 @@
"UI Border": "Границы UI",
"Chat Style:": "Стиль чата",
"Chat Width (PC)": "Ширина чата (для ПК)",
"Chat Timestamps": "Временные метки в чате",
"Chat Timestamps": "Метки времени в чате",
"Tags as Folders": "Теги как папки",
"Chat Truncation": "Усечение чата",
"(0 = unlimited)": "(0 = неограниченное)",
@ -559,8 +559,8 @@
"Disables animations and transitions": "Отключение анимаций и переходов.",
"removes blur from window backgrounds": "Убрать размытие с фона окон, чтобы ускорить рендеринг.",
"Remove text shadow effect": "Удаление эффекта тени от текста.",
"Reduce chat height, and put a static sprite behind the chat window": "Уменьшитm высоту чата и поместить статичный спрайт за окном чата.",
"Always show the full list of the Message Actions context items for chat messages, instead of hiding them behind '...'": "Всегда показывать полный список контекстных элементов 'Действия с сообщением' для сообщений чата, а не прятать их за '...'.",
"Reduce chat height, and put a static sprite behind the chat window": "Уменьшить высоту чата и поместить статичный спрайт за окном чата.",
"Always show the full list of the Message Actions context items for chat messages, instead of hiding them behind '...'": "Всегда показывать полный список действий с сообщением, а не прятать их за '...'.",
"Alternative UI for numeric sampling parameters with fewer steps": "Альтернативный пользовательский интерфейс для числовых параметров выборки с меньшим количеством шагов.",
"Entirely unrestrict all numeric sampling parameters": "Полностью разграничить все числовые параметры выборки.",
"Time the AI's message generation, and show the duration in the chat log": "Время генерации сообщений ИИ и его показ в журнале чата.",
@ -600,7 +600,7 @@
"Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Включить авто-свайп. Настройки в этом разделе действуют только при включенном авто-свайпе.",
"If the generated message is shorter than this, trigger an auto-swipe": "Если сгенерированное сообщение короче этого значения, срабатывает авто-свайп.",
"Reload and redraw the currently open chat": "Перезагрузить и перерисовать открытый в данный момент чат.",
"Auto-Expand Message Actions": "Развернуть контекстные элементы",
"Auto-Expand Message Actions": "Развернуть действия",
"Not Connected": "Не подключено",
"Persona Management": "Управление персоной",
"Persona Description": "Описание персоны",
@ -629,16 +629,15 @@
"Most chats": "Больше всего чатов",
"Least chats": "Меньше всего чатов",
"Back": "Назад",
"Prompt Overrides (For OpenAI/Claude/Scale APIs, Window/OpenRouter, and Instruct mode)": "Перезапись промпта (Для OpenAI/Claude/Scale API, Window/OpenRouter, и режима Instruct)",
"Insert {{original}} into either box to include the respective default prompt from system settings.": "Введите {{original}} в любое поле, чтобы использовать соответствующий промпт из системных настроек",
"Prompt Overrides": "Индивидуальный промпт",
"(For OpenAI/Claude/Scale APIs, Window/OpenRouter, and Instruct Mode)": "(для API OpenAI/Claude/Scale, Window/OpenRouter, а также режима Instruct)",
"Insert {{original}} into either box to include the respective default prompt from system settings.": "Введите {{original}} в любое поле, чтобы вставить соответствующий промпт из системных настроек",
"Main Prompt": "Основной промпт",
"Jailbreak": "Джейлбрейк",
"Creator's Metadata (Not sent with the AI prompt)": "Метаданные (не отправляются ИИ)",
"Everything here is optional": "Все поля необязательные",
"Created by": "Автор",
"Character Version": "Версия персонажа",
"Tags to Embed": "Встраиваемые теги",
"How often the character speaks in group chats!": "Как часто персонаж говорит в групповых чатах",
"Important to set the character's writing style.": "Серьёзно влияет на стиль письма персонажа.",
"ATTENTION!": "ВНИМАНИЕ!",
"Samplers Order": "Порядок сэмплеров",
@ -655,7 +654,7 @@
"Use 'Unlocked Context' to enable chunked generation.": "Использовать 'Неограниченный контекст' для активации кусочной генерации",
"It extends the context window in exchange for reply generation speed.": "Увеличивает размер контекста в обмен на скорость генерации.",
"Continue": "Продолжить",
"CFG Scale": "Масштаб CFG",
"CFG Scale": "CFG Scale",
"Editing:": "Изменения",
"AI reply prefix": "Префикс для ответа ИИ",
"Custom Stopping Strings": "Стоп-строки",
@ -671,9 +670,9 @@
"Chat Name (Optional)": "Название чата (необязательно)",
"Filter...": "Фильтры...",
"Search...": "Поиск...",
"Any contents here will replace the default Main Prompt used for this character. (v2 spec: system_prompt)": "Все содержание этой ячейки будет заменять стандартный Промт",
"Any contents here will replace the default Jailbreak Prompt used for this character. (v2 spec: post_history_instructions)": "Все содержание этой ячейки будет заменять стандартный Джейлбрейк",
"(Botmaker's name / Contact Info)": "(Имя автора / Контакты)",
"Any contents here will replace the default Main Prompt used for this character. (v2 spec: system_prompt)": "Все содержимое этого поля будет заменять стандартный промпт",
"Any contents here will replace the default Jailbreak Prompt used for this character. (v2 spec: post_history_instructions)": "Все содержимое этого поля будет заменять стандартный джейлбрейк",
"(Botmaker's name / Contact Info)": "(Имя автора, контакты)",
"(If you want to track character versions)": "Если вы хотите отслеживать версии персонажа",
"(Describe the bot, give use tips, or list the chat models it has been tested on. This will be displayed in the character list.)": "(Описание персонажа, советы по использованию, список моделей, на которых он тестировался. Информация будет отображаться в списке персонажей)",
"(Write a comma-separated list of tags)": "(Список тегов через запятую)",
@ -713,12 +712,12 @@
"Restore defaul note": "Восстановить стандартную заметку",
"API Connections": "Соединения с API",
"Can help with bad responses by queueing only the approved workers. May slowdown the response time.": "Может помочь с плохими ответами ставя в очередь только подтвержденных работников. Может замедлить время ответа.",
"Clear your API key": "Очистите свой ключ от API",
"Clear your API key": "Стереть ключ от API",
"Refresh models": "Обновить модели",
"Get your OpenRouter API token using OAuth flow. You will be redirected to openrouter.ai": "Получите свой OpenRouter API токен используя OAuth. У вас будет открыта вкладка openrouter.ai",
"Verifies your API connection by sending a short test message. Be aware that you'll be credited for it!": "Проверка работоспособности вашего соединения с API. Знайте, что оно будет отправлено от вашего лица.",
"Create New": "Создать новое",
"Edit": "Изменить",
"Edit": "Редактировать",
"Locked = World Editor will stay open": "Закреплено = Редактор мира останется открытым",
"Entries can activate other entries by mentioning their keywords": "Записи могут активировать другие записи, если в них содержатся ключевые слова",
"Lookup for the entry keys in the context will respect the case": "Большая буква имеет значение при активации ключевого слова",
@ -847,7 +846,7 @@
"Underlined Text": "Подчёркнутый",
"Token Probabilities": "Вероятности токенов",
"Close chat": "Закрыть чат",
"Manage chat files": "Управление файлами чата",
"Manage chat files": "Управление чатами",
"Import Extension From Git Repo": "Импортировать расширение из Git Repository",
"Install extension": "Установить расширение",
"Manage extensions": "Управление расширениями",
@ -863,12 +862,12 @@
"When this is off, responses will be displayed all at once when they are complete.": "Если параметр выключен, ответы будут отображаться сразу целиком, и только после полного завершения генерации.",
"Quick Prompts Edit": "Быстрое редактирование промптов",
"Enable OpenAI completion streaming": "Включить стриминг OpenAI",
"Main": "Главное",
"Main": "Основной",
"Utility Prompts": "Служебные промпты",
"Add character names": "Добавить имена персонажей",
"Send names in the message objects. Helps the model to associate messages with characters.": "Отправить имена в объектах сообщений. Помогает модели ассоциировать сообщения с персонажами.",
"Continue prefill": "Префилл для продолжения",
"Continue sends the last message as assistant role instead of system message with instruction.": "Продолжение отправляет последнее сообщение в роли ассистента, а не системное сообщение с инструкцией.",
"Continue sends the last message as assistant role instead of system message with instruction.": "Продолжение отправляет последнее сообщение в роли ассистента, вместо системного сообщения с инструкцией.",
"Squash system messages": "Склеивать сообщения системыы",
"Combines consecutive system messages into one (excluding example dialogues). May improve coherence for some models.": "Объединяет последовательные системные сообщения в одно (за исключением примеров диалогов). Может улучшить согласованность для некоторых моделей.",
"Send inline images": "Отправлять встроенные изображения",
@ -973,12 +972,128 @@
"Most tokens have a leading space.": "У большинства токенов в начале пробел.",
"Prompts": "Промпты",
"Text or token ids": "Текст или [идентификаторы токенов]",
"World Info Format Template": "Шаблон форматирования информации о мире",
"World Info Format Template": "Шаблон оформления информации о мире",
"Wraps activated World Info entries before inserting into the prompt.": "Дополняет информацию об активном на данный момент мире перед её отправкой в промпт.",
"Doesn't work? Try adding": "Не работает? Попробуйте добавить в конце",
"at the end!": "!",
"Authorize": "Авторизоваться",
"No persona description": "[Нет описания]",
"Not connected to API!": "Нет соединения с API!",
"Type a message, or /? for help": "Введите сообщение, или /? для получения справки по командам"
"Type a message, or /? for help": "Введите сообщение, или /? для получения справки по командам",
"Welcome to SillyTavern!": "Добро пожаловать в SillyTavern!",
"Won't be shared with the character card on export.": "Не попадут в карточку персонажа при экспорте.",
"Web-search": "Веб-поиск",
"Persona Name:": "Имя персоны:",
"User first message": "Первое сообщение пользователя",
"extension_token_counter": "Токенов:",
"Character's Note": "Заметка о персонаже",
"(Text to be inserted in-chat @ designated depth and role)": "Этот текст будет вставлен в чат на заданную глубину и с определённой ролью",
"@ Depth": "Глубина",
"Role": "Роль",
"System": "Система",
"User": "Пользователь",
"Assistant": "Ассистент",
"How often the character speaks in": "Как часто персонаж говорит в",
"group chats!": "групповых чатах!",
"Creator's Metadata": "Метаданные",
"(Not sent with the AI Prompt)": "(не отправляются ИИ)",
"New Chat": "Новый чат",
"Import Chat": "Импорт чата",
"Chat Lore": "Лор чата",
"Chat Lorebook for": "Лорбук для чата",
"A selected World Info will be bound to this chat.": "Выбранный мир будет привязан к этому чату. При генерации ответа ИИ он будет совмещён с записями из глобального лорбука и лорбука персонажа.",
"Missing key": "❌ Ключа нет",
"Key saved": "✔️ Ключ сохранён",
"Use the appropriate tokenizer for Jurassic models, which is more efficient than GPT's.": "Использовать токенайзер для моделей Jurassic, эффективнее GPT-токенайзера",
"Use system prompt (Gemini 1.5 pro+ only)": "Использовать системный промпт (только для Gemini 1.5 pro и выше)",
"Experimental feature. May not work for all backends.": "Экспериментальная возможность, на некоторых бэкендах может не работать.",
"Avatar Hover Magnification": "Зум аватарки по наведению",
"Enable magnification for zoomed avatar display.": "Добавляет возможность приближать увеличенную версию аватарки.",
"Unique to this chat": "Только для текущего чата",
"Checkpoints inherit the Note from their parent, and can be changed individually after that.": "Чекпоинты наследуют заметки от родительского чата, но впоследствие их всегда можно изменить.",
"Include in World Info Scanning": "Учитывать при сканировании Информации о мире",
"Before Main Prompt / Story String": "Перед основным промптом / строкой истории",
"After Main Prompt / Story String": "После основного промпта / строки истории",
"In-chat @ Depth": "Встав. на глуб.",
"as": "роль:",
"Insertion Frequency": "Частота вставки",
"(0 = Disable, 1 = Always)": "(0 = никогда, 1 = всегда)",
"User inputs until next insertion:": "Ваших сообщений до след. вставки:",
"Character Author's Note (Private)": "Заметки автора персонажа (личные)",
"Will be automatically added as the author's note for this character. Will be used in groups, but can't be modified when a group chat is open.": "Автоматически применятся к этому персонажу в качестве заметок автора. Будут использоваться в группах, но при активном групповом чате к редактированию недоступны.",
"Use character author's note": "Использовать заметки автора персонажа",
"Replace Author's Note": "Вместо заметок автора",
"Top of Author's Note": "Сверху от заметок автора",
"Bottom of Author's Note": "Снизу от заметок автора",
"Default Author's Note": "Стандартные заметки автора",
"Will be automatically added as the Author's Note for all new chats.": "Будут автоматически добавляться во все новые чаты в качестве Заметок автора",
"1 = disabled": "1 = откл.",
"write short replies, write replies using past tense": "пиши короткие ответы, пиши в настоящем времени",
"Positive Prompt": "Положительный промпт",
"Character CFG": "CFG для персонажа",
"Will be automatically added as the CFG for this character.": "Автоматически применится к персонажу как его CFG.",
"Global CFG": "Глобальный CFG",
"Will be used as the default CFG options for every chat unless overridden.": "Будет применяться как стандартный CFG для всех чатов, если не указаны индивидуальные настройки.",
"CFG Prompt Cascading": "Совмещение CFG-промптов",
"Combine positive/negative prompts from other boxes.": "Комбинировать различные положительные и негативные промпты.",
"For example, ticking the chat, global, and character boxes combine all negative prompts into a comma-separated string.": "К примеру, если отметить галочки с чатом, персонажем и глобальной настройкой, то все эти негативы соберутся в одну строку, разделённую запятыми.",
"Always Include": "Всегда применять",
"Chat Negatives": "Негативы от чата",
"Character Negatives": "Негативы от персонажа",
"Global Negatives": "Глобальные негативы",
"Custom Separator:": "Кастомный разделитель:",
"Insertion Depth:": "Глубина вставки:",
"Chat CFG": "CFG для чата",
"Chat backgrounds generated with the": "Здесь будут появляться фоны, сгенерированные расширением",
"extension will appear here.": ".",
"Prevent further recursion (this entry will not activate others)": "Пресечь дальнейшую рекурсию (эта запись не будет активировать другие)",
"Alert if your world info is greater than the allocated budget.": "Оповещать, если ваш мир выходит за выделенный бюджет.",
"Convert to Persona": "Преобразовать в персону",
"Link to Source": "Ссылка на источник",
"Replace / Update": "Заменить / Обновить",
"Smoothing Curve": "Кривая сглаживания",
"Message Actions": "Действия с сообщением",
"SillyTavern is aimed at advanced users.": "SillyTavern рассчитана на продвинутых пользователей.",
"If you're new to this, enable the simplified UI mode below.": "Если вы новичок, советуем включить упрощённый UI.",
"Enable simple UI mode": "Включить упрощённый UI",
"welcome_message_part_1": "Ознакомьтесь с",
"welcome_message_part_2": "официальной документацией",
"welcome_message_part_3": ".",
"welcome_message_part_4": "Введите",
"welcome_message_part_5": "в чате, чтобы получить справку по командам и макросам.",
"welcome_message_part_6": "Заходите на наш",
"Discord server": "Discord-сервер,",
"welcome_message_part_7": "там публикуется много разной полезной информации, в том числе анонсы.",
"Before you get started, you must select a persona name.": "Для начала вам следует выбрать имя своей персоны.",
"welcome_message_part_8": "Его можно будет изменить в любое время через иконку",
"welcome_message_part_9": ".",
"UI Language:": "Язык интерфейса:",
"Ignore EOS Token": "Игнорировать EOS-токен",
"Ignore the EOS Token even if it generates.": "Игнорировать EOS-токен, даже если он сгенерировался.",
"Hide Muted Member Sprites": "Скрыть спрайты заглушенных участников",
"Group generation handling mode": "Генерировать ответы путём...",
"Swap character cards": "Подмены карточки персонажа",
"Join character cards (exclude muted)": "Совмещения карточек (кроме заглушенных)",
"Join character cards (include muted)": "Совмещения карточек (включая заглушенных)",
"Click to allow/forbid the use of external media for this group.": "Нажмите, чтобы разрешить/запретить использование внешних медиа в этой группе.",
"Scenario Format Template": "Шаблон оформления сценария",
"scenario_format_template_part_1": "Используйте",
"scenario_format_template_part_2": "чтобы указать, куда именно вставляется основное содержимое.",
"Personality Format Template": "Шаблон оформления характера",
"Group Nudge Prompt Template": "Шаблон промпта-подсказки для групп",
"Sent at the end of the group chat history to force reply from a specific character.": "Добавляется в конец истории сообщений в групповом чате, чтобы запросить ответ от конкретного персонажа.",
"Set at the beginning of the chat history to indicate that a new chat is about to start.": "Добавляется в начале истории сообщений в качестве указания на то, что дальше начнётся новый чат.",
"New Group Chat": "Новый групповой чат",
"Set at the beginning of the chat history to indicate that a new group chat is about to start.": "Добавляется в начале истории сообщений в качестве указания на то, что дальше начнётся новый групповой чат.",
"New Example Chat": "Новый образец чата",
"Set at the beginning of Dialogue examples to indicate that a new example chat is about to start.": "Добавляется в начале примеров диалогов в качестве указания на то, что дальше начнётся новый чат-пример.",
"Continue nudge": "Подсказка для продолжения",
"Set at the end of the chat history when the continue button is pressed.": "Добавляется в конец истории чата, когда отправлен запрос на продолжение текущего сообщения.",
"Prompts": "Промпты",
"Your Persona": "Ваша персона",
"Continue Postfix": "Постфикс для продолжения",
"Space": "Пробел",
"Newline": "Новая строка",
"Double Newline": "Две новые строки",
"The next chunk of the continued message will be appended using this as a separator.": "Используется в качестве разделителя между уже имеющимся сообщением и его новым отрывком, при генерации продолжения"
}

View File

@ -152,6 +152,7 @@ import {
Stopwatch,
isValidUrl,
ensureImageFormatSupported,
flashHighlight,
} from './scripts/utils.js';
import { ModuleWorkerWrapper, doDailyExtensionUpdatesCheck, extension_settings, getContext, loadExtensionSettings, renderExtensionTemplate, renderExtensionTemplateAsync, runGenerationInterceptors, saveMetadataDebounced, writeExtensionField } from './scripts/extensions.js';
@ -293,6 +294,17 @@ export {
renderTemplate,
};
/**
* Wait for page to load before continuing the app initialization.
*/
await new Promise((resolve) => {
if (document.readyState === 'complete') {
resolve();
} else {
window.addEventListener('load', resolve);
}
});
showLoader();
// Yoink preloader entirely; it only exists to cover up unstyled content while loading JS
document.getElementById('preloader').remove();
@ -449,6 +461,7 @@ export const event_types = {
CHARACTER_DUPLICATED: 'character_duplicated',
SMOOTH_STREAM_TOKEN_RECEIVED: 'smooth_stream_token_received',
FILE_ATTACHMENT_DELETED: 'file_attachment_deleted',
WORLDINFO_FORCE_ACTIVATE: 'worldinfo_force_activate',
};
export const eventSource = new EventEmitter();
@ -3779,6 +3792,7 @@ async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, qu
// Fetches the combined prompt for both negative and positive prompts
const cfgGuidanceScale = getGuidanceScale();
const useCfgPrompt = cfgGuidanceScale && cfgGuidanceScale.value !== 1;
// For prompt bit itemization
let mesSendString = '';
@ -3786,7 +3800,7 @@ async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, qu
function getCombinedPrompt(isNegative) {
// Only return if the guidance scale doesn't exist or the value is 1
// Also don't return if constructing the neutral prompt
if (isNegative && (!cfgGuidanceScale || cfgGuidanceScale?.value === 1)) {
if (isNegative && !useCfgPrompt) {
return;
}
@ -3798,22 +3812,20 @@ async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, qu
// Deep clone
let finalMesSend = structuredClone(mesSend);
let cfgPrompt = {};
if (cfgGuidanceScale && cfgGuidanceScale?.value !== 1) {
cfgPrompt = getCfgPrompt(cfgGuidanceScale, isNegative);
}
if (cfgPrompt && cfgPrompt?.value) {
if (cfgPrompt?.depth === 0) {
finalMesSend[finalMesSend.length - 1].message +=
/\s/.test(finalMesSend[finalMesSend.length - 1].message.slice(-1))
? cfgPrompt.value
: ` ${cfgPrompt.value}`;
} else {
// TODO: Make all extension prompts use an array/splice method
const lengthDiff = mesSend.length - cfgPrompt.depth;
const cfgDepth = lengthDiff >= 0 ? lengthDiff : 0;
finalMesSend[cfgDepth].extensionPrompts.push(`${cfgPrompt.value}\n`);
if (useCfgPrompt) {
const cfgPrompt = getCfgPrompt(cfgGuidanceScale, isNegative);
if (cfgPrompt.value) {
if (cfgPrompt.depth === 0) {
finalMesSend[finalMesSend.length - 1].message +=
/\s/.test(finalMesSend[finalMesSend.length - 1].message.slice(-1))
? cfgPrompt.value
: ` ${cfgPrompt.value}`;
} else {
// TODO: Make all extension prompts use an array/splice method
const lengthDiff = mesSend.length - cfgPrompt.depth;
const cfgDepth = lengthDiff >= 0 ? lengthDiff : 0;
finalMesSend[cfgDepth].extensionPrompts.push(`${cfgPrompt.value}\n`);
}
}
}
@ -3894,75 +3906,78 @@ async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, qu
return !data.combinedPrompt ? combine() : data.combinedPrompt;
}
// Get the negative prompt first since it has the unmodified mesSend array
let negativePrompt = main_api == 'textgenerationwebui' ? getCombinedPrompt(true) : undefined;
let finalPrompt = getCombinedPrompt(false);
// Include the entire guidance scale object
const cfgValues = cfgGuidanceScale && cfgGuidanceScale?.value !== 1 ? ({ guidanceScale: cfgGuidanceScale, negativePrompt: negativePrompt }) : null;
let maxLength = Number(amount_gen); // how many tokens the AI will be requested to generate
let thisPromptBits = [];
// TODO: Make this a switch
if (main_api == 'koboldhorde' && horde_settings.auto_adjust_response_length) {
maxLength = Math.min(maxLength, adjustedParams.maxLength);
maxLength = Math.max(maxLength, MIN_LENGTH); // prevent validation errors
}
let generate_data;
if (main_api == 'koboldhorde' || main_api == 'kobold') {
generate_data = {
prompt: finalPrompt,
gui_settings: true,
max_length: maxLength,
max_context_length: max_context,
api_server,
};
switch (main_api) {
case 'koboldhorde':
case 'kobold':
if (main_api == 'koboldhorde' && horde_settings.auto_adjust_response_length) {
maxLength = Math.min(maxLength, adjustedParams.maxLength);
maxLength = Math.max(maxLength, MIN_LENGTH); // prevent validation errors
}
if (preset_settings != 'gui') {
const isHorde = main_api == 'koboldhorde';
const presetSettings = koboldai_settings[koboldai_setting_names[preset_settings]];
const maxContext = (adjustedParams && horde_settings.auto_adjust_context_length) ? adjustedParams.maxContextLength : max_context;
generate_data = getKoboldGenerationData(finalPrompt, presetSettings, maxLength, maxContext, isHorde, type);
generate_data = {
prompt: finalPrompt,
gui_settings: true,
max_length: maxLength,
max_context_length: max_context,
api_server,
};
if (preset_settings != 'gui') {
const isHorde = main_api == 'koboldhorde';
const presetSettings = koboldai_settings[koboldai_setting_names[preset_settings]];
const maxContext = (adjustedParams && horde_settings.auto_adjust_context_length) ? adjustedParams.maxContextLength : max_context;
generate_data = getKoboldGenerationData(finalPrompt, presetSettings, maxLength, maxContext, isHorde, type);
}
break;
case 'textgenerationwebui': {
const cfgValues = useCfgPrompt ? { guidanceScale: cfgGuidanceScale, negativePrompt: getCombinedPrompt(true) } : null;
generate_data = getTextGenGenerationData(finalPrompt, maxLength, isImpersonate, isContinue, cfgValues, type);
break;
}
}
else if (main_api == 'textgenerationwebui') {
generate_data = getTextGenGenerationData(finalPrompt, maxLength, isImpersonate, isContinue, cfgValues, type);
}
else if (main_api == 'novel') {
const presetSettings = novelai_settings[novelai_setting_names[nai_settings.preset_settings_novel]];
generate_data = getNovelGenerationData(finalPrompt, presetSettings, maxLength, isImpersonate, isContinue, cfgValues, type);
}
else if (main_api == 'openai') {
let [prompt, counts] = await prepareOpenAIMessages({
name2: name2,
charDescription: description,
charPersonality: personality,
Scenario: scenario,
worldInfoBefore: worldInfoBefore,
worldInfoAfter: worldInfoAfter,
extensionPrompts: extension_prompts,
bias: promptBias,
type: type,
quietPrompt: quiet_prompt,
quietImage: quietImage,
cyclePrompt: cyclePrompt,
systemPromptOverride: system,
jailbreakPromptOverride: jailbreak,
personaDescription: persona,
messages: oaiMessages,
messageExamples: oaiMessageExamples,
}, dryRun);
generate_data = { prompt: prompt };
// counts will return false if the user has not enabled the token breakdown feature
if (counts) {
parseTokenCounts(counts, thisPromptBits);
case 'novel': {
const cfgValues = useCfgPrompt ? { guidanceScale: cfgGuidanceScale } : null;
const presetSettings = novelai_settings[novelai_setting_names[nai_settings.preset_settings_novel]];
generate_data = getNovelGenerationData(finalPrompt, presetSettings, maxLength, isImpersonate, isContinue, cfgValues, type);
break;
}
case 'openai': {
let [prompt, counts] = await prepareOpenAIMessages({
name2: name2,
charDescription: description,
charPersonality: personality,
Scenario: scenario,
worldInfoBefore: worldInfoBefore,
worldInfoAfter: worldInfoAfter,
extensionPrompts: extension_prompts,
bias: promptBias,
type: type,
quietPrompt: quiet_prompt,
quietImage: quietImage,
cyclePrompt: cyclePrompt,
systemPromptOverride: system,
jailbreakPromptOverride: jailbreak,
personaDescription: persona,
messages: oaiMessages,
messageExamples: oaiMessageExamples,
}, dryRun);
generate_data = { prompt: prompt };
if (!dryRun) {
setInContextMessages(openai_messages_count, type);
// TODO: move these side-effects somewhere else, so this switch-case solely sets generate_data
// counts will return false if the user has not enabled the token breakdown feature
if (counts) {
parseTokenCounts(counts, thisPromptBits);
}
if (!dryRun) {
setInContextMessages(openai_messages_count, type);
}
break;
}
}
@ -4010,16 +4025,14 @@ async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, qu
userPersona: (power_user.persona_description || ''),
};
thisPromptBits = additionalPromptStuff;
//console.log(thisPromptBits);
const itemizedIndex = itemizedPrompts.findIndex((item) => item.mesId === thisPromptBits['mesId']);
//console.log(additionalPromptStuff);
const itemizedIndex = itemizedPrompts.findIndex((item) => item.mesId === additionalPromptStuff.mesId);
if (itemizedIndex !== -1) {
itemizedPrompts[itemizedIndex] = thisPromptBits;
itemizedPrompts[itemizedIndex] = additionalPromptStuff;
}
else {
itemizedPrompts.push(thisPromptBits);
itemizedPrompts.push(additionalPromptStuff);
}
console.debug(`pushed prompt bits to itemizedPrompts array. Length is now: ${itemizedPrompts.length}`);
@ -6798,10 +6811,7 @@ function select_rm_info(type, charId, previousCharId = null) {
const scrollOffset = element.offset().top - element.parent().offset().top;
element.parent().scrollTop(scrollOffset);
element.addClass('flash animated');
setTimeout(function () {
element.removeClass('flash animated');
}, 5000);
flashHighlight(element, 5000);
});
} catch (e) {
console.error(e);
@ -6827,10 +6837,7 @@ function select_rm_info(type, charId, previousCharId = null) {
const element = $(selector);
const scrollOffset = element.offset().top - element.parent().offset().top;
element.parent().scrollTop(scrollOffset);
$(element).addClass('flash animated');
setTimeout(function () {
$(element).removeClass('flash animated');
}, 5000);
flashHighlight(element, 5000);
});
} catch (e) {
console.error(e);
@ -7099,57 +7106,49 @@ function onScenarioOverrideRemoveClick() {
* @returns
*/
function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) {
function getOkButtonText() {
if (['avatarToCrop'].includes(popup_type)) {
return okButton ?? 'Accept';
} else if (['text', 'alternate_greeting', 'char_not_selected'].includes(popup_type)) {
$dialoguePopupCancel.css('display', 'none');
return okButton ?? 'Ok';
} else if (['delete_extension'].includes(popup_type)) {
return okButton ?? 'Ok';
} else if (['new_chat', 'confirm'].includes(popup_type)) {
return okButton ?? 'Yes';
} else if (['input'].includes(popup_type)) {
return okButton ?? 'Save';
}
return okButton ?? 'Delete';
}
dialogueCloseStop = true;
if (type) {
popup_type = type;
}
$('#dialogue_popup').toggleClass('wide_dialogue_popup', !!wide);
$('#dialogue_popup').toggleClass('large_dialogue_popup', !!large);
$('#dialogue_popup').toggleClass('horizontal_scrolling_dialogue_popup', !!allowHorizontalScrolling);
$('#dialogue_popup').toggleClass('vertical_scrolling_dialogue_popup', !!allowVerticalScrolling);
const $dialoguePopup = $('#dialogue_popup');
const $dialoguePopupCancel = $('#dialogue_popup_cancel');
const $dialoguePopupOk = $('#dialogue_popup_ok');
const $dialoguePopupInput = $('#dialogue_popup_input');
const $dialoguePopupText = $('#dialogue_popup_text');
const $shadowPopup = $('#shadow_popup');
$('#dialogue_popup_cancel').css('display', 'inline-block');
switch (popup_type) {
case 'avatarToCrop':
$('#dialogue_popup_ok').text(okButton ?? 'Accept');
break;
case 'text':
case 'alternate_greeting':
case 'char_not_selected':
$('#dialogue_popup_ok').text(okButton ?? 'Ok');
$('#dialogue_popup_cancel').css('display', 'none');
break;
case 'delete_extension':
$('#dialogue_popup_ok').text(okButton ?? 'Ok');
break;
case 'new_chat':
case 'confirm':
$('#dialogue_popup_ok').text(okButton ?? 'Yes');
break;
case 'del_group':
case 'rename_chat':
case 'del_chat':
default:
$('#dialogue_popup_ok').text(okButton ?? 'Delete');
}
$dialoguePopup.toggleClass('wide_dialogue_popup', !!wide)
.toggleClass('large_dialogue_popup', !!large)
.toggleClass('horizontal_scrolling_dialogue_popup', !!allowHorizontalScrolling)
.toggleClass('vertical_scrolling_dialogue_popup', !!allowVerticalScrolling);
$('#dialogue_popup_input').val(inputValue);
$('#dialogue_popup_input').attr('rows', rows ?? 1);
$dialoguePopupCancel.css('display', 'inline-block');
$dialoguePopupOk.text(getOkButtonText());
$dialoguePopupInput.toggle(popup_type === 'input').val(inputValue).attr('rows', rows ?? 1);
$dialoguePopupText.empty().append(text);
$shadowPopup.css('display', 'block');
if (popup_type == 'input') {
$('#dialogue_popup_input').css('display', 'block');
$('#dialogue_popup_ok').text(okButton ?? 'Save');
}
else {
$('#dialogue_popup_input').css('display', 'none');
$dialoguePopupInput.trigger('focus');
}
$('#dialogue_popup_text').empty().append(text);
$('#shadow_popup').css('display', 'block');
if (popup_type == 'input') {
$('#dialogue_popup_input').focus();
}
if (popup_type == 'avatarToCrop') {
// unset existing data
crop_data = undefined;
@ -7165,7 +7164,8 @@ function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, a
},
});
}
$('#shadow_popup').transition({
$shadowPopup.transition({
opacity: 1,
duration: animation_duration,
easing: animation_easing,
@ -10515,6 +10515,7 @@ jQuery(async function () {
<li>Chub Lorebook (Direct Link or ID)<br>Example: <tt>lorebooks/bartleby/example-lorebook</tt></li>
<li>JanitorAI Character (Direct Link or UUID)<br>Example: <tt>ddd1498a-a370-4136-b138-a8cd9461fdfe_character-aqua-the-useless-goddess</tt></li>
<li>Pygmalion.chat Character (Direct Link or UUID)<br>Example: <tt>a7ca95a1-0c88-4e23-91b3-149db1e78ab9</tt></li>
<li>AICharacterCard.com Character (Direct Link or ID)<br>Example: <tt>AICC/aicharcards/the-game-master</tt></li>
<li>More coming soon...</li>
<ul>`;
const input = await callPopup(html, 'input', '', { okButton: 'Import', rows: 4 });

View File

@ -776,7 +776,7 @@ class PromptManager {
const promptOrder = this.getPromptOrderForCharacter(character);
const index = promptOrder.findIndex(entry => entry.identifier === prompt.identifier);
if (-1 === index) promptOrder.push({ identifier: prompt.identifier, enabled: false });
if (-1 === index) promptOrder.unshift({ identifier: prompt.identifier, enabled: false });
}
/**
@ -1286,7 +1286,7 @@ class PromptManager {
} else if (!entry.enabled && entry.identifier === 'main') {
// Some extensions require main prompt to be present for relative inserts.
// So we make a GMO-free vegan replacement.
const prompt = this.getPromptById(entry.identifier);
const prompt = structuredClone(this.getPromptById(entry.identifier));
prompt.content = '';
if (prompt) promptCollection.add(this.preparePrompt(prompt));
}

View File

@ -1,7 +1,7 @@
import { callPopup, chat_metadata, eventSource, event_types, generateQuietPrompt, getCurrentChatId, getRequestHeaders, getThumbnailUrl, saveSettingsDebounced } from '../script.js';
import { saveMetadataDebounced } from './extensions.js';
import { registerSlashCommand } from './slash-commands.js';
import { stringFormat } from './utils.js';
import { flashHighlight, stringFormat } from './utils.js';
const BG_METADATA_KEY = 'custom_background';
const LIST_METADATA_KEY = 'chat_backgrounds';
@ -453,8 +453,7 @@ function highlightNewBackground(bg) {
const newBg = $(`.bg_example[bgfile="${bg}"]`);
const scrollOffset = newBg.offset().top - newBg.parent().offset().top;
$('#Backgrounds').scrollTop(scrollOffset);
newBg.addClass('flash animated');
setTimeout(() => newBg.removeClass('flash animated'), 2000);
flashHighlight(newBg);
}
function onBackgroundFilterInput() {

View File

@ -53,11 +53,11 @@ import { ScraperManager } from './scrapers.js';
* @returns {Promise<string>} Converted file text
*/
const fileSizeLimit = 1024 * 1024 * 10; // 10 MB
const fileSizeLimit = 1024 * 1024 * 100; // 100 MB
const ATTACHMENT_SOURCE = {
GLOBAL: 'global',
CHAT: 'chat',
CHARACTER: 'character',
CHAT: 'chat',
};
/**
@ -670,6 +670,55 @@ async function editAttachment(attachment, source, callback) {
callback();
}
/**
* Downloads an attachment to the user's device.
* @param {FileAttachment} attachment Attachment to download
*/
async function downloadAttachment(attachment) {
const fileText = attachment.text || (await getFileAttachment(attachment.url));
const blob = new Blob([fileText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = attachment.name;
a.click();
URL.revokeObjectURL(url);
}
/**
* Moves a file attachment to a different source.
* @param {FileAttachment} attachment Attachment to moves
* @param {string} source Source of the attachment
* @param {function} callback Success callback
* @returns {Promise<void>} A promise that resolves when the attachment is moved.
*/
async function moveAttachment(attachment, source, callback) {
let selectedTarget = source;
const targets = getAvailableTargets();
const template = $(await renderExtensionTemplateAsync('attachments', 'move-attachment', { name: attachment.name, targets }));
template.find('.moveAttachmentTarget').val(source).on('input', function () {
selectedTarget = String($(this).val());
});
const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { wide: false, large: false, okButton: 'Move', cancelButton: 'Cancel' });
if (result !== POPUP_RESULT.AFFIRMATIVE) {
console.debug('Move attachment cancelled');
return;
}
if (selectedTarget === source) {
console.debug('Move attachment cancelled: same source and target');
return;
}
const content = await getFileAttachment(attachment.url);
const file = new File([content], attachment.name, { type: 'text/plain' });
await deleteAttachment(attachment, source, () => { }, false);
await uploadFileAttachmentToServer(file, selectedTarget);
callback();
}
/**
* Deletes an attachment from the server and the chat.
* @param {FileAttachment} attachment Attachment to delete
@ -765,6 +814,8 @@ async function openAttachmentManager() {
attachmentTemplate.find('.viewAttachmentButton').on('click', () => openFilePopup(attachment));
attachmentTemplate.find('.editAttachmentButton').on('click', () => editAttachment(attachment, source, renderAttachments));
attachmentTemplate.find('.deleteAttachmentButton').on('click', () => deleteAttachment(attachment, source, renderAttachments));
attachmentTemplate.find('.downloadAttachmentButton').on('click', () => downloadAttachment(attachment));
attachmentTemplate.find('.moveAttachmentButton').on('click', () => moveAttachment(attachment, source, renderAttachments));
template.find(sources[source]).append(attachmentTemplate);
}
}
@ -869,6 +920,50 @@ async function openAttachmentManager() {
template.find('.chatAttachmentsName').text(chatName);
}
function addDragAndDrop() {
$(document.body).on('dragover', '.dialogue_popup', (event) => {
event.preventDefault();
event.stopPropagation();
$(event.target).closest('.dialogue_popup').addClass('dragover');
});
$(document.body).on('dragleave', '.dialogue_popup', (event) => {
event.preventDefault();
event.stopPropagation();
$(event.target).closest('.dialogue_popup').removeClass('dragover');
});
$(document.body).on('drop', '.dialogue_popup', async (event) => {
event.preventDefault();
event.stopPropagation();
$(event.target).closest('.dialogue_popup').removeClass('dragover');
const files = Array.from(event.originalEvent.dataTransfer.files);
let selectedTarget = ATTACHMENT_SOURCE.GLOBAL;
const targets = getAvailableTargets();
const targetSelectTemplate = $(await renderExtensionTemplateAsync('attachments', 'files-dropped', { count: files.length, targets: targets }));
targetSelectTemplate.find('.droppedFilesTarget').on('input', function () {
selectedTarget = String($(this).val());
});
const result = await callGenericPopup(targetSelectTemplate, POPUP_TYPE.CONFIRM, '', { wide: false, large: false, okButton: 'Upload', cancelButton: 'Cancel' });
if (result !== POPUP_RESULT.AFFIRMATIVE) {
console.log('File upload cancelled');
return;
}
for (const file of files) {
await uploadFileAttachmentToServer(file, selectedTarget);
}
renderAttachments();
});
}
function removeDragAndDrop() {
$(document.body).off('dragover', '.shadow_popup');
$(document.body).off('dragleave', '.shadow_popup');
$(document.body).off('drop', '.shadow_popup');
}
let sortField = localStorage.getItem('DataBank_sortField') || 'created';
let sortOrder = localStorage.getItem('DataBank_sortOrder') || 'desc';
let filterString = '';
@ -894,9 +989,32 @@ async function openAttachmentManager() {
const cleanupFn = await renderButtons();
await verifyAttachments();
await renderAttachments();
addDragAndDrop();
await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: true, large: true, okButton: 'Close' });
cleanupFn();
removeDragAndDrop();
}
/**
* Gets a list of available targets for attachments.
* @returns {string[]} List of available targets
*/
function getAvailableTargets() {
const targets = Object.values(ATTACHMENT_SOURCE);
const isNotCharacter = this_chid === undefined || selected_group;
const isNotInChat = getCurrentChatId() === undefined;
if (isNotCharacter) {
targets.splice(targets.indexOf(ATTACHMENT_SOURCE.CHARACTER), 1);
}
if (isNotInChat) {
targets.splice(targets.indexOf(ATTACHMENT_SOURCE.CHAT), 1);
}
return targets;
}
/**
@ -1168,6 +1286,7 @@ jQuery(function () {
const textarea = document.createElement('textarea');
textarea.value = String(bro.val());
textarea.classList.add('height100p', 'wide100p');
bro.hasClass('monospace') && textarea.classList.add('monospace');
textarea.addEventListener('input', function () {
bro.val(textarea.value).trigger('input');
});

View File

@ -0,0 +1,8 @@
<div class="flex-container justifyCenter alignItemsBaseline">
<span>Save <span class="droppedFilesCount">{{count}}</span> file(s) to...</span>
<select class="droppedFilesTarget">
{{#each targets}}
<option value="{{this}}">{{this}}</option>
{{/each}}
</select>
</div>

View File

@ -1,6 +1,9 @@
import { renderExtensionTemplateAsync } from '../../extensions.js';
import { registerSlashCommand } from '../../slash-commands.js';
jQuery(async () => {
const buttons = await renderExtensionTemplateAsync('attachments', 'buttons', {});
$('#extensionsMenu').prepend(buttons);
registerSlashCommand('db', () => document.getElementById('manageAttachments')?.click(), ['databank', 'data-bank'], ' open the data bank', true, true);
});

View File

@ -7,8 +7,13 @@
<div data-i18n="These files will be available for extensions that support attachments (e.g. Vector Storage).">
These files will be available for extensions that support attachments (e.g. Vector Storage).
</div>
<div data-i18n="Supported file types: Plain Text, PDF, Markdown, HTML, EPUB." class="marginTopBot5">
Supported file types: Plain Text, PDF, Markdown, HTML, EPUB.
<div class="marginTopBot5">
<span data-i18n="Supported file types: Plain Text, PDF, Markdown, HTML, EPUB." >
Supported file types: Plain Text, PDF, Markdown, HTML, EPUB.
</span>
<span data-i18n="Drag and drop files here to upload.">
Drag and drop files here to upload.
</span>
</div>
<div class="flex-container marginTopBot5">
<input type="search" id="attachmentSearch" class="attachmentSearch text_pole margin0 flex1" placeholder="Search...">
@ -102,7 +107,9 @@
<small class="attachmentListItemCreated"></small>
<small class="attachmentListItemSize"></small>
<div class="viewAttachmentButton right_menu_button fa-solid fa-magnifying-glass" title="View attachment content"></div>
<div class="moveAttachmentButton right_menu_button fa-solid fa-arrows-alt" title="Move attachment"></div>
<div class="editAttachmentButton right_menu_button fa-solid fa-pencil" title="Edit attachment"></div>
<div class="downloadAttachmentButton right_menu_button fa-solid fa-download" title="Download attachment"></div>
<div class="deleteAttachmentButton right_menu_button fa-solid fa-trash" title="Delete attachment"></div>
</div>
</div>

View File

@ -0,0 +1,8 @@
<div class="flex-container justifyCenter alignItemsBaseline">
<span>Move <strong class="moveAttachmentName">{{name}}</strong> to...</span>
<select class="moveAttachmentTarget">
{{#each targets}}
<option value="{{this}}">{{this}}</option>
{{/each}}
</select>
</div>

View File

@ -1270,13 +1270,10 @@ async function getExpressionsList() {
* @returns {Promise<string[]>}
*/
async function resolveExpressionsList() {
// get something for offline mode (default images)
if (!modules.includes('classify') && extension_settings.expressions.api == EXPRESSION_API.extras) {
return DEFAULT_EXPRESSIONS;
}
// See if we can retrieve a specific expression list from the API
try {
if (extension_settings.expressions.api == EXPRESSION_API.extras) {
// Check Extras api first, if enabled and that module active
if (extension_settings.expressions.api == EXPRESSION_API.extras && modules.includes('classify')) {
const url = new URL(getApiUrl());
url.pathname = '/api/classify/labels';
@ -1291,7 +1288,10 @@ async function getExpressionsList() {
expressionsList = data.labels;
return expressionsList;
}
} else {
}
// If running the local classify model (not using the LLM), we ask that one
if (extension_settings.expressions.api == EXPRESSION_API.local) {
const apiResult = await fetch('/api/extra/classify/labels', {
method: 'POST',
headers: getRequestHeaders(),
@ -1303,11 +1303,12 @@ async function getExpressionsList() {
return expressionsList;
}
}
}
catch (error) {
} catch (error) {
console.log(error);
return [];
}
// If there was no specific list, or an error, just return the default expressions
return DEFAULT_EXPRESSIONS;
}
const result = await resolveExpressionsList();

View File

@ -94,7 +94,7 @@ async function loadRegexScripts() {
await onRegexEditorOpenClick(scriptHtml.attr('id'));
});
scriptHtml.find('.export_regex').on('click', async function () {
const fileName = `${script.scriptName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`;
const fileName = `${script.scriptName.replace(/[\s.<>:"/\\|?*\x00-\x1F\x7F]/g, '_').toLowerCase()}.json`;
const fileData = JSON.stringify(script, null, 4);
download(fileData, fileName, 'application/json');
});

View File

@ -1657,6 +1657,10 @@ async function loadNovelModels() {
value: 'safe-diffusion',
text: 'NAI Diffusion Anime V1 (Curated)',
},
{
value: 'nai-diffusion-furry-3',
text: 'NAI Diffusion Furry V3',
},
{
value: 'nai-diffusion-furry',
text: 'NAI Diffusion Furry',

View File

@ -424,6 +424,24 @@ function createEventHandler(translateFunction, shouldTranslateFunction) {
};
}
async function onTranslateInputMessageClick() {
const textarea = document.getElementById('send_textarea');
if (!(textarea instanceof HTMLTextAreaElement)) {
return;
}
if (!textarea.value) {
toastr.warning('Enter a message first');
return;
}
const toast = toastr.info('Input Message is translating', 'Please wait...');
const translatedText = await translate(textarea.value, extension_settings.translate.internal_language);
textarea.value = translatedText;
toastr.clear(toast);
}
// Prevents the chat from being translated in parallel
let translateChatExecuting = false;
@ -555,10 +573,16 @@ jQuery(() => {
<div id="translate_chat" class="list-group-item flex-container flexGap5">
<div class="fa-solid fa-language extensionsMenuExtensionButton" /></div>
Translate Chat
</div>`;
</div>
<div id="translate_input_message" class="list-group-item flex-container flexGap5">
<div class="fa-solid fa-keyboard extensionsMenuExtensionButton" /></div>
Translate Input
</div>
`;
$('#extensionsMenu').append(buttonHtml);
$('#extensions_settings2').append(html);
$('#translate_chat').on('click', onTranslateChatClick);
$('#translate_input_message').on('click', onTranslateInputMessageClick);
$('#translation_clear').on('click', onTranslationsClearClick);
for (const [key, value] of Object.entries(languageCodes)) {

View File

@ -23,6 +23,7 @@ import { collapseNewlines } from '../../power-user.js';
import { SECRET_KEYS, secret_state, writeSecret } from '../../secrets.js';
import { getDataBankAttachments, getFileAttachment } from '../../chats.js';
import { debounce, getStringHash as calculateHash, waitUntilCondition, onlyUnique, splitRecursive } from '../../utils.js';
import { getSortedEntries } from '../../world-info.js';
const MODULE_NAME = 'vectors';
@ -66,6 +67,11 @@ const settings = {
file_position_db: extension_prompt_types.IN_PROMPT,
file_depth_db: 4,
file_depth_role_db: extension_prompt_roles.SYSTEM,
// For World Info
enabled_world_info: false,
enabled_for_all: false,
max_entries: 5,
};
const moduleWorker = new ModuleWorkerWrapper(synchronizeChat);
@ -281,8 +287,10 @@ async function synchronizeChat(batchSize = 5) {
}
}
// Cache object for storing hash values
const hashCache = {};
/**
* @type {Map<string, number>} Cache object for storing hash values
*/
const hashCache = new Map();
/**
* Gets the hash value for a given string
@ -291,15 +299,15 @@ const hashCache = {};
*/
function getStringHash(str) {
// Check if the hash is already in the cache
if (Object.hasOwn(hashCache, str)) {
return hashCache[str];
if (hashCache.has(str)) {
return hashCache.get(str);
}
// Calculate the hash value
const hash = calculateHash(str);
// Store the hash in the cache
hashCache[str] = hash;
hashCache.set(str, hash);
return hash;
}
@ -472,6 +480,10 @@ async function rearrangeChat(chat) {
await processFiles(chat);
}
if (settings.enabled_world_info) {
await activateWorldInfo(chat);
}
if (!settings.enabled_chats) {
return;
}
@ -845,6 +857,7 @@ async function purgeVectorIndex(collectionId) {
function toggleSettings() {
$('#vectors_files_settings').toggle(!!settings.enabled_files);
$('#vectors_chats_settings').toggle(!!settings.enabled_chats);
$('#vectors_world_info_settings').toggle(!!settings.enabled_world_info);
$('#together_vectorsModel').toggle(settings.source === 'togetherai');
$('#openai_vectorsModel').toggle(settings.source === 'openai');
$('#cohere_vectorsModel').toggle(settings.source === 'cohere');
@ -934,6 +947,111 @@ async function onPurgeFilesClick() {
}
}
async function activateWorldInfo(chat) {
if (!settings.enabled_world_info) {
console.debug('Vectors: Disabled for World Info');
return;
}
const entries = await getSortedEntries();
if (!Array.isArray(entries) || entries.length === 0) {
console.debug('Vectors: No WI entries found');
return;
}
// Group entries by "world" field
const groupedEntries = {};
for (const entry of entries) {
// Skip orphaned entries. Is it even possible?
if (!entry.world) {
console.debug('Vectors: Skipped orphaned WI entry', entry);
continue;
}
// Skip disabled entries
if (entry.disable) {
console.debug('Vectors: Skipped disabled WI entry', entry);
continue;
}
// Skip entries without content
if (!entry.content) {
console.debug('Vectors: Skipped WI entry without content', entry);
continue;
}
// Skip non-vectorized entries
if (!entry.vectorized && !settings.enabled_for_all) {
console.debug('Vectors: Skipped non-vectorized WI entry', entry);
continue;
}
if (!Object.hasOwn(groupedEntries, entry.world)) {
groupedEntries[entry.world] = [];
}
groupedEntries[entry.world].push(entry);
}
const collectionIds = [];
if (Object.keys(groupedEntries).length === 0) {
console.debug('Vectors: No WI entries to synchronize');
return;
}
// Synchronize collections
for (const world in groupedEntries) {
const collectionId = `world_${getStringHash(world)}`;
const hashesInCollection = await getSavedHashes(collectionId);
const newEntries = groupedEntries[world].filter(x => !hashesInCollection.includes(getStringHash(x.content)));
const deletedHashes = hashesInCollection.filter(x => !groupedEntries[world].some(y => getStringHash(y.content) === x));
if (newEntries.length > 0) {
console.log(`Vectors: Found ${newEntries.length} new WI entries for world ${world}`);
await insertVectorItems(collectionId, newEntries.map(x => ({ hash: getStringHash(x.content), text: x.content, index: x.uid })));
}
if (deletedHashes.length > 0) {
console.log(`Vectors: Deleted ${deletedHashes.length} old hashes for world ${world}`);
await deleteVectorItems(collectionId, deletedHashes);
}
collectionIds.push(collectionId);
}
// Perform a multi-query
const queryText = await getQueryText(chat);
if (queryText.length === 0) {
console.debug('Vectors: No text to query for WI');
return;
}
const queryResults = await queryMultipleCollections(collectionIds, queryText, settings.max_entries);
const activatedHashes = Object.values(queryResults).flatMap(x => x.hashes).filter(onlyUnique);
const activatedEntries = [];
// Activate entries found in the query results
for (const entry of entries) {
const hash = getStringHash(entry.content);
if (activatedHashes.includes(hash)) {
activatedEntries.push(entry);
}
}
if (activatedEntries.length === 0) {
console.debug('Vectors: No activated WI entries found');
return;
}
console.log(`Vectors: Activated ${activatedEntries.length} WI entries`, activatedEntries);
await eventSource.emit(event_types.WORLDINFO_FORCE_ACTIVATE, activatedEntries);
}
jQuery(async () => {
if (!extension_settings.vectors) {
extension_settings.vectors = settings;
@ -1134,6 +1252,25 @@ jQuery(async () => {
saveSettingsDebounced();
});
$('#vectors_enabled_world_info').prop('checked', settings.enabled_world_info).on('input', () => {
settings.enabled_world_info = !!$('#vectors_enabled_world_info').prop('checked');
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
toggleSettings();
});
$('#vectors_enabled_for_all').prop('checked', settings.enabled_for_all).on('input', () => {
settings.enabled_for_all = !!$('#vectors_enabled_for_all').prop('checked');
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
$('#vectors_max_entries').val(settings.max_entries).on('input', () => {
settings.max_entries = Number($('#vectors_max_entries').val());
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
const validSecret = !!secret_state[SECRET_KEYS.NOMICAI];
const placeholder = validSecret ? '✔️ Key saved' : '❌ Missing key';
$('#api_key_nomicai').attr('placeholder', placeholder);

View File

@ -97,6 +97,46 @@
<hr>
<h4>
World Info settings
</h4>
<label class="checkbox_label" for="vectors_enabled_world_info" title="Enable activation of World Info entries based on vector similarity.">
<input id="vectors_enabled_world_info" type="checkbox" class="checkbox">
Enabled for World Info
</label>
<div id="vectors_world_info_settings" class="marginTopBot5">
<div class="flex-container">
<label for="vectors_enabled_for_all" class="checkbox_label">
<input id="vectors_enabled_for_all" type="checkbox" />
<span>Enabled for all entries</span>
</label>
<ul class="margin0">
<li>
<small>Checked: all entries except ❌ status can be activated.</small>
</li>
<li>
<small>Unchecked: only entries with 🔗 status can be activated.</small>
</li>
</ul>
</div>
<div class="flex-container">
<div class="flex1">
<!-- Vacant for future use -->
</div>
<div class="flex1" title="Maximum number of entries to be activated">
<label for="vectors_max_entries" >
<small>Max Entries</small>
</label>
<input id="vectors_max_entries" type="number" class="text_pole widthUnset" min="1" max="9999" />
</div>
<div class="flex1">
<!-- Vacant for future use -->
</div>
</div>
</div>
<h4>
File vectorization settings
</h4>

View File

@ -1,4 +1,5 @@
import { registerDebugFunction } from './power-user.js';
import { updateSecretDisplay } from './secrets.js';
const storageKey = 'language';
const overrideLanguage = localStorage.getItem(storageKey);
@ -12,10 +13,8 @@ const localeData = await getLocaleData(localeFile);
* @returns {Promise<Record<string, string>>} Locale data
*/
async function getLocaleData(language) {
let supportedLang = langs.find(x => x.lang === language);
let supportedLang = findLang(language);
if (!supportedLang) {
console.warn(`Unsupported language: ${language}`);
return {};
}
@ -30,11 +29,24 @@ async function getLocaleData(language) {
return data;
}
function findLang(language) {
var supportedLang = langs.find(x => x.lang === language);
if (!supportedLang) {
console.warn(`Unsupported language: ${language}`);
}
return supportedLang;
}
async function getMissingTranslations() {
const missingData = [];
for (const language of langs) {
const localeData = await getLocaleData(language);
// Determine locales to search for untranslated strings
const isNotSupported = !findLang(localeFile);
const langsToProcess = (isNotSupported || localeFile == 'en') ? langs : [findLang(localeFile)];
for (const language of langsToProcess) {
const localeData = await getLocaleData(language.lang);
$(document).find('[data-i18n]').each(function () {
const keys = $(this).data('i18n').split(';'); // Multi-key entries are ; delimited
for (const key of keys) {
@ -42,12 +54,12 @@ async function getMissingTranslations() {
if (attributeMatch) { // attribute-tagged key
const localizedValue = localeData?.[attributeMatch[2]];
if (!localizedValue) {
missingData.push({ key, language, value: $(this).attr(attributeMatch[1]) });
missingData.push({ key, language: language.lang, value: $(this).attr(attributeMatch[1]) });
}
} else { // No attribute tag, treat as 'text'
const localizedValue = localeData?.[key];
if (!localizedValue) {
missingData.push({ key, language, value: $(this).text().trim() });
missingData.push({ key, language: language.lang, value: $(this).text().trim() });
}
}
}
@ -130,6 +142,7 @@ function addLanguagesToDropdown() {
export function initLocales() {
applyLocale();
addLanguagesToDropdown();
updateSecretDisplay();
$('#ui_language_select').on('change', async function () {
const language = String($(this).val());
@ -143,6 +156,6 @@ export function initLocales() {
location.reload();
});
registerDebugFunction('getMissingTranslations', 'Get missing translations', 'Detects missing localization data and dumps the data into the browser console.', getMissingTranslations);
registerDebugFunction('getMissingTranslations', 'Get missing translations', 'Detects missing localization data in the current locale and dumps the data into the browser console. If the current locale is English, searches all other locales.', getMissingTranslations);
registerDebugFunction('applyLocale', 'Apply locale', 'Reapplies the currently selected locale to the page.', applyLocale);
}

View File

@ -3250,7 +3250,8 @@ async function onExportPresetClick() {
delete preset.proxy_password;
const presetJsonString = JSON.stringify(preset, null, 4);
download(presetJsonString, oai_settings.preset_settings_openai, 'application/json');
const presetFileName = `${oai_settings.preset_settings_openai}.json`;
download(presetJsonString, presetFileName, 'application/json');
}
async function onLogitBiasPresetImportFileChange(e) {
@ -3298,7 +3299,8 @@ function onLogitBiasPresetExportClick() {
}
const presetJsonString = JSON.stringify(oai_settings.bias_presets[oai_settings.bias_preset_selected], null, 4);
download(presetJsonString, oai_settings.bias_preset_selected, 'application/json');
const presetFileName = `${oai_settings.bias_preset_selected}.json`;
download(presetJsonString, presetFileName, 'application/json');
}
async function onDeletePresetClick() {

View File

@ -178,6 +178,7 @@ let power_user = {
timestamps_enabled: true,
timestamp_model_icon: false,
mesIDDisplay_enabled: false,
hideChatAvatars_enabled: false,
max_context_unlocked: false,
message_token_count_enabled: false,
expand_message_actions: false,
@ -295,6 +296,7 @@ const storage_keys = {
timestamps_enabled: 'TimestampsEnabled',
timestamp_model_icon: 'TimestampModelIcon',
mesIDDisplay_enabled: 'mesIDDisplayEnabled',
hideChatAvatars_enabled: 'hideChatAvatarsEnabled',
message_token_count_enabled: 'MessageTokenCountEnabled',
expand_message_actions: 'ExpandMessageActions',
enableZenSliders: 'enableZenSliders',
@ -464,6 +466,17 @@ function switchMesIDDisplay() {
$('#mesIDDisplayEnabled').prop('checked', power_user.mesIDDisplay_enabled);
}
function switchHideChatAvatars() {
const value = localStorage.getItem(storage_keys.hideChatAvatars_enabled);
power_user.hideChatAvatars_enabled = value === null ? false : value == 'true';
/*console.log(`
localstorage value:${value},
poweruser after:${power_user.hideChatAvatars_enabled}`)
*/
$('body').toggleClass('hideChatAvatars', power_user.hideChatAvatars_enabled);
$('#hideChatAvatarsEnabled').prop('checked', power_user.hideChatAvatars_enabled);
}
function switchMessageActions() {
const value = localStorage.getItem(storage_keys.expand_message_actions);
power_user.expand_message_actions = value === null ? false : value == 'true';
@ -1269,6 +1282,13 @@ async function applyTheme(name) {
switchMesIDDisplay();
},
},
{
key: 'hideChatAvatars_enabled',
action: async () => {
localStorage.setItem(storage_keys.hideChatAvatars_enabled, Boolean(power_user.hideChatAvatars_enabled));
switchHideChatAvatars();
},
},
{
key: 'expand_message_actions',
action: async () => {
@ -1391,6 +1411,7 @@ switchTimer();
switchTimestamps();
switchIcons();
switchMesIDDisplay();
switchHideChatAvatars();
switchTokenCount();
switchMessageActions();
@ -1433,6 +1454,7 @@ function loadPowerUserSettings(settings, data) {
const timer = localStorage.getItem(storage_keys.timer_enabled);
const timestamps = localStorage.getItem(storage_keys.timestamps_enabled);
const mesIDDisplay = localStorage.getItem(storage_keys.mesIDDisplay_enabled);
const hideChatAvatars = localStorage.getItem(storage_keys.hideChatAvatars_enabled);
const expandMessageActions = localStorage.getItem(storage_keys.expand_message_actions);
const enableZenSliders = localStorage.getItem(storage_keys.enableZenSliders);
const enableLabMode = localStorage.getItem(storage_keys.enableLabMode);
@ -1456,6 +1478,7 @@ function loadPowerUserSettings(settings, data) {
power_user.timer_enabled = timer === null ? true : timer == 'true';
power_user.timestamps_enabled = timestamps === null ? true : timestamps == 'true';
power_user.mesIDDisplay_enabled = mesIDDisplay === null ? true : mesIDDisplay == 'true';
power_user.hideChatAvatars_enabled = hideChatAvatars === null ? true : hideChatAvatars == 'true';
power_user.expand_message_actions = expandMessageActions === null ? true : expandMessageActions == 'true';
power_user.enableZenSliders = enableZenSliders === null ? false : enableZenSliders == 'true';
power_user.enableLabMode = enableLabMode === null ? false : enableLabMode == 'true';
@ -1541,6 +1564,7 @@ function loadPowerUserSettings(settings, data) {
$('#messageTimestampsEnabled').prop('checked', power_user.timestamps_enabled);
$('#messageModelIconEnabled').prop('checked', power_user.timestamp_model_icon);
$('#mesIDDisplayEnabled').prop('checked', power_user.mesIDDisplay_enabled);
$('#hideChatAvatarsEndabled').prop('checked', power_user.hideChatAvatars_enabled);
$('#prefer_character_prompt').prop('checked', power_user.prefer_character_prompt);
$('#prefer_character_jailbreak').prop('checked', power_user.prefer_character_jailbreak);
$('#enableZenSliders').prop('checked', power_user.enableZenSliders).trigger('input');
@ -2150,6 +2174,7 @@ async function saveTheme(name = undefined) {
timestamp_model_icon: power_user.timestamp_model_icon,
mesIDDisplay_enabled: power_user.mesIDDisplay_enabled,
hideChatAvatars_enabled: power_user.hideChatAvatars_enabled,
message_token_count_enabled: power_user.message_token_count_enabled,
expand_message_actions: power_user.expand_message_actions,
enableZenSliders: power_user.enableZenSliders,
@ -2373,7 +2398,7 @@ async function doRandomChat(_, tagName) {
.map(x => x[0]) // Map the character avatar
.filter(x => characters.find(y => y.avatar === x)); // Filter out characters that don't exist
const randomCharacter = taggedCharacters[Math.floor(Math.random() * taggedCharacters.length)];
const randomIndex = characters.findIndex(x => x.avatar === randomCharacter);
const randomIndex = characters.findIndex(x => x.avatar === randomCharacter);
if (randomIndex === -1) {
return;
}
@ -3376,6 +3401,13 @@ $(document).ready(() => {
switchMesIDDisplay();
});
$('#hideChatAvatarsEnabled').on('input', function () {
const value = !!$(this).prop('checked');
power_user.hideChatAvatars_enabled = value;
localStorage.setItem(storage_keys.hideChatAvatars_enabled, Boolean(power_user.hideChatAvatars_enabled));
switchHideChatAvatars();
});
$('#hotswapEnabled').on('input', function () {
const value = !!$(this).prop('checked');
power_user.hotswap_enabled = value;

View File

@ -372,7 +372,7 @@ class YouTubeScraper {
this.id = 'youtube';
this.name = 'YouTube';
this.description = 'Download a transcript from a YouTube video.';
this.iconClass = 'fa-solid fa-closed-captioning';
this.iconClass = 'fa-brands fa-youtube';
this.iconAvailable = true;
}

View File

@ -62,10 +62,11 @@ async function clearSecret() {
$('#main_api').trigger('change');
}
function updateSecretDisplay() {
export function updateSecretDisplay() {
for (const [secret_key, input_selector] of Object.entries(INPUT_MAP)) {
const validSecret = !!secret_state[secret_key];
const placeholder = validSecret ? '✔️ Key saved' : '❌ Missing key';
const placeholder = $('#viewSecrets').attr(validSecret ? 'key_saved_text' : 'missing_key_text');
$(input_selector).attr('placeholder', placeholder);
}
}

View File

@ -146,7 +146,7 @@ class SlashCommandParser {
return {
commandName: command,
command: {
callback: () => {},
callback: () => { },
helpString: '',
interruptsGeneration: false,
purgeFromMessage: true,
@ -203,13 +203,10 @@ class SlashCommandParser {
// Excluded commands format in their own function
if (!excludedFromRegex.includes(command)) {
console.debug(`parse: !excludedFromRegex.includes(${command}`);
console.debug(` parse: unnamedArg before: ${unnamedArg}`);
unnamedArg = getRegexedString(
unnamedArg,
regex_placement.SLASH_COMMAND,
);
console.debug(` parse: unnamedArg after: ${unnamedArg}`);
}
// your weird complex command is now transformed into a juicy tiny text or something useful :)
@ -338,13 +335,12 @@ function injectCallback(args, value) {
chat_metadata.script_injects = {};
}
chat_metadata.script_injects[id] = {
value,
position,
depth,
scan,
role,
};
if (value) {
const inject = { value, position, depth, scan, role };
chat_metadata.script_injects[id] = inject;
} else {
delete chat_metadata.script_injects[id];
}
setExtensionPrompt(prefixedId, value, position, depth, scan, role);
saveMetadataDebounced();

View File

@ -15,7 +15,7 @@ import {
import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js';
import { groupCandidatesFilter, groups, selected_group } from './group-chats.js';
import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay } from './utils.js';
import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight } from './utils.js';
import { power_user } from './power-user.js';
export {
@ -350,18 +350,20 @@ function createTagMapFromList(listElement, key) {
* If you have an entity, you can get it's key easily via `getTagKeyForEntity(entity)`.
*
* @param {string} key - The key for which to get tags via the tag map
* @param {boolean} [sort=true] -
* @returns {Tag[]} A list of tags
*/
function getTagsList(key) {
function getTagsList(key, sort = true) {
if (!Array.isArray(tag_map[key])) {
tag_map[key] = [];
return [];
}
return tag_map[key]
const list = tag_map[key]
.map(x => tags.find(y => y.id === x))
.filter(x => x)
.sort(compareTagsForSort);
.filter(x => x);
if (sort) list.sort(compareTagsForSort);
return list;
}
function getInlineListSelector() {
@ -384,7 +386,7 @@ function getTagKey() {
return selected_group;
}
if (this_chid && menu_type === 'character_edit') {
if (this_chid !== undefined && menu_type === 'character_edit') {
return characters[this_chid].avatar;
}
@ -644,6 +646,7 @@ function createNewTag(tagName) {
* @property {Tag} [addTag=undefined] - Optionally provide a tag that should be manually added to this print. Either to the overriden tag list or the found tags based on the entity/key. Will respect the tag exists check.
* @property {object|number|string} [forEntityOrKey=undefined] - Optional override for the chosen entity, otherwise the currently selected is chosen. Can be an entity with id property (character, group, tag), or directly an id or tag key.
* @property {boolean|string} [empty=true] - Whether the list should be initially empty. If a string string is provided, 'always' will always empty the list, otherwise it'll evaluate to a boolean.
* @property {boolean} [sort=true] - Whether the tags should be sorted via the sort function, or kept as is.
* @property {function(object): function} [tagActionSelector=undefined] - An optional override for the action property that can be assigned to each tag via tagOptions.
* If set, the selector is executed on each tag as input argument. This allows a list of tags to be provided and each tag can have it's action based on the tag object itself.
* @property {TagOptions} [tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList")
@ -655,10 +658,10 @@ function createNewTag(tagName) {
* @param {JQuery<HTMLElement>|string} element - The container element where the tags are to be printed. (Optionally can also be a string selector for the element, which will then be resolved)
* @param {PrintTagListOptions} [options] - Optional parameters for printing the tag list.
*/
function printTagList(element, { tags = undefined, addTag = undefined, forEntityOrKey = undefined, empty = true, tagActionSelector = undefined, tagOptions = {} } = {}) {
function printTagList(element, { tags = undefined, addTag = undefined, forEntityOrKey = undefined, empty = true, sort = true, tagActionSelector = undefined, tagOptions = {} } = {}) {
const $element = (typeof element === 'string') ? $(element) : element;
const key = forEntityOrKey !== undefined ? getTagKeyForEntity(forEntityOrKey) : getTagKey();
let printableTags = tags ? (typeof tags === 'function' ? tags() : tags) : getTagsList(key);
let printableTags = tags ? (typeof tags === 'function' ? tags() : tags) : getTagsList(key, sort);
if (empty === 'always' || (empty && (printableTags?.length > 0 || key))) {
$element.empty();
@ -669,7 +672,7 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity
}
// one last sort, because we might have modified the tag list or manually retrieved it from a function
printableTags = printableTags.sort(compareTagsForSort);
if (sort) printableTags = printableTags.sort(compareTagsForSort);
const customAction = typeof tagActionSelector === 'function' ? tagActionSelector : null;
@ -872,10 +875,10 @@ function printTagFilters(type = tag_filter_types.character) {
// Print all action tags. (Exclude folder if that setting isn't chosen)
const actionTags = Object.values(ACTIONABLE_TAGS).filter(tag => power_user.bogus_folders || tag.id != ACTIONABLE_TAGS.FOLDER.id);
printTagList($(FILTER_SELECTOR), { empty: false, tags: actionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: actionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
const inListActionTags = Object.values(InListActionable);
printTagList($(FILTER_SELECTOR), { empty: false, tags: inListActionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: inListActionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
const characterTagIds = Object.values(tag_map).flat();
const tagsToDisplay = tags.filter(x => characterTagIds.includes(x.id)).sort(compareTagsForSort);
@ -992,11 +995,11 @@ export function createTagInput(inputSelector, listSelector, tagListOptions = {})
}
function onViewTagsListClick() {
$('#dialogue_popup').addClass('large_dialogue_popup');
const list = $(document.createElement('div'));
list.attr('id', 'tag_view_list');
const everything = Object.values(tag_map).flat();
$(list).append(`
const popup = $('#dialogue_popup');
popup.addClass('large_dialogue_popup');
const html = $(document.createElement('div'));
html.attr('id', 'tag_view_list');
html.append(`
<div class="title_restorable alignItemsBaseline">
<h3>Tag Management</h3>
<div class="flex-container alignItemsBaseline">
@ -1017,25 +1020,57 @@ function onViewTagsListClick() {
</div>
<div class="justifyLeft m-b-1">
<small>
Drag the handle to reorder.<br>
Drag handle to reorder. Click name to rename. Click color to change display.<br>
${(power_user.bogus_folders ? 'Click on the folder icon to use this tag as a folder.<br>' : '')}
Click on the tag name to edit it.<br>
Click on color box to assign new color.
<label class="checkbox flex-container alignitemscenter flexNoGap m-t-1" for="auto_sort_tags">
<input type="checkbox" id="auto_sort_tags" name="auto_sort_tags" ${power_user.auto_sort_tags ? ' checked' : ''} />
<span data-i18n="Use alphabetical sorting">
Use alphabetical sorting
<div class="fa-solid fa-circle-info opacity50p" data-i18n="[title]If enabled, tags will automatically be sorted alphabetically on creation or rename.\nIf disabled, new tags will be appended at the end.\n\nIf a tag is manually reordered by dragging, automatic sorting will be disabled."
title="If enabled, tags will automatically be sorted alphabetically on creation or rename.\nIf disabled, new tags will be appended at the end.\n\nIf a tag is manually reordered by dragging, automatic sorting will be disabled.">
</div>
</span>
</label>
</small>
</div>`);
const tagContainer = $('<div class="tag_view_list_tags ui-sortable"></div>');
list.append(tagContainer);
html.append(tagContainer);
const sortedTags = sortTags(tags);
for (const tag of sortedTags) {
appendViewTagToList(tagContainer, tag, everything);
}
callPopup(html, 'text', null, { allowVerticalScrolling: true });
printViewTagList();
makeTagListDraggable(tagContainer);
callPopup(list, 'text');
$('#dialogue_popup .tag-color').on('change', (evt) => onTagColorize(evt));
$('#dialogue_popup .tag-color2').on('change', (evt) => onTagColorize2(evt));
}
/**
* Print the list of tags in the tag management view
* @param {Event} event Event that triggered the color change
* @param {boolean} toggle State of the toggle
*/
function toggleAutoSortTags(event, toggle) {
if (toggle === power_user.auto_sort_tags) return;
// Ask user to confirm if enabling and it was manually sorted before
if (toggle && isManuallySorted() && !confirm('Are you sure you want to automatically sort alphabetically?')) {
if (event.target instanceof HTMLInputElement) {
event.target.checked = false;
}
return;
}
power_user.auto_sort_tags = toggle;
printCharactersDebounced();
saveSettingsDebounced();
}
/** This function goes over all existing tags and checks whether they were reorderd in the past. @returns {boolean} */
function isManuallySorted() {
return tags.some((tag, index) => tag.sort_order !== index);
}
function makeTagListDraggable(tagContainer) {
@ -1067,6 +1102,13 @@ function makeTagListDraggable(tagContainer) {
}
});
// If tags were dragged manually, we have to disable auto sorting
if (power_user.auto_sort_tags) {
power_user.auto_sort_tags = false;
$('#dialogue_popup input[name="auto_sort_tags"]').prop('checked', false);
toastr.info('Automatic sorting of tags deactivated.');
}
// If the order of tags in display has changed, we need to redraw some UI elements. Do it debounced so it doesn't block and you can drag multiple tags.
printCharactersDebounced();
saveSettingsDebounced();
@ -1098,6 +1140,11 @@ function sortTags(tags) {
* @returns {number} The compare result
*/
function compareTagsForSort(a, b) {
const defaultSort = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
if (power_user.auto_sort_tags) {
return defaultSort;
}
if (a.sort_order !== undefined && b.sort_order !== undefined) {
return a.sort_order - b.sort_order;
} else if (a.sort_order !== undefined) {
@ -1105,7 +1152,7 @@ function compareTagsForSort(a, b) {
} else if (b.sort_order !== undefined) {
return 1;
} else {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
return defaultSort;
}
}
@ -1208,7 +1255,10 @@ function onTagsBackupClick() {
function onTagCreateClick() {
const tag = createNewTag('New Tag');
appendViewTagToList($('#tag_view_list .tag_view_list_tags'), tag, []);
printViewTagList();
const tagElement = ($('#dialogue_popup .tag_view_list_tags')).find(`.tag_view_item[id="${tag.id}"]`);
flashHighlight(tagElement);
printCharactersDebounced();
saveSettingsDebounced();
@ -1248,18 +1298,6 @@ function appendViewTagToList(list, tag, everything) {
list.append(template);
setTimeout(function () {
document.querySelector(`.tag-color[id="${colorPickerId}"`).addEventListener('change', (evt) => {
onTagColorize(evt);
});
}, 100);
setTimeout(function () {
document.querySelector(`.tag-color2[id="${colorPicker2Id}"`).addEventListener('change', (evt) => {
onTagColorize2(evt);
});
}, 100);
updateDrawTagFolder(template, tag);
// @ts-ignore
@ -1394,6 +1432,17 @@ function copyTags(data) {
tag_map[data.newAvatar] = Array.from(new Set([...prevTagMap, ...newTagMap]));
}
function printViewTagList(empty = true) {
const tagContainer = $('#dialogue_popup .tag_view_list_tags');
if (empty) tagContainer.empty();
const everything = Object.values(tag_map).flat();
const sortedTags = sortTags(tags);
for (const tag of sortedTags) {
appendViewTagToList(tagContainer, tag, everything);
}
}
export function initTags() {
createTagInput('#tagInput', '#tagList', { tagOptions: { removable: true } });
createTagInput('#groupTagInput', '#groupTagList', { tagOptions: { removable: true } });
@ -1412,4 +1461,31 @@ export function initTags() {
$(document).on('click', '.tag_view_backup', onTagsBackupClick);
$(document).on('click', '.tag_view_restore', onBackupRestoreClick);
eventSource.on(event_types.CHARACTER_DUPLICATED, copyTags);
$(document).on('input', '#dialogue_popup input[name="auto_sort_tags"]', (evt) => {
const toggle = $(evt.target).is(':checked');
toggleAutoSortTags(evt.originalEvent, toggle);
printViewTagList();
});
$(document).on('focusout', `#dialogue_popup .tag_view_name`, (evt) => {
// Remember the order, so we can flash highlight if it changed after reprinting
const tagId = $(evt.target).parent('.tag_view_item').attr('id');
const oldOrder = $(`#dialogue_popup .tag_view_item`).map((_, el) => el.id).get();
printViewTagList();
const newOrder = $(`#dialogue_popup .tag_view_item`).map((_, el) => el.id).get();
const orderChanged = !oldOrder.every((id, index) => id === newOrder[index]);
if (orderChanged) {
flashHighlight($(`#dialogue_popup .tag_view_item[id="${tagId}"]`));
}
});
// Initialize auto sort setting based on whether it was sorted before
if (power_user.auto_sort_tags === undefined || power_user.auto_sort_tags === null) {
power_user.auto_sort_tags = !isManuallySorted();
if (power_user.auto_sort_tags) {
printCharactersDebounced();
}
}
}

View File

@ -1419,3 +1419,13 @@ export function setValueByPath(obj, path, value) {
currentObject[keyParts[keyParts.length - 1]] = value;
}
/**
* Flashes the given HTML element via CSS flash animation for a defined period
* @param {JQuery<HTMLElement>} element - The element to flash
* @param {number} timespan - A numer in milliseconds how the flash should last
*/
export function flashHighlight(element, timespan = 2000) {
element.addClass('flash animated');
setTimeout(() => element.removeClass('flash animated'), timespan);
}

View File

@ -1,5 +1,5 @@
import { saveSettings, callPopup, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js';
import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath } from './utils.js';
import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight } from './utils.js';
import { extension_settings, getContext } from './extensions.js';
import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
import { registerSlashCommand } from './slash-commands.js';
@ -82,6 +82,11 @@ class WorldInfoBuffer {
/** @typedef {{scanDepth?: number, caseSensitive?: boolean, matchWholeWords?: boolean}} WIScanEntry The entry that triggered the scan */
// End typedef area
/**
* @type {object[]} Array of entries that need to be activated no matter what
*/
static externalActivations = [];
/**
* @type {string[]} Array of messages sorted by ascending depth
*/
@ -220,6 +225,23 @@ class WorldInfoBuffer {
getDepth() {
return world_info_depth + this.#skew;
}
/**
* Check if the current entry is externally activated.
* @param {object} entry WI entry to check
* @returns {boolean} True if the entry is forcefully activated
*/
isExternallyActivated(entry) {
// Entries could be copied with structuredClone, so we need to compare them by string representation
return WorldInfoBuffer.externalActivations.some(x => JSON.stringify(x) === JSON.stringify(entry));
}
/**
* Clears the force activations buffer.
*/
cleanExternalActivations() {
WorldInfoBuffer.externalActivations.splice(0, WorldInfoBuffer.externalActivations.length);
}
}
export function getWorldInfoSettings() {
@ -362,6 +384,10 @@ function setWorldInfoSettings(settings, data) {
$('.chat_lorebook_button').toggleClass('world_set', hasWorldInfo);
});
eventSource.on(event_types.WORLDINFO_FORCE_ACTIVATE, (entries) => {
WorldInfoBuffer.externalActivations.push(...entries);
});
// Add slash commands
registerWorldInfoSlashCommands();
}
@ -496,7 +522,7 @@ function registerWorldInfoSlashCommands() {
return '';
}
const entry = createWorldInfoEntry(file, data, true);
const entry = createWorldInfoEntry(file, data);
if (key) {
entry.key.push(key);
@ -564,6 +590,7 @@ function registerWorldInfoSlashCommands() {
return '';
}
registerSlashCommand('world', onWorldInfoChange, [], '<span class="monospace">[optional state=off|toggle] [optional silent=true] (optional name)</span> sets active World, or unsets if no args provided, use <code>state=off</code> and <code>state=toggle</code> to deactivate or toggle a World, use <code>silent=true</code> to suppress toast messages', true, true);
registerSlashCommand('getchatbook', getChatBookCallback, ['getchatlore', 'getchatwi'], ' get a name of the chat-bound lorebook or create a new one if was unbound, and pass it down the pipe', true, true);
registerSlashCommand('findentry', findBookEntryCallback, ['findlore', 'findwi'], '<span class="monospace">(file=bookName field=field [texts])</span> find a UID of the record from the specified book using the fuzzy match of a field value (default: key) and pass it down the pipe, e.g. <tt>/findentry file=chatLore field=key Shadowfang</tt>', true, true);
registerSlashCommand('getentryfield', getEntryFieldCallback, ['getlorefield', 'getwifield'], '<span class="monospace">(file=bookName field=field [UID])</span> get a field value (default: content) of the record with the UID from the specified book and pass it down the pipe, e.g. <tt>/getentryfield file=chatLore field=content 123</tt>', true, true);
@ -768,20 +795,20 @@ function displayWorldEntries(name, data, navigation = navigation_option.none) {
callback: function (/** @type {object[]} */ page) {
$('#world_popup_entries_list').empty();
const keywordHeaders = `
<div id="WIEntryHeaderTitlesPC" class="flex-container wide100p spaceBetween justifyCenter textAlignCenter" style="padding:0 2.5em;">
<div id="WIEntryHeaderTitlesPC" class="flex-container wide100p spaceBetween justifyCenter textAlignCenter" style="padding:0 4.5em;">
<small class="flex1">
Title/Memo
</small>
<small style="width: calc(3.5em + 5px)">
<small style="width: calc(3.5em + 15px)">
Status
</small>
<small style="width: calc(3.5em + 20px)">
<small style="width: calc(3.5em + 30px)">
Position
</small>
<small style="width: calc(3.5em + 15px)">
<small style="width: calc(3.5em + 20px)">
Depth
</small>
<small style="width: calc(3.5em + 15px)">
<small style="width: calc(3.5em + 20px)">
Order
</small>
<small style="width: calc(3.5em + 15px)">
@ -827,13 +854,13 @@ function displayWorldEntries(name, data, navigation = navigation_option.none) {
const parentOffset = element.parent().offset();
const scrollOffset = elementOffset.top - parentOffset.top;
$('#WorldInfo').scrollTop(scrollOffset);
element.addClass('flash animated');
setTimeout(() => element.removeClass('flash animated'), 2000);
flashHighlight(element);
});
}
$('#world_popup_new').off('click').on('click', () => {
createWorldInfoEntry(name, data);
const entry = createWorldInfoEntry(name, data);
if (entry) updateEditor(entry.uid);
});
$('#world_popup_name_button').off('click').on('click', async () => {
@ -964,6 +991,8 @@ const originalDataKeyMap = {
'caseSensitive': 'extensions.case_sensitive',
'scanDepth': 'extensions.scan_depth',
'automationId': 'extensions.automation_id',
'vectorized': 'extensions.vectorized',
'groupOverride': 'extensions.group_override',
};
function setOriginalDataValue(data, uid, key, value) {
@ -1071,6 +1100,16 @@ function getWorldEntry(name, data, entry) {
);
}
// Verify names to exist in the system
if (data.entries[uid]?.characterFilter?.names?.length > 0) {
for (const name of [...data.entries[uid].characterFilter.names]) {
if (!getContext().characters.find(x => x.avatar.replace(/\.[^/.]+$/, '') === name)) {
console.warn(`World Info: Character ${name} not found. Removing from the entry filter.`, entry);
data.entries[uid].characterFilter.names = data.entries[uid].characterFilter.names.filter(x => x !== name);
}
}
}
setOriginalDataValue(data, uid, 'character_filter', data.entries[uid].characterFilter);
saveWorldInfo(name, data);
});
@ -1300,6 +1339,18 @@ function getWorldEntry(name, data, entry) {
groupInput.val(entry.group ?? '').trigger('input');
setTimeout(() => createEntryInputAutocomplete(groupInput, getInclusionGroupCallback(data)), 1);
// inclusion priority
const groupOverrideInput = template.find('input[name="groupOverride"]');
groupOverrideInput.data('uid', entry.uid);
groupOverrideInput.on('input', function () {
const uid = $(this).data('uid');
const value = $(this).prop('checked');
data.entries[uid].groupOverride = value;
setOriginalDataValue(data, uid, 'extensions.groupOverride', data.entries[uid].groupOverride);
saveWorldInfo(name, data);
});
groupOverrideInput.prop('checked', entry.groupOverride).trigger('input');
// probability
if (entry.probability === undefined) {
entry.probability = null;
@ -1454,22 +1505,37 @@ function getWorldEntry(name, data, entry) {
case 'constant':
data.entries[uid].constant = true;
data.entries[uid].disable = false;
data.entries[uid].vectorized = false;
setOriginalDataValue(data, uid, 'enabled', true);
setOriginalDataValue(data, uid, 'constant', true);
setOriginalDataValue(data, uid, 'extensions.vectorized', false);
template.removeClass('disabledWIEntry');
break;
case 'normal':
data.entries[uid].constant = false;
data.entries[uid].disable = false;
data.entries[uid].vectorized = false;
setOriginalDataValue(data, uid, 'enabled', true);
setOriginalDataValue(data, uid, 'constant', false);
setOriginalDataValue(data, uid, 'extensions.vectorized', false);
template.removeClass('disabledWIEntry');
break;
case 'vectorized':
data.entries[uid].constant = false;
data.entries[uid].disable = false;
data.entries[uid].vectorized = true;
setOriginalDataValue(data, uid, 'enabled', true);
setOriginalDataValue(data, uid, 'constant', false);
setOriginalDataValue(data, uid, 'extensions.vectorized', true);
template.removeClass('disabledWIEntry');
break;
case 'disabled':
data.entries[uid].constant = false;
data.entries[uid].disable = true;
data.entries[uid].vectorized = false;
setOriginalDataValue(data, uid, 'enabled', false);
setOriginalDataValue(data, uid, 'constant', false);
setOriginalDataValue(data, uid, 'extensions.vectorized', false);
template.addClass('disabledWIEntry');
break;
}
@ -1480,6 +1546,8 @@ function getWorldEntry(name, data, entry) {
const entryState = function () {
if (entry.constant === true) {
return 'constant';
} else if (entry.vectorized === true) {
return 'vectorized';
} else if (entry.disable === true) {
return 'disabled';
} else {
@ -1517,6 +1585,18 @@ function getWorldEntry(name, data, entry) {
});
preventRecursionInput.prop('checked', entry.preventRecursion).trigger('input');
// duplicate button
const duplicateButton = template.find('.duplicate_entry_button');
duplicateButton.data('uid', entry.uid);
duplicateButton.on('click', function () {
const uid = $(this).data('uid');
const entry = duplicateWorldInfoEntry(data, uid);
if (entry) {
saveWorldInfo(name, data);
updateEditor(entry.uid);
}
});
// delete button
const deleteButton = template.find('.delete_entry_button');
deleteButton.data('uid', entry.uid);
@ -1701,7 +1781,33 @@ function createEntryInputAutocomplete(input, callback) {
});
}
async function deleteWorldInfoEntry(data, uid) {
/**
* Duplicated a WI entry by copying all of its properties and assigning a new uid
* @param {*} data - The data of the book
* @param {number} uid - The uid of the entry to copy in this book
* @returns {*} The new WI duplicated entry
*/
function duplicateWorldInfoEntry(data, uid) {
if (!data || !('entries' in data) || !data.entries[uid]) {
return;
}
// Exclude uid and gather the rest of the properties
const { uid: _, ...originalData } = data.entries[uid];
// Create new entry and copy over data
const entry = createWorldInfoEntry(data.name, data);
Object.assign(entry, originalData);
return entry;
}
/**
* Deletes a WI entry, with a user confirmation dialog
* @param {*[]} data - The data of the book
* @param {number} uid - The uid of the entry to copy in this book
*/
function deleteWorldInfoEntry(data, uid) {
if (!data || !('entries' in data)) {
return;
}
@ -1719,6 +1825,7 @@ const newEntryTemplate = {
comment: '',
content: '',
constant: false,
vectorized: false,
selective: true,
selectiveLogic: world_info_logic.AND_ANY,
addMemo: false,
@ -1730,6 +1837,7 @@ const newEntryTemplate = {
useProbability: true,
depth: DEFAULT_DEPTH,
group: '',
groupOverride: false,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
@ -1737,7 +1845,7 @@ const newEntryTemplate = {
role: 0,
};
function createWorldInfoEntry(name, data, fromSlashCommand = false) {
function createWorldInfoEntry(name, data) {
const newUid = getFreeWorldEntryUid(data);
if (!Number.isInteger(newUid)) {
@ -1748,10 +1856,6 @@ function createWorldInfoEntry(name, data, fromSlashCommand = false) {
const newEntry = { uid: newUid, ...structuredClone(newEntryTemplate) };
data.entries[newUid] = newEntry;
if (!fromSlashCommand) {
updateEditor(newUid);
}
return newEntry;
}
@ -1925,7 +2029,7 @@ async function getCharacterLore() {
}
const data = await loadWorldInfoData(worldName);
const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]) : [];
const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(x => ({ ...x, world: worldName })) : [];
entries = entries.concat(newEntries);
}
@ -1941,7 +2045,7 @@ async function getGlobalLore() {
let entries = [];
for (const worldName of selected_world_info) {
const data = await loadWorldInfoData(worldName);
const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]) : [];
const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(x => ({ ...x, world: worldName })) : [];
entries = entries.concat(newEntries);
}
@ -1963,14 +2067,14 @@ async function getChatLore() {
}
const data = await loadWorldInfoData(chatWorld);
const entries = data ? Object.keys(data.entries).map((x) => data.entries[x]) : [];
const entries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(x => ({ ...x, world: chatWorld })) : [];
console.debug(`Chat lore has ${entries.length} entries`);
return entries;
}
async function getSortedEntries() {
export async function getSortedEntries() {
try {
const globalLore = await getGlobalLore();
const characterLore = await getCharacterLore();
@ -2098,7 +2202,7 @@ async function checkWorldInfo(chat, maxContext) {
continue;
}
if (entry.constant) {
if (entry.constant || buffer.isExternallyActivated(entry)) {
entry.content = substituteParams(entry.content);
activatedNow.add(entry);
continue;
@ -2186,7 +2290,7 @@ async function checkWorldInfo(chat, maxContext) {
for (const entry of newEntries) {
const rollValue = Math.random() * 100;
if (entry.useProbability && rollValue > entry.probability) {
if (!entry.group && entry.useProbability && rollValue > entry.probability) {
console.debug(`WI entry ${entry.uid} ${entry.key} failed probability check, skipping`);
failedProbabilityChecks.add(entry);
continue;
@ -2295,6 +2399,8 @@ async function checkWorldInfo(chat, maxContext) {
context.setExtensionPrompt(NOTE_MODULE_NAME, ANWithWI, chat_metadata[metadata_keys.position], chat_metadata[metadata_keys.depth], extension_settings.note.allowWIScan, chat_metadata[metadata_keys.role]);
}
buffer.cleanExternalActivations();
return { worldInfoBefore, worldInfoAfter, WIDepthEntries, allActivatedEntries };
}
@ -2318,15 +2424,25 @@ function filterByInclusionGroups(newEntries, allActivatedEntries) {
return;
}
const removeEntry = (entry) => newEntries.splice(newEntries.indexOf(entry), 1);
function removeAllBut(group, chosen, logging = true) {
for (const entry of group) {
if (entry === chosen) {
continue;
}
if (logging) console.debug(`Removing loser from inclusion group '${entry.group}' entry '${entry.uid}'`, entry);
removeEntry(entry);
}
}
for (const [key, group] of Object.entries(grouped)) {
console.debug(`Checking inclusion group '${key}' with ${group.length} entries`, group);
if (Array.from(allActivatedEntries).some(x => x.group === key)) {
console.debug(`Skipping inclusion group check, group already activated '${key}'`);
// We need to forcefully deactivate all other entries in the group
for (const entry of group) {
newEntries.splice(newEntries.indexOf(entry), 1);
}
removeAllBut(group, null, false);
continue;
}
@ -2335,6 +2451,14 @@ function filterByInclusionGroups(newEntries, allActivatedEntries) {
continue;
}
// Check for group prio
const prios = group.filter(x => x.groupOverride).sort(sortFn);
if (prios.length) {
console.debug(`Activated inclusion group '${key}' with by prio winner entry '${prios[0].uid}'`, prios[0]);
removeAllBut(group, prios[0]);
continue;
}
// Do weighted random using probability of entry as weight
const totalWeight = group.reduce((acc, item) => acc + item.probability, 0);
const rollValue = Math.random() * totalWeight;
@ -2345,7 +2469,7 @@ function filterByInclusionGroups(newEntries, allActivatedEntries) {
currentWeight += entry.probability;
if (rollValue <= currentWeight) {
console.debug(`Activated inclusion group '${key}' with entry '${entry.uid}'`, entry);
console.debug(`Activated inclusion group '${key}' with roll winner entry '${entry.uid}'`, entry);
winner = entry;
break;
}
@ -2357,14 +2481,7 @@ function filterByInclusionGroups(newEntries, allActivatedEntries) {
}
// Remove every group item from newEntries but the winner
for (const entry of group) {
if (entry === winner) {
continue;
}
console.debug(`Removing loser from inclusion group '${key}' entry '${entry.uid}'`, entry);
newEntries.splice(newEntries.indexOf(entry), 1);
}
removeAllBut(group, winner);
}
}
@ -2381,6 +2498,7 @@ function convertAgnaiMemoryBook(inputObj) {
content: entry.entry,
constant: false,
selective: false,
vectorized: false,
selectiveLogic: world_info_logic.AND_ANY,
order: entry.weight,
position: 0,
@ -2391,6 +2509,7 @@ function convertAgnaiMemoryBook(inputObj) {
probability: null,
useProbability: false,
group: '',
groupOverride: false,
scanDepth: entry.extensions?.scan_depth ?? null,
caseSensitive: entry.extensions?.case_sensitive ?? null,
matchWholeWords: entry.extensions?.match_whole_words ?? null,
@ -2415,6 +2534,7 @@ function convertRisuLorebook(inputObj) {
content: entry.content,
constant: entry.alwaysActive,
selective: entry.selective,
vectorized: false,
selectiveLogic: world_info_logic.AND_ANY,
order: entry.insertorder,
position: world_info_position.before,
@ -2425,6 +2545,7 @@ function convertRisuLorebook(inputObj) {
probability: entry.activationPercent ?? null,
useProbability: entry.activationPercent ?? false,
group: '',
groupOverride: false,
scanDepth: entry.extensions?.scan_depth ?? null,
caseSensitive: entry.extensions?.case_sensitive ?? null,
matchWholeWords: entry.extensions?.match_whole_words ?? null,
@ -2454,6 +2575,7 @@ function convertNovelLorebook(inputObj) {
content: entry.text,
constant: false,
selective: false,
vectorized: false,
selectiveLogic: world_info_logic.AND_ANY,
order: entry.contextConfig?.budgetPriority ?? 0,
position: 0,
@ -2464,6 +2586,7 @@ function convertNovelLorebook(inputObj) {
probability: null,
useProbability: false,
group: '',
groupOverride: false,
scanDepth: entry.extensions?.scan_depth ?? null,
caseSensitive: entry.extensions?.case_sensitive ?? null,
matchWholeWords: entry.extensions?.match_whole_words ?? null,
@ -2505,11 +2628,13 @@ function convertCharacterBook(characterBook) {
depth: entry.extensions?.depth ?? DEFAULT_DEPTH,
selectiveLogic: entry.extensions?.selectiveLogic ?? world_info_logic.AND_ANY,
group: entry.extensions?.group ?? '',
groupOverride: entry.extensions?.group_override ?? false,
scanDepth: entry.extensions?.scan_depth ?? null,
caseSensitive: entry.extensions?.case_sensitive ?? null,
matchWholeWords: entry.extensions?.match_whole_words ?? null,
automationId: entry.extensions?.automation_id ?? '',
role: entry.extensions?.role ?? extension_prompt_roles.SYSTEM,
vectorized: entry.extensions?.vectorized ?? false,
};
});
@ -2785,11 +2910,6 @@ function assignLorebookToChat() {
jQuery(() => {
$(document).ready(function () {
registerSlashCommand('world', onWorldInfoChange, [], '<span class="monospace">[optional state=off|toggle] [optional silent=true] (optional name)</span> sets active World, or unsets if no args provided, use <code>state=off</code> and <code>state=toggle</code> to deactivate or toggle a World, use <code>silent=true</code> to suppress toast messages', true, true);
});
$('#world_info').on('mousedown change', async function (e) {
// If there's no world names, don't do anything
if (world_names.length === 0) {

View File

@ -1000,6 +1000,7 @@ body .panelControlBar {
padding-left: 10px;
width: 100%;
overflow-x: hidden;
overflow-y: clip;
}
.mes_text {
@ -2262,6 +2263,11 @@ grammarly-extension {
}
}
.dialogue_popup.dragover {
filter: brightness(1.1) saturate(1.1);
outline: 3px dashed var(--SmartThemeBorderColor);
}
#bgtest {
display: none;
width: 100vw;
@ -3159,6 +3165,15 @@ body.big-avatars .missing-avatar {
text-align: center;
}
.userSettingsInnerExpandable {
border: 1px solid;
border-color: var(--SmartThemeBorderColor);
border-radius: 5px;
padding: 2px 5px !important;
margin: 5px 0;
}
@keyframes ellipsis {
0% {
content: ""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -45,7 +45,6 @@ const {
forwardFetchResponse,
} = require('./src/util');
const { ensureThumbnailCache } = require('./src/endpoints/thumbnails');
const { loadTokenizers } = require('./src/endpoints/tokenizers');
// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
@ -263,10 +262,14 @@ app.get('/login', async (request, response) => {
return response.redirect('/');
}
const autoLogin = await userModule.tryAutoLogin(request);
try {
const autoLogin = await userModule.tryAutoLogin(request);
if (autoLogin) {
return response.redirect('/');
if (autoLogin) {
return response.redirect('/');
}
} catch (error) {
console.error('Error during auto-login:', error);
}
return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') });
@ -283,6 +286,7 @@ app.use(userModule.requireLoginMiddleware);
// File uploads
app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
app.use(require('./src/middleware/multerMonkeyPatch'));
// User data mount
app.use('/', userModule.router);
@ -526,7 +530,10 @@ const autorunUrl = new URL(
(':' + server_port),
);
const setupTasks = async function () {
/**
* Tasks that need to be run before the server starts listening.
*/
const preSetupTasks = async function () {
const version = await getVersion();
// Print formatted header
@ -539,28 +546,21 @@ const setupTasks = async function () {
}
console.log();
// TODO: do endpoint init functions depend on certain directories existing or not existing? They should be callable
// in any order for encapsulation reasons, but right now it's unknown if that would break anything.
await userModule.initUserStorage(dataRoot);
if (listen && !basicAuthMode && enableAccounts) {
await userModule.checkAccountsProtection();
}
await settingsEndpoint.init();
const directories = await userModule.ensurePublicDirectoriesExist();
await userModule.migrateUserData();
const directories = await userModule.getUserDirectoriesList();
await contentManager.checkForNewContent(directories);
await ensureThumbnailCache();
cleanUploads();
await loadTokenizers();
await settingsEndpoint.init();
await statsEndpoint.init();
const cleanupPlugins = await loadPlugins();
const consoleTitle = process.title;
let isExiting = false;
const exitProcess = async () => {
if (isExiting) return;
isExiting = true;
statsEndpoint.onExit();
if (typeof cleanupPlugins === 'function') {
await cleanupPlugins();
@ -576,8 +576,12 @@ const setupTasks = async function () {
console.error('Uncaught exception:', err);
exitProcess();
});
};
/**
* Tasks that need to be run after the server starts listening.
*/
const postSetupTasks = async function () {
console.log('Launching...');
if (autorun) open(autorunUrl.toString());
@ -597,6 +601,9 @@ const setupTasks = async function () {
}
}
if (listen && !basicAuthMode && enableAccounts) {
await userModule.checkAccountsProtection();
}
};
/**
@ -638,21 +645,28 @@ function setWindowTitle(title) {
}
}
if (cliArguments.ssl) {
https.createServer(
{
cert: fs.readFileSync(cliArguments.certPath),
key: fs.readFileSync(cliArguments.keyPath),
}, app)
.listen(
Number(tavernUrl.port) || 443,
tavernUrl.hostname,
setupTasks,
);
} else {
http.createServer(app).listen(
Number(tavernUrl.port) || 80,
tavernUrl.hostname,
setupTasks,
);
}
// User storage module needs to be initialized before starting the server
userModule.initUserStorage(dataRoot)
.then(userModule.ensurePublicDirectoriesExist)
.then(userModule.migrateUserData)
.then(preSetupTasks)
.finally(() => {
if (cliArguments.ssl) {
https.createServer(
{
cert: fs.readFileSync(cliArguments.certPath),
key: fs.readFileSync(cliArguments.keyPath),
}, app)
.listen(
Number(tavernUrl.port) || 443,
tavernUrl.hostname,
postSetupTasks,
);
} else {
http.createServer(app).listen(
Number(tavernUrl.port) || 80,
tavernUrl.hostname,
postSetupTasks,
);
}
});

View File

@ -56,6 +56,8 @@ function validateAssetFileName(inputFilename) {
* @returns {string[]} - The array of files
*/
function getFiles(dir, files = []) {
if (!fs.existsSync(dir)) return files;
// Get an array of all files and directories in the passed directory using fs.readdirSync
const fileList = fs.readdirSync(dir, { withFileTypes: true });
// Create the full path of the file/directory by concatenating the passed directory and file/directory name

View File

@ -325,7 +325,7 @@ router.post('/generate', jsonParser, async function (request, response) {
// Map InfermaticAI response to OAI completions format
if (apiType === TEXTGEN_TYPES.INFERMATICAI) {
data['choices'] = (data?.choices || []).map(choice => ({ text: choice.message.content }));
data['choices'] = (data?.choices || []).map(choice => ({ text: choice?.message?.content || choice.text }));
}
return response.send(data);

View File

@ -433,12 +433,14 @@ function convertWorldInfoToCharacterBook(name, entries) {
depth: entry.depth ?? 4,
selectiveLogic: entry.selectiveLogic ?? 0,
group: entry.group ?? '',
group_override: entry.groupOverride ?? false,
prevent_recursion: entry.preventRecursion ?? false,
scan_depth: entry.scanDepth ?? null,
match_whole_words: entry.matchWholeWords ?? null,
case_sensitive: entry.caseSensitive ?? null,
automation_id: entry.automationId ?? '',
role: entry.role ?? 0,
vectorized: entry.vectorized ?? false,
},
};
@ -1097,7 +1099,7 @@ router.post('/export', jsonParser, async function (request, response) {
const fileContent = await fsPromises.readFile(filename);
const contentType = mime.lookup(filename) || 'image/png';
response.setHeader('Content-Type', contentType);
response.setHeader('Content-Disposition', `attachment; filename=${path.basename(filename)}`);
response.setHeader('Content-Disposition', `attachment; filename="${encodeURI(path.basename(filename))}"`);
return response.send(fileContent);
}
case 'json': {

View File

@ -5,7 +5,10 @@ const TASK = 'text-classification';
const router = express.Router();
const cacheObject = {};
/**
* @type {Map<string, object>} Cache for classification results
*/
const cacheObject = new Map();
router.post('/labels', jsonParser, async (req, res) => {
try {
@ -23,15 +26,20 @@ router.post('/', jsonParser, async (req, res) => {
try {
const { text } = req.body;
/**
* Get classification result for a given text
* @param {string} text Text to classify
* @returns {Promise<object>} Classification result
*/
async function getResult(text) {
if (Object.hasOwn(cacheObject, text)) {
return cacheObject[text];
if (cacheObject.has(text)) {
return cacheObject.get(text);
} else {
const module = await import('../transformers.mjs');
const pipe = await module.default.getPipeline(TASK);
const result = await pipe(text, { topk: 5 });
result.sort((a, b) => b.score - a.score);
cacheObject[text] = result;
cacheObject.set(text, result);
return result;
}
}

View File

@ -5,6 +5,7 @@ const fetch = require('node-fetch').default;
const sanitize = require('sanitize-filename');
const { getConfigValue } = require('../util');
const { jsonParser } = require('../express-common');
const writeFileAtomicSync = require('write-file-atomic').sync;
const contentDirectory = path.join(process.cwd(), 'default/content');
const contentIndexPath = path.join(contentDirectory, 'index.json');
const characterCardParser = require('../character-card-parser.js');
@ -133,7 +134,7 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
console.log(`Content file ${contentItem.filename} copied to ${contentTarget}`);
}
fs.writeFileSync(contentLogPath, contentLog.join('\n'));
writeFileAtomicSync(contentLogPath, contentLog.join('\n'));
}
/**
@ -386,6 +387,45 @@ async function downloadJannyCharacter(uuid) {
throw new Error('Failed to download character');
}
//Download Character Cards from AICharactersCards.com (AICC) API.
async function downloadAICCCharacter(id) {
const apiURL = `https://aicharactercards.com/wp-json/pngapi/v1/image/${id}`;
try {
const response = await fetch(apiURL);
if (!response.ok) {
throw new Error(`Failed to download character: ${response.statusText}`);
}
const contentType = response.headers.get('content-type') || 'image/png'; // Default to 'image/png' if header is missing
const buffer = await response.buffer();
const fileName = `${sanitize(id)}.png`; // Assuming PNG, but adjust based on actual content or headers
return {
buffer: buffer,
fileName: fileName,
fileType: contentType,
};
} catch (error) {
console.error('Error downloading character:', error);
throw error;
}
}
/**
* Parses an aicharactercards URL to extract the path.
* @param {string} url URL to parse
* @returns {string | null} AICC path
*/
function parseAICC(url) {
const pattern = /^https?:\/\/aicharactercards\.com\/character-cards\/([^/]+)\/([^/]+)\/?$|([^/]+)\/([^/]+)$/;
const match = url.match(pattern);
if (match) {
// Match group 1 & 2 for full URL, 3 & 4 for relative path
return match[1] && match[2] ? `${match[1]}/${match[2]}` : `${match[3]}/${match[4]}`;
}
return null;
}
/**
* @param {String} url
* @returns {String | null } UUID of the character
@ -414,6 +454,7 @@ router.post('/importURL', jsonParser, async (request, response) => {
const isJannnyContent = url.includes('janitorai');
const isPygmalionContent = url.includes('pygmalion.chat');
const isAICharacterCardsContent = url.includes('aicharactercards.com');
if (isPygmalionContent) {
const uuid = getUuidFromUrl(url);
@ -431,6 +472,13 @@ router.post('/importURL', jsonParser, async (request, response) => {
type = 'character';
result = await downloadJannyCharacter(uuid);
} else if (isAICharacterCardsContent) {
const AICCParsed = parseAICC(url);
if (!AICCParsed) {
return response.sendStatus(404);
}
type = 'character';
result = await downloadAICCCharacter(AICCParsed);
} else {
const chubParsed = parseChubUrl(url);
type = chubParsed?.type;
@ -469,6 +517,7 @@ router.post('/importUUID', jsonParser, async (request, response) => {
const isJannny = uuid.includes('_character');
const isPygmalion = (!isJannny && uuid.length == 36);
const isAICC = uuid.startsWith('AICC/');
const uuidType = uuid.includes('lorebook') ? 'lorebook' : 'character';
if (isPygmalion) {
@ -477,6 +526,10 @@ router.post('/importUUID', jsonParser, async (request, response) => {
} else if (isJannny) {
console.log('Downloading Janitor character:', uuid.split('_')[0]);
result = await downloadJannyCharacter(uuid.split('_')[0]);
} else if (isAICC) {
const [, author, card] = uuid.split('/');
console.log('Downloading AICC character:', `${author}/${card}`);
result = await downloadAICCCharacter(`${author}/${card}`);
} else {
if (uuidType === 'character') {
console.log('Downloading chub character:', uuid);

View File

@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
const express = require('express');
const _ = require('lodash');
const writeFileAtomicSync = require('write-file-atomic').sync;
const { PUBLIC_DIRECTORIES, SETTINGS_FILE } = require('../constants');
const { getConfigValue, generateTimestamp, removeOldBackups } = require('../util');
@ -10,6 +11,32 @@ const { getAllUserHandles, getUserDirectories } = require('../users');
const ENABLE_EXTENSIONS = getConfigValue('enableExtensions', true);
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
// 10 minutes
const AUTOSAVE_INTERVAL = 10 * 60 * 1000;
/**
* Map of functions to trigger settings autosave for a user.
* @type {Map<string, function>}
*/
const AUTOSAVE_FUNCTIONS = new Map();
/**
* Triggers autosave for a user every 10 minutes.
* @param {string} handle User handle
* @returns {void}
*/
function triggerAutoSave(handle) {
if (!AUTOSAVE_FUNCTIONS.has(handle)) {
const throttledAutoSave = _.throttle(() => backupUserSettings(handle), AUTOSAVE_INTERVAL);
AUTOSAVE_FUNCTIONS.set(handle, throttledAutoSave);
}
const functionToCall = AUTOSAVE_FUNCTIONS.get(handle);
if (functionToCall) {
functionToCall();
}
}
/**
* Reads and parses files from a directory.
* @param {string} directoryPath Path to the directory
@ -121,6 +148,7 @@ router.post('/save', jsonParser, function (request, response) {
try {
const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE);
writeFileAtomicSync(pathToSettings, JSON.stringify(request.body, null, 4), 'utf8');
triggerAutoSave(request.user.profile.handle);
response.send({ result: 'ok' });
} catch (err) {
console.log(err);

View File

@ -10,6 +10,10 @@ const { getAllUserHandles, getUserDirectories } = require('../users');
const { getConfigValue } = require('../util');
const { jsonParser } = require('../express-common');
const thumbnailsDisabled = getConfigValue('disableThumbnails', false);
const quality = getConfigValue('thumbnailsQuality', 95);
const pngFormat = getConfigValue('avatarThumbnailsPng', false);
/**
* Gets a path to thumbnail folder based on the type.
* @param {import('../users').UserDirectoryList} directories User directories
@ -115,9 +119,8 @@ async function generateThumbnail(directories, type, file) {
let buffer;
try {
const quality = getConfigValue('thumbnailsQuality', 95);
const image = await jimp.read(pathToOriginalFile);
const imgType = type == 'avatar' && getConfigValue('avatarThumbnailsPng', false) ? 'image/png' : 'image/jpeg';
const imgType = type == 'avatar' && pngFormat ? 'image/png' : 'image/jpeg';
buffer = await image.cover(mySize[0], mySize[1]).quality(quality).getBufferAsync(imgType);
}
catch (inner) {
@ -188,7 +191,6 @@ router.get('/', jsonParser, async function (request, response) {
return response.sendStatus(403);
}
const thumbnailsDisabled = getConfigValue('disableThumbnails', false);
if (thumbnailsDisabled) {
const folder = getOriginalFolder(request.user.directories, type);

View File

@ -10,6 +10,10 @@ const { TEXTGEN_TYPES } = require('../constants');
const { jsonParser } = require('../express-common');
const { setAdditionalHeaders } = require('../additional-headers');
/**
* @typedef { (req: import('express').Request, res: import('express').Response) => Promise<any> } TokenizationHandler
*/
/**
* @type {{[key: string]: import("@dqbd/tiktoken").Tiktoken}} Tokenizers cache
*/
@ -48,16 +52,30 @@ const TEXT_COMPLETION_MODELS = [
const CHARS_PER_TOKEN = 3.35;
/**
* Sentencepiece tokenizer for tokenizing text.
*/
class SentencePieceTokenizer {
/**
* @type {import('@agnai/sentencepiece-js').SentencePieceProcessor} Sentencepiece tokenizer instance
*/
#instance;
/**
* @type {string} Path to the tokenizer model
*/
#model;
/**
* Creates a new Sentencepiece tokenizer.
* @param {string} model Path to the tokenizer model
*/
constructor(model) {
this.#model = model;
}
/**
* Gets the Sentencepiece tokenizer instance.
* @returns {Promise<import('@agnai/sentencepiece-js').SentencePieceProcessor|null>} Sentencepiece tokenizer instance
*/
async get() {
if (this.#instance) {
@ -76,18 +94,61 @@ class SentencePieceTokenizer {
}
}
const spp_llama = new SentencePieceTokenizer('src/sentencepiece/llama.model');
const spp_nerd = new SentencePieceTokenizer('src/sentencepiece/nerdstash.model');
const spp_nerd_v2 = new SentencePieceTokenizer('src/sentencepiece/nerdstash_v2.model');
const spp_mistral = new SentencePieceTokenizer('src/sentencepiece/mistral.model');
const spp_yi = new SentencePieceTokenizer('src/sentencepiece/yi.model');
let claude_tokenizer;
/**
* Web tokenizer for tokenizing text.
*/
class WebTokenizer {
/**
* @type {Tokenizer} Web tokenizer instance
*/
#instance;
/**
* @type {string} Path to the tokenizer model
*/
#model;
/**
* Creates a new Web tokenizer.
* @param {string} model Path to the tokenizer model
*/
constructor(model) {
this.#model = model;
}
/**
* Gets the Web tokenizer instance.
* @returns {Promise<Tokenizer|null>} Web tokenizer instance
*/
async get() {
if (this.#instance) {
return this.#instance;
}
try {
const arrayBuffer = fs.readFileSync(this.#model).buffer;
this.#instance = await Tokenizer.fromJSON(arrayBuffer);
console.log('Instantiated the tokenizer for', path.parse(this.#model).name);
return this.#instance;
} catch (error) {
console.error('Web tokenizer failed to load: ' + this.#model, error);
return null;
}
}
}
const spp_llama = new SentencePieceTokenizer('src/tokenizers/llama.model');
const spp_nerd = new SentencePieceTokenizer('src/tokenizers/nerdstash.model');
const spp_nerd_v2 = new SentencePieceTokenizer('src/tokenizers/nerdstash_v2.model');
const spp_mistral = new SentencePieceTokenizer('src/tokenizers/mistral.model');
const spp_yi = new SentencePieceTokenizer('src/tokenizers/yi.model');
const claude_tokenizer = new WebTokenizer('src/tokenizers/claude.json');
const sentencepieceTokenizers = [
'llama',
'nerdstash',
'nerdstash_v2',
'mistral',
'yi',
];
/**
@ -112,6 +173,10 @@ function getSentencepiceTokenizer(model) {
return spp_nerd_v2;
}
if (model.includes('yi')) {
return spp_yi;
}
return null;
}
@ -168,13 +233,23 @@ async function getTiktokenChunks(tokenizer, ids) {
return chunks;
}
async function getWebTokenizersChunks(tokenizer, ids) {
/**
* Gets the token chunks for the given token IDs using the Web tokenizer.
* @param {Tokenizer} tokenizer Web tokenizer instance
* @param {number[]} ids Token IDs
* @returns {string[]} Token chunks
*/
function getWebTokenizersChunks(tokenizer, ids) {
const chunks = [];
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
const chunkText = await tokenizer.decode(new Uint32Array([id]));
for (let i = 0, lastProcessed = 0; i < ids.length; i++) {
const chunkIds = ids.slice(lastProcessed, i + 1);
const chunkText = tokenizer.decode(new Int32Array(chunkIds));
if (chunkText === '<27>') {
continue;
}
chunks.push(chunkText);
lastProcessed = i + 1;
}
return chunks;
@ -237,17 +312,12 @@ function getTiktokenTokenizer(model) {
return tokenizer;
}
async function loadClaudeTokenizer(modelPath) {
try {
const arrayBuffer = fs.readFileSync(modelPath).buffer;
const instance = await Tokenizer.fromJSON(arrayBuffer);
return instance;
} catch (error) {
console.error('Claude tokenizer failed to load: ' + modelPath, error);
return null;
}
}
/**
* Counts the tokens for the given messages using the Claude tokenizer.
* @param {Tokenizer} tokenizer Web tokenizer
* @param {object[]} messages Array of messages
* @returns {number} Number of tokens
*/
function countClaudeTokens(tokenizer, messages) {
// Should be fine if we use the old conversion method instead of the messages API one i think?
const convertedPrompt = convertClaudePrompt(messages, false, '', false, false, '', false);
@ -264,9 +334,14 @@ function countClaudeTokens(tokenizer, messages) {
/**
* Creates an API handler for encoding Sentencepiece tokens.
* @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
* @returns {any} Handler function
* @returns {TokenizationHandler} Handler function
*/
function createSentencepieceEncodingHandler(tokenizer) {
/**
* Request handler for encoding Sentencepiece tokens.
* @param {import('express').Request} request
* @param {import('express').Response} response
*/
return async function (request, response) {
try {
if (!request.body) {
@ -276,7 +351,7 @@ function createSentencepieceEncodingHandler(tokenizer) {
const text = request.body.text || '';
const instance = await tokenizer?.get();
const { ids, count } = await countSentencepieceTokens(tokenizer, text);
const chunks = await instance?.encodePieces(text);
const chunks = instance?.encodePieces(text);
return response.send({ ids, count, chunks });
} catch (error) {
console.log(error);
@ -288,9 +363,14 @@ function createSentencepieceEncodingHandler(tokenizer) {
/**
* Creates an API handler for decoding Sentencepiece tokens.
* @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
* @returns {any} Handler function
* @returns {TokenizationHandler} Handler function
*/
function createSentencepieceDecodingHandler(tokenizer) {
/**
* Request handler for decoding Sentencepiece tokens.
* @param {import('express').Request} request
* @param {import('express').Response} response
*/
return async function (request, response) {
try {
if (!request.body) {
@ -299,6 +379,7 @@ function createSentencepieceDecodingHandler(tokenizer) {
const ids = request.body.ids || [];
const instance = await tokenizer?.get();
if (!instance) throw new Error('Failed to load the Sentencepiece tokenizer');
const ops = ids.map(id => instance.decodeIds([id]));
const chunks = await Promise.all(ops);
const text = chunks.join('');
@ -313,9 +394,14 @@ function createSentencepieceDecodingHandler(tokenizer) {
/**
* Creates an API handler for encoding Tiktoken tokens.
* @param {string} modelId Tiktoken model ID
* @returns {any} Handler function
* @returns {TokenizationHandler} Handler function
*/
function createTiktokenEncodingHandler(modelId) {
/**
* Request handler for encoding Tiktoken tokens.
* @param {import('express').Request} request
* @param {import('express').Response} response
*/
return async function (request, response) {
try {
if (!request.body) {
@ -337,9 +423,14 @@ function createTiktokenEncodingHandler(modelId) {
/**
* Creates an API handler for decoding Tiktoken tokens.
* @param {string} modelId Tiktoken model ID
* @returns {any} Handler function
* @returns {TokenizationHandler} Handler function
*/
function createTiktokenDecodingHandler(modelId) {
/**
* Request handler for decoding Tiktoken tokens.
* @param {import('express').Request} request
* @param {import('express').Response} response
*/
return async function (request, response) {
try {
if (!request.body) {
@ -358,14 +449,6 @@ function createTiktokenDecodingHandler(modelId) {
};
}
/**
* Loads the model tokenizers.
* @returns {Promise<void>} Promise that resolves when the tokenizers are loaded
*/
async function loadTokenizers() {
claude_tokenizer = await loadClaudeTokenizer('src/claude.json');
}
const router = express.Router();
router.post('/ai21/count', jsonParser, async function (req, res) {
@ -446,8 +529,10 @@ router.post('/openai/encode', jsonParser, async function (req, res) {
if (queryModel.includes('claude')) {
const text = req.body.text || '';
const tokens = Object.values(claude_tokenizer.encode(text));
const chunks = await getWebTokenizersChunks(claude_tokenizer, tokens);
const instance = await claude_tokenizer.get();
if (!instance) throw new Error('Failed to load the Claude tokenizer');
const tokens = Object.values(instance.encode(text));
const chunks = getWebTokenizersChunks(instance, tokens);
return res.send({ ids: tokens, count: tokens.length, chunks });
}
@ -481,7 +566,9 @@ router.post('/openai/decode', jsonParser, async function (req, res) {
if (queryModel.includes('claude')) {
const ids = req.body.ids || [];
const chunkText = await claude_tokenizer.decode(new Uint32Array(ids));
const instance = await claude_tokenizer.get();
if (!instance) throw new Error('Failed to load the Claude tokenizer');
const chunkText = instance.decode(new Int32Array(ids));
return res.send({ text: chunkText });
}
@ -503,7 +590,9 @@ router.post('/openai/count', jsonParser, async function (req, res) {
const model = getTokenizerModel(queryModel);
if (model === 'claude') {
num_tokens = countClaudeTokens(claude_tokenizer, req.body);
const instance = await claude_tokenizer.get();
if (!instance) throw new Error('Failed to load the Claude tokenizer');
num_tokens = countClaudeTokens(instance, req.body);
return res.send({ 'token_count': num_tokens });
}
@ -665,7 +754,6 @@ module.exports = {
getTokenizerModel,
getTiktokenTokenizer,
countClaudeTokens,
loadTokenizers,
getSentencepiceTokenizer,
sentencepieceTokenizers,
router,

View File

@ -0,0 +1,30 @@
/**
* Decodes a file name from Latin1 to UTF-8.
* @param {string} str Input string
* @returns {string} Decoded file name
*/
function decodeFileName(str) {
return Buffer.from(str, 'latin1').toString('utf-8');
}
/**
* Middleware to decode file names from Latin1 to UTF-8.
* See: https://github.com/expressjs/multer/issues/1104
* @param {import('express').Request} req Request
* @param {import('express').Response} _res Response
* @param {import('express').NextFunction} next Next middleware
*/
function multerMonkeyPatch(req, _res, next) {
try {
if (req.file) {
req.file.originalname = decodeFileName(req.file.originalname);
}
next();
} catch (error) {
console.error('Error in multerMonkeyPatch:', error);
next();
}
}
module.exports = multerMonkeyPatch;

View File

@ -112,6 +112,16 @@ async function ensurePublicDirectoriesExist() {
return directoriesList;
}
/**
* Gets a list of all user directories.
* @returns {Promise<import('./users').UserDirectoryList[]>} - The list of user directories
*/
async function getUserDirectoriesList() {
const userHandles = await getAllUserHandles();
const directoriesList = userHandles.map(handle => getUserDirectories(handle));
return directoriesList;
}
/**
* Perform migration from the old user data format to the new one.
*/
@ -289,7 +299,7 @@ async function migrateUserData() {
fs.cpSync(
migration.old,
path.join(backupDirectory, path.basename(migration.old)),
{ recursive: true, force: true }
{ recursive: true, force: true },
);
fs.rmSync(migration.old, { recursive: true, force: true });
} else {
@ -299,7 +309,7 @@ async function migrateUserData() {
fs.cpSync(
migration.old,
path.join(backupDirectory, path.basename(migration.old)),
{ recursive: true, force: true }
{ recursive: true, force: true },
);
fs.rmSync(migration.old, { recursive: true, force: true });
}
@ -511,7 +521,7 @@ async function tryAutoLogin(request) {
const userHandles = await getAllUserHandles();
if (userHandles.length === 1) {
const user = await storage.getItem(toKey(userHandles[0]));
if (!user.password) {
if (user && !user.password) {
request.session.handle = userHandles[0];
return true;
}
@ -602,9 +612,13 @@ function createRouteHandler(directoryFn) {
try {
const directory = directoryFn(req);
const filePath = decodeURIComponent(req.params[0]);
const exists = fs.existsSync(path.join(directory, filePath));
if (!exists) {
return res.sendStatus(404);
}
return res.sendFile(filePath, { root: directory });
} catch (error) {
return res.sendStatus(404);
return res.sendStatus(500);
}
};
}
@ -707,6 +721,7 @@ module.exports = {
toAvatarKey,
initUserStorage,
ensurePublicDirectoriesExist,
getUserDirectoriesList,
getAllUserHandles,
getUserDirectories,
setUserDataMiddleware,

View File

@ -1,6 +1,7 @@
const path = require('path');
const fs = require('fs');
const commandExistsSync = require('command-exists').sync;
const writeFileAtomicSync = require('write-file-atomic').sync;
const _ = require('lodash');
const yauzl = require('yauzl');
const mime = require('mime-types');
@ -10,11 +11,20 @@ const { Readable } = require('stream');
const { PUBLIC_DIRECTORIES } = require('./constants');
/**
* Parsed config object.
*/
let CACHED_CONFIG = null;
/**
* Returns the config object from the config.yaml file.
* @returns {object} Config object
*/
function getConfig() {
if (CACHED_CONFIG) {
return CACHED_CONFIG;
}
if (!fs.existsSync('./config.yaml')) {
console.error(color.red('No config file found. Please create a config.yaml file. The default config file can be found in the /default folder.'));
console.error(color.red('The program will now exit.'));
@ -23,6 +33,7 @@ function getConfig() {
try {
const config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml'), 'utf8'));
CACHED_CONFIG = config;
return config;
} catch (error) {
console.warn('Failed to read config.yaml');
@ -47,9 +58,11 @@ function getConfigValue(key, defaultValue = null) {
* @param {any} value Value to set
*/
function setConfigValue(key, value) {
// Reset cache so that the next getConfig call will read the updated config file
CACHED_CONFIG = null;
const config = getConfig();
_.set(config, key, value);
fs.writeFileSync('./config.yaml', yaml.stringify(config));
writeFileAtomicSync('./config.yaml', yaml.stringify(config));
}
/**
@ -350,7 +363,7 @@ function generateTimestamp() {
* @param {string} prefix
*/
function removeOldBackups(prefix) {
const MAX_BACKUPS = 25;
const MAX_BACKUPS = 50;
let files = fs.readdirSync(PUBLIC_DIRECTORIES.backups).filter(f => f.startsWith(prefix));
if (files.length > MAX_BACKUPS) {

View File

@ -1,5 +1,8 @@
#!/usr/bin/env bash
# Make sure pwd is the directory of the script
cd "$(dirname "$0")"
if ! command -v npm &> /dev/null
then
read -p "npm is not installed. Do you want to install nodejs and npm? (y/n)" choice
@ -26,4 +29,4 @@ export NODE_ENV=production
npm i --no-audit --no-fund --quiet --omit=dev
echo "Entering SillyTavern..."
node "$(dirname "$0")/server.js" "$@"
node "server.js" "$@"