Merge pull request #3 from Darkatse/chore/upstream-sync-1.16-plan

Chore/upstream sync 1.16.0 plan
This commit is contained in:
Darkatse 2026-02-23 17:53:03 -05:00 • committed by GitHub
commit 8fb5dcbf95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
138 changed files with 13185 additions and 2441 deletions

View file

@ -10,6 +10,7 @@
4. [后端结构](./BackendStructure.md) - 后端架构和模块说明
5. [实施计划](./ImplementationPlan.md) - 项目实施的阶段和里程碑
6. [移动端开发说明](./MobileDevelopment.md) - Android/iOS 关键坑位、资源访问与路径解析方案
7. [上游 1.16.0 同步计划](./upstream-sync-1.16-plan/README.md) - 上游升级分阶段计划、冲突矩阵与执行清单
## 项目概述

31
fastools/Cargo.lock generated
View file

@ -182,8 +182,10 @@ dependencies = [
"dialoguer",
"indicatif",
"regex",
"serde",
"serde_json",
"sysinfo",
"walkdir",
"which",
"winreg",
]
@ -473,6 +475,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "semver"
version = "1.0.27"
@ -486,6 +497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
@ -595,6 +607,16 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
@ -729,6 +751,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"

View file

@ -11,8 +11,10 @@ dialoguer = "0.12.0"
indicatif = "0.18.4"
which = "8.0.0"
sysinfo = "0.33"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
regex = "1.12.3"
walkdir = "2.5.0"
[target.'cfg(windows)'.dependencies]
winreg = "0.50"

View file

@ -3,38 +3,60 @@ use chrono::Local;
use colored::*;
use dialoguer::{theme::ColorfulTheme, Select};
use indicatif::{ProgressBar, ProgressStyle};
use regex::Regex;
use std::env;
use std::fs;
use std::io::{BufRead, BufReader};
use std::net::TcpListener;
use std::path::Path;
use std::process::{Command, ExitStatus, Stdio};
use std::time::Duration;
use which::which;
use sysinfo::System;
use std::net::TcpListener;
use std::io::{BufRead, BufReader};
use regex::Regex;
use which::which;
mod upsync;
const TAOBAO_REGISTRY: &str = "https://registry.npmmirror.com";
// 日志辅助函数
fn log_info(msg: &str) {
let time = Local::now().format("%H:%M:%S").to_string();
println!("{} {} {}", format!("[{}]", time).dimmed(), "INFO".cyan().bold(), msg);
println!(
"{} {} {}",
format!("[{}]", time).dimmed(),
"INFO".cyan().bold(),
msg
);
}
fn log_success(msg: &str) {
let time = Local::now().format("%H:%M:%S").to_string();
println!("{} {} {}", format!("[{}]", time).dimmed(), "SUCCESS".green().bold(), msg);
println!(
"{} {} {}",
format!("[{}]", time).dimmed(),
"SUCCESS".green().bold(),
msg
);
}
fn log_warn(msg: &str) {
let time = Local::now().format("%H:%M:%S").to_string();
println!("{} {} {}", format!("[{}]", time).dimmed(), "WARN".yellow().bold(), msg);
println!(
"{} {} {}",
format!("[{}]", time).dimmed(),
"WARN".yellow().bold(),
msg
);
}
fn log_error(msg: &str) {
let time = Local::now().format("%H:%M:%S").to_string();
println!("{} {} {}", format!("[{}]", time).dimmed(), "ERROR".red().bold(), msg);
println!(
"{} {} {}",
format!("[{}]", time).dimmed(),
"ERROR".red().bold(),
msg
);
}
fn main() -> Result<()> {
@ -42,6 +64,15 @@ fn main() -> Result<()> {
#[cfg(windows)]
let _ = colored::control::set_virtual_terminal(true);
let cli_args: Vec<String> = env::args().skip(1).collect();
if !cli_args.is_empty() {
if let Err(error) = run_cli_command(&cli_args) {
eprintln!("ERROR: {:#}", error);
std::process::exit(1);
}
return Ok(());
}
clear_terminal();
print_banner();
@ -55,11 +86,51 @@ fn main() -> Result<()> {
}
}
fn run_cli_command(args: &[String]) -> Result<()> {
match args {
[command, subcommand, rest @ ..] if command == "upsync" && subcommand == "analyze" => {
upsync::run_upsync_analyze_cli(rest)
}
[flag] if flag == "--help" || flag == "-h" => {
print_cli_help();
Ok(())
}
_ => {
print_cli_help();
Err(anyhow::anyhow!("Unsupported command: {}", args.join(" ")))
}
}
}
fn print_cli_help() {
println!("FasTools CLI");
println!();
println!("Usage:");
println!(" fastools upsync analyze [options]");
println!();
println!("Run `fastools upsync analyze --help` for detailed options.");
}
fn handle_error(e: anyhow::Error) {
println!();
println!("{}", "┌──────────────────────────────────────────────────────┐".red().bold());
println!("{} {:^52} {}", "│".red().bold(), "🛑 启动器发生错误 (Launcher Error) 🛑".white().bold(), "│".red().bold());
println!("{}", "└──────────────────────────────────────────────────────┘".red().bold());
println!(
"{}",
"┌──────────────────────────────────────────────────────┐"
.red()
.bold()
);
println!(
"{} {:^52} {}",
"│".red().bold(),
"🛑 启动器发生错误 (Launcher Error) 🛑".white().bold(),
"│".red().bold()
);
println!(
"{}",
"└──────────────────────────────────────────────────────┘"
.red()
.bold()
);
println!();
log_error(&format!("错误详情: {:?}", e));
println!();
@ -114,8 +185,16 @@ fn print_banner() {
}
println!();
println!("{}", " >>> FasTools (TauriTavern Manager) <<< ".truecolor(220, 220, 220).bold());
println!("{}", " ----------------------------------------------------- ".dimmed());
println!(
"{}",
" >>> FasTools (TauriTavern Manager) <<< "
.truecolor(220, 220, 220)
.bold()
);
println!(
"{}",
" ----------------------------------------------------- ".dimmed()
);
println!();
}
@ -125,9 +204,10 @@ fn step_header(current: usize, total: usize, title: &str, subtitle: &str) {
let bar = "█".repeat(filled) + &"░".repeat(bar_len - filled);
println!();
println!("{} {} {} {} {} {}",
"🔵".blue(),
bar.blue().bold(),
println!(
"{} {} {} {} {} {}",
"🔵".blue(),
bar.blue().bold(),
"".clear(),
format!("{}/{}", current, total).bold(),
title.white().bold(),
@ -163,8 +243,8 @@ fn check_environment() -> Result<()> {
// 检查 WebView2 (仅 Windows)
#[cfg(windows)]
if !check_webview2()? {
pause();
std::process::exit(1);
pause();
std::process::exit(1);
}
// 检查 pnpm
@ -187,9 +267,11 @@ fn check_webview2() -> Result<bool> {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let subkey_path = "SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
let subkey_path_64 = "SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
let subkey_path_64 =
"SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
let has_webview2 = hklm.open_subkey(subkey_path).is_ok() || hklm.open_subkey(subkey_path_64).is_ok();
let has_webview2 =
hklm.open_subkey(subkey_path).is_ok() || hklm.open_subkey(subkey_path_64).is_ok();
if has_webview2 {
log_success("WebView2 Runtime 已安装");
@ -239,10 +321,10 @@ fn check_and_install_dependencies() -> Result<()> {
root_dir = root_dir.parent().unwrap().to_path_buf();
env::set_current_dir(&root_dir)?;
} else {
if root_dir.ends_with("launcher") {
root_dir = root_dir.parent().unwrap().to_path_buf();
env::set_current_dir(&root_dir)?;
}
if root_dir.ends_with("launcher") {
root_dir = root_dir.parent().unwrap().to_path_buf();
env::set_current_dir(&root_dir)?;
}
}
}
@ -253,7 +335,7 @@ fn check_and_install_dependencies() -> Result<()> {
// 检查 node_modules
if !Path::new("node_modules").exists() {
log_warn("检测到依赖缺失,准备安装...");
if which("npm").is_ok() {
log_info("设置 npm 镜像源为淘宝源...");
let _ = Command::new(get_cmd("npm"))
@ -300,7 +382,7 @@ fn run_sequential_attempts(candidates: &[(&str, Vec<&str>)]) -> Result<ExitStatu
for (prog, args) in candidates {
// 在 Windows 上自动处理 .cmd 后缀
let cmd_prog = get_cmd(prog);
match Command::new(&cmd_prog)
.args(args)
.stdout(Stdio::inherit())
@ -315,12 +397,13 @@ fn run_sequential_attempts(candidates: &[(&str, Vec<&str>)]) -> Result<ExitStatu
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// 如果加上 .cmd 还没找到,尝试不加后缀(可能用户用的 git bash 或 cygwin)
if cfg!(windows) && *prog != "corepack" { // corepack 通常也是 cmd
match Command::new(prog)
if cfg!(windows) && *prog != "corepack" {
// corepack 通常也是 cmd
match Command::new(prog)
.args(args)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.status()
{
Ok(status) => return Ok(status),
Err(_) => {}
@ -337,10 +420,10 @@ fn run_sequential_attempts(candidates: &[(&str, Vec<&str>)]) -> Result<ExitStatu
}
}
}
Err(anyhow::anyhow!(
"未找到可用的包管理器或执行失败,请安装 pnpm 或 npm 后重试"
Err(
anyhow::anyhow!("未找到可用的包管理器或执行失败,请安装 pnpm 或 npm 后重试")
.context(last_err.unwrap_or_else(|| anyhow::anyhow!("未知错误"))),
)
.context(last_err.unwrap_or_else(|| anyhow::anyhow!("未知错误"))))
}
// 辅助函数:在 Windows 上自动添加 .cmd 后缀
@ -445,14 +528,23 @@ fn backup_data() -> Result<()> {
} else {
// 全局路径检测
let global_path = if cfg!(target_os = "windows") {
env::var("APPDATA").ok().map(|p| Path::new(&p).join("com.tauritavern.client").join("data"))
env::var("APPDATA")
.ok()
.map(|p| Path::new(&p).join("com.tauritavern.client").join("data"))
} else if cfg!(target_os = "macos") {
env::var("HOME").ok().map(|p| Path::new(&p).join("Library/Application Support/com.tauritavern.client/data"))
env::var("HOME").ok().map(|p| {
Path::new(&p).join("Library/Application Support/com.tauritavern.client/data")
})
} else {
// Linux: XDG_CONFIG_HOME or ~/.config
env::var("XDG_CONFIG_HOME").ok()
// Linux: XDG_CONFIG_HOME or ~/.config
env::var("XDG_CONFIG_HOME")
.ok()
.map(|p| Path::new(&p).join("com.tauritavern.client/data"))
.or_else(|| env::var("HOME").ok().map(|p| Path::new(&p).join(".config/com.tauritavern.client/data")))
.or_else(|| {
env::var("HOME")
.ok()
.map(|p| Path::new(&p).join(".config/com.tauritavern.client/data"))
})
};
if let Some(path) = global_path {
@ -481,7 +573,11 @@ fn backup_data() -> Result<()> {
// 获取 data_dir 的绝对路径以便显示和压缩
let abs_data_dir = fs::canonicalize(&data_dir)?;
log_info(&format!("正在创建备份: {} -> {}", abs_data_dir.display(), backup_file));
log_info(&format!(
"正在创建备份: {} -> {}",
abs_data_dir.display(),
backup_file
));
#[cfg(windows)]
{
@ -513,7 +609,7 @@ fn backup_data() -> Result<()> {
{
// Linux/macOS 使用 tar 打包 (tar -czf backup.tar.gz -C parent_dir dir_name)
let backup_file_tar = format!("backups/backup_{}.tar.gz", timestamp);
// 获取父目录和目录名
let parent = abs_data_dir.parent().unwrap_or(Path::new("/"));
let dirname = abs_data_dir.file_name().unwrap();
@ -535,7 +631,7 @@ fn backup_data() -> Result<()> {
}
}
Err(e) => {
log_error(&format!("无法执行 tar: {}", e));
log_error(&format!("无法执行 tar: {}", e));
}
}
}
@ -554,16 +650,23 @@ fn clean_webview2_cache() -> Result<()> {
println!("请确保 TauriTavern 已经完全关闭,否则清理将失败。");
let cache_path = if cfg!(target_os = "windows") {
env::var("LOCALAPPDATA").ok()
env::var("LOCALAPPDATA")
.ok()
.map(|p| Path::new(&p).join("com.tauritavern.client/EBWebView"))
} else if cfg!(target_os = "macos") {
env::var("HOME").ok()
.map(|p| Path::new(&p).join("Library/Caches/com.tauritavern.client"))
env::var("HOME")
.ok()
.map(|p| Path::new(&p).join("Library/Caches/com.tauritavern.client"))
} else {
// Linux
env::var("XDG_CACHE_HOME").ok()
.map(|p| Path::new(&p).join("com.tauritavern.client"))
.or_else(|| env::var("HOME").ok().map(|p| Path::new(&p).join(".cache/com.tauritavern.client")))
env::var("XDG_CACHE_HOME")
.ok()
.map(|p| Path::new(&p).join("com.tauritavern.client"))
.or_else(|| {
env::var("HOME")
.ok()
.map(|p| Path::new(&p).join(".cache/com.tauritavern.client"))
})
};
if let Some(path) = cache_path {
@ -588,14 +691,14 @@ fn clean_webview2_cache() -> Result<()> {
#[cfg(windows)]
log_error("无法定位缓存目录。");
}
pause();
Ok(())
}
fn run_dev() -> Result<()> {
log_info("正在启动 Tauri 开发模式...");
let status = run_sequential_attempts(&[
("pnpm", vec!["run", "tauri:dev"]),
("corepack", vec!["pnpm", "run", "tauri:dev"]),
@ -627,7 +730,7 @@ fn run_android_dev() -> Result<()> {
fn run_build() -> Result<()> {
log_info("正在构建生产版本...");
let status = run_sequential_attempts(&[
("pnpm", vec!["run", "tauri:build"]),
("corepack", vec!["pnpm", "run", "tauri:build"]),
@ -674,11 +777,11 @@ fn clean_environment() -> Result<()> {
} else {
log_info("src-tauri/target 不存在,跳过。");
}
// 清理后需要重新安装依赖
log_info("清理完成,正在重新安装依赖...");
check_and_install_dependencies()?;
pause();
Ok(())
}
@ -692,9 +795,7 @@ fn update_repository() -> Result<()> {
return Ok(());
}
let status = Command::new("git")
.args(&["pull"])
.status();
let status = Command::new("git").args(&["pull"]).status();
match status {
Ok(s) => {
@ -754,7 +855,7 @@ fn show_debug_menu() -> Result<()> {
fn inspect_config() -> Result<()> {
log_info("正在读取 Tauri 配置文件...");
// Check paths
let config_path = if Path::new("src-tauri/tauri.conf.json").exists() {
Path::new("src-tauri/tauri.conf.json").to_path_buf()
@ -767,12 +868,12 @@ fn inspect_config() -> Result<()> {
};
let content = fs::read_to_string(&config_path)?;
let json: serde_json::Value = serde_json::from_str(&content)
.context("解析 tauri.conf.json 失败")?;
let json: serde_json::Value =
serde_json::from_str(&content).context("解析 tauri.conf.json 失败")?;
println!();
println!("{}", "--- Tauri 配置概览 ---".cyan().bold());
if let Some(name) = json.get("productName").and_then(|v| v.as_str()) {
println!("📦 产品名称: {}", name.green());
}
@ -782,27 +883,27 @@ fn inspect_config() -> Result<()> {
if let Some(id) = json.get("identifier").and_then(|v| v.as_str()) {
println!("🆔 包名: {}", id);
}
// Build config
if let Some(build) = json.get("build") {
if let Some(dist) = build.get("frontendDist").and_then(|v| v.as_str()) {
println!("📂 前端输出: {}", dist);
}
if let Some(dev) = build.get("devUrl").and_then(|v| v.as_str()) {
println!("🌐 开发地址: {}", dev);
println!("🌐 开发地址: {}", dev);
}
}
println!();
log_success(&format!("配置文件路径: {:?}", config_path));
pause();
Ok(())
}
fn view_logs() -> Result<()> {
log_info("正在查找日志文件...");
// 智能检测 logs 目录位置
let mut log_dir = Path::new("logs").to_path_buf();
let mut found = false;
@ -815,14 +916,23 @@ fn view_logs() -> Result<()> {
} else {
// 全局路径检测
let global_path = if cfg!(target_os = "windows") {
env::var("APPDATA").ok().map(|p| Path::new(&p).join("com.tauritavern.client").join("logs"))
env::var("APPDATA")
.ok()
.map(|p| Path::new(&p).join("com.tauritavern.client").join("logs"))
} else if cfg!(target_os = "macos") {
env::var("HOME").ok().map(|p| Path::new(&p).join("Library/Logs/com.tauritavern.client"))
env::var("HOME")
.ok()
.map(|p| Path::new(&p).join("Library/Logs/com.tauritavern.client"))
} else {
// Linux: XDG_DATA_HOME or ~/.local/share
env::var("XDG_DATA_HOME").ok()
// Linux: XDG_DATA_HOME or ~/.local/share
env::var("XDG_DATA_HOME")
.ok()
.map(|p| Path::new(&p).join("com.tauritavern.client/logs"))
.or_else(|| env::var("HOME").ok().map(|p| Path::new(&p).join(".local/share/com.tauritavern.client/logs")))
.or_else(|| {
env::var("HOME")
.ok()
.map(|p| Path::new(&p).join(".local/share/com.tauritavern.client/logs"))
})
};
if let Some(path) = global_path {
@ -858,7 +968,15 @@ fn view_logs() -> Result<()> {
}
// 按修改时间降序排序
entries.sort_by_key(|entry| std::cmp::Reverse(entry.metadata().ok().and_then(|m| m.modified().ok()).unwrap_or(std::time::SystemTime::UNIX_EPOCH)));
entries.sort_by_key(|entry| {
std::cmp::Reverse(
entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH),
)
});
let log_file_path = entries[0].path();
log_success(&format!("打开最新日志: {:?}", log_file_path));
@ -867,15 +985,19 @@ fn view_logs() -> Result<()> {
let file = fs::File::open(&log_file_path)?;
let reader = BufReader::new(file);
let lines: Vec<String> = reader.lines().filter_map(Result::ok).collect();
let start = if lines.len() > 50 { lines.len() - 50 } else { 0 };
let start = if lines.len() > 50 {
lines.len() - 50
} else {
0
};
println!();
println!("{}", "--- 日志末尾 50 行 ---".dimmed());
for line in &lines[start..] {
println!("{}", line);
}
println!("{}", "---------------------".dimmed());
pause();
Ok(())
}
@ -884,20 +1006,20 @@ fn kill_process() -> Result<()> {
log_warn("正在扫描相关进程...");
let mut sys = System::new_all();
sys.refresh_all();
let target_names = if cfg!(windows) {
vec!["TauriTavern.exe", "tauritavern.exe"]
} else {
vec!["tauritavern", "TauriTavern"]
};
let mut killed = 0;
for process in sys.processes().values() {
let name = process.name().to_string_lossy();
// 在 Windows 上 process.name() 可能包含 .exe
let match_found = target_names.iter().any(|&target| {
name.eq_ignore_ascii_case(target)
});
let match_found = target_names
.iter()
.any(|&target| name.eq_ignore_ascii_case(target));
if match_found {
println!("发现进程: {} (PID: {}) - 正在终止...", name, process.pid());
@ -908,13 +1030,13 @@ fn kill_process() -> Result<()> {
}
}
}
if killed > 0 {
log_success(&format!("成功终止了 {} 个进程。", killed));
} else {
log_info("未发现运行中的 TauriTavern 进程。");
}
pause();
Ok(())
}
@ -922,18 +1044,18 @@ fn kill_process() -> Result<()> {
fn check_port() -> Result<()> {
let port = 1420;
log_info(&format!("正在检查端口 {} (前端开发服务)...", port));
match TcpListener::bind(format!("127.0.0.1:{}", port)) {
Ok(_listener) => {
log_success(&format!("端口 {} 未被占用 (空闲)", port));
println!(" 这意味着开发服务器目前没有运行。");
},
log_success(&format!("端口 {} 未被占用 (空闲)", port));
println!(" 这意味着开发服务器目前没有运行。");
}
Err(_) => {
log_warn(&format!("端口 {} 已被占用", port));
println!(" 这意味着开发服务器正在运行,或者其他程序占用了该端口。");
log_warn(&format!("端口 {} 已被占用", port));
println!(" 这意味着开发服务器正在运行,或者其他程序占用了该端口。");
}
}
pause();
Ok(())
}
@ -946,8 +1068,8 @@ fn get_tool_version(tool: &str) -> String {
} else {
"Unknown".to_string()
}
},
Err(_) => "Not Found".to_string()
}
Err(_) => "Not Found".to_string(),
}
}
@ -955,17 +1077,27 @@ fn sys_info() -> Result<()> {
log_info("正在获取系统信息...");
let mut sys = System::new_all();
sys.refresh_all();
println!();
println!("{}", "--- 系统概览 ---".cyan().bold());
println!("🖥️ 系统: {} {}", System::name().unwrap_or("Unknown".into()), System::os_version().unwrap_or("".into()));
println!("⚙️ 内核: {}", System::kernel_version().unwrap_or("Unknown".into()));
println!("🏠 主机名: {}", System::host_name().unwrap_or("Unknown".into()));
println!(
"🖥️ 系统: {} {}",
System::name().unwrap_or("Unknown".into()),
System::os_version().unwrap_or("".into())
);
println!(
"⚙️ 内核: {}",
System::kernel_version().unwrap_or("Unknown".into())
);
println!(
"🏠 主机名: {}",
System::host_name().unwrap_or("Unknown".into())
);
let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
println!("💾 内存: {:.2} GB / {:.2} GB", used_mem, total_mem);
let cpus = sys.cpus();
if !cpus.is_empty() {
println!("🧠 CPU: {} ({} 核心)", cpus[0].brand(), cpus.len());
@ -986,11 +1118,11 @@ fn sys_info() -> Result<()> {
// 为了不让它报错退出,我们需要稍微修改一下 check_webview2 或者在这里捕获它的输出
// 由于 check_webview2 返回 Result<()>,我们可以直接调用
match check_webview2() {
Ok(_) => {}, // 它会打印 "WebView2 Runtime 已安装"
Ok(_) => {} // 它会打印 "WebView2 Runtime 已安装"
Err(_) => println!("WebView2 Runtime 未检测到或检查失败"),
}
}
println!();
pause();
Ok(())
@ -999,12 +1131,12 @@ fn sys_info() -> Result<()> {
fn run_debug() -> Result<()> {
log_info("正在启动调试模式 (Debug Mode)...");
log_info("已启用: RUST_LOG=debug, RUST_BACKTRACE=1");
// Set environment variables
env::set_var("RUST_LOG", "debug");
env::set_var("RUST_BACKTRACE", "1");
// Force colors
env::set_var("FORCE_COLOR", "1");
env::set_var("FORCE_COLOR", "1");
env::set_var("CARGO_TERM_COLOR", "always");
// 与 run_sequential_attempts 保持一致的多候选回退策略,避免单条命令 NotFound 直接失败。
@ -1089,7 +1221,7 @@ fn run_debug() -> Result<()> {
// Wait for child
let status = child.wait()?;
stdout_handle.join().unwrap();
stderr_handle.join().unwrap();
@ -1106,21 +1238,26 @@ fn process_log_line(line: &str, is_stderr: bool) {
let re = Regex::new(r"\x1b\[[0-9;]*m").unwrap();
let clean_line = re.replace_all(line, "");
let upper = clean_line.to_uppercase();
let timestamp = Local::now().format("%H:%M:%S").to_string();
let prefix = format!("[{}]", timestamp).dimmed();
// Check for errors/warnings
if upper.contains("ERROR") || (is_stderr && !upper.contains("WARN") && !upper.contains("INFO") && !upper.contains("DEBUG")) {
if upper.contains("ERROR")
|| (is_stderr
&& !upper.contains("WARN")
&& !upper.contains("INFO")
&& !upper.contains("DEBUG"))
{
// Treat generic stderr as error unless it looks like other levels
// Note: Some tools print normal info to stderr, so be careful.
// If line contains "ERROR", definitely red.
if upper.contains("ERROR") {
println!("{} {} {}", prefix, "ERR".red().bold(), line);
println!("{} {} {}", prefix, "ERR".red().bold(), line);
} else {
// Maybe just yellow for unknown stderr? Or just print it.
// Let's just print stderr as is but with prefix, unless it has specific keywords.
println!("{} {}", prefix, line);
// Maybe just yellow for unknown stderr? Or just print it.
// Let's just print stderr as is but with prefix, unless it has specific keywords.
println!("{} {}", prefix, line);
}
} else if upper.contains("WARN") {
println!("{} {} {}", prefix, "WARN".yellow().bold(), line);

1009
fastools/src/upsync.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "tauritavern",
"private": true,
"version": "1.1.4",
"version": "1.2.0-beta",
"type": "module",
"scripts": {
"tauri": "tauri",

1
sillytavern-1.15.0 Submodule

@ -0,0 +1 @@
Subproject commit f63399a4f9c5ef0f311b305e82b8734333375ab2

1
sillytavern-1.16.0 Submodule

@ -0,0 +1 @@
Subproject commit 7ffb28f753b98759bc7f3ac780e2743120023657

2
src-tauri/Cargo.lock generated
View file

@ -5198,7 +5198,7 @@ dependencies = [
[[package]]
name = "tauritavern"
version = "1.1.4"
version = "1.2.0-beta"
dependencies = [
"async-trait",
"base64 0.21.7",

View file

@ -1,6 +1,6 @@
[package]
name = "tauritavern"
version = "1.1.4"
version = "1.2.0-beta"
description = "SillyTavern backend rebuilt with Tauri and Rust"
authors = ["TauriTavern Team"]
edition = "2021"

View file

@ -3,7 +3,9 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
fn main() {
fn main() {
// Ensure embedded frontend assets are regenerated whenever anything under frontendDist changes.
println!("cargo:rerun-if-changed=../src");
println!("cargo:rerun-if-changed=../default/content");
println!("cargo:rerun-if-changed=../src/scripts/templates");
println!("cargo:rerun-if-changed=../src/scripts/extensions");

View file

@ -218,7 +218,6 @@ fn build_repositories(
Arc::new(FileExtensionRepository::new(
default_user_dir.join("extensions"),
data_root.join("extensions").join("third-party"),
data_root.join("extensions"),
));
let avatar_repository: Arc<dyn AvatarRepository> = Arc::new(FileAvatarRepository::new(
@ -230,9 +229,11 @@ fn build_repositories(
data_directory.group_chats().to_path_buf(),
));
let background_repository: Arc<dyn BackgroundRepository> = Arc::new(
FileBackgroundRepository::new(data_directory.default_user().join("backgrounds")),
);
let background_repository: Arc<dyn BackgroundRepository> =
Arc::new(FileBackgroundRepository::new(
data_directory.default_user().join("backgrounds"),
data_directory.default_user().join("thumbnails/bg"),
));
let theme_repository: Arc<dyn ThemeRepository> =
Arc::new(FileThemeRepository::new(default_user_dir.join("themes")));

View file

@ -8,6 +8,7 @@ pub struct UserDirectoryDto {
pub thumbnails: String,
pub thumbnails_bg: String,
pub thumbnails_avatar: String,
pub thumbnails_persona: String,
pub worlds: String,
pub user: String,
pub avatars: String,
@ -44,6 +45,7 @@ impl From<UserDirectory> for UserDirectoryDto {
thumbnails: directory.thumbnails.to_string_lossy().to_string(),
thumbnails_bg: directory.thumbnails_bg.to_string_lossy().to_string(),
thumbnails_avatar: directory.thumbnails_avatar.to_string_lossy().to_string(),
thumbnails_persona: directory.thumbnails_persona.to_string_lossy().to_string(),
worlds: directory.worlds.to_string_lossy().to_string(),
user: directory.user.to_string_lossy().to_string(),
avatars: directory.avatars.to_string_lossy().to_string(),

View file

@ -1,5 +1,7 @@
use crate::domain::errors::DomainError;
use crate::domain::models::background::Background;
use crate::domain::models::background::{
Background, BackgroundAsset, BackgroundImageMetadataIndex,
};
use crate::domain::repositories::background_repository::BackgroundRepository;
use crate::infrastructure::logging::logger;
use std::sync::Arc;
@ -93,4 +95,40 @@ impl BackgroundService {
self.repository.upload_background(filename, data).await
}
pub async fn read_background_thumbnail(
&self,
filename: &str,
animated: bool,
) -> Result<BackgroundAsset, DomainError> {
logger::debug(&format!(
"BackgroundService: Reading thumbnail for '{}' (animated: {})",
filename, animated
));
if filename.is_empty() {
return Err(DomainError::InvalidData(
"Background filename cannot be empty".to_string(),
));
}
self.repository
.read_background_thumbnail(filename, animated)
.await
}
pub async fn get_all_background_metadata(
&self,
prefix: Option<&str>,
) -> Result<BackgroundImageMetadataIndex, DomainError> {
logger::debug("BackgroundService: Getting all background metadata");
let mut metadata = self.repository.get_all_background_metadata().await?;
let prefix = prefix.unwrap_or_default().trim();
if !prefix.is_empty() {
metadata.images.retain(|key, _| key.starts_with(prefix));
}
Ok(metadata)
}
}

View file

@ -258,6 +258,8 @@ fn supports_reverse_proxy(source: ChatCompletionSource) -> bool {
| ChatCompletionSource::Claude
| ChatCompletionSource::Makersuite
| ChatCompletionSource::DeepSeek
| ChatCompletionSource::Moonshot
| ChatCompletionSource::Zai
)
}
@ -284,8 +286,8 @@ mod tests {
use crate::domain::repositories::chat_completion_repository::ChatCompletionSource;
use super::{
default_base_url, source_extra_headers, ApiConfigPurpose, DEEPSEEK_STATUS_API_BASE,
OPENROUTER_API_BASE, ZAI_API_BASE_CODING,
default_base_url, source_extra_headers, supports_reverse_proxy, ApiConfigPurpose,
DEEPSEEK_STATUS_API_BASE, OPENROUTER_API_BASE, ZAI_API_BASE_CODING,
};
#[test]
@ -322,4 +324,10 @@ mod tests {
assert!(headers.contains_key("HTTP-Referer"));
assert!(headers.contains_key("X-Title"));
}
#[test]
fn moonshot_and_zai_support_reverse_proxy() {
assert!(supports_reverse_proxy(ChatCompletionSource::Moonshot));
assert!(supports_reverse_proxy(ChatCompletionSource::Zai));
}
}

View file

@ -156,6 +156,7 @@ fn build_claude_payload(
}
}
let mut should_convert_prefill_to_user = requires_claude_prefill_role_fix(model);
if supports_claude_thinking(model) {
let reasoning_effort = payload.get("reasoning_effort").and_then(Value::as_str);
if let Some(budget_tokens) =
@ -175,11 +176,14 @@ fn build_claude_payload(
request.remove("temperature");
request.remove("top_p");
request.remove("top_k");
convert_thinking_prefill_to_user(&mut messages);
should_convert_prefill_to_user = true;
}
}
if should_convert_prefill_to_user {
convert_thinking_prefill_to_user(&mut messages);
}
request.insert("messages".to_string(), Value::Array(messages));
request.insert(
"max_tokens".to_string(),
@ -404,11 +408,19 @@ fn supports_claude_thinking(model: &str) -> bool {
"claude-sonnet-4",
"claude-haiku-4-5",
"claude-opus-4-5",
"claude-opus-4-6",
]
.iter()
.any(|prefix| model.starts_with(prefix))
}
fn requires_claude_prefill_role_fix(model: &str) -> bool {
model
.trim()
.to_ascii_lowercase()
.starts_with("claude-opus-4-6")
}
fn calculate_claude_budget_tokens(
max_tokens: i64,
reasoning_effort: Option<&str>,
@ -503,6 +515,36 @@ mod tests {
assert_eq!(last_role, "user");
}
#[test]
fn claude_opus_4_6_prefill_is_converted_to_user_without_thinking() {
let payload = json!({
"model": "claude-opus-4-6",
"messages": [{"role": "user", "content": "hello"}],
"assistant_prefill": "prefill",
"max_tokens": 1000,
"reasoning_effort": "auto",
"stream": false
})
.as_object()
.cloned()
.expect("payload must be object");
let (_, upstream) = build(payload).expect("build should succeed");
let body = upstream.as_object().expect("body must be object");
assert!(body.get("thinking").is_none());
let last_role = body
.get("messages")
.and_then(Value::as_array)
.and_then(|messages| messages.last())
.and_then(Value::as_object)
.and_then(|message| message.get("role"))
.and_then(Value::as_str)
.unwrap_or_default();
assert_eq!(last_role, "user");
}
#[test]
fn claude_depth_cache_marks_two_turn_boundaries() {
let payload = json!({

View file

@ -6,6 +6,7 @@ use crate::domain::repositories::chat_completion_repository::ChatCompletionSourc
mod claude;
mod custom;
mod makersuite;
mod moonshot;
mod openai;
mod openrouter;
mod prompt_cache;
@ -20,8 +21,8 @@ pub(super) fn build_payload(
match source {
ChatCompletionSource::OpenAi
| ChatCompletionSource::DeepSeek
| ChatCompletionSource::Moonshot
| ChatCompletionSource::SiliconFlow => Ok(openai::build(payload)),
ChatCompletionSource::Moonshot => Ok(moonshot::build(payload)),
ChatCompletionSource::OpenRouter => Ok(openrouter::build(payload)),
ChatCompletionSource::Zai => Ok(zai::build(payload)),
ChatCompletionSource::Custom => custom::build(payload),

View file

@ -0,0 +1,59 @@
use serde_json::{Map, Value};
use super::openai;
pub(super) fn build(payload: Map<String, Value>) -> (String, Value) {
let include_reasoning = payload
.get("include_reasoning")
.and_then(Value::as_bool)
.unwrap_or(false);
let (endpoint, mut upstream_payload) = openai::build(payload);
if endpoint == "/chat/completions" {
if let Some(body) = upstream_payload.as_object_mut() {
body.insert(
"thinking".to_string(),
serde_json::json!({
"type": if include_reasoning { "enabled" } else { "disabled" },
}),
);
}
}
(endpoint, upstream_payload)
}
#[cfg(test)]
mod tests {
use serde_json::{json, Value};
use super::build;
#[test]
fn moonshot_payload_injects_thinking_flag() {
let payload = json!({
"model": "moonshot-v1-8k",
"messages": [{"role": "user", "content": "hello"}],
"include_reasoning": true,
"chat_completion_source": "moonshot"
})
.as_object()
.cloned()
.expect("payload must be object");
let (endpoint, upstream) = build(payload);
assert_eq!(endpoint, "/chat/completions");
let thinking_type = upstream
.as_object()
.and_then(|object| object.get("thinking"))
.and_then(Value::as_object)
.and_then(|thinking| thinking.get("type"))
.and_then(Value::as_str)
.unwrap_or_default();
assert_eq!(thinking_type, "enabled");
}
}

View file

@ -133,18 +133,32 @@ fn map_provider_preferences(source_payload: &Map<String, Value>) -> Option<Value
let order = source_payload
.get("provider")
.and_then(Value::as_array)
.filter(|providers| !providers.is_empty())?
.clone();
.filter(|providers| !providers.is_empty())
.cloned();
let quantizations = source_payload
.get("quantizations")
.and_then(Value::as_array)
.filter(|items| !items.is_empty())
.cloned();
let allow_fallbacks = source_payload
.get("allow_fallbacks")
.and_then(Value::as_bool)
.unwrap_or(true);
if order.is_none() && quantizations.is_none() {
return None;
}
Some(json!({
"allow_fallbacks": allow_fallbacks,
"order": order,
}))
let mut provider = Map::new();
if let Some(order) = order {
let allow_fallbacks = source_payload
.get("allow_fallbacks")
.and_then(Value::as_bool)
.unwrap_or(true);
provider.insert("allow_fallbacks".to_string(), Value::Bool(allow_fallbacks));
provider.insert("order".to_string(), Value::Array(order));
}
if let Some(quantizations) = quantizations {
provider.insert("quantizations".to_string(), Value::Array(quantizations));
}
Some(Value::Object(provider))
}
#[cfg(test)]
@ -227,6 +241,36 @@ mod tests {
assert_eq!(transforms_len, 0);
}
#[test]
fn openrouter_quantizations_are_forwarded_without_provider_order() {
let payload = json!({
"chat_completion_source": "openrouter",
"model": "openai/gpt-4.1-mini",
"messages": [{"role": "user", "content": "hello"}],
"quantizations": ["int8", "fp16"]
})
.as_object()
.cloned()
.expect("payload must be object");
let (_, upstream) = build(payload);
let quantizations = upstream
.as_object()
.and_then(|body| body.get("provider"))
.and_then(Value::as_object)
.and_then(|provider| provider.get("quantizations"))
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.collect::<Vec<&str>>()
})
.unwrap_or_default();
assert_eq!(quantizations, vec!["int8", "fp16"]);
}
#[test]
fn openrouter_claude_cache_is_applied_for_system_and_depth() {
let payload = json!({

View file

@ -1,3 +1,5 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// Represents a background image in the system
@ -16,3 +18,26 @@ impl Background {
Self { filename, path }
}
}
#[derive(Debug, Clone)]
pub struct BackgroundAsset {
pub bytes: Vec<u8>,
pub mime_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundImageMetadata {
pub aspect_ratio: f64,
pub is_animated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub dominant_color: Option<String>,
pub added_timestamp: i64,
pub thumbnail_resolution: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackgroundImageMetadataIndex {
pub version: u8,
pub images: HashMap<String, BackgroundImageMetadata>,
}

View file

@ -222,6 +222,9 @@ impl SecretKeys {
pub const ZAI: &'static str = "api_key_zai";
pub const SILICONFLOW: &'static str = "api_key_siliconflow";
pub const ELEVENLABS: &'static str = "api_key_elevenlabs";
pub const POLLINATIONS: &'static str = "api_key_pollinations";
pub const VOLCENGINE_APP_ID: &'static str = "volcengine_app_id";
pub const VOLCENGINE_ACCESS_KEY: &'static str = "volcengine_access_key";
pub fn known_keys() -> &'static [&'static str] {
&[
@ -283,6 +286,9 @@ impl SecretKeys {
Self::ZAI,
Self::SILICONFLOW,
Self::ELEVENLABS,
Self::POLLINATIONS,
Self::VOLCENGINE_APP_ID,
Self::VOLCENGINE_ACCESS_KEY,
]
}

View file

@ -9,6 +9,7 @@ pub struct UserDirectory {
pub thumbnails: PathBuf,
pub thumbnails_bg: PathBuf,
pub thumbnails_avatar: PathBuf,
pub thumbnails_persona: PathBuf,
pub worlds: PathBuf,
pub user: PathBuf,
pub avatars: PathBuf,
@ -48,6 +49,7 @@ impl UserDirectory {
thumbnails: root.join("thumbnails"),
thumbnails_bg: root.join("thumbnails/bg"),
thumbnails_avatar: root.join("thumbnails/avatar"),
thumbnails_persona: root.join("thumbnails/persona"),
worlds: root.join("worlds"),
user: root.join("user"),
avatars: root.join("User Avatars"),
@ -89,6 +91,7 @@ impl UserDirectory {
&self.thumbnails,
&self.thumbnails_bg,
&self.thumbnails_avatar,
&self.thumbnails_persona,
&self.worlds,
&self.user,
&self.avatars,

View file

@ -1,5 +1,7 @@
use crate::domain::errors::DomainError;
use crate::domain::models::background::Background;
use crate::domain::models::background::{
Background, BackgroundAsset, BackgroundImageMetadataIndex,
};
use async_trait::async_trait;
/// Repository interface for background images
@ -20,4 +22,16 @@ pub trait BackgroundRepository: Send + Sync {
/// Upload a new background image
async fn upload_background(&self, filename: &str, data: &[u8]) -> Result<String, DomainError>;
/// Read a background thumbnail asset.
async fn read_background_thumbnail(
&self,
filename: &str,
animated: bool,
) -> Result<BackgroundAsset, DomainError>;
/// Get metadata for all system backgrounds.
async fn get_all_background_metadata(
&self,
) -> Result<BackgroundImageMetadataIndex, DomainError>;
}

View file

@ -13,6 +13,8 @@ use super::normalizers;
use super::HttpChatCompletionRepository;
const ANTHROPIC_VERSION: &str = "2023-06-01";
const ANTHROPIC_BETA_OUTPUT_128K: &str = "output-128k-2025-02-19";
const ANTHROPIC_BETA_CONTEXT_1M: &str = "context-1m-2025-08-07";
const ANTHROPIC_BETA_PROMPT_CACHING: &str = "prompt-caching-2024-07-31";
const ANTHROPIC_BETA_EXTENDED_CACHE_TTL: &str = "extended-cache-ttl-2025-04-11";
@ -165,7 +167,29 @@ fn apply_anthropic_beta_header(
config: &ChatCompletionApiConfig,
payload: &Value,
) -> RequestBuilder {
let mut beta_values = configured_anthropic_beta_values(&config.extra_headers);
let beta_values = build_anthropic_beta_values(&config.extra_headers, payload);
if beta_values.is_empty() {
return request;
}
request.header("anthropic-beta", beta_values.join(","))
}
fn build_anthropic_beta_values(
extra_headers: &HashMap<String, String>,
payload: &Value,
) -> Vec<String> {
let mut beta_values = vec![
ANTHROPIC_BETA_OUTPUT_128K.to_string(),
ANTHROPIC_BETA_CONTEXT_1M.to_string(),
];
for value in configured_anthropic_beta_values(extra_headers) {
if !beta_values.iter().any(|existing| existing == &value) {
beta_values.push(value);
}
}
if payload_contains_cache_control(payload) {
for value in [
@ -178,11 +202,7 @@ fn apply_anthropic_beta_header(
}
}
if beta_values.is_empty() {
return request;
}
request.header("anthropic-beta", beta_values.join(","))
beta_values
}
fn configured_anthropic_beta_values(extra_headers: &HashMap<String, String>) -> Vec<String> {
@ -219,8 +239,10 @@ mod tests {
use serde_json::json;
use super::{
configured_anthropic_beta_values, payload_contains_cache_control,
ANTHROPIC_BETA_EXTENDED_CACHE_TTL, ANTHROPIC_BETA_PROMPT_CACHING,
build_anthropic_beta_values, configured_anthropic_beta_values,
payload_contains_cache_control, ANTHROPIC_BETA_CONTEXT_1M,
ANTHROPIC_BETA_EXTENDED_CACHE_TTL, ANTHROPIC_BETA_OUTPUT_128K,
ANTHROPIC_BETA_PROMPT_CACHING,
};
#[test]
@ -257,4 +279,31 @@ mod tests {
]
);
}
#[test]
fn always_includes_default_beta_values() {
let headers = HashMap::new();
let payload = json!({ "messages": [{"role": "user", "content": "hello"}] });
let beta_values = build_anthropic_beta_values(&headers, &payload);
assert!(beta_values.contains(&ANTHROPIC_BETA_OUTPUT_128K.to_string()));
assert!(beta_values.contains(&ANTHROPIC_BETA_CONTEXT_1M.to_string()));
}
#[test]
fn cache_control_adds_cache_beta_values() {
let headers = HashMap::new();
let payload = json!({
"messages": [{
"content": [{
"type": "text",
"cache_control": { "type": "ephemeral", "ttl": "5m" }
}]
}]
});
let beta_values = build_anthropic_beta_values(&headers, &payload);
assert!(beta_values.contains(&ANTHROPIC_BETA_PROMPT_CACHING.to_string()));
assert!(beta_values.contains(&ANTHROPIC_BETA_EXTENDED_CACHE_TTL.to_string()));
}
}

View file

@ -64,6 +64,7 @@ impl DataDirectory {
"thumbnails",
"thumbnails/bg",
"thumbnails/avatar",
"thumbnails/persona",
"worlds",
"user",
"user/images",

View file

@ -5,3 +5,4 @@ pub mod data_archive_jobs;
pub mod file_system;
pub mod jsonl_utils;
pub mod png_utils;
pub mod thumbnail_cache;

View file

@ -0,0 +1,300 @@
use image::codecs::jpeg::JpegEncoder;
use image::imageops::FilterType;
use mime_guess::from_path;
use std::path::Path;
use tokio::fs;
use tokio::io::AsyncReadExt;
use crate::domain::errors::DomainError;
const ANIMATED_EXTENSIONS: &[&str] = &[".apng", ".mp4", ".webm", ".avi", ".mkv", ".flv", ".gif"];
#[derive(Debug, Clone, Copy)]
pub enum ThumbnailResizeMode {
PreserveArea,
Cover,
}
#[derive(Debug, Clone, Copy)]
pub struct ThumbnailConfig {
pub width: u32,
pub height: u32,
pub quality: u8,
pub resize_mode: ThumbnailResizeMode,
}
#[derive(Debug, Clone)]
pub struct ThumbnailAsset {
pub bytes: Vec<u8>,
pub mime_type: String,
}
fn extension_lowercase(path: &Path) -> String {
path.extension()
.and_then(|extension| extension.to_str())
.map(|extension| format!(".{}", extension.to_ascii_lowercase()))
.unwrap_or_default()
}
fn is_apng_header(buffer: &[u8]) -> bool {
buffer.windows(4).any(|chunk| chunk == b"acTL")
}
fn is_animated_webp_header(buffer: &[u8]) -> bool {
buffer
.windows(4)
.any(|chunk| chunk == b"ANIM" || chunk == b"ANMF")
}
async fn read_image_header(path: &Path) -> Result<Vec<u8>, DomainError> {
let mut file = fs::File::open(path).await.map_err(|error| {
DomainError::InternalError(format!(
"Failed to inspect image header '{}': {}",
path.display(),
error
))
})?;
let mut header = vec![0u8; 512];
let read_len = file.read(&mut header).await.map_err(|error| {
DomainError::InternalError(format!(
"Failed to inspect image header '{}': {}",
path.display(),
error
))
})?;
header.truncate(read_len);
Ok(header)
}
pub async fn is_animated_image(path: &Path) -> Result<bool, DomainError> {
let extension = extension_lowercase(path);
if ANIMATED_EXTENSIONS.contains(&extension.as_str()) {
return Ok(true);
}
if extension == ".png" {
let header = read_image_header(path).await?;
return Ok(is_apng_header(&header));
}
if extension == ".webp" {
let header = read_image_header(path).await?;
return Ok(is_animated_webp_header(&header));
}
Ok(false)
}
async fn read_original_asset(original_path: &Path) -> Result<ThumbnailAsset, DomainError> {
let bytes = fs::read(original_path).await.map_err(|error| {
DomainError::InternalError(format!(
"Failed to read original image '{}': {}",
original_path.display(),
error
))
})?;
let mime_type = from_path(original_path)
.first_or_octet_stream()
.essence_str()
.to_string();
Ok(ThumbnailAsset { bytes, mime_type })
}
async fn thumbnail_is_fresh(
thumbnail_path: &Path,
original_path: &Path,
) -> Result<bool, DomainError> {
let thumbnail_metadata = match fs::metadata(thumbnail_path).await {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
return Err(DomainError::InternalError(format!(
"Failed to read thumbnail metadata '{}': {}",
thumbnail_path.display(),
error
)));
}
};
let original_metadata = fs::metadata(original_path).await.map_err(|error| {
DomainError::InternalError(format!(
"Failed to read original image metadata '{}': {}",
original_path.display(),
error
))
})?;
let Some(original_modified) = original_metadata.modified().ok() else {
return Ok(false);
};
let Some(thumbnail_modified) = thumbnail_metadata.modified().ok() else {
return Ok(false);
};
Ok(original_modified <= thumbnail_modified)
}
async fn generate_thumbnail(
original_path: &Path,
thumbnail_path: &Path,
config: ThumbnailConfig,
) -> Result<(), DomainError> {
let source_bytes = fs::read(original_path).await.map_err(|error| {
DomainError::InternalError(format!(
"Failed to read source image '{}': {}",
original_path.display(),
error
))
})?;
let source_image = image::load_from_memory(&source_bytes).map_err(|error| {
DomainError::InternalError(format!(
"Failed to decode source image '{}': {}",
original_path.display(),
error
))
})?;
let width = config.width.max(1);
let height = config.height.max(1);
let thumbnail_image = match config.resize_mode {
ThumbnailResizeMode::PreserveArea => {
let source_width = source_image.width().max(1);
let source_height = source_image.height().max(1);
let aspect_ratio = source_width as f64 / source_height as f64;
let target_area = (width as f64) * (height as f64);
let thumbnail_width = ((target_area * aspect_ratio).sqrt().round() as u32).max(1);
let thumbnail_height = ((target_area / aspect_ratio).sqrt().round() as u32).max(1);
source_image.resize(thumbnail_width, thumbnail_height, FilterType::Triangle)
}
ThumbnailResizeMode::Cover => {
source_image.resize_to_fill(width, height, FilterType::Triangle)
}
};
let quality = config.quality.clamp(1, 100);
let mut encoded = Vec::new();
let mut encoder = JpegEncoder::new_with_quality(&mut encoded, quality);
encoder.encode_image(&thumbnail_image).map_err(|error| {
DomainError::InternalError(format!(
"Failed to encode thumbnail for '{}': {}",
original_path.display(),
error
))
})?;
if let Some(parent) = thumbnail_path.parent() {
fs::create_dir_all(parent).await.map_err(|error| {
DomainError::InternalError(format!(
"Failed to ensure thumbnail directory '{}': {}",
parent.display(),
error
))
})?;
}
let temp_path = thumbnail_path.with_extension("tmp");
fs::write(&temp_path, &encoded).await.map_err(|error| {
DomainError::InternalError(format!(
"Failed to write temporary thumbnail '{}': {}",
temp_path.display(),
error
))
})?;
match fs::remove_file(thumbnail_path).await {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(DomainError::InternalError(format!(
"Failed to replace thumbnail '{}': {}",
thumbnail_path.display(),
error
)));
}
}
fs::rename(&temp_path, thumbnail_path)
.await
.map_err(|error| {
DomainError::InternalError(format!(
"Failed to finalize thumbnail '{}': {}",
thumbnail_path.display(),
error
))
})
}
async fn ensure_thumbnail(
original_path: &Path,
thumbnail_path: &Path,
config: ThumbnailConfig,
) -> Result<(), DomainError> {
if thumbnail_is_fresh(thumbnail_path, original_path).await? {
return Ok(());
}
generate_thumbnail(original_path, thumbnail_path, config).await
}
pub async fn read_thumbnail_or_original(
original_path: &Path,
thumbnail_path: &Path,
config: ThumbnailConfig,
) -> Result<ThumbnailAsset, DomainError> {
let original_metadata =
fs::metadata(original_path)
.await
.map_err(|error| match error.kind() {
std::io::ErrorKind::NotFound => DomainError::NotFound(format!(
"Source image not found: {}",
original_path.display()
)),
_ => DomainError::InternalError(format!(
"Failed to read source image metadata '{}': {}",
original_path.display(),
error
)),
})?;
if !original_metadata.is_file() {
return Err(DomainError::NotFound(format!(
"Source image not found: {}",
original_path.display()
)));
}
let is_animated = is_animated_image(original_path).await?;
if is_animated {
return read_original_asset(original_path).await;
}
if ensure_thumbnail(original_path, thumbnail_path, config)
.await
.is_err()
{
return read_original_asset(original_path).await;
}
match fs::read(thumbnail_path).await {
Ok(bytes) => Ok(ThumbnailAsset {
bytes,
mime_type: "image/jpeg".to_string(),
}),
Err(_) => read_original_asset(original_path).await,
}
}
pub async fn invalidate_thumbnail_cache(thumbnail_path: &Path) -> Result<(), DomainError> {
match fs::remove_file(thumbnail_path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(DomainError::InternalError(format!(
"Failed to remove thumbnail cache '{}': {}",
thumbnail_path.display(),
error
))),
}
}

View file

@ -1,22 +1,39 @@
use async_trait::async_trait;
use mime_guess::from_path;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::fs;
use crate::domain::errors::DomainError;
use crate::domain::models::background::Background;
use crate::domain::models::background::{
Background, BackgroundAsset, BackgroundImageMetadata, BackgroundImageMetadataIndex,
};
use crate::domain::repositories::background_repository::BackgroundRepository;
use crate::infrastructure::logging::logger;
use crate::infrastructure::persistence::thumbnail_cache::{
invalidate_thumbnail_cache, is_animated_image, read_thumbnail_or_original, ThumbnailConfig,
ThumbnailResizeMode,
};
const THUMBNAIL_WIDTH: u32 = 160;
const THUMBNAIL_HEIGHT: u32 = 90;
const THUMBNAIL_QUALITY: u8 = 90;
const THUMBNAIL_RESOLUTION: u32 = THUMBNAIL_WIDTH * THUMBNAIL_HEIGHT;
/// File system implementation of the BackgroundRepository
pub struct FileBackgroundRepository {
backgrounds_dir: PathBuf,
thumbnails_bg_dir: PathBuf,
}
impl FileBackgroundRepository {
/// Create a new FileBackgroundRepository instance
pub fn new(backgrounds_dir: PathBuf) -> Self {
Self { backgrounds_dir }
pub fn new(backgrounds_dir: PathBuf, thumbnails_bg_dir: PathBuf) -> Self {
Self {
backgrounds_dir,
thumbnails_bg_dir,
}
}
/// Check if a file is an image
@ -27,9 +44,119 @@ impl FileBackgroundRepository {
false
}
/// Get the full path for a background filename
fn get_full_path(&self, filename: &str) -> PathBuf {
self.backgrounds_dir.join(filename)
fn normalize_filename(&self, filename: &str) -> Result<String, DomainError> {
let sanitized = filename
.chars()
.map(|ch| match ch {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ if ch.is_control() => '_',
_ => ch,
})
.collect::<String>();
let sanitized = sanitized.trim().trim_end_matches(['.', ' ']).to_string();
if sanitized.is_empty() {
return Err(DomainError::InvalidData(
"Invalid background filename".to_string(),
));
}
Ok(sanitized)
}
fn thumbnail_cache_path(&self, filename: &str) -> PathBuf {
self.thumbnails_bg_dir.join(filename)
}
async fn ensure_backgrounds_dir_exists(&self) -> Result<(), DomainError> {
if self.backgrounds_dir.exists() {
return Ok(());
}
fs::create_dir_all(&self.backgrounds_dir)
.await
.map_err(|error| {
logger::error(&format!(
"Failed to create backgrounds directory: {}",
error
));
DomainError::InternalError(format!(
"Failed to create backgrounds directory: {}",
error
))
})
}
async fn invalidate_thumbnail_cache(&self, filename: &str) -> Result<(), DomainError> {
let thumbnail_path = self.thumbnail_cache_path(filename);
invalidate_thumbnail_cache(&thumbnail_path).await
}
fn round_aspect_ratio(value: f64) -> f64 {
(value * 10_000.0).round() / 10_000.0
}
fn system_time_to_timestamp_millis(time: SystemTime) -> Option<i64> {
let millis = time.duration_since(UNIX_EPOCH).ok()?.as_millis();
i64::try_from(millis).ok()
}
fn file_added_timestamp_millis(metadata: &std::fs::Metadata) -> i64 {
metadata
.created()
.ok()
.and_then(Self::system_time_to_timestamp_millis)
.or_else(|| {
metadata
.modified()
.ok()
.and_then(Self::system_time_to_timestamp_millis)
})
.unwrap_or_else(|| chrono::Utc::now().timestamp_millis())
}
async fn build_background_metadata(
&self,
original_path: &Path,
) -> Result<Option<BackgroundImageMetadata>, DomainError> {
let (width, height) = match image::image_dimensions(original_path) {
Ok(dimensions) => dimensions,
Err(error) => {
logger::warn(&format!(
"Failed to read background dimensions '{}': {}",
original_path.display(),
error
));
return Ok(None);
}
};
if width == 0 || height == 0 {
return Ok(None);
}
let file_metadata = fs::metadata(original_path).await.map_err(|error| {
DomainError::InternalError(format!(
"Failed to read background metadata '{}': {}",
original_path.display(),
error
))
})?;
let is_animated = is_animated_image(original_path).await?;
let dominant_color = if is_animated {
Some("#808080".to_string())
} else {
None
};
Ok(Some(BackgroundImageMetadata {
aspect_ratio: Self::round_aspect_ratio((width as f64) / (height as f64)),
is_animated,
dominant_color,
added_timestamp: Self::file_added_timestamp_millis(&file_metadata),
thumbnail_resolution: THUMBNAIL_RESOLUTION,
}))
}
}
@ -41,41 +168,25 @@ impl BackgroundRepository for FileBackgroundRepository {
self.backgrounds_dir
));
// Ensure the directory exists
if !self.backgrounds_dir.exists() {
fs::create_dir_all(&self.backgrounds_dir)
.await
.map_err(|e| {
logger::error(&format!("Failed to create backgrounds directory: {}", e));
DomainError::InternalError(format!(
"Failed to create backgrounds directory: {}",
e
))
})?;
return Ok(Vec::new());
}
self.ensure_backgrounds_dir_exists().await?;
// Read the directory
let mut entries = fs::read_dir(&self.backgrounds_dir).await.map_err(|e| {
logger::error(&format!("Failed to read backgrounds directory: {}", e));
DomainError::InternalError(format!("Failed to read backgrounds directory: {}", e))
let mut entries = fs::read_dir(&self.backgrounds_dir).await.map_err(|error| {
logger::error(&format!("Failed to read backgrounds directory: {}", error));
DomainError::InternalError(format!("Failed to read backgrounds directory: {}", error))
})?;
let mut backgrounds = Vec::new();
// Process each entry
while let Some(entry) = entries.next_entry().await.map_err(|e| {
logger::error(&format!("Failed to read directory entry: {}", e));
DomainError::InternalError(format!("Failed to read directory entry: {}", e))
while let Some(entry) = entries.next_entry().await.map_err(|error| {
logger::error(&format!("Failed to read directory entry: {}", error));
DomainError::InternalError(format!("Failed to read directory entry: {}", error))
})? {
let path = entry.path();
// Check if it's an image file
if path.is_file() && self.is_image(&path) {
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
let background =
Background::new(filename.to_string(), path.to_string_lossy().to_string());
backgrounds.push(background);
backgrounds.push(Background::new(
filename.to_string(),
path.to_string_lossy().to_string(),
));
}
}
}
@ -83,15 +194,54 @@ impl BackgroundRepository for FileBackgroundRepository {
Ok(backgrounds)
}
async fn get_all_background_metadata(
&self,
) -> Result<BackgroundImageMetadataIndex, DomainError> {
logger::debug("FileBackgroundRepository: Getting all background metadata");
self.ensure_backgrounds_dir_exists().await?;
let mut entries = fs::read_dir(&self.backgrounds_dir).await.map_err(|error| {
logger::error(&format!(
"Failed to read backgrounds directory for metadata: {}",
error
));
DomainError::InternalError(format!("Failed to read backgrounds directory: {}", error))
})?;
let mut images = HashMap::new();
while let Some(entry) = entries.next_entry().await.map_err(|error| {
logger::error(&format!(
"Failed to read background metadata directory entry: {}",
error
));
DomainError::InternalError(format!("Failed to read directory entry: {}", error))
})? {
let path = entry.path();
if !path.is_file() || !self.is_image(&path) {
continue;
}
let Some(filename) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if let Some(metadata) = self.build_background_metadata(&path).await? {
images.insert(format!("backgrounds/{}", filename), metadata);
}
}
Ok(BackgroundImageMetadataIndex { version: 1, images })
}
async fn delete_background(&self, filename: &str) -> Result<(), DomainError> {
logger::debug(&format!(
"FileBackgroundRepository: Deleting background: {}",
filename
));
let file_path = self.get_full_path(filename);
// Check if the file exists
let normalized = self.normalize_filename(filename)?;
let file_path = self.backgrounds_dir.join(&normalized);
if !file_path.exists() {
return Err(DomainError::NotFound(format!(
"Background not found: {}",
@ -99,12 +249,12 @@ impl BackgroundRepository for FileBackgroundRepository {
)));
}
// Delete the file
fs::remove_file(&file_path).await.map_err(|e| {
logger::error(&format!("Failed to delete background file: {}", e));
DomainError::InternalError(format!("Failed to delete background file: {}", e))
fs::remove_file(&file_path).await.map_err(|error| {
logger::error(&format!("Failed to delete background file: {}", error));
DomainError::InternalError(format!("Failed to delete background file: {}", error))
})?;
self.invalidate_thumbnail_cache(&normalized).await?;
Ok(())
}
@ -118,18 +268,17 @@ impl BackgroundRepository for FileBackgroundRepository {
old_filename, new_filename
));
let old_path = self.get_full_path(old_filename);
let new_path = self.get_full_path(new_filename);
let old_normalized = self.normalize_filename(old_filename)?;
let new_normalized = self.normalize_filename(new_filename)?;
let old_path = self.backgrounds_dir.join(&old_normalized);
let new_path = self.backgrounds_dir.join(&new_normalized);
// Check if the source file exists
if !old_path.exists() {
return Err(DomainError::NotFound(format!(
"Background not found: {}",
old_filename
)));
}
// Check if the destination file already exists
if new_path.exists() {
return Err(DomainError::InvalidData(format!(
"Background already exists: {}",
@ -137,17 +286,13 @@ impl BackgroundRepository for FileBackgroundRepository {
)));
}
// Rename the file
fs::copy(&old_path, &new_path).await.map_err(|e| {
logger::error(&format!("Failed to copy background file: {}", e));
DomainError::InternalError(format!("Failed to copy background file: {}", e))
})?;
fs::remove_file(&old_path).await.map_err(|e| {
logger::error(&format!("Failed to delete old background file: {}", e));
DomainError::InternalError(format!("Failed to delete old background file: {}", e))
fs::rename(&old_path, &new_path).await.map_err(|error| {
logger::error(&format!("Failed to rename background file: {}", error));
DomainError::InternalError(format!("Failed to rename background file: {}", error))
})?;
self.invalidate_thumbnail_cache(&old_normalized).await?;
self.invalidate_thumbnail_cache(&new_normalized).await?;
Ok(())
}
@ -157,27 +302,67 @@ impl BackgroundRepository for FileBackgroundRepository {
filename
));
// Ensure the directory exists
if !self.backgrounds_dir.exists() {
fs::create_dir_all(&self.backgrounds_dir)
.await
.map_err(|e| {
logger::error(&format!("Failed to create backgrounds directory: {}", e));
DomainError::InternalError(format!(
"Failed to create backgrounds directory: {}",
e
))
})?;
}
self.ensure_backgrounds_dir_exists().await?;
let file_path = self.get_full_path(filename);
// Write the file
fs::write(&file_path, data).await.map_err(|e| {
logger::error(&format!("Failed to write background file: {}", e));
DomainError::InternalError(format!("Failed to write background file: {}", e))
let normalized = self.normalize_filename(filename)?;
let file_path = self.backgrounds_dir.join(&normalized);
fs::write(&file_path, data).await.map_err(|error| {
logger::error(&format!("Failed to write background file: {}", error));
DomainError::InternalError(format!("Failed to write background file: {}", error))
})?;
Ok(filename.to_string())
self.invalidate_thumbnail_cache(&normalized).await?;
Ok(normalized)
}
async fn read_background_thumbnail(
&self,
filename: &str,
_animated: bool,
) -> Result<BackgroundAsset, DomainError> {
let normalized = self.normalize_filename(filename)?;
let original_path = self.backgrounds_dir.join(&normalized);
let thumbnail_path = self.thumbnail_cache_path(&normalized);
let config = ThumbnailConfig {
width: THUMBNAIL_WIDTH,
height: THUMBNAIL_HEIGHT,
quality: THUMBNAIL_QUALITY,
resize_mode: ThumbnailResizeMode::PreserveArea,
};
let asset = read_thumbnail_or_original(&original_path, &thumbnail_path, config).await?;
Ok(BackgroundAsset {
bytes: asset.bytes,
mime_type: asset.mime_type,
})
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::FileBackgroundRepository;
#[test]
fn normalize_filename_replaces_unsafe_characters() {
let repository = FileBackgroundRepository::new(
PathBuf::from("backgrounds"),
PathBuf::from("thumbnails/bg"),
);
let normalized = repository
.normalize_filename("..\\bad:*name?.png")
.expect("filename should be valid after normalization");
assert_eq!(normalized, ".._bad__name_.png");
}
#[test]
fn normalize_filename_rejects_empty_result() {
let repository = FileBackgroundRepository::new(
PathBuf::from("backgrounds"),
PathBuf::from("thumbnails/bg"),
);
assert!(repository.normalize_filename(" ... ").is_err());
}
}

View file

@ -29,11 +29,10 @@ pub struct FileExtensionRepository {
http_client: Client,
user_extensions_dir: PathBuf,
global_extensions_dir: PathBuf,
system_extensions_dir: PathBuf,
}
/// Built-in extensions enabled in TauriTavern.
/// Keep this list explicit so unsupported built-ins stay disabled by default.
/// Keep this list explicit so custom built-ins remain predictable after upstream sync.
const ENABLED_SYSTEM_EXTENSIONS: &[&str] = &[
"regex",
"code-render",
@ -71,18 +70,12 @@ struct GithubRepoLocation {
}
impl FileExtensionRepository {
pub fn new(
user_extensions_dir: PathBuf,
global_extensions_dir: PathBuf,
system_extensions_dir: PathBuf,
) -> Self {
pub fn new(user_extensions_dir: PathBuf, global_extensions_dir: PathBuf) -> Self {
// Create directories if they don't exist
fs::create_dir_all(&user_extensions_dir)
.expect("Failed to create user extensions directory");
fs::create_dir_all(&global_extensions_dir)
.expect("Failed to create global extensions directory");
fs::create_dir_all(&system_extensions_dir)
.expect("Failed to create system extensions directory");
let http_client =
build_http_client(Client::builder()).expect("Failed to build extension HTTP client");
@ -91,7 +84,6 @@ impl FileExtensionRepository {
http_client,
user_extensions_dir,
global_extensions_dir,
system_extensions_dir,
}
}
@ -191,15 +183,14 @@ impl ExtensionRepository for FileExtensionRepository {
let mut extensions = Vec::new();
// Built-in extensions are explicitly allowlisted; unsupported modules remain disabled.
// Built-in extensions are explicitly allowlisted; unsupported modules stay disabled.
for &name in ENABLED_SYSTEM_EXTENSIONS {
let path = self.system_extensions_dir.join(name);
let manifest = self.get_manifest(&path).await.ok().flatten();
let path = PathBuf::from(format!("scripts/extensions/{}", name));
extensions.push(Extension {
name: name.to_string(),
extension_type: ExtensionType::System,
manifest,
manifest: None,
path,
remote_url: None,
commit_hash: None,

View file

@ -1,12 +1,122 @@
use std::sync::Arc;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine;
use serde::Serialize;
use tauri::State;
use crate::app::AppState;
use crate::application::dto::background_dto::{DeleteBackgroundDto, RenameBackgroundDto};
use crate::domain::models::background::BackgroundImageMetadataIndex;
use crate::infrastructure::persistence::thumbnail_cache::{
read_thumbnail_or_original, ThumbnailConfig, ThumbnailResizeMode,
};
use crate::presentation::commands::helpers::{log_command, map_command_error};
use crate::presentation::errors::CommandError;
const AVATAR_THUMBNAIL_WIDTH: u32 = 96;
const AVATAR_THUMBNAIL_HEIGHT: u32 = 144;
const AVATAR_THUMBNAIL_QUALITY: u8 = 90;
#[derive(Debug, Clone, Serialize)]
pub struct ThumbnailAssetPayload {
pub content_base64: String,
pub mime_type: String,
}
#[derive(Debug, Clone, Copy)]
enum ThumbnailType {
Bg,
Avatar,
Persona,
}
impl ThumbnailType {
fn parse(value: &str) -> Result<Self, CommandError> {
match value.trim().to_ascii_lowercase().as_str() {
"bg" => Ok(Self::Bg),
"avatar" => Ok(Self::Avatar),
"persona" => Ok(Self::Persona),
_ => Err(CommandError::BadRequest(
"Invalid thumbnail type".to_string(),
)),
}
}
fn as_str(self) -> &'static str {
match self {
Self::Bg => "bg",
Self::Avatar => "avatar",
Self::Persona => "persona",
}
}
}
fn sanitize_thumbnail_filename(filename: &str) -> Result<String, CommandError> {
let sanitized = filename
.chars()
.map(|character| match character {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ if character.is_control() => '_',
_ => character,
})
.collect::<String>();
let sanitized = sanitized.trim().trim_end_matches(['.', ' ']).to_string();
if sanitized.is_empty() {
return Err(CommandError::BadRequest(
"Invalid thumbnail file name".to_string(),
));
}
Ok(sanitized)
}
async fn read_non_background_thumbnail_asset(
app_state: &Arc<AppState>,
thumbnail_type: ThumbnailType,
file: &str,
) -> Result<ThumbnailAssetPayload, CommandError> {
let directories = app_state
.user_directory_service
.get_default_user_directory()
.await
.map_err(map_command_error(
"Failed to resolve default user directories for thumbnail",
))?;
let safe_file_name = sanitize_thumbnail_filename(file)?;
let (source_directory, thumbnail_directory) = match thumbnail_type {
ThumbnailType::Avatar => (directories.characters, directories.thumbnails_avatar),
ThumbnailType::Persona => (directories.avatars, directories.thumbnails_persona),
ThumbnailType::Bg => {
return Err(CommandError::BadRequest(
"Unsupported non-background thumbnail type".to_string(),
));
}
};
let original_path = std::path::PathBuf::from(source_directory).join(&safe_file_name);
let thumbnail_path = std::path::PathBuf::from(thumbnail_directory).join(&safe_file_name);
let asset = read_thumbnail_or_original(
&original_path,
&thumbnail_path,
ThumbnailConfig {
width: AVATAR_THUMBNAIL_WIDTH,
height: AVATAR_THUMBNAIL_HEIGHT,
quality: AVATAR_THUMBNAIL_QUALITY,
resize_mode: ThumbnailResizeMode::Cover,
},
)
.await
.map_err(map_command_error("Failed to read non-background thumbnail"))?;
Ok(ThumbnailAssetPayload {
content_base64: BASE64_STANDARD.encode(asset.bytes),
mime_type: asset.mime_type,
})
}
#[tauri::command]
pub async fn get_all_backgrounds(
app_state: State<'_, Arc<AppState>>,
@ -21,6 +131,23 @@ pub async fn get_all_backgrounds(
.map_err(map_command_error("Failed to get all backgrounds"))
}
#[tauri::command]
pub async fn get_all_background_metadata(
app_state: State<'_, Arc<AppState>>,
prefix: Option<String>,
) -> Result<BackgroundImageMetadataIndex, CommandError> {
log_command(format!(
"get_all_background_metadata, prefix: {}",
prefix.clone().unwrap_or_default()
));
app_state
.background_service
.get_all_background_metadata(prefix.as_deref())
.await
.map_err(map_command_error("Failed to get background metadata"))
}
#[tauri::command]
pub async fn delete_background(
app_state: State<'_, Arc<AppState>>,
@ -66,3 +193,36 @@ pub async fn upload_background(
.await
.map_err(map_command_error("Failed to upload background"))
}
#[tauri::command]
pub async fn read_thumbnail_asset(
app_state: State<'_, Arc<AppState>>,
thumbnail_type: String,
file: String,
animated: Option<bool>,
) -> Result<ThumbnailAssetPayload, CommandError> {
let thumbnail_type = ThumbnailType::parse(&thumbnail_type)?;
log_command(format!(
"read_thumbnail_asset type={} file={}",
thumbnail_type.as_str(),
file
));
match thumbnail_type {
ThumbnailType::Bg => {
let asset = app_state
.background_service
.read_background_thumbnail(&file, animated.unwrap_or(false))
.await
.map_err(map_command_error("Failed to read background thumbnail"))?;
Ok(ThumbnailAssetPayload {
content_base64: BASE64_STANDARD.encode(asset.bytes),
mime_type: asset.mime_type,
})
}
ThumbnailType::Avatar | ThumbnailType::Persona => {
read_non_background_thumbnail_asset(app_state.inner(), thumbnail_type, &file).await
}
}
}

View file

@ -7,7 +7,7 @@ use crate::infrastructure::assets::read_resource_text;
use crate::presentation::commands::helpers::{log_command, map_command_error};
use crate::presentation::errors::CommandError;
const SILLYTAVERN_COMPAT_VERSION: &str = "1.15.0";
const SILLYTAVERN_COMPAT_VERSION: &str = "1.16.0";
const BUILD_GIT_REVISION: &str = env!("TAURITAVERN_GIT_REVISION");
const BUILD_GIT_BRANCH: &str = env!("TAURITAVERN_GIT_BRANCH");

View file

@ -121,9 +121,11 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Sen
super::group_commands::clear_group_cache,
// Background commands
super::background_commands::get_all_backgrounds,
super::background_commands::get_all_background_metadata,
super::background_commands::delete_background,
super::background_commands::rename_background,
super::background_commands::upload_background,
super::background_commands::read_thumbnail_asset,
// Theme commands
super::theme_commands::save_theme,
super::theme_commands::delete_theme,

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "TauriTavern",
"version": "1.1.4",
"version": "1.2.0-beta",
"identifier": "com.tauritavern.client",
"build": {
"frontendDist": "../src"

View file

@ -1 +1 @@
export const SILLYTAVERN_COMPAT_VERSION = '1.15.0';
export const SILLYTAVERN_COMPAT_VERSION = '1.16.0';

View file

@ -96,6 +96,12 @@
font-size: calc(var(--mainFontSize) * 0.95);
}
#bg-sort {
width: auto;
max-width: 6em;
flex-shrink: 0;
}
/* Thumbnails */
.bg_example:hover .BGSampleTitle {
opacity: 1;

View file

@ -39,7 +39,7 @@ label[for="extensions_autoconnect"] {
text-align: left;
}
.extensions_info h3 {
.extensions_info h3:not(.margin0) {
margin-bottom: 0.5em;
}
@ -112,6 +112,10 @@ label[for="extensions_autoconnect"] {
color: limegreen;
}
.extensions_info .third_party_toolbar {
user-select: none;
}
input.extension_missing[type="checkbox"] {
opacity: 0.5;
}
@ -157,4 +161,4 @@ input.extension_missing[type="checkbox"] {
z-index: 1;
margin-bottom: 10px;
padding: 5px;
}
}

View file

@ -465,6 +465,89 @@
color: #F89406;
}
/* Arity warning banner in details */
.macro-ac-warning {
display: flex;
align-items: baseline;
gap: 0.5em;
padding: 0.5em 0.75em;
background: linear-gradient(90deg, rgba(248, 148, 6, 0.2), transparent);
border-left: 3px solid #F89406;
border-radius: 0 4px 4px 0;
margin-bottom: 0.5em;
font-size: 0.9em;
color: #F89406;
}
.macro-ac-warning i {
font-size: 0.9em;
}
/* Scoped content info banner in details */
.macro-ac-scoped-info {
display: flex;
align-items: baseline;
gap: 0.5em;
padding: 0.5em 0.75em;
background: linear-gradient(90deg, rgba(91, 192, 222, 0.2), transparent);
border-left: 3px solid #5BC0DE;
border-radius: 0 4px 4px 0;
margin-bottom: 0.5em;
font-size: 0.9em;
color: #5BC0DE;
}
.macro-ac-scoped-info i {
font-size: 0.9em;
}
.macro-ac-scoped-info code {
background: rgba(91, 192, 222, 0.15);
padding: 0.1em 0.3em;
border-radius: 3px;
}
/* OPTIONAL badge for optional scoped content */
.macro-ac-optional-badge {
display: inline-block;
background: linear-gradient(135deg, #f0ad4e, #ec971f);
color: #000;
font-weight: bold;
font-size: 0.75em;
padding: 0.15em 0.5em;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.05em;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Smaller variant for use in autocomplete list items */
.macro-ac-optional-badge-small {
font-size: 0.65em;
padding: 0.1em 0.35em;
vertical-align: middle;
color: #f0ad4e;
}
/* Closing tag autocomplete option */
.autoComplete > .item.macro-closing-tag-item > .type {
color: var(--ac-color-matchedText, var(--SmartThemeBorderColor));
}
.macro-closing-tag-details {
padding: 0.5em;
}
.macro-closing-tag-details h3 {
margin: 0 0 0.5em 0;
font-size: 1.1em;
}
.macro-closing-tag-details p {
margin: 0;
opacity: 0.9;
}
/* Current argument hint banner in details */
.macro-ac-arg-hint {
display: flex;
@ -483,6 +566,11 @@
font-size: 0.8em;
}
.macro-ac-arg-hint .macro-ac-arg-hint-small {
font-size: 0.85em;
opacity: 0.8;
}
.macro-ac-hint-type {
font-family: var(--monoFontFamily);
font-size: 0.85em;

View file

@ -67,6 +67,10 @@
display: none;
}
.tag.tag-absent {
text-decoration: line-through;
}
.tag.actionable {
border-radius: 50%;
aspect-ratio: 1 / 1;

View file

@ -564,3 +564,7 @@ label[for="bind_preset_to_connection"]:has(input:checked) {
#request_images_block:has(#openai_request_images:not(:checked)) #request_images_settings {
display: none;
}
#adaptive_p_block:has([data-tg-samplers="adaptive_target"][style*="display: none"]):has([data-tg-samplers="adaptive_decay"][style*="display: none"]) {
display: none;
}

View file

@ -1 +0,0 @@
/* Put custom styles here. */

View file

@ -115,6 +115,7 @@ body.hideChatAvatars .welcomePanel .recentChatList .recentChat .avatar {
cursor: pointer;
gap: 10px;
border: 1px solid var(--SmartThemeBorderColor);
position: relative;
}
.welcomeRecent .recentChatList .recentChat .avatar {
@ -222,6 +223,14 @@ body.big-avatars .welcomeRecent .recentChatList .recentChat .chatMessageContaine
transform: rotate(180deg);
}
.welcomeRecent .recentChatList .recentChat .recentChatPinned {
top: 1px;
left: 1px;
position: absolute;
opacity: 0.8;
color: var(--SmartThemeQuoteColor);
}
@media screen and (max-width: 1000px) {
.welcomePanel .welcomeShortcuts a span {
display: none;

View file

@ -1,22 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Forbidden</title>
</head>
<body>
<h1>Forbidden</h1>
<p>
If you are the system administrator, add your IP address to the
whitelist or disable whitelist mode by editing
<code>config.yaml</code> in the root directory of your installation.
</p>
<hr />
<p>
<em>Connection from {{ipDetails}} has been blocked. This attempt
has been logged.</em>
</p>
</body>
</html>

View file

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Forbidden</title>
</head>
<body>
<h1>Forbidden</h1>
<p>
If you are the system administrator, add the hostname you are accessing from to the
host whitelist, or disable host whitelisting in the
<code>config.yaml</code> file located in the root directory of your installation.
</p>
<hr />
<p>
<em>Access from this host is not allowed. This attempt has been logged.</em>
</p>
</body>
</html>

View file

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Unauthorized</title>
</head>
<body>
<h1>Unauthorized</h1>
<p>
If you are the system administrator, you can configure the
<code>basicAuthUser</code> credentials by editing
<code>config.yaml</code> in the root directory of your installation.
</p>
</body>
</html>

View file

@ -1,15 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Not found</title>
</head>
<body>
<h1>Not found</h1>
<p>
The requested URL was not found on this server.
</p>
</body>
</html>

9
src/global.d.ts vendored
View file

@ -38,6 +38,7 @@ declare global {
avatar_url?: string;
hideMutedSprites?: boolean;
fav?: boolean;
date_last_chat?: MessageTimestamp;
}
interface ChatFile extends Array<ChatMessage> {
@ -235,3 +236,11 @@ declare global {
type SwipeEvent = JQuery.TriggeredEvent<any, any, HTMLElement, HTMLElement>;
}
//Overrides for public/scripts/chats.js
declare module 'dompurify' {
interface Config {
MESSAGE_SANITIZE?: boolean;
MESSAGE_ALLOW_SYSTEM_UI?: boolean;
}
}

View file

@ -1402,6 +1402,28 @@
<input class="neo-range-slider" type="range" id="max_tokens_second_textgenerationwebui" name="volume" min="0" max="20" step="1" />
<input class="neo-range-input" type="number" min="0" max="20" step="1" data-for="max_tokens_second_textgenerationwebui" id="max_tokens_second_counter_textgenerationwebui">
</div>
<div data-tg-type="koboldcpp, llamacpp, tabby" id="adaptive_p_block" class="wide100p">
<h4 class="wide100p textAlignCenter">
<label data-i18n="Adaptive-P">Adaptive-P</label>
<a href="https://github.com/MrJackSpade/adaptive-p-docs" target="_blank">
<div class="fa-solid fa-circle-info opacity50p"></div>
</a>
</h4>
<div class="flex-container flexFlowRow alignitemscenter gap10px flexShrink">
<div data-tg-samplers="adaptive_target" class="alignitemscenter flex-container marginBot5 flexFlowColumn flexGrow flexShrink gap0">
<small data-i18n="Target">Target</small>
<input class="neo-range-slider" type="range" id="adaptive_target_textgenerationwebui" min="-0.01" max="1" step="0.01" />
<input class="neo-range-input" type="number" min="-0.01" max="1" step="0.01" data-for="adaptive_target_textgenerationwebui" id="adaptive_target_counter_textgenerationwebui">
</div>
<div data-tg-samplers="adaptive_decay" class="alignitemscenter flex-container marginBot5 flexFlowColumn flexGrow flexShrink gap0">
<small data-i18n="Decay">Decay</small>
<input class="neo-range-slider" type="range" id="adaptive_decay_textgenerationwebui" min="0" max="0.99" step="0.01" />
<input class="neo-range-input" type="number" min="0" max="0.99" step="0.01" data-for="adaptive_decay_textgenerationwebui" id="adaptive_decay_counter_textgenerationwebui">
</div>
</div>
</div>
<div data-tg-type="mancer, ooba, koboldcpp, aphrodite, tabby" data-tg-samplers="smoothing_factor" id="smoothingBlock" name="smoothingBlock" class="wide100p">
<h4 class="wide100p textAlignCenter">
<label data-i18n="Smooth Sampling">Smooth Sampling</label>
@ -1977,7 +1999,7 @@
</b>
</div>
</div>
<div class="range-block" data-source="openai,cohere,mistralai,custom,claude,aimlapi,openrouter,groq,siliconflow,deepseek,makersuite,vertexai,ai21,xai,pollinations,moonshot,fireworks,cometapi,electronhub,chutes,azure_openai,zai">
<div class="range-block" data-source="openai,cohere,mistralai,custom,claude,aimlapi,openrouter,groq,siliconflow,deepseek,makersuite,vertexai,ai21,xai,pollinations,moonshot,fireworks,cometapi,electronhub,chutes,azure_openai,zai,nanogpt">
<label for="openai_function_calling" class="checkbox_label flexWrap widthFreeExpand">
<input id="openai_function_calling" type="checkbox" />
<span data-i18n="Enable function calling">Enable function calling</span>
@ -2005,7 +2027,7 @@
<i class="icon-supported fa-solid fa-film" title="Supported by the current model" data-i18n="[title]Supported by the current model"></i>
<i class="icon-unsupported fa-solid fa-film" title="Unsupported by the current model" data-i18n="[title]Unsupported by the current model"></i>
</div>
<div id="openai_audio_inlining_supported" data-source="makersuite,vertexai,openrouter">
<div id="openai_audio_inlining_supported" data-source="makersuite,vertexai,openrouter,openai,custom">
<i class="icon-supported fa-solid fa-music" title="Supported by the current model" data-i18n="[title]Supported by the current model"></i>
<i class="icon-unsupported fa-solid fa-music" title="Unsupported by the current model" data-i18n="[title]Unsupported by the current model"></i>
</div>
@ -2086,12 +2108,12 @@
<span data-i18n="Allows the model to return its thinking process.">
Allows the model to return its thinking process.
</span>
<strong data-i18n="This setting affects visibility only." data-source-mode="except" data-source="zai">
<strong data-i18n="This setting affects visibility only." data-source-mode="except" data-source="zai,moonshot">
This setting affects visibility only.
</strong>
</div>
</div>
<div class="flex-container flexFlowColumn wide100p textAlignCenter marginTop10" data-source="openai,custom,claude,xai,makersuite,vertexai,aimlapi,openrouter,pollinations,perplexity,cometapi,electronhub,azure_openai,chutes">
<div class="flex-container flexFlowColumn wide100p textAlignCenter marginTop10" data-source="openai,custom,claude,xai,makersuite,vertexai,aimlapi,openrouter,pollinations,perplexity,cometapi,electronhub,azure_openai,chutes,nanogpt">
<div class="flex-container oneline-dropdown" title="Constrains effort on reasoning for reasoning models.&#10;Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response." data-i18n="[title]Constrains effort on reasoning for reasoning models.">
<label for="openai_reasoning_effort">
<span data-i18n="Reasoning Effort">Reasoning Effort</span>
@ -2422,6 +2444,20 @@
<span data-i18n="Allow fallback providers">Allow fallback providers</span>
</label>
</div>
<div>
<h4 data-i18n="Model Quantizations">Model Quantizations</h4>
<select id="openrouter_quantizations_text" class="openrouter_quantizations" multiple>
<option data-i18n="Integer (4 bit)" value="int4">Integer (4 bit)</option>
<option data-i18n="Integer (8 bit)" value="int8">Integer (8 bit)</option>
<option data-i18n="Floating point (4 bit)" value="fp4">Floating point (4 bit)</option>
<option data-i18n="Floating point (6 bit)" value="fp6">Floating point (6 bit)</option>
<option data-i18n="Floating point (8 bit)" value="fp8">Floating point (8 bit)</option>
<option data-i18n="Floating point (16 bit)" value="fp16">Floating point (16 bit)</option>
<option data-i18n="Brain floating point (16 bit)" value="bf16">Brain floating point (16 bit)</option>
<option data-i18n="Floating point (32 bit)" value="fp32">Floating point (32 bit)</option>
<option data-i18n="Unknown" value="unknown">Unknown</option>
</select>
</div>
</div>
<div data-tg-type="infermaticai" class="flex-container flexFlowColumn">
<h4 data-i18n="InfermaticAI API Key">InfermaticAI API Key</h4>
@ -2848,7 +2884,7 @@
<option value="zai">Z.AI (GLM)</option>
</optgroup>
</select>
<div class="inline-drawer wide100p" data-source="openai,claude,mistralai,makersuite,vertexai,deepseek,xai">
<div class="inline-drawer wide100p" data-source="openai,claude,mistralai,makersuite,vertexai,deepseek,xai,zai,moonshot">
<div class="inline-drawer-toggle inline-drawer-header">
<b data-i18n="Reverse Proxy">Reverse Proxy</b>
<div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
@ -2912,7 +2948,7 @@
</div>
</div>
</div>
<div id="ReverseProxyWarningMessage" data-source="openai,claude,mistralai,makersuite,vertexai,deepseek,xai">
<div id="ReverseProxyWarningMessage" data-source="openai,claude,mistralai,makersuite,vertexai,deepseek,xai,zai,moonshot">
<div class="reverse_proxy_warning">
<b>
<div data-i18n="Using a proxy that you're not running yourself is a risk to your data privacy.">
@ -3071,6 +3107,7 @@
<h4 data-i18n="Claude Model">Claude Model</h4>
<select id="model_claude_select">
<optgroup label="Versions">
<option value="claude-opus-4-6">claude-opus-4-6</option>
<option value="claude-opus-4-5">claude-opus-4-5</option>
<option value="claude-opus-4-5-20251101">claude-opus-4-5-20251101</option>
<option value="claude-sonnet-4-5">claude-sonnet-4-5</option>
@ -3164,6 +3201,20 @@
<i class="fa-solid fa-lightbulb"></i>
<span data-i18n="To use instruct formatting, switch to OpenRouter under Text Completion API.">To use instruct formatting, switch to OpenRouter under Text Completion API.</span>
</small>
<div>
<h4 data-i18n="Model Quantizations">Model Quantizations</h4>
<select id="openrouter_quantizations_chat" class="openrouter_quantizations" multiple>
<option data-i18n="Integer (4 bit)" value="int4">Integer (4 bit)</option>
<option data-i18n="Integer (8 bit)" value="int8">Integer (8 bit)</option>
<option data-i18n="Floating point (4 bit)" value="fp4">Floating point (4 bit)</option>
<option data-i18n="Floating point (6 bit)" value="fp6">Floating point (6 bit)</option>
<option data-i18n="Floating point (8 bit)" value="fp8">Floating point (8 bit)</option>
<option data-i18n="Floating point (16 bit)" value="fp16">Floating point (16 bit)</option>
<option data-i18n="Brain floating point (16 bit)" value="bf16">Brain floating point (16 bit)</option>
<option data-i18n="Floating point (32 bit)" value="fp32">Floating point (32 bit)</option>
<option data-i18n="Unknown" value="unknown">Unknown</option>
</select>
</div>
</form>
<form id="ai21_form" data-source="ai21" action="javascript:void(null);" method="post" enctype="multipart/form-data">
<h4 data-i18n="AI21 API Key">AI21 API Key</h4>
@ -3753,19 +3804,22 @@
</select>
</div>
<div id="pollinations_form" data-source="pollinations">
<h4>
<a href="https://enter.pollinations.ai/" target="_blank" rel="noopener noreferrer" data-i18n="Pollinations API Key">
Pollinations API Key
</a>
</h4>
<div class="flex-container">
<input id="api_key_pollinations" name="api_key_pollinations" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_pollinations"></div>
</div>
<div data-for="api_key_pollinations" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<h4 data-i18n="Pollinations Model">Pollinations Model</h4>
<select id="model_pollinations_select">
<!-- Populated by JavaScript -->
</select>
<div class="info-block hint">
<a href="https://pollinations.ai/" target="_blank" rel="noopener noreferrer" data-i18n="Provided free of charge by Pollinations.AI">
Provided free of charge by Pollinations.AI
</a>
<br>
<span data-i18n="Avoid sending sensitive information. Provider's outputs may include ads.">
Avoid sending sensitive information. Provider's outputs may include ads.
</span>
</div>
</div>
<div id="moonshot_form" data-source="moonshot">
<h4>
@ -3814,7 +3868,10 @@
</select>
<h4 data-i18n="Z.AI Model">Z.AI Model</h4>
<select id="model_zai_select">
<option value="glm-5">glm-5</option>
<option value="glm-4.7">glm-4.7</option>
<option value="glm-4.7-flash">glm-4.7-flash</option>
<option value="glm-4.7-flashx">glm-4.7-flashx</option>
<option value="glm-4.6">glm-4.6</option>
<option value="glm-4.6v">glm-4.6v</option>
<option value="glm-4.6v-flash">glm-4.6v-flash</option>
@ -3985,7 +4042,7 @@
<small data-i18n="Story String">Story String</small>
<i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="context_story_string" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
</label>
<textarea id="context_story_string" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="context_story_string" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
<div class="flex-container flexFlowColumn" data-cc-null>
<div id="context_story_string_position_block">
@ -4022,7 +4079,7 @@
<small data-i18n="Example Separator">Example Separator</small>
</label>
<div>
<textarea id="context_example_separator" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="context_example_separator" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
</div>
<div class="flex1">
@ -4030,7 +4087,7 @@
<small data-i18n="Chat Start">Chat Start</small>
</label>
<div>
<textarea id="context_chat_start" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="context_chat_start" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
</div>
</div>
@ -4194,11 +4251,11 @@
<div class="flex-container">
<div class="flexAuto" title="Inserted before a User message and as a last prompt line when impersonating." data-i18n="[title]Inserted before a User message and as a last prompt line when impersonating.">
<small data-i18n="User Prefix">User Message Prefix</small>
<textarea id="instruct_input_sequence" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="instruct_input_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
<div class="flexAuto" title="Inserted after a User message." data-i18n="[title]Inserted after a User message.">
<small data-i18n="User Suffix">User Message Suffix</small>
<textarea id="instruct_input_suffix" class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
<textarea id="instruct_input_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
</div>
</div>
</details>
@ -4207,11 +4264,11 @@
<div class="flex-container">
<div class="flexAuto" title="Inserted before an Assistant message and as a last prompt line when generating an AI reply." data-i18n="[title]Inserted before an Assistant message and as a last prompt line when generating an AI reply.">
<small data-i18n="Assistant Prefix">Assistant Message Prefix</small>
<textarea id="instruct_output_sequence" class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
<textarea id="instruct_output_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
</div>
<div class="flexAuto" title="Inserted after an Assistant message." data-i18n="[title]Inserted after an Assistant message.">
<small data-i18n="Assistant Suffix">Assistant Message Suffix</small>
<textarea id="instruct_output_suffix" class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
<textarea id="instruct_output_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
</div>
</div>
</details>
@ -4220,11 +4277,11 @@
<div class="flex-container">
<div class="flexAuto" id="instruct_system_sequence_block" title="Inserted before a System (added by slash commands or extensions) message." data-i18n="[title]Inserted before a System (added by slash commands or extensions) message.">
<small data-i18n="System Prefix">System Message Prefix</small>
<textarea id="instruct_system_sequence" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="instruct_system_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
<div class="flexAuto" id="instruct_system_suffix_block" title="Inserted after a System message." data-i18n="[title]Inserted after a System message.">
<small data-i18n="System Suffix">System Message Suffix</small>
<textarea id="instruct_system_suffix" class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
<textarea id="instruct_system_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
</div>
</div>
<div class="flexBasis100p" title="If enabled, System Sequences will be the same as User Sequences." data-i18n="[title]If enabled, System Sequences will be the same as User Sequences.">
@ -4239,37 +4296,37 @@
<div class="flex-container">
<div class="flexAuto" title="Inserted before the first Assistant's message." data-i18n="[title]Inserted before the first Assistant's message.">
<small data-i18n="First Assistant Prefix">First Assistant Prefix</small>
<textarea id="instruct_first_output_sequence" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="instruct_first_output_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
<div class="flexAuto" title="Inserted before the last Assistant's message or as a last prompt line when generating an AI reply (except a neutral/system role)." data-i18n="[title]instruct_last_output_sequence">
<small data-i18n="Last Assistant Prefix">Last Assistant Prefix</small>
<textarea id="instruct_last_output_sequence" class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
<textarea id="instruct_last_output_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
</div>
</div>
<div class="flex-container">
<div class="flexAuto" title="Inserted before the first User's message." data-i18n="[title]Inserted before the first User's message.">
<small data-i18n="First User Prefix">First User Prefix</small>
<textarea id="instruct_first_input_sequence" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="instruct_first_input_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
<div class="flexAuto" title="Inserted before the last User's message." data-i18n="[title]instruct_last_input_sequence">
<small data-i18n="Last User Prefix">Last User Prefix</small>
<textarea id="instruct_last_input_sequence" class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
<textarea id="instruct_last_input_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
</div>
</div>
<div class="flex-container">
<div class="flexAuto" title="Will be inserted as a last prompt line when using system/neutral generation." data-i18n="[title]Will be inserted as a last prompt line when using system/neutral generation.">
<small data-i18n="System Instruction Prefix">System Instruction Prefix</small>
<textarea id="instruct_last_system_sequence" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="instruct_last_system_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
<div class="flexAuto" title="If a stop sequence is generated, everything past it will be removed from the output (inclusive)." data-i18n="[title]If a stop sequence is generated, everything past it will be removed from the output (inclusive).">
<small data-i18n="Stop Sequence">Stop Sequence</small>
<textarea id="instruct_stop_sequence" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="instruct_stop_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
</div>
<div class="flex-container">
<div class="flexAuto" title="Will be inserted at the start of the chat history if it doesn't start with a User message." data-i18n="[title]Will be inserted at the start of the chat history if it doesn't start with a User message.">
<small data-i18n="User Filler Message">User Filler Message</small>
<textarea id="instruct_user_alignment_message" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="instruct_user_alignment_message" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
</div>
</details>
@ -4307,7 +4364,7 @@
<small data-i18n="Prompt Content">Prompt Content</small>
<i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="sysprompt_content" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
</label>
<textarea id="sysprompt_content" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="sysprompt_content" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
<div>
@ -4315,7 +4372,7 @@
<small data-i18n="Post-History Instructions">Post-History Instructions</small>
<i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="sysprompt_post_history" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
</label>
<textarea id="sysprompt_post_history" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="sysprompt_post_history" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
</div>
@ -4440,17 +4497,17 @@
<div class="flex-container">
<div class="flex1" title="Inserted before the reasoning content." data-i18n="[title]reasoning_prefix">
<small data-i18n="Prefix">Prefix</small>
<textarea id="reasoning_prefix" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="reasoning_prefix" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
<div class="flex1" title="Inserted after the reasoning content." data-i18n="[title]reasoning_suffix">
<small data-i18n="Suffix">Suffix</small>
<textarea id="reasoning_suffix" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="reasoning_suffix" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
</div>
<div class="flex-container">
<div class="flex1" title="Inserted between the reasoning and the message content." data-i18n="[title]reasoning_separator">
<small data-i18n="Separator">Separator</small>
<textarea id="reasoning_separator" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="reasoning_separator" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
</div>
</details>
@ -4472,7 +4529,7 @@
</span>
</small>
<div>
<input id="markdown_escape_strings" class="text_pole textarea_compact" type="text" data-i18n="[placeholder]comma delimited,no spaces between" placeholder="comma delimited,no spaces between" />
<input id="markdown_escape_strings" data-macros class="text_pole textarea_compact" type="text" data-i18n="[placeholder]comma delimited,no spaces between" placeholder="comma delimited,no spaces between" />
</div>
</div>
@ -4484,7 +4541,7 @@
</span>
</small>
<div>
<textarea id="start_reply_with" class="text_pole textarea_compact autoSetHeight"></textarea>
<textarea id="start_reply_with" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
</div>
<label class="checkbox_label" for="chat-show-reply-prefix-checkbox">
<input id="chat-show-reply-prefix-checkbox" type="checkbox" />
@ -4783,7 +4840,7 @@
<div name="themeElements" class="flex-container flexFlowColumn flexNoGap">
<!-- <h4><span data-i18n="UI Colors">Theme Settings</span></h4> -->
<div name="AvatarAndChatDisplay" class="flex-container flexFlowColumn">
<div class="flex-container alignItemsBaseline" title="This style applies to all avatars globaly, including your Persona, Character Managment, Account selection, etc." data-i18n="[title]This style applies to all avatars globaly, including your Persona, Character Managment, Account selection, etc.">
<div class="flex-container alignItemsBaseline" title="This style applies to all avatars globaly, including your Persona, Character Management, Account selection, etc." data-i18n="[title]This style applies to all avatars globaly, including your Persona, Character Management, Account selection, etc.">
<span data-i18n="Avatar Style:">Avatars:</span>
<select id="avatar_style" class="widthNatural flex1 margin0 text_pole">
<option value="0" data-i18n="Circle">Circle</option>
@ -5365,20 +5422,28 @@
<div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
</div>
<div class="inline-drawer-content">
<label for="stscript_autocomplete_state">
<small data-i18n="Visibility">Visibility</small>
</label>
<select id="stscript_autocomplete_state">
<option value="0" data-i18n="Don't show">Don't show</option>
<option value="1" data-i18n="Input length > 1">Input length > 1</option>
<option value="2" data-i18n="Always show">Always show</option>
</select>
<div class="flex1" title="When to show the autocomplete for slash commands and macros." data-i18n="[title]When to show the autocomplete for slash commands and macros.">
<label for="stscript_autocomplete_state">
<small data-i18n="Visibility">Visibility</small>
</label>
<select id="stscript_autocomplete_state">
<option value="0" data-i18n="Don't show">Don't show</option>
<option value="1" data-i18n="Input length > 1">Input length > 1</option>
<option value="2" data-i18n="Always show">Always show</option>
</select>
</div>
<label class="checkbox_label" for="stscript_autocomplete_autoHide">
<input id="stscript_autocomplete_autoHide" type="checkbox" />
<small data-i18n="Automatically hide details">
Automatically hide details
</small>
</label>
<label class="checkbox_label" for="stscript_autocomplete_showInAllMacroFields" title="Show macro autocomplete in all macro-enabled fields. When off, autocomplete only shows in expanded editors or when pressing Ctrl+Space." data-i18n="[title]Show macro autocomplete in all macro-enabled fields. When off, autocomplete only shows in expanded editors or when pressing Ctrl+Space.">
<input id="stscript_autocomplete_showInAllMacroFields" type="checkbox" />
<small data-i18n="Show in all macro fields">
Show in all macro fields
</small>
</label>
<div class="flex-container">
<div class="flex1" title="Determines how entries are found for autocomplete." data-i18n="[title]Determines how entries are found for autocomplete.">
<label for="stscript_matching">
@ -5502,6 +5567,12 @@
</div>
<div class="bg-header-row-2">
<input id="bg-filter" class="text_pole" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." />
<select id="bg-sort" class="text_pole margin0" title="Sort backgrounds" data-i18n="[title]Sort backgrounds">
<option value="az" data-i18n="A-Z">A-Z</option>
<option value="za" data-i18n="Z-A">Z-A</option>
<option value="newest" data-i18n="Newest">Newest</option>
<option value="oldest" data-i18n="Oldest">Oldest</option>
</select>
</div>
</div>
<div id="bg_tabs" class="heading-container-with-controls">
@ -5701,7 +5772,7 @@
<span data-i18n="Persona Description">Persona Description</span>
<i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="persona_description" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
</h4>
<textarea id="persona_description" name="persona_description" data-i18n="[placeholder]Example: [{{user}} is a 28-year-old Romanian cat girl.]" placeholder="Example:&#10;[{{user}} is a 28-year-old Romanian cat girl.]" class="text_pole textarea_compact" value="" autocomplete="off" rows="8"></textarea>
<textarea id="persona_description" name="persona_description" data-macros data-i18n="[placeholder]Example: [{{user}} is a 28-year-old Romanian cat girl.]" placeholder="Example:&#10;[{{user}} is a 28-year-old Romanian cat girl.]" class="text_pole textarea_compact" value="" autocomplete="off" rows="8"></textarea>
<div class="flex-container justifySpaceBetween">
<h4 data-i18n="Position">Position</h4>
@ -5960,7 +6031,7 @@
</span>
</div>
</div>
<textarea id="description_textarea" class="mdHotkeys" data-i18n="[placeholder]Describe your character's physical and mental traits here." placeholder="Describe your character's physical and mental traits here." name="description" placeholder=""></textarea>
<textarea id="description_textarea" class="mdHotkeys" data-macros data-i18n="[placeholder]Describe your character's physical and mental traits here." placeholder="Describe your character's physical and mental traits here." name="description" placeholder=""></textarea>
<div class="extension_token_counter">
<span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="description_textarea" data-token-permanent="true">counting...</span>
</div>
@ -5980,7 +6051,7 @@
</span>
</div>
</div>
<textarea class="mdHotkeys" id="firstmessage_textarea" data-i18n="[placeholder]This will be the first message from the character that starts every chat." placeholder="This will be the first message from the character that starts every chat." name="first_mes" placeholder=""></textarea>
<textarea id="firstmessage_textarea" class="mdHotkeys" data-macros data-i18n="[placeholder]This will be the first message from the character that starts every chat." placeholder="This will be the first message from the character that starts every chat." name="first_mes" placeholder=""></textarea>
<div class="extension_token_counter">
<span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="firstmessage_textarea">counting...</span>
</div>
@ -6110,6 +6181,12 @@
</div>
<div class="inline-drawer-content">
<div id="currentGroupMembers" name="Current Group Members" class="flex-container flexFlowColumn overflowYAuto flex1">
<div id="rm_group_members_header">
<input id="rm_group_members_filter" class="text_pole margin0" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." />
</div>
<div class="rm_tag_controls">
<div class="tags rm_tag_filter"></div>
</div>
<div id="rm_group_members_pagination" class="rm_group_members_pagination group_pagination"></div>
<div id="rm_group_members" class="rm_group_members overflowYAuto flex-container" group_empty_text="Group is empty." data-i18n="[group_empty_text]Group is empty."></div>
</div>
@ -6121,7 +6198,7 @@
<div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
</div>
<div class="inline-drawer-content">
<div name="Unadded Char List" class="flex-container flexFlowColumn overflowYAuto flex1">
<div id="unaddedCharList" name="Unadded Char List" class="flex-container flexFlowColumn overflowYAuto flex1">
<div id="rm_group_add_members_header">
<input id="rm_group_filter" class="text_pole margin0" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." />
</div>
@ -6298,7 +6375,7 @@
<span data-i18n="Main Prompt">Main Prompt</span>
<i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="system_prompt_textarea" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
</h4>
<textarea id="system_prompt_textarea" name="system_prompt" data-i18n="[placeholder]Any contents here will replace the default Main Prompt used for this character. (v2 spec: system_prompt)" placeholder="Any contents here will replace the default Main Prompt used for this character.&#10;(v2 spec: system_prompt)" form="form_create" class="text_pole" autocomplete="off" rows="3"></textarea>
<textarea id="system_prompt_textarea" name="system_prompt" data-macros data-i18n="[placeholder]Any contents here will replace the default Main Prompt used for this character. (v2 spec: system_prompt)" placeholder="Any contents here will replace the default Main Prompt used for this character.&#10;(v2 spec: system_prompt)" form="form_create" class="text_pole" autocomplete="off" rows="3"></textarea>
<div class="extension_token_counter">
<span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="system_prompt_textarea">counting...</span>
</div>
@ -6308,7 +6385,7 @@
<span data-i18n="Post-History Instructions">Post-History Instructions</span>
<i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="post_history_instructions_textarea" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
</h4>
<textarea id="post_history_instructions_textarea" name="post_history_instructions" data-i18n="[placeholder]Any contents here will replace the default Post-History Instructions used for this character. (v2 spec: post_history_instructions)" placeholder="Any contents here will replace the default Post-History Instructions used for this character.&#10;(v2 spec: post_history_instructions)" form="form_create" class="text_pole" autocomplete="off" rows="3"></textarea>
<textarea id="post_history_instructions_textarea" name="post_history_instructions" data-macros data-i18n="[placeholder]Any contents here will replace the default Post-History Instructions used for this character. (v2 spec: post_history_instructions)" placeholder="Any contents here will replace the default Post-History Instructions used for this character.&#10;(v2 spec: post_history_instructions)" form="form_create" class="text_pole" autocomplete="off" rows="3"></textarea>
<div class="extension_token_counter">
<span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="post_history_instructions_textarea">counting...</span>
</div>
@ -6361,7 +6438,7 @@
<i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="personality_textarea" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
<a href="https://docs.sillytavern.app/usage/core-concepts/characterdesign/#personality-summary" class="notes-link" target="_blank"><span class="fa-solid fa-circle-question note-link-span"></span></a>
</h4>
<textarea id="personality_textarea" name="personality" data-i18n="[placeholder](A brief description of the personality)" placeholder="(A brief description of the personality)" form="form_create" class="text_pole" autocomplete="off" rows="4"></textarea>
<textarea id="personality_textarea" name="personality" data-macros data-i18n="[placeholder](A brief description of the personality)" placeholder="(A brief description of the personality)" form="form_create" class="text_pole" autocomplete="off" rows="4"></textarea>
<div class="extension_token_counter">
<span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="personality_textarea" data-token-permanent="true">counting...</span>
</div>
@ -6374,7 +6451,7 @@
<span class="fa-solid fa-circle-question note-link-span"></span>
</a>
</h4>
<textarea id="scenario_pole" name="scenario" data-i18n="[placeholder](Circumstances and context of the interaction)" placeholder="(Circumstances and context of the interaction)" class="text_pole" value="" autocomplete="off" form="form_create" rows="4"></textarea>
<textarea id="scenario_pole" name="scenario" data-macros data-i18n="[placeholder](Circumstances and context of the interaction)" placeholder="(Circumstances and context of the interaction)" class="text_pole" value="" autocomplete="off" form="form_create" rows="4"></textarea>
<div class="extension_token_counter">
<span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="scenario_pole" data-token-permanent="true">counting...</span>
</div>
@ -6387,7 +6464,7 @@
</span>
<i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="depth_prompt_prompt" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
</h4>
<textarea id="depth_prompt_prompt" name="depth_prompt_prompt" class="text_pole" rows="5" autocomplete="off" form="form_create" data-i18n="[placeholder](Text to be inserted in-chat @ designated depth and role)" placeholder="(Text to be inserted in-chat @ designated depth and role)"></textarea>
<textarea id="depth_prompt_prompt" name="depth_prompt_prompt" data-macros class="text_pole" rows="5" autocomplete="off" form="form_create" data-i18n="[placeholder](Text to be inserted in-chat @ designated depth and role)" placeholder="(Text to be inserted in-chat @ designated depth and role)"></textarea>
</div>
<div>
<h4>
@ -6437,7 +6514,7 @@
</a>
</h5>
</div>
<textarea id="mes_example_textarea" class="flexGrow mdHotkeys" name="mes_example" data-i18n="[placeholder](Examples of chat dialog. Begin each example with START on a new line.)" placeholder="(Examples of chat dialog. Begin each example with &lt;START&gt; on a new line.)" form="form_create" rows="6"></textarea>
<textarea id="mes_example_textarea" class="flexGrow mdHotkeys" name="mes_example" data-macros data-i18n="[placeholder](Examples of chat dialog. Begin each example with START on a new line.)" placeholder="(Examples of chat dialog. Begin each example with &lt;START&gt; on a new line.)" form="form_create" rows="6"></textarea>
<div class="extension_token_counter">
<span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="mes_example_textarea">counting...</span>
</div>
@ -7130,7 +7207,7 @@
<span>&nbsp;</span>
<span id="completion_prompt_manager_popup_entry_source"></span>
</div>
<textarea id="completion_prompt_manager_popup_entry_form_prompt" class="text_pole" name="prompt" placeholder="The prompt to be sent." data-i18n="[placeholder]The prompt to be sent."></textarea>
<textarea id="completion_prompt_manager_popup_entry_form_prompt" class="text_pole" name="prompt" data-macros data-macros-autocomplete="always" data-macros-autocomplete-style="expanded" placeholder="The prompt to be sent." data-i18n="[placeholder]The prompt to be sent."></textarea>
</div>
<div class="completion_prompt_manager_popup_entry_form_footer">
<a id="completion_prompt_manager_popup_entry_form_close" title="Close" data-i18n="[title]close" class="fa-solid fa-close menu_button"></a>
@ -7416,7 +7493,7 @@
</div>
</div>
</summary>
<textarea name="alternate_greetings" data-i18n="[placeholder](This will be the first message from the character that starts every chat)" placeholder="(This will be the first message from the character that starts every chat)" class="text_pole textarea_compact alternate_greeting_text mdHotkeys" value="" autocomplete="off" rows="12"></textarea>
<textarea data-macros name="alternate_greetings" data-i18n="[placeholder](This will be the first message from the character that starts every chat)" placeholder="(This will be the first message from the character that starts every chat)" class="text_pole textarea_compact alternate_greeting_text mdHotkeys" value="" autocomplete="off" rows="12"></textarea>
</details>
</div>
</div>
@ -7506,7 +7583,7 @@
<b data-i18n="Unique to this chat">Unique to this chat</b>.<br>
<span data-i18n="Checkpoints inherit the Note from their parent, and can be changed individually after that.">Checkpoints inherit the Note from their parent, and can be changed individually after that.</span><br>
</small>
<textarea id="extension_floating_prompt" class="text_pole textarea_compact" rows="8"></textarea>
<textarea id="extension_floating_prompt" data-macros class="text_pole textarea_compact" rows="8"></textarea>
<div class="extension_token_counter">
<span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_prompt_token_counter">0</span>
</div>
@ -7563,7 +7640,7 @@
<div class="inline-drawer-content">
<small data-i18n="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.">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.</small>
<textarea id="extension_floating_chara" class="text_pole textarea_compact" rows="8" placeholder="Example:&#10;[Scenario: wacky adventures; Genre: romantic comedy; Style: verbose, creative]"></textarea>
<textarea id="extension_floating_chara" data-macros class="text_pole textarea_compact" rows="8" placeholder="Example:&#10;[Scenario: wacky adventures; Genre: romantic comedy; Style: verbose, creative]"></textarea>
<div class="extension_token_counter">
<span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_chara_token_counter">0</span>
</div>
@ -7595,7 +7672,7 @@
</div>
<div class="inline-drawer-content">
<small data-i18n="Will be automatically added as the Author's Note for all new chats.">Will be automatically added as the Author's Note for all new chats.</small>
<textarea id="extension_floating_default" class="text_pole textarea_compact" rows="8" placeholder="Example:&#10;[Scenario: wacky adventures; Genre: romantic comedy; Style: verbose, creative]"></textarea>
<textarea id="extension_floating_default" data-macros class="text_pole textarea_compact" rows="8" placeholder="Example:&#10;[Scenario: wacky adventures; Genre: romantic comedy; Style: verbose, creative]"></textarea>
<div class="extension_token_counter">
<span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_default_token_counter">0</span>
</div>

View file

@ -290,6 +290,8 @@
"View Remaining Credits": "Afficher les crédits restants",
"OpenRouter Model": "Modèle OpenRouter",
"Model Providers": "Fournisseurs de modèles",
"Model Quantizations": "Quantifications du modèle",
"Select quantizations. No selection = all quantizations.": "Sélectionnez les quantifications. Aucune sélection = toutes les quantifications.",
"InfermaticAI API Key": "Clé API InfermaticAI",
"InfermaticAI Model": "Modèle InfermaticAI",
"DreamGen API key": "Clé API DreamGen",

View file

@ -185,9 +185,7 @@
"Mirostat (mode=1 is only for llama.cpp)": "Mirostat(mode=1 仅用于 llama.cpp)",
"Mirostat_desc": "Mirostat 是一个用于控制输出困惑度的恒温器",
"Mirostat Mode": "Mirostat 模式",
"Variability parameter for Mirostat outputs": "Mirostat 输出的变异性参数。",
"Mirostat Eta": "Mirostat η",
"Learning rate of Mirostat": "Mirostat 的学习率。",
"Beam search": "束搜索",
"A greedy, brute-force algorithm used in LLM sampling to find the most likely sequence of words or tokens. It expands multiple candidate sequences at once, maintaining a fixed number (beam width) of top sequences at each step.": "一种在LLM采样中使用的贪婪暴力算法,用于找到最可能的单词或标记序列。它一次扩展多个候选序列,在每一步保留固定数量(光束宽度)的最佳序列。",
"# of Beams": "光束数量",
@ -215,9 +213,9 @@
"Spaces Between Special Tokens": "特殊词符之间的空格",
"Seed_desc": "一个用于生成确定性和可复现的输出的随机种子。设置为 -1 时会使用随机种子。",
"LLaMA / Mistral / Yi models only": "LLaMA / Mistral / Yi模型专用。首先确保您选择了适当的词符化器。\n这项设置决定了你不想在结果中看到的字符串。\n每行一个字符串。可以是文本或者[词符id]。\n许多词符以空格开头。如果不确定,请使用词符计数器。",
"Global list": "Global list",
"Global list": "全局列表",
"Example: some text [42, 69, 1337]": "例如:\n一些文本\n[42, 69, 1337]",
"Preset-specific list": "Preset-specific list",
"Preset-specific list": "预设特有的列表",
"CFG": "CFG",
"Classifier Free Guidance. More helpful tip coming soon": "无分类器指导(CFG)。更多有用的提示敬请期待。",
"Scale": "缩放比例",
@ -228,6 +226,7 @@
"GBNF or EBNF, depends on the backend in use. If you're using this you should know which.": "GBNF 或 EBNF,取决于使用的后端。如果您使用这个,您应该知道该用哪一个。",
"JSON Schema": "JSON 结构",
"Type in the desired JSON schema": "输入所需的 JSON 结构",
"Allow empty schema objects": "允许空结构对象",
"Top P & Min P": "Top P 和 Min P",
"Load default order": "加载默认顺序",
"Sampler Order": "取样器顺序",
@ -250,14 +249,12 @@
"Space": "空格",
"Newline": "换行",
"Double Newline": "双换行",
"Wrap user messages in quotes before sending": "在发送之前将用户消息用引号括起来",
"Wrap in Quotes": "用引号包裹",
"Wrap entire user message in quotes before sending.": "在发送之前用引号包裹整个用户消息。",
"Leave off if you use quotes manually for speech.": "如果您手动使用引号包裹对话,请忽略此项。",
"Continue prefill": "继续预填充",
"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.": "将连续的系统消息合并为一条(不包括示例对话),可能会提高一些模型的连贯性。",
"Use system prompt": "使用系统提示词",
"Send the system prompt for supported models. If disabled, the user message is added to the beginning of the prompt.": "为支持的模型发送系统提示词。如果禁用,则用户消息将添加到提示词的开头。",
"Enable web search": "启用联网搜索",
"Use search capabilities provided by the backend.": "使用后端提供的联网搜索功能。",
"openrouter_web_search_fee": "收费,每个提示词会多收 $0.02。",
@ -268,20 +265,19 @@
"enable_functions_desc_2": "功能工具",
"enable_functions_desc_3": "可以被各种扩展利用来提供附加功能。",
"enable_functions_desc_4": "当提示词后处理没有选择工具时不支持。",
"Send inline images": "发送图片",
"Send inline media": "发送内联媒体",
"image_inlining_hint_1": "如果模型支持,就可以在提示词中发送媒体文件。",
"video_inlining_hint_4": "视频必须在 20MB 以下且时长不超过1分钟。",
"audio_inlining_hint_2": "音频必须小于 20 MB。",
"Inline Image Quality": "图片画质",
"openai_inline_image_quality_auto": "自动",
"openai_inline_image_quality_low": "低",
"openai_inline_image_quality_high": "高",
"Send inline videos": "发送视频",
"video_inlining_hint_4": "视频必须在 20MB 以下且时长不超过1分钟。",
"Request inline images": "请求图片返回",
"Allows the model to return image attachments.": "允许模型返回图片附件。",
"Request inline images_desc_2": "与以下几个功能不兼容:函数调用、联网搜搜、系统提示词。",
"Use system prompt": "使用系统提示词",
"Merges_all_system_messages_desc_1": "合并所有系统消息,直到第一条具有非系统角色的消息,然后通过",
"Merges_all_system_messages_desc_2": "字段发送。",
"Resolution": "分辨率",
"Aspect Ratio": "长宽比",
"Request model reasoning": "请求思维链",
"Allows the model to return its thinking process.": "允许模型返回其思维过程。",
"This setting affects visibility only.": "此设置只影响思维链是否可见。",
@ -295,12 +291,18 @@
"openai_reasoning_effort_maximum": "极高",
"OpenAI-style options: low, medium, high. Minimum and maximum are aliased to low and high. Auto does not send an effort level.": "OpenAI式选项:低、中、高。极低等于低,极高等于高。选择自动,则不传入推理强度参数。",
"Allocates a portion of the response length for thinking (min: 1024 tokens, low: 10%, medium: 25%, high: 50%, max: 95%), but minimum 1024 tokens. Auto does not request thinking.": "将最大回复空间的一部分分配给思维链(极低:1024词符,低:10%,中:25%,高:50%,极高:95%),最低1024词符。选择“自动”不会请求模型思维链。",
"Sets a dynamic reasoning depth level for thinking (Flash 3/Pro 3). High and low are supported by both, minimal and medium are Flash 3 only. Auto lets the model decide.": "设置动态推理强度等级(3 Flash / 3 Pro)。两者都支持高和低,但极低和中只有 3 Flash 支持。自动会让模型自己选择。",
"Allocates a portion of the response length for thinking (Flash 2.5/Pro 2.5) (min: 0/128 tokens, low: 10%, medium: 25%, high: 50%, max: 24576/32768 tokens). Auto lets the model decide.": "将最大回复空间的一部分分配给思维链(仅 2.5 Flash / 2.5 Pro 模型)(极低:0/128词符,低:10%,中:25%,高:50%,极高:24576/32768词符),最低1024词符。选择“自动”会让模型自己决定。",
"Verbosity": "长度",
"openai_verbosity_auto": "自动",
"openai_verbosity_low": "低",
"openai_verbosity_medium": "中",
"openai_verbosity_high": "高",
"Constrains the verbosity of the model's response.": "限制模型回复的长度。",
"Assistant Prefill": "AI预填",
"Expand the editor": "展开编辑器",
"Start Claude's answer with...": "以如下内容开始Claude的回答...",
"Assistant Impersonation Prefill": "AI帮答预填",
"Send the system prompt for supported models. If disabled, the user message is added to the beginning of the prompt.": "为支持的模型发送系统提示词。如果禁用,则用户消息将添加到提示词的开头。",
"Confirm token parsing with": "确认使用以下工具进行词符解析",
"Tokenizer": "分词器",
"New preset": "新预设",
@ -381,7 +383,7 @@
"Date Desc": "日期倒序",
"category": "分类",
"Top": "热门",
"New": "新建",
"New": "最新",
"All": "全部",
"All Classes": "所有分类",
"Toggle grid view": "切换网格视图",
@ -398,6 +400,7 @@
"Aphrodite Model": "Aphrodite 模型",
"ggml-org/llama.cpp": "ggml-org/llama.cpp",
"Example: http://127.0.0.1:8080": "示例:http://127.0.0.1:8080",
"llama.cpp Model": "llama.cpp 模型",
"Example: http://127.0.0.1:11434": "示例:http://127.0.0.1:11434",
"Ollama Model": "Ollama 模型",
"Download": "下载",
@ -465,7 +468,7 @@
"(Express mode)": "(快速模式)",
"API Key": "API 密钥",
"Project ID": "项目ID:",
"Project ID is required when selecting regions other than the default (us-central1). You can find this in a model 404 error message.": "仅当选择非默认区域(us-central1)时才需要`项目ID`。\n 您可以在模型 404 错误消息中找到它。",
"Project ID is required when selecting regions other than the default (us-central1). You can find this in a model 404 error message.": "仅当选择非默认区域(us-central1)时才需要 项目ID。\n 您可以在模型 404 错误消息中找到它。",
"Service Account Configuration": "服务帐户配置",
"Service Account JSON Content": "服务帐户 JSON 内容:",
"For privacy reasons, your Service Account JSON content will be hidden after you click 'Validate JSON'.": "出于隐私考虑,你的服务账号 JSON 内容将在点击“验证JSON”后隐藏。",
@ -478,6 +481,13 @@
"Groq Model": "Groq 模型",
"Electron Hub API Key": "Electron Hub API 密钥",
"Electron Hub Model": "Electron Hub 模型",
"Electron Hub Model Sorting": "Electron Hub 模型排序",
"Input Price": "输入价格(最便宜)",
"Output Price": "输出价格(最便宜)",
"Chutes API Key": "Chutes API 密钥",
"View Billing/Balance": "查看账单/余额",
"Chutes Model": "Chutes 模型",
"Chutes Model Sorting": "Chutes 模型排序",
"NanoGPT API Key": "NanoGPT API 密钥",
"NanoGPT Model": "NanoGPT 模型",
"DeepSeek API Key": "DeepSeek API 密钥",
@ -506,6 +516,19 @@
"Avoid sending sensitive information. Provider's outputs may include ads.": "请避免发送敏感信息。输出可能有提供商的广告。",
"Moonshot AI API Key": "Moonshot AI API 密钥",
"Moonshot AI Model": "Moonshot AI 模型",
"Z.AI API Key": "Z.AI API 密钥",
"Z.AI Endpoint": "Z.AI 端点",
"Common API": "通用 API",
"Coding API": "编码 API",
"Z.AI Model": "Z.AI 模型",
"Azure Base URL": "Azure 基础 URL",
"Deployment Name": "部署名称",
"The name of your model deployment in Azure.": "你在 Azure 中的模型部署名称。",
"API Version": "API 版本",
"Azure API Key": "Azure API 密钥",
"Model Name": "模型名称",
"Click 'Connect' to fetch model name": "点击“连接”以获取模型名称",
"The underlying model of your deployment. This is detected automatically when you connect.": "你部署的底层模型。连接时会自动检测。",
"Prompt Post-Processing": "提示词后处理",
"Applies additional processing to the prompt before sending it to the API.": "在将提示词发送到 API 之前对其进行额外处理。",
"prompt_post_processing_none": "未选择",
@ -529,6 +552,7 @@
"Master Import": "全局导入",
"Export Advanced Formatting settings": "导出高级格式化设置",
"Master Export": "全局导出",
"Grayed-out options have no effect when Chat Completion API is used.": "灰色选项在使用 聊天补全API 时无效。",
"Context Template": "上下文模板",
"context_derived": "若可能,从模型的元数据获取。",
"Select your current Context Template": "选择你当前的上下文模板",
@ -728,6 +752,7 @@
"Delete a theme": "删除主题",
"Update a theme file": "更新主题文件",
"Save as a new theme": "另存为新主题",
"This style applies to all avatars globaly, including your Persona, Character Management, Account selection, etc.": "此样式将应用在所有头像,包括您的用户设定、角色管理、帐户选择等。",
"Avatar Style:": "头像样式:",
"Circle": "圆形",
"Square": "正方形",
@ -737,6 +762,10 @@
"Flat": "扁平",
"Bubbles": "气泡",
"Document": "文档",
"Default display style for media attachments in chat messages. Extensions can override this setting.": "聊天消息中媒体附件的默认显示样式。扩展可以覆盖此设置。",
"Media Style:": "媒体样式:",
"List": "列表",
"Gallery": "画廊",
"Notifications:": "通知:",
"Top Left": "左上",
"Top Center": "顶部居中",
@ -835,9 +864,13 @@
"Find and delete backups, unused chats, files, images, etc.": "寻找和删除备份、未使用的聊天、文件、图片等。",
"Clean-Up": "清理",
"Smooth Streaming": "平滑流式传输",
"Experimental feature. May not work for all backends.": "实验性功能。可能不适用于所有后端。",
"Bypass smooth streaming in reasoning blocks.": "在推理块中不使用平滑流式传输。",
"Exclude 'Thinking...'": "排除“思考中...”",
"Slow": "慢",
"Fast": "快",
"Fade in streamed text when it appears, instead of it just popping in": "流式传输的文本淡入显示,而不是直接弹出",
"Stream Fade-In": "流式淡入",
"Experimental feature. May not work for all backends.": "实验性功能。可能不适用于所有后端。",
"Play a sound when a message generation finishes": "当消息生成完毕时播放声音",
"Message Sound": "消息声音",
"Only play a sound when ST's browser tab is unfocused": "仅在ST的浏览器标签页未被打开时播放声音",
@ -871,6 +904,9 @@
"Gradual push-out": "逐渐推出",
"Always include examples": "始终包含示例",
"Never include examples": "永不包含示例",
"Image Swipe Behavior:": "图片滑动刷新行为:",
"Generate new": "生成新的",
"Roll over": "循环现有",
"Send on Enter": "按 Enter 发送",
"Disabled": "已禁用",
"Automatic (PC)": "自动(PC)",
@ -896,6 +932,8 @@
"Allow {{user}}: in bot messages": "在机器人消息中允许 {{user}}: ",
"Skip encoding and characters in message text, allowing a subset of HTML markup as well as Markdown": "跳过消息文本中的编码和字符,允许一部分HTML标记以及Markdown",
"Show tags in responses": "在响应中显示标签",
"Experimental Macro Engine": "实验性宏引擎",
"Experimental feature. Currently in development to test.": "实验性功能。目前正在开发测试中。",
"Allow AI messages in groups to contain lines spoken by other group members": "允许群聊中的AI输出群中其他成员说的话",
"Relax message trim in Groups": "减轻群聊中的消息修剪",
"Log prompts to console": "将提示词输出到控制台",
@ -960,10 +998,15 @@
"Center": "居中",
"Automatically select a background based on the chat context": "根据聊天上下文自动选择背景",
"Auto-select": "自动选择",
"Add a new background": "添加新背景",
"Add Background": "添加背景",
"Global": "全局",
"Chat": "聊天",
"Make thumbnails smaller": "缩小缩略图",
"Make thumbnails larger": "放大缩略图",
"bg_chat_hint_1": "使用生成的聊天背景",
"bg_chat_hint_2": "扩展名将出现在这里。",
"Scroll backgrounds to top": "回顶",
"Extensions": "扩展",
"Notify on extension updates": "在扩展更新时通知",
"Manage extensions": "管理扩展",
@ -1004,7 +1047,6 @@
"Click to lock your selected persona to the current character. Click again to remove the lock.": "点击将选择的用户设定与当前角色绑定。再次点击以解绑。",
"Character": "角色",
"Click to lock your selected persona to the current chat. Click again to remove the lock.": "点击将选择的人设与当前聊天绑定。再次点击以解绑。",
"Chat": "聊天",
"Global Settings": "全局设置",
"Show notifications on switching personas": "切换用户设定时显示通知",
"When multiple personas are connected to a character, a popup will appear to select which one to use": "当多个用户设定与一个角色绑定时,会弹出一个弹窗让用户选择使用哪一个。",
@ -1038,7 +1080,7 @@
"More...": "更多...",
"Link to World Info": "链接到世界书",
"Import Card Lore": "导入角色卡的世界书",
"Scenario Override": "场景覆盖",
"Character Settings Overrides": "角色设置覆盖",
"Convert to Persona": "转换为用户角色",
"Rename": "重命名",
"Link to Source": "来源链接",
@ -1078,7 +1120,7 @@
"When 'Join character cards' is selected, all respective fields of the characters are being joined together.This means that in the story string for example all character descriptions will be joined to one big text.If you want those fields to be separated, you can define a prefix or suffix here.This value supports normal macros and will also replace {{char}} with the relevant char's name and <FIELDNAME> with the name of the part (e.g.: description, personality, scenario, etc.)": "当选择“合并角色卡”时,角色的所有相应字段将被合并在一起。这意味着在故事字符串中,例如,所有角色描述都将合并为一个大文本。如果您希望将这些字段分开,可以在此处定义前缀或后缀。此值支持普通宏,还会将 {{char}} 替换为相关角色的名称,将 <FIELDNAME> 替换为部分的名称(例如:描述、个性、场景等)",
"Inserted after each part of the joined fields.": "插入到加入字段的每个部分之后。",
"Join Suffix": "加入后缀",
"Set a group chat scenario": "设置群聊背景",
"Set group chat character settings overrides": "设置群聊角色设置覆盖",
"Click to allow/forbid the use of external media for this group.": "单击以允许/禁止该组使用外部媒体。",
"Restore collage avatar": "恢复拼贴头像",
"Allow self responses": "允许自我回复",
@ -1156,9 +1198,9 @@
"Save": "保存",
"Chat History": "聊天记录",
"Import Chat": "导入聊天",
"Copy to global backgrounds": "复制到全局背景",
"Lock": "锁定",
"Unlock": "解锁",
"Copy to global backgrounds": "复制到全局背景",
"Rename Background": "重命名背景",
"Delete Background": "删除背景",
"Select a World Info file for": "选择一个世界书文件给",
@ -1191,6 +1233,8 @@
"Optional Filter": "可选过滤器",
"Keywords or Regexes (ignored if empty)": "关键字或正则表达式(如果为空则忽略)",
"Comma separated list (ignored if empty)": "逗号分隔列表(如果为空则忽略)",
"wi_outlet_name": "为此世界信息条目设置锚点名称。\n\n位置为“锚点”的世界信息条目不会自动添加到提示词中。相反,它们将被收集并可作为提示词中的宏使用。\n在提示词中任何想要添加此特定锚点的所有世界信息条目的位置添加 {{outlet::YourName}}。",
"Outlet Name": "锚点名称",
"Use global setting": "使用全局设置",
"Case-Sensitive": "区分大小写",
"Use global": "使用全局",
@ -1260,6 +1304,7 @@
"at Depth System": "@D ⚙ [系统]在深度​​️",
"at Depth User": "@D 👤 [用户]在深度",
"at Depth AI": "@D 🤖 [AI]在深度",
"Outlet": "➡️ 锚点",
"Depth": "深度",
"Order:": "顺序:",
"Order": "顺序",
@ -1302,6 +1347,7 @@
"Narrate": "朗读",
"Exclude message from prompts": "从提示词中排除消息",
"Include message in prompts": "将消息包含在提示词中",
"Toggle media display style": "切换媒体显示样式",
"Embed file or image": "嵌入文件或图像",
"Create checkpoint": "创建检查点",
"Create Branch": "创建分支",
@ -1321,10 +1367,6 @@
"Collapse all reasoning blocks": "折叠所有推理块",
"Copy reasoning": "复制推理内容",
"Edit reasoning": "编辑推理内容",
"Expand and zoom": "展开并缩放",
"Caption": "标题",
"Swipe left": "向左滑动",
"Swipe right": "向右滑动",
"Welcome to SillyTavern!": "欢迎来到 SillyTavern!",
"SillyTavern is aimed at advanced users.": "SillyTavern 面向高级用户。",
"welcome_message_part_1": "阅读",
@ -1362,6 +1404,12 @@
"(This will be the first message from the character that starts every chat)": "(这是每次聊天开始时角色的第一条消息)",
"View contents": "查看内容",
"Remove the file": "删除文件",
"Expand and zoom": "展开并缩放",
"Caption": "标题",
"Swipe left": "向左滑动",
"Swipe right": "向右滑动",
"Play": "播放",
"Mute": "静音",
"Author's Note": "作者注释",
"Unique to this chat": "仅对此聊天生效",
"Checkpoints inherit the Note from their parent, and can be changed individually after that.": "检查点从其父级继承注释,之后可以单独更改。",
@ -1479,6 +1527,7 @@
"API": "API",
"Text Generation WebUI (oobabooga)": "文本生成 WebUI (oobabooga)",
"Model": "模型",
"Refresh model list": "刷新模型列表",
"currently_selected": "[当前选定]",
"currently_loaded": "[当前正在加载]",
"Custom Model Tag": "自定义模型标签",
@ -1513,21 +1562,21 @@
"Character Expressions": "角色表情",
"Use the selected API from Chat Translation extension settings.": "使用聊天翻译扩展程序中已选择的API。",
"Translate text to English before classification": "分类之前将文本翻译成英文",
"A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected.": "A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected.",
"Allow multiple sprites per expression": "Allow multiple sprites per expression",
"If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned.": "If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned.",
"Re-roll if same expression is used again": "Re-roll if same sprite is used again",
"A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected.": "使单个关键词可以有多个表情包。每当出现该关键词时,将随机选择其中一个。",
"Allow multiple sprites per expression": "允许关键词重复",
"If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned.": "再次使用相同关键词时,将重新刷新表情包。仅适用于关键词重复的表情包。",
"Re-roll if same expression is used again": "再次使用相同关键词时刷新表情包。",
"Classifier API": "分类器 API",
"Select the API for classifying expressions.": "选择用于对表达式进行分类的API。",
"Main API": "当前连接的 API",
"WebLLM Extension": "WebLLM 扩展程序",
"When using LLM or WebLLM classifier, only show and use expressions that have sprites assigned to them.": "When using LLM or WebLLM classifier, only show and use expressions that have sprites assigned to them.",
"Filter expressions for available sprites": "Filter expressions for available sprites",
"When using LLM or WebLLM classifier, only show and use expressions that have sprites assigned to them.": "使用 LLM 或 WebLLM 分类器时,仅显示和使用已分配表情包的关键词。",
"Filter expressions for available sprites": "筛选已有表情包的关键词",
"LLM Prompt": "大语言模型提示词",
"Used in addition to JSON schemas and function calling.": "Used in addition to JSON schemas and function calling.",
"LLM Prompt Strategy": "LLM Prompt Strategy",
"Limited Context": "Limited Context",
"Full Context": "Full Context",
"Used in addition to JSON schemas and function calling.": "可与 JSON结构 和 函数调用 一同使用。",
"LLM Prompt Strategy": "LLM 提示词策略",
"Limited Context": "限制上下文",
"Full Context": "完整上下文",
"Default / Fallback Expression": "默认/后备表达式",
"Set the default and fallback expression being used when no matching expression is found.": "设置在未找到匹配表达式时使用的默认表达式和后备表达式。",
"Custom Expressions": "自定义表达式",
@ -1640,46 +1689,53 @@
"macro for manual injection)": "宏用于手动注入)",
"Color": "颜色",
"Only apply color as accent": "仅应用颜色作为强调",
"ext_regex_new_global_script_desc": "新增「全局」正则表达式",
"ext_regex_new_scoped_script_desc": "新增「局部」正则表达式",
"ext_regex_new_preset_script_desc": "新增「预设」正则表达式",
"ext_regex_debugger_active_rules": "激活的规则",
"ext_regex_debugger_save_order_help": "保存当前规则顺序",
"ext_regex_debugger_save_order": "保存此顺序",
"ext_regex_debugger_testing_area": "测试区域",
"ext_regex_debugger_raw_input": "原始输入",
"ext_regex_debugger_run_test_help": "运行测试流程",
"ext_regex_debugger_run_test": "运行测试",
"ext_regex_debugger_display_replace": "替换",
"ext_regex_debugger_display_highlight": "高亮",
"ext_regex_debugger_render_text": "渲染为文本",
"ext_regex_debugger_render_message": "渲染为消息",
"ext_regex_debugger_step_by_step": "逐步转换",
"Expand view": "展开视图",
"ext_regex_debugger_final_output": "最终输出",
"Edit Rule": "编辑规则",
"ext_regex_title": "正则",
"ext_regex_new_global_script_desc": "新增「全局」正则表达式",
"ext_regex_new_global_script": "新建全局正则",
"ext_regex_new_preset_script_desc": "新增「预设」正则表达式",
"ext_regex_new_preset_script": "新建预设正则",
"ext_regex_new_scoped_script_desc": "新增「局部」正则表达式",
"ext_regex_new_scoped_script": "新建局部正则",
"ext_regex_import_script": "导入正则",
"ext_regex_bulk_edit": "批量编辑",
"ext_regex_debugger_desc": "高级正则调试工具",
"ext_regex_debugger": "调试工具",
"ext_regex_move_to_global": "移至全局",
"ext_regex_move_to_preset": "移至预设",
"ext_regex_move_to_scoped": "移至局部",
"Export": "导出",
"ext_regex_presets": "正则预设",
"ext_regex_presets_desc": "可以轻松保存并切换多组正则开关状态。",
"ext_regex_preset_create": "创建新预设",
"ext_regex_preset_update": "更新已有预设",
"ext_regex_preset_apply": "重新应用当前预设",
"ext_regex_preset_delete": "删除当前预设",
"ext_regex_new_global_script": "新建全局正则",
"ext_regex_new_scoped_script": "新建局部正则",
"ext_regex_new_preset_script": "新建预设正则",
"ext_regex_import_script": "导入正则",
"ext_regex_bulk_edit": "批量编辑",
"ext_regex_debugger_desc": "高级正则调试工具",
"ext_regex_debugger": "调试工具",
"Export": "导出",
"ext_regex_global_scripts": "全局正则脚本",
"ext_regex_global_scripts_desc": "影响所有角色,保存在本地设定中",
"No scripts found": "没有找到脚本",
"ext_regex_scoped_scripts": "局部正则脚本",
"ext_regex_scoped_scripts_desc": "只影响当前角色,保存在角色卡片中",
"ext_regex_preset_scripts": "预设正则脚本",
"ext_regex_preset_scripts_desc": "只影响当前预设,保存在预设中",
"ext_regex_disallow_scoped": "不允许使用局部正则",
"ext_regex_allow_scoped": "允许使用局部正则",
"ext_regex_disallow_preset": "不允许使用预设正则",
"ext_regex_allow_preset": "允许使用预设正则",
"ext_regex_preset_scripts_desc": "只影响当前预设,保存在预设中",
"ext_regex_scoped_scripts": "局部正则脚本",
"ext_regex_disallow_scoped": "不允许使用局部正则",
"ext_regex_allow_scoped": "允许使用局部正则",
"ext_regex_scoped_scripts_desc": "只影响当前角色,保存在角色卡片中",
"Regex Editor": "正则表达式编辑器",
"Test Mode": "测试模式",
"ext_regex_desc": "“正则”是一个使用“正则表达式”来查找/替换字符串的工具。如果您想了解更多信息,请点击标题旁边的“?”。",
@ -1725,22 +1781,17 @@
"Would you like to allow using them?": "你想要启用它们吗?",
"If you want to do it later, select 'Regex' from the extensions menu.": "你可以稍后在扩展栏的 \"正则\" 区域管理它们。",
"ext_regex_import_target": "导入至:",
"This preset has embedded regex script(s).": "此预设包含内置正则脚本。",
"ext_regex_disable_script": "禁用脚本",
"ext_regex_enable_script": "启用脚本",
"ext_regex_edit_script": "编辑脚本",
"ext_regex_move_to_global": "移至全局",
"ext_regex_move_to_scoped": "移至局部",
"ext_regex_move_to_preset": "移至预设",
"Show more options": "展示更多选项",
"ext_regex_export_script": "导出脚本",
"ext_regex_edit_script": "编辑脚本",
"ext_regex_delete_script": "删除脚本",
"This preset has embedded regex script(s).": "此预设包含内置正则脚本。",
"Preset '${0}' contains enabled regex scripts": "预设 '${0}' 包含被启用的正则脚本",
"Reload the chat for regex to take effect": "重新加载聊天以使正则生效",
"Click here to reload immediately": "点击此处立即重新加载",
"If you want to do it later, select \"Regex\" from the extensions menu.": "您可稍后从扩展菜单中的(Regex)启用。",
"Trigger Stable Diffusion": "触发Stable Diffusion",
"Abort current image generation task": "中止当前图像生成",
"Stop Image Generation": "停止图像生成",
"Send me a picture of:": "给我发一张……的照片:",
"sd_Yourself": "你自己",
"sd_Your_Face": "你的脸",
"sd_Me": "我",
@ -1751,8 +1802,8 @@
"Image Generation": "图像生成",
"sd_refine_mode": "允许在将提示词发送到生成 API 之前手动编辑提示词",
"sd_refine_mode_txt": "生成之前编辑提示词",
"sd_function_tool": "Use the function tool to automatically detect intents to generate images.",
"sd_function_tool_txt": "Use function tool",
"sd_function_tool": "使用函数工具自动检测生成图像的意图。",
"sd_function_tool_txt": "使用函数工具",
"sd_interactive_mode": "发送消息时自动生成图像,例如“给我发一张猫的照片”。",
"sd_interactive_mode_txt": "交互模式",
"sd_multimodal_captioning": "使用多模态字幕根据用户和角色的头像生成提示词。",
@ -1770,35 +1821,45 @@
"sd_auto_auth_warning_2": "注意!服务器必须可从 SillyTavern 主机访问。",
"sd_drawthings_url": "例如:{{drawthings_url}}",
"sd_drawthings_auth_txt": "运行 DrawThings 应用程序并在 UI 中启用 HTTP API 开关!必须可以从 SillyTavern 主机访问服务器。",
"Model ID": "Model ID",
"Hint: Save an API key in the Hugging Face (Text Completion) API settings to use it here.": "提示:在 Hugging Face(文本补全)API 设置中保存一个 API 密钥以在此处使用。",
"Model ID": "模型 ID",
"e.g. black-forest-labs/FLUX.1-dev": "例如:black-forest-labs/FLUX.1-dev",
"Hint: Save an API key in the Chutes (Chat Completion) API settings to use it here.": "提示:在 Chutes(聊天补全)API 设置中保存一个 API 密钥以在此处使用。",
"Hint: Save an API key in the Electron Hub (Chat Completion) API settings to use it here.": "提示:在 Electron Hub(聊天补全)API 设置中保存一个 API 密钥以在此处使用。",
"Image Quality": "画面质量",
"Hint: Save an API key in the NanoGPT (Chat Completion) API settings to use it here.": "提示:在 NanoGPT(聊天补全)API 设置中保存一个 API 密钥以在此处使用。",
"sd_vlad_url": "例如:{{vlad_url}}",
"The server must be accessible from the SillyTavern host machine.": "必须能够从 SillyTavern 主机访问该服务器。",
"Hint: Save an API key in AI Horde API settings to use it here.": "提示:在 Horde AI API 设置中保存一个 API 密钥以便在此处使用它。",
"Hint: Save an API key in AI Horde API settings to use it here.": "提示:在 Horde AI API 设置中保存一个 API 密钥以在此处使用。",
"Allow NSFW images from Horde": "允许来自 Horde 的 NSFW 图片",
"Sanitize prompts (recommended)": "净化提示词(推荐)",
"Automatically adjust generation parameters to ensure free image generations.": "自动调整生成参数,确保图像生成自由。",
"Avoid spending Anlas": "避免花费 Anlas",
"Opus tier": "(作品层)",
"View my Anlas": "查看我的目录",
"Opus tier": "(Opus 级别)",
"View my Anlas": "查看我的 Anlas",
"Hint: Save an API key in the NovelAI API settings to use it here.": "提示:在 NovelAI API 设置中保存一个 API 密钥以在此处使用。",
"Click to set": "点击设置",
"These settings only apply to DALL-E 3": "这些设置仅适用于 DALL-E 3",
"Image Style": "图像风格",
"Image Quality": "画面质量",
"Standard": "标准",
"HD": "高清",
"Duration": "持续时间",
"Short (4 seconds)": "短(4秒)",
"Medium (8 seconds)": "中(8秒)",
"Long (16 seconds)": "长(16秒)",
"sd_comfy_url": "例如:{{comfy_url}}",
"sd_comfy_runpod_url": "eg: https://api.runpod.ai/v2/<your endpoint id>",
"Open workflow editor": "打开工作流编辑器",
"Create new workflow": "创建新的工作流",
"Delete workflow": "删除工作流",
"Enables prompt enhancing (passes prompts through an LLM to add detail).": "允许提示词增强(通过大语言模型处理提示词以添加细节)。",
"Enhance": "提高",
"You can find your API key in the Stability AI dashboard.": "您可以在 Stability AI 仪表板中找到您的 API 密钥。",
"Style Preset": "风格预设",
"Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.": "是否对提示词使用提示词增强(Upsampling)。若开启,则会自动修改提示词,使回复更有创造力。",
"Prompt Upsampling": "提示词增强(Upsampling)",
"Duration (Veo)": "持续时间(Veo)",
"Sampling method": "采样方法",
"Scheduler": "调度器",
"Resolution": "分辨率",
"Upscaler": "图像扩大器",
"Sampling steps": "采样步数",
"Width": "宽度",
@ -1812,15 +1873,15 @@
"Hires. Fix": "高清修复",
"Karras": "Karras",
"Not all samplers supported.": "并非所有采样器都受支持。",
"sd_adetailer_face": "Use ADetailer with face model during the generation. The ADetailer extension must be installed on the backend.",
"sd_adetailer_face": "使用 ADetailer 脸部模型进行生成。后端必须安装 ADetailer 扩展。",
"Use ADetailer (Face)": "使用 ADetailer(脸部)",
"SMEA versions of samplers are modified to perform better at high resolution.": "SMEA 版本的采样器经过修改,在高分辨率下性能更佳。",
"SMEA": "中小企业协会",
"DYN variants of SMEA samplers often lead to more varied output, but may fail at very high resolutions.": "SMEA 采样器的 DYN 变体通常会产生更加多样化的输出,但在非常高的分辨率下可能会失败。",
"DYN": "动态",
"Decrisper": "去伪器",
"Enable guidance only after body has been formed, to improve diversity and saturation of samples. May reduce relevance": "Enable guidance only after body has been formed, to improve diversity and saturation of samples. May reduce relevance",
"Variety+": "Variety+",
"Enable guidance only after body has been formed, to improve diversity and saturation of samples. May reduce relevance": "仅在图像主体形成后启用引导,以提高样本的多样性和饱和度。可能会降低相关性",
"Variety+": "多样性+",
"(-1 for random)": "(“-1”为随机)",
"Preset for prompt prefix and negative prompt": "提示词前缀和负面提示词的预设",
"Style": "风格",
@ -1863,6 +1924,7 @@
"ext_translate_target_lang": "目标语言",
"ext_translate_clear": "清空设置",
"Select TTS Provider": "选择 文本转语音 的服务提供商",
"tts_refresh": "刷新",
"tts_enabled": "已启用",
"Narrate user messages": "朗读用户消息",
"Auto Generation": "自动生成",
@ -1875,18 +1937,22 @@
"Skip codeblocks": "跳过代码块",
"Skip tagged blocks": "跳过标签块里的内容(<标签>跳过这里</标签>)",
"Pass Asterisks to TTS Engine": "将星号传递给文本转语音服务",
"Works best when: Pass Asterisks to TTS Engine is enabled, and both Only narrate quotes and Ignore *text, even 'quotes', inside asterisks* are disabled.": "Works best when: Pass Asterisks to TTS Engine is enabled, and both Only narrate quotes and Ignore *text, even 'quotes', inside asterisks* are disabled.",
"Different voices for quotes and text inside asterisks": "Different voices for \"quotes\", *text inside asterisks* and other text",
"Works best when: Pass Asterisks to TTS Engine is enabled, and both Only narrate quotes and Ignore *text, even 'quotes', inside asterisks* are disabled.": "最佳效果:启用“将星号传递给文本转语音服务”,同时禁用“只朗读引号内文本”和“忽略*星号内文本*(即使其被引号包裹)”功能。",
"Different voices for quotes and text inside asterisks": "为“引号内文本”、*星号内文本*使用不同的声音",
"Audio Playback Speed": "音频播放速度",
"Available voices": "可用声音",
"Vector Storage": "向量存储",
"Vectorization Source": "向量化源",
"Local (Transformers)": "本地(Transformers)",
"Secondary Embedding endpoint URL": "Secondary Embedding endpoint URL",
"Vectorization Model": "向量化模型",
"Hint: Set your Chutes API key in API Connections.": "提示:在 API 连接设置中设置 Chutes API 密钥。",
"Hint: Set your Electron Hub API key in API Connections.": "提示:在 API 连接设置中设置 Electron Hub API 密钥。",
"Secondary Embedding endpoint URL": "次级向量化端点 URL",
"Keep model in memory": "将模型保存在内存中",
"Hint: Set the URL in the API connection settings.": "提示:在 API 连接设置中设置 URL。",
"The server MUST be started with the --embedding flag to use this feature!": "服务器必须使用 --embedding 标志启动才能使用此功能!",
"NomicAI API Key": "NomicAI API 密钥",
"Hint: Set your OpenRouter API key in API Connections.": "提示:在 API 连接设置中设置 OpenRouter API 密钥。",
"Query messages": "查询消息",
"Score threshold": "分数阈值",
"Chunk boundary": "区块边界",
@ -2115,106 +2181,16 @@
"World Info:": "世界书:",
"Chat History:": "聊天记录:",
"Extensions:": "扩展程序:",
"Bias:": "Bias:",
"Bias:": "偏置:",
"Total Tokens in Prompt:": "提示词的总Token数量:",
"Max Context": "最大上下文:",
"(Context Size - Response Length)": "(上下文长度 - 回复长度)",
"System-wide Replacement Macros (in order of evaluation):": "系统范围的替换宏(按评估顺序):",
"help_macros_1": "仅适用于斜线命令批处理。替换为上一个命令的返回结果。",
"help_macros_2": "仅插入一个换行符。",
"help_macros_3": "修剪此宏周围的换行符。",
"help_macros_4": "没有操作,只是一个空字符串。",
"help_macros_5": "API 设置中定义的全局提示。仅在高级定义提示覆盖中有效。",
"help_macros_6": "用户输入",
"help_macros_7": "角色的主提示覆盖",
"help_macros_8": "角色越狱提示覆盖",
"help_macros_9": "角色描述",
"help_macros_10": "人物性格",
"help_macros_11": "角色场景",
"help_macros_12": "您当前的角色描述",
"help_macros_13": "角色对话示例",
"help_macros_14": "未格式化的对话示例",
"help_macros_summary": "“总结”扩展程序生成的最新聊天总结(如果有)。",
"help_macros_15": "您当前的用户设定名称",
"help_macros_16": "角色的名字",
"help_macros_17": "角色的版本号",
"help_macros_charDepthPrompt": "角色的 @ 深度注释",
"help_macros_18": "以逗号分隔的群成员名称列表或单人聊天中的角色名称。别名:{{charIfNotGroup}}",
"help_groupNotMuted": "与 {{group}} 相同,但排除被禁言的成员",
"help_macros_19": "当前选定的 API 的文本生成模型名称。",
"Can be inaccurate!": "不一定准确!",
"help_macros_20": "最新聊天消息的文本。",
"help_macros_lastUser": "最后的用户聊天消息文本。",
"help_macros_lastChar": "最后的角色聊天消息文本。",
"help_macros_21": "最新聊天消息的索引号。对于斜线命令批处理很有用。",
"help_macros_22": "上下文中包含的第一条消息的 ID。要求在当前会话中至少运行一次生成。",
"help_macros_firstDisplayedMessageId": "第一条载入可见聊天的消息的ID",
"help_macros_23": "最后一条聊天消息中当前滑动的 ID(以 1 为基数)。如果最后一条消息是用户或提示隐藏的,则为空字符串。",
"help_macros_24": "最后一条聊天消息中的滑动次数。如果最后一条消息是用户隐藏或提示隐藏的,则为空字符串。",
"help_macros_reverse": "反转宏的内容。",
"help_macros_25": "您可以在此处留言,宏将被替换为空白内容。AI 看不到。",
"help_macros_26": "当前时间",
"help_macros_27": "当前日期",
"help_macros_28": "当前工作日",
"help_macros_29": "当前 ISO 时间(24 小时制)",
"help_macros_30": "当前 ISO 日期 (YYYY-MM-DD)",
"help_macros_31": "指定格式的当前日期/时间,例如德国日期/时间:",
"help_macros_32": "指定 UTC 时区偏移量的当前时间,例如 UTC-4 或 UTC+2",
"help_macros_33": "time1 和 time2 之间的时间差。接受时间和日期宏。(例如:{{timeDiff::{{isodate}} {{time}}::2024/5/11 12:30:00}})",
"help_macros_34": "距离上次用户消息发送的时间",
"help_macros_35": "为 AI 设置行为偏差,直到下一个用户输入。文本周围的引号很重要。",
"help_macros_36": "掷骰子。(例如:",
"space_ will roll a 6-sided dice and return a number between 1 and 6)": "将掷一个 6 面骰子并返回 1 到 6 之间的数字)",
"help_macros_37": "从列表中返回一个随机项目。(例如:",
"space_ will return 1 of the 4 numbers at random. Works with text lists too.": "将随机返回 4 个数字中的 1 个。也适用于文本列表。",
"help_macros_38": "随机的替代语法允许在列表项中使用逗号。",
"help_macros_39": "从列表中随机挑选一项。工作原理与 {{random}} 相同,具有相同的语法选项,但一旦挑选,挑选将一直持续到本次聊天,不会在连续消息和提示处理中重新滚动。",
"help_macros_40": "如果使用文本生成 WebUI 后端,则动态地将引号中的文本添加到禁用单词序列中。对其他后端不执行任何操作。可以在任何地方使用(角色描述、WI、AN 等)。文本周围的引号很重要。",
"help_macros_isMobile": "当为移动端时为\"true\",反之为\"false\"",
"Instruct Mode and Context Template Macros:": "指导模式和上下文模板宏:",
"(enabled in the Advanced Formatting settings)": "(在高级格式设置中启用)",
"help_macros_41": "令牌中允许的最大提示长度 = (上下文长度 - 响应长度)",
"help_macros_42": "上下文模板示例对话分隔符",
"help_macros_43": "上下文模板聊天开始行",
"help_macros_44": "主系统提示(如果选择,则覆盖字符提示,或 instructSystemPrompt)",
"help_macros_45": "指示系统提示",
"help_macros_46": "指示系统提示前缀序列",
"help_macros_47": "指示系统提示后缀序列",
"help_macros_48": "指示用户前缀序列",
"help_macros_49": "指示用户后缀序列",
"help_macros_50": "指导助理前缀序列",
"help_macros_51": "指导助理后缀序列",
"help_macros_52": "指导助理第一个输出序列",
"help_macros_53": "指导助手最后输出序列",
"help_macros_54": "指示系统消息前缀序列",
"help_macros_55": "指示系统消息后缀序列",
"help_macros_56": "指示系统指令前缀",
"help_macros_57": "指示第一个用户消息填充器",
"help_macros_58": "指示停止顺序",
"help_macros_first_user": "指示用户第一个输入序列",
"help_macros_last_user": "指示用户最后输入序列",
"Chat variables Macros:": "聊天变量宏:",
"Local variables = unique to the current chat": "局部变量 = 当前聊天所独有",
"Global variables = works in any chat for any character": "全局变量 = 适用于任何角色的任何聊天",
"Scoped variables = works in STscript": "范围变量 = 在 STscript 中有效",
"help_macros_59": "替换为局部变量“name”的值",
"help_macros_60": "替换为空字符串,将局部变量“name”设置为“value”",
"help_macros_61": "替换为空字符串,将“increment”的数值添加到局部变量“name”",
"help_macros_62": "替换为变量“name”的值增加 1 的结果",
"help_macros_63": "替换为变量“name”的值减 1 的结果",
"help_macros_64": "替换为全局变量“name”的值",
"help_macros_65": "替换为空字符串,将全局变量“name”设置为“value”",
"help_macros_66": "替换为空字符串,将“increment”的数值添加到全局变量“name”",
"help_macros_67": "替换为全局变量“name”的值增加 1 的结果",
"help_macros_68": "替换为全局变量“name”的值减 1 的结果",
"help_macros_69": "替换为范围变量“name”的值",
"help_macros_70": "用范围变量“name”的索引处的项目值(对于数组/列表或对象/字典)替换",
"Choose what to export": "选择您想要导出什么:",
"Choose what to import": "选择您想要导入什么:",
"If necessary, you can later restore this chat file from the /backups folder": "若需要,您可稍后在 /backups 文件夹中恢复此聊天文件。",
"Also delete the current chat file": "同时删除当前聊天文件",
"Persona Lorebook for": "Persona Lorebook for",
"persona_world_template_txt": "A selected World Info will be bound to this persona. When generating an AI reply,\n it will be combined with the entries from global, character and chat lorebooks.",
"Persona Lorebook for": "Ta 的角色世界书:",
"persona_world_template_txt": "将世界书绑定到此角色。生成 AI 回复时,\n 它将与全局、角色和聊天世界书中的条目结合使用。",
"Insert prompt": "插入提示词",
"Import a prompt list": "导入提示词列表",
"Export this prompt list": "导出此提示词列表",
@ -2234,13 +2210,21 @@
"Don't forget to save a snapshot of your settings before proceeding.": "在继续之前,不要忘记保存您的设置快照。",
"Enter your password below to confirm:": "输入您的密码以确认:",
"Reset custom sampler selection": "重置自定义采样器选择",
"Reset": "重置",
"Prioritize showing samplers manually selected from this popup.": "优先显示从此弹出窗口手动选择的采样器。",
"Toggle on to force the samplers selected in this menu to be shown when switching to the current API Type (API Connections Panel). By default, SillyTavern automatically selects samplers used or needed by the selected API Type.": "开启此选项,以在切换到当前 API 配置(API 连接面板)时强制显示现在所选择的采样器。默认情况下,SillyTavern 会自动选择所选 API 配置使用或需要的采样器。",
"Here you can toggle the display of individual samplers. (WIP)": "在此可以切换单个采样器的显示。(开发中)",
"Chat Scenario Override": "聊天场景覆盖",
"Chat Character Settings Override": "聊天角色设置覆盖",
"Remove": "移除",
"Unique to this chat.": "仅对此聊天生效。",
"All group members will use the following scenario text instead of what is specified in their character cards.": "All group members will use the following scenario text instead of what is specified in their character cards.",
"The following scenario text will be used instead of the value set in the character card.": "The following scenario text will be used instead of the value set in the character card.",
"Checkpoints inherit the scenario override from their parent, and can be changed individually after that.": "Checkpoints inherit the scenario override from their parent, and can be changed individually after that.",
"All group members will use the following values instead of what is specified in their character cards.": "所有群成员将使用以下值,而不是其角色卡中指定的值。",
"The following values will be used instead of the value set in the character card.": "以下值将替代角色卡中设置的值。",
"Checkpoints inherit the overrides from their parent, and can be changed individually after that.": "检查点继承其父级的覆盖设置,并可后续单独更改。",
"Type Scenario here...": "在此输入场景...",
"Type Example Messages here...": "在此输入示例消息...",
"Prefer Char. Prompt": "优先角色提示词",
"MUST be enabled!": "必须启用!",
"Type System Prompt here...": "在此输入系统提示词...",
"API:": "API:",
"Key:": "密钥:",
"Add Secret": "添加密钥",
@ -2256,7 +2240,7 @@
"Extra parameters for downloading/HuggingFace API": "下载/HuggingFace API 的额外参数。如果不确定,请将其留空。",
"Revision": "修订",
"Folder Name": "输出文件夹名称",
"HF Token": "HF代币",
"HF Token": "HF 令牌",
"Include Patterns": "包含模式",
"Glob patterns of files to include in the download.": "要包含在下载中的文件的全局模式。每个模式用换行符分隔。",
"Exclude Patterns": "排除模式",
@ -2267,14 +2251,12 @@
"Save your tags to a file": "将标签保存为文件",
"Restore tags from a file": "从文件中恢复标签",
"Create a new tag": "新建一个标签",
"Drag handle to reorder. Click name to rename. Click color to change display.": "拖拽左侧三条横线以排序,点击名字以重命名,点击调色盘以切换颜色。",
"Click on the folder icon to use this tag as a folder.": "点击文件夹图标来将此标签作为一个文件夹。",
"Use alphabetical sorting": "按字母顺序排列",
"Sort mode": "排序模式",
"Manual (Drag & Drop)": "手动 (拖放)",
"Alphabetical (A-Z)": "按字母 (A-Z)",
"Most Used (By Count)": "按使用次数",
"tags_sorting_desc": "启用后,标签在创建或重命名时会自动按字母顺序排序。\n禁用后,新标签会追加到末尾。\n\n如果通过拖动手动重新排列标签,则自动排序将被禁用。",
"Drag handle to reorder. Click name to rename. Click color to change display.": "拖拽左侧三条横线以排序,点击名字以重命名,点击调色盘以切换颜色。",
"Click on the folder icon to use this tag as a folder.": "点击文件夹图标来将此标签作为一个文件夹。",
"Are you sure you want to delete the theme?": "你确定要删除这个主题吗?",
"Hi,": "嗨,",
"To enable multi-account features, restart the SillyTavern server with": "要启用多帐户功能,请使用以下命令重新启动 SillyTavern 服务器",
@ -2297,7 +2279,7 @@
"Wipe all user data and reset your account to factory settings.": "删除所有用户数据并将您的账号重置为默认设置。",
"Reset Everything": "重置一切",
"This will delete all your settings and data. There will be no undo button. Make sure you have a backup before proceeding.": "这将删除您所有的设置和数据,不可撤销。请确保您已备份数据。",
"Account reset code has been posted to the server console.": "账户重置代码已发布到服务器控制台。",
"Account reset code has been posted to the server console.": "账户重置代码已发送至服务器控制台。",
"Reset Code:": "重置代码:",
"Want to update?": "获取最新版本",
"How to start chatting?": "如何快速开始聊天?",

File diff suppressed because it is too large Load diff

View file

@ -765,7 +765,7 @@ class PromptManager {
eventSource.on(event_types.CHATCOMPLETION_MODEL_CHANGED, () => this.renderDebounced());
// Re-render when the character changes.
eventSource.on('chatLoaded', (event) => {
eventSource.on(event_types.CHAT_LOADED, (event) => {
this.handleCharacterSelected(event);
this.saveServiceSettings().then(() => this.renderDebounced());
});

View file

@ -408,7 +408,7 @@ function RA_autoconnect(PrevApi) {
|| (secret_state[SECRET_KEYS.FIREWORKS] && oai_settings.chat_completion_source == chat_completion_sources.FIREWORKS)
|| (secret_state[SECRET_KEYS.COMETAPI] && oai_settings.chat_completion_source == chat_completion_sources.COMETAPI)
|| (secret_state[SECRET_KEYS.ZAI] && oai_settings.chat_completion_source == chat_completion_sources.ZAI)
|| (oai_settings.chat_completion_source === chat_completion_sources.POLLINATIONS)
|| (secret_state[SECRET_KEYS.POLLINATIONS] && oai_settings.chat_completion_source === chat_completion_sources.POLLINATIONS)
|| (isValidUrl(oai_settings.custom_url) && oai_settings.chat_completion_source == chat_completion_sources.CUSTOM)
|| (secret_state[SECRET_KEYS.AZURE_OPENAI] && oai_settings.chat_completion_source == chat_completion_sources.AZURE_OPENAI)
) {
@ -996,7 +996,7 @@ export function initRossMods() {
}
//Enter to send when send_textarea in focus
if (document.activeElement == hotkeyTargets['send_textarea']) {
if (document.activeElement == hotkeyTargets.send_textarea) {
const sendOnEnter = shouldSendOnEnter();
if (!event.isComposing && !event.shiftKey && !event.ctrlKey && !event.altKey && event.key == 'Enter' && sendOnEnter) {
event.preventDefault();
@ -1004,7 +1004,7 @@ export function initRossMods() {
return;
}
}
if (document.activeElement == hotkeyTargets['dialogue_popup_input'] && !isMobile()) {
if (document.activeElement == hotkeyTargets.dialogue_popup_input && !isMobile()) {
if (!event.shiftKey && !event.ctrlKey && event.key == 'Enter') {
event.preventDefault();
$('#dialogue_popup_ok').trigger('click');
@ -1139,7 +1139,7 @@ export function initRossMods() {
if (event.ctrlKey && event.key == 'ArrowUp') { //edits last USER message if chatbar is empty and focused
if (
hotkeyTargets['send_textarea'].value === '' &&
hotkeyTargets.send_textarea.value === '' &&
chatbarInFocus === true &&
($('.swipe_right:last').css('display') === 'flex' || $('.last_mes').attr('is_system') === 'true') &&
$('#character_popup').css('display') === 'none' &&
@ -1158,7 +1158,7 @@ export function initRossMods() {
if (event.key == 'ArrowUp') { //edits last message if chatbar is empty and focused
console.log('got uparrow input');
if (
hotkeyTargets['send_textarea'].value === '' &&
hotkeyTargets.send_textarea.value === '' &&
chatbarInFocus === true &&
//$('.swipe_right:last').css('display') === 'flex' &&
$('.last_mes .mes_buttons').is(':visible') &&

View file

@ -4,11 +4,10 @@ import {
chat_metadata,
eventSource,
event_types,
extension_prompt_roles,
extension_prompt_types,
saveSettingsDebounced,
this_chid,
} from '../script.js';
import { extension_prompt_roles, extension_prompt_types } from './extension-prompts.js';
import { selected_group } from './group-chats.js';
import { extension_settings, getContext, saveMetadataDebounced } from './extensions.js';
import { getCharaFilename, debounce, delay } from './utils.js';
@ -594,7 +593,7 @@ function registerAuthorsNoteMacros() {
handler: () => chat_metadata[metadata_keys.prompt] ?? '',
});
macros.register('charAuthorsNote', {
category: MacroCategory.CHARACTER,
category: MacroCategory.PROMPTS,
description: t`The contents of the Character Author's Note`,
handler: () => this_chid !== undefined ? (extension_settings.note.chara.find((e) => e.name === getCharaFilename())?.prompt ?? '') : '',
});

View file

@ -155,6 +155,10 @@ export class AutoComplete {
*/
updateName(item) {
const chars = Array.from(item.dom.querySelector('.name').children);
if (item.forceFullNameMatch) {
chars.forEach(c => c.classList.toggle('matched', true));
return;
}
switch (this.matchType) {
case 'strict': {
chars.forEach((it, idx) => {
@ -275,6 +279,7 @@ export class AutoComplete {
//TODO check if isInput and isForced are both required
this.text = this.textarea.value;
this.isReplaceable = false;
this.isShowForced = isForced; // Store forced state for checkIfActivate to access
if (document.activeElement != this.textarea) {
// only show with textarea in focus
@ -311,8 +316,8 @@ export class AutoComplete {
this.name = this.parserResult.name.toLowerCase() ?? '';
const isCursorInNamePart = this.textarea.selectionStart >= this.parserResult.start && this.textarea.selectionStart <= this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0);
if (isForced || isInput) {
// if forced (ctrl+space) or user input...
if (isForced || isInput || isSelect) {
// if forced (ctrl+space) or user input or just selected an option...
if (isCursorInNamePart) {
// ...and cursor is somewhere in the name part (including right behind the final char)
// -> show autocomplete for the (partial if cursor in the middle) name
@ -393,8 +398,20 @@ export class AutoComplete {
this.updateName(option);
return option;
})
// sort by fuzzy score or alphabetical
.toSorted(this.matchType == 'fuzzy' ? this.fuzzyScoreCompare : (a, b) => a.name.localeCompare(b.name));
// sort by priority first, then by fuzzy score or alphabetical
.toSorted((a, b) => {
// First compare by sortPriority (lower = higher priority)
const priorityA = a.sortPriority ?? 100;
const priorityB = b.sortPriority ?? 100;
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
// Then by fuzzy score or alphabetical
if (this.matchType == 'fuzzy') {
return this.fuzzyScoreCompare(a, b);
}
return a.name.localeCompare(b.name);
});
@ -430,7 +447,7 @@ export class AutoComplete {
} else if (!this.isReplaceable && this.result.length > 1) {
return this.hide();
}
this.selectedItem = this.result[0];
this.selectedItem = this.selectDefaultItem(this.result);
this.isActive = true;
this.wasForced = isForced;
this.renderDebounced();
@ -588,7 +605,22 @@ export class AutoComplete {
if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {
return this.hide();
}
const left = Math.max(rect.left, location.left) - layerRect.left;
let left = Math.max(rect.left, location.left) - layerRect.left;
// Check if the autocomplete list is constrained by the right edge of the viewport.
// If so, adjust the details panel position to align with the actual list position.
// Only do this when the list is actually visible (isReplaceable).
if (this.isReplaceable) {
const listRect = this.dom.getBoundingClientRect();
const listActualLeft = listRect.left - layerRect.left;
const isConstrainedRight = listActualLeft < left - 5; // 5px tolerance
if (isConstrainedRight) {
// Use the actual list position instead of cursor position
left = listActualLeft;
}
}
this.detailsWrap.style.setProperty('--targetOffset', `${left}`);
if (this.isReplaceable) {
this.detailsWrap.classList.remove('full');
@ -680,8 +712,10 @@ export class AutoComplete {
*/
async select() {
if (this.isReplaceable && this.selectedItem.value !== null) {
this.textarea.value = `${this.text.slice(0, this.effectiveParserResult.start)}${this.selectedItem.replacer}${this.text.slice(this.effectiveParserResult.start + this.effectiveParserResult.name.length + (this.startQuote ? 1 : 0) + (this.endQuote ? 1 : 0))}`;
this.textarea.selectionStart = this.effectiveParserResult.start + this.selectedItem.replacer.length;
// Apply per-option replacement offset (e.g., for closing tags that need to replace leading whitespace)
const effectiveStart = this.effectiveParserResult.start + (this.selectedItem.replacementStartOffset ?? 0);
this.textarea.value = `${this.text.slice(0, effectiveStart)}${this.selectedItem.replacer}${this.text.slice(this.effectiveParserResult.start + this.effectiveParserResult.name.length + (this.startQuote ? 1 : 0) + (this.endQuote ? 1 : 0))}`;
this.textarea.selectionStart = effectiveStart + this.selectedItem.replacer.length;
this.textarea.selectionEnd = this.textarea.selectionStart;
this.show(false, false, true);
} else {
@ -696,6 +730,24 @@ export class AutoComplete {
}
/**
* Select the default item for the autocomplete list.
* Selects the first selectable item if any is present, or falls back to the last item.
* (To preserve context of where we are with multiple non-selectable options, if they are present for info)
* @param {AutoCompleteOption[]} result The list of autocomplete options.
* @returns {AutoCompleteOption} The item to select.
*/
selectDefaultItem(result) {
if (result.length === 0) return null;
// Find first selectable item
const firstSelectable = result.find(it => it.isSelectable);
if (firstSelectable) return firstSelectable;
// Fall back to last item
return result[result.length - 1];
}
/**
* Mark the item at newIdx in the autocomplete list as selected.
* @param {number} newIdx

View file

@ -24,7 +24,7 @@ export class AutoCompleteNameResultBase {
this.start = start;
this.optionList = optionList;
this.canBeQuoted = canBeQuoted;
this.noMatchText = makeNoMatchText ?? this.makeNoMatchText;
this.noOptionstext = makeNoOptionsText ?? this.makeNoOptionsText;
if (makeNoMatchText) this.makeNoMatchText = makeNoMatchText;
if (makeNoOptionsText) this.makeNoOptionsText = makeNoOptionsText;
}
}

View file

@ -13,6 +13,22 @@ export class AutoCompleteOption {
/** @type {(input:string)=>boolean} */ matchProvider;
/** @type {(input:string)=>string} */ valueProvider;
/** @type {boolean} */ makeSelectable = false;
/** @type {boolean} */ forceFullNameMatch = false;
/**
* Offset to adjust the replacement start position.
* Negative values start replacement earlier (e.g., -2 to include 2 chars before normal start).
* Used by closing tag autocomplete to replace leading whitespace.
* @type {number}
*/
replacementStartOffset = 0;
/**
* Priority for sorting. Lower values = higher priority (sorted first).
* Default is 100 (normal priority). Use lower values for items that should appear at the top.
* @type {number}
*/
sortPriority = 100;
/**

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,307 @@
/**
* Macro autocomplete for free text inputs (textareas and input fields).
* Provides macro autocomplete when typing `{{` in marked text inputs.
*
* This module uses shared utilities from MacroAutoCompleteHelper.js to ensure
* consistent behavior with the slash command macro autocomplete.
*
* Usage:
* - Mark a textarea/input with `data-macros` or `data-macros="true"` attribute
* - Call `initMacroAutoComplete()` to initialize all marked elements
* - Dynamically added elements are automatically initialized via MutationObserver
*/
import { power_user } from '../power-user.js';
import { AutoComplete, AUTOCOMPLETE_STATE } from './AutoComplete.js';
import { findMacroAtCursor, findUnclosedScopes, getMacroAutoCompleteAt } from './MacroAutoCompleteHelper.js';
/** Custom attribute name used to mark elements that support macro autocomplete */
export const MACRO_AUTOCOMPLETE_ATTRIBUTE = 'data-macros';
/** Attribute to control autocomplete visibility: 'always' (force show) or 'hide' (never show) */
export const MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE = 'data-macros-autocomplete';
/** Generic attribute to control autocomplete popup style/size (used by AutoComplete) */
export const MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE = 'data-macros-autocomplete-style';
/**
* @readonly
* @enum {string}
*/
export const MACRO_AUTOCOMPLETE_MODE = Object.freeze({
/** Default behavior: respects global setting showInAllMacroFields */
DEFAULT: 'default',
/** Always show autocomplete in this field (expanded editors, prompt manager) */
ALWAYS: 'always',
/** Never show autocomplete in this field */
HIDE: 'hide',
});
/**
* @readonly
* @enum {string}
*/
export const MACRO_AUTOCOMPLETE_STYLE = Object.freeze({
/** Small popup (33vw, max 700px) for inline fields */
SMALL: 'small',
/** Expanded popup (default chat width) for expanded editors */
EXPANDED: 'expanded',
});
/** @type {WeakSet<HTMLElement>} Track initialized elements to avoid double-init */
const initializedElements = new WeakSet();
/** @type {WeakMap<HTMLElement, AutoComplete>} Map elements to their autocomplete instances */
const elementAutoCompleteMap = new WeakMap();
/**
* Checks if the cursor is positioned where macro autocomplete should activate.
* Activates when:
* - Cursor is right after typing `{{`
* - Cursor is inside a macro `{{...}}`
* - Cursor is in scoped content of an unclosed scoped macro (e.g., after `{{setvar myvar}}`)
*
* @param {string} text - The full text content.
* @param {number} cursorPos - The cursor position.
* @param {Object} [options={}] - Additional options.
* @param {boolean} [options.isForced=false] - Whether this is a forced activation (e.g., Ctrl+Space).
* @param {MACRO_AUTOCOMPLETE_MODE} [options.autocompleteMode=MACRO_AUTOCOMPLETE_MODE.DEFAULT] - The autocomplete mode.
* @returns {boolean}
*/
function shouldActivateMacroAutocomplete(text, cursorPos, { isForced = false, autocompleteMode = MACRO_AUTOCOMPLETE_MODE.DEFAULT } = {}) {
// If mode is 'hide', never show autocomplete
if (autocompleteMode === MACRO_AUTOCOMPLETE_MODE.HIDE) {
return false;
}
// Check if autocomplete is enabled at all
if (power_user.stscript.autocomplete.state === AUTOCOMPLETE_STATE.DISABLED) {
return false;
}
// Determine if we should show normally based on mode and settings
// ALWAYS mode: always show, DEFAULT mode: respect global setting
const alwaysShow = autocompleteMode === MACRO_AUTOCOMPLETE_MODE.ALWAYS;
const shouldShowNormally = isForced || alwaysShow || power_user.stscript.autocomplete.showInAllMacroFields;
// Whether setting says autocomplete should only activate after typing {{ and two characters after that
// Ctrl+Space (isForced) overrides this restriction
const onlyAfter2 = !isForced && power_user.stscript.autocomplete.state === AUTOCOMPLETE_STATE.MIN_LENGTH;
// Check if we're right after {{ (just typed the second brace)
if (cursorPos >= 2 && text.slice(cursorPos - 2, cursorPos) === '{{') {
return shouldShowNormally && !onlyAfter2;
}
// Check if we're inside a macro
const macro = findMacroAtCursor(text, cursorPos);
if (macro !== null) {
if (!shouldShowNormally) return false;
return !onlyAfter2 || (macro.content.trim()).length >= 2;
}
// Check if we're in scoped content of an unclosed scoped macro
const textUpToCursor = text.slice(0, cursorPos);
const unclosedScopes = findUnclosedScopes(textUpToCursor);
return shouldShowNormally && unclosedScopes.length > 0;
}
/**
* Sets up macro autocomplete for a text input element.
* The autocomplete will trigger when typing `{{` inside the element.
*
* @param {HTMLTextAreaElement|HTMLInputElement} textarea - The input element.
* @param {Object} [options={}] - Options for the autocomplete.
* @param {MACRO_AUTOCOMPLETE_MODE} [options.autocompleteMode=MACRO_AUTOCOMPLETE_MODE.DEFAULT] - The autocomplete mode.
* @param {MACRO_AUTOCOMPLETE_STYLE} [options.autocompleteStyle=MACRO_AUTOCOMPLETE_STYLE.SMALL] - The autocomplete style.
* @returns {AutoComplete} The autocomplete instance.
*/
export function setMacroAutoComplete(textarea, { autocompleteMode = MACRO_AUTOCOMPLETE_MODE.DEFAULT, autocompleteStyle = MACRO_AUTOCOMPLETE_STYLE.SMALL } = {}) {
const ac = new AutoComplete(
textarea,
() => shouldActivateMacroAutocomplete(ac.text, textarea.selectionStart, { isForced: ac.isShowForced, autocompleteMode }),
(text, index) => getMacroAutoCompleteAt(text, index, { isForced: ac.isShowForced }),
true, // isFloating - always use floating mode for free text macro autocomplete
);
// Set the style via data attribute for CSS targeting
ac.domWrap.dataset.macrosAutocompleteStyle = autocompleteStyle;
ac.detailsWrap.dataset.macrosAutocompleteStyle = autocompleteStyle;
elementAutoCompleteMap.set(textarea, ac);
return ac;
}
/**
* Gets the autocomplete mode from an element's data-macros-autocomplete attribute.
*
* @param {Element} element - The element to check.
* @returns {MACRO_AUTOCOMPLETE_MODE} The mode ('default', 'always', 'hide').
*/
function getAutocompleteMode(element) {
if (!element.hasAttribute(MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE)) {
return MACRO_AUTOCOMPLETE_MODE.DEFAULT;
}
const value = element.getAttribute(MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE);
if (value === MACRO_AUTOCOMPLETE_MODE.ALWAYS || value === MACRO_AUTOCOMPLETE_MODE.HIDE) {
return value;
}
return MACRO_AUTOCOMPLETE_MODE.DEFAULT;
}
/**
* Gets the autocomplete style from an element's data-autocomplete-style attribute.
*
* @param {Element} element - The element to check.
* @returns {MACRO_AUTOCOMPLETE_STYLE} The style ('expanded', 'small').
*/
function getAutocompleteStyle(element) {
if (!element.hasAttribute(MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE)) {
return MACRO_AUTOCOMPLETE_STYLE.SMALL; // Default for macro autocomplete is small
}
const value = element.getAttribute(MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE);
if (value === MACRO_AUTOCOMPLETE_STYLE.SMALL || value === MACRO_AUTOCOMPLETE_STYLE.EXPANDED) {
return value;
}
return MACRO_AUTOCOMPLETE_STYLE.EXPANDED;
}
/**
* Initializes macro autocomplete on a single element if not already initialized.
*
* @param {HTMLTextAreaElement|HTMLInputElement} element - The element to initialize.
* @returns {AutoComplete|null} The autocomplete instance, or null if already initialized.
*/
function initializeElement(element) {
if (initializedElements.has(element)) {
return null;
}
if (!(element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement)) {
return null;
}
const autocompleteMode = getAutocompleteMode(element);
const autocompleteStyle = getAutocompleteStyle(element);
initializedElements.add(element);
return setMacroAutoComplete(element, { autocompleteMode, autocompleteStyle });
}
/**
* Checks if an element has the macro autocomplete attribute enabled.
* Supports both `data-macros` (presence) and `data-macros="true"`.
*
* @param {Element} element - The element to check.
* @returns {boolean}
*/
function hasMacroAttribute(element) {
if (!element.hasAttribute(MACRO_AUTOCOMPLETE_ATTRIBUTE)) {
return false;
}
const value = element.getAttribute(MACRO_AUTOCOMPLETE_ATTRIBUTE);
// Attribute present with no value, empty string, or "true" all count as enabled
return value === null || value === '' || value === 'true';
}
/**
* Handles node changes from MutationObserver - checks for macro autocomplete attribute.
*
* @param {Node} node - The node to check.
*/
function handleNodeChange(node) {
if (node.nodeType !== Node.ELEMENT_NODE || !(node instanceof Element)) {
return;
}
// Check if this element has the macro autocomplete attribute
if (hasMacroAttribute(node)) {
if (node instanceof HTMLTextAreaElement || node instanceof HTMLInputElement) {
initializeElement(node);
}
}
// Check child elements - select all elements with the attribute (any value or no value)
const children = node.querySelectorAll(`[${MACRO_AUTOCOMPLETE_ATTRIBUTE}]`);
for (const child of children) {
if (hasMacroAttribute(child) && (child instanceof HTMLTextAreaElement || child instanceof HTMLInputElement)) {
initializeElement(child);
}
}
}
/**
* MutationObserver to watch for dynamically added elements with macro autocomplete attribute.
* @type {MutationObserver}
*/
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
handleNodeChange(node);
}
}
if (mutation.type === 'attributes') {
const target = mutation.target;
const isRelevantAttr = mutation.attributeName === MACRO_AUTOCOMPLETE_ATTRIBUTE ||
mutation.attributeName === MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE ||
mutation.attributeName === MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE;
if (isRelevantAttr && target instanceof Element) {
handleNodeChange(target);
}
}
}
});
/**
* Initializes macro autocomplete for all elements with the `data-macros` attribute.
* Also starts the MutationObserver to watch for dynamically added elements.
* Should be called after DOM is ready.
*
* @returns {AutoComplete[]} Array of autocomplete instances created.
*/
export function initMacroAutoComplete() {
const elements = /** @type {NodeListOf<HTMLTextAreaElement|HTMLInputElement>} */ (
document.querySelectorAll(`[${MACRO_AUTOCOMPLETE_ATTRIBUTE}]`)
);
const instances = [];
for (const element of elements) {
if (hasMacroAttribute(element)) {
const ac = initializeElement(element);
if (ac) {
instances.push(ac);
}
}
}
// Start observing for dynamically added elements
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [MACRO_AUTOCOMPLETE_ATTRIBUTE, MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE, MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE],
});
return instances;
}
/**
* Enables macro autocomplete on a specific element by ID.
* Adds the attribute and initializes autocomplete.
*
* @param {string} elementId - The element ID (without #).
* @returns {AutoComplete|null} The autocomplete instance, or null if element not found.
*/
export function enableMacroAutoCompleteById(elementId) {
const element = /** @type {HTMLTextAreaElement|HTMLInputElement|null} */ (
document.getElementById(elementId)
);
if (!element || !(element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement)) {
console.warn(`[MacroAutoComplete] Element not found or invalid: ${elementId}`);
return null;
}
element.setAttribute(MACRO_AUTOCOMPLETE_ATTRIBUTE, 'true');
return initializeElement(element);
}

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ import { characters, chat_metadata, eventSource, event_types, generateQuietPromp
import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop, saveBase64AsFile, getFileExtension } from './utils.js';
import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop, saveBase64AsFile, getFileExtension, sortIgnoreCaseAndAccents } from './utils.js';
import { debounce_timeout } from './constants.js';
import { t } from './i18n.js';
import { Popup } from './popup.js';
@ -35,12 +35,19 @@ const THUMBNAIL_STORAGE = localforage.createInstance({ name: 'SillyTavern_Thumbn
* @type {Map<string, string>}
*/
const THUMBNAIL_BLOBS = new Map();
const SERVER_THUMBNAIL_BLOBS = new Map();
const THUMBNAIL_CONFIG = {
width: 160,
height: 90,
};
/**
* Cache for image metadata.
* @type {Map<string, import('../../src/endpoints/image-metadata.js').ImageMetadata>}
*/
const METADATA_CACHE = new Map();
/**
* Background source types.
* @readonly
@ -51,6 +58,18 @@ const BG_SOURCES = {
CHAT: 1,
};
/**
* Background sorting options.
* @readonly
* @enum {string}
*/
const BG_SORT_OPTIONS = {
AZ: 'az',
ZA: 'za',
NEWEST: 'newest',
OLDEST: 'oldest',
};
/**
* Mapping of background sources to their corresponding tab IDs.
* @readonly
@ -67,13 +86,55 @@ const BG_TABS = Object.freeze({
*/
let lazyLoadObserver = null;
/**
* Cache for the current list of system background filenames.
* Used to re-sort backgrounds without refetching from the server.
* @type {string[]}
*/
let cachedSystemBackgrounds = [];
export let background_settings = {
name: '__transparent.png',
url: generateUrlParameter('__transparent.png', false),
fitting: 'classic',
animation: false,
sortOrder: BG_SORT_OPTIONS.AZ,
};
/**
* Sorts an array of background filenames based on the current sort order.
* @param {string[]} backgrounds - Array of background filenames
* @param {boolean} isCustom - Whether these are custom (chat) backgrounds
* @returns {string[]} Sorted array of background filenames
*/
function sortBackgrounds(backgrounds, isCustom = false) {
const sortOrder = background_settings.sortOrder || BG_SORT_OPTIONS.AZ;
return [...backgrounds].sort((a, b) => {
switch (sortOrder) {
case BG_SORT_OPTIONS.AZ:
return sortIgnoreCaseAndAccents(a, b);
case BG_SORT_OPTIONS.ZA:
return sortIgnoreCaseAndAccents(b, a);
case BG_SORT_OPTIONS.NEWEST:
case BG_SORT_OPTIONS.OLDEST: {
const keyA = isCustom ? a : `backgrounds/${a}`;
const keyB = isCustom ? b : `backgrounds/${b}`;
const metaA = METADATA_CACHE.get(keyA);
const metaB = METADATA_CACHE.get(keyB);
const timestampA = metaA?.addedTimestamp ?? 0;
const timestampB = metaB?.addedTimestamp ?? 0;
// Newest first (descending) or oldest first (ascending)
return sortOrder === BG_SORT_OPTIONS.NEWEST
? timestampB - timestampA
: timestampA - timestampB;
}
default:
return 0;
}
});
}
/**
* Creates a single thumbnail DOM element. The CSS now handles all sizing.
* @param {object} imageData - Data for the image (filename, isCustom).
@ -89,6 +150,18 @@ function createThumbnailElement(imageData) {
clipper.className = 'thumbnail-clipper lazy-load-background';
clipper.style.backgroundImage = PLACEHOLDER_IMAGE;
// Apply dominant color and aspect ratio as placeholder if available
const metadataKey = isCustom ? bg : `backgrounds/${bg}`;
const metadata = METADATA_CACHE.get(metadataKey);
if (metadata) {
if (metadata.dominantColor) {
clipper.style.backgroundColor = metadata.dominantColor;
}
if (metadata.aspectRatio) {
thumbnail.css('aspect-ratio', metadata.aspectRatio);
}
}
const titleElement = thumbnail.find('.BGSampleTitle');
clipper.appendChild(titleElement.get(0));
thumbnail.append(clipper);
@ -132,6 +205,9 @@ export function loadBackgroundSettings(settings) {
if (!Object.hasOwn(backgroundSettings, 'animation')) {
backgroundSettings.animation = false;
}
if (!backgroundSettings.sortOrder) {
backgroundSettings.sortOrder = BG_SORT_OPTIONS.AZ;
}
// If a value is already saved, use it. Otherwise, determine default based on screen size.
let columns = backgroundSettings.thumbnailColumns;
@ -140,12 +216,14 @@ export function loadBackgroundSettings(settings) {
columns = isNarrowScreen ? THUMBNAIL_COLUMNS_DEFAULT_MOBILE : THUMBNAIL_COLUMNS_DEFAULT_DESKTOP;
}
background_settings.thumbnailColumns = columns;
background_settings.sortOrder = backgroundSettings.sortOrder;
applyThumbnailColumns(background_settings.thumbnailColumns);
setBackground(backgroundSettings.name, backgroundSettings.url);
setFittingClass(backgroundSettings.fitting);
$('#background_fitting').val(backgroundSettings.fitting);
$('#background_thumbnails_animation').prop('checked', background_settings.animation);
$('#bg-sort').val(background_settings.sortOrder);
highlightSelectedBackground();
}
@ -178,14 +256,14 @@ async function onChatChanged() {
}
function getBackgroundPath(fileUrl) {
if (typeof window.__TAURITAVERN_BACKGROUND_PATH__ === 'function') {
if (typeof window.__TAURITAVERN_BACKGROUND_PATH__ === 'function') {
try {
return window.__TAURITAVERN_BACKGROUND_PATH__(fileUrl);
} catch (error) {
console.warn('Tauri background helper failed:', error);
}
}
return `backgrounds/${encodeURIComponent(fileUrl)}`;
}
@ -348,6 +426,65 @@ async function getThumbnailFromStorage(bg, isCustom) {
}
}
function getServerThumbnailCacheKey(bg, animated = false) {
return `${bg}::${animated ? 'animated' : 'static'}`;
}
function revokeThumbnailBlob(cache, key) {
const url = cache.get(key);
if (!url) {
return;
}
URL.revokeObjectURL(url);
cache.delete(key);
}
async function invalidateThumbnailCaches(bg) {
await THUMBNAIL_STORAGE.removeItem(bg);
revokeThumbnailBlob(THUMBNAIL_BLOBS, bg);
revokeThumbnailBlob(SERVER_THUMBNAIL_BLOBS, getServerThumbnailCacheKey(bg, false));
revokeThumbnailBlob(SERVER_THUMBNAIL_BLOBS, getServerThumbnailCacheKey(bg, true));
}
function pruneServerThumbnailBlobCache(backgrounds) {
const validKeys = new Set(backgrounds.flatMap((bg) => [
getServerThumbnailCacheKey(bg, false),
getServerThumbnailCacheKey(bg, true),
]));
for (const key of SERVER_THUMBNAIL_BLOBS.keys()) {
if (!validKeys.has(key)) {
revokeThumbnailBlob(SERVER_THUMBNAIL_BLOBS, key);
}
}
}
async function getServerThumbnailBlobUrl(bg, animated = false) {
const cacheKey = getServerThumbnailCacheKey(bg, animated);
const cachedBlobUrl = SERVER_THUMBNAIL_BLOBS.get(cacheKey);
if (cachedBlobUrl) {
return cachedBlobUrl;
}
try {
const thumbnailUrl = getThumbnailUrl('bg', bg);
const requestUrl = animated ? `${thumbnailUrl}&animated=true` : thumbnailUrl;
const response = await fetch(requestUrl, { cache: 'no-store' });
if (!response.ok) {
throw new Error('Fetch failed with status: ' + response.status);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
SERVER_THUMBNAIL_BLOBS.set(cacheKey, blobUrl);
return blobUrl;
} catch (error) {
console.error('Error fetching server thumbnail, falling back to original background:', error);
return getBackgroundPath(bg);
}
}
/**
* Gets the new background name from the user.
* @param {Element} referenceElement
@ -437,6 +574,11 @@ async function onDeleteBackgroundClick(e) {
// If it's not custom, it's a built-in background. Delete it from the server
if (!isCustom) {
await delBackground(bg);
// Remove from cache to prevent reappearing on sort change
const cacheIndex = cachedSystemBackgrounds.indexOf(bg);
if (cacheIndex !== -1) {
cachedSystemBackgrounds.splice(cacheIndex, 1);
}
} else {
const list = chat_metadata[LIST_METADATA_KEY] || [];
const index = list.indexOf(bg);
@ -525,7 +667,8 @@ function renderSystemBackgrounds(backgrounds) {
if (sourceList.length === 0) return;
sourceList.forEach(bg => {
const sortedList = sortBackgrounds(sourceList, false);
sortedList.forEach(bg => {
const imageData = { filename: bg, isCustom: false };
const thumbnail = createThumbnailElement(imageData);
container.append(thumbnail);
@ -546,7 +689,8 @@ function renderChatBackgrounds(backgrounds) {
if (sourceList.length === 0) return;
sourceList.forEach(bg => {
const sortedList = sortBackgrounds(sourceList, true);
sortedList.forEach(bg => {
const imageData = { filename: bg, isCustom: true };
const thumbnail = createThumbnailElement(imageData);
container.append(thumbnail);
@ -556,6 +700,8 @@ function renderChatBackgrounds(backgrounds) {
}
export async function getBackgrounds() {
const metadataPromise = preloadImageMetadata();
const response = await fetch('/api/backgrounds/all', {
method: 'POST',
headers: getRequestHeaders(),
@ -565,11 +711,41 @@ export async function getBackgrounds() {
const { images, config } = await response.json();
Object.assign(THUMBNAIL_CONFIG, config);
cachedSystemBackgrounds = images;
pruneServerThumbnailBlobCache(images);
await metadataPromise;
renderSystemBackgrounds(images);
highlightSelectedBackground();
}
}
/**
* Preloads all image metadata to use dominant colors as placeholders.
* @return {Promise<void>}
*/
async function preloadImageMetadata() {
try {
const response = await fetch('/api/image-metadata/all', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ prefix: 'backgrounds/' }),
});
if (response.ok) {
const data = await response.json();
if (data?.images) {
METADATA_CACHE.clear();
for (const [path, metadata] of Object.entries(data.images)) {
METADATA_CACHE.set(path, metadata);
}
}
}
} catch (error) {
console.error('[ImageMetadata] Failed to preload metadata:', error);
}
}
function activateLazyLoader() {
// Disconnect previous observer to prevent memory leaks
if (lazyLoadObserver) {
@ -636,7 +812,7 @@ async function resolveImageUrl(bg, isCustom) {
? await getThumbnailFromStorage(bg, isCustom)
: isCustom
? bg
: getThumbnailUrl('bg', bg);
: await getServerThumbnailBlobUrl(bg, isAnimated && background_settings.animation);
return `url("${thumbnailUrl}")`;
}
@ -660,11 +836,7 @@ async function delBackground(bg) {
}),
});
await THUMBNAIL_STORAGE.removeItem(bg);
if (THUMBNAIL_BLOBS.has(bg)) {
URL.revokeObjectURL(THUMBNAIL_BLOBS.get(bg));
THUMBNAIL_BLOBS.delete(bg);
}
await invalidateThumbnailCaches(bg);
}
/**
@ -768,6 +940,7 @@ async function uploadBackground(formData) {
}
const bg = await response.text();
await invalidateThumbnailCaches(bg);
setBackground(bg, generateUrlParameter(bg, false));
await getBackgrounds();
highlightNewBackground(bg);
@ -929,6 +1102,17 @@ export function initBackgrounds() {
$('#auto_background').on('click', autoBackgroundCommand);
$('#add_bg_button').on('change', (e) => onBackgroundUploadSelected(e.originalEvent));
$('#bg-filter').on('input', () => debouncedOnBackgroundFilterInput());
$('#bg-sort').on('change', function () {
background_settings.sortOrder = String($(this).val());
saveSettingsDebounced();
// Re-render both galleries with new sort order
renderSystemBackgrounds(cachedSystemBackgrounds);
renderChatBackgrounds();
highlightSelectedBackground();
highlightLockedBackground();
// Re-apply any active search filter
onBackgroundFilterInput();
});
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'lockbg',
callback: () => {

View file

@ -12,6 +12,7 @@ import {
saveChatConditional,
saveItemizedPrompts,
setActiveGroup,
getCurrentChatDetails,
} from '../script.js';
import { humanizedDateTime } from './RossAscends-mods.js';
import {
@ -81,30 +82,35 @@ async function getExistingChatNames() {
}
async function getBookmarkName({ isReplace = false, forceName = null } = {}) {
const chatNames = await getExistingChatNames();
const mainChatName = (getCurrentChatDetails()).sessionName;
const body = await renderTemplateAsync('createCheckpoint', { isReplace: isReplace });
let name = forceName ?? await Popup.show.input('Create Checkpoint', body);
function buildCheckpointName(name, i) {
// Strip off existing suffixes, then build new name
let cleanName = name.replace(new RegExp(` - ${bookmarkNameToken}\\d+$`), '');
// Strip off legacy old name prefix too
cleanName = cleanName.replace(new RegExp(`^${bookmarkNameToken}\\d+ - `), '');
return `${cleanName} - ${bookmarkNameToken}${i}`;
}
const existingChats = await getExistingChatNames();
const suggestedName = getUniqueName(mainChatName, (x) => existingChats.includes(x), { nameBuilder: buildCheckpointName });
const body = await renderTemplateAsync('createCheckpoint', { isReplace: isReplace, suggestedName: suggestedName });
let name = forceName ?? await Popup.show.input('Create Checkpoint', body, suggestedName);
// Special handling for confirmed empty input (=> auto-generate name)
if (name === '') {
for (let i = chatNames.length; i < 1000; i++) {
name = bookmarkNameToken + i;
if (!chatNames.includes(name)) {
break;
}
}
name = suggestedName;
}
if (!name) {
return null;
}
return `${name} - ${humanizedDateTime()}`;
return name;
}
function getMainChatName() {
if (chat_metadata) {
if (chat_metadata['main_chat']) {
return chat_metadata['main_chat'];
if (chat_metadata.main_chat) {
return chat_metadata.main_chat;
}
// groups didn't support bookmarks before chat metadata was introduced
else if (selected_group) {
@ -112,8 +118,8 @@ function getMainChatName() {
}
else if (characters[this_chid].chat && characters[this_chid].chat.includes(bookmarkNameToken)) {
const tokenIndex = characters[this_chid].chat.lastIndexOf(bookmarkNameToken);
chat_metadata['main_chat'] = characters[this_chid].chat.substring(0, tokenIndex).trim();
return chat_metadata['main_chat'];
chat_metadata.main_chat = characters[this_chid].chat.substring(0, tokenIndex).trim();
return chat_metadata.main_chat;
}
}
return null;
@ -127,7 +133,7 @@ export function showBookmarksButtons() {
$('#option_convert_to_group').show();
}
if (chat_metadata['main_chat']) {
if (chat_metadata.main_chat) {
// In bookmark chat
$('#option_back_to_main').show();
$('#option_new_bookmark').show();
@ -170,9 +176,23 @@ export async function createBranch(mesId) {
}
const lastMes = chat[mesId];
const mainChat = selected_group ? groups?.find(x => x.id == selected_group)?.chat_id : characters[this_chid].chat;
const newMetadata = { main_chat: mainChat };
let name = `Branch #${mesId} - ${humanizedDateTime()}`;
const mainChatName = (getCurrentChatDetails()).sessionName;
const newMetadata = { main_chat: mainChatName };
function buildBranchName(name, i) {
// Strip off existing suffixes, then build new name
let cleanName = name.replace(/ - Branch #\d+$/, '');
// Strip off legacy old name prefix too
cleanName = cleanName.replace(/^Branch #\d+ - /, '');
return `${cleanName} - Branch #${i}`;
}
const existingChats = await getExistingChatNames();
const name = getUniqueName(mainChatName, (x) => existingChats.includes(x), { nameBuilder: buildBranchName });
if (!name) {
console.error('Could not generate a unique branch name.');
toastr.error('Could not generate a unique branch name.', 'Branch creation failed');
return;
}
if (selected_group) {
await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId);
@ -184,10 +204,10 @@ export async function createBranch(mesId) {
if (typeof lastMes.extra !== 'object') {
lastMes.extra = {};
}
if (typeof lastMes.extra['branches'] !== 'object') {
lastMes.extra['branches'] = [];
if (typeof lastMes.extra.branches !== 'object') {
lastMes.extra.branches = [];
}
lastMes.extra['branches'].push(name);
lastMes.extra.branches.push(name);
return name;
}
@ -236,7 +256,7 @@ export async function createNewBookmark(mesId, { forceName = null } = {}) {
await saveChat({ chatName: name, withMetadata: newMetadata, mesId });
}
lastMes.extra['bookmark_link'] = name;
lastMes.extra.bookmark_link = name;
const mes = $(`.mes[mesid="${mesId}"]`);
updateBookmarkDisplay(mes, name);
@ -636,7 +656,7 @@ export function initBookmarks() {
const fileName = $(this).hasClass('mes_bookmark')
? $(this).closest('.mes').attr('bookmark_link')
: $(this).attr('file_name').replace('.jsonl', '');
: $(this).attr('file_name');
if (!fileName) {
return;

View file

@ -42,13 +42,13 @@ function setCharCfg(tempValue, setting) {
switch (setting) {
case settingType.guidance_scale:
tempCharaCfg['guidance_scale'] = Number(tempValue);
tempCharaCfg.guidance_scale = Number(tempValue);
break;
case settingType.negative_prompt:
tempCharaCfg['negative_prompt'] = tempValue;
tempCharaCfg.negative_prompt = tempValue;
break;
case settingType.positive_prompt:
tempCharaCfg['positive_prompt'] = tempValue;
tempCharaCfg.positive_prompt = tempValue;
break;
default:
return false;
@ -239,31 +239,31 @@ function migrateSettings() {
if (power_user.guidance_scale) {
extension_settings.cfg.global.guidance_scale = power_user.guidance_scale;
delete power_user['guidance_scale'];
delete power_user.guidance_scale;
performSettingsSave = true;
}
if (power_user.negative_prompt) {
extension_settings.cfg.global.negative_prompt = power_user.negative_prompt;
delete power_user['negative_prompt'];
delete power_user.negative_prompt;
performSettingsSave = true;
}
if (chat_metadata['cfg_negative_combine']) {
chat_metadata[metadataKeys.prompt_combine] = chat_metadata['cfg_negative_combine'];
chat_metadata['cfg_negative_combine'] = undefined;
if (chat_metadata.cfg_negative_combine) {
chat_metadata[metadataKeys.prompt_combine] = chat_metadata.cfg_negative_combine;
chat_metadata.cfg_negative_combine = undefined;
performMetaSave = true;
}
if (chat_metadata['cfg_negative_insertion_depth']) {
chat_metadata[metadataKeys.prompt_insertion_depth] = chat_metadata['cfg_negative_insertion_depth'];
chat_metadata['cfg_negative_insertion_depth'] = undefined;
if (chat_metadata.cfg_negative_insertion_depth) {
chat_metadata[metadataKeys.prompt_insertion_depth] = chat_metadata.cfg_negative_insertion_depth;
chat_metadata.cfg_negative_insertion_depth = undefined;
performMetaSave = true;
}
if (chat_metadata['cfg_negative_separator']) {
chat_metadata[metadataKeys.prompt_separator] = chat_metadata['cfg_negative_separator'];
chat_metadata['cfg_negative_separator'] = undefined;
if (chat_metadata.cfg_negative_separator) {
chat_metadata[metadataKeys.prompt_separator] = chat_metadata.cfg_negative_separator;
chat_metadata.cfg_negative_separator = undefined;
performMetaSave = true;
}

View file

@ -148,8 +148,8 @@ export async function bindModelTemplates(power_user, online_status) {
?? power_user.model_templates_mappings[chatTemplateHash]
?? {};
const bindingsMatch = bindModelTemplates
&& power_user.context.preset == bindModelTemplates['context']
&& (!power_user.instruct.enabled || power_user.instruct.preset === bindModelTemplates['instruct']);
&& power_user.context.preset == bindModelTemplates.context
&& (!power_user.instruct.enabled || power_user.instruct.preset === bindModelTemplates.instruct);
const bound = [];
@ -160,21 +160,21 @@ export async function bindModelTemplates(power_user, online_status) {
toastr.info(t`Context preset for ${online_status} will use defaults when loaded the next time.`);
} else {
if (power_user.context_derived) {
if (power_user.context.preset !== bindModelTemplates['context']) {
if (power_user.context.preset !== bindModelTemplates.context) {
bound.push(`${power_user.context.preset} context preset`);
// toastr.info(`Bound ${power_user.context.preset} preset to currently loaded model and all models that share its chat template.`);
// map current preset to current chat template hash
bindModelTemplates['context'] = power_user.context.preset;
bindModelTemplates.context = power_user.context.preset;
}
} else {
toastr.warning(t`Note: Context derivation is disabled. Not including context preset.`);
}
if (power_user.instruct.enabled) {
if (power_user.instruct_derived) {
if (power_user.instruct.preset !== bindModelTemplates['instruct']) {
if (power_user.instruct.preset !== bindModelTemplates.instruct) {
bound.push(`${power_user.instruct.preset} instruct preset`);
bindModelTemplates['instruct'] = power_user.instruct.preset;
bindModelTemplates.instruct = power_user.instruct.preset;
}
} else {
toastr.warning(t`Note: Instruct derivation is disabled. Not including instruct preset.`);

View file

@ -685,7 +685,7 @@ export function formatCreatorNotes(text, avatarId) {
const preference = new StylesPreference(avatarId);
const sanitizeStyles = !preference.get();
const decodeStyleParam = { prefix: sanitizeStyles ? '#creator_notes_spoiler ' : '' };
/** @type {import('dompurify').Config & { MESSAGE_SANITIZE: boolean }} */
/** @type {DOMPurify.Config} */
const config = {
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
@ -1911,13 +1911,13 @@ export function addDOMPurifyHooks() {
});
DOMPurify.addHook('uponSanitizeAttribute', (node, data, config) => {
if (!config['MESSAGE_SANITIZE']) {
if (!config.MESSAGE_SANITIZE) {
return;
}
/* Retain the classes on UI elements of messages that interact with the main UI */
const permittedNodeTypes = ['BUTTON', 'DIV'];
if (config['MESSAGE_ALLOW_SYSTEM_UI'] && node.classList.contains('menu_button') && permittedNodeTypes.includes(node.nodeName)) {
if (config.MESSAGE_ALLOW_SYSTEM_UI && node.classList.contains('menu_button') && permittedNodeTypes.includes(node.nodeName)) {
return;
}
@ -1938,7 +1938,7 @@ export function addDOMPurifyHooks() {
});
DOMPurify.addHook('uponSanitizeElement', (node, _, config) => {
if (!config['MESSAGE_SANITIZE']) {
if (!config.MESSAGE_SANITIZE) {
return;
}
@ -2239,6 +2239,11 @@ export function initChatUtilities() {
wrapper.classList.add('flexFlowColumn', 'justifyCenter', 'alignitemscenter');
const textarea = document.createElement('textarea');
textarea.dataset.for = broId;
if (bro[0].dataset.macros !== undefined) {
textarea.dataset.macros = bro[0].dataset.macros;
textarea.dataset.macrosAutocomplete = 'always'; // Always show autocomplete in expanded editor
textarea.dataset.macrosAutocompleteStyle = 'expanded'; // Use expanded autocomplete style
}
textarea.value = String(contentEditable ? bro[0].innerText : bro.val());
textarea.classList.add('height100p', 'wide100p', 'maximized_textarea');
bro.hasClass('monospace') && textarea.classList.add('monospace');

View file

@ -1,6 +1,7 @@
import { EventEmitter } from '../lib/eventemitter.js';
export const event_types = {
APP_INITIALIZED: 'app_initialized',
APP_READY: 'app_ready',
EXTRAS_CONNECTED: 'extras_connected',
MESSAGE_SWIPED: 'message_swiped',
@ -16,6 +17,8 @@ export const event_types = {
MORE_MESSAGES_LOADED: 'more_messages_loaded',
IMPERSONATE_READY: 'impersonate_ready',
CHAT_CHANGED: 'chat_id_changed',
// TODO: Naming convention is inconsistent with other events
CHAT_LOADED: 'chatLoaded',
GENERATION_AFTER_COMMANDS: 'GENERATION_AFTER_COMMANDS',
GENERATION_STARTED: 'generation_started',
GENERATION_STOPPED: 'generation_stopped',
@ -95,4 +98,4 @@ export const event_types = {
MEDIA_ATTACHMENT_DELETED: 'media_attachment_deleted',
};
export const eventSource = new EventEmitter([event_types.APP_READY]);
export const eventSource = new EventEmitter([event_types.APP_READY, event_types.APP_INITIALIZED]);

View file

@ -0,0 +1,18 @@
/**
* @enum {number} Extension prompt types
*/
export const extension_prompt_types = {
NONE: -1,
IN_PROMPT: 0,
IN_CHAT: 1,
BEFORE_PROMPT: 2,
};
/**
* @enum {number} Extension prompt roles
*/
export const extension_prompt_roles = {
SYSTEM: 0,
USER: 1,
ASSISTANT: 2,
};

View file

@ -1,11 +1,11 @@
import { disableExtension, enableExtension, extension_settings, extensionNames } from './extensions.js';
import { disableExtension, enableExtension, extensionNames, findExtension } from './extensions.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { equalsIgnoreCaseAndAccents, isFalseBoolean, isTrueBoolean } from './utils.js';
import { isFalseBoolean, isTrueBoolean } from './utils.js';
/**
* @param {'enable' | 'disable' | 'toggle'} action - The action to perform on the extension
@ -22,30 +22,28 @@ function getExtensionActionCallback(action) {
}
const reload = !isFalseBoolean(args?.reload?.toString());
const internalExtensionName = findExtension(extensionName);
if (!internalExtensionName) {
const extension = findExtension(extensionName);
if (!extension) {
toastr.warning(`Extension ${extensionName} does not exist.`);
return '';
}
const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
if (action === 'enable' && isEnabled) {
toastr.info(`Extension ${extensionName} is already enabled.`);
return internalExtensionName;
if (action === 'enable' && extension.enabled) {
toastr.info(`Extension ${extension.name} is already enabled.`);
return extension.name;
}
if (action === 'disable' && !isEnabled) {
toastr.info(`Extension ${extensionName} is already disabled.`);
return internalExtensionName;
if (action === 'disable' && !extension.enabled) {
toastr.info(`Extension ${extension.name} is already disabled.`);
return extension.name;
}
if (action === 'toggle') {
action = isEnabled ? 'disable' : 'enable';
action = extension.enabled ? 'disable' : 'enable';
}
if (reload) {
toastr.info(`${action.charAt(0).toUpperCase() + action.slice(1)}ing extension ${extensionName} and reloading...`);
toastr.info(`${action.charAt(0).toUpperCase() + action.slice(1)}ing extension ${extension.name} and reloading...`);
// Clear input, so it doesn't stay because the command didn't "finish",
// and wait for a bit to both show the toast and let the clear bubble through.
@ -54,35 +52,23 @@ function getExtensionActionCallback(action) {
}
if (action === 'enable') {
await enableExtension(internalExtensionName, reload);
await enableExtension(extension.name, reload);
} else {
await disableExtension(internalExtensionName, reload);
await disableExtension(extension.name, reload);
}
toastr.success(`Extension ${extensionName} ${action}d.`);
toastr.success(`Extension ${extension.name} ${action}d.`);
console.info(`Extension ${action}ed: ${extensionName}`);
console.info(`Extension ${action}ed: ${extension.name}`);
if (!reload) {
console.info('Reload not requested, so page needs to be reloaded manually for changes to take effect.');
}
return internalExtensionName;
return extension.name;
};
}
/**
* Finds an extension by name, allowing omission of the "third-party/" prefix.
*
* @param {string} name - The name of the extension to find
* @returns {string?} - The matched extension name or undefined if not found
*/
function findExtension(name) {
return extensionNames.find(extName => {
return equalsIgnoreCaseAndAccents(extName, name) || equalsIgnoreCaseAndAccents(extName, `third-party/${name}`);
});
}
/**
* Provides an array of SlashCommandEnumValue objects based on the extension names.
* Each object contains the name of the extension and a description indicating if it is a third-party extension.
@ -244,14 +230,13 @@ export function registerExtensionSlashCommands() {
name: 'extension-state',
callback: async (_, extensionName) => {
if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
const internalExtensionName = findExtension(extensionName);
if (!internalExtensionName) {
const extension = findExtension(extensionName);
if (!extension) {
toastr.warning(`Extension ${extensionName} does not exist.`);
return '';
}
const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
return String(isEnabled);
return String(extension.enabled);
},
returns: 'The state of the extension, whether it is enabled.',
unnamedArgumentList: [
@ -282,8 +267,8 @@ export function registerExtensionSlashCommands() {
aliases: ['extension-installed'],
callback: async (_, extensionName) => {
if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
const exists = findExtension(extensionName) !== undefined;
return exists ? 'true' : 'false';
const extension = findExtension(extensionName);
return extension !== null ? 'true' : 'false';
},
returns: 'Whether the extension exists and is installed.',
unnamedArgumentList: [

View file

@ -4,7 +4,7 @@ import { eventSource, event_types, saveSettings, saveSettingsDebounced, getReque
import { showLoader } from './loader.js';
import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
import { renderTemplate, renderTemplateAsync } from './templates.js';
import { delay, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';
import { delay, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';
import { getContext } from './st-context.js';
import { isAdmin } from './user.js';
import { addLocaleData, getCurrentLocale, t } from './i18n.js';
@ -317,6 +317,64 @@ function onEnableExtensionClick() {
enableExtension(name, false);
}
/**
* Handles toggling all extensions on or off.
* @param {Object[]} extensionsToToggle
* @param {JQuery<HTMLElement>} toggleContainer
* @returns {Object[]} Updated extensionsToToggle array
*/
function onToggleAllExtensions(extensionsToToggle, toggleContainer) {
const extensionNames = Object.keys(manifests);
const thirdPartyExtensions = extensionNames.filter(name => ['local', 'global'].includes(getExtensionType(name)));
const checkIfDisabled = (name) => {
const toggle = extensionsToToggle.find(ext => ext.name === name);
return toggle
? !toggle.enable
: extension_settings.disabledExtensions.includes(name);
};
if (thirdPartyExtensions.length === 0) return [];
let enable = true;
for (const name of thirdPartyExtensions) {
const isEnabled = !checkIfDisabled(name);
if (isEnabled) {
enable = false;
break;
}
}
const toggleHandler = enable ? enableExtension : disableExtension;
for (const name of thirdPartyExtensions) {
const isDisabled = checkIfDisabled(name);
const doToggleExtension = enable ? isDisabled : !isDisabled;
if (doToggleExtension) {
const toggle = extensionsToToggle.find(ext => ext.name === name);
if (toggle) {
toggle.toggleHandler = toggleHandler;
toggle.enable = enable;
} else {
extensionsToToggle.push({ name, toggleHandler, enable });
}
toggleContainer
.find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`)
.prop('checked', enable)
.toggleClass('toggle_enable', !enable)
.toggleClass('toggle_disable', enable)
.toggleClass('checkbox_disabled', !enable);
}
}
return extensionsToToggle;
}
/**
* Enables an extension by name.
* @param {string} name Extension name
@ -349,6 +407,21 @@ export async function disableExtension(name, reload = true) {
}
}
/**
* Finds an extension by name, allowing omission of the "third-party/" prefix.
*
* @param {string} name - The name of the extension to find
* @returns {{name: string, enabled: boolean}|null} Object with name and enabled properties, or null if not found
*/
export function findExtension(name) {
const internalExtensionName = extensionNames.find(extName => {
return equalsIgnoreCaseAndAccents(extName, name) || equalsIgnoreCaseAndAccents(extName, `third-party/${name}`);
});
if (!internalExtensionName) return null;
const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
return { name: internalExtensionName, enabled: isEnabled };
}
/**
* Loads manifest.json files for extensions.
* @param {string[]} names Array of extension names
@ -789,8 +862,15 @@ async function showExtensionsDetails() {
await oldPopup.completeCancelled();
}
const htmlErrors = getExtensionLoadErrorsHtml();
const htmlDefault = $('<div class="marginBot10"><h3 class="textAlignCenter">' + t`Built-in Extensions:` + '</h3></div>');
const htmlExternal = $('<div class="marginBot10"><h3 class="textAlignCenter">' + t`Installed Extensions:` + '</h3></div>');
const htmlDefault = $('<div class="marginBot10"><h3>' + t`Built-in Extensions:` + '</h3></div>');
const htmlExternal = $(`<div class="marginBot10">
<div class="flex-container alignitemscenter spaceBetween flexnowrap marginBot10">
<h3 class="margin0">${t`Installed Extensions:`}</h3>
<div class="flex-container third_party_toolbar"></div>
</div>
</div>`);
const htmlLoading = $(`<div class="flex-container alignItemsCenter justifyCenter marginTop10 marginBot5">
<i class="fa-solid fa-spinner fa-spin"></i>
<span>` + t`Loading third-party extensions... Please wait...` + `</span>
@ -802,6 +882,7 @@ async function showExtensionsDetails() {
const sortByName = accountStorage.getItem(sortOrderKey) === 'true';
const sortFn = sortByName ? sortManifestsByName : sortManifestsByOrder;
const extensions = Object.entries(manifests).sort((a, b) => sortFn(a[1], b[1])).map(getExtensionData);
let extensionsToToggle = [];
extensions.forEach(value => {
const { isExternal, extensionHtml } = value;
@ -836,6 +917,54 @@ async function showExtensionsDetails() {
updateEnabledOnlyButton.textContent = t`Update enabled`;
updateEnabledOnlyButton.addEventListener('click', () => updateAction(false));
const toggleAllExtensionsButton = document.createElement('div');
toggleAllExtensionsButton.classList.add('menu_button', 'menu_button_icon');
toggleAllExtensionsButton.title = t`Bulk toggle third-party extensions.`;
toggleAllExtensionsButton.innerHTML = `
<span>${t`Toggle extensions`}</span>
<div class="fa-solid fa-circle-info opacity50p"></div>
`;
const restoreBulkToggledExtensionsButton = document.createElement('div');
restoreBulkToggledExtensionsButton.classList.add('menu_button', 'menu_button_icon', 'fa-solid', 'fa-arrow-right-rotate', 'displayNone');
restoreBulkToggledExtensionsButton.title = t`Restore toggled extensions.\n\nIt does not restore extensions toggled individually.`;
toggleAllExtensionsButton.addEventListener('click', () => {
extensionsToToggle = onToggleAllExtensions(extensionsToToggle, htmlExternal);
for (const extension of extensionsToToggle) {
const { name } = extension;
htmlExternal
.find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`)
.off('click')
.one('click', () => {
extensionsToToggle = extensionsToToggle.filter(ext => ext.name !== name);
});
}
const restoreButtonHandler = extensionsToToggle.length > 0 ? 'remove' : 'add';
restoreBulkToggledExtensionsButton.classList[restoreButtonHandler]('displayNone');
});
restoreBulkToggledExtensionsButton.addEventListener('click', () => {
for (const extension of extensionsToToggle) {
const { name } = extension;
const isDisabled = extension_settings.disabledExtensions.includes(name);
htmlExternal
.find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`)
.prop('checked', !isDisabled)
.toggleClass('toggle_enable', isDisabled)
.toggleClass('toggle_disable', !isDisabled)
.toggleClass('checkbox_disabled', isDisabled);
}
extensionsToToggle = [];
restoreBulkToggledExtensionsButton.classList.add('displayNone');
});
const flexExpander = document.createElement('div');
flexExpander.classList.add('expander');
@ -849,6 +978,7 @@ async function showExtensionsDetails() {
});
toolbar.append(updateAllButton, updateEnabledOnlyButton, flexExpander, sortOrderButton);
htmlExternal.find('.third_party_toolbar').append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton);
html.prepend(toolbar);
}
@ -864,6 +994,24 @@ async function showExtensionsDetails() {
if (waitingForSave) {
return false;
}
for (const extension of extensionsToToggle) {
const { name, toggleHandler, enable } = extension;
const isDisabled = extension_settings.disabledExtensions.includes(name);
try {
if (isDisabled && !enable) continue;
if (!isDisabled && enable) continue;
requiresReload = true;
await toggleHandler(name, false);
} catch (error) {
console.error(`Could not toggle extension ${name}:`, error);
toastr.error(t`Could not toggle extension ${name}. See console for details.`);
}
}
if (stateChanged) {
waitingForSave = true;
const toast = toastr.info(t`The page will be reloaded shortly...`, t`Extensions state changed`);
@ -872,6 +1020,7 @@ async function showExtensionsDetails() {
waitingForSave = false;
requiresReload = true;
}
return true;
},
});

View file

@ -103,10 +103,10 @@ async function downloadAssetsList(url) {
for (const i of json) {
//console.log(DEBUG_PREFIX,i)
if (availableAssets[i['type']] === undefined)
availableAssets[i['type']] = [];
if (availableAssets[i.type] === undefined)
availableAssets[i.type] = [];
availableAssets[i['type']].push(i);
availableAssets[i.type].push(i);
}
console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets);
@ -139,7 +139,7 @@ async function downloadAssetsList(url) {
assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation'));
}
for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a['name'].localeCompare(b['name']))) {
for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a.name.localeCompare(b.name))) {
const i = availableAssets[assetType].indexOf(asset);
const elemId = `assets_install_${assetType}_${i}`;
let element = $('<div />', { id: elemId, class: 'asset-download-button right_menu_button' });
@ -149,13 +149,13 @@ async function downloadAssetsList(url) {
//if (DEBUG_TONY_SAMA_FORK_MODE)
// asset["url"] = asset["url"].replace("https://github.com/SillyTavern/","https://github.com/Tony-sama/"); // DBG
console.debug(DEBUG_PREFIX, 'Checking asset', asset['id'], asset['url']);
console.debug(DEBUG_PREFIX, 'Checking asset', asset.id, asset.url);
const assetInstall = async function () {
element.off('click');
label.removeClass('fa-download');
this.classList.add('asset-download-button-loading');
await installAsset(asset['url'], assetType, asset['id']);
await installAsset(asset.url, assetType, asset.id);
label.addClass('fa-check');
this.classList.remove('asset-download-button-loading');
element.on('click', assetDelete);
@ -173,11 +173,11 @@ async function downloadAssetsList(url) {
const assetDelete = async function () {
if (assetType === 'character') {
toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
await executeSlashCommandsWithOptions(`/go ${asset['id']}`);
await executeSlashCommandsWithOptions(`/go ${asset.id}`);
return;
}
element.off('click');
await deleteAsset(assetType, asset['id']);
await deleteAsset(assetType, asset.id);
label.removeClass('fa-check');
label.removeClass('redOverlayGlow');
label.removeClass('fa-trash');
@ -186,7 +186,7 @@ async function downloadAssetsList(url) {
element.on('click', assetInstall);
};
if (isAssetInstalled(assetType, asset['id'])) {
if (isAssetInstalled(assetType, asset.id)) {
console.debug(DEBUG_PREFIX, 'installed, checked');
label.toggleClass('fa-download');
label.toggleClass('fa-check');
@ -207,14 +207,14 @@ async function downloadAssetsList(url) {
element.on('click', assetInstall);
}
console.debug(DEBUG_PREFIX, 'Created element for ', asset['id']);
console.debug(DEBUG_PREFIX, 'Created element for ', asset.id);
const displayName = DOMPurify.sanitize(asset['name'] || asset['id']);
const description = DOMPurify.sanitize(asset['description'] || '');
const url = isValidUrl(asset['url']) ? asset['url'] : '';
const displayName = DOMPurify.sanitize(asset.name || asset.id);
const description = DOMPurify.sanitize(asset.description || '');
const url = isValidUrl(asset.url) ? asset.url : '';
const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`;
const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple';
const toolTag = assetType === 'extension' && asset['tool'];
const toolTag = assetType === 'extension' && asset.tool;
const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR;
const assetBlock = $('<i></i>')
@ -246,7 +246,7 @@ async function downloadAssetsList(url) {
if (asset.highlight) {
assetBlock.find('.asset-name').append('<i class="fa-solid fa-sm fa-trophy"></i>');
}
assetBlock.find('.asset-name').prepend(`<div class="avatar"><img src="${asset['url']}" alt="${displayName}"></div>`);
assetBlock.find('.asset-name').prepend(`<div class="avatar"><img src="${asset.url}" alt="${displayName}"></div>`);
}
assetBlock.addClass('asset-block');

View file

@ -204,7 +204,7 @@ async function sendCaptionedMessage(caption, image, mimeType) {
inline_image: !!extension_settings.caption.show_in_chat,
},
};
chat_metadata['tainted'] = true;
chat_metadata.tainted = true;
context.chat.push(message);
const messageId = context.chat.length - 1;
await eventSource.emit(event_types.MESSAGE_SENT, messageId);
@ -489,6 +489,8 @@ jQuery(async function () {
'vertexai': SECRET_KEYS.VERTEXAI,
'anthropic': SECRET_KEYS.CLAUDE,
'xai': SECRET_KEYS.XAI,
'zai': SECRET_KEYS.ZAI,
'moonshot': SECRET_KEYS.MOONSHOT,
};
if (reverseProxyApis[api]) {
@ -502,11 +504,10 @@ jQuery(async function () {
'groq': SECRET_KEYS.GROQ,
'cohere': SECRET_KEYS.COHERE,
'aimlapi': SECRET_KEYS.AIMLAPI,
'moonshot': SECRET_KEYS.MOONSHOT,
'nanogpt': SECRET_KEYS.NANOGPT,
'chutes': SECRET_KEYS.CHUTES,
'electronhub': SECRET_KEYS.ELECTRONHUB,
'zai': SECRET_KEYS.ZAI,
'pollinations': SECRET_KEYS.POLLINATIONS,
};
if (chatCompletionApis[api] && secret_state[chatCompletionApis[api]]) {
@ -530,7 +531,7 @@ jQuery(async function () {
}
// Custom API doesn't need additional checks
if (api === 'custom' || api === 'pollinations') {
if (api === 'custom') {
return true;
}
}
@ -602,7 +603,7 @@ jQuery(async function () {
const modelIds = await response.json();
if (Array.isArray(modelIds) && modelIds.length > 0) {
modelIds.sort().forEach((modelId) => {
if (!modelId || typeof modelId !== 'string' || options.some(o => o.value === modelId)) {
if (!modelId || typeof modelId !== 'string' || options.some(o => o.value === modelId && o.dataset.type === api)) {
return;
}
const option = document.createElement('option');
@ -622,6 +623,7 @@ jQuery(async function () {
await processEndpoint('electronhub', '/api/backends/chat-completions/multimodal-models/electronhub');
await processEndpoint('mistral', '/api/backends/chat-completions/multimodal-models/mistral');
await processEndpoint('xai', '/api/backends/chat-completions/multimodal-models/xai');
await processEndpoint('moonshot', '/api/backends/chat-completions/multimodal-models/moonshot');
}
await addSettings();
@ -699,6 +701,10 @@ jQuery(async function () {
extension_settings.caption.ollama_custom_model = String($('#caption_ollama_custom_model').val()).trim();
saveSettingsDebounced();
});
$('#caption_custom_model').val(extension_settings.caption.custom_model || '').on('input', () => {
extension_settings.caption.custom_model = String($('#caption_custom_model').val()).trim();
saveSettingsDebounced();
});
$('#caption_refresh_models').on('click', async () => {
extension_settings.caption.multimodal_model = '';
await switchMultimodalBlocks();

View file

@ -49,13 +49,10 @@
</div>
</label>
<select id="caption_multimodal_model" class="flex1 text_pole">
<!-- AI/ML API, OpenRouter, Pollinations, NanoGPT, Mistral, xAI are added externally by JavaScript -->
<!-- AI/ML API, OpenRouter, Pollinations, NanoGPT, Mistral, xAI, Moonshot are added externally by JavaScript -->
<option data-type="cohere" value="c4ai-aya-vision-8b">c4ai-aya-vision-8b</option>
<option data-type="cohere" value="c4ai-aya-vision-32b">c4ai-aya-vision-32b</option>
<option data-type="cohere" value="command-a-vision-07-2025">command-a-vision-07-2025</option>
<option data-type="moonshot" value="moonshot-v1-8k-vision-preview">moonshot-v1-8k-vision-preview</option>
<option data-type="moonshot" value="moonshot-v1-32k-vision-preview">moonshot-v1-32k-vision-preview</option>
<option data-type="moonshot" value="moonshot-v1-128k-vision-preview">moonshot-v1-128k-vision-preview</option>
<option data-type="openai" value="gpt-5.2">gpt-5.2</option>
<option data-type="openai" value="gpt-5.2-2025-12-11">gpt-5.2-2025-12-11</option>
<option data-type="openai" value="gpt-5.2-chat-latest">gpt-5.2-chat-latest</option>
@ -89,6 +86,7 @@
<option data-type="openai" value="o4-mini-2025-04-16">o4-mini-2025-04-16</option>
<option data-type="openai" value="gpt-4.5-preview">gpt-4.5-preview</option>
<option data-type="openai" value="gpt-4.5-preview-2025-02-27">gpt-4.5-preview-2025-02-27</option>
<option data-type="anthropic" value="claude-opus-4-6">claude-opus-4-6</option>
<option data-type="anthropic" value="claude-opus-4-5">claude-opus-4-5</option>
<option data-type="anthropic" value="claude-opus-4-5-20251101">claude-opus-4-5-20251101</option>
<option data-type="anthropic" value="claude-sonnet-4-5">claude-sonnet-4-5</option>
@ -177,8 +175,16 @@
<option data-type="koboldcpp" value="koboldcpp_current" data-i18n="currently_loaded">[Currently loaded]</option>
<option data-type="vllm" value="vllm_current" data-i18n="currently_selected">[Currently selected]</option>
<option data-type="custom" value="custom_current" data-i18n="currently_selected">[Currently selected]</option>
<option data-type="custom" value="custom_custom" data-i18n="[Custom model]">[Custom model]</option>
</select>
</div>
<div data-type="custom">
<label for="caption_custom_model">
<span data-i18n="Model Id">Model Id</span>
<small data-i18n="(for [Custom model] option)">(for [Custom model] option)</small>
</label>
<input id="caption_custom_model" class="text_pole" type="text" placeholder="e.g. gpt-4o" />
</div>
<div data-type="ollama">
<div>
The model must be downloaded first! Do it with the <code>ollama pull</code> command or <a href="#" id="caption_ollama_pull">click here</a>.
@ -191,7 +197,7 @@
<input id="caption_ollama_custom_model" class="text_pole" type="text" placeholder="e.g. gemma3:latest" />
</div>
</div>
<label data-type="openai,anthropic,google,vertexai,mistral,xai" class="checkbox_label flexBasis100p" for="caption_allow_reverse_proxy" title="Allow using reverse proxy if defined and valid.">
<label data-type="openai,anthropic,google,vertexai,mistral,xai,zai,moonshot" class="checkbox_label flexBasis100p" for="caption_allow_reverse_proxy" title="Allow using reverse proxy if defined and valid.">
<input id="caption_allow_reverse_proxy" type="checkbox" class="checkbox">
<span data-i18n="Allow reverse proxy">Allow reverse proxy</span>
</label>

View file

@ -6,8 +6,6 @@ import {
animation_duration,
eventSource,
event_types,
extension_prompt_roles,
extension_prompt_types,
generateQuietPrompt,
is_send_press,
saveSettingsDebounced,
@ -18,6 +16,7 @@ import {
streamingProcessor,
animation_easing,
} from '../../../script.js';
import { extension_prompt_roles, extension_prompt_types } from '../../extension-prompts.js';
import { is_group_generating, selected_group } from '../../group-chats.js';
import { loadMovingUIState, power_user } from '../../power-user.js';
import { dragElement } from '../../RossAscends-mods.js';
@ -437,9 +436,13 @@ async function onChatEvent() {
const context = getContext();
const chat = context.chat;
// Chat can't be empty.
if (chat.length === 0) return;
const lastMessage = chat[chat.length - 1];
// No new messages - do nothing
if (chat.length === 0 || (lastMessageId === chat.length && getStringHash(chat[chat.length - 1].mes) === lastMessageHash)) {
if ((lastMessageId === chat.length && getStringHash(lastMessage.mes) === lastMessageHash)) {
return;
}
@ -451,18 +454,18 @@ async function onChatEvent() {
// Message has been edited / regenerated - delete the saved memory
if (chat.length
&& chat[chat.length - 1].extra
&& chat[chat.length - 1].extra.memory
&& lastMessage.extra
&& lastMessage.extra.memory
&& lastMessageId === chat.length
&& getStringHash(chat[chat.length - 1].mes) !== lastMessageHash) {
delete chat[chat.length - 1].extra.memory;
&& getStringHash(lastMessage.mes) !== lastMessageHash) {
delete lastMessage.extra.memory;
}
summarizeChat(context)
.catch(console.error)
.finally(() => {
lastMessageId = context.chat?.length ?? null;
lastMessageHash = getStringHash((context.chat.length && context.chat[context.chat.length - 1]['mes']) ?? '');
lastMessageHash = getStringHash((context.chat.length && context.chat[context.chat.length - 1].mes) ?? '');
});
}

View file

@ -185,7 +185,7 @@ const init = async () => {
buttons.show();
settings.onSave = ()=>buttons.refresh();
window['executeQuickReplyByName'] = async(name, args = {}, options = {}) => {
globalThis.executeQuickReplyByName = async(name, args = {}, options = {}) => {
let qr = [
...settings.config.setList,
...(settings.chatConfig?.setList ?? []),

View file

@ -77,7 +77,7 @@ export class SlashCommandHandler {
},
};
window['qrEnumProviderExecutables'] = localEnumProviders.qrExecutables;
globalThis.qrEnumProviderExecutables = localEnumProviders.qrExecutables;
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr',
callback: (_, value) => this.executeQuickReplyByIndex(Number(value)),

View file

@ -1,6 +1,43 @@
const TAURI_MAIN_READY_KEY = '__TAURITAVERN_MAIN_READY__';
const READY_PROMISE_WAIT_TIMEOUT_MS = 7000;
const READY_PROMISE_POLL_INTERVAL_MS = 50;
function isTauriRuntime() {
if (typeof window === 'undefined') {
return false;
}
return window.__TAURI_RUNNING__ === true
|| window.__TAURI_INTERNALS__ !== undefined
|| typeof window.__TAURI__?.core?.invoke === 'function';
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForReadyPromiseRegistration() {
if (typeof window === 'undefined' || !isTauriRuntime()) {
return null;
}
const deadline = Date.now() + READY_PROMISE_WAIT_TIMEOUT_MS;
while (Date.now() < deadline) {
const readyPromise = window[TAURI_MAIN_READY_KEY];
if (readyPromise && typeof readyPromise.then === 'function') {
return readyPromise;
}
await sleep(READY_PROMISE_POLL_INTERVAL_MS);
}
return null;
}
export async function waitForTauriMainReady() {
const readyPromise = window.__TAURITAVERN_MAIN_READY__;
if (!readyPromise || typeof readyPromise.then !== 'function') {
const readyPromise = await waitForReadyPromiseRegistration();
if (!readyPromise) {
return;
}

View file

@ -15,7 +15,7 @@ import { createThumbnail, isValidUrl } from '../utils.js';
*/
export async function getMultimodalCaption(base64Img, prompt) {
const useReverseProxy =
(['openai', 'anthropic', 'google', 'mistral', 'vertexai', 'xai'].includes(extension_settings.caption.multimodal_api))
(['openai', 'anthropic', 'google', 'mistral', 'vertexai', 'xai', 'zai', 'moonshot'].includes(extension_settings.caption.multimodal_api))
&& extension_settings.caption.allow_reverse_proxy
&& oai_settings.reverse_proxy
&& isValidUrl(oai_settings.reverse_proxy);
@ -108,8 +108,15 @@ export async function getMultimodalCaption(base64Img, prompt) {
}
if (isCustom) {
if (extension_settings.caption.multimodal_model === 'custom_current') {
requestBody.model = oai_settings.custom_model || '';
}
if (extension_settings.caption.multimodal_model === 'custom_custom') {
requestBody.model = extension_settings.caption.custom_model || '';
}
requestBody.server_url = oai_settings.custom_url;
requestBody.model = oai_settings.custom_model || 'gpt-4-turbo';
requestBody.custom_include_headers = oai_settings.custom_include_headers;
requestBody.custom_include_body = oai_settings.custom_include_body;
requestBody.custom_exclude_body = oai_settings.custom_exclude_body;
@ -245,6 +252,10 @@ function throwIfInvalidModel(useReverseProxy) {
throw new Error('Custom API URL is not set.');
}
if (multimodalApi === 'custom' && multimodalModel === 'custom_custom' && !extension_settings.caption.custom_model) {
throw new Error('Custom OpenAI-compatible Model ID is not set.');
}
if (multimodalApi === 'aimlapi' && !secret_state[SECRET_KEYS.AIMLAPI]) {
throw new Error('AI/ML API key is not set.');
}
@ -268,6 +279,10 @@ function throwIfInvalidModel(useReverseProxy) {
if (multimodalApi === 'zai' && !secret_state[SECRET_KEYS.ZAI]) {
throw new Error('Z.AI API key is not set.');
}
if (multimodalApi === 'pollinations' && !secret_state[SECRET_KEYS.POLLINATIONS]) {
throw new Error('Pollinations API key is not set.');
}
}
/**

View file

@ -69,10 +69,17 @@ const MODULE_NAME = 'sd';
// This is a 1x1 transparent PNG
const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const CUSTOM_STOP_EVENT = 'sd_stop_generation';
// Generation tracking for status indicator
let activeGenerations = 0;
/** @type {JQuery<HTMLElement>|null} */
let generationToast = null;
const sources = {
extras: 'extras',
horde: 'horde',
auto: 'auto',
sdcpp: 'sdcpp',
novel: 'novel',
vlad: 'vlad',
openai: 'openai',
@ -277,6 +284,7 @@ const defaultSettings = {
snap: false,
free_extend: false,
function_tool: false,
minimal_prompt_processing: false,
prompts: promptTemplates,
@ -284,6 +292,9 @@ const defaultSettings = {
auto_url: 'http://localhost:7860',
auto_auth: '',
// stable-diffusion.cpp settings
sdcpp_url: 'http://127.0.0.1:1234',
vlad_url: 'http://localhost:7860',
vlad_auth: '',
@ -320,6 +331,7 @@ const defaultSettings = {
// OpenAI settings
openai_style: 'vivid',
openai_quality: 'standard',
openai_quality_gpt: 'auto',
openai_duration: '8',
style: 'Default',
@ -425,7 +437,7 @@ function processTriggers(chat, _, abort, type) {
}
}
window['SD_ProcessTriggers'] = processTriggers;
globalThis.SD_ProcessTriggers = processTriggers;
function getSdRequestBody() {
switch (extension_settings.sd.source) {
@ -521,6 +533,7 @@ async function loadSettings() {
$('#sd_multimodal_captioning').prop('checked', extension_settings.sd.multimodal_captioning);
$('#sd_auto_url').val(extension_settings.sd.auto_url);
$('#sd_auto_auth').val(extension_settings.sd.auto_auth);
$('#sd_sdcpp_url').val(extension_settings.sd.sdcpp_url);
$('#sd_vlad_url').val(extension_settings.sd.vlad_url);
$('#sd_vlad_auth').val(extension_settings.sd.vlad_auth);
$('#sd_drawthings_url').val(extension_settings.sd.drawthings_url);
@ -528,12 +541,14 @@ async function loadSettings() {
$('#sd_interactive_mode').prop('checked', extension_settings.sd.interactive_mode);
$('#sd_openai_style').val(extension_settings.sd.openai_style);
$('#sd_openai_quality').val(extension_settings.sd.openai_quality);
$('#sd_openai_quality_gpt').val(extension_settings.sd.openai_quality_gpt);
$('#sd_openai_duration').val(extension_settings.sd.openai_duration);
$('#sd_comfy_type').val(extension_settings.sd.comfy_type);
$('#sd_comfy_url').val(extension_settings.sd.comfy_url);
$('#sd_comfy_prompt').val(extension_settings.sd.comfy_prompt);
$('#sd_comfy_runpod_url').val(extension_settings.sd.comfy_runpod_url);
$('#sd_snap').prop('checked', extension_settings.sd.snap);
$('#sd_minimal_prompt_processing').prop('checked', extension_settings.sd.minimal_prompt_processing);
$('#sd_clip_skip').val(extension_settings.sd.clip_skip);
$('#sd_clip_skip_value').val(extension_settings.sd.clip_skip);
$('#sd_seed').val(extension_settings.sd.seed);
@ -656,6 +671,11 @@ function onSnapInput() {
saveSettingsDebounced();
}
function onMinimalPromptProcessing() {
extension_settings.sd.minimal_prompt_processing = !!$(this).prop('checked');
saveSettingsDebounced();
}
function onStyleSelect() {
const selectedStyle = String($('#sd_style').find(':selected').val());
const styleObject = extension_settings.sd.styles.find(x => x.name === selectedStyle);
@ -708,7 +728,8 @@ async function onDeleteStyleClick() {
}
async function onSaveStyleClick() {
const userInput = await callGenericPopup('Enter style name:', POPUP_TYPE.INPUT);
const selectedStyle = extension_settings.sd.style || '';
const userInput = await callGenericPopup(t`Enter style name:`, POPUP_TYPE.INPUT, selectedStyle);
if (!userInput) {
return;
@ -744,6 +765,48 @@ async function onSaveStyleClick() {
saveSettingsDebounced();
}
async function onRenameStyleClick() {
const selectedStyle = extension_settings.sd.style;
const styleObject = extension_settings.sd.styles.find(x => x.name === selectedStyle);
if (!styleObject) {
return;
}
const newName = await callGenericPopup(t`Enter new style name:`, POPUP_TYPE.INPUT, selectedStyle);
if (!newName) {
return;
}
const name = String(newName).trim();
if (name === selectedStyle) {
return;
}
const existingStyle = extension_settings.sd.styles.find(x => x.name === name);
if (existingStyle) {
toastr.error(t`A style with that name already exists`);
return;
}
styleObject.name = name;
extension_settings.sd.style = name;
$('#sd_style').empty();
for (const style of extension_settings.sd.styles) {
const option = document.createElement('option');
option.value = style.name;
option.text = style.name;
option.selected = style.name === extension_settings.sd.style;
$('#sd_style').append(option);
}
saveSettingsDebounced();
}
/**
* Modifies prompt based on user inputs.
* @param {string} prompt Prompt to refine
@ -977,6 +1040,13 @@ const resolutionOptions = {
sd_res_1024x1536: { width: 1024, height: 1536, name: '1024x1536 (2:3, ChatGPT)' },
sd_res_1024x1792: { width: 1024, height: 1792, name: '1024x1792 (4:7, DALL-E)' },
sd_res_1792x1024: { width: 1792, height: 1024, name: '1792x1024 (7:4, DALL-E)' },
sd_res_1280x1280: { width: 1280, height: 1280, name: '1280x1280 (1:1, Z.AI)' },
sd_res_1568x1056: { width: 1568, height: 1056, name: '1568x1056 (3:2, Z.AI)' },
sd_res_1056x1568: { width: 1056, height: 1568, name: '1056x1568 (2:3, Z.AI)' },
sd_res_1472x1088: { width: 1472, height: 1088, name: '1472x1088 (4:3, Z.AI)' },
sd_res_1088x1472: { width: 1088, height: 1472, name: '1088x1472 (3:4, Z.AI)' },
sd_res_1728x960: { width: 1728, height: 960, name: '1728x960 (16:9, Z.AI)' },
sd_res_960x1728: { width: 960, height: 1728, name: '960x1728 (9:16, Z.AI)' },
};
function onResolutionChange() {
@ -1141,6 +1211,11 @@ function onAutoAuthInput() {
saveSettingsDebounced();
}
function onSdcppUrlInput() {
extension_settings.sd.sdcpp_url = $('#sd_sdcpp_url').val();
saveSettingsDebounced();
}
function onVladUrlInput() {
extension_settings.sd.vlad_url = $('#sd_vlad_url').val();
saveSettingsDebounced();
@ -1249,6 +1324,29 @@ async function validateAutoUrl() {
}
}
async function validateSdcppUrl() {
try {
if (!extension_settings.sd.sdcpp_url) {
throw new Error('URL is not set.');
}
const result = await fetch('/api/sd/sdcpp/ping', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ url: extension_settings.sd.sdcpp_url }),
});
if (!result.ok) {
throw new Error('stable-diffusion.cpp server returned an error.');
}
await loadSettingOptions();
toastr.success('stable-diffusion.cpp server connected.');
} catch (error) {
toastr.error(`Could not validate stable-diffusion.cpp server: ${error.message}`);
}
}
async function validateDrawthingsUrl() {
try {
if (!extension_settings.sd.drawthings_url) {
@ -1542,6 +1640,9 @@ async function loadSamplers() {
case sources.auto:
samplers = await loadAutoSamplers();
break;
case sources.sdcpp:
samplers = await loadSdcppSamplers();
break;
case sources.drawthings:
samplers = await loadDrawthingsSamplers();
break;
@ -1667,6 +1768,11 @@ async function loadAutoSamplers() {
}
}
async function loadSdcppSamplers() {
// The sdcpp server does not provide an API for samplers, so we return the known list.
return ['euler', 'euler_a', 'heun', 'dpm2', 'dpm++2s_a', 'dpm++2m', 'dpm++2mv2', 'ipndm', 'ipndm_v', 'lcm', 'ddim_trailing', 'tcd'];
}
async function loadDrawthingsSamplers() {
// The app developer doesn't provide an API to get these yet
return [
@ -1756,6 +1862,9 @@ async function loadModels() {
case sources.auto:
models = await loadAutoModels();
break;
case sources.sdcpp:
models = [{ value: '', text: 'N/A' }];
break;
case sources.drawthings:
models = await loadDrawthingsModels();
break;
@ -1850,7 +1959,7 @@ function switchModelSpecificControls(modelId) {
modelControls.each(function () {
const models = String($(this).attr('data-sd-model') || '').split(',').map(m => m.trim());
$(this).toggle(models.includes(modelId));
$(this).toggle(models.some(m => modelId.includes(m)));
});
}
@ -1940,6 +2049,8 @@ async function loadXAIModels() {
}
async function loadPollinationsModels() {
$('#sd_pollinations_key').toggleClass('success', !!secret_state[SECRET_KEYS.POLLINATIONS]);
const result = await fetch('/api/sd/pollinations/models', {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true }),
@ -2169,6 +2280,7 @@ async function loadOpenAiModels() {
{ value: 'gpt-image-1.5', text: 'gpt-image-1.5' },
{ value: 'gpt-image-1-mini', text: 'gpt-image-1-mini' },
{ value: 'gpt-image-1', text: 'gpt-image-1' },
{ value: 'chatgpt-image-latest', text: 'chatgpt-image-latest' },
{ value: 'dall-e-3', text: 'dall-e-3' },
{ value: 'dall-e-2', text: 'dall-e-2' },
{ value: 'sora-2', text: 'sora-2' },
@ -2294,7 +2406,12 @@ async function loadGoogleModels() {
}
async function loadZaiModels() {
return ['cogview-4-250304'].map(name => ({ value: name, text: name }));
return [
{ value: 'glm-image', text: 'GLM-Image' },
{ value: 'cogview-4-250304', text: 'CogView-4' },
{ value: 'cogvideox-3', text: 'CogVideoX-3' },
{ value: 'viduq1-text', text: 'Viduq1-Text' },
];
}
async function loadOpenRouterModels() {
@ -2356,6 +2473,9 @@ async function loadSchedulers() {
case sources.auto:
schedulers = await getAutoRemoteSchedulers();
break;
case sources.sdcpp:
schedulers = await loadSdcppSchedulers();
break;
case sources.novel:
schedulers = loadNovelSchedulers();
break;
@ -2454,6 +2574,11 @@ async function loadComfySchedulers() {
}
}
async function loadSdcppSchedulers() {
// The sdcpp server does not provide an API for schedulers, so we return the known list.
return ['discrete', 'karras', 'exponential', 'ays', 'gits', 'smoothstep', 'sgm_uniform', 'simple', 'kl_optimal', 'lcm'];
}
async function loadVaes() {
$('#sd_vae').empty();
let vaes = [];
@ -2468,6 +2593,9 @@ async function loadVaes() {
case sources.auto:
vaes = await loadAutoVaes();
break;
case sources.sdcpp:
vaes = ['N/A'];
break;
case sources.novel:
vaes = ['N/A'];
break;
@ -2657,6 +2785,15 @@ function processReply(str) {
return '';
}
if (extension_settings.sd.minimal_prompt_processing) {
// Minimal prompt processing
// JSON and similar should be preserved
str = str.normalize('NFD');
str = str.replace(/\s+/g, ' '); // Collapse multiple whitespaces into one
str = str.trim();
return str;
}
str = str.replaceAll('"', '');
str = str.replaceAll('“', '');
str = str.replaceAll('\n', ', ');
@ -2727,6 +2864,62 @@ function ensureSelectionExists(setting, selector) {
}
}
/**
* Updates the generation status indicator based on active generation count.
* Shows/hides various UI indicators to inform user of background image generation.
*/
function updateGenerationIndicator() {
if (activeGenerations > 0) {
const countText = activeGenerations > 1 ? ` (${activeGenerations})` : '';
const toastText = `<i class="fa-solid fa-spinner fa-spin"></i> ${t`Generating image`}${countText}...`;
// Show persistent toast if not already showing
if (!generationToast) {
generationToast = toastr.info(
toastText,
'Image Generation',
{
timeOut: 0,
extendedTimeOut: 0,
tapToDismiss: true,
escapeHtml: false,
onHidden: () => {
generationToast = null;
},
},
);
} else if (activeGenerations > 1) {
// Update count in existing toast
const toastMessage = $(generationToast).find('.toast-message');
if (toastMessage.length) {
toastMessage.html(toastText);
}
}
} else {
// Hide toast when done
if (generationToast) {
toastr.clear(generationToast);
generationToast = null;
}
}
}
/**
* Increments the active generation counter and updates indicators.
*/
function startGenerationTracking() {
activeGenerations++;
updateGenerationIndicator();
}
/**
* Decrements the active generation counter and updates indicators.
*/
function endGenerationTracking() {
activeGenerations = Math.max(0, activeGenerations - 1);
updateGenerationIndicator();
}
/**
* Generates an image based on the given trigger word.
* @param {string} initiator The initiator of the image generation
@ -2801,6 +2994,9 @@ async function generatePicture(initiator, args, trigger, message, callback) {
await eventSource.emit(event_types.SD_PROMPT_PROCESSING, eventData);
prompt = eventData.prompt; // Allow extensions to modify the prompt
// Track this generation for status indicator
startGenerationTracking();
// Show stop button after prompt is ready (prompt generation uses separate abort mechanism)
$(stopButton).show();
eventSource.once(CUSTOM_STOP_EVENT, stopListener);
@ -2811,6 +3007,13 @@ async function generatePicture(initiator, args, trigger, message, callback) {
// generate the image
imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiator, abortController.signal);
} catch (err) {
// Check if this was an intentional abort by user
if (abortController.signal.aborted) {
console.log('SD: Image generation aborted by user');
toastr.info('Image generation stopped.', 'Image Generation');
return;
}
console.trace(err);
// errors here are most likely due to text generation failure
// sendGenerationRequest mostly deals with its own errors
@ -2823,6 +3026,7 @@ async function generatePicture(initiator, args, trigger, message, callback) {
$(stopButton).hide();
restoreOriginalDimensions(dimensions);
eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);
endGenerationTracking();
}
return imagePath;
@ -3014,8 +3218,10 @@ function getUserAvatarUrl() {
* @returns {Promise<string>} - A promise that resolves when the prompt generation completes.
*/
async function generatePrompt(quietPrompt) {
const toast = toastr.info('Generating image prompt with an LLM...', 'Image Generation');
const reply = await generateQuietPrompt({ quietPrompt });
const processedReply = processReply(reply);
toastr.clear(toast);
if (!processedReply) {
toastr.error('Prompt generation produced no text. Make sure you\'re using a valid instruct template and try again', 'Image Generation');
@ -3074,6 +3280,9 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
case sources.auto:
result = await generateAutoImage(prefixedPrompt, negativePrompt, signal);
break;
case sources.sdcpp:
result = await generateSdcppImage(prefixedPrompt, negativePrompt, signal);
break;
case sources.novel:
result = await generateNovelImage(prefixedPrompt, negativePrompt, signal);
break;
@ -3140,6 +3349,13 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
throw new Error('Endpoint did not return image data.');
}
} catch (err) {
// Check if this was an intentional abort by user
if (signal?.aborted) {
console.log('SD: Image generation aborted by user');
toastr.info('Image generation stopped.', 'Image Generation');
return;
}
console.error('Image generation request error: ', err);
toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation');
return;
@ -3215,7 +3431,7 @@ async function generatePollinationsImage(prompt, negativePrompt, signal) {
if (result.ok) {
const data = await result.json();
return { format: 'jpg', data: data?.image };
return { format: data?.format, data: data?.image };
} else {
const text = await result.text();
throw new Error(text);
@ -3271,7 +3487,7 @@ async function generateExtrasImage(prompt, negativePrompt, signal) {
* Gets an aspect ratio for Stability that is the closest to the given width and height.
* @param {number} width Target width
* @param {number} height Target height
* @param {'google'|'stability'} source Source of the request, used to determine aspect ratio
* @param {'google'|'stability'|'zai'} source Source of the request, used to determine aspect ratio
* @returns {string} Closest aspect ratio as a string
*/
function getClosestAspectRatio(width, height, source) {
@ -3297,6 +3513,12 @@ function getClosestAspectRatio(width, height, source) {
'4:3': 4 / 3,
'3:4': 3 / 4,
};
case 'zai':
return {
'1:1': 1,
'16:9': 16 / 9,
'9:16': 9 / 16,
};
default:
console.warn(`Unknown source "${source}" for aspect ratio calculation.`);
return null;
@ -3325,22 +3547,41 @@ function getClosestAspectRatio(width, height, source) {
* Get closest size for Electron Hub
* @param {number} width - The width of the image
* @param {number} height - The height of the image
* @param {string[]} sizes - Available sizes
* @returns {Promise<string>} - The closest size
*/
async function getClosestSize(width, height) {
const response = await fetch('/api/sd/electronhub/sizes', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
model: extension_settings.sd.model,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(text);
async function getClosestSize(width, height, sizes = []) {
const sizesData = [];
if (Array.isArray(sizes) && sizes.length > 0) {
sizesData.push(...sizes);
} else if (extension_settings.sd.source === sources.electronhub) {
const response = await fetch('/api/sd/electronhub/sizes', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
model: extension_settings.sd.model,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(text);
}
const result = await response.json();
sizesData.push(...result.sizes);
} else {
return null;
}
const result = await response.json();
const sizesData = result.sizes;
const targetWidth = Number(width);
const targetHeight = Number(height);
if (isNaN(targetWidth) || isNaN(targetHeight)) {
return null;
}
const targetAspect = targetWidth / targetHeight;
const targetResolution = targetWidth * targetHeight;
const closestSize = sizesData.reduce((closest, size) => {
if (!size || typeof size !== 'string') {
@ -3353,16 +3594,14 @@ async function getClosestSize(width, height) {
const sizeWidth = Number(sizeParts[0]);
const sizeHeight = Number(sizeParts[1]);
const targetWidth = Number(width);
const targetHeight = Number(height);
if (isNaN(sizeWidth) || isNaN(sizeHeight) || isNaN(targetWidth) || isNaN(targetHeight)) {
if (isNaN(sizeWidth) || isNaN(sizeHeight)) {
return closest;
}
const sizeArea = sizeWidth * sizeHeight;
const targetArea = targetWidth * targetHeight;
const diff = Math.abs(sizeArea - targetArea);
const aspectDiff = Math.abs((sizeWidth / sizeHeight) - targetAspect) / targetAspect;
const resolutionDiff = Math.abs(sizeWidth * sizeHeight - targetResolution) / targetResolution;
const diff = aspectDiff + resolutionDiff;
return diff < closest.diff ? { size, diff } : closest;
}, { size: null, diff: Infinity });
@ -3531,6 +3770,55 @@ async function generateAutoImage(prompt, negativePrompt, signal) {
}
}
/**
* Generates an image using stable-diffusion.cpp server API.
*
* @param {string} prompt - The main instruction used to guide the image generation.
* @param {string} negativePrompt - The instruction used to restrict the image generation.
* @param {AbortSignal} signal - An AbortSignal object that can be used to cancel the request.
* @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
*/
async function generateSdcppImage(prompt, negativePrompt, signal) {
const payload = {
url: extension_settings.sd.sdcpp_url,
prompt: prompt,
negative_prompt: negativePrompt,
steps: extension_settings.sd.steps,
cfg_scale: extension_settings.sd.scale,
width: extension_settings.sd.width,
height: extension_settings.sd.height,
batch_size: 1,
seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
};
if (extension_settings.sd.sampler && extension_settings.sd.sampler !== 'N/A') {
payload.sampler_name = extension_settings.sd.sampler;
}
if (extension_settings.sd.scheduler && extension_settings.sd.scheduler !== 'N/A') {
payload.scheduler = extension_settings.sd.scheduler;
}
if (Number.isFinite(extension_settings.sd.clip_skip)) {
payload.clip_skip = extension_settings.sd.clip_skip;
}
const result = await fetch('/api/sd/sdcpp/generate', {
method: 'POST',
headers: getRequestHeaders(),
signal: signal,
body: JSON.stringify(payload),
});
if (result.ok) {
const data = await result.json();
return { format: 'png', data: data.images?.[0] };
} else {
const text = await result.text();
throw new Error(text);
}
}
/**
* Generates an image in Drawthings API using the provided prompt and configuration settings.
*
@ -3697,7 +3985,7 @@ async function generateOpenAiImage(prompt, signal) {
const isDalle2 = /dall-e-2/.test(extension_settings.sd.model);
const isDalle3 = /dall-e-3/.test(extension_settings.sd.model);
const isGptImg = /gpt-image-1/.test(extension_settings.sd.model);
const isGptImg = /gpt-image-(1|latest)/.test(extension_settings.sd.model);
const isSora2 = /sora-2/.test(extension_settings.sd.model);
if (isDalle2 && prompt.length > dalle2PromptLimit) {
@ -3770,7 +4058,7 @@ async function generateOpenAiImage(prompt, signal) {
model: extension_settings.sd.model,
size: `${width}x${height}`,
n: 1,
quality: isDalle3 ? extension_settings.sd.openai_quality : undefined,
quality: isDalle3 ? extension_settings.sd.openai_quality : (isGptImg ? extension_settings.sd.openai_quality_gpt : undefined),
style: isDalle3 ? extension_settings.sd.openai_style : undefined,
response_format: isDalle2 || isDalle3 ? 'b64_json' : undefined,
moderation: isGptImg ? 'low' : undefined,
@ -4242,38 +4530,77 @@ async function generateGoogleImage(prompt, negativePrompt, signal) {
* @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete.
*/
async function generateZaiImage(prompt, signal) {
// Round width and height to nearest multiple of 16, and clamp to 512-2048 range
let width = clamp(Math.round(extension_settings.sd.width / 16) * 16, 512, 2048);
let height = clamp(Math.round(extension_settings.sd.height / 16) * 16, 512, 2048);
// Make sure the pixel count does not exceed 2^21px
while ((width * height) > Math.pow(2, 21)) {
if (width >= height) {
width -= 16;
} else {
height -= 16;
// Video generation models (CogVideoX, Viduq1)
if (/(cogvideox|vidu)/.test(extension_settings.sd.model)) {
const videoParams = {};
if (/cogvideox/.test(extension_settings.sd.model)) {
const cogVideoSizes = ['1280x720', '720x1280', '1024x1024', '1080x1920', '2048x1080', '3840x2160'];
videoParams.quality = extension_settings.sd.openai_quality === 'hd' ? 'quality' : 'speed';
videoParams.size = await getClosestSize(extension_settings.sd.width, extension_settings.sd.height, cogVideoSizes);
}
if (/vidu/.test(extension_settings.sd.model)) {
videoParams.aspect_ratio = getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height, 'zai');
}
const videoResult = await fetch('/api/sd/zai/generate-video', {
method: 'POST',
headers: getRequestHeaders(),
signal: signal,
body: JSON.stringify({
prompt: prompt,
model: extension_settings.sd.model,
...videoParams,
}),
});
if (videoResult.ok) {
const data = await videoResult.json();
return { format: data.format, data: data.video };
}
const text = await videoResult.text();
throw new Error(text);
} else {
// Image generation models (GLM-Image, CogView)
// GLM-Image requires multiples of 32, CogView requires multiples of 16
const isGlmImage = /glm-image/.test(extension_settings.sd.model);
const multiple = isGlmImage ? 32 : 16;
// Round width and height to nearest multiple and clamp to 512-2048 range
let width = clamp(Math.round(extension_settings.sd.width / multiple) * multiple, 512, 2048);
let height = clamp(Math.round(extension_settings.sd.height / multiple) * multiple, 512, 2048);
// CogView has a 2^21px pixel count limit, GLM-Image does not
if (!isGlmImage) {
while ((width * height) > Math.pow(2, 21)) {
if (width >= height) {
width -= multiple;
} else {
height -= multiple;
}
}
}
const result = await fetch('/api/sd/zai/generate', {
method: 'POST',
headers: getRequestHeaders(),
signal: signal,
body: JSON.stringify({
prompt: prompt,
model: extension_settings.sd.model,
quality: extension_settings.sd.openai_quality,
size: `${width}x${height}`,
}),
});
if (result.ok) {
const data = await result.json();
return { format: data.format, data: data.image };
}
const text = await result.text();
throw new Error(text);
}
const result = await fetch('/api/sd/zai/generate', {
method: 'POST',
headers: getRequestHeaders(),
signal: signal,
body: JSON.stringify({
prompt: prompt,
model: extension_settings.sd.model,
quality: extension_settings.sd.openai_quality,
size: `${width}x${height}`,
}),
});
if (result.ok) {
const data = await result.json();
return { format: data.format, data: data.image };
}
const text = await result.text();
throw new Error(text);
}
/**
@ -4443,6 +4770,58 @@ async function onComfyDeleteWorkflowClick() {
onComfyWorkflowChange();
}
async function onComfyRenameWorkflowClick() {
const oldName = extension_settings.sd.comfy_workflow;
if (!oldName) {
return;
}
let newName = await callGenericPopup(t`Enter new workflow name:`, POPUP_TYPE.INPUT, oldName);
if (!newName) {
return;
}
newName = String(newName).trim();
if (!newName.toLowerCase().endsWith('.json')) {
newName += '.json';
}
if (newName === oldName) {
return;
}
const existingWorkflow = Array
.from(document.querySelectorAll('#sd_comfy_workflow option'))
.find(opt => opt instanceof HTMLOptionElement && opt.value === newName);
if (existingWorkflow) {
toastr.warning(t`A workflow with that name already exists`);
return;
}
const response = await fetch('/api/sd/comfy/rename-workflow', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
old_name: oldName,
new_name: newName,
}),
});
if (!response.ok) {
const text = await response.text();
toastr.error(t`Failed to rename workflow.\n\n${text}`);
return;
}
extension_settings.sd.comfy_workflow = newName;
saveSettingsDebounced();
await loadComfyWorkflows();
}
/**
* Sends a chat message with the generated image.
* @param {string} prompt Prompt used for the image generation
@ -4575,6 +4954,8 @@ function isValidState() {
return true;
case sources.auto:
return !!extension_settings.sd.auto_url;
case sources.sdcpp:
return !!extension_settings.sd.sdcpp_url;
case sources.drawthings:
return !!extension_settings.sd.drawthings_url;
case sources.vlad:
@ -4598,7 +4979,7 @@ function isValidState() {
case sources.togetherai:
return secret_state[SECRET_KEYS.TOGETHERAI];
case sources.pollinations:
return true;
return secret_state[SECRET_KEYS.POLLINATIONS];
case sources.stability:
return secret_state[SECRET_KEYS.STABILITY];
case sources.huggingface:
@ -4626,7 +5007,8 @@ function isValidState() {
}
}
let buttonAbortController = null;
/** @type {WeakMap<HTMLElement, AbortController>} */
const buttonAbortControllers = new WeakMap();
/**
* "Paintbrush" button handler to generate a new image for a message.
@ -4644,16 +5026,30 @@ async function sdMessageButton($icon, { animate } = {}) {
$icon.toggleClass(classes.idle, !isBusy);
$icon.toggleClass(classes.busy, isBusy);
$media.toggleClass(classes.animation, isBusy);
// Update generation counter toast
const trackingFunction = isBusy ? startGenerationTracking : endGenerationTracking;
trackingFunction();
}
let $media = jQuery();
const classes = { busy: 'fa-hourglass', idle: 'fa-paintbrush', animation: 'fa-fade' };
const context = getContext();
const abortController = (() => {
const nativeElement = $icon.get(0);
if (buttonAbortControllers.has(nativeElement)) {
return buttonAbortControllers.get(nativeElement);
} else {
const controller = new AbortController();
buttonAbortControllers.set(nativeElement, controller);
return controller;
}
})();
if ($icon.hasClass(classes.busy)) {
buttonAbortController?.abort('Aborted by user');
console.log('Previous image is still being generated...');
abortController.abort('Aborted by user');
console.log('SD: Image generation aborted by user');
return;
}
@ -4690,13 +5086,12 @@ async function sdMessageButton($icon, { animate } = {}) {
$media = messageElement.find(`.mes_media_container[data-index="${index}"]`).find('.mes_img, .mes_video');
}
buttonAbortController = new AbortController();
const newMediaAttachment = await generateMediaSwipe(
selectedMedia,
message,
() => setBusyIcon(true),
() => setBusyIcon(false),
buttonAbortController,
abortController,
);
if (!newMediaAttachment) {
@ -4869,6 +5264,17 @@ function applyCommandArguments(args) {
'denoise': 'denoising_strength',
'2ndpass': 'hr_second_pass_steps',
'faces': 'restore_faces',
'processing': 'minimal_prompt_processing',
};
const enumHandlers = {
'processing': (value) => {
if (/standard/gi.test(String(value))) {
return false;
}
if (/minimal/gi.test(String(value))) {
return true;
}
},
};
for (const [param, setting] of Object.entries(settingMap)) {
@ -4877,6 +5283,14 @@ function applyCommandArguments(args) {
}
currentSettings[setting] = extension_settings.sd[setting];
const value = String(args[param]);
const enumHandler = enumHandlers[param];
if (typeof enumHandler === 'function') {
const enumValue = enumHandler(value);
if (enumValue !== undefined) {
overrideSettings[setting] = enumValue;
}
continue;
}
const type = typeof defaultSettings[setting];
switch (type) {
case 'boolean':
@ -4998,6 +5412,17 @@ jQuery(async () => {
isRequired: false,
acceptsMultiple: false,
}),
SlashCommandNamedArgument.fromProps({
name: 'processing',
description: 'level of response prompt processing returned by the LLM',
typeList: [ARGUMENT_TYPE.STRING],
enumList: [
new SlashCommandEnumValue('standard', 'Standard prompt processing'),
new SlashCommandEnumValue('minimal', 'Minimal prompt processing'),
],
isRequired: false,
acceptsMultiple: false,
}),
SlashCommandNamedArgument.fromProps({
name: 'seed',
description: 'random seed',
@ -5243,6 +5668,8 @@ jQuery(async () => {
$('#sd_auto_validate').on('click', validateAutoUrl);
$('#sd_auto_url').on('input', onAutoUrlInput);
$('#sd_auto_auth').on('input', onAutoAuthInput);
$('#sd_sdcpp_validate').on('click', validateSdcppUrl);
$('#sd_sdcpp_url').on('input', onSdcppUrlInput);
$('#sd_drawthings_validate').on('click', validateDrawthingsUrl);
$('#sd_drawthings_url').on('input', onDrawthingsUrlInput);
$('#sd_drawthings_auth').on('input', onDrawthingsAuthInput);
@ -5268,9 +5695,11 @@ jQuery(async () => {
$('#sd_comfy_workflow').on('change', onComfyWorkflowChange);
$('#sd_comfy_open_workflow_editor').on('click', onComfyOpenWorkflowEditorClick);
$('#sd_comfy_new_workflow').on('click', onComfyNewWorkflowClick);
$('#sd_comfy_rename_workflow').on('click', onComfyRenameWorkflowClick);
$('#sd_comfy_delete_workflow').on('click', onComfyDeleteWorkflowClick);
$('#sd_style').on('change', onStyleSelect);
$('#sd_save_style').on('click', onSaveStyleClick);
$('#sd_rename_style').on('click', onRenameStyleClick);
$('#sd_delete_style').on('click', onDeleteStyleClick);
$('#sd_character_prompt_block').hide();
$('#sd_interactive_mode').on('input', onInteractiveModeInput);
@ -5279,6 +5708,7 @@ jQuery(async () => {
$('#sd_openai_duration').on('input', onOpenAiDurationSelect);
$('#sd_multimodal_captioning').on('input', onMultimodalCaptioningInput);
$('#sd_snap').on('input', onSnapInput);
$('#sd_minimal_prompt_processing').on('input', onMinimalPromptProcessing);
$('#sd_clip_skip').on('input', onClipSkipInput);
$('#sd_seed').on('input', onSeedInput);
$('#sd_character_prompt_share').on('input', onCharacterPromptShareInput);
@ -5309,6 +5739,10 @@ jQuery(async () => {
extension_settings.sd.electronhub_quality = String($(this).val());
saveSettingsDebounced();
});
$('#sd_openai_quality_gpt').on('input', function () {
extension_settings.sd.openai_quality_gpt = String($(this).val());
saveSettingsDebounced();
});
if (!CSS.supports('field-sizing', 'content')) {
$('.sd_settings .inline-drawer-toggle').on('click', function () {
@ -5337,15 +5771,19 @@ jQuery(async () => {
[event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
eventSource.on(event, async (/** @type {string} */ key) => {
switch (key) {
case SECRET_KEYS.BFL:
case SECRET_KEYS.FALAI:
case SECRET_KEYS.STABILITY:
case SECRET_KEYS.AIMLAPI:
case SECRET_KEYS.COMFY_RUNPOD:
await loadSettingOptions();
break;
const keySourceMap = {
[sources.bfl]: SECRET_KEYS.BFL,
[sources.falai]: SECRET_KEYS.FALAI,
[sources.stability]: SECRET_KEYS.STABILITY,
[sources.aimlapi]: SECRET_KEYS.AIMLAPI,
[sources.comfy]: SECRET_KEYS.COMFY_RUNPOD,
[sources.pollinations]: SECRET_KEYS.POLLINATIONS,
};
const shouldReloadOptions = Object.entries(keySourceMap).some(([k, v]) => k === extension_settings.sd.source && v === key);
if (!shouldReloadOptions) {
return;
}
await loadSettingOptions();
});
});

View file

@ -35,6 +35,10 @@
<input id="sd_snap" type="checkbox" />
<span data-i18n="sd_snap_txt">Snap auto-adjusted resolutions</span>
</label>
<label for="sd_minimal_prompt_processing" class="checkbox_label" data-i18n="[title]sd_minimal_prompt_processing" title="Reduce post-processing on a prompt generated by the LLM to preserve JSON and other structured output.">
<input id="sd_minimal_prompt_processing" type="checkbox" />
<span data-i18n="sd_minimal_prompt_processing_txt">Minimal response prompt processing</span>
</label>
<label for="sd_source" data-i18n="Source">Source</label>
<select id="sd_source">
<option value="aimlapi">AI/ML API</option>
@ -55,10 +59,11 @@
<option value="vlad">SD.Next (vladmandic)</option>
<option value="stability">Stability AI</option>
<option value="auto">Stable Diffusion Web UI (AUTOMATIC1111)</option>
<option value="sdcpp">stable-diffusion.cpp server</option>
<option value="horde">Stable Horde</option>
<option value="togetherai">TogetherAI</option>
<option value="xai">xAI (Grok)</option>
<option value="zai">Z.AI (CogView)</option>
<option value="zai">Z.AI</option>
</select>
<div data-sd-source="auto">
<label for="sd_auto_url">SD Web UI URL</label>
@ -76,6 +81,19 @@
<!-- (Original Text)<b>Important:</b> run SD Web UI with the <tt>--api</tt> flag! The server must be accessible from the SillyTavern host machine. -->
<i><b data-i18n="Important:">Important:</b></i><i data-i18n="sd_auto_auth_warning_1"> run SD Web UI with the </i><i><tt>--api</tt></i><i data-i18n="sd_auto_auth_warning_2"> flag! The server must be accessible from the SillyTavern host machine.</i>
</div>
<div data-sd-source="sdcpp">
<label for="sd_sdcpp_url">stable-diffusion.cpp URL</label>
<div class="flex-container flexnowrap">
<input id="sd_sdcpp_url" type="text" class="text_pole" placeholder="Example: {{sdcpp_url}}" value="{{sdcpp_url}}" />
<div id="sd_sdcpp_validate" class="menu_button menu_button_icon">
<i class="fa-solid fa-check"></i>
<span data-i18n="Connect">
Connect
</span>
</div>
</div>
<i data-i18n="The server must be accessible from the SillyTavern host machine.">The server must be accessible from the SillyTavern host machine.</i>
</div>
<div data-sd-source="drawthings">
<label for="sd_drawthings_url">DrawThings API URL</label>
<div class="flex-container flexnowrap">
@ -178,7 +196,16 @@
<option value="natural">Natural</option>
</select>
</div>
<div data-sd-model="dall-e-3,cogview-4-250304" class="flex1">
<div data-sd-model="gpt-image" class="flex1">
<label for="sd_openai_quality_gpt" data-i18n="Image Quality">Image Quality</label>
<select id="sd_openai_quality_gpt">
<option value="auto" data-i18n="Auto">Auto</option>
<option value="low" data-i18n="Low">Low</option>
<option value="medium" data-i18n="Medium">Medium</option>
<option value="high" data-i18n="High">High</option>
</select>
</div>
<div data-sd-model="dall-e-3,cogview-4,glm-image,cogvideox" class="flex1">
<label for="sd_openai_quality" data-i18n="Image Quality">Image Quality</label>
<select id="sd_openai_quality">
<option value="standard" data-i18n="Standard">Standard</option>
@ -248,15 +275,23 @@
<div id="sd_comfy_new_workflow" class="menu_button menu_button_icon" data-i18n="[title]Create new workflow" title="Create new workflow">
<i class="fa-solid fa-plus"></i>
</div>
<div id="sd_comfy_rename_workflow" class="menu_button menu_button_icon" data-i18n="[title]Rename workflow" title="Rename workflow">
<i class="fa-solid fa-pencil"></i>
</div>
<div id="sd_comfy_delete_workflow" class="menu_button menu_button_icon" data-i18n="[title]Delete workflow" title="Delete workflow">
<i class="fa-solid fa-trash-can"></i>
</div>
</div>
</div>
<div data-sd-source="pollinations">
<p>
<a href="https://pollinations.ai">Pollinations.ai</a>
</p>
<a href="https://enter.pollinations.ai">Pollinations.ai</a>
<div class="flex-container flexnowrap alignItemsBaseline marginBot5">
<strong class="flex1" data-i18n="API Key">API Key</strong>
<div id="sd_pollinations_key" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_pollinations">
<i class="fa-fw fa-solid fa-key"></i>
<span data-i18n="Click to set">Click to set</span>
</div>
</div>
<div class="flex-container">
<label class="flex1 checkbox_label" for="sd_pollinations_enhance" data-i18n="[title]Enables prompt enhancing (passes prompts through an LLM to add detail)." title="Enables prompt enhancing (passes prompts through an LLM to add detail).">
<input id="sd_pollinations_enhance" type="checkbox" />
@ -381,12 +416,12 @@
</div>
<div class="flex-container">
<div class="flex1" data-sd-source="extras,horde,auto,drawthings,novel,vlad,comfy">
<div class="flex1" data-sd-source="extras,horde,auto,drawthings,novel,vlad,comfy,sdcpp">
<label for="sd_sampler" data-i18n="Sampling method">Sampling method</label>
<select id="sd_sampler"></select>
</div>
<div class="flex1" data-sd-source="comfy,auto,novel">
<div class="flex1" data-sd-source="comfy,auto,novel,sdcpp">
<label for="sd_scheduler" data-i18n="Scheduler">Scheduler</label>
<select id="sd_scheduler"></select>
</div>
@ -469,7 +504,7 @@
<input class="neo-range-input" type="number" id="sd_hr_second_pass_steps_value" data-for="sd_hr_second_pass_steps" max="{{hr_second_pass_steps_max}}" step="{{hr_second_pass_steps_step}}" value="{{hr_second_pass_steps}}" >
</div>
<div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p" data-sd-source="auto,vlad,comfy,horde,drawthings,extras">
<div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p" data-sd-source="auto,vlad,comfy,horde,drawthings,extras,sdcpp">
<small>
<span data-i18n="CLIP Skip">CLIP Skip</span>
</small>
@ -523,7 +558,7 @@
</label>
</div>
<div data-sd-source="novel,togetherai,pollinations,comfy,drawthings,vlad,auto,horde,extras,stability,bfl" class="marginTop5">
<div data-sd-source="novel,togetherai,pollinations,comfy,drawthings,vlad,auto,horde,extras,stability,bfl,sdcpp" class="marginTop5">
<label for="sd_seed">
<span data-i18n="Seed">Seed</span>
<small data-i18n="(-1 for random)">(-1 for random)</small>
@ -540,6 +575,9 @@
<div id="sd_save_style" data-i18n="[title]Save style" title="Save style" class="menu_button">
<i class="fa-solid fa-save"></i>
</div>
<div id="sd_rename_style" data-i18n="[title]Rename style" title="Rename style" class="menu_button">
<i class="fa-solid fa-pencil"></i>
</div>
<div id="sd_delete_style" data-i18n="[title]Delete style" title="Delete style" class="menu_button">
<i class="fa-solid fa-trash-can"></i>
</div>

View file

@ -207,13 +207,13 @@ class CoquiTtsProvider {
this.settings.customVoices = {};
for (let voiceName in this.settings.voiceMapDict) {
const voiceId = this.settings.voiceMapDict[voiceName];
this.settings.customVoices[voiceName] = voiceId['model_id'];
this.settings.customVoices[voiceName] = voiceId.model_id;
if (voiceId['model_language'] != null)
this.settings.customVoices[voiceName] += '[' + voiceId['model_language'] + ']';
if (voiceId.model_language != null)
this.settings.customVoices[voiceName] += '[' + voiceId.model_language + ']';
if (voiceId['model_speaker'] != null)
this.settings.customVoices[voiceName] += '[' + voiceId['model_speaker'] + ']';
if (voiceId.model_speaker != null)
this.settings.customVoices[voiceName] += '[' + voiceId.model_speaker + ']';
}
// Update UI select list with voices
@ -493,8 +493,8 @@ class CoquiTtsProvider {
.append('<option value="none">Select language</option>')
.val('none');
for (let i = 0; i < model_settings['languages'].length; i++) {
const language_label = JSON.stringify(model_settings['languages'][i]).replaceAll('"', '');
for (let i = 0; i < model_settings.languages.length; i++) {
const language_label = JSON.stringify(model_settings.languages[i]).replaceAll('"', '');
$('#coqui_api_model_settings_language').append(new Option(language_label, i));
}
}
@ -512,8 +512,8 @@ class CoquiTtsProvider {
.append('<option value="none">Select speaker</option>')
.val('none');
for (let i = 0; i < model_settings['speakers'].length; i++) {
const speaker_label = JSON.stringify(model_settings['speakers'][i]).replaceAll('"', '');
for (let i = 0; i < model_settings.speakers.length; i++) {
const speaker_label = JSON.stringify(model_settings.speakers[i]).replaceAll('"', '');
$('#coqui_api_model_settings_speaker').append(new Option(speaker_label, i));
}
}
@ -525,11 +525,11 @@ class CoquiTtsProvider {
$('#coqui_api_model_install_status').show();
// Check if already installed and propose to do it otherwise
const model_id = modelDict[model_language][model_dataset][model_name]['id'];
const model_id = modelDict[model_language][model_dataset][model_name].id;
console.debug(DEBUG_PREFIX,'Check if model is already installed',model_id);
let result = await CoquiTtsProvider.checkmodel_state(model_id);
result = await result.json();
const model_state = result['model_state'];
const result = await CoquiTtsProvider.checkmodel_state(model_id);
const resultJSON = await result.json();
const model_state = resultJSON.model_state;
console.debug(DEBUG_PREFIX, ' Model state:', model_state);
@ -556,18 +556,18 @@ class CoquiTtsProvider {
$('#coqui_api_model_install_status').text('Downloading model...');
$('#coqui_api_model_install_button').hide();
//toastr.info("For model "+model_id, DEBUG_PREFIX+" Started "+action, { timeOut: 10000, extendedTimeOut: 20000, preventDuplicates: true });
let apiResult = await CoquiTtsProvider.installModel(model_id, action);
apiResult = await apiResult.json();
const apiResult = await CoquiTtsProvider.installModel(model_id, action);
const apiResultJSON = await apiResult.json();
console.debug(DEBUG_PREFIX, 'Response:', apiResult);
if (apiResult['status'] == 'done') {
if (apiResultJSON.status == 'done') {
$('#coqui_api_model_install_status').text('Model installed and ready to use!');
$('#coqui_api_model_install_button').hide();
onModelNameChange_pointer();
}
if (apiResult['status'] == 'downloading') {
if (apiResultJSON.status == 'downloading') {
toastr.error('Check extras console for progress', DEBUG_PREFIX + ' already downloading', { timeOut: 10000, extendedTimeOut: 20000, preventDuplicates: true });
$('#coqui_api_model_install_status').text('Already downloading a model, check extras console!');
$('#coqui_api_model_install_button').show();
@ -750,10 +750,10 @@ async function initLocalModels() {
// Initialized local model once
if (!coquiLocalModelsReceived) {
let result = await CoquiTtsProvider.getLocalModelList();
result = await result.json();
const result = await CoquiTtsProvider.getLocalModelList();
const resultJSON = await result.json();
coquiLocalModels = result['models_list'];
coquiLocalModels = resultJSON.models_list;
$('#coqui_local_model_name').show();
$('#coqui_local_model_name')

View file

@ -175,7 +175,7 @@ class CosyVoiceProvider {
};
if (streaming) {
params['streaming'] = 1;
params.streaming = 1;
}
const url = `${this.settings.provider_endpoint}/`;

View file

@ -126,16 +126,16 @@ class ElevenLabsTtsProvider {
this.settings = this.defaultSettings;
// Migrate old settings
if (settings['multilingual'] !== undefined) {
if (settings.multilingual !== undefined) {
settings.model = settings.multilingual ? 'eleven_multilingual_v1' : 'eleven_monolingual_v1';
delete settings['multilingual'];
delete settings.multilingual;
}
if (Object.hasOwn(settings, 'apiKey')) {
if (settings.apiKey && !secret_state[SECRET_KEYS.ELEVENLABS]){
await writeSecret(SECRET_KEYS.ELEVENLABS, settings.apiKey);
}
delete settings['apiKey'];
delete settings.apiKey;
}
$('#elevenlabs_tts_key').toggleClass('success', !!secret_state[SECRET_KEYS.ELEVENLABS]);

View file

@ -0,0 +1,233 @@
import { saveTtsProviderSettings } from './index.js';
import { getCharacters, getPreviewString } from './index.js';
export { GptSoVITSAdapterProvider };
/*
This file is adapted from gpt-sovits-v2.js. It was created because the original file is no longer maintained.
Some logic has been optimized and more functionality has been added.
*/
class GptSoVITSAdapterProvider {
settings;
ready = false;
voices = [];
separator = '. ';
audioElement = document.createElement('audio');
/*
do not modify the text, adapter will handle it
*/
processText(text) {
return text;
}
audioFormats = ['wav', 'ogg', 'silk', 'mp3', 'flac'];
langKey2LangCode = {
'zh': 'zh-CN',
'en': 'en-US',
'ja': 'ja-JP',
'ko': 'ko-KR',
};
defaultSettings = {
provider_endpoint: 'http://localhost:9881',
format: 'wav',
lang: 'auto',
streaming: false,
text_lang: 'zh',
media_type: 'auto',
};
textLangOptions = [
{ value: 'zh', label: 'Chinese' },
{ value: 'en', label: 'English' },
{ value: 'ja', label: 'Japanese' },
{ value: 'ko', label: 'Korean' },
];
mediaTypeOptions = [
{ value: 'auto', label: 'Auto' },
{ value: 'wav', label: 'WAV' },
{ value: 'mp3', label: 'MP3' },
{ value: 'ogg', label: 'OGG' },
{ value: 'silk', label: 'SILK' },
{ value: 'flac', label: 'FLAC' },
];
_generateOptions(options, currentSetting) {
return options.map(opt => {
const isSelected = opt.value === currentSetting ? 'selected' : '';
return `<option value="${opt.value}" ${isSelected}>${opt.label}</option>`;
}).join('');
}
get settingsHtml() {
const currentSettings = this.settings || this.defaultSettings;
let html = `
<label for="gpt_sovits_adapter_tts_endpoint">Provider Endpoint:</label>
<div class="flex1">
<input id="gpt_sovits_adapter_tts_endpoint" type="text" class="text_pole" maxlength="250" height="300" value="${this.defaultSettings.provider_endpoint}"/>
</div>
<span>Use <a target="_blank" href="https://github.com/guoql666/GPT-SoVITS_sillytavern_adapter">GPT-SoVITS-adapter</a>.</span><br/>
<label for="text_lang">Text Lang(Inference text language):</label>
<select id="text_lang" class="text_pole">
${this._generateOptions(this.textLangOptions, currentSettings.text_lang)}
</select>
<label for="media_type">Media Type:</label>
<select id="media_type" class="text_pole">
${this._generateOptions(this.mediaTypeOptions, currentSettings.media_type)}
</select>
<br/>
`;
return html;
}
onSettingsChange() {
// Used when provider settings are updated from UI
this.settings.provider_endpoint = $('#gpt_sovits_adapter_tts_endpoint').val();
this.settings.text_lang = $('#text_lang').val();
this.settings.media_type = $('#media_type').val();
saveTtsProviderSettings();
this.changeTTSSettings();
}
async loadSettings(settings) {
// Populate Provider UI given input settings
if (Object.keys(settings).length == 0) {
console.info('Using default TTS Provider settings');
}
// Only accept keys defined in defaultSettings
this.settings = this.defaultSettings;
for (const key in settings) {
if (key in this.settings) {
this.settings[key] = settings[key];
} else {
console.debug(`Ignoring non-user-configurable setting: ${key}`);
}
}
// Set initial values from the settings
$('#tts_endpoint').val(this.settings.provider_endpoint).on('change', this.onSettingsChange.bind(this));
$('#text_lang').val(this.settings.text_lang).on('change', this.onSettingsChange.bind(this));
$('#media_type').val(this.settings.media_type).on('change', this.onSettingsChange.bind(this));
await this.checkReady();
console.info('ITS: Settings loaded');
}
// Perform a simple readiness check by trying to fetch voiceIds
async checkReady() {
await Promise.allSettled([this.fetchTtsVoiceObjects(), this.changeTTSSettings()]);
}
async onRefreshClick() {
return await this.checkReady();
}
//#################//
// TTS Interfaces //
//#################//
async getVoice(voiceName) {
if (this.voices.length == 0) {
this.voices = await this.fetchTtsVoiceObjects();
}
const match = this.voices.filter(
v => v.name == voiceName,
)[0];
if (!match) {
throw `TTS Voice name ${voiceName} not found`;
}
return match;
}
async generateTts(text, voiceId) {
const response = await this.fetchTtsGeneration(text, voiceId);
return response;
}
//###########//
// API CALLS //
//###########//
async fetchTtsVoiceObjects() {
const response = await fetch(`${this.settings.provider_endpoint}/speakers`);
console.info(response);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.json()}`);
}
const responseJson = await response.json();
this.voices = responseJson;
return responseJson;
}
// Each time a parameter is changed, we change the configuration
async changeTTSSettings() {
}
/**
* Preview TTS voice by generating a short sample.
* @param {string} voiceId Voice ID to preview (model_type&speaker_id))
*/
async previewTtsVoice(voiceId) {
const langCode = this.langKey2LangCode[this.settings.text_lang] || 'zh-CN';
const previewText = getPreviewString(langCode);
const response = await this.fetchTtsGeneration(previewText, voiceId);
const audio = await response.blob();
const url = URL.createObjectURL(audio);
this.audioElement.src = url;
this.audioElement.play();
this.audioElement.onended = () => URL.revokeObjectURL(url);
}
/**
* Fetch TTS generation from the API.
* @param {string} inputText Text to generate TTS for
* @param {string} voiceId Voice ID to use (model_type&speaker_id))
* @returns {Promise<Response>} Fetch response
*/
async fetchTtsGeneration(inputText, voiceId, lang = null, forceNoStreaming = false) {
console.info(`Generating new TTS for voice_id ${voiceId}`);
const params = {
text: inputText,
card_name: getCharacters(false),
use_st_adapter: true,
target_voice: voiceId,
text_lang: this.settings.text_lang,
text_split_method: 'cut5',
batch_size: 1,
media_type: this.settings.media_type,
streaming_mode: 'true',
};
const url = `${this.settings.provider_endpoint}/`;
const response = await fetch(
url,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
},
);
if (!response.ok) {
toastr.error(response.statusText, 'TTS Generation Failed');
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
return response;
}
// Interface not used
async fetchTtsFromHistory(history_item_id) {
return Promise.resolve(history_item_id);
}
}

View file

@ -1,10 +1,11 @@
import { cancelTtsPlay, eventSource, event_types, getCurrentChatId, isStreamingEnabled, name2, saveSettingsDebounced, substituteParams } from '../../../script.js';
import { ModuleWorkerWrapper, extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js';
import { delay, escapeRegex, getBase64Async, getStringHash, onlyUnique } from '../../utils.js';
import { delay, escapeRegex, getBase64Async, getStringHash, onlyUnique, regexFromString } from '../../utils.js';
import { EdgeTtsProvider } from './edge.js';
import { ElevenLabsTtsProvider } from './elevenlabs.js';
import { SileroTtsProvider } from './silerotts.js';
import { GptSovitsV2Provider } from './gpt-sovits-v2.js';
import { GptSoVITSAdapterProvider } from './gpt-sovits-adapter.js';
import { CoquiTtsProvider } from './coqui.js';
import { SystemTtsProvider } from './system.js';
import { NovelTtsProvider } from './novel.js';
@ -35,6 +36,7 @@ import { PollinationsTtsProvider } from './pollinations.js';
import { MiniMaxTtsProvider } from './minimax.js';
import { ElectronHubTtsProvider } from './electronhub.js';
import { ChutesTtsProvider } from './chutes.js';
import { VolcengineTtsProvider } from './volcengine.js';
const UPDATE_INTERVAL = 1000;
const wrapper = new ModuleWorkerWrapper(moduleWorker);
@ -130,6 +132,7 @@ const ttsProviders = {
'Google Translate': GoogleTranslateTtsProvider,
'Google Gemini TTS': GoogleNativeTtsProvider,
GSVI: GSVITtsProvider,
'GPT-SoVITS-Adapter': GptSoVITSAdapterProvider,
'GPT-SoVITS-V2 (Unofficial)': GptSovitsV2Provider,
Kokoro: KokoroTtsProvider,
MiniMax: MiniMaxTtsProvider,
@ -144,6 +147,7 @@ const ttsProviders = {
'TTS WebUI': TtsWebuiProvider,
VITS: VITSTtsProvider,
XTTSv2: XTTSTtsProvider,
Volcengine: VolcengineTtsProvider,
};
let ttsProvider;
let ttsProviderName;
@ -282,7 +286,7 @@ function debugTtsPlayback() {
},
));
}
window['debugTtsPlayback'] = debugTtsPlayback;
globalThis.debugTtsPlayback = debugTtsPlayback;
//##################//
// Audio Control //
@ -319,8 +323,8 @@ async function playAudioData(audioJob) {
const srcUrl = await getBase64Async(audioBlob);
// VRM lip sync
if (extension_settings.vrm?.enabled && typeof window['vrmLipSync'] === 'function') {
await window['vrmLipSync'](audioBlob, char);
if (extension_settings.vrm?.enabled && typeof globalThis.vrmLipSync === 'function') {
await globalThis.vrmLipSync(audioBlob, char);
}
audioElement.src = srcUrl;
@ -337,7 +341,7 @@ async function playAudioData(audioJob) {
});
}
window['tts_preview'] = function (id) {
globalThis.tts_preview = function (id) {
const audio = document.getElementById(id);
if (audio instanceof HTMLAudioElement && !$(audio).data('disabled')) {
@ -472,8 +476,8 @@ function completeTtsJob() {
async function tts(text, voiceId, char, voiceMapKey = null) {
async function processResponse(response) {
// RVC injection
if (typeof window['rvcVoiceConversion'] === 'function' && extension_settings.rvc.enabled)
response = await window['rvcVoiceConversion'](response, char, text);
if (typeof globalThis.rvcVoiceConversion === 'function' && extension_settings.rvc.enabled)
response = await globalThis.rvcVoiceConversion(response, char, text);
await addAudioJob(response, char);
}
@ -638,6 +642,16 @@ async function processTtsQueue() {
: text.replaceAll('*', '').trim(); // remove just the asterisks
}
if (extension_settings.tts.apply_regex && extension_settings.tts.regex_pattern) {
const regex = regexFromString(extension_settings.tts.regex_pattern);
if (regex) {
// Clean up extra spaces that might be left after removal
text = text.replace(regex, '').replace(/\s+/g, ' ').trim();
} else {
console.warn('Invalid regex pattern:', extension_settings.tts.regex_pattern);
}
}
if (extension_settings.tts.narrate_quoted_only) {
const partJoiner = (ttsProvider?.separator || ' ... ');
text = joinQuotedBlocks(text, { separator: partJoiner, includeQuotes: true });
@ -794,7 +808,7 @@ async function playFullConversation() {
ttsJobQueue = chat;
}
window['playFullConversation'] = playFullConversation;
globalThis.playFullConversation = playFullConversation;
//#############################//
// Extension UI and Settings //
@ -825,6 +839,10 @@ function loadSettings() {
$('#tts_skip_codeblocks').prop('checked', extension_settings.tts.skip_codeblocks);
$('#tts_skip_tags').prop('checked', extension_settings.tts.skip_tags);
$('#tts_multi_voice_enabled').prop('checked', extension_settings.tts.multi_voice_enabled);
$('#tts_apply_regex').prop('checked', extension_settings.tts.apply_regex);
$('#tts_regex_pattern').val(extension_settings.tts.regex_pattern);
$('#tts_regex_block').toggle(extension_settings.tts.apply_regex);
updateRegexPatternWarning();
$('#playback_rate').val(extension_settings.tts.playback_rate);
$('#playback_rate_counter').val(Number(extension_settings.tts.playback_rate).toFixed(2));
$('#playback_rate_block').toggle(extension_settings.tts.currentProvider !== 'System');
@ -840,6 +858,8 @@ const defaultSettings = {
narrate_user: false,
playback_rate: 1,
multi_voice_enabled: false,
apply_regex: false,
regex_pattern: '',
};
function setTtsStatus(status, success) {
@ -940,6 +960,36 @@ function onMultiVoiceClick() {
initVoiceMap();
}
function onApplyRegexChange() {
extension_settings.tts.apply_regex = !!$('#tts_apply_regex').prop('checked');
saveSettingsDebounced();
$('#tts_regex_block').toggle(extension_settings.tts.apply_regex);
updateRegexPatternWarning();
}
function onRegexPatternChange() {
extension_settings.tts.regex_pattern = $('#tts_regex_pattern').val().toString();
saveSettingsDebounced();
updateRegexPatternWarning();
}
function updateRegexPatternWarning() {
const warning = $('#tts_regex_warning');
if (!extension_settings.tts.apply_regex) {
warning.hide();
return;
}
const pattern = extension_settings.tts.regex_pattern;
if (!pattern) {
warning.hide();
return;
}
const regex = regexFromString(pattern);
warning.toggle(!regex);
}
//##############//
// TTS Provider //
//##############//
@ -1191,7 +1241,7 @@ async function onPeriodicMessageGenerationTick() {
* @param {boolean} unrestricted - If true, will include all characters in voiceMapEntries, even if they are not in the current chat.
* @returns {string[]} - Array of character names
*/
function getCharacters(unrestricted) {
export function getCharacters(unrestricted) {
const context = getContext();
if (unrestricted) {
@ -1449,6 +1499,8 @@ jQuery(async function () {
$('#tts_narrate_by_paragraphs').on('click', onNarrateByParagraphsClick);
$('#tts_narrate_user').on('click', onNarrateUserClick);
$('#tts_multi_voice_enabled').on('click', onMultiVoiceClick);
$('#tts_apply_regex').on('change', onApplyRegexChange);
$('#tts_regex_pattern').on('input', onRegexPatternChange);
$('#playback_rate').on('input', function () {
const value = $(this).val();

View file

@ -67,6 +67,17 @@
Different voices for "quotes", *text inside asterisks* and other text
</small>
</label>
<label class="checkbox_label" for="tts_apply_regex" title="Applies the regex pattern to filter text before TTS.">
<input type="checkbox" id="tts_apply_regex">
<small data-i18n="Apply regex filter">Apply regex filter to text</small>
</label>
<div id="tts_regex_block" class="marginTop5">
<div class="range-block-title justifyLeft">
<small data-i18n="Regex Pattern">Regex Pattern (removes matching text)</small>
</div>
<input type="text" id="tts_regex_pattern" class="text_pole width100p" placeholder="Example: /[^a-zA-Z0-9\s.,!?;:'&quot;()]+/g" />
<small id="tts_regex_warning" class="warning" data-i18n="Invalid regex pattern.">Invalid regex pattern.</small>
</div>
</div>
<div id="playback_rate_block" class="range-block">
<hr>

View file

@ -0,0 +1,316 @@
import { event_types, eventSource, getRequestHeaders } from '../../../script.js';
import { SECRET_KEYS, secret_state } from '../../secrets.js';
import { saveTtsProviderSettings, initVoiceMap } from './index.js';
import { Popup } from '../../popup.js';
export { VolcengineTtsProvider };
class VolcengineTtsProvider {
static voices = [
{
name: 'zh_female_xiaohe_uranus_bigtts',
voice_id: 'zh_female_xiaohe_uranus_bigtts',
lang: 'cl',
},
{
name: 'zh_female_vv_uranus_bigtts',
voice_id: 'zh_female_vv_uranus_bigtts',
lang: 'cl',
},
{
name: 'saturn_zh_female_keainvsheng_tob',
voice_id: 'saturn_zh_female_keainvsheng_tob',
lang: 'cl',
},
{
name: 'saturn_zh_female_tiaopigongzhu_tob',
voice_id: 'saturn_zh_female_tiaopigongzhu_tob',
lang: 'cl',
},
{
name: 'saturn_zh_female_cancan_tob',
voice_id: 'saturn_zh_female_cancan_tob',
lang: 'cl',
},
{
name: 'saturn_zh_male_shuanglangshaonian_tob',
voice_id: 'saturn_zh_male_shuanglangshaonian_tob',
lang: 'cl',
},
{
name: 'saturn_zh_male_tiancaitongzhuo_tob',
voice_id: 'saturn_zh_male_tiancaitongzhuo_tob',
lang: 'cl',
},
{
name: 'zh_male_taocheng_uranus_bigtts',
voice_id: 'zh_male_taocheng_uranus_bigtts',
lang: 'cl',
},
];
settings;
audioElement = document.createElement('audio');
defaultSettings = {
voiceMap: {},
customVoices: [],
resource_id: '',
speed: 0,
provider_endpoint: 'https://openspeech.bytedance.com/api/v3/tts/unidirectional',
};
processText(text) {
return text.split('...').join('');
}
constructor() {
this.handler = async function (/** @type {string} */ key) {
if (![SECRET_KEYS.VOLCENGINE_APP_ID, SECRET_KEYS.VOLCENGINE_ACCESS_KEY].includes(key)) return;
$('#volcengine-tts-app-id').toggleClass('success', !!secret_state[SECRET_KEYS.VOLCENGINE_APP_ID]);
$('#volcengine-tts-access-key').toggleClass('success', !!secret_state[SECRET_KEYS.VOLCENGINE_ACCESS_KEY]);
await this.onRefreshClick();
}.bind(this);
}
dispose() {
[event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
eventSource.removeListener(event, this.handler);
});
}
async previewTtsVoice(voice) {
const text = 'Hello! Nice to meet you!';
const audio = await this.generateTts(text, voice);
const audioElement = new Audio(URL.createObjectURL(await audio.blob()));
audioElement.play().catch(e => console.error('Error playing audio:', e));
}
async fetchTtsVoiceObjects() {
return this.getAllVoices();
}
get settingsHtml() {
let html = `
<div>Volcengine (Doubao) TTS Configuration.</div>
<small>Hint: Volcengine (Doubao) TTS configuration items.</small>
<small>Please refer to the <a href="https://www.volcengine.com/docs/6561/1598757" target="_blank">documentation</a> to obtain the configuration items.</small>
<div class="flex-container alignItemsCenter">
<div id="volcengine-tts-app-id" class="menu_button menu_button_icon manage-api-keys" data-key="volcengine_app_id">
<i class="fa-solid fa-key"></i>
<span>App ID</span>
</div>
<div id="volcengine-tts-access-key" class="menu_button menu_button_icon manage-api-keys" data-key="volcengine_access_key">
<i class="fa-solid fa-key"></i>
<span>Access Key</span>
</div>
</div>
<div>
<label for="volcengine-tts-resource-id">Resource ID:</label>
<input type="text" class="text_pole" id="volcengine-tts-resource-id">
</div>
<label for="volcengine-tts-voice">Custom Voice (Speaker):</label>
<div class="tts_custom_voices">
<select id="volcengine-tts-voice-select">
</select>
<i title="Add" id="volcengine-tts-add-voice" class="tts-button fa-solid fa-plus fa-xl success" role="button"></i>
<i title="Delete" id="volcengine-tts-delete-voice" class="tts-button fa-solid fa-xmark fa-xl failure" tabindex="0" role="button"></i>
</div>
<div>
<label for="volcengine-tts-speed">Speed:</label>
<div class="flex-container">
<div class="range-block-range">
<input type="range" id="volcengine-tts-speed" min="-50" max="100" step="1">
</div>
<div class="range-block-counter">
<input type="number" min="-50" max="100" step="1" data-for="volcengine-tts-speed" id="volcengine-tts-speed_counter">
</div>
</div>
</div>
<div>
<label for="volcengine-tts-provider-endpoint">Provider Endpoint:</label>
<input type="text" class="text_pole" id="volcengine-tts-provider-endpoint">
</div>
`;
return html;
}
async getVoice(voiceName) {
const allVoices = this.getAllVoices();
return allVoices.find(voice => voice.name == voiceName);
}
getAllVoices() {
const voices = [...VolcengineTtsProvider.voices];
for (const customVoice of this.settings.customVoices) {
voices.push({
name: customVoice,
voice_id: customVoice,
lang: 'cl',
});
}
return voices;
}
populateVoices() {
const voiceSelect = $('#volcengine-tts-voice-select');
voiceSelect.empty();
for (const customVoice of this.settings.customVoices) {
const option = document.createElement('option');
option.value = customVoice;
option.textContent = customVoice;
voiceSelect.append(option);
}
}
async onRefreshClick() {
return await this.checkReady();
}
onSettingsChange() {
// Used when provider settings are updated from UI
this.settings.resource_id = $('#volcengine-tts-resource-id').val();
this.settings.speed = $('#volcengine-tts-speed').val();
this.settings.provider_endpoint = $('#volcengine-tts-provider-endpoint').val();
saveTtsProviderSettings();
this.changeTTSSettings();
}
async changeTTSSettings() {
const speed = this.settings.speed;
$('#volcengine-tts-speed').val(speed);
$('#volcengine-tts-speed_counter').val(speed);
}
async loadSettings(settings) {
// Populate Provider UI given input settings
if (Object.keys(settings).length == 0) {
console.info('Using default TTS Provider settings');
}
// Only accept keys defined in defaultSettings
this.settings = { ...this.defaultSettings };
for (const key in settings) {
if (key in this.settings) {
this.settings[key] = settings[key];
} else {
throw `Invalid setting passed to TTS Provider: ${key}`;
}
}
// Set initial values from the settings
$('#volcengine-tts-resource-id').val(this.settings.resource_id).on('change', this.onSettingsChange.bind(this));
$('#volcengine-tts-add-voice').on('click', this.createNewVoice.bind(this));
$('#volcengine-tts-delete-voice').on('click', this.deleteSelectedVoice.bind(this));
// Ensure custom configuration arrays exist
if (!this.settings.customVoices) this.settings.customVoices = [];
this.populateVoices();
// Speed control - range and number inputs
const speedInput = $('#volcengine-tts-speed');
const speedCounter = $('#volcengine-tts-speed_counter');
speedInput.val(this.settings.speed).on('input change', (e) => {
const value = $(e.target).val();
speedCounter.val(value);
this.settings.speed = value;
saveTtsProviderSettings();
this.changeTTSSettings();
});
speedCounter.val(this.settings.speed).on('input change', (e) => {
const value = $(e.target).val();
speedInput.val(value);
this.settings.speed = value;
saveTtsProviderSettings();
this.changeTTSSettings();
});
$('#volcengine-tts-provider-endpoint').val(this.settings.provider_endpoint).on('change', this.onSettingsChange.bind(this));
// Initialize secret keys UI
$('#volcengine-tts-app-id').toggleClass('success', !!secret_state[SECRET_KEYS.VOLCENGINE_APP_ID]);
$('#volcengine-tts-access-key').toggleClass('success', !!secret_state[SECRET_KEYS.VOLCENGINE_ACCESS_KEY]);
[event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
eventSource.on(event, this.handler);
});
await this.checkReady();
console.info('Volcengine TTS: Settings loaded');
}
async createNewVoice() {
const name = await Popup.show.input('Voice name: ', null);
if (!name) {
return;
}
if (this.settings.customVoices.includes(name)) {
toastr.error('Voice name should be unique.');
return;
}
this.settings.customVoices.push(name);
this.populateVoices();
initVoiceMap();
saveTtsProviderSettings();
}
async deleteSelectedVoice() {
const selectedVoiceName = $('#volcengine-tts-voice-select').val();
if (!selectedVoiceName) {
toastr.error('Please select a voice first.');
return;
}
const confirm = await Popup.show.confirm(`Are you sure you want to delete the selected voice ${selectedVoiceName}?`);
if (!confirm) {
return;
}
const voiceIndex = this.settings.customVoices.indexOf(selectedVoiceName);
if (voiceIndex !== -1) {
this.settings.customVoices.splice(voiceIndex, 1);
}
this.populateVoices();
initVoiceMap();
saveTtsProviderSettings();
}
async checkReady() {
await Promise.allSettled([this.changeTTSSettings()]);
}
async generateTts(text, speaker) {
const response = await this.fetchTtsGeneration(text, speaker);
return response;
}
async fetchTtsGeneration(text, voice_speaker) {
console.info(`Generating new TTS for voice_id ${voice_speaker}`);
const response = await fetch('/api/volcengine/generate-voice', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
'provider_endpoint': this.settings.provider_endpoint,
'resource_id': this.settings.resource_id,
'text': text,
'voice_speaker': voice_speaker,
'speed': this.settings.speed,
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error(`HTTP ${response.status}: ${errorText}`);
toastr.error(errorText);
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
return response;
}
}

View file

@ -1,8 +1,6 @@
import {
eventSource,
event_types,
extension_prompt_types,
extension_prompt_roles,
getCurrentChatId,
getRequestHeaders,
is_send_press,
@ -12,6 +10,7 @@ import {
generateRaw,
substituteParamsExtended,
} from '../../../script.js';
import { extension_prompt_roles, extension_prompt_types } from '../../extension-prompts.js';
import {
ModuleWorkerWrapper,
extension_settings,
@ -24,7 +23,7 @@ import {
import { collapseNewlines, registerDebugFunction } from '../../power-user.js';
import { SECRET_KEYS, secret_state } from '../../secrets.js';
import { getDataBankAttachments, getDataBankAttachmentsForSource, getFileAttachment } from '../../chats.js';
import { debounce, getStringHash as calculateHash, waitUntilCondition, onlyUnique, splitRecursive, trimToStartSentence, trimToEndSentence, escapeHtml } from '../../utils.js';
import { debounce, getStringHash as calculateHash, waitUntilCondition, onlyUnique, splitRecursive, trimToStartSentence, trimToEndSentence, escapeHtml, isTrueBoolean } from '../../utils.js';
import { debounce_timeout } from '../../constants.js';
import { getSortedEntries } from '../../world-info.js';
import { textgen_types, textgenerationwebui_settings } from '../../textgen-settings.js';
@ -32,6 +31,7 @@ import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';
import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
import { WebLlmVectorProvider } from './webllm.js';
@ -70,6 +70,7 @@ const settings = {
webllm_model: '',
google_model: 'text-embedding-005',
chutes_model: 'chutes-qwen-qwen3-embedding-8b',
nanogpt_model: 'text-embedding-3-small',
summarize: false,
summarize_sent: false,
summary_source: 'main',
@ -746,7 +747,7 @@ function overlapChunks(chunk, index, chunks, overlapSize) {
return overlappedChunk;
}
window['vectors_rearrangeChat'] = rearrangeChat;
globalThis.vectors_rearrangeChat = rearrangeChat;
const onChatEvent = debounce(async () => await moduleWorker.update(), debounce_timeout.relaxed);
@ -833,6 +834,9 @@ function getVectorsRequestBody(args = {}) {
case 'chutes':
body.model = extension_settings.vectors.chutes_model;
break;
case 'nanogpt':
body.model = extension_settings.vectors.nanogpt_model;
break;
default:
break;
}
@ -918,6 +922,7 @@ function throwIfSourceInvalid() {
if (settings.source === 'openai' && !secret_state[SECRET_KEYS.OPENAI] ||
settings.source === 'electronhub' && !secret_state[SECRET_KEYS.ELECTRONHUB] ||
settings.source === 'chutes' && !secret_state[SECRET_KEYS.CHUTES] ||
settings.source === 'nanogpt' && !secret_state[SECRET_KEYS.NANOGPT] ||
settings.source === 'openrouter' && !secret_state[SECRET_KEYS.OPENROUTER] ||
settings.source === 'palm' && !secret_state[SECRET_KEYS.MAKERSUITE] ||
settings.source === 'vertexai' && !secret_state[SECRET_KEYS.VERTEXAI] && !secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT] ||
@ -1133,6 +1138,7 @@ function toggleSettings() {
$('#openai_vectorsModel').toggle(settings.source === 'openai');
$('#electronhub_vectorsModel').toggle(settings.source === 'electronhub');
$('#chutes_vectorsModel').toggle(settings.source === 'chutes');
$('#nanogpt_vectorsModel').toggle(settings.source === 'nanogpt');
$('#openrouter_vectorsModel').toggle(settings.source === 'openrouter');
$('#cohere_vectorsModel').toggle(settings.source === 'cohere');
$('#ollama_vectorsModel').toggle(settings.source === 'ollama');
@ -1156,6 +1162,9 @@ function toggleSettings() {
case 'chutes':
loadChutesModels();
break;
case 'nanogpt':
loadNanoGPTModels();
break;
}
}
@ -1193,6 +1202,40 @@ function populateChutesModelSelect(models) {
$('#vectors_chutes_model').val(settings.chutes_model);
}
async function loadNanoGPTModels() {
try {
const response = await fetch('/api/openai/nanogpt/models/embedding', {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
/** @type {Array<any>} */
const data = await response.json();
const models = Array.isArray(data) ? data : [];
populateNanoGPTModelSelect(models);
} catch (err) {
console.warn('NanoGPT models fetch failed', err);
populateNanoGPTModelSelect([]);
}
}
function populateNanoGPTModelSelect(models) {
const select = $('#vectors_nanogpt_model');
select.empty();
for (const m of models) {
const option = document.createElement('option');
option.value = m.id;
option.text = m.name || m.id;
select.append(option);
}
if (!settings.nanogpt_model && models.length) {
settings.nanogpt_model = models[0].id;
}
$('#vectors_nanogpt_model').val(settings.nanogpt_model);
}
async function loadElectronHubModels() {
try {
const response = await fetch('/api/openai/electronhub/models', {
@ -1620,7 +1663,7 @@ jQuery(async () => {
}
// Migrate from old settings
if (settings['enabled']) {
if (settings.enabled) {
settings.enabled_chats = true;
}
@ -1678,6 +1721,11 @@ jQuery(async () => {
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
$('#vectors_nanogpt_model').val(settings.nanogpt_model).on('change', () => {
settings.nanogpt_model = String($('#vectors_nanogpt_model').val());
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
$('#vectors_openrouter_model').val(settings.openrouter_model).on('change', () => {
settings.openrouter_model = String($('#vectors_openrouter_model').val());
Object.assign(extension_settings.vectors, settings);
@ -2036,6 +2084,174 @@ jQuery(async () => {
returns: ARGUMENT_TYPE.LIST,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'vector-threshold',
helpString: 'Set the vector score threshold or return the current threshold if no argument is provided.',
returns: 'score threshold value',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Score threshold (number).',
typeList: [ARGUMENT_TYPE.NUMBER],
}),
],
callback: async (_args, value) => {
const raw = String(value ?? '').trim();
if (!raw) {
return String(settings.score_threshold);
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
toastr.warning('Score threshold must be a number between 0 and 1.');
return '';
}
$('#vectors_score_threshold')
.val(parsed)
.trigger('input');
return String(settings.score_threshold);
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'vector-query',
helpString: 'Set the vector query messages or returns the current query messages count if no argument is provided',
returns: 'the query messages value',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Query messages (number > 0).',
typeList: [ARGUMENT_TYPE.NUMBER],
}),
],
callback: async (_args, value) => {
const raw = String(value ?? '').trim();
if (!raw) {
return String(settings.query);
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
toastr.warning('Query messages must be a number greater than 0.');
return '';
}
$('#vectors_query')
.val(parsed)
.trigger('input');
return String(settings.query);
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'vector-max-entries',
helpString: 'Set the vector world info max entries or returns the current max entries if no argument is provided',
returns: 'world info max entries',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Max entries (number > 0).',
typeList: [ARGUMENT_TYPE.NUMBER],
}),
],
callback: async (_args, value) => {
const raw = String(value ?? '').trim();
if (!raw) {
return String(settings.max_entries);
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
toastr.warning('Max entries must be a number greater than 0.');
return '';
}
$('#vectors_max_entries')
.val(parsed)
.trigger('input');
return String(settings.max_entries);
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'vector-chats-state',
helpString: 'Set whether chat vectorization is enabled or return the current boolean if no argument is provided',
returns: 'boolean for if chat vectorization is enabled',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'boolean to set whether chat vectorization is enabled',
typeList: [ARGUMENT_TYPE.BOOLEAN],
enumList: commonEnumProviders.boolean('trueFalse')(),
}),
],
callback: async (_args, value) => {
const raw = String(value ?? '').trim();
if (!raw) {
return String(settings.enabled_chats);
}
const parsed = isTrueBoolean(raw);
$('#vectors_enabled_chats')
.prop('checked', parsed)
.trigger('input');
return String(settings.enabled_chats);
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'vector-files-state',
helpString: 'Set whether file vectorization is enabled or return the current boolean if no argument is provided',
returns: 'boolean for if file vectorization is enabled',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'boolean to set whether file vectorization is enabled',
typeList: [ARGUMENT_TYPE.BOOLEAN],
enumList: commonEnumProviders.boolean('trueFalse')(),
}),
],
callback: async (_args, value) => {
const raw = String(value ?? '').trim();
if (!raw) {
return String(settings.enabled_files);
}
const parsed = isTrueBoolean(raw) ;
$('#vectors_enabled_files')
.prop('checked', parsed)
.trigger('input');
return String(settings.enabled_files);
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'vector-worldinfo-state',
helpString: 'Set whether world info vectorization is enabled or return the current boolean if no argument is provided',
returns: 'boolean for if world info vectorization is enabled',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'boolean to set whether world info vectorization is enabled',
typeList: [ARGUMENT_TYPE.BOOLEAN],
enumList: commonEnumProviders.boolean('trueFalse')(),
}),
],
callback: async (_args, value) => {
const raw = String(value ?? '').trim();
if (!raw) {
return String(settings.enabled_world_info);
}
const parsed = isTrueBoolean(raw);
$('#vectors_enabled_world_info')
.prop('checked', parsed)
.trigger('input');
return String(settings.enabled_world_info);
},
}));
registerDebugFunction('purge-everything', 'Purge all vector indices', 'Obliterate all stored vectors for all sources. No mercy.', async () => {
if (!confirm('Are you sure?')) {
return;

View file

@ -20,6 +20,7 @@
<option value="llamacpp">llama.cpp</option>
<option value="transformers" data-i18n="Local (Transformers)">Local (Transformers)</option>
<option value="mistral">MistralAI</option>
<option value="nanogpt">NanoGPT</option>
<option value="nomicai">NomicAI</option>
<option value="ollama">Ollama</option>
<option value="openai">OpenAI</option>
@ -38,6 +39,15 @@
Hint: Set your Chutes API key in API Connections.
</i>
</div>
<div class="flex-container flexFlowColumn" id="nanogpt_vectorsModel">
<label for="vectors_nanogpt_model" data-i18n="Vectorization Model">
Vectorization Model
</label>
<select id="vectors_nanogpt_model" class="text_pole"></select>
<i data-i18n="Hint: Set your NanoGPT API key in API Connections.">
Hint: Set your NanoGPT API key in API Connections.
</i>
</div>
<div class="flex-container flexFlowColumn" id="electronhub_vectorsModel">
<label for="vectors_electronhub_model" data-i18n="Vectorization Model">
Vectorization Model

View file

@ -18,7 +18,6 @@ import {
paginationDropdownChangeHandler,
waitUntilCondition,
uuidv4,
humanFileSize,
} from './utils.js';
import { RA_CountCharTokens, humanizedDateTime, dragElement, favsToHotswap, getMessageTimeStamp } from './RossAscends-mods.js';
import { power_user, loadMovingUIState, sortEntitiesList } from './power-user.js';
@ -81,7 +80,7 @@ import {
chatElement,
ensureMessageMediaIsArray,
} from '../script.js';
import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js';
import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect, printTagFilters, tag_filter_type } from './tags.js';
import { FILTER_TYPES, FilterHelper } from './filters.js';
import { isExternalMediaAllowed } from './chats.js';
import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
@ -140,6 +139,7 @@ export const group_generation_mode = {
export const DEFAULT_AUTO_MODE_DELAY = 5;
export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, debounce_timeout.quick));
export const groupMembersFilter = new FilterHelper(debounce(printGroupMembers, debounce_timeout.quick));
let autoModeWorker = null;
const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), debounce_timeout.relaxed);
/** @type {Map<string, number>} */
@ -297,8 +297,8 @@ export async function getGroupChat(groupId, reload = false) {
}
// Add integrity slug if missing
if (!metadata['integrity']) {
metadata['integrity'] = uuidv4();
if (!metadata.integrity) {
metadata.integrity = uuidv4();
}
await loadItemizedPrompts(getCurrentChatId());
@ -582,8 +582,8 @@ export function getGroupCharacterCardsLazy(groupId, characterId) {
return values.filter(x => x.length).join('\n');
}
const scenarioOverride = String(chat_metadata['scenario'] || '');
const mesExamplesOverride = String(chat_metadata['mes_example'] || '');
const scenarioOverride = String(chat_metadata.scenario || '');
const mesExamplesOverride = String(chat_metadata.mes_example || '');
return createLazyFields({
description: () => collectField('Description', c => c.description),
@ -616,16 +616,16 @@ async function getFirstCharacterMessage(character) {
}
const mes = {};
mes['is_user'] = false;
mes['is_system'] = false;
mes['name'] = character.name;
mes['send_date'] = getMessageTimeStamp();
mes['original_avatar'] = character.avatar;
mes['extra'] = { 'gen_id': Date.now() * Math.random() * 1000000 };
mes['mes'] = messageText
mes.is_user = false;
mes.is_system = false;
mes.name = character.name;
mes.send_date = getMessageTimeStamp();
mes.original_avatar = character.avatar;
mes.extra = { 'gen_id': Date.now() * Math.random() * 1000000 };
mes.mes = messageText
? substituteParams(messageText.trim(), { name2Override: character.name })
: '';
mes['force_avatar'] =
mes.force_avatar =
character.avatar != 'none'
? getThumbnailUrl('avatar', character.avatar)
: default_avatar;
@ -651,7 +651,7 @@ async function saveGroupChat(groupId, shouldSaveGroup, force = false) {
return;
}
const chatId = group.chat_id;
group['date_last_chat'] = Date.now();
group.date_last_chat = Date.now();
/** @type {ChatHeader} */
const chatHeader = {
chat_metadata: { ...chat_metadata },
@ -911,7 +911,7 @@ function updateGroupAvatar(group) {
*/
function isValidImageUrl(url) {
// check if empty dict
if (Object.keys(url).length === 0) {
if (!url || Object.keys(url).length === 0) {
return false;
}
return isDataURL(url) || (url && (url.startsWith('user') || url.startsWith('/user')));
@ -1504,6 +1504,10 @@ async function modifyGroupMember(groupId, groupMember, isDelete) {
printGroupCandidates();
printGroupMembers();
// Refresh the tag filters for both lists to reflect any new tags
printTagFilters(tag_filter_type.group_candidates_list);
printTagFilters(tag_filter_type.group_members_list);
const groupHasMembers = getGroupCharacters({ doFilter: false, onlyMembers: true }).length > 0;
$('#rm_group_submit').prop('disabled', !groupHasMembers);
}
@ -1612,31 +1616,63 @@ function isGroupMember(group, avatarId) {
* @returns {Array<{item: Character, id: number, type: string}>} Array of group character objects
*/
function getGroupCharacters({ doFilter = false, onlyMembers = false } = {}) {
function sortMembersFn(a, b) {
function applyFilterAndSort(results, filter, filterSelector) {
let filtered = results;
if (doFilter) {
filtered = filter.applyFilters(filtered);
}
const useFilterOrder = doFilter && !!$(filterSelector).val();
sortEntitiesList(filtered, useFilterOrder, filter);
filter.clearFuzzySearchCaches();
return filtered;
}
function handleMembers(results, thisGroup) {
const membersArray = thisGroup?.members ?? newGroupMembers;
const aIndex = membersArray.indexOf(a.item.avatar);
const bIndex = membersArray.indexOf(b.item.avatar);
return aIndex - bIndex;
// Create index map for O(1) lookups in member sort function
// (separate from characterIndexMap which maps character objects to their array indices)
const memberIndexMap = new Map(membersArray.map((avatar, index) => [avatar, index]));
function sortMembersFn(a, b) {
const aIndex = memberIndexMap.get(a.item.avatar) ?? -1;
const bIndex = memberIndexMap.get(b.item.avatar) ?? -1;
return aIndex - bIndex;
}
// Apply manual member sort before filter and sort
let filtered = results;
if (doFilter) {
filtered = groupMembersFilter.applyFilters(filtered);
}
filtered.sort(sortMembersFn);
// Apply conditional filter-based sort and cleanup
const useFilterOrder = doFilter && !!$('#rm_group_members_filter').val();
if (useFilterOrder) {
sortEntitiesList(filtered, useFilterOrder, groupMembersFilter);
}
groupMembersFilter.clearFuzzySearchCaches();
return filtered;
}
const thisGroup = openGroupId && groups.find((x) => x.id == openGroupId);
let candidates = characters
// Create index map for O(1) lookups when mapping characters to their array indices
// (separate from memberIndexMap used later for sorting members by their group order)
const characterIndexMap = new Map(characters.map((char, index) => [char, index]));
const results = characters
.filter((x) => isGroupMember(thisGroup, x.avatar) == onlyMembers)
.map((x) => ({ item: x, id: characters.indexOf(x), type: 'character' }));
.map((x) => ({ item: x, id: characterIndexMap.get(x), type: 'character' }));
if (doFilter) {
candidates = groupCandidatesFilter.applyFilters(candidates);
// Early return for candidates (non-members)
if (!onlyMembers) {
return applyFilterAndSort(results, groupCandidatesFilter, '#rm_group_filter');
}
if (onlyMembers) {
candidates.sort(sortMembersFn);
} else {
const useFilterOrder = doFilter && !!$('#rm_group_filter').val();
sortEntitiesList(candidates, useFilterOrder, groupCandidatesFilter);
}
groupCandidatesFilter.clearFuzzySearchCaches();
return candidates;
// Handle members with manual sort capability
return handleMembers(results, thisGroup);
}
function printGroupCandidates() {
@ -1676,7 +1712,7 @@ function printGroupMembers() {
const pageSize = Number(accountStorage.getItem(storageKey)) || 5;
const sizeChangerOptions = [5, 10, 25, 50, 100, 200, 500, 1000];
$(this).pagination({
dataSource: getGroupCharacters({ doFilter: false, onlyMembers: true }),
dataSource: getGroupCharacters({ doFilter: true, onlyMembers: true }),
pageRange: 1,
position: 'top',
showPageNumbers: false,
@ -1837,6 +1873,7 @@ function select_group_chats(groupId, skipAnimation) {
$('#group_avatar_preview').empty().append(getGroupAvatar(group));
$('#rm_group_restore_avatar').toggle(!!group && isValidImageUrl(group.avatar_url));
$('#rm_group_filter').val('').trigger('input');
$('#rm_group_members_filter').val('').trigger('input');
$('#rm_group_activation_strategy').val(replyStrategy);
$(`#rm_group_activation_strategy option[value="${replyStrategy}"]`).prop('selected', true);
$('#rm_group_generation_mode').val(generationMode);
@ -2056,14 +2093,14 @@ export async function openGroupById(groupId) {
if (selected_group !== groupId) {
groupChatQueueOrder = new Map();
await clearChat();
cancelTtsPlay();
selected_group = groupId;
setCharacterId(undefined);
setCharacterName('');
resetSelectedGroup();
await clearChat({ clearData: true });
cancelTtsPlay();
selected_group = groupId;
setEditedMessageId(undefined);
updateChatMetadata({}, true);
chat.length = 0;
await getGroupChat(groupId);
return true;
}
@ -2104,6 +2141,11 @@ function filterGroupMembers() {
groupCandidatesFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue);
}
function filterGroupMemberList() {
const searchValue = String($(this).val()).toLowerCase();
groupMembersFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue);
}
async function createGroup() {
let name = $('#rm_group_chat_name').val().toString();
let allowSelfResponses = !!$('#rm_group_allow_self_responses').prop('checked');
@ -2164,8 +2206,7 @@ export async function createNewGroupChat(groupId) {
return;
}
await clearChat();
chat.length = 0;
await clearChat({ clearData: true });
const newChatName = humanizedDateTime();
group.chats.push(newChatName);
group.chat_id = newChatName;
@ -2178,7 +2219,7 @@ export async function createNewGroupChat(groupId) {
/**
* Retrieves past chats for a specified group.
* @param {string} groupId Group ID
* @returns {Promise<Array>} Array of past chats
* @returns {Promise<Array<import('../../src/endpoints/chats.js').ChatInfo>>} Array of past chats
*/
export async function getGroupPastChats(groupId) {
const group = groups.find(x => x.id === groupId);
@ -2191,24 +2232,15 @@ export async function getGroupPastChats(groupId) {
try {
for (const chatId of group.chats) {
const messages = await loadGroupChat(chatId);
if (!Array.isArray(messages)) {
continue;
}
const fileSize = humanFileSize(JSON.stringify(messages).length);
if (messages.length > 0 && Object.hasOwn(messages[0], 'chat_metadata')) {
messages.shift();
}
const chatItems = messages.length;
const lastMessage = messages.length ? messages[messages.length - 1].mes : '[The chat is empty]';
const lastMessageDate = messages.length ? (messages[messages.length - 1].send_date || Date.now()) : Date.now();
chats.push({
'file_name': chatId,
'mes': lastMessage,
'last_mes': lastMessageDate,
'file_size': fileSize,
'chat_items': chatItems,
const response = await fetch('/api/chats/group/info', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ id: chatId }),
});
if (response.ok) {
const data = await response.json();
chats.push(data);
}
}
} catch (err) {
console.error(err);
@ -2230,10 +2262,9 @@ export async function openGroupChat(groupId, chatId) {
return;
}
await clearChat();
chat.length = 0;
await clearChat({ clearData: true });
group.chat_id = chatId;
group['date_last_chat'] = Date.now();
group.date_last_chat = Date.now();
updateChatMetadata({}, true);
await editGroup(groupId, true, false);
@ -2499,6 +2530,7 @@ jQuery(() => {
openGroupById(groupId);
});
$('#rm_group_filter').on('input', filterGroupMembers);
$('#rm_group_members_filter').on('input', filterGroupMemberList);
$('#rm_group_submit').on('click', createGroup);
$('#rm_group_scenario').on('click', setCharacterSettingsOverrides);
$('#rm_group_automode').on('input', function () {

View file

@ -205,11 +205,11 @@ export async function generateHorde(prompt, params, signal, reportProgress) {
delete params.prompt;
// No idea what these do
params['n'] = 1;
params['frmtadsnsp'] = false;
params['frmtrmblln'] = false;
params['frmtrmspch'] = false;
params['frmttriminc'] = false;
params.n = 1;
params.frmtadsnsp = false;
params.frmtrmblln = false;
params.frmtrmspch = false;
params.frmttriminc = false;
const payload = {
'prompt': prompt,

View file

@ -1,6 +1,3 @@
import { registerDebugFunction } from './power-user.js';
import { updateSecretDisplay } from './secrets.js';
const storageKey = 'language';
const overrideLanguage = localStorage.getItem(storageKey);
const localeFile = String(overrideLanguage || navigator.language || navigator.userLanguage || 'en').toLowerCase();
@ -14,6 +11,30 @@ let trackMissingDynamicTranslate = null;
export const getCurrentLocale = () => localeFile;
async function updateSecretDisplayAfterLocaleApplied() {
try {
const module = await import('./secrets.js');
if (typeof module?.updateSecretDisplay === 'function') {
module.updateSecretDisplay();
}
} catch (error) {
console.warn('Failed to update secret display after locale init:', error);
}
}
async function loadRegisterDebugFunction() {
try {
const module = await import('./power-user.js');
if (typeof module?.registerDebugFunction === 'function') {
return module.registerDebugFunction;
}
} catch (error) {
console.warn('Failed to load debug function registry:', error);
}
return null;
}
/**
* Adds additional localization data to the current locale file.
* @param {string} localeId Locale ID (e.g. 'fr-fr' or 'zh-cn')
@ -258,8 +279,8 @@ function addLanguagesToDropdown() {
const uiLanguageSelects = $('#ui_language_select, #onboarding_ui_language_select');
for (const langObj of langs) { // Set the value to the language code
const option = document.createElement('option');
option.value = langObj['lang']; // Set the value to the language code
option.innerText = langObj['display']; // Set the display text to the language name
option.value = langObj.lang; // Set the value to the language code
option.innerText = langObj.display; // Set the display text to the language name
uiLanguageSelects.append(option);
}
@ -272,9 +293,10 @@ function addLanguagesToDropdown() {
export async function initLocales() {
langs = await fetch('/locales/lang.json').then(response => response.json());
localeData = await getLocaleData(localeFile);
document.documentElement.lang = localeFile;
applyLocale();
addLanguagesToDropdown();
updateSecretDisplay();
await updateSecretDisplayAfterLocaleApplied();
$('#ui_language_select, #onboarding_ui_language_select').on('change', async function () {
const language = String($(this).val());
@ -299,27 +321,30 @@ export async function initLocales() {
trackMissingDynamicTranslate = new Set();
}
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('trackDynamicTranslate', 'Track dynamic translation',
'Toggles tracking of dynamic translations, which will be dumped into the missing translations translations too. ' +
'This includes things translated via the t`...` function and translate(). It will only track strings translated <b>after</b> this is toggled on, '
+ 'and when they actually pop up, so refreshing the page and opening popups, etc, is needed. Will only track if the current locale is not English.',
() => {
const isTracking = localStorage.getItem('trackDynamicTranslate') !== 'true';
localStorage.setItem('trackDynamicTranslate', isTracking ? 'true' : 'false');
if (isTracking && isSupportedNonEnglish()) {
trackMissingDynamicTranslate = new Set();
toastr.success('Dynamic translation tracking enabled.');
} else if (isTracking) {
trackMissingDynamicTranslate = null;
toastr.warning('Dynamic translation tracking enabled, but will not be tracked with locale English.');
} else {
trackMissingDynamicTranslate = null;
toastr.info('Dynamic translation tracking disabled.');
}
});
registerDebugFunction('applyLocale', 'Apply locale', 'Reapplies the currently selected locale to the page.', applyLocale);
const registerDebugFunction = await loadRegisterDebugFunction();
if (typeof registerDebugFunction === 'function') {
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('trackDynamicTranslate', 'Track dynamic translation',
'Toggles tracking of dynamic translations, which will be dumped into the missing translations translations too. ' +
'This includes things translated via the t`...` function and translate(). It will only track strings translated <b>after</b> this is toggled on, '
+ 'and when they actually pop up, so refreshing the page and opening popups, etc, is needed. Will only track if the current locale is not English.',
() => {
const isTracking = localStorage.getItem('trackDynamicTranslate') !== 'true';
localStorage.setItem('trackDynamicTranslate', isTracking ? 'true' : 'false');
if (isTracking && isSupportedNonEnglish()) {
trackMissingDynamicTranslate = new Set();
toastr.success('Dynamic translation tracking enabled.');
} else if (isTracking) {
trackMissingDynamicTranslate = null;
toastr.warning('Dynamic translation tracking enabled, but will not be tracked with locale English.');
} else {
trackMissingDynamicTranslate = null;
toastr.info('Dynamic translation tracking disabled.');
}
});
registerDebugFunction('applyLocale', 'Apply locale', 'Reapplies the currently selected locale to the page.', applyLocale);
}
}

View file

@ -1,8 +1,9 @@
'use strict';
import { extension_prompt_types, name1, name2, online_status, saveSettingsDebounced, substituteParams } from '../script.js';
import { name1, name2, online_status, saveSettingsDebounced, substituteParams } from '../script.js';
import { selected_group } from './group-chats.js';
import { parseExampleIntoIndividual } from './openai.js';
import { extension_prompt_types } from './extension-prompts.js';
import {
power_user,
context_presets,
@ -10,7 +11,7 @@ import {
import { onlyUnique, regexFromString, resetScrollHeight } from './utils.js';
/**
* @type {any[]} Instruct mode presets.
* @type {InstructSettings[]} Instruct mode presets.
*/
export let instruct_presets = [];
@ -161,7 +162,7 @@ export async function loadInstructMode(data) {
*/
export function updateBindModelTemplatesState() {
const bindModelTemplates = power_user.model_templates_mappings[online_status] ?? power_user.model_templates_mappings[power_user.chat_template_hash];
const bindingsMatch = (bindModelTemplates && power_user.context.preset === bindModelTemplates['context'] && (!power_user.instruct.enabled || power_user.instruct.preset === bindModelTemplates['instruct'])) ?? false;
const bindingsMatch = (bindModelTemplates && power_user.context.preset === bindModelTemplates.context && (!power_user.instruct.enabled || power_user.instruct.preset === bindModelTemplates.instruct)) ?? false;
const currentState = $('#bind_model_templates').prop('checked');
if (bindingsMatch === currentState) {
// No change needed

View file

@ -221,10 +221,8 @@ export async function itemizedParams(itemizedPrompts, thisPromptSet, incomingMes
}
export function findItemizedPromptSet(itemizedPrompts, incomingMesId) {
PromptArrayItemForRawPromptDisplay = null;
priorPromptArrayItemForRawPromptDisplay = null;
let thisPromptSet = undefined;
priorPromptArrayItemForRawPromptDisplay = -1;
for (let i = 0; i < itemizedPrompts.length; i++) {
console.log(`looking for ${incomingMesId} vs ${itemizedPrompts[i].mesId}`);
@ -261,90 +259,70 @@ export async function promptItemize(itemizedPrompts, requestedMesId) {
? await renderTemplateAsync('itemizationChat', params)
: await renderTemplateAsync('itemizationText', params);
if (typeof template !== 'string' || !template.trim()) {
console.warn('Failed to render itemization template.');
return null;
}
const popup = new Popup(template, POPUP_TYPE.TEXT);
/** @type {HTMLElement | null} */
/** @type {HTMLElement} */
const diffPrevPrompt = popup.dlg.querySelector('#diffPrevPrompt');
if (diffPrevPrompt) {
if (typeof priorPromptArrayItemForRawPromptDisplay === 'number') {
diffPrevPrompt.style.display = '';
diffPrevPrompt.addEventListener('click', function () {
const dmp = new DiffMatchPatch();
const text1 = flatten(itemizedPrompts[priorPromptArrayItemForRawPromptDisplay].rawPrompt);
const text2 = flatten(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt);
if (priorPromptArrayItemForRawPromptDisplay >= 0) {
diffPrevPrompt.style.display = '';
diffPrevPrompt.addEventListener('click', function () {
const dmp = new DiffMatchPatch();
const text1 = flatten(itemizedPrompts[priorPromptArrayItemForRawPromptDisplay].rawPrompt);
const text2 = flatten(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt);
dmp.Diff_Timeout = 2.0;
dmp.Diff_Timeout = 2.0;
const d = dmp.diff_main(text1, text2);
let ds = dmp.diff_prettyHtml(d);
// make it readable
ds = ds.replaceAll('background:#e6ffe6;', 'background:#b9f3b9; color:black;');
ds = ds.replaceAll('background:#ffe6e6;', 'background:#f5b4b4; color:black;');
ds = ds.replaceAll('&para;', '');
const container = document.createElement('div');
container.innerHTML = DOMPurify.sanitize(ds);
const rawPromptWrapper = document.getElementById('rawPromptWrapper');
if (!rawPromptWrapper) {
return;
}
rawPromptWrapper.replaceChildren(container);
$('#rawPromptPopup').slideToggle();
});
} else {
diffPrevPrompt.style.display = 'none';
}
}
const copyPromptToClipboard = popup.dlg.querySelector('#copyPromptToClipboard');
if (copyPromptToClipboard) {
copyPromptToClipboard.addEventListener('pointerup', async function () {
let rawPrompt = itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt;
let rawPromptValues = rawPrompt;
if (Array.isArray(rawPrompt)) {
rawPromptValues = rawPrompt.map(x => x.content).join('\n');
}
await copyText(rawPromptValues);
toastr.info(t`Copied!`);
});
}
const showRawPrompt = popup.dlg.querySelector('#showRawPrompt');
if (showRawPrompt) {
showRawPrompt.addEventListener('click', async function () {
//console.log(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt);
console.log(PromptArrayItemForRawPromptDisplay);
console.log(itemizedPrompts);
console.log(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt);
const rawPrompt = flatten(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt);
// Mobile needs special handholding. The side-view on the popup wouldn't work,
// so we just show an additional popup for this.
if (isMobile()) {
const content = document.createElement('div');
content.classList.add('tokenItemizingMaintext');
content.innerText = rawPrompt;
const popup = new Popup(content, POPUP_TYPE.TEXT, null, { allowVerticalScrolling: true, leftAlign: true });
await popup.show();
return;
}
//let DisplayStringifiedPrompt = JSON.stringify(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt).replace(/\n+/g, '<br>');
const d = dmp.diff_main(text1, text2);
let ds = dmp.diff_prettyHtml(d);
// make it readable
ds = ds.replaceAll('background:#e6ffe6;', 'background:#b9f3b9; color:black;');
ds = ds.replaceAll('background:#ffe6e6;', 'background:#f5b4b4; color:black;');
ds = ds.replaceAll('&para;', '');
const container = document.createElement('div');
container.innerHTML = DOMPurify.sanitize(ds);
const rawPromptWrapper = document.getElementById('rawPromptWrapper');
if (!rawPromptWrapper) {
return;
}
rawPromptWrapper.innerText = rawPrompt;
rawPromptWrapper.replaceChildren(container);
$('#rawPromptPopup').slideToggle();
});
} else {
diffPrevPrompt.style.display = 'none';
}
popup.dlg.querySelector('#copyPromptToClipboard').addEventListener('pointerup', async function () {
let rawPrompt = itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt;
let rawPromptValues = rawPrompt;
if (Array.isArray(rawPrompt)) {
rawPromptValues = rawPrompt.map(x => x.content).join('\n');
}
await copyText(rawPromptValues);
toastr.info(t`Copied!`);
});
popup.dlg.querySelector('#showRawPrompt').addEventListener('click', async function () {
//console.log(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt);
console.log(PromptArrayItemForRawPromptDisplay);
console.log(itemizedPrompts);
console.log(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt);
const rawPrompt = flatten(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt);
// Mobile needs special handholding. The side-view on the popup wouldn't work,
// so we just show an additional popup for this.
if (isMobile()) {
const content = document.createElement('div');
content.classList.add('tokenItemizingMaintext');
content.innerText = rawPrompt;
const popup = new Popup(content, POPUP_TYPE.TEXT, null, { allowVerticalScrolling: true, leftAlign: true });
await popup.show();
return;
}
//let DisplayStringifiedPrompt = JSON.stringify(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt).replace(/\n+/g, '<br>');
const rawPromptWrapper = document.getElementById('rawPromptWrapper');
rawPromptWrapper.innerText = rawPrompt;
$('#rawPromptPopup').slideToggle();
});
await popup.show();
}
@ -373,3 +351,44 @@ export function initItemizedPrompts() {
await deleteItemizedPrompts(name);
});
}
/**
* Swaps the itemized prompts between two messages. Useful when moving messages around in the chat.
* @param {number} sourceMessageId Source message ID
* @param {number} targetMessageId Target message ID
*/
export function swapItemizedPrompts(sourceMessageId, targetMessageId) {
if (!Array.isArray(itemizedPrompts)) {
return;
}
const sourcePrompts = itemizedPrompts.filter(x => x.mesId === sourceMessageId);
const targetPrompts = itemizedPrompts.filter(x => x.mesId === targetMessageId);
sourcePrompts.forEach(prompt => {
prompt.mesId = targetMessageId;
});
targetPrompts.forEach(prompt => {
prompt.mesId = sourceMessageId;
});
itemizedPrompts.sort((a, b) => a.mesId - b.mesId);
}
/**
* Deletes the itemized prompt for a specific message.
* Shifts down other itemized prompts as necessary.
* @param {number} messageId Message ID to delete itemized prompt for
*/
export function deleteItemizedPromptForMessage(messageId) {
if (!Array.isArray(itemizedPrompts)) {
return;
}
itemizedPrompts = itemizedPrompts.filter(x => x.mesId !== messageId);
for (const prompt of itemizedPrompts.filter(x => x.mesId > messageId)) {
prompt.mesId -= 1;
}
}

View file

@ -312,14 +312,14 @@ export class MacrosParser {
* @returns {number} The hashed chat id
*/
function getChatIdHash() {
const cachedIdHash = chat_metadata['chat_id_hash'];
const cachedIdHash = chat_metadata.chat_id_hash;
// If chat_id_hash is not already set, calculate it
if (!cachedIdHash) {
// Use the main_chat if it's available, otherwise get the current chat ID
const chatId = chat_metadata['main_chat'] ?? getCurrentChatId();
const chatId = chat_metadata.main_chat ?? getCurrentChatId();
const chatIdHash = getStringHash(chatId);
chat_metadata['chat_id_hash'] = chatIdHash;
chat_metadata.chat_id_hash = chatIdHash;
return chatIdHash;
}
@ -361,7 +361,7 @@ export function getLastMessageId({ exclude_swipe_in_propress = true, filter = nu
* @returns {number|null} The ID of the first message in the context
*/
function getFirstIncludedMessageId() {
return chat_metadata['lastInContextMessageId'];
return chat_metadata.lastInContextMessageId;
}
/**

View file

@ -104,7 +104,7 @@ function getLastCharMessage() {
}
function getFirstIncludedMessageId() {
const value = chat_metadata['lastInContextMessageId'];
const value = chat_metadata.lastInContextMessageId;
return typeof value === 'number' ? value : null;
}

View file

@ -1,9 +1,23 @@
import { seedrandom, droll } from '../../../lib.js';
import { chat_metadata, main_api, getMaxContextSize, extension_prompts, getCurrentChatId } from '../../../script.js';
import { getStringHash } from '../../utils.js';
import { getStringHash, isFalseBoolean } from '../../utils.js';
import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js';
import { inject_ids } from '../../constants.js';
import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../engine/MacroLexer.js';
import { MacroParser } from '../engine/MacroParser.js';
import { MacroCstWalker } from '../engine/MacroCstWalker.js';
/**
* Marker used by {{else}} to split content in {{if}} blocks.
* Uses control characters to minimize collision with real content.
*
* This marker is used internally by the macro engine to separate if/else branches.
* It should never appear in user-generated content.
*
* @type {string}
*/
export const ELSE_MARKER = '\u0000\u001FELSE\u001F\u0000';
/**
* Registers SillyTavern's core built-in macros in the MacroRegistry.
@ -59,11 +73,155 @@ export function registerCoreMacros() {
});
// {{trim}} -> macro will currently replace itself with itself. Trimming is handled in post-processing.
// Scoped: {{trim}}content{{/trim}} -> trims whitespace from content (handled by engine auto-trim)
MacroRegistry.registerMacro('trim', {
category: MacroCategory.UTILITY,
description: 'Trims all whitespaces around the trim macro.',
description: 'Trims whitespace. Non-scoped: trims newlines around the macro (post-processing). Scoped: returns the content (auto-trimmed by the engine).',
unnamedArgs: [
{
name: 'content',
description: 'Content to trim (when used as scoped macro)',
optional: true,
},
],
returns: '',
handler: () => '{{trim}}',
handler: ({ unnamedArgs: [content], isScoped }) => {
// Scoped usage: return content (already auto-trimmed by the engine)
if (isScoped) return content ?? '';
// Non-scoped: return marker for post-processing regex
return '{{trim}}';
},
});
/**
* Splits raw content on the first {{else}} macro at nesting depth 0.
* Tracks scoped {{if}}/{{/if}} pairs to find the correct top-level else.
* Only {{if}} with 1 argument (condition only) are considered scoped blocks.
*
* @param {string} content - The raw content to split
* @returns {{ thenBranch: string, elseBranch: string | undefined }}
*/
function splitOnTopLevelElse(content) {
const { cst } = MacroParser.parseDocument(content);
const macroNodes = /** @type {import('chevrotain').CstNode[]} */ (cst?.children?.macro || []);
let depth = 0;
for (const macroNode of macroNodes) {
const info = MacroCstWalker.extractMacroInfo(macroNode);
if (!info) continue;
// Only track scoped {{if}} blocks (1 arg = condition only, expects {{/if}})
// Inline {{if condition::content}} has 2 args and doesn't affect depth
if (info.name === 'if' && !info.isClosing && info.argCount === 1) {
depth++;
} else if (info.name === 'if' && info.isClosing) {
depth--;
} else if (info.name === 'else' && depth === 0) {
return {
thenBranch: content.slice(0, info.startOffset),
elseBranch: content.slice(info.endOffset + 1),
};
}
}
return { thenBranch: content, elseBranch: undefined };
}
// {{if condition}}content{{/if}} -> conditional content
// {{if condition}}then-content{{else}}else-content{{/if}} -> conditional with else branch
// {{if !condition}}content{{/if}} -> inverted conditional (negated)
// Condition can be a macro name (resolved automatically), variable shorthand (.var or $var), or any value
MacroRegistry.registerMacro('if', {
category: MacroCategory.UTILITY,
description: 'Conditional macro. Returns the content if the condition is truthy, otherwise returns nothing (or the else branch if present). Prefix the condition with ! to invert. If the condition is a registered macro name (without braces), it will be resolved first. Variable shorthands (.varname for local, $varname for global) are also supported.',
unnamedArgs: [
{
name: 'condition',
description: 'The condition to evaluate. Prefix with ! to invert. Can be a macro name (auto-resolved), variable shorthand (.var or $var), or a value. Falsy: empty string, "false", "off", "0".',
},
{
name: 'content',
description: 'The content to return if condition is truthy (typically provided as scoped content). May contain {{else}} to define an else branch.',
},
],
displayOverride: '{{if condition}}then{{else}}other{{/if}}',
exampleUsage: [
'{{if description}}# Description\n{{description}}{{/if}}',
'{{if charVersion}}{{charVersion}}{{else}}No version{{/if}}',
'{{if !personality}}No personality defined{{/if}}',
'{{if {{getvar::showHeader}}}}# Header{{/if}}',
'{{if .myvar}}Local var exists{{/if}}',
'{{if $globalFlag}}Global flag is set{{/if}}',
],
returns: 'The content if condition is truthy, else branch or empty string otherwise.',
// Delay argument resolution so nested macros are only evaluated in the chosen branch
delayArgResolution: true,
handler: ({ unnamedArgs: [rawCondition, rawContent], flags, resolve, trimContent }) => {
// With delayArgResolution: true, args contain raw (unresolved) text.
// We resolve the condition first, then only resolve the chosen branch.
// Check if the condition starts with ! for inversion
let inverted = false;
let condition = rawCondition;
if (/^\s*!/.test(rawCondition)) {
inverted = true;
condition = rawCondition.replace(/^\s*!\s*/, '');
}
// Resolve the condition (may contain nested macros like {{getvar::x}})
condition = resolve(condition);
// Check if condition is a variable shorthand (.varname or $varname)
// If so, resolve it using the appropriate variable macro
const varShorthandRegex = new RegExp(`^([.$])(${MACRO_VARIABLE_SHORTHAND_PATTERN.source})$`);
const varShorthandMatch = condition.match(varShorthandRegex);
if (varShorthandMatch) {
const [, prefix, varName] = varShorthandMatch;
const varMacro = prefix === '.' ? 'getvar' : 'getglobalvar';
condition = resolve(`{{${varMacro}::${varName}}}`);
} else {
// Check if condition is a registered macro name (without braces)
// If so, resolve it first (only for macros that accept 0 required args)
const macroDef = MacroRegistry.getPrimaryMacro(condition);
if (macroDef && macroDef.minArgs === 0) {
condition = resolve(`{{${condition}}}`);
}
}
// Check if condition is falsy: empty string or isFalseBoolean
let isFalsy = condition === '' || isFalseBoolean(condition);
if (inverted) isFalsy = !isFalsy;
// Split raw content on {{else}} macro at the top nesting level
// We need to track nesting depth to find the correct {{else}} for this if
const { thenBranch, elseBranch } = splitOnTopLevelElse(rawContent);
// Only resolve the chosen branch
const chosenBranch = !isFalsy ? thenBranch : elseBranch;
if (chosenBranch === undefined) {
return '';
}
// Resolve nested macros in the chosen branch
// Trim result unless # flag is set (preserveWhitespace)
let result = resolve(chosenBranch);
if (!flags.preserveWhitespace) {
result = trimContent(result);
}
return result;
},
});
// {{else}} -> marker for else branch inside {{if}} blocks
// Only meaningful inside a scoped {{if}} macro
MacroRegistry.registerMacro('else', {
category: MacroCategory.UTILITY,
description: 'Marks the else branch inside a scoped {{if}} block. Only works inside {{if}}...{{/if}}. If used outside, returns an invisible marker.',
exampleUsage: [
'{{if condition}}true branch{{else}}false branch{{/if}}',
],
returns: 'Invisible marker (consumed by the enclosing {{if}} macro).',
handler: () => ELSE_MARKER,
});
// {{input}} -> current textarea content
@ -101,8 +259,15 @@ export function registerCoreMacros() {
// Comment macro: {{// ...}} -> '' (consumes any arguments)
MacroRegistry.registerMacro('//', {
aliases: [{ alias: 'comment' }],
aliases: [{ alias: 'comment', visible: false }],
category: MacroCategory.UTILITY,
unnamedArgs: [
{
name: 'comment',
type: MacroValueType.STRING,
description: 'Any kind of text as comment. If you want multiline comments, consider using a scoped macro like {{//}}First\nSecond{{///}}.',
},
],
list: true, // We consume any arguments as if this is a list, but we'll ignore them in the handler anyway
strictArgs: false, // and we also always remove it, even if the parsing might say it's invalid
description: 'Comment macro that produces an empty string. Can be used for writing into prompt definitions, without being passed to the context.',
@ -132,7 +297,7 @@ export function registerCoreMacros() {
'{{roll::6}}',
'{{roll::3d6+4}}',
],
handler: ({ unnamedArgs: [formula] }) => {
handler: ({ unnamedArgs: [formula], warn }) => {
// If only digits were provided, treat it as `1dX`.
if (/^\d+$/.test(formula)) {
formula = `1d${formula}`;
@ -140,7 +305,7 @@ export function registerCoreMacros() {
const isValid = droll.validate(formula);
if (!isValid) {
console.debug(`Invalid roll formula: ${formula}`);
warn(`Invalid roll formula: ${formula}`);
return '';
}
@ -177,10 +342,15 @@ export function registerCoreMacros() {
MacroRegistry.registerMacro('pick', {
category: MacroCategory.RANDOM,
list: true,
description: 'Picks a random item from a list, but keeps the choice stable for a given chat and macro position.',
description: 'Picks a random item from a list, but keeps the choice stable for a given chat and macro position. Can be rerolled via /reroll-pick slash command.',
// TODO: add expanded documentation once HTML details are supported
// descriptionDetails: `
// <p>Picks a random item from a list, but keeps the choice stable for a given chat and macro position.</p>
// <p>The choice can be reset per chat using the <code>/reroll-pick</code> slash command.</p>
// `,
returns: 'Stable randomly selected item from the list.',
exampleUsage: ['{{pick::blonde::brown::red::black::blue}}'],
handler: ({ list, range, env }) => {
handler: ({ list, globalOffset, env }) => {
// Handle old legacy cases, where we have to split the list manually
if (list.length === 1) {
list = readSingleArgsRandomList(list[0]);
@ -190,14 +360,24 @@ export function registerCoreMacros() {
return '';
}
// NOTE:
// When changing the hashing logic, make sure to update unit test functionality
// in registerTestablePick() to be identical.
const chatIdHash = getChatIdHash();
// Use the full original input string for deterministic behavior
const rawContentHash = env.contentHash;
const offset = typeof range?.startOffset === 'number' ? range.startOffset : 0;
// Use globalOffset for deterministic seeding - this ensures identical macros
// at different positions in the document produce different results, even when
// nested inside arguments or scoped content
const offset = globalOffset;
const combinedSeedString = `${chatIdHash}-${rawContentHash}-${offset}`;
// Reroll seed allows users to reset all picks in the chat via /reroll-pick command
const rerollSeed = chat_metadata.pick_reroll_seed || null;
const combinedSeedString = [chatIdHash, rawContentHash, offset, rerollSeed].filter(it => it !== null).join('-');
const finalSeed = getStringHash(combinedSeedString);
const rng = seedrandom(String(finalSeed));
const randomIndex = Math.floor(rng() * list.length);
@ -207,7 +387,7 @@ export function registerCoreMacros() {
/** @param {string} listString @return {string[]} */
function readSingleArgsRandomList(listString) {
// If it contains double colons, those will have precedence over comma-seperated lists.
// If it contains double colons, those will have precedence over comma-separated lists.
// This can only happen if the macro only had a single colon to introduce the list...
// like, {{random:a::b::c}}
if (listString.includes('::')) {
@ -227,11 +407,11 @@ export function registerCoreMacros() {
{
name: 'word',
sampleValue: 'word',
description: 'Word to ban for textgenerationwebui backend.',
description: 'Word to ban for Text Completion backend.',
type: 'string',
},
],
description: 'Bans a word for textgenerationwebui backend. (Strips quotes surrounding the banned word, if present)',
description: 'Bans a word for Text Completion backend. (Strips quotes surrounding the banned word, if present)',
returns: '',
exampleUsage: ['{{banned::delve}}'],
handler: ({ unnamedArgs: [bannedWord] }) => {
@ -268,13 +448,13 @@ export function registerCoreMacros() {
}
function getChatIdHash() {
const cachedIdHash = chat_metadata['chat_id_hash'];
const cachedIdHash = chat_metadata.chat_id_hash;
if (typeof cachedIdHash === 'number') {
return cachedIdHash;
}
const chatId = chat_metadata['main_chat'] ?? getCurrentChatId();
const chatId = chat_metadata.main_chat ?? getCurrentChatId();
const chatIdHash = getStringHash(chatId);
chat_metadata['chat_id_hash'] = chatIdHash;
chat_metadata.chat_id_hash = chatIdHash;
return chatIdHash;
}

View file

@ -1,5 +1,6 @@
import { MacroRegistry, MacroCategory } from '../engine/MacroRegistry.js';
import { eventSource, event_types } from '../../events.js';
import { findExtension } from '/scripts/extensions.js';
let lastGenerationTypeValue = '';
let lastGenerationTypeTrackingInitialized = false;
@ -37,4 +38,20 @@ export function registerStateMacros() {
returns: 'Type of the last queued generation request.',
handler: () => lastGenerationTypeValue,
});
// Macro that checks if an extension is enabled
MacroRegistry.registerMacro('hasExtension', {
category: MacroCategory.STATE,
unnamedArgs: [{
name: 'extensionName',
type: 'string',
description: 'The name of the extension to check',
}],
description: 'Checks if a specific extension is enabled. If the extension does not exist, returns false.',
returns: 'true if the extension is enabled, false otherwise.',
handler: ({ unnamedArgs: [extensionName] }) => {
const extension = findExtension(extensionName);
return String(extension?.enabled ?? false);
},
});
}

View file

@ -68,7 +68,7 @@ export function registerVariableMacros() {
description: 'Increments a local variable by 1 and returns the new value. If the variable does not exist, it will be created.',
returns: 'The new value of the local variable.',
returnType: MacroValueType.NUMBER,
exampleUsage: ['{{incvar::myintvar}}'],
exampleUsage: ['{{incvar::myintvar}}', '{{incvar some-local-int-var}}'],
handler: ({ unnamedArgs: [name], normalize }) => {
const result = ctx.variables.local.inc(name);
return normalize(result);
@ -88,7 +88,7 @@ export function registerVariableMacros() {
description: 'Decrements a local variable by 1 and returns the new value. If the variable does not exist, it will be created.',
returns: 'The new value of the local variable.',
returnType: MacroValueType.NUMBER,
exampleUsage: ['{{decvar::myintvar}}'],
exampleUsage: ['{{decvar::myintvar}}', '{{decvar some-local-int-var}}'],
handler: ({ unnamedArgs: [name], normalize }) => {
const result = ctx.variables.local.dec(name);
return normalize(result);
@ -108,13 +108,53 @@ export function registerVariableMacros() {
description: 'Gets the value of a local variable.',
returns: 'The value of the local variable.',
returnType: [MacroValueType.STRING, MacroValueType.NUMBER],
exampleUsage: ['{{getvar::myvar}}', '{{getvar::myintvar}}'],
exampleUsage: ['{{getvar::myvar}}', '{{getvar myintvar}}'],
handler: ({ unnamedArgs: [name], normalize }) => {
const result = ctx.variables.local.get(name);
return normalize(result);
},
});
// {{hasvar::name}} -> returns 'true' or 'false'
MacroRegistry.registerMacro('hasvar', {
aliases: [{ alias: 'varexists' }],
category: MacroCategory.VARIABLE,
unnamedArgs: [
{
name: 'name',
type: MacroValueType.STRING,
description: 'The name of the local variable to check.',
},
],
description: 'Checks if a local variable exists.',
returns: '"true" if the variable exists, "false" otherwise.',
returnType: MacroValueType.STRING,
exampleUsage: ['{{hasvar::myvar}}', '{{hasvar some-local-var}}'],
handler: ({ unnamedArgs: [name] }) => {
return ctx.variables.local.has(name) ? 'true' : 'false';
},
});
// {{deletevar::name}} -> returns ''
MacroRegistry.registerMacro('deletevar', {
aliases: [{ alias: 'flushvar' }],
category: MacroCategory.VARIABLE,
unnamedArgs: [
{
name: 'name',
type: MacroValueType.STRING,
description: 'The name of the local variable to delete.',
},
],
description: 'Deletes a local variable.',
returns: '',
exampleUsage: ['{{deletevar::myvar}}', '{{deletevar some-local-var}}'],
handler: ({ unnamedArgs: [name] }) => {
ctx.variables.local.del(name);
return '';
},
});
// {{setglobalvar::name::value}} -> ''
MacroRegistry.registerMacro('setglobalvar', {
category: MacroCategory.VARIABLE,
@ -176,6 +216,7 @@ export function registerVariableMacros() {
description: 'Increments a global variable by 1 and returns the new value. If the variable does not exist, it will be created.',
returns: 'The new value of the global variable.',
returnType: MacroValueType.NUMBER,
exampleUsage: ['{{incglobalvar::myintvar}}', '{{incglobalvar some-global-int-var}}'],
handler: ({ unnamedArgs: [name], normalize }) => {
const result = ctx.variables.global.inc(name);
return normalize(result);
@ -195,7 +236,7 @@ export function registerVariableMacros() {
description: 'Decrements a global variable by 1 and returns the new value. If the variable does not exist, it will be created.',
returns: 'The new value of the global variable.',
returnType: MacroValueType.NUMBER,
exampleUsage: ['{{decglobalvar::myintvar}}'],
exampleUsage: ['{{decglobalvar::myintvar}}', '{{decglobalvar some-global-int-var}}'],
handler: ({ unnamedArgs: [name], normalize }) => {
const result = ctx.variables.global.dec(name);
return normalize(result);
@ -215,10 +256,50 @@ export function registerVariableMacros() {
description: 'Gets the value of a global variable.',
returns: 'The value of the global variable.',
returnType: [MacroValueType.STRING, MacroValueType.NUMBER],
exampleUsage: ['{{getglobalvar::myvar}}', '{{getglobalvar::myintvar}}'],
exampleUsage: ['{{getglobalvar::myvar}}', '{{getglobalvar myintvar}}'],
handler: ({ unnamedArgs: [name], normalize }) => {
const result = ctx.variables.global.get(name);
return normalize(result);
},
});
// {{hasglobalvar::name}} -> returns 'true' or 'false'
MacroRegistry.registerMacro('hasglobalvar', {
aliases: [{ alias: 'globalvarexists' }],
category: MacroCategory.VARIABLE,
unnamedArgs: [
{
name: 'name',
type: MacroValueType.STRING,
description: 'The name of the global variable to check.',
},
],
description: 'Checks if a global variable exists.',
returns: '"true" if the variable exists, "false" otherwise.',
returnType: MacroValueType.STRING,
exampleUsage: ['{{hasglobalvar::myvar}}', '{{hasglobalvar some-global-var}}'],
handler: ({ unnamedArgs: [name] }) => {
return ctx.variables.global.has(name) ? 'true' : 'false';
},
});
// {{deleteglobalvar::name}} -> returns ''
MacroRegistry.registerMacro('deleteglobalvar', {
aliases: [{ alias: 'flushglobalvar' }],
category: MacroCategory.VARIABLE,
unnamedArgs: [
{
name: 'name',
type: MacroValueType.STRING,
description: 'The name of the global variable to delete.',
},
],
description: 'Deletes a global variable.',
returns: '',
exampleUsage: ['{{deleteglobalvar::myvar}}', '{{deleteglobalvar some-global-var}}'],
handler: ({ unnamedArgs: [name] }) => {
ctx.variables.global.del(name);
return '';
},
});
}

View file

@ -3,11 +3,12 @@
* Similar to SlashCommandBrowser but for the macro system.
*/
import { MacroRegistry, MacroCategory } from './engine/MacroRegistry.js';
import { performFuzzySearch } from '../power-user.js';
import { MacroRegistry, MacroCategory } from './MacroRegistry.js';
import { performFuzzySearch } from '../../power-user.js';
import { escapeRegex } from '/scripts/utils.js';
/** @typedef {import('./engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
/** @typedef {import('./engine/MacroRegistry.js').MacroValueType} MacroValueType */
/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */
/** @typedef {import('./MacroRegistry.js').MacroValueType} MacroValueType */
/**
* Category display names and order for documentation.
@ -328,6 +329,11 @@ function getCategoryConfig(category) {
export function formatMacroSignature(macro) {
// Use displayOverride if provided
if (macro.displayOverride) {
if (macro.aliasOf) {
// Replace all occurrences of the macro name with the alias for this list
const escapedMainName = escapeRegex(macro.aliasOf);
return macro.displayOverride.replace(new RegExp(`(?<=[\\b{\\s])${escapedMainName}(?=[\\b}:\\s])`, 'g'), `${macro.name}`);
}
return macro.displayOverride;
}

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,12 @@
/** @typedef {import('chevrotain').ILexingError} ILexingError */
/** @typedef {import('chevrotain').IRecognitionException} IRecognitionException */
import { t } from '/scripts/i18n.js';
import { Popup, POPUP_RESULT } from '/scripts/popup.js';
import { power_user } from '/scripts/power-user.js';
import { accountStorage } from '/scripts/util/AccountStorage.js';
import { SimpleMutex } from '/scripts/util/SimpleMutex.js';
/**
* @typedef {Object} MacroErrorContext
* @property {string} [macroName]
@ -22,6 +28,45 @@
* @typedef {MacroErrorContext & { message: string, error?: any }} MacroLogOptions
*/
// Use mutex here so even on parallel usage without awaiting the popup, this will only show up once.
export const onboardingExperimentalMacroEngineMutex = new SimpleMutex(onboardingExperimentalMacroEngineUnsafe);
/**
* Onboards the user to use the experimental macro engine.
* Asks the user to enable it if they haven't already.
*
* @param {string|null} feature - The feature that requires the experimental macro engine, or null if not applicable or unknown.
* @returns {Promise<void>} - A promise that resolves when the user has been onboarded.
*/
export const onboardingExperimentalMacroEngine = onboardingExperimentalMacroEngineMutex.update.bind(onboardingExperimentalMacroEngineMutex);
async function onboardingExperimentalMacroEngineUnsafe(feature = null) {
// Show a popup once telling a user that they are using experimental features that only work with the new engine.
// Ask them if they want to turn the experimental engine on.
if (power_user.experimental_macro_engine) return;
// If already shown, do not show again
const shown = accountStorage.getItem('slash_command_experimental_engine_warning_shown');
if (shown === 'true') return;
const result = await Popup.show.confirm(t`Experimental Macro Engine`, `
<p>${t`You are using experimental macro features that require the new macro engine.`}</p>
${feature ? `<div class="info-block hint">
<span>${t`Recognized Feature: `}<strong>${feature}</strong></span>
</div>` : ''}
<p>${t`For more information on the new macro engine, visit the <br />${`<a href="https://docs.sillytavern.app/usage/core-concepts/macros/">${t`Macro Documentation`}</a>`}.`}</p>
<p>${t`You can enable the engine any time under:<br />${t`User Settings`} → ${t`Experimental Macro Engine`}`}</p>
<p>${t`Would you like to enable it now?`}</p>`);
if (result == POPUP_RESULT.AFFIRMATIVE) {
power_user.experimental_macro_engine = true;
$('#experimental_macro_engine').prop('checked', power_user.experimental_macro_engine).trigger('input');
}
// Only show this once
accountStorage.setItem('slash_command_experimental_engine_warning_shown', 'true');
}
/**
* Creates an error representing a runtime macro invocation problem (such as
* arity or type mismatches). These errors are intended to be caught by the

Some files were not shown because too many files have changed in this diff Show more