Merge branch 'staging' into immutable-config

This commit is contained in:
Cohee
2025-02-22 20:15:13 +02:00
35 changed files with 1267 additions and 989 deletions

View File

@@ -0,0 +1,13 @@
These are master copies of the default content files and are managed by SillyTavern.
Editing any of these files would not only have no effect, but will also cause merge conflicts during update pulls.
You should edit their respective copies instead, for example:
1. /default/config.yaml => /config.yaml
2. /default/public/css/user.css => /public/css/user.css
etc.
Any questions? You're always welcome at our official documentation website:
https://docs.sillytavern.app/

View File

@@ -71,8 +71,6 @@ autheliaAuth: false
# the username and passwords for basic auth are the same as those
# for the individual accounts
perUserBasicAuth: false
# Minimum log level to display in the terminal (DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3)
minLogLevel: 0
# User session timeout *in seconds* (defaults to 24 hours).
## Set to a positive number to expire session after a certain time of inactivity
@@ -83,6 +81,13 @@ sessionTimeout: -1
disableCsrfProtection: false
# Disable startup security checks - NOT RECOMMENDED
securityOverride: false
# -- LOGGING CONFIGURATION --
logging:
# Enable access logging to access.log file
# Records new connections with timestamp, IP address and user agent
enableAccessLog: true
# Minimum log level to display in the terminal (DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3)
minLogLevel: 0
# -- RATE LIMITING CONFIGURATION --
rateLimiting:
# Use X-Real-IP header instead of socket IP for rate limiting

View File

@@ -8,7 +8,7 @@ import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { default as git } from 'simple-git';
import { default as git, CheckRepoActions } from 'simple-git';
import { color } from './src/util.js';
const __dirname = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
@@ -49,7 +49,7 @@ async function updatePlugins() {
const pluginPath = path.join(pluginsPath, directory);
const pluginRepo = git(pluginPath);
const isRepo = await pluginRepo.checkIsRepo();
const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
if (!isRepo) {
console.log(`Directory ${color.yellow(directory)} is not a Git repository`);
continue;

View File

@@ -104,6 +104,11 @@ const keyMigrationMap = [
newKey: 'extensions.models.textToSpeech',
migrate: (value) => value,
},
{
oldKey: 'minLogLevel',
newKey: 'logging.minLogLevel',
migrate: (value) => value,
},
// uncommend one release after 1.12.13
/*
{

8
public/global.d.ts vendored
View File

@@ -40,4 +40,12 @@ declare global {
searchInputCssClass?: string;
}
}
/**
* Translates a text to a target language using a translation provider.
* @param text Text to translate
* @param lang Target language
* @param provider Translation provider
*/
async function translate(text: string, lang: string, provider: string = null): Promise<string>;
}

View File

@@ -2000,7 +2000,7 @@
</span>
</div>
</div>
<div class="range-block" data-source="deepseek,openrouter">
<div class="range-block" data-source="deepseek,openrouter,custom">
<label for="openai_show_thoughts" class="checkbox_label widthFreeExpand">
<input id="openai_show_thoughts" type="checkbox" />
<span>
@@ -3186,21 +3186,33 @@
</div>
<h4 data-i18n="Groq Model">Groq Model</h4>
<select id="model_groq_select">
<optgroup label="Production Models">
<option value="gemma2-9b-it">gemma2-9b-it</option>
<option value="llama-3.3-70b-versatile">llama-3.3-70b-versatile</option>
<option value="llama-3.1-8b-instant">llama-3.1-8b-instant</option>
<option value="llama3-70b-8192">llama3-70b-8192</option>
<option value="llama3-8b-8192">llama3-8b-8192</option>
<option value="mixtral-8x7b-32768">mixtral-8x7b-32768</option>
<optgroup label="Alibaba Cloud">
<option value="qwen-2.5-32b">qwen-2.5-32b</option>
<option value="qwen-2.5-coder-32b">qwen-2.5-coder-32b</option>
</optgroup>
<optgroup label="Preview Models">
<optgroup label="DeepSeek / Alibaba Cloud">
<option value="deepseek-r1-distill-qwen-32b">deepseek-r1-distill-qwen-32b</option>
</optgroup>
<optgroup label="DeepSeek / Meta">
<option value="deepseek-r1-distill-llama-70b">deepseek-r1-distill-llama-70b</option>
<option value="llama-3.3-70b-specdec">llama-3.3-70b-specdec</option>
<option value="llama-3.2-1b-preview">llama-3.2-1b-preview</option>
<option value="llama-3.2-3b-preview">llama-3.2-3b-preview</option>
<option value="llama-3.2-11b-vision-preview">llama-3.2-11b-vision-preview</option>
<option value="llama-3.2-90b-vision-preview">llama-3.2-90b-vision-preview</option>
</optgroup>
<optgroup label="Google">
<option value="gemma2-9b-it">gemma2-9b-it</option>
</optgroup>
<optgroup label="Meta">
<option value="llama-3.1-8b-instant">llama-3.1-8b-instant </option>
<option value="llama-3.2-11b-vision-preview">llama-3.2-11b-vision-preview </option>
<option value="llama-3.2-1b-preview">llama-3.2-1b-preview </option>
<option value="llama-3.2-3b-preview">llama-3.2-3b-preview </option>
<option value="llama-3.2-90b-vision-preview">llama-3.2-90b-vision-preview </option>
<option value="llama-3.3-70b-specdec">llama-3.3-70b-specdec </option>
<option value="llama-3.3-70b-versatile">llama-3.3-70b-versatile </option>
<option value="llama-guard-3-8b">llama-guard-3-8b </option>
<option value="llama3-70b-8192">llama3-70b-8192 </option>
<option value="llama3-8b-8192">llama3-8b-8192 </option>
</optgroup>
<optgroup label="Mistral AI">
<option value="mixtral-8x7b-32768">mixtral-8x7b-32768</option>
</optgroup>
</select>
</div>
@@ -3253,6 +3265,10 @@
<option value="sonar">sonar</option>
<option value="sonar-pro">sonar-pro</option>
<option value="sonar-reasoning">sonar-reasoning</option>
<option value="sonar-reasoning-pro">sonar-reasoning-pro</option>
</optgroup>
<optgroup label="Offline Models">
<option value="r1-1776">r1-1776</option>
</optgroup>
<optgroup label="Deprecated Models">
<!-- These are scheduled for deprecation after 2/22/2025 -->

View File

@@ -24,10 +24,22 @@ if (typeof Array.prototype.indexOf === 'function') {
/* Polyfill EventEmitter. */
var EventEmitter = function () {
/**
* Creates an event emitter.
* @param {string[]} autoFireAfterEmit Auto-fire event names
*/
var EventEmitter = function (autoFireAfterEmit = []) {
this.events = {};
this.autoFireLastArgs = new Map();
this.autoFireAfterEmit = new Set(autoFireAfterEmit);
};
/**
* Adds a listener to an event.
* @param {string} event Event name
* @param {function} listener Event listener
* @returns
*/
EventEmitter.prototype.on = function (event, listener) {
// Unknown event used by external libraries?
if (event === undefined) {
@@ -40,6 +52,10 @@ EventEmitter.prototype.on = function (event, listener) {
}
this.events[event].push(listener);
if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) {
listener.apply(this, this.autoFireLastArgs.get(event));
}
};
/**
@@ -60,6 +76,10 @@ EventEmitter.prototype.makeLast = function (event, listener) {
}
events.push(listener);
if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) {
listener.apply(this, this.autoFireLastArgs.get(event));
}
}
/**
@@ -80,8 +100,17 @@ EventEmitter.prototype.makeFirst = function (event, listener) {
}
events.unshift(listener);
if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) {
listener.apply(this, this.autoFireLastArgs.get(event));
}
}
/**
* Removes a listener from an event.
* @param {string} event Event name
* @param {function} listener Event listener
*/
EventEmitter.prototype.removeListener = function (event, listener) {
var idx;
@@ -94,6 +123,10 @@ EventEmitter.prototype.removeListener = function (event, listener) {
}
};
/**
* Emits an event with optional arguments.
* @param {string} event Event name
*/
EventEmitter.prototype.emit = async function (event) {
let args = [].slice.call(arguments, 1);
if (localStorage.getItem('eventTracing') === 'true') {
@@ -118,6 +151,10 @@ EventEmitter.prototype.emit = async function (event) {
}
}
}
if (this.autoFireAfterEmit.has(event)) {
this.autoFireLastArgs.set(event, args);
}
};
EventEmitter.prototype.emitAndWait = function (event) {
@@ -144,10 +181,14 @@ EventEmitter.prototype.emitAndWait = function (event) {
}
}
}
if (this.autoFireAfterEmit.has(event)) {
this.autoFireLastArgs.set(event, args);
}
};
EventEmitter.prototype.once = function (event, listener) {
this.on(event, function g () {
this.on(event, function g() {
this.removeListener(event, g);
listener.apply(this, arguments);
});

View File

@@ -1602,7 +1602,6 @@
"Character Expressions": "Expressions de personnages",
"Translate text to English before classification": "Traduire le texte en anglais avant de le classer",
"Show default images (emojis) if sprite missing": "Afficher les images par défaut (emojis) si le sprite est manquant",
"Image Type - talkinghead (extras)": "Type d'image - talkinghead (extras)",
"Classifier API": "API de classification",
"Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.",
"Main API": "API principale",

View File

@@ -1467,7 +1467,6 @@
"menu within": "내의 메뉴",
"Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.",
"Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.",
"Image Type - talkinghead (extras)": "이미지 유형 - 토킹 헤드 (부가 사항)",
"Classifier API": "분류를 위한 API",
"Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.",
"Local": "로컬",

View File

@@ -1349,7 +1349,6 @@
"Character Expressions": "角色表情",
"Translate text to English before classification": "分类之前将文本翻译成英文",
"Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)",
"Image Type - talkinghead (extras)": "图像类型 - 说话头像(附加内容)",
"Classifier API": "分类器 API",
"Select the API for classifying expressions.": "选择用于对表达式进行分类的API。",
"Main API": "主要 API",

View File

@@ -1653,7 +1653,6 @@
"HuggingFace Token": "HuggingFace 符元",
"Image Captioning": "圖片註解",
"Generate Caption": "產生圖片註解",
"Image Type - talkinghead (extras)": "圖片類型 - talkinghead額外選項",
"Injection Position": "插入位置",
"Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。",
"Injection Template": "插入範本",

View File

@@ -512,7 +512,7 @@ export const event_types = {
TOOL_CALLS_RENDERED: 'tool_calls_rendered',
};
export const eventSource = new EventEmitter();
export const eventSource = new EventEmitter([event_types.APP_READY]);
eventSource.on(event_types.CHAT_CHANGED, processChatSlashCommands);

View File

@@ -1487,7 +1487,7 @@ jQuery(function () {
...chat.filter(x => x?.extra?.type !== system_message_types.ASSISTANT_NOTE),
];
download(JSON.stringify(chatToSave, null, 4), `Assistant - ${humanizedDateTime()}.json`, 'application/json');
download(chatToSave.map((m) => JSON.stringify(m)).join('\n'), `Assistant - ${humanizedDateTime()}.jsonl`, 'application/json');
});
// Do not change. #attachFile is added by extension.

View File

@@ -154,8 +154,18 @@ export const extension_settings = {
refine_mode: false,
},
expressions: {
/** @type {number} see `EXPRESSION_API` */
api: undefined,
/** @type {string[]} */
custom: [],
showDefault: false,
translate: false,
/** @type {string} */
fallback_expression: undefined,
/** @type {string} */
llmPrompt: undefined,
allowMultiple: true,
rerollIfSame: false,
},
connectionManager: {
selectedProfile: '',

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
<div id="{{item}}" class="expression_list_item">
{{#each images}}
<div class="expression_list_item interactable" data-expression="{{../expression}}" data-expression-type="{{this.type}}" data-filename="{{this.fileName}}">
<div class="expression_list_buttons">
<div class="menu_button expression_list_upload" title="Upload image">
<i class="fa-solid fa-upload"></i>
@@ -7,11 +8,14 @@
<i class="fa-solid fa-trash"></i>
</div>
</div>
<div class="expression_list_title {{textClass}}">
<span>{{item}}</span>
{{#if isCustom}}
<div class="expression_list_title">
<span>{{../expression}}</span>
{{#if ../isCustom}}
<small class="expression_list_custom">(custom)</small>
{{/if}}
</div>
<img class="expression_list_image" src="{{imageSrc}}" />
<div class="expression_list_image_container" title="{{this.title}}">
<img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" />
</div>
</div>
{{/each}}

View File

@@ -6,17 +6,17 @@
</div>
<div class="inline-drawer-content">
<label class="checkbox_label" for="expression_translate" title="Use the selected API from Chat Translation extension settings.">
<label class="checkbox_label" for="expression_translate" title="Use the selected API from Chat Translation extension settings." data-i18n="[title]Use the selected API from Chat Translation extension settings.">
<input id="expression_translate" type="checkbox">
<span data-i18n="Translate text to English before classification">Translate text to English before classification</span>
</label>
<label class="checkbox_label" for="expressions_show_default">
<input id="expressions_show_default" type="checkbox">
<span data-i18n="Show default images (emojis) if sprite missing">Show default images (emojis) if sprite missing</span>
<label class="checkbox_label" for="expressions_allow_multiple" title="A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected." data-i18n="[title]A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected.">
<input id="expressions_allow_multiple" type="checkbox">
<span data-i18n="Allow multiple sprites per expression">Allow multiple sprites per expression</span>
</label>
<label id="image_type_block" class="checkbox_label" for="image_type_toggle">
<input id="image_type_toggle" type="checkbox">
<span data-i18n="Image Type - talkinghead (extras)">Image Type - talkinghead (extras)</span>
<label class="checkbox_label" for="expressions_reroll_if_same" title="If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned." data-i18n="[title]If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned.">
<input id="expressions_reroll_if_same" type="checkbox">
<span data-i18n="Re-roll if same expression is used again">Re-roll if same sprite is used again</span>
</label>
<div class="expression_api_block m-b-1 m-t-1">
<label for="expression_api" data-i18n="Classifier API">Classifier API</label>
@@ -75,8 +75,20 @@
<span data-i18n="Remove all image overrides">Remove all image overrides</span>
</div>
</div>
<p class="hint"><b data-i18n="Hint:">Hint:</b> <i><span data-i18n="Create new folder in the _space">Create new folder in the </span><b>/characters/</b> <span data-i18n="folder of your user data directory and name it as the name of the character.">folder of your user data directory and name it as the name of the character.</span>
<span data-i18n="Put images with expressions there. File names should follow the pattern:">Put images with expressions there. File names should follow the pattern: </span><tt data-i18n="expression_label_pattern">[expression_label].[image_format]</tt></i></p>
<p class="hint">
<b data-i18n="Hint:">Hint:</b>
<i>
<span data-i18n="Create new folder in the _space">Create new folder in the </span><b>/characters/</b> <span data-i18n="folder of your user data directory and name it as the name of the character.">folder of your user data directory and name it as the name of the character.</span>
<span data-i18n="Put images with expressions there. File names should follow the pattern:">Put images with expressions there. File names should follow the pattern: </span><tt data-i18n="expression_label_pattern">[expression_label].[image_format]</tt>
</i>
</p>
<p>
<i>
<span>In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a
dash.
Examples: </span><tt>joy.png</tt>, <tt>joy-1.png</tt>, <tt>joy.expressive.png</tt>
</i>
</p>
<h3 id="image_list_header">
<strong data-i18n="Sprite set:">Sprite set:</strong>&nbsp;<span id="image_list_header_name"></span>
</h3>

View File

@@ -111,6 +111,10 @@ img.expression.default {
justify-content: center;
}
.expression_list_image_container {
overflow: hidden;
}
.expression_list_title {
position: absolute;
bottom: 0;
@@ -126,6 +130,9 @@ img.expression.default {
flex-direction: column;
line-height: 1;
}
.expression_list_custom {
font-size: 0.66rem;
}
.expression_list_buttons {
position: absolute;
@@ -162,11 +169,24 @@ img.expression.default {
row-gap: 1rem;
}
#image_list .success {
#image_list .expression_list_item[data-expression-type="success"] .expression_list_title {
color: green;
}
#image_list .failure {
#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title {
color: darkolivegreen;
}
#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title::before {
content: '';
position: absolute;
top: -7px;
left: -9px;
font-size: 14px;
color: transparent;
text-shadow: 0 0 0 darkolivegreen;
}
#image_list .expression_list_item[data-expression-type="failure"] .expression_list_title {
color: red;
}
@@ -189,3 +209,12 @@ img.expression.default {
flex-direction: row;
}
#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"],
#expressions_container:has(#expressions_allow_multiple:not(:checked)) label[for="expressions_reroll_if_same"] {
opacity: 0.3;
transition: opacity var(--animation-duration) ease;
}
#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:hover,
#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:focus {
opacity: unset;
}

View File

@@ -0,0 +1,12 @@
<div class="m-b-1" data-i18n="upload_expression_request">Please enter a name for the sprite (without extension).</div>
<div class="m-b-1" data-i18n="upload_expression_naming_1">
Sprite names must follow the naming schema for the selected expression: {{expression}}
</div>
<div data-i18n="upload_expression_naming_2">
For multiple expressions, the name must follow the expression name and a valid suffix. Allowed separators are '-' or dot '.'.
</div>
<span class="m-b-1" data-i18n="Examples:">Examples:</span> <tt>{{expression}}.png</tt>, <tt>{{expression}}-1.png</tt>, <tt>{{expression}}.expressive.png</tt>
{{#if clickedFileName}}
<div class="m-t-1" data-i18n="upload_expression_replace">Click 'Replace' to replace the existing expression:</div>
<tt>{{clickedFileName}}</tt>
{{/if}}

View File

@@ -605,7 +605,7 @@ const handleOutgoingMessage = createEventHandler(translateOutgoingMessage, () =>
const handleImpersonateReady = createEventHandler(translateImpersonate, () => shouldTranslate(incomingTypes));
const handleMessageEdit = createEventHandler(translateMessageEdit, () => true);
window['translate'] = translate;
globalThis.translate = translate;
jQuery(async () => {
const html = await renderExtensionTemplateAsync('translate', 'index');

View File

@@ -27,14 +27,12 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm
import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
import { GoogleTranslateTtsProvider } from './google-translate.js';
export { talkingAnimation };
const UPDATE_INTERVAL = 1000;
const wrapper = new ModuleWorkerWrapper(moduleWorker);
let voiceMapEntries = [];
let voiceMap = {}; // {charName:voiceid, charName2:voiceid2}
let talkingHeadState = false;
let lastChatId = null;
let lastMessage = null;
let lastMessageHash = null;
@@ -166,27 +164,6 @@ async function moduleWorker() {
updateUiAudioPlayState();
}
function talkingAnimation(switchValue) {
if (!modules.includes('talkinghead')) {
console.debug('Talking Animation module not loaded');
return;
}
const apiUrl = getApiUrl();
const animationType = switchValue ? 'start' : 'stop';
if (switchValue !== talkingHeadState) {
try {
console.log(animationType + ' Talking Animation');
doExtrasFetch(`${apiUrl}/api/talkinghead/${animationType}_talking`);
talkingHeadState = switchValue;
} catch (error) {
// Handle the error here or simply ignore it to prevent logging
}
}
updateUiAudioPlayState();
}
function resetTtsPlayback() {
// Stop system TTS utterance
cancelTtsPlay();
@@ -378,7 +355,6 @@ function onAudioControlClicked() {
// Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful
if (!audioElement.paused || isTtsProcessing()) {
resetTtsPlayback();
talkingAnimation(false);
} else {
// Default play behavior if not processing or playing is to play the last message.
processAndQueueTtsMessage(context.chat[context.chat.length - 1]);
@@ -405,7 +381,6 @@ function addAudioControl() {
function completeCurrentAudioJob() {
audioQueueProcessorReady = true;
currentAudioJob = null;
talkingAnimation(false); //stop lip animation
// updateUiPlayState();
wrapper.update();
}
@@ -436,7 +411,6 @@ async function processAudioJobQueue() {
audioQueueProcessorReady = false;
currentAudioJob = audioJobQueue.shift();
playAudioData(currentAudioJob);
talkingAnimation(true);
} catch (error) {
toastr.error(error.toString());
console.error(error);

View File

@@ -1,6 +1,5 @@
import { isMobile } from '../../RossAscends-mods.js';
import { getPreviewString } from './index.js';
import { talkingAnimation } from './index.js';
import { saveTtsProviderSettings } from './index.js';
export { SystemTtsProvider };
@@ -70,7 +69,6 @@ var speechUtteranceChunker = function (utt, settings, callback) {
//placing the speak invocation inside a callback fixes ordering and onend issues.
setTimeout(function () {
speechSynthesis.speak(newUtt);
talkingAnimation(true);
}, 0);
};
@@ -240,7 +238,6 @@ class SystemTtsProvider {
//some code to execute when done
resolve(silence);
console.log('System TTS done');
talkingAnimation(false);
});
});
}

View File

@@ -561,9 +561,9 @@ async function retrieveFileChunks(queryText, collectionId) {
*/
async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overlapPercent) {
try {
if (settings.translate_files && typeof window['translate'] === 'function') {
if (settings.translate_files && typeof globalThis.translate === 'function') {
console.log(`Vectors: Translating file ${fileName} to English...`);
const translatedText = await window['translate'](fileText, 'en');
const translatedText = await globalThis.translate(fileText, 'en');
fileText = translatedText;
}

View File

@@ -2167,6 +2167,14 @@ function getStreamingReply(data, state) {
state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');
}
return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
} else if (oai_settings.chat_completion_source === chat_completion_sources.CUSTOM) {
if (oai_settings.show_thoughts) {
state.reasoning +=
data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content ??
data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning ??
'';
}
return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
} else {
return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
}
@@ -4107,6 +4115,40 @@ function getMaxContextWindowAI(value) {
}
}
/**
* Get the maximum context size for the Groq model
* @param {string} model Model identifier
* @param {boolean} isUnlocked Whether context limits are unlocked
* @returns {number} Maximum context size in tokens
*/
function getGroqMaxContext(model, isUnlocked) {
if (isUnlocked) {
return unlocked_max;
}
const contextMap = {
'gemma2-9b-it': max_8k,
'llama-3.3-70b-versatile': max_128k,
'llama-3.1-8b-instant': max_128k,
'llama3-70b-8192': max_8k,
'llama3-8b-8192': max_8k,
'llama-guard-3-8b': max_8k,
'mixtral-8x7b-32768': max_32k,
'deepseek-r1-distill-llama-70b': max_128k,
'llama-3.3-70b-specdec': max_8k,
'llama-3.2-1b-preview': max_128k,
'llama-3.2-3b-preview': max_128k,
'llama-3.2-11b-vision-preview': max_128k,
'llama-3.2-90b-vision-preview': max_128k,
'qwen-2.5-32b': max_128k,
'deepseek-r1-distill-qwen-32b': max_128k,
'deepseek-r1-distill-llama-70b-specdec': max_128k,
};
// Return context size if model found, otherwise default to 128k
return Object.entries(contextMap).find(([key]) => model.includes(key))?.[1] || max_128k;
}
async function onModelChange() {
biasCache = undefined;
let value = String($(this).val() || '');
@@ -4387,7 +4429,7 @@ async function onModelChange() {
if (oai_settings.max_context_unlocked) {
$('#openai_max_context').attr('max', unlocked_max);
}
else if (['sonar', 'sonar-reasoning'].includes(oai_settings.perplexity_model)) {
else if (['sonar', 'sonar-reasoning', 'sonar-reasoning-pro', 'r1-1776'].includes(oai_settings.perplexity_model)) {
$('#openai_max_context').attr('max', 127000);
}
else if (['sonar-pro'].includes(oai_settings.perplexity_model)) {
@@ -4408,33 +4450,8 @@ async function onModelChange() {
}
if (oai_settings.chat_completion_source == chat_completion_sources.GROQ) {
if (oai_settings.max_context_unlocked) {
$('#openai_max_context').attr('max', unlocked_max);
} else if (oai_settings.groq_model.includes('gemma2-9b-it')) {
$('#openai_max_context').attr('max', max_8k);
} else if (oai_settings.groq_model.includes('llama-3.3-70b-versatile')) {
$('#openai_max_context').attr('max', max_128k);
} else if (oai_settings.groq_model.includes('llama-3.1-8b-instant')) {
$('#openai_max_context').attr('max', max_128k);
} else if (oai_settings.groq_model.includes('llama3-70b-8192')) {
$('#openai_max_context').attr('max', max_8k);
} else if (oai_settings.groq_model.includes('llama3-8b-8192')) {
$('#openai_max_context').attr('max', max_8k);
} else if (oai_settings.groq_model.includes('mixtral-8x7b-32768')) {
$('#openai_max_context').attr('max', max_32k);
} else if (oai_settings.groq_model.includes('deepseek-r1-distill-llama-70b')) {
$('#openai_max_context').attr('max', max_128k);
} else if (oai_settings.groq_model.includes('llama-3.3-70b-specdec')) {
$('#openai_max_context').attr('max', max_8k);
} else if (oai_settings.groq_model.includes('llama-3.2-1b-preview')) {
$('#openai_max_context').attr('max', max_128k);
} else if (oai_settings.groq_model.includes('llama-3.2-3b-preview')) {
$('#openai_max_context').attr('max', max_128k);
} else if (oai_settings.groq_model.includes('llama-3.2-11b-vision-preview')) {
$('#openai_max_context').attr('max', max_128k);
} else if (oai_settings.groq_model.includes('llama-3.2-90b-vision-preview')) {
$('#openai_max_context').attr('max', max_128k);
}
const maxContext = getGroqMaxContext(oai_settings.groq_model, oai_settings.max_context_unlocked);
$('#openai_max_context').attr('max', maxContext);
oai_settings.openai_max_context = Math.min(Number($('#openai_max_context').attr('max')), oai_settings.openai_max_context);
$('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai);

View File

@@ -1845,14 +1845,15 @@ async function loadContextSettings() {
/**
* Common function to perform fuzzy search with optional caching
* @template T
* @param {string} type - Type of search from fuzzySearchCategories
* @param {any[]} data - Data array to search in
* @param {Array<{name: string, weight: number, getFn?: (obj: any) => string}>} keys - Fuse.js keys configuration
* @param {T[]} data - Data array to search in
* @param {Array<{name: string, weight: number, getFn?: (obj: T) => string}>} keys - Fuse.js keys configuration
* @param {string} searchValue - The search term
* @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
* @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
* @returns {import('fuse.js').FuseResult<T>[]} Results as items with their score
*/
function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {
export function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {
// Check cache if provided
if (fuzzySearchCaches) {
const cache = fuzzySearchCaches[type];

View File

@@ -76,6 +76,11 @@ export function extractReasoningFromData(data) {
return data?.choices?.[0]?.message?.reasoning ?? '';
case chat_completion_sources.MAKERSUITE:
return data?.responseContent?.parts?.filter(part => part.thought)?.map(part => part.text)?.join('\n\n') ?? '';
case chat_completion_sources.CUSTOM: {
return data?.choices?.[0]?.message?.reasoning_content
?? data?.choices?.[0]?.message?.reasoning
?? '';
}
}
break;
}
@@ -338,14 +343,15 @@ export class ReasoningHandler {
return mesChanged;
}
if (this.state === ReasoningState.None) {
if (this.state === ReasoningState.None || this.#isHiddenReasoningModel) {
// If streamed message starts with the opening, cut it out and put all inside reasoning
if (message.mes.startsWith(power_user.reasoning.prefix) && message.mes.length > power_user.reasoning.prefix.length) {
this.#isParsingReasoning = true;
// Manually set starting state here, as we might already have received the ending suffix
this.state = ReasoningState.Thinking;
this.startTime = this.initialTime;
this.startTime = this.startTime ?? this.initialTime;
this.endTime = null;
}
}

View File

@@ -679,7 +679,7 @@ export function getTokenizerModel() {
}
if (oai_settings.chat_completion_source === chat_completion_sources.PERPLEXITY) {
if (oai_settings.perplexity_model.includes('sonar-reasoning')) {
if (oai_settings.perplexity_model.includes('sonar-reasoning') || oai_settings.perplexity_model.includes('r1-1776')) {
return deepseekTokenizer;
}
if (oai_settings.perplexity_model.includes('llama-3') || oai_settings.perplexity_model.includes('llama3')) {
@@ -694,6 +694,9 @@ export function getTokenizerModel() {
}
if (oai_settings.chat_completion_source === chat_completion_sources.GROQ) {
if (oai_settings.groq_model.includes('qwen')) {
return qwen2Tokenizer;
}
if (oai_settings.groq_model.includes('llama-3') || oai_settings.groq_model.includes('llama3')) {
return llama3Tokenizer;
}

View File

@@ -55,6 +55,10 @@
--interactable-outline-color: var(--white100);
--interactable-outline-color-faint: var(--white20a);
--reasoning-body-color: var(--SmartThemeEmColor);
--reasoning-em-color: color-mix(in srgb, var(--SmartThemeEmColor) 67%, var(--SmartThemeBlurTintColor) 33%);
--reasoning-saturation: 0.5;
/*Default Theme, will be changed by ToolCool Color Picker*/
--SmartThemeBodyColor: rgb(220, 220, 210);
@@ -348,13 +352,13 @@ input[type='checkbox']:focus-visible {
.mes_reasoning {
display: block;
border-left: 2px solid var(--SmartThemeEmColor);
border-left: 2px solid var(--reasoning-body-color);
border-radius: 2px;
padding: 5px;
padding-left: 14px;
margin-bottom: 0.5em;
overflow-y: auto;
color: var(--SmartThemeEmColor);
color: hsl(from var(--reasoning-body-color) h calc(s * var(--reasoning-saturation)) l);
}
.mes_reasoning_details {
@@ -374,18 +378,6 @@ input[type='checkbox']:focus-visible {
margin-bottom: 0;
}
.mes_reasoning em,
.mes_reasoning i,
.mes_reasoning u,
.mes_reasoning q,
.mes_reasoning blockquote {
filter: saturate(0.5);
}
.mes_reasoning_details .mes_reasoning em {
color: color-mix(in srgb, var(--SmartThemeEmColor) 67%, var(--SmartThemeBlurTintColor) 33%);
}
.mes_reasoning_header_block {
flex-grow: 1;
}
@@ -461,26 +453,36 @@ input[type='checkbox']:focus-visible {
}
.mes_text i,
.mes_text em,
.mes_text em {
color: var(--SmartThemeEmColor);
}
.mes_reasoning i,
.mes_reasoning em {
color: var(--SmartThemeEmColor);
color: hsl(from var(--reasoning-em-color) h calc(s * var(--reasoning-saturation)) l);
}
.mes_text q i,
.mes_text q em {
color: inherit;
}
.mes_text u,
.mes_reasoning u {
color: var(--SmartThemeUnderlineColor);
.mes_reasoning q i,
.mes_reasoning q em {
color: hsl(from var(--SmartThemeQuoteColor) h calc(s * var(--reasoning-saturation)) l);
}
.mes_text q,
.mes_reasoning q {
.mes_text u {
color: var(--SmartThemeUnderlineColor);
}
.mes_reasoning u {
color: hsl(from var(--SmartThemeUnderlineColor) h calc(s * var(--reasoning-saturation)) l);
}
.mes_text q {
color: var(--SmartThemeQuoteColor);
}
.mes_reasoning q {
color: hsl(from var(--SmartThemeQuoteColor) h calc(s * var(--reasoning-saturation)) l);
}
.mes_text font[color] em,
.mes_text font[color] i,

View File

@@ -57,7 +57,8 @@ import {
import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
import basicAuthMiddleware from './src/middleware/basicAuth.js';
import whitelistMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/whitelist.js';
import whitelistMiddleware from './src/middleware/whitelist.js';
import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogWriter.js';
import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';
import initRequestProxy from './src/request-proxy.js';
import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
@@ -243,7 +244,6 @@ const cliArguments = yargs(hideBin(process.argv))
describe: 'Request proxy URL (HTTP or SOCKS protocols)',
}).option('requestProxyBypass', {
type: 'array',
default: null,
describe: 'Request proxy bypass list (space separated list of hosts)',
}).parseSync();
@@ -340,9 +340,17 @@ const CORS = cors({
app.use(CORS);
if (listen && basicAuthMode) app.use(basicAuthMiddleware);
if (listen && basicAuthMode) {
app.use(basicAuthMiddleware);
}
app.use(whitelistMiddleware(enableWhitelist, listen));
if (enableWhitelist) {
app.use(whitelistMiddleware());
}
if (listen) {
app.use(accessLoggerMiddleware());
}
if (enableCorsProxy) {
app.use(bodyParser.json({

View File

@@ -230,7 +230,7 @@ router.post('/version', jsonParser, async (request, response) => {
} catch (error) {
// it is not a git repo, or has no commits yet, or is a bare repo
// not possible to update it, most likely can't get the branch name either
return response.send({ currentBranchName: null, currentCommitHash, isUpToDate: true, remoteUrl: null });
return response.send({ currentBranchName: '', currentCommitHash: '', isUpToDate: true, remoteUrl: '' });
}
const currentBranch = await git.cwd(extensionPath).branch();

View File

@@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) {
.map((file) => {
const pathToSprite = path.join(spritesPath, file);
const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14);
const fileName = path.parse(pathToSprite).name.toLowerCase();
// Extract the label from the filename via regex, which can be suffixed with a sub-name, either connected with a dash or a dot.
// Examples: joy.png, joy-1.png, joy.expressive.png
const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName;
return {
label: path.parse(pathToSprite).name.toLowerCase(),
label: label,
path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
};
});
@@ -141,8 +147,9 @@ router.get('/get', jsonParser, function (request, response) {
router.post('/delete', jsonParser, async (request, response) => {
const label = request.body.label;
const name = request.body.name;
const spriteName = request.body.spriteName || label;
if (!label || !name) {
if (!spriteName || !name) {
return response.sendStatus(400);
}
@@ -158,7 +165,7 @@ router.post('/delete', jsonParser, async (request, response) => {
// Remove existing sprite with the same label
for (const file of files) {
if (path.parse(file).name === label) {
if (path.parse(file).name === spriteName) {
fs.rmSync(path.join(spritesPath, file));
}
}
@@ -221,6 +228,7 @@ router.post('/upload', urlencodedParser, async (request, response) => {
const file = request.file;
const label = request.body.label;
const name = request.body.name;
const spriteName = request.body.spriteName || label;
if (!file || !label || !name) {
return response.sendStatus(400);
@@ -243,12 +251,12 @@ router.post('/upload', urlencodedParser, async (request, response) => {
// Remove existing sprite with the same label
for (const file of files) {
if (path.parse(file).name === label) {
if (path.parse(file).name === spriteName) {
fs.rmSync(path.join(spritesPath, file));
}
}
const filename = label + path.parse(file.originalname).ext;
const filename = spriteName + path.parse(file.originalname).ext;
const spritePath = path.join(file.destination, file.filename);
const pathToFile = path.join(spritesPath, filename);
// Copy uploaded file to sprites folder

View File

@@ -0,0 +1,59 @@
import path from 'node:path';
import fs from 'node:fs';
import { getRealIpFromHeader } from '../express-common.js';
import { color, getConfigValue } from '../util.js';
const enableAccessLog = getConfigValue('logging.enableAccessLog', true);
const knownIPs = new Set();
export const getAccessLogPath = () => path.join(globalThis.DATA_ROOT, 'access.log');
export function migrateAccessLog() {
try {
if (!fs.existsSync('access.log')) {
return;
}
const logPath = getAccessLogPath();
if (fs.existsSync(logPath)) {
return;
}
fs.renameSync('access.log', logPath);
console.log(color.yellow('Migrated access.log to new location:'), logPath);
} catch (e) {
console.error('Failed to migrate access log:', e);
console.info('Please move access.log to the data directory manually.');
}
}
/**
* Creates middleware for logging access and new connections
* @returns {import('express').RequestHandler}
*/
export default function accessLoggerMiddleware() {
return function (req, res, next) {
const clientIp = getRealIpFromHeader(req);
const userAgent = req.headers['user-agent'];
if (!knownIPs.has(clientIp)) {
// Log new connection
console.info(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));
knownIPs.add(clientIp);
// Write to access log if enabled
if (enableAccessLog) {
const logPath = getAccessLogPath();
const timestamp = new Date().toISOString();
const log = `${timestamp} ${clientIp} ${userAgent}\n`;
fs.appendFile(logPath, log, (err) => {
if (err) {
console.error('Failed to write access log:', err);
}
});
}
}
next();
};
}

View File

@@ -10,9 +10,6 @@ import { color, getConfigValue, safeReadFileSync } from '../util.js';
const whitelistPath = path.join(process.cwd(), './whitelist.txt');
const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false, 'boolean');
let whitelist = getConfigValue('whitelist', []);
let knownIPs = new Set();
export const getAccessLogPath = () => path.join(globalThis.DATA_ROOT, 'access.log');
if (fs.existsSync(whitelistPath)) {
try {
@@ -48,67 +45,41 @@ function getForwardedIp(req) {
return undefined;
}
export function migrateAccessLog() {
try {
if (!fs.existsSync('access.log')) {
return;
}
const logPath = getAccessLogPath();
if (fs.existsSync(logPath)) {
return;
}
fs.renameSync('access.log', logPath);
console.log(color.yellow('Migrated access.log to new location:'), logPath);
} catch (e) {
console.error('Failed to migrate access log:', e);
console.info('Please move access.log to the data directory manually.');
}
}
/**
* Returns a middleware function that checks if the client IP is in the whitelist.
* @param {boolean} whitelistMode If whitelist mode is enabled via config or command line
* @param {boolean} listen If listen mode is enabled via config or command line
* @returns {import('express').RequestHandler} The middleware function
*/
export default function whitelistMiddleware(whitelistMode, listen) {
export default function whitelistMiddleware() {
const forbiddenWebpage = Handlebars.compile(
safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '',
);
const noLogPaths = [
'/favicon.ico',
];
return function (req, res, next) {
const clientIp = getIpFromRequest(req);
const forwardedIp = getForwardedIp(req);
const userAgent = req.headers['user-agent'];
if (listen && !knownIPs.has(clientIp)) {
console.info(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));
knownIPs.add(clientIp);
// Write access log
const logPath = getAccessLogPath();
const timestamp = new Date().toISOString();
const log = `${timestamp} ${clientIp} ${userAgent}\n`;
fs.appendFile(logPath, log, (err) => {
if (err) {
console.error('Failed to write access log:', err);
}
});
}
//clientIp = req.connection.remoteAddress.split(':').pop();
if (whitelistMode === true && !whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x)))
|| forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))
if (!whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x)))
|| forwardedIp && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))
) {
// Log the connection attempt with real IP address
const ipDetails = forwardedIp
? `${clientIp} (forwarded from ${forwardedIp})`
: clientIp;
console.warn(
color.red(
`Blocked connection from ${clientIp}; User Agent: ${userAgent}\n\tTo allow this connection, add its IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your SillyTavern installation.\n`,
),
);
if (!noLogPaths.includes(req.path)) {
console.warn(
color.red(
`Blocked connection from ${clientIp}; User Agent: ${userAgent}\n\tTo allow this connection, add its IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your SillyTavern installation.\n`,
),
);
}
return res.status(403).send(forbiddenWebpage({ ipDetails }));
}
next();

View File

@@ -3,7 +3,7 @@ import path from 'node:path';
import url from 'node:url';
import express from 'express';
import { default as git } from 'simple-git';
import { default as git, CheckRepoActions } from 'simple-git';
import { sync as commandExistsSync } from 'command-exists';
import { getConfigValue, color } from './util.js';
@@ -256,7 +256,7 @@ async function updatePlugins(pluginsPath) {
const pluginPath = path.join(pluginsPath, directory);
const pluginRepo = git(pluginPath);
const isRepo = await pluginRepo.checkIsRepo();
const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
if (!isRepo) {
continue;
}

View File

@@ -805,7 +805,7 @@ export function stringToBool(str) {
* Setup the minimum log level
*/
export function setupLogLevel() {
const logLevel = getConfigValue('minLogLevel', LOG_LEVELS.DEBUG, 'number');
const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number');
globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {};
globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {};