perf(chat): coalesce automatic backups after stable checkpoints #128 #131

This commit is contained in:
Darkatse 2026-07-15 22:23:22 -04:00
parent 97799d27f5
commit 95d3829e92
40 changed files with 1844 additions and 174 deletions

View file

@ -84,6 +84,12 @@ pub fn spawn_initialization(
.await;
});
let chat_history_coordinator = state.services.chat_history_coordinator.clone();
let chat_history_cancel = state.lifecycle.chat_history_cancel.clone();
tauri::async_runtime::spawn(async move {
chat_history_coordinator.run(chat_history_cancel).await;
});
match app_handle.emit("app-ready", ()) {
Ok(_) => tracing::debug!("Application is ready"),
Err(error) => tracing::error!("Failed to emit app-ready event: {}", error),

View file

@ -3,6 +3,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use tt_application::services::character_service::CharacterService;
use tt_application::services::chat_history_coordinator::ChatHistoryCoordinator;
use tt_application::services::chat_service::ChatService;
use tt_application::services::group_chat_service::GroupChatService;
use tt_application::services::group_service::GroupService;
@ -18,6 +19,7 @@ pub(in crate::app::composition) fn data_change_reconciler(
group_service: Arc<GroupService>,
secret_service: Arc<SecretService>,
settings_service: Arc<SettingsService>,
chat_history_coordinator: Arc<ChatHistoryCoordinator>,
) -> Arc<dyn DataChangeReconciler> {
Arc::new(ServiceCacheReconciler {
character_service,
@ -26,6 +28,7 @@ pub(in crate::app::composition) fn data_change_reconciler(
group_service,
secret_service,
settings_service,
chat_history_coordinator,
})
}
@ -36,6 +39,7 @@ struct ServiceCacheReconciler {
group_service: Arc<GroupService>,
secret_service: Arc<SecretService>,
settings_service: Arc<SettingsService>,
chat_history_coordinator: Arc<ChatHistoryCoordinator>,
}
#[async_trait]
@ -46,6 +50,8 @@ impl DataChangeReconciler for ServiceCacheReconciler {
"Refreshing runtime caches after external data change"
);
self.chat_history_coordinator.invalidate_all_pending().await;
self.character_service.clear_cache().await?;
self.chat_service.clear_cache().await?;
self.group_chat_service.clear_cache().await?;

View file

@ -16,6 +16,7 @@ use tt_application::services::avatar_service::AvatarService;
use tt_application::services::background_service::BackgroundService;
use tt_application::services::character_service::CharacterService;
use tt_application::services::chat_completion_service::ChatCompletionService;
use tt_application::services::chat_history_coordinator::ChatHistoryCoordinator;
use tt_application::services::chat_service::ChatService;
use tt_application::services::content_service::ContentService;
use tt_application::services::extension_service::ExtensionService;
@ -141,24 +142,33 @@ pub(super) async fn build(
crate::product::VERSION,
));
let chat_history_coordinator = Arc::new(ChatHistoryCoordinator::new(
repositories.chat_repository.clone(),
repositories.group_chat_repository.clone(),
));
let group_service = Arc::new(GroupService::new(
repositories.group_repository.clone(),
agent_services.agent_workspace_lifecycle_service.clone(),
chat_history_coordinator.clone(),
));
let character_service = Arc::new(CharacterService::new(
repositories.character_repository.clone(),
repositories.chat_repository.clone(),
repositories.world_info_repository.clone(),
agent_services.agent_workspace_lifecycle_service.clone(),
chat_history_coordinator.clone(),
));
let chat_service = Arc::new(ChatService::new(
repositories.chat_repository.clone(),
repositories.character_repository.clone(),
agent_services.agent_workspace_lifecycle_service.clone(),
chat_history_coordinator.clone(),
));
let group_chat_service = Arc::new(GroupChatService::new(
repositories.group_chat_repository.clone(),
agent_services.agent_workspace_lifecycle_service,
chat_history_coordinator.clone(),
));
let secret_service = Arc::new(SecretService::new(
repositories.secret_repository.clone(),
@ -180,6 +190,7 @@ pub(super) async fn build(
group_service.clone(),
secret_service.clone(),
settings_service.clone(),
chat_history_coordinator.clone(),
);
let data_archive_service = archive::build(app_handle, data_change_reconciler.clone());
let sync_services = sync::build(
@ -194,6 +205,7 @@ pub(super) async fn build(
character_service,
chat_service,
group_chat_service,
chat_history_coordinator,
user_service,
settings_service,
user_directory_service,

View file

@ -48,6 +48,7 @@ use tt_application::services::agent_workspace_lifecycle_service::{
AgentRunActivity, AgentWorkspaceLifecycleService,
};
use tt_application::services::character_service::CharacterService;
use tt_application::services::chat_history_coordinator::ChatHistoryCoordinator;
use tt_application::services::llm_connection_service::LlmConnectionService;
use tt_application::services::prompt_assembly_service::PromptAssemblyService;
use tt_application::services::skill_service::SkillService;
@ -127,7 +128,12 @@ async fn character_service_with_world_repository(
aliases,
file_chat_repository.clone(),
));
let chat_repository: Arc<dyn ChatRepository> = file_chat_repository;
let chat_repository: Arc<dyn ChatRepository> = file_chat_repository.clone();
let group_chat_repository: Arc<dyn GroupChatRepository> = file_chat_repository;
let chat_history_coordinator = Arc::new(ChatHistoryCoordinator::new(
chat_repository.clone(),
group_chat_repository,
));
let world_repository = Arc::new(FileWorldInfoRepository::new(default_user.join("worlds")));
let agent_repository = Arc::new(FileAgentRepository::new(
root.join("_tauritavern/agent-workspaces"),
@ -144,6 +150,7 @@ async fn character_service_with_world_repository(
chat_repository,
world_repository.clone(),
lifecycle_service,
chat_history_coordinator,
),
world_repository,
)

View file

@ -60,6 +60,7 @@ pub(super) fn handle_run_event(app_handle: &tauri::AppHandle, event: tauri::RunE
.lifecycle
.agent_run_retention_automation_cancel
.cancel();
state.lifecycle.chat_history_cancel.cancel();
let lan_sync_service = state.services.lan_sync_service.clone();
tauri::async_runtime::spawn(async move {
if let Err(error) = lan_sync_service.shutdown().await {

View file

@ -14,6 +14,7 @@ use tt_application::services::avatar_service::AvatarService;
use tt_application::services::background_service::BackgroundService;
use tt_application::services::character_service::CharacterService;
use tt_application::services::chat_completion_service::ChatCompletionService;
use tt_application::services::chat_history_coordinator::ChatHistoryCoordinator;
use tt_application::services::chat_service::ChatService;
use tt_application::services::content_service::ContentService;
use tt_application::services::data_archive_service::DataArchiveService;
@ -57,6 +58,7 @@ pub struct AppState {
pub(crate) struct AppLifecycle {
pub(crate) sync_automation_cancel: CancellationToken,
pub(crate) agent_run_retention_automation_cancel: CancellationToken,
pub(crate) chat_history_cancel: CancellationToken,
}
impl AppLifecycle {
@ -64,6 +66,7 @@ impl AppLifecycle {
Self {
sync_automation_cancel: CancellationToken::new(),
agent_run_retention_automation_cancel: CancellationToken::new(),
chat_history_cancel: CancellationToken::new(),
}
}
}
@ -72,6 +75,7 @@ pub(crate) struct AppServices {
pub(crate) character_service: Arc<CharacterService>,
pub(crate) chat_service: Arc<ChatService>,
pub(crate) group_chat_service: Arc<GroupChatService>,
pub(crate) chat_history_coordinator: Arc<ChatHistoryCoordinator>,
pub(crate) user_service: Arc<UserService>,
pub(crate) settings_service: Arc<SettingsService>,
pub(crate) user_directory_service: Arc<UserDirectoryService>,

View file

@ -11,6 +11,7 @@ use tt_application::dto::chat_dto::{
HideChatBeforeCursorDto, ImportCharacterChatsDto, ImportChatDto, PatchChatWindowedDto,
PinnedCharacterChatDto, RenameChatDto, SaveChatFromFileDto,
};
use tt_application::dto::chat_history_dto::ChatHistoryLocator;
use tt_application::errors::ApplicationError;
use tt_ports::repositories::chat_repository::{
ChatPayloadChunk, ChatPayloadCursor, ChatPayloadTail, ChatPayloadWindowPatchRequest,
@ -30,6 +31,32 @@ pub async fn get_all_chats(
.map_err(map_command_error("Failed to get all chats"))
}
#[tauri::command]
pub async fn chat_history_generation_started(
locator: ChatHistoryLocator,
app_state: State<'_, Arc<AppState>>,
) -> Result<(), CommandError> {
app_state
.services
.chat_history_coordinator
.generation_started(locator)
.await
.map_err(Into::into)
}
#[tauri::command]
pub async fn chat_history_generation_finished(
locator: ChatHistoryLocator,
app_state: State<'_, Arc<AppState>>,
) -> Result<(), CommandError> {
app_state
.services
.chat_history_coordinator
.generation_finished(locator)
.await
.map_err(Into::into)
}
#[tauri::command]
pub async fn get_chat(
character_name: String,
@ -443,6 +470,7 @@ pub async fn patch_chat_payload_windowed(
patch,
expected_window_line_count,
force,
commit_reason,
} = dto;
log_command(format!(
"patch_chat_payload_windowed {}/{}",
@ -460,7 +488,12 @@ pub async fn patch_chat_payload_windowed(
app_state
.services
.chat_service
.patch_chat_payload_windowed(&character_name, &file_name, request)
.patch_chat_payload_windowed(
&character_name,
&file_name,
request,
commit_reason.unwrap_or_default(),
)
.await
.map_err(map_command_error("Failed to patch windowed chat payload"))
}

View file

@ -184,6 +184,7 @@ pub async fn patch_group_chat_payload_windowed(
patch,
expected_window_line_count,
force,
commit_reason,
} = dto;
log_command(format!("patch_group_chat_payload_windowed {}", id));
@ -198,7 +199,7 @@ pub async fn patch_group_chat_payload_windowed(
app_state
.services
.group_chat_service
.patch_group_chat_payload_windowed(&id, request)
.patch_group_chat_payload_windowed(&id, request, commit_reason.unwrap_or_default())
.await
.map_err(map_command_error(
"Failed to patch windowed group chat payload",

View file

@ -22,6 +22,8 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Sen
super::character_commands::get_character_chats_by_id,
super::character_commands::clear_character_cache,
// Chat commands
super::chat_commands::chat_history_generation_started,
super::chat_commands::chat_history_generation_finished,
super::chat_commands::get_all_chats,
super::chat_commands::get_chat,
super::chat_commands::get_character_chats,

View file

@ -20,40 +20,50 @@ impl FileChatRepository {
&self,
chat_path: &Path,
backup_name: &str,
backup_key: &str,
) {
let Ok(mut state) = self.backup_history.try_lock() else {
return;
) -> Result<(), DomainError> {
let policy = match self.backup_policy.try_read() {
Ok(policy) => *policy,
Err(_) => {
return Err(DomainError::transient(
"Chat backup policy is being updated",
));
}
};
let Ok(policy) = self.backup_policy.try_read() else {
return;
};
let policy = *policy;
if !policy.automatic_enabled
|| policy.history_disabled()
|| !state.throttle.should_backup(backup_key)
{
return;
if !policy.automatic_enabled || policy.history_disabled() {
return Ok(());
}
let result = match &mut state.inventory {
BackupInventoryState::Ready(inventory) => {
self.publish_chat_backup(chat_path, backup_name, policy, inventory)
.await
let Ok(mut state) = self.backup_history.try_lock() else {
return Err(DomainError::transient("Chat backup history is busy"));
};
let inventory = match &mut state.inventory {
BackupInventoryState::Ready(inventory) => inventory,
BackupInventoryState::Uninitialized => {
return Err(DomainError::transient(
"Chat backup inventory is initializing",
));
}
BackupInventoryState::Failed(message) => {
return Err(DomainError::InternalError(format!(
"Chat backup inventory is unavailable: {}",
message
)));
}
BackupInventoryState::Uninitialized | BackupInventoryState::Failed(_) => return,
};
match result {
Ok(()) => state.throttle.update(backup_key),
Err(DomainError::Conflict(message)) => {
tracing::warn!("Skipping automatic chat backup: {}", message)
match self
.publish_chat_backup(chat_path, backup_name, policy, inventory)
.await
{
Ok(()) => {
tracing::info!(source = ?chat_path, "Created automatic chat backup");
Ok(())
}
Err(error) => tracing::error!(
target: tt_contracts::observability::USER_VISIBLE_ERROR,
"Automatic chat backup failed after the current chat was saved: {}",
error
),
Err(DomainError::Conflict(message)) => {
tracing::warn!(reason = %message, "Skipping automatic chat backup");
Ok(())
}
Err(error) => Err(error),
}
}

View file

@ -7,7 +7,6 @@ use tt_domain::errors::DomainError;
use tt_domain::models::settings::ChatBackupSettings;
use super::FileChatRepository;
use super::cache::ThrottledBackup;
pub(super) const BACKUP_TEMP_PREFIX: &str = ".tmp-chat-backup-";
@ -63,14 +62,12 @@ pub(super) enum BackupInventoryState {
pub(super) struct BackupHistoryState {
pub inventory: BackupInventoryState,
pub throttle: ThrottledBackup,
}
impl BackupHistoryState {
pub fn new(throttle: ThrottledBackup) -> Self {
pub fn new() -> Self {
Self {
inventory: BackupInventoryState::Uninitialized,
throttle,
}
}
}

View file

@ -57,33 +57,3 @@ impl MemoryCache {
self.chats.clear();
}
}
/// Throttled function for backups
pub(super) struct ThrottledBackup {
last_backup: HashMap<String, Instant>,
interval: Duration,
}
impl ThrottledBackup {
/// Create a new throttled backup with the specified interval
pub(super) fn new(interval_seconds: u64) -> Self {
Self {
last_backup: HashMap::new(),
interval: Duration::from_secs(interval_seconds),
}
}
/// Check if a backup should be performed
pub(super) fn should_backup(&self, key: &str) -> bool {
if let Some(last) = self.last_backup.get(key) {
last.elapsed() >= self.interval
} else {
true
}
}
/// Update the last backup time
pub(super) fn update(&mut self, key: &str) {
self.last_backup.insert(key.to_string(), Instant::now());
}
}

View file

@ -189,15 +189,33 @@ impl GroupChatRepository for FileChatRepository {
) -> Result<(), DomainError> {
self.ensure_directory_exists().await?;
let path = self.get_group_chat_path(chat_id)?;
let backup_key = Self::get_group_backup_key(chat_id)?;
self.write_payload_file_to_path(&path, source_path, force, chat_id, &backup_key)
self.write_payload_file_to_path(&path, source_path, force)
.await?;
self.remove_summary_cache_for_path(&path).await;
Ok(())
}
async fn backup_group_chat_automatic(&self, chat_id: &str) -> Result<(), DomainError> {
let path = self.get_group_chat_path(chat_id)?;
let Some(_write_guard) = self.try_acquire_payload_write_lock(&path).await else {
return Err(DomainError::transient(format!(
"Group chat current is busy: {}",
path.display()
)));
};
if !path.exists() {
return Err(DomainError::NotFound(format!(
"Group chat not found: {}",
chat_id
)));
}
self.backup_chat_file_automatic(&path, chat_id).await
}
async fn delete_group_chat_payload(&self, chat_id: &str) -> Result<(), DomainError> {
let path = self.get_group_chat_path(chat_id)?;
let _write_guard = self.acquire_payload_write_lock(&path).await;
if !path.exists() {
return Err(DomainError::NotFound(format!(
"Group chat not found: {}",

View file

@ -31,7 +31,7 @@ mod windowed_payload_io;
mod tests;
use self::backup_inventory::BackupHistoryState;
use self::cache::{MemoryCache, ThrottledBackup};
use self::cache::MemoryCache;
use self::summary::SummaryCache;
use crate::chat_directory_identity::{
SharedChatAliasStore, chat_alias_path_for_user_dir, new_shared_chat_alias_store,
@ -138,9 +138,7 @@ impl FileChatRepository {
summary_cache,
chat_aliases,
backup_policy: Arc::new(RwLock::new(backup_settings)),
backup_history: Arc::new(Mutex::new(BackupHistoryState::new(ThrottledBackup::new(
10,
)))),
backup_history: Arc::new(Mutex::new(BackupHistoryState::new())),
}
}
}

View file

@ -53,11 +53,11 @@ impl FileChatRepository {
sanitize_chat_dir_key(value, fallback)
}
pub(super) async fn acquire_payload_write_lock(&self, path: &Path) -> OwnedMutexGuard<()> {
async fn payload_write_lock(&self, path: &Path) -> Arc<Mutex<()>> {
const MAX_RETAINED_LOCK_ENTRIES: usize = 2048;
let key = path.to_path_buf();
let lock = {
{
let mut locks = self.path_write_locks.lock().await;
if locks.len() > MAX_RETAINED_LOCK_ENTRIES {
locks.retain(|_, value| value.strong_count() > 0);
@ -71,9 +71,18 @@ impl FileChatRepository {
created
}
}
};
}
}
lock.lock_owned().await
pub(super) async fn acquire_payload_write_lock(&self, path: &Path) -> OwnedMutexGuard<()> {
self.payload_write_lock(path).await.lock_owned().await
}
pub(super) async fn try_acquire_payload_write_lock(
&self,
path: &Path,
) -> Option<OwnedMutexGuard<()>> {
self.payload_write_lock(path).await.try_lock_owned().ok()
}
pub(super) async fn acquire_payload_rename_locks(
@ -276,11 +285,4 @@ impl FileChatRepository {
Self::normalize_jsonl_file_stem(file_name)?
))
}
pub(super) fn get_group_backup_key(chat_id: &str) -> Result<String, DomainError> {
Ok(format!(
"group:{}",
Self::normalize_jsonl_file_stem(chat_id)?
))
}
}

View file

@ -174,8 +174,6 @@ impl FileChatRepository {
path: &Path,
payload: &[Value],
force: bool,
backup_name: &str,
backup_key: &str,
) -> Result<(), DomainError> {
if payload.is_empty() {
return Err(DomainError::InvalidData(
@ -187,8 +185,6 @@ impl FileChatRepository {
self.verify_chat_integrity_if_needed(path, payload, force)
.await?;
write_jsonl_file(path, payload).await?;
self.backup_chat_file_automatic(path, backup_name, backup_key)
.await;
Ok(())
}
@ -198,8 +194,6 @@ impl FileChatRepository {
path: &Path,
source_path: &Path,
force: bool,
backup_name: &str,
backup_key: &str,
) -> Result<(), DomainError> {
if !source_path.exists() {
return Err(DomainError::NotFound(format!(
@ -229,8 +223,6 @@ impl FileChatRepository {
})?;
replace_file_with_fallback(&temp_path, path).await?;
self.backup_chat_file_automatic(path, backup_name, backup_key)
.await;
Ok(())
}
@ -295,8 +287,7 @@ impl FileChatRepository {
let objects = Self::build_payload_from_chat(chat)?;
self.write_payload_to_path(&path, &objects, force, &chat.character_name, &backup_key)
.await?;
self.write_payload_to_path(&path, &objects, force).await?;
// Update cache
let mut cache = self.memory_cache.lock().await;

View file

@ -164,6 +164,7 @@ impl ChatRepository for FileChatRepository {
let path = self
.resolve_character_chat_path(character_name, file_name)
.await?;
let _write_guard = self.acquire_payload_write_lock(&path).await;
if !path.exists() {
return Err(DomainError::NotFound(format!(
@ -470,6 +471,7 @@ impl ChatRepository for FileChatRepository {
let chat_path = self
.resolve_character_chat_path(character_name, file_name)
.await?;
let _write_guard = self.acquire_payload_write_lock(&chat_path).await;
if !chat_path.exists() {
return Err(DomainError::NotFound(format!(
"Chat not found: {}/{}",
@ -477,11 +479,35 @@ impl ChatRepository for FileChatRepository {
)));
}
let _write_guard = self.acquire_payload_write_lock(&chat_path).await;
self.backup_chat_file_explicit(&chat_path, character_name)
.await
}
async fn backup_chat_automatic(
&self,
character_name: &str,
file_name: &str,
) -> Result<(), DomainError> {
let chat_path = self
.resolve_character_chat_path(character_name, file_name)
.await?;
let Some(_write_guard) = self.try_acquire_payload_write_lock(&chat_path).await else {
return Err(DomainError::transient(format!(
"Chat current is busy: {}",
chat_path.display()
)));
};
if !chat_path.exists() {
return Err(DomainError::NotFound(format!(
"Chat not found: {}/{}",
character_name, file_name
)));
}
self.backup_chat_file_automatic(&chat_path, character_name)
.await
}
async fn list_chat_backups(&self) -> Result<Vec<ChatSearchResult>, DomainError> {
let descriptors = self.list_chat_backup_files().await?;
let mut results = Vec::with_capacity(descriptors.len());
@ -645,7 +671,7 @@ impl ChatRepository for FileChatRepository {
})?;
}
self.write_payload_file_to_path(&path, source_path, force, character_name, &backup_key)
self.write_payload_file_to_path(&path, source_path, force)
.await?;
{

View file

@ -213,18 +213,6 @@ fn backup_name_reserved_windows_name_becomes_empty() {
assert_eq!(key, "");
}
#[test]
fn group_backup_key_uses_committed_chat_file_stem() {
assert_eq!(
FileChatRepository::get_group_backup_key("group/name.jsonl").expect("group backup key"),
"group:groupname"
);
assert_eq!(
FileChatRepository::get_group_backup_key("Story.JSONL").expect("group backup key"),
"group:Story.JSONL"
);
}
#[test]
fn backup_file_prefix_matches_sillytavern_pattern() {
let prefix = FileChatRepository::backup_file_prefix("A:li*ce Name");
@ -373,6 +361,12 @@ async fn automatic_quota_rejection_does_not_fail_current_save() {
.expect("current save must succeed");
assert!(backup_file_names(&root).await.is_empty());
repository
.backup_chat_automatic("Alice", "session")
.await
.expect("automatic quota rejection is an expected skip");
assert!(backup_file_names(&root).await.is_empty());
let error = repository
.backup_chat("Alice", "session")
.await
@ -387,6 +381,76 @@ async fn automatic_quota_rejection_does_not_fail_current_save() {
);
}
#[tokio::test]
async fn automatic_character_and_group_snapshots_run_only_when_requested() {
let (repository, root) = setup_repository().await;
apply_and_reconcile_backups(&repository, backup_policy(-1, -1, -1)).await;
let source = root.join("source.jsonl");
fs::write(
&source,
payload_to_jsonl(&payload_with_integrity("automatic")),
)
.await
.expect("write source");
repository
.save_chat_payload_from_path("Alice", "session", &source, false)
.await
.expect("save character current");
repository
.save_group_chat_payload_from_path("group-session", &source, false)
.await
.expect("save group current");
assert!(backup_file_names(&root).await.is_empty());
repository
.backup_chat_automatic("Alice", "session")
.await
.expect("automatic character snapshot");
repository
.backup_group_chat_automatic("group-session")
.await
.expect("automatic group snapshot");
assert_eq!(backup_file_names(&root).await.len(), 2);
}
#[tokio::test]
async fn automatic_snapshot_defers_instead_of_waiting_for_a_busy_current() {
let (repository, root) = setup_repository().await;
apply_and_reconcile_backups(&repository, backup_policy(-1, -1, -1)).await;
let source = root.join("source.jsonl");
fs::write(
&source,
payload_to_jsonl(&payload_with_integrity("automatic-busy")),
)
.await
.expect("write source");
repository
.save_chat_payload_from_path("Alice", "session", &source, false)
.await
.expect("save character current");
let current_path = repository
.get_chat_payload_path("Alice", "session")
.await
.expect("resolve current path");
let current_guard = repository.acquire_payload_write_lock(&current_path).await;
let error = repository
.backup_chat_automatic("Alice", "session")
.await
.expect_err("busy current should defer the automatic snapshot");
assert!(matches!(error, DomainError::Transient(_)));
assert!(backup_file_names(&root).await.is_empty());
drop(current_guard);
repository
.backup_chat_automatic("Alice", "session")
.await
.expect("automatic snapshot after current writer finishes");
assert_eq!(backup_file_names(&root).await.len(), 1);
}
#[tokio::test]
async fn automatic_toggle_does_not_disable_explicit_backups() {
let (repository, root) = setup_repository().await;

View file

@ -47,9 +47,6 @@ impl FileChatRepository {
}
self.remove_summary_cache_for_path(&path).await;
self.backup_chat_file_automatic(&path, character_name, &backup_key)
.await;
Ok(result)
}
@ -65,7 +62,6 @@ impl FileChatRepository {
let path = self.get_group_chat_path(chat_id)?;
let _write_guard = self.acquire_payload_write_lock(&path).await;
let backup_key = Self::get_group_backup_key(chat_id)?;
let result = hide_payload_before_cursor_internal(
&path,
cursor,
@ -76,8 +72,6 @@ impl FileChatRepository {
.await?;
self.remove_summary_cache_for_path(&path).await;
self.backup_chat_file_automatic(&path, chat_id, &backup_key)
.await;
Ok(result)
}

View file

@ -42,9 +42,6 @@ impl FileChatRepository {
}
self.remove_summary_cache_for_path(&path).await;
self.backup_chat_file_automatic(&path, character_name, &backup_key)
.await;
Ok(result)
}
@ -57,12 +54,9 @@ impl FileChatRepository {
let path = self.get_group_chat_path(chat_id)?;
let _write_guard = self.acquire_payload_write_lock(&path).await;
let backup_key = Self::get_group_backup_key(chat_id)?;
let result = patch_payload_windowed_internal(&path, request).await?;
self.remove_summary_cache_for_path(&path).await;
self.backup_chat_file_automatic(&path, chat_id, &backup_key)
.await;
Ok(result)
}

View file

@ -5,6 +5,8 @@ use tt_ports::repositories::chat_repository::{
ChatPayloadCursor, ChatPayloadPatchOp, ChatSearchResult, PinnedCharacterChat, PinnedGroupChat,
};
use super::chat_history_dto::CurrentCommitReason;
/// DTO for chat message extra data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageExtraDto {
@ -160,6 +162,7 @@ pub struct SaveChatFromFileDto {
pub file_name: String,
pub file_path: String,
pub force: Option<bool>,
pub commit_reason: Option<CurrentCommitReason>,
}
/// DTO for patching a windowed character chat payload.
@ -173,6 +176,7 @@ pub struct PatchChatWindowedDto {
pub patch: ChatPayloadPatchOp,
pub expected_window_line_count: usize,
pub force: Option<bool>,
pub commit_reason: Option<CurrentCommitReason>,
}
/// DTO for toggling the hidden flag on messages before the window cursor.
@ -203,6 +207,7 @@ pub struct SaveGroupChatFromFileDto {
pub id: String,
pub file_path: String,
pub force: Option<bool>,
pub commit_reason: Option<CurrentCommitReason>,
}
/// DTO for patching a windowed group chat payload.
@ -214,6 +219,7 @@ pub struct PatchGroupChatWindowedDto {
pub patch: ChatPayloadPatchOp,
pub expected_window_line_count: usize,
pub force: Option<bool>,
pub commit_reason: Option<CurrentCommitReason>,
}
/// DTO for deleting a group chat payload

View file

@ -0,0 +1,60 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum ChatHistoryLocator {
#[serde(rename = "character")]
Character {
#[serde(rename = "characterId")]
character_id: String,
#[serde(rename = "fileName")]
file_name: String,
},
#[serde(rename = "group")]
Group {
#[serde(rename = "chatId")]
chat_id: String,
},
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CurrentCommitReason {
#[default]
Mutation,
ProviderBarrier,
GenerationCheckpoint,
Maintenance,
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn frontend_locator_and_reason_shape_is_camel_case() {
let locator: ChatHistoryLocator = serde_json::from_value(json!({
"kind": "character",
"characterId": "Alice#1",
"fileName": "Story",
}))
.expect("deserialize character locator");
assert_eq!(
locator,
ChatHistoryLocator::Character {
character_id: "Alice#1".to_string(),
file_name: "Story".to_string(),
}
);
let reason: CurrentCommitReason = serde_json::from_value(json!("generationCheckpoint"))
.expect("deserialize commit reason");
assert_eq!(reason, CurrentCommitReason::GenerationCheckpoint);
let maintenance: CurrentCommitReason =
serde_json::from_value(json!("maintenance")).expect("deserialize maintenance reason");
assert_eq!(maintenance, CurrentCommitReason::Maintenance);
}
}

View file

@ -5,6 +5,7 @@ pub mod bootstrap_dto;
pub mod character_dto;
pub mod chat_completion_dto;
pub mod chat_dto;
pub mod chat_history_dto;
pub mod data_archive_dto;
pub mod dev_observability_dto;
pub mod group_dto;

View file

@ -15,6 +15,7 @@ use crate::errors::ApplicationError;
use crate::services::agent_workspace_lifecycle_service::{
AgentChatWorkspaceTarget, AgentWorkspaceLifecycleService,
};
use crate::services::chat_history_coordinator::ChatHistoryCoordinator;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
@ -38,6 +39,7 @@ pub struct CharacterService {
chat_repository: Arc<dyn ChatRepository>,
world_info_repository: Arc<dyn WorldInfoRepository>,
agent_workspace_lifecycle_service: Arc<AgentWorkspaceLifecycleService>,
chat_history_coordinator: Arc<ChatHistoryCoordinator>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -59,12 +61,14 @@ impl CharacterService {
chat_repository: Arc<dyn ChatRepository>,
world_info_repository: Arc<dyn WorldInfoRepository>,
agent_workspace_lifecycle_service: Arc<AgentWorkspaceLifecycleService>,
chat_history_coordinator: Arc<ChatHistoryCoordinator>,
) -> Self {
Self {
repository,
chat_repository,
world_info_repository,
agent_workspace_lifecycle_service,
chat_history_coordinator,
}
}
@ -471,7 +475,18 @@ impl CharacterService {
.ensure_chat_workspaces_inactive(&workspace_targets)
.await?;
let execution_guard = self
.chat_history_coordinator
.lock_snapshot_execution()
.await;
self.chat_history_coordinator
.invalidate_character(&dto.name)
.await;
self.repository.delete(&dto.name, dto.delete_chats).await?;
self.chat_history_coordinator
.invalidate_character(&dto.name)
.await;
drop(execution_guard);
self.agent_workspace_lifecycle_service
.delete_chat_workspaces(&workspace_targets)
.await?;
@ -510,7 +525,24 @@ impl CharacterService {
self.validate_character_name(&dto.new_name)?;
tracing::debug!("Renaming character: {} -> {}", dto.old_name, dto.new_name);
let execution_guard = self
.chat_history_coordinator
.lock_snapshot_execution()
.await;
self.chat_history_coordinator
.invalidate_character(&dto.old_name)
.await;
self.chat_history_coordinator
.invalidate_character(&dto.new_name)
.await;
let character = self.repository.rename(&dto.old_name, &dto.new_name).await?;
self.chat_history_coordinator
.invalidate_character(&dto.old_name)
.await;
self.chat_history_coordinator
.invalidate_character(&dto.new_name)
.await;
drop(execution_guard);
Ok(CharacterDto::from(character))
}

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@ use crate::dto::chat_dto::{
AddMessageDto, ChatDto, ChatSearchResultDto, CreateChatDto, ExportChatDto,
ImportCharacterChatsDto, ImportChatDto, RenameChatDto, SaveChatFromFileDto,
};
use crate::dto::chat_history_dto::{ChatHistoryLocator, CurrentCommitReason};
use crate::errors::ApplicationError;
use crate::services::agent_workspace_lifecycle_service::{
AgentChatWorkspaceTarget, AgentWorkspaceLifecycleService,
@ -14,6 +15,7 @@ use crate::services::agent_workspace_lifecycle_service::{
use crate::services::chat_file_validation::{
validate_character_path_component, validate_chat_file_name,
};
use crate::services::chat_history_coordinator::ChatHistoryCoordinator;
use tt_domain::errors::DomainError;
use tt_domain::models::chat::{Chat, ChatMessage, MessageExtra};
use tt_ports::repositories::agent_workspace_lifecycle_repository::{
@ -32,6 +34,7 @@ pub struct ChatService {
chat_repository: Arc<dyn ChatRepository>,
character_repository: Arc<dyn CharacterRepository>,
agent_workspace_lifecycle_service: Arc<AgentWorkspaceLifecycleService>,
chat_history_coordinator: Arc<ChatHistoryCoordinator>,
}
impl ChatService {
@ -40,11 +43,13 @@ impl ChatService {
chat_repository: Arc<dyn ChatRepository>,
character_repository: Arc<dyn CharacterRepository>,
agent_workspace_lifecycle_service: Arc<AgentWorkspaceLifecycleService>,
chat_history_coordinator: Arc<ChatHistoryCoordinator>,
) -> Self {
Self {
chat_repository,
character_repository,
agent_workspace_lifecycle_service,
chat_history_coordinator,
}
}
@ -68,6 +73,14 @@ impl ChatService {
// Save the chat
self.chat_repository.save(&chat).await?;
if let Some(file_name) = chat.file_name.as_deref() {
self.note_current_committed(
&chat.character_name,
file_name,
CurrentCommitReason::Maintenance,
)
.await;
}
Ok(ChatDto::from(chat))
}
@ -152,6 +165,12 @@ impl ChatService {
.chat_repository
.add_message(&dto.character_name, &dto.file_name, message)
.await?;
self.note_current_committed(
&dto.character_name,
&dto.file_name,
CurrentCommitReason::Mutation,
)
.await;
Ok(ChatDto::from(chat))
}
@ -175,6 +194,16 @@ impl ChatService {
.rename_chat(&dto.character_name, &dto.old_file_name, &dto.new_file_name)
.await?;
self.chat_history_coordinator
.invalidate(&character_locator(&dto.character_name, &dto.old_file_name))
.await;
self.chat_history_coordinator
.invalidate(&character_locator(
&dto.character_name,
&committed_file_name,
))
.await;
Ok(committed_file_name)
}
@ -207,6 +236,9 @@ impl ChatService {
self.chat_repository
.delete_chat(character_name, file_name)
.await?;
self.chat_history_coordinator
.invalidate(&character_locator(character_name, file_name))
.await;
if let Some(target) = target {
self.agent_workspace_lifecycle_service
@ -325,8 +357,8 @@ impl ChatService {
) -> Result<(), ApplicationError> {
tracing::info!("Backing up chat: {}/{}", character_name, file_name);
self.chat_repository
.backup_chat(character_name, file_name)
self.chat_history_coordinator
.backup_character_explicit(character_name, file_name)
.await?;
Ok(())
@ -405,6 +437,8 @@ impl ChatService {
self.chat_repository
.set_character_chat_metadata_extension(character_name, file_name, namespace, value)
.await?;
self.note_current_committed(character_name, file_name, CurrentCommitReason::Mutation)
.await;
Ok(())
}
@ -624,14 +658,18 @@ impl ChatService {
character_name: &str,
file_name: &str,
request: ChatPayloadWindowPatchRequest,
commit_reason: CurrentCommitReason,
) -> Result<ChatPayloadCursor, ApplicationError> {
validate_character_path_component(character_name)?;
validate_chat_file_name(file_name, "Chat file name")?;
self.chat_repository
let cursor = self
.chat_repository
.patch_chat_payload_windowed(character_name, file_name, request)
.await
.map_err(Into::into)
.await?;
self.note_current_committed(character_name, file_name, commit_reason)
.await;
Ok(cursor)
}
/// Set the hidden flag on all messages stored before the window cursor.
@ -647,7 +685,8 @@ impl ChatService {
validate_character_path_component(character_name)?;
validate_chat_file_name(file_name, "Chat file name")?;
self.chat_repository
let cursor = self
.chat_repository
.hide_chat_payload_before_cursor(
character_name,
file_name,
@ -656,8 +695,10 @@ impl ChatService {
name_filter,
expected_window_line_count,
)
.await
.map_err(Into::into)
.await?;
self.note_current_committed(character_name, file_name, CurrentCommitReason::Mutation)
.await;
Ok(cursor)
}
/// Save a character chat payload from a JSONL file path.
@ -668,6 +709,7 @@ impl ChatService {
validate_character_path_component(&dto.character_name)?;
validate_chat_file_name(&dto.file_name, "Chat file name")?;
let commit_reason = dto.commit_reason.unwrap_or_default();
self.chat_repository
.save_chat_payload_from_path(
&dto.character_name,
@ -675,8 +717,10 @@ impl ChatService {
Path::new(&dto.file_path),
dto.force.unwrap_or(false),
)
.await
.map_err(Into::into)
.await?;
self.note_current_committed(&dto.character_name, &dto.file_name, commit_reason)
.await;
Ok(())
}
/// Import one or more character chats from an uploaded file.
@ -712,4 +756,22 @@ impl ChatService {
.await
.map_err(Into::into)
}
async fn note_current_committed(
&self,
character_id: &str,
file_name: &str,
reason: CurrentCommitReason,
) {
self.chat_history_coordinator
.note_current_committed(character_locator(character_id, file_name), reason)
.await;
}
}
fn character_locator(character_id: &str, file_name: &str) -> ChatHistoryLocator {
ChatHistoryLocator::Character {
character_id: character_id.to_string(),
file_name: file_name.to_string(),
}
}

View file

@ -7,9 +7,11 @@ use crate::dto::chat_dto::{
ChatSearchResultDto, DeleteGroupChatDto, ImportGroupChatDto, RenameGroupChatDto,
SaveGroupChatFromFileDto,
};
use crate::dto::chat_history_dto::{ChatHistoryLocator, CurrentCommitReason};
use crate::errors::ApplicationError;
use crate::services::agent_workspace_lifecycle_service::AgentWorkspaceLifecycleService;
use crate::services::chat_file_validation::validate_chat_file_name;
use crate::services::chat_history_coordinator::ChatHistoryCoordinator;
use tt_domain::errors::DomainError;
use tt_ports::repositories::chat_types::{
ChatMessageSearchHit, ChatMessageSearchQuery, ChatPayloadChunk, ChatPayloadCursor,
@ -22,16 +24,19 @@ use tt_ports::repositories::group_chat_repository::GroupChatRepository;
pub struct GroupChatService {
group_chat_repository: Arc<dyn GroupChatRepository>,
agent_workspace_lifecycle_service: Arc<AgentWorkspaceLifecycleService>,
chat_history_coordinator: Arc<ChatHistoryCoordinator>,
}
impl GroupChatService {
pub fn new(
group_chat_repository: Arc<dyn GroupChatRepository>,
agent_workspace_lifecycle_service: Arc<AgentWorkspaceLifecycleService>,
chat_history_coordinator: Arc<ChatHistoryCoordinator>,
) -> Self {
Self {
group_chat_repository,
agent_workspace_lifecycle_service,
chat_history_coordinator,
}
}
@ -107,6 +112,8 @@ impl GroupChatService {
self.group_chat_repository
.set_group_chat_metadata_extension(chat_id, namespace, value)
.await?;
self.note_current_committed(chat_id, CurrentCommitReason::Mutation)
.await;
Ok(())
}
@ -297,13 +304,16 @@ impl GroupChatService {
&self,
chat_id: &str,
request: ChatPayloadWindowPatchRequest,
commit_reason: CurrentCommitReason,
) -> Result<ChatPayloadCursor, ApplicationError> {
validate_chat_file_name(chat_id, "Group chat id")?;
self.group_chat_repository
let cursor = self
.group_chat_repository
.patch_group_chat_payload_windowed(chat_id, request)
.await
.map_err(Into::into)
.await?;
self.note_current_committed(chat_id, commit_reason).await;
Ok(cursor)
}
/// Set the hidden flag on all messages stored before the window cursor.
@ -317,7 +327,8 @@ impl GroupChatService {
) -> Result<ChatPayloadCursor, ApplicationError> {
validate_chat_file_name(chat_id, "Group chat id")?;
self.group_chat_repository
let cursor = self
.group_chat_repository
.hide_group_chat_payload_before_cursor(
chat_id,
cursor,
@ -325,8 +336,10 @@ impl GroupChatService {
name_filter,
expected_window_line_count,
)
.await
.map_err(Into::into)
.await?;
self.note_current_committed(chat_id, CurrentCommitReason::Mutation)
.await;
Ok(cursor)
}
/// Save a group chat payload from a JSONL file path.
@ -336,14 +349,16 @@ impl GroupChatService {
) -> Result<(), ApplicationError> {
validate_chat_file_name(&dto.id, "Group chat id")?;
let commit_reason = dto.commit_reason.unwrap_or_default();
self.group_chat_repository
.save_group_chat_payload_from_path(
&dto.id,
Path::new(&dto.file_path),
dto.force.unwrap_or(false),
)
.await
.map_err(Into::into)
.await?;
self.note_current_committed(&dto.id, commit_reason).await;
Ok(())
}
/// Delete a group chat payload file.
@ -358,6 +373,9 @@ impl GroupChatService {
self.group_chat_repository
.delete_group_chat_payload(&dto.id)
.await?;
self.chat_history_coordinator
.invalidate(&group_locator(&dto.id))
.await;
self.agent_workspace_lifecycle_service
.delete_chat_workspace(&target)
.await?;
@ -376,6 +394,12 @@ impl GroupChatService {
.group_chat_repository
.rename_group_chat_payload(&dto.old_file_name, &dto.new_file_name)
.await?;
self.chat_history_coordinator
.invalidate(&group_locator(&dto.old_file_name))
.await;
self.chat_history_coordinator
.invalidate(&group_locator(&committed_file_name))
.await;
Ok(committed_file_name)
}
@ -389,4 +413,16 @@ impl GroupChatService {
.await
.map_err(Into::into)
}
async fn note_current_committed(&self, chat_id: &str, reason: CurrentCommitReason) {
self.chat_history_coordinator
.note_current_committed(group_locator(chat_id), reason)
.await;
}
}
fn group_locator(chat_id: &str) -> ChatHistoryLocator {
ChatHistoryLocator::Group {
chat_id: chat_id.to_string(),
}
}

View file

@ -1,9 +1,11 @@
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::dto::chat_history_dto::ChatHistoryLocator;
use crate::dto::group_dto::{CreateGroupDto, DeleteGroupDto, UpdateGroupDto};
use crate::errors::ApplicationError;
use crate::services::agent_workspace_lifecycle_service::AgentWorkspaceLifecycleService;
use crate::services::chat_history_coordinator::ChatHistoryCoordinator;
use tt_domain::errors::DomainError;
use tt_domain::models::group::Group;
use tt_ports::repositories::group_repository::GroupRepository;
@ -13,6 +15,7 @@ pub struct GroupService {
/// Repository for group data
repository: Arc<dyn GroupRepository>,
agent_workspace_lifecycle_service: Arc<AgentWorkspaceLifecycleService>,
chat_history_coordinator: Arc<ChatHistoryCoordinator>,
}
impl GroupService {
@ -20,10 +23,12 @@ impl GroupService {
pub fn new(
repository: Arc<dyn GroupRepository>,
agent_workspace_lifecycle_service: Arc<AgentWorkspaceLifecycleService>,
chat_history_coordinator: Arc<ChatHistoryCoordinator>,
) -> Self {
Self {
repository,
agent_workspace_lifecycle_service,
chat_history_coordinator,
}
}
@ -112,7 +117,26 @@ impl GroupService {
.ensure_chat_workspaces_inactive(&targets)
.await?;
let execution_guard = self
.chat_history_coordinator
.lock_snapshot_execution()
.await;
for chat_id in &group.chats {
self.chat_history_coordinator
.invalidate(&ChatHistoryLocator::Group {
chat_id: chat_id.clone(),
})
.await;
}
self.repository.delete_group(&dto.id).await?;
for chat_id in &group.chats {
self.chat_history_coordinator
.invalidate(&ChatHistoryLocator::Group {
chat_id: chat_id.clone(),
})
.await;
}
drop(execution_guard);
self.agent_workspace_lifecycle_service
.delete_chat_workspaces(&targets)
.await?;

View file

@ -19,6 +19,7 @@ pub mod bundled_template_service;
pub mod character_service;
pub mod chat_completion_service;
mod chat_file_validation;
pub mod chat_history_coordinator;
pub mod chat_service;
pub mod content_service;
pub mod data_archive_service;

View file

@ -94,6 +94,16 @@ pub trait ChatRepository: Send + Sync {
/// Backup a chat
async fn backup_chat(&self, character_name: &str, file_name: &str) -> Result<(), DomainError>;
/// Create an automatic history snapshot for a character chat.
///
/// Scheduling is owned by the application layer; the repository only
/// serializes the source payload and publishes the snapshot.
async fn backup_chat_automatic(
&self,
character_name: &str,
file_name: &str,
) -> Result<(), DomainError>;
/// List all chat backup files.
async fn list_chat_backups(&self) -> Result<Vec<ChatSearchResult>, DomainError>;

View file

@ -80,6 +80,12 @@ pub trait GroupChatRepository: Send + Sync {
force: bool,
) -> Result<(), DomainError>;
/// Create an automatic history snapshot for a group chat.
///
/// Scheduling is owned by the application layer; the repository only
/// serializes the source payload and publishes the snapshot.
async fn backup_group_chat_automatic(&self, chat_id: &str) -> Result<(), DomainError>;
/// Delete a group chat payload file.
async fn delete_group_chat_payload(&self, chat_id: &str) -> Result<(), DomainError>;

View file

@ -9,13 +9,14 @@ import {
default as libs,
lodash,
} from './lib.js';
import { getClientVersion as getBridgeClientVersion } from './tauri-bridge.js';
import { getClientVersion as getBridgeClientVersion, invoke } from './tauri-bridge.js';
import { SILLYTAVERN_COMPAT_VERSION } from './compat-version.js';
import { registerLifecycleFlushHandler } from './tauri/main/services/lifecycle/lifecycle-flush-service.js';
import { replaceMesTextHtmlWithRuntimePolicy } from './scripts/tauri/message/mes-text-write.js';
import { getCodeHighlightCoordinator } from './scripts/tauri/perf/code-highlight-coordinator.js';
import { isInlineDrawerContentOpen, setInlineDrawerContentOpen } from './scripts/tauri/perf/inline-drawer-motion.js';
import {
CHAT_COMMIT_REASON,
isTauriChatPayloadTransportEnabled,
loadCharacterChatPayload,
loadCharacterChatPayloadTail,
@ -25,6 +26,7 @@ import {
saveCharacterChatPayload,
patchCharacterChatPayloadWindowed,
} from './scripts/chat-payload-transport.js';
import { getActiveChatSnapshot } from './tauri/main/adapters/st/active-chat-ref.js';
import {
buildWindowedPayloadPatch,
clearWindowedChatState,
@ -4554,7 +4556,9 @@ class StreamingProcessor {
if (!isAborted && power_user.auto_swipe && generatedTextFiltered(text)) {
return await swipe(null, SWIPE_DIRECTION.RIGHT, { source: SWIPE_SOURCE.AUTO_SWIPE, repeated: true, forceMesId: chat.length - 1 });
}
await saveChatConditional();
await saveChatConditional(this.type === 'impersonate'
? CHAT_COMMIT_REASON.MUTATION
: CHAT_COMMIT_REASON.GENERATION_CHECKPOINT);
playMessageSound();
}
@ -5033,26 +5037,58 @@ function removeLastMessage() {
const generationIdleGate = createGenerationIdleGate();
let generationInFlightCount = 0;
let generationHistoryLocator = null;
export function waitForGenerationIdle() {
return generationIdleGate.wait();
}
function enterGeneration() {
async function enterGeneration(dryRun) {
if (generationInFlightCount === 0) {
generationIdleGate.markBusy();
generationInFlightCount = 1;
generationHistoryLocator = null;
// Prompt-assembly dry runs do not define a chat-history transaction.
if (dryRun) return;
try {
generationHistoryLocator = getActiveChatSnapshot().ref;
} catch (error) {
generationHistoryLocator = null;
console.warn('Failed to capture chat-history generation locator', error);
}
if (generationHistoryLocator) {
try {
await invoke('chat_history_generation_started', { locator: generationHistoryLocator });
} catch (error) {
console.error('Failed to report chat-history generation start', error);
}
}
return;
}
generationInFlightCount += 1;
}
function exitGeneration() {
async function exitGeneration() {
if (generationInFlightCount <= 0) {
throw new Error('Generation in-flight counter underflow');
}
generationInFlightCount -= 1;
if (generationInFlightCount === 0) {
generationIdleGate.markIdle();
const locator = generationHistoryLocator;
generationHistoryLocator = null;
try {
if (locator) {
await invoke('chat_history_generation_finished', { locator });
}
} catch (error) {
console.error('Failed to report chat-history generation finish', error);
} finally {
generationIdleGate.markIdle();
}
}
}
@ -5065,14 +5101,14 @@ function exitGeneration() {
* @returns {Promise<any>} Returns a promise that resolves when the text is done generating.
*/
export async function Generate(type, options = {}, dryRun = false) {
enterGeneration();
await enterGeneration(dryRun);
try {
return await GenerateInternal(type, options, dryRun);
} catch (error) {
cleanupGenerationAfterUnhandledError(type, dryRun);
throw error;
} finally {
exitGeneration();
await exitGeneration();
}
}
@ -5259,11 +5295,11 @@ async function GenerateInternal(type, { automatic_trigger, force_name2, quiet_pr
if (messageBias && !removeMacros(textareaText)) {
sendSystemMessage(system_message_types.GENERIC, ' ', { bias: messageBias });
} else {
await sendMessageAsUser(textareaText, messageBias);
await sendMessageAsUserAtProviderBarrier(textareaText, messageBias);
}
} else if (textareaText == '' && !automatic_trigger && !dryRun && [undefined, 'normal'].includes(type) && main_api == 'openai' && oai_settings.send_if_empty.trim().length > 0 && !depth) {
// Use send_if_empty if set and the user message is empty. Only when sending messages normally
await sendMessageAsUser(oai_settings.send_if_empty.trim(), messageBias);
await sendMessageAsUserAtProviderBarrier(oai_settings.send_if_empty.trim(), messageBias);
}
let {
@ -6487,7 +6523,9 @@ async function GenerateInternal(type, { automatic_trigger, force_name2, quiet_pr
}
console.debug('/api/chats/save called by /Generate');
await saveChatConditional();
await saveChatConditional(isImpersonate
? CHAT_COMMIT_REASON.MUTATION
: CHAT_COMMIT_REASON.GENERATION_CHECKPOINT);
unblockGeneration(type);
streamingProcessor = null;
@ -6955,9 +6993,10 @@ export function removeMacros(str) {
* @param {boolean} [compact] Send as a compact display message.
* @param {string} [name] Name of the user sending the message. Defaults to name1.
* @param {string} [avatar] Avatar of the user sending the message. Defaults to user_avatar.
* @param {string} [commitReason] Current/history scheduling reason.
* @returns {Promise<any>} A promise that resolves to the message when it is inserted.
*/
export async function sendMessageAsUser(messageText, messageBias, insertAt = null, compact = false, name = name1, avatar = user_avatar) {
export async function sendMessageAsUser(messageText, messageBias, insertAt = null, compact = false, name = name1, avatar = user_avatar, commitReason = CHAT_COMMIT_REASON.MUTATION) {
messageText = getRegexedString(messageText, regex_placement.USER_INPUT);
const message = {
@ -6993,13 +7032,13 @@ export async function sendMessageAsUser(messageText, messageBias, insertAt = nul
if (typeof insertAt === 'number' && insertAt >= 0 && insertAt <= chat.length) {
chat.splice(insertAt, 0, message);
markWindowedChatDirtyFromIndex(insertAt);
await saveChatConditional();
await saveChatConditional(commitReason);
await eventSource.emit(event_types.MESSAGE_SENT, insertAt);
await reloadCurrentChat();
await eventSource.emit(event_types.USER_MESSAGE_RENDERED, insertAt);
} else {
chat.push(message);
await saveChatConditional();
await saveChatConditional(commitReason);
const chat_id = (chat.length - 1);
await eventSource.emit(event_types.MESSAGE_SENT, chat_id);
addOneMessage(message);
@ -7009,6 +7048,18 @@ export async function sendMessageAsUser(messageText, messageBias, insertAt = nul
return message;
}
export function sendMessageAsUserAtProviderBarrier(messageText, messageBias) {
return sendMessageAsUser(
messageText,
messageBias,
null,
false,
name1,
user_avatar,
CHAT_COMMIT_REASON.PROVIDER_BARRIER,
);
}
/**
* Gets the maximum context token limit (the full context window size before subtracting response length).
* @returns {number} The maximum context token limit for the current API.
@ -8555,6 +8606,7 @@ export function markWindowedChatDirtyFromIndex(messageId) {
* @param {number} [options.mesId] The message ID to save the chat up to
* @param {boolean} [options.force] Force the saving despite the integrity check result
* @param {ChatMessage[]} [options.chatData] Chat snapshot to save instead of the current in-memory chat
* @param {string} [options.commitReason] Current/history scheduling reason.
*
* @returns {Promise<void>}
*/
@ -8567,7 +8619,7 @@ export async function saveChat(...args) {
return enqueueChatSave(() => saveChatUnsafe(...args));
}
async function saveChatUnsafe({ chatName, withMetadata, mesId, force = false, chatData = undefined } = {}) {
async function saveChatUnsafe({ chatName, withMetadata, mesId, force = false, chatData = undefined, commitReason = CHAT_COMMIT_REASON.MUTATION } = {}) {
if (arguments.length > 0 && typeof arguments[0] !== 'object') {
console.trace('saveChat called with positional arguments. Please use an object instead.');
[chatName, withMetadata, mesId, force] = arguments;
@ -8625,6 +8677,7 @@ async function saveChatUnsafe({ chatName, withMetadata, mesId, force = false, ch
patch,
expectedWindowLineCount,
force: Boolean(force),
commitReason,
});
const activeWindowState = getWindowedChatState();
@ -8650,6 +8703,7 @@ async function saveChatUnsafe({ chatName, withMetadata, mesId, force = false, ch
fileName,
payload,
force: Boolean(force),
commitReason,
});
}
return;
@ -8665,6 +8719,7 @@ async function saveChatUnsafe({ chatName, withMetadata, mesId, force = false, ch
chat: payload,
avatar_url: characters[this_chid].avatar,
force: force,
commit_reason: commitReason,
}),
});
const result = await fetch('/api/chats/save', saveChatRequest);
@ -8708,7 +8763,7 @@ async function saveChatUnsafe({ chatName, withMetadata, mesId, force = false, ch
return;
}
await saveChatUnsafe({ chatName, withMetadata, mesId, force: true });
await saveChatUnsafe({ chatName, withMetadata, mesId, force: true, chatData, commitReason });
}
}
@ -8993,7 +9048,7 @@ async function getChatResult({ allowNewChat = false } = {}) {
freshChat = true;
}
// Make sure the chat appears on the server
await saveChatConditional();
await saveChatConditional(CHAT_COMMIT_REASON.MAINTENANCE);
}
await loadItemizedPrompts(getCurrentChatId());
await printMessages({ frontendSourceHandoffEvent: event_types.CHAT_LOADED });
@ -10859,13 +10914,13 @@ export async function saveMetadata() {
return await saveChatConditional();
}
export async function saveChatConditional() {
export async function saveChatConditional(commitReason = CHAT_COMMIT_REASON.MUTATION) {
try {
cancelDebouncedChatSave();
const savePromise = selected_group
? saveGroupChat(selected_group, true)
: saveChat();
? saveGroupChat(selected_group, true, false, commitReason)
: saveChat({ commitReason });
// Keep prompt/token persistence serialized with chat writes to avoid
// chat switches corrupting per-chat IndexedDB state.

View file

@ -1,5 +1,6 @@
export { payloadToJsonl, jsonlToPayload } from './tauri/chat/jsonl.js';
export {
CHAT_COMMIT_REASON,
isTauriChatPayloadTransportEnabled,
normalizeChatFileName,
resolveCharacterDirectoryId,

View file

@ -88,6 +88,7 @@ import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
import { t } from './i18n.js';
import { accountStorage } from './util/AccountStorage.js';
import {
CHAT_COMMIT_REASON,
isTauriChatPayloadTransportEnabled,
loadGroupChatPayloadTail,
saveGroupChatPayload,
@ -395,7 +396,7 @@ export async function getGroupChat(groupId, reload = false, { allowNewChat = fal
addOneMessage(mes);
await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, (chat.length - 1), 'first_message');
}
await saveGroupChat(groupId, false);
await saveGroupChat(groupId, false, false, CHAT_COMMIT_REASON.MAINTENANCE);
} else if (Array.isArray(data) && data.length) {
if (!isStillActive()) {
return;
@ -731,13 +732,14 @@ function resetSelectedGroup() {
* @param {string} groupId Group ID
* @param {boolean} shouldSaveGroup Whether to save the group after saving the chat
* @param {boolean} force Force the saving on integrity error
* @param {string} commitReason Current/history scheduling reason
* @returns {Promise<void>} A promise that resolves when the group chat has been saved.
*/
async function saveGroupChat(groupId, shouldSaveGroup, force = false) {
return enqueueChatSave(() => saveGroupChatUnsafe(groupId, shouldSaveGroup, force));
async function saveGroupChat(groupId, shouldSaveGroup, force = false, commitReason = CHAT_COMMIT_REASON.MUTATION) {
return enqueueChatSave(() => saveGroupChatUnsafe(groupId, shouldSaveGroup, force, commitReason));
}
async function saveGroupChatUnsafe(groupId, shouldSaveGroup, force = false) {
async function saveGroupChatUnsafe(groupId, shouldSaveGroup, force = false, commitReason = CHAT_COMMIT_REASON.MUTATION) {
const group = groups.find(x => x.id == groupId);
if (!group) {
console.warn('Group not found', groupId);
@ -778,6 +780,7 @@ async function saveGroupChatUnsafe(groupId, shouldSaveGroup, force = false) {
patch,
expectedWindowLineCount,
force: Boolean(force),
commitReason,
});
const activeWindowState = getWindowedChatState();
@ -800,13 +803,14 @@ async function saveGroupChatUnsafe(groupId, shouldSaveGroup, force = false) {
id: chatId,
payload,
force: Boolean(force),
commitReason,
});
}
} else {
const response = await fetch('/api/chats/group/save', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ id: chatId, chat: payload, force: force }),
body: JSON.stringify({ id: chatId, chat: payload, force: force, commit_reason: commitReason }),
});
if (response.ok) {
@ -849,7 +853,7 @@ async function saveGroupChatUnsafe(groupId, shouldSaveGroup, force = false) {
return;
}
await saveGroupChatUnsafe(groupId, shouldSaveGroup, true);
await saveGroupChatUnsafe(groupId, shouldSaveGroup, true, commitReason);
}
if (shouldSaveGroup) {

View file

@ -11,6 +11,13 @@ import {
getChatHistoryBootstrapModeName,
} from '../../../tauri/main/services/chat-history/chat-history-mode-state.js';
export const CHAT_COMMIT_REASON = Object.freeze({
MUTATION: 'mutation',
PROVIDER_BARRIER: 'providerBarrier',
GENERATION_CHECKPOINT: 'generationCheckpoint',
MAINTENANCE: 'maintenance',
});
export function normalizeChatFileName(fileName) {
return stripJsonl(fileName);
}
@ -157,7 +164,7 @@ export async function loadCharacterChatPayloadBeforePages({ characterName, avata
});
}
export async function saveCharacterChatPayload({ characterName, avatarUrl, fileName, payload, force = false }) {
export async function saveCharacterChatPayload({ characterName, avatarUrl, fileName, payload, force = false, commitReason = CHAT_COMMIT_REASON.MUTATION }) {
const normalizedCharacter = resolveCharacterDirectoryId(characterName, avatarUrl);
const normalizedFile = normalizeChatFileName(fileName);
if (!Array.isArray(payload) || payload.length === 0 || !normalizedCharacter || !normalizedFile.trim()) {
@ -170,6 +177,7 @@ export async function saveCharacterChatPayload({ characterName, avatarUrl, fileN
file_name: normalizedFile,
file_path: filePath,
force,
commit_reason: commitReason,
},
}));
}
@ -182,7 +190,7 @@ function normalizeExpectedWindowLineCount(value) {
return normalized;
}
export async function patchCharacterChatPayloadWindowed({ characterName, avatarUrl, fileName, cursor, header, patch, expectedWindowLineCount, force = false }) {
export async function patchCharacterChatPayloadWindowed({ characterName, avatarUrl, fileName, cursor, header, patch, expectedWindowLineCount, force = false, commitReason = CHAT_COMMIT_REASON.MUTATION }) {
const normalizedCharacter = resolveCharacterDirectoryId(characterName, avatarUrl);
const normalizedFile = normalizeChatFileName(fileName);
if (!normalizedCharacter || !normalizedFile.trim()) {
@ -198,6 +206,7 @@ export async function patchCharacterChatPayloadWindowed({ characterName, avatarU
patch,
expected_window_line_count: normalizeExpectedWindowLineCount(expectedWindowLineCount),
force,
commit_reason: commitReason,
},
});
}
@ -305,7 +314,7 @@ export async function loadGroupChatPayloadBeforePages({ id, cursor, maxLines, ma
});
}
export async function saveGroupChatPayload({ id, payload, force = false }) {
export async function saveGroupChatPayload({ id, payload, force = false, commitReason = CHAT_COMMIT_REASON.MUTATION }) {
const normalizedId = normalizeChatFileName(id);
if (!Array.isArray(payload) || payload.length === 0 || !normalizedId.trim()) {
throw new Error('Invalid group chat payload');
@ -316,11 +325,12 @@ export async function saveGroupChatPayload({ id, payload, force = false }) {
id: normalizedId,
file_path: filePath,
force,
commit_reason: commitReason,
},
}));
}
export async function patchGroupChatPayloadWindowed({ id, cursor, header, patch, expectedWindowLineCount, force = false }) {
export async function patchGroupChatPayloadWindowed({ id, cursor, header, patch, expectedWindowLineCount, force = false, commitReason = CHAT_COMMIT_REASON.MUTATION }) {
const normalizedId = normalizeChatFileName(id);
if (!normalizedId.trim()) {
throw new Error('Invalid group chat payload patch request');
@ -334,6 +344,7 @@ export async function patchGroupChatPayloadWindowed({ id, cursor, header, patch,
patch,
expected_window_line_count: normalizeExpectedWindowLineCount(expectedWindowLineCount),
force,
commit_reason: commitReason,
},
});
}

View file

@ -1,6 +1,7 @@
// @ts-check
import { resolveStableChatId } from './agent-chat-identity.js';
import { CHAT_COMMIT_REASON } from '../../../scripts/chat-payload-transport.js';
const activeCommitBridges = new Map();
const TERMINAL_EVENTS = new Set(['run_completed', 'run_partial_success', 'run_cancelled', 'run_failed']);
@ -149,7 +150,7 @@ async function handleChatCommitRequested({ state, event, safeInvoke, readWorkspa
createdMessage: state.createdMessage,
firstSwipeId: state.firstSwipeId,
});
await state.persistChat(script);
await state.persistChat(script, CHAT_COMMIT_REASON.GENERATION_CHECKPOINT);
await safeInvoke('resolve_agent_chat_commit', {
dto: {
@ -183,7 +184,7 @@ async function handlePersistentStateMetadataUpdateRequested({ state, event, safe
const messageId = normalizeMessageId(payload.messageId ?? state.messageId);
const stateId = requirePayloadString(payload, 'stateId');
mergePersistentStateExtraIntoMessage(script.chat, messageId, payload, stateId);
await state.persistChat(script);
await state.persistChat(script, CHAT_COMMIT_REASON.MUTATION);
await safeInvoke('resolve_agent_persistent_state_metadata_update', {
dto: {
@ -438,18 +439,23 @@ function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
async function persistActiveChat(script) {
async function persistActiveChat(script, commitReason) {
const groupChats = await import('../../../scripts/group-chats.js');
if (groupChats.selected_group) {
if (typeof groupChats.saveGroupChat !== 'function') {
throw new Error('saveGroupChat is not available');
}
await groupChats.saveGroupChat(groupChats.selected_group, true);
await groupChats.saveGroupChat(
groupChats.selected_group,
true,
false,
commitReason,
);
return;
}
if (typeof script.saveChat !== 'function') {
throw new Error('saveChat is not available');
}
await script.saveChat();
await script.saveChat({ commitReason });
}

View file

@ -77,7 +77,6 @@ export function registerChatRoutes(router, context, { jsonResponse }) {
if (!characterId || !fileName.trim() || !Array.isArray(body?.chat)) {
return jsonResponse({ error: 'Invalid chat payload' }, 400);
}
try {
await saveCharacterChatPayload({
characterName: characterId,
@ -85,6 +84,7 @@ export function registerChatRoutes(router, context, { jsonResponse }) {
fileName,
payload: body.chat,
force: Boolean(body?.force),
commitReason: body?.commit_reason,
});
return jsonResponse({ ok: true });
} catch (error) {
@ -431,6 +431,7 @@ export function registerChatRoutes(router, context, { jsonResponse }) {
id,
payload: body.chat,
force: Boolean(body?.force),
commitReason: body?.commit_reason,
});
return jsonResponse({ ok: true });
} catch (error) {

View file

@ -52,6 +52,32 @@ test('active chat ref resolves character identity from exact avatar filename', a
});
});
test('active chat ref resolves a group locator from the committed chat id', async () => {
const { getActiveChatSnapshot } = await importFresh(
path.join(REPO_ROOT, 'src/tauri/main/adapters/st/active-chat-ref.js'),
);
const snapshot = withWindow({
SillyTavern: {
getContext: () => ({
chat: [{ mes: 'hello' }, { mes: 'world' }],
chatId: 'party-session.jsonl',
groupId: 'party',
characters: [],
characterId: null,
}),
},
}, () => getActiveChatSnapshot());
assert.deepEqual(snapshot, {
ref: {
kind: 'group',
chatId: 'party-session',
},
windowLength: 2,
});
});
test('active chat ref rejects URL-like active character avatars', async () => {
const { getActiveChatSnapshot } = await importFresh(
path.join(REPO_ROOT, 'src/tauri/main/adapters/st/active-chat-ref.js'),

View file

@ -11,10 +11,11 @@ test('Generate wrapper uses an in-flight lifecycle gate around GenerateInternal(
assert.match(source, /const generationIdleGate = createGenerationIdleGate\(\);/);
assert.match(source, /let generationInFlightCount = 0;/);
assert.match(source, /let generationHistoryLocator = null;/);
assert.match(source, /export function waitForGenerationIdle\(\)\s*{\s*return generationIdleGate\.wait\(\);\s*}/s);
assert.match(source, /function enterGeneration\(\)\s*{\s*if \(generationInFlightCount === 0\) {\s*generationIdleGate\.markBusy\(\);\s*}\s*generationInFlightCount \+= 1;\s*}/s);
assert.match(source, /function exitGeneration\(\)\s*{\s*if \(generationInFlightCount <= 0\) {\s*throw new Error\('Generation in-flight counter underflow'\);\s*}\s*generationInFlightCount -= 1;\s*if \(generationInFlightCount === 0\) {\s*generationIdleGate\.markIdle\(\);\s*}\s*}/s);
assert.match(source, /export async function Generate\(type, options = \{\}, dryRun = false\)\s*{\s*enterGeneration\(\);\s*try {\s*return await GenerateInternal\(type, options, dryRun\);\s*} catch \(error\) {\s*cleanupGenerationAfterUnhandledError\(type, dryRun\);\s*throw error;\s*} finally {\s*exitGeneration\(\);\s*}\s*}/s);
assert.match(source, /async function enterGeneration\(dryRun\)[\s\S]*generationInFlightCount = 1;[\s\S]*generationHistoryLocator = null;[\s\S]*if \(dryRun\) return;[\s\S]*getActiveChatSnapshot\(\)\.ref;[\s\S]*invoke\('chat_history_generation_started', \{ locator: generationHistoryLocator \}\);/);
assert.match(source, /async function exitGeneration\(\)[\s\S]*const locator = generationHistoryLocator;[\s\S]*generationHistoryLocator = null;[\s\S]*invoke\('chat_history_generation_finished', \{ locator \}\);[\s\S]*finally \{\s*generationIdleGate\.markIdle\(\);/s);
assert.match(source, /export async function Generate\(type, options = \{\}, dryRun = false\)\s*{\s*await enterGeneration\(dryRun\);\s*try {\s*return await GenerateInternal\(type, options, dryRun\);\s*} catch \(error\) {\s*cleanupGenerationAfterUnhandledError\(type, dryRun\);\s*throw error;\s*} finally {\s*await exitGeneration\(\);\s*}\s*}/s);
assert.match(source, /async function GenerateInternal\(/);
});
@ -26,6 +27,34 @@ test('Unhandled foreground Generate errors reuse the legacy unblock path', async
assert.match(source, /cleanupGenerationAfterUnhandledError[\s\S]*unblockGeneration\(type\);/);
});
test('stable chat-history reasons cover provider, final, transport, and agent commits', async () => {
const [script, groups, transport, routes, agentBridge] = await Promise.all([
readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8'),
readFile(path.join(REPO_ROOT, 'src/scripts/group-chats.js'), 'utf8'),
readFile(path.join(REPO_ROOT, 'src/scripts/tauri/chat/transport.js'), 'utf8'),
readFile(path.join(REPO_ROOT, 'src/tauri/main/routes/chat-routes.js'), 'utf8'),
readFile(path.join(REPO_ROOT, 'src/tauri/main/api/agent-chat-commit-bridge.js'), 'utf8'),
]);
assert.match(script, /sendMessageAsUserAtProviderBarrier\(textareaText, messageBias\)/);
assert.match(script, /sendMessageAsUserAtProviderBarrier\(oai_settings\.send_if_empty\.trim\(\), messageBias\)/);
assert.match(script, /saveChatConditional\(this\.type === 'impersonate'[\s\S]*CHAT_COMMIT_REASON\.MUTATION[\s\S]*CHAT_COMMIT_REASON\.GENERATION_CHECKPOINT\)/);
assert.match(script, /saveChatConditional\(isImpersonate[\s\S]*CHAT_COMMIT_REASON\.MUTATION[\s\S]*CHAT_COMMIT_REASON\.GENERATION_CHECKPOINT\)/);
assert.match(script, /saveChatUnsafe\(\{ chatName, withMetadata, mesId, force: true, chatData, commitReason \}\)/);
assert.match(groups, /saveGroupChatUnsafe\(groupId, shouldSaveGroup, true, commitReason\)/);
assert.match(groups, /sendMessageAsUser\(userInput, bias\.messageBias\)/);
assert.match(script, /saveChatConditional\(CHAT_COMMIT_REASON\.MAINTENANCE\)/);
assert.match(groups, /saveGroupChat\(groupId, false, false, CHAT_COMMIT_REASON\.MAINTENANCE\)/);
assert.match(transport, /MAINTENANCE: 'maintenance'/);
assert.match(transport, /commit_reason: commitReason/g);
assert.equal(transport.match(/commit_reason: commitReason/g)?.length, 4);
assert.equal(routes.match(/commitReason: body\?\.commit_reason/g)?.length, 2);
assert.match(agentBridge, /handleChatCommitRequested[\s\S]*state\.persistChat\(script, CHAT_COMMIT_REASON\.GENERATION_CHECKPOINT\)/);
assert.match(agentBridge, /handlePersistentStateMetadataUpdateRequested[\s\S]*state\.persistChat\(script, CHAT_COMMIT_REASON\.MUTATION\)/);
assert.match(agentBridge, /saveGroupChat\([\s\S]*commitReason,[\s\S]*script\.saveChat\(\{ commitReason \}\)/);
});
test('Unhandled error cleanup only targets foreground UI lifecycle leaks', async () => {
const { shouldUnblockGenerationAfterUnhandledError } = await import('../src/scripts/util/generation-lifecycle.js');

View file

@ -70,7 +70,7 @@ test('Chat rendering and generation rely on index presence, not whole records',
const source = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
const saveConditionalFn = extractBetween(
source,
'export async function saveChatConditional() {',
'export async function saveChatConditional(commitReason = CHAT_COMMIT_REASON.MUTATION) {',
'/**\n * Saves the chat to the server.',
);