From 87b9eea12947cd6bbe995fd7ad74f4ae21fcad62 Mon Sep 17 00:00:00 2001 From: Darkatse <39111369+Darkatse@users.noreply.github.com> Date: Sat, 28 Feb 2026 20:07:52 -0500 Subject: [PATCH] Refactoring (chat): chat service backend chain has been changed to direct file path reading. --- docs/BackendStructure.md | 60 +-- src-tauri/capabilities/default.json | 9 + src-tauri/src/application/dto/chat_dto.rs | 24 - .../src/application/services/chat_service.rs | 184 +------ .../domain/repositories/chat_repository.rs | 43 +- .../file_chat_repository/payload.rs | 117 ++--- .../file_chat_repository/repository_impl.rs | 166 +------ .../file_chat_repository/tests.rs | 296 ++++++------ .../presentation/commands/chat_commands.rs | 371 +-------------- .../src/presentation/commands/registry.rs | 10 - src/scripts/chat-payload-transport.js | 450 +----------------- src/scripts/chat/asset-io.js | 184 +++++++ src/scripts/chat/jsonl.js | 210 ++++++++ src/scripts/chat/platform.js | 6 + src/scripts/chat/transport.js | 175 +++++++ src/scripts/group-chats.js | 15 +- src/tauri/main/context.js | 13 - src/tauri/main/routes/chat-routes.js | 131 ++--- 18 files changed, 872 insertions(+), 1592 deletions(-) create mode 100644 src/scripts/chat/asset-io.js create mode 100644 src/scripts/chat/jsonl.js create mode 100644 src/scripts/chat/platform.js create mode 100644 src/scripts/chat/transport.js diff --git a/docs/BackendStructure.md b/docs/BackendStructure.md index 4a13203e..28768f01 100644 --- a/docs/BackendStructure.md +++ b/docs/BackendStructure.md @@ -406,18 +406,18 @@ tauri::Builder::default() 这样可以避免在 `lib.rs` 中直接维护超长命令列表,命令增减时只需更新 `registry.rs`。 -### 3.5 聊天后端链路(Payload-First + Byte Transport,2026-02重构) +### 3.5 聊天后端链路(Payload-First + Path/From-File Transport,2026-02重构) 本次聊天链路重构目标是对齐 SillyTavern 上游业务逻辑与文件系统语义,同时降低字段漂移/数据丢失风险。 #### 3.5.1 设计原则 - **Payload First**:聊天保存/读取优先以 JSONL 原始 payload 为边界,而非先强制转领域模型再落盘。 -- **Control/Data Plane 分离**:小参数走 JSON invoke(control plane),大 payload 走 raw bytes / 文件路径(data plane)。 +- **Control/Data Plane 分离**:小参数走 JSON invoke(control plane),大 payload 走文件路径(data plane)。 - **字段保真**:`ChatMetadata`、`ChatMessage`、`MessageExtra` 通过 `#[serde(flatten)] additional` 保留未知字段。 - **完整性优先**:保存链路内置 `chat_metadata.integrity` 校验;冲突时返回 `integrity`,由前端决定是否 `force` 覆盖。 - **兼容上游文件系统**:目录与命名策略对齐 SillyTavern,便于用户无损迁移。 -- **移动端优先规避峰值内存**:Android 读走 `asset://` 路径,写走“前端临时文件 -> 后端按路径原子落盘”。 +- **移动端优先规避峰值内存**:Android 读走 fs plugin 流式读(`open/read`),写走“前端临时文件 -> 后端按路径原子落盘”。 #### 3.5.2 分层职责 @@ -425,41 +425,31 @@ tauri::Builder::default() - 保留 typed API(`get_chat` / `save` / `search_chats` 等)用于领域操作。 - payload API 扩展为三类:对象数组(`*_payload`)、字节流(`*_payload_bytes`)、路径/文件直通(`*_payload_path`、`*_payload_from_path`)。 - `application/services/chat_service.rs` - - `save_chat` 直接调用 payload 写接口,不做 typed -> payload 二次构建。 - - 新增 character/group 的 bytes、path、from-file 服务方法;`save_*_from_file` 走仓库路径直通,避免整文件读入内存。 + - 新增 character/group 的 path/from-file 服务方法;`save_*_from_file` 走仓库路径直通,避免整文件通过 IPC。 - `presentation/commands/chat_commands.rs` - - 保留 JSON payload 命令:`get_chat_payload` / `save_chat` / `get_group_chat` / `save_group_chat` 等。 - - 新增 raw/path/from-file 命令:`get_chat_payload_raw`、`get_chat_payload_path`、`save_chat_payload_raw`、`save_chat_payload_from_file`、`get_group_chat_raw`、`get_group_chat_path`、`save_group_chat_raw`、`save_group_chat_from_file`。 - - raw 保存命令通过 header 传递元信息;后端对 header 值执行 percent decode,兼容非 ASCII 名称。 + - 前端大 payload 不再通过命令传输;仅读取 payload path、保存 from-file: + - `get_chat_payload_path` / `save_chat_payload_from_file` + - `get_group_chat_path` / `save_group_chat_from_file` - `infrastructure/repositories/file_chat_repository/` - `repository_impl.rs`:统一编排 typed/payload/bytes/path 写入入口。 - `payload.rs`:完整性校验、字节写入、文件路径直通写入(原子替换 + 备份)。 - - `jsonl_utils.rs`:`parse_jsonl_bytes`、`write_jsonl_bytes_file`、`read_first_non_empty_jsonl_line` 等基础能力。 + - `jsonl_utils.rs`:`parse_jsonl_bytes`、`write_jsonl_file`、`read_first_non_empty_jsonl_line` 等基础能力。 - 完整性校验改为“首个非空行”解析,避免为校验反序列化整个文件。 #### 3.5.3 关键链路 -1. 桌面/iOS 角色聊天读取(高性能路径) -前端 `invoke(get_chat_payload_raw)` -> 命令返回 bytes -> 前端按 JSONL 解析。 +1. 角色聊天读取(Path-First) +前端 `invoke(get_chat_payload_path)` -> 获取绝对路径 ->(Android:fs plugin 流式读;其他平台:`convertFileSrc(..., 'asset')` + `fetch`)-> 前端 JSONL 流式解析。 -2. Android 角色聊天读取 -前端 `invoke(get_chat_payload_path)` -> `convertFileSrc(..., 'asset')` -> `fetch(asset://...)`。 +2. 角色聊天保存(From-File) +前端 payload 分块编码 -> 写入临时 JSONL 文件 -> `save_chat_payload_from_file` -> `ChatService::save_chat_from_file` -> `save_chat_payload_from_path`(原子替换 + 备份)。 -3. 桌面/iOS 角色聊天保存(高性能路径) -前端 raw bytes + headers -> `save_chat_payload_raw` -> `ChatService::save_chat_payload_bytes` -> 仓库原子写入 + 备份。 +3. 群聊读写链路 +与角色聊天对称:`get_group_chat_path` / `save_group_chat_from_file`。 -4. Android 角色聊天保存 -前端先落临时 JSONL 文件 -> `save_chat_payload_from_file` -> `ChatService::save_chat_from_file` -> `save_chat_payload_from_path`。 - -5. 群聊读写链路 -与角色聊天完全对称:`get_group_chat_raw/get_group_chat_path/save_group_chat_raw/save_group_chat_from_file`。 - -6. 完整性冲突 +4. 完整性冲突 当现有文件的 `chat_metadata.integrity` 与待写入 payload 不一致且 `force=false` 时,仓库返回 `DomainError::InvalidData("integrity")`,前端映射为冲突提示并可二次 `force` 覆盖。 -7. Header 编码约定(raw 保存) -`x-character-name` / `x-file-name` / `x-chat-id` 在前端使用 URI 百分号编码(ASCII 安全),后端解码后再进入服务层。 - #### 3.5.4 文件系统与备份语义 - 角色聊天:`default-user/chats//*.jsonl` @@ -666,34 +656,26 @@ TauriTavern的后端API通过Tauri命令暴露给前端。以下是主要API类 | 命令 | 描述 | 参数 | 返回值 | |------|------|------|--------| | `get_all_chats` | 获取所有聊天 | 无 | `Vec` | -| `get_character_chats` | 获取角色的聊天 | `character_name: String` | `Vec` | -| `get_chat` | 获取单个聊天 | `character_name: String, file_name: String` | `ChatDto` | -| `get_chat_payload` | 获取角色聊天原始JSONL对象数组 | `character_name: String, file_name: String` | `Vec` | -| `get_chat_payload_raw` | 获取角色聊天原始JSONL字节流 | `characterName: String, fileName: String, allowNotFound?: bool` | `InvokeResponse(bytes)` | -| `get_chat_payload_path` | 获取角色聊天文件绝对路径 | `characterName: String, fileName: String` | `String` | +| `get_character_chats` | 获取角色的聊天 | `characterName: String` | `Vec` | +| `get_chat` | 获取单个聊天 | `characterName: String, fileName: String` | `ChatDto` | +| `get_chat_payload_path` | 获取角色聊天文件绝对路径 | `characterName: String, fileName: String, allowNotFound?: bool` | `String` | | `create_chat` | 创建新聊天 | `CreateChatDto` | `ChatDto` | -| `delete_chat` | 删除聊天 | `character_name: String, file_name: String` | `()` | +| `delete_chat` | 删除聊天 | `characterName: String, fileName: String` | `()` | | `add_message` | 添加消息 | `AddMessageDto` | `ChatDto` | | `rename_chat` | 重命名聊天 | `RenameChatDto` | `()` | | `search_chats` | 搜索聊天 | `query: String, character_filter: Option` | `Vec` | | `import_chat` | 导入聊天(legacy typed) | `ImportChatDto` | `ChatDto` | | `import_character_chats` | 导入角色聊天(payload链路) | `ImportCharacterChatsDto` | `Vec` | | `export_chat` | 导出聊天 | `ExportChatDto` | `()` | -| `save_chat` | 保存角色聊天payload(含 integrity/force) | `SaveChatDto` | `()` | -| `save_chat_payload_raw` | 以 raw bytes 保存角色聊天 | `body: bytes, headers: x-character-name/x-file-name/x-force` | `()` | | `save_chat_payload_from_file` | 从现有JSONL文件路径保存角色聊天 | `SaveChatFromFileDto` | `()` | -| `backup_chat` | 触发聊天备份 | `character_name: String, file_name: String` | `()` | +| `backup_chat` | 触发聊天备份 | `characterName: String, fileName: String` | `()` | | `clear_chat_cache` | 清理聊天缓存 | 无 | `()` | ### 5.2.1 群聊Payload命令 | 命令 | 描述 | 参数 | 返回值 | |------|------|------|--------| -| `get_group_chat` | 获取群聊原始JSONL对象数组 | `GetGroupChatDto` | `Vec` | -| `get_group_chat_raw` | 获取群聊原始JSONL字节流 | `GetGroupChatDto, allowNotFound?: bool` | `InvokeResponse(bytes)` | -| `get_group_chat_path` | 获取群聊文件绝对路径 | `GetGroupChatDto` | `String` | -| `save_group_chat` | 保存群聊payload(含 integrity/force) | `SaveGroupChatDto` | `()` | -| `save_group_chat_raw` | 以 raw bytes 保存群聊 | `body: bytes, headers: x-chat-id/x-force` | `()` | +| `get_group_chat_path` | 获取群聊文件绝对路径 | `id: String, allowNotFound?: bool` | `String` | | `save_group_chat_from_file` | 从现有JSONL文件路径保存群聊 | `SaveGroupChatFromFileDto` | `()` | | `delete_group_chat` | 删除群聊聊天文件 | `DeleteGroupChatDto` | `()` | | `rename_group_chat` | 重命名群聊聊天文件 | `RenameGroupChatDto` | `()` | diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 14b7c6cd..27108f60 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,11 +9,20 @@ "core:default", "opener:default", "notification:allow-is-permission-granted", + "fs:allow-open", + "fs:allow-read", "fs:allow-read-text-file", "fs:allow-resource-read-recursive", "fs:default", "fs:allow-appcache-write", "fs:allow-temp-write", + { + "identifier": "fs:scope", + "allow": [ + "/storage/emulated/0/Android/data/com.tauritavern.client/**", + "/sdcard/Android/data/com.tauritavern.client/**" + ] + }, { "identifier": "fs:allow-write-file", "allow": [ diff --git a/src-tauri/src/application/dto/chat_dto.rs b/src-tauri/src/application/dto/chat_dto.rs index 7dc99dee..f9bbefc8 100644 --- a/src-tauri/src/application/dto/chat_dto.rs +++ b/src-tauri/src/application/dto/chat_dto.rs @@ -152,16 +152,6 @@ pub struct ExportChatDto { pub format: String, } -/// DTO for saving a full chat payload (metadata + messages) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SaveChatDto { - #[serde(rename = "ch_name")] - pub character_name: String, - pub file_name: String, - pub chat: Vec, - pub force: Option, -} - /// DTO for saving a character chat payload from an existing JSONL file path. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SaveChatFromFileDto { @@ -172,20 +162,6 @@ pub struct SaveChatFromFileDto { pub force: Option, } -/// DTO for retrieving a group chat payload -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GetGroupChatDto { - pub id: String, -} - -/// DTO for saving a group chat payload -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SaveGroupChatDto { - pub id: String, - pub chat: Vec, - pub force: Option, -} - /// DTO for saving a group chat payload from an existing JSONL file path. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SaveGroupChatFromFileDto { diff --git a/src-tauri/src/application/services/chat_service.rs b/src-tauri/src/application/services/chat_service.rs index 9816ab81..96d55870 100644 --- a/src-tauri/src/application/services/chat_service.rs +++ b/src-tauri/src/application/services/chat_service.rs @@ -3,9 +3,8 @@ use std::sync::Arc; use crate::application::dto::chat_dto::{ AddMessageDto, ChatDto, ChatSearchResultDto, CreateChatDto, DeleteGroupChatDto, ExportChatDto, - GetGroupChatDto, ImportCharacterChatsDto, ImportChatDto, ImportGroupChatDto, RenameChatDto, - RenameGroupChatDto, SaveChatDto, SaveChatFromFileDto, SaveGroupChatDto, - SaveGroupChatFromFileDto, + ImportCharacterChatsDto, ImportChatDto, ImportGroupChatDto, RenameChatDto, RenameGroupChatDto, + SaveChatFromFileDto, SaveGroupChatFromFileDto, }; use crate::application::errors::ApplicationError; use crate::domain::models::chat::{Chat, ChatMessage, MessageExtra}; @@ -13,7 +12,6 @@ use crate::domain::repositories::character_repository::CharacterRepository; use crate::domain::repositories::chat_repository::{ ChatExportFormat, ChatImportFormat, ChatRepository, PinnedCharacterChat, PinnedGroupChat, }; -use serde_json::Value; /// Service for managing chats pub struct ChatService { @@ -386,45 +384,6 @@ impl ChatService { Ok(()) } - /// Get raw JSONL payload for a character chat. - pub async fn get_chat_payload( - &self, - character_name: &str, - file_name: &str, - ) -> Result, ApplicationError> { - tracing::info!("Getting chat payload: {}/{}", character_name, file_name); - self.chat_repository - .get_chat_payload(character_name, file_name) - .await - .map_err(Into::into) - } - - /// Get raw JSONL bytes for a character chat payload. - pub async fn get_chat_payload_bytes( - &self, - character_name: &str, - file_name: &str, - ) -> Result, ApplicationError> { - self.chat_repository - .get_chat_payload_bytes(character_name, file_name) - .await - .map_err(Into::into) - } - - /// Get raw JSONL text for a character chat payload. - pub async fn get_chat_payload_text( - &self, - character_name: &str, - file_name: &str, - ) -> Result { - let bytes = self - .get_chat_payload_bytes(character_name, file_name) - .await?; - String::from_utf8(bytes).map_err(|e| { - ApplicationError::InternalError(format!("Chat payload is not valid UTF-8: {}", e)) - }) - } - /// Get the absolute path to a character chat payload file. pub async fn get_chat_payload_path( &self, @@ -438,58 +397,6 @@ impl ChatService { Ok(path.to_string_lossy().to_string()) } - /// Save a full raw JSONL payload for a character chat. - pub async fn save_chat(&self, dto: SaveChatDto) -> Result<(), ApplicationError> { - tracing::info!( - "Saving chat payload: {}/{}", - dto.character_name, - dto.file_name - ); - - if dto.chat.is_empty() { - return Err(ApplicationError::ValidationError( - "Chat payload is empty".to_string(), - )); - } - - self.chat_repository - .save_chat_payload( - &dto.character_name, - &dto.file_name, - &dto.chat, - dto.force.unwrap_or(false), - ) - .await?; - - Ok(()) - } - - /// Save a character chat payload from raw JSONL bytes. - pub async fn save_chat_payload_bytes( - &self, - character_name: &str, - file_name: &str, - payload: &[u8], - force: bool, - ) -> Result<(), ApplicationError> { - if character_name.trim().is_empty() || file_name.trim().is_empty() { - return Err(ApplicationError::ValidationError( - "Character name and file name cannot be empty".to_string(), - )); - } - - if payload.is_empty() { - return Err(ApplicationError::ValidationError( - "Chat payload is empty".to_string(), - )); - } - - self.chat_repository - .save_chat_payload_bytes(character_name, file_name, payload, force) - .await?; - Ok(()) - } - /// Save a character chat payload from a JSONL file path. pub async fn save_chat_from_file( &self, @@ -512,48 +419,6 @@ impl ChatService { .map_err(Into::into) } - /// Get raw JSONL payload for a group chat. - pub async fn get_group_chat( - &self, - dto: GetGroupChatDto, - ) -> Result, ApplicationError> { - if dto.id.trim().is_empty() { - return Err(ApplicationError::ValidationError( - "Group chat id cannot be empty".to_string(), - )); - } - - self.chat_repository - .get_group_chat_payload(&dto.id) - .await - .map_err(Into::into) - } - - /// Get raw JSONL bytes for a group chat payload. - pub async fn get_group_chat_payload_bytes( - &self, - chat_id: &str, - ) -> Result, ApplicationError> { - if chat_id.trim().is_empty() { - return Err(ApplicationError::ValidationError( - "Group chat id cannot be empty".to_string(), - )); - } - - self.chat_repository - .get_group_chat_payload_bytes(chat_id) - .await - .map_err(Into::into) - } - - /// Get raw JSONL text for a group chat payload. - pub async fn get_group_chat_text(&self, chat_id: &str) -> Result { - let bytes = self.get_group_chat_payload_bytes(chat_id).await?; - String::from_utf8(bytes).map_err(|e| { - ApplicationError::InternalError(format!("Group chat payload is not valid UTF-8: {}", e)) - }) - } - /// Get the absolute path to a group chat payload file. pub async fn get_group_chat_payload_path( &self, @@ -572,51 +437,6 @@ impl ChatService { Ok(path.to_string_lossy().to_string()) } - /// Save raw JSONL payload for a group chat. - pub async fn save_group_chat(&self, dto: SaveGroupChatDto) -> Result<(), ApplicationError> { - if dto.id.trim().is_empty() { - return Err(ApplicationError::ValidationError( - "Group chat id cannot be empty".to_string(), - )); - } - - if dto.chat.is_empty() { - return Err(ApplicationError::ValidationError( - "Chat payload is empty".to_string(), - )); - } - - self.chat_repository - .save_group_chat_payload(&dto.id, &dto.chat, dto.force.unwrap_or(false)) - .await?; - Ok(()) - } - - /// Save a group chat payload from raw JSONL bytes. - pub async fn save_group_chat_payload_bytes( - &self, - chat_id: &str, - payload: &[u8], - force: bool, - ) -> Result<(), ApplicationError> { - if chat_id.trim().is_empty() { - return Err(ApplicationError::ValidationError( - "Group chat id cannot be empty".to_string(), - )); - } - - if payload.is_empty() { - return Err(ApplicationError::ValidationError( - "Chat payload is empty".to_string(), - )); - } - - self.chat_repository - .save_group_chat_payload_bytes(chat_id, payload, force) - .await?; - Ok(()) - } - /// Save a group chat payload from a JSONL file path. pub async fn save_group_chat_from_file( &self, diff --git a/src-tauri/src/domain/repositories/chat_repository.rs b/src-tauri/src/domain/repositories/chat_repository.rs index a5a59b51..663d330c 100644 --- a/src-tauri/src/domain/repositories/chat_repository.rs +++ b/src-tauri/src/domain/repositories/chat_repository.rs @@ -58,8 +58,7 @@ pub trait ChatRepository: Send + Sync { async fn save(&self, chat: &Chat) -> Result<(), DomainError>; /// Save a chat with explicit overwrite/integrity options. - async fn save_with_options(&self, chat: &Chat, force: bool) -> Result<(), DomainError> { - let _ = force; + async fn save_with_options(&self, chat: &Chat, _force: bool) -> Result<(), DomainError> { self.save(chat).await } @@ -187,24 +186,6 @@ pub trait ChatRepository: Send + Sync { file_name: &str, ) -> Result; - /// Save a raw chat JSONL payload for a character chat. - async fn save_chat_payload( - &self, - character_name: &str, - file_name: &str, - payload: &[Value], - force: bool, - ) -> Result<(), DomainError>; - - /// Save raw JSONL bytes for a character chat payload. - async fn save_chat_payload_bytes( - &self, - character_name: &str, - file_name: &str, - payload: &[u8], - force: bool, - ) -> Result<(), DomainError>; - /// Save raw JSONL bytes for a character chat payload from an existing file path. async fn save_chat_payload_from_path( &self, @@ -214,31 +195,9 @@ pub trait ChatRepository: Send + Sync { force: bool, ) -> Result<(), DomainError>; - /// Get a raw chat JSONL payload for a group chat. - async fn get_group_chat_payload(&self, chat_id: &str) -> Result, DomainError>; - - /// Get raw JSONL bytes for a group chat payload. - async fn get_group_chat_payload_bytes(&self, chat_id: &str) -> Result, DomainError>; - /// Get the absolute path to a group chat payload file. async fn get_group_chat_payload_path(&self, chat_id: &str) -> Result; - /// Save a raw chat JSONL payload for a group chat. - async fn save_group_chat_payload( - &self, - chat_id: &str, - payload: &[Value], - force: bool, - ) -> Result<(), DomainError>; - - /// Save raw JSONL bytes for a group chat payload. - async fn save_group_chat_payload_bytes( - &self, - chat_id: &str, - payload: &[u8], - force: bool, - ) -> Result<(), DomainError>; - /// Save raw JSONL bytes for a group chat payload from an existing file path. async fn save_group_chat_payload_from_path( &self, diff --git a/src-tauri/src/infrastructure/repositories/file_chat_repository/payload.rs b/src-tauri/src/infrastructure/repositories/file_chat_repository/payload.rs index 80349c82..976f81d8 100644 --- a/src-tauri/src/infrastructure/repositories/file_chat_repository/payload.rs +++ b/src-tauri/src/infrastructure/repositories/file_chat_repository/payload.rs @@ -7,7 +7,7 @@ use crate::domain::errors::DomainError; use crate::domain::models::chat::Chat; use crate::infrastructure::logging::logger; use crate::infrastructure::persistence::jsonl_utils::{ - parse_jsonl_bytes, read_first_non_empty_jsonl_line, write_jsonl_bytes_file, write_jsonl_file, + parse_jsonl_bytes, read_first_non_empty_jsonl_line, write_jsonl_file, }; use super::FileChatRepository; @@ -49,21 +49,30 @@ impl FileChatRepository { }; if let Some(chat_metadata) = metadata.get("chat_metadata") { - if let Ok(parsed) = serde_json::from_value(chat_metadata.clone()) { - chat.chat_metadata = parsed; - } + chat.chat_metadata = serde_json::from_value(chat_metadata.clone()).map_err(|error| { + DomainError::InvalidData(format!( + "Failed to parse chat metadata for {}: {}", + file_name, error + )) + })?; } - for obj in objects.iter().skip(1) { - if let Ok(message) = serde_json::from_value(obj.clone()) { - chat.add_message(message); - } + for (index, obj) in objects.iter().enumerate().skip(1) { + let message = serde_json::from_value(obj.clone()).map_err(|error| { + DomainError::InvalidData(format!( + "Failed to parse chat message at line {} for {}: {}", + index + 1, + file_name, + error + )) + })?; + chat.add_message(message); } Ok(chat) } - pub(super) fn build_payload_from_chat(chat: &Chat) -> Vec { + pub(super) fn build_payload_from_chat(chat: &Chat) -> Result, DomainError> { let mut objects = Vec::with_capacity(chat.messages.len() + 1); objects.push(serde_json::json!({ "user_name": chat.user_name, @@ -73,12 +82,12 @@ impl FileChatRepository { })); for message in &chat.messages { - if let Ok(value) = serde_json::to_value(message) { - objects.push(value); - } + objects.push(serde_json::to_value(message).map_err(|error| { + DomainError::InternalError(format!("Failed to serialize chat message: {}", error)) + })?); } - objects + Ok(objects) } fn extract_integrity_slug_from_header(header: &Value) -> Option { @@ -90,11 +99,11 @@ impl FileChatRepository { .map(ToString::to_string) } - fn extract_integrity_slug_from_jsonl_line(line: &str) -> Option { - serde_json::from_str::(line) - .ok() - .as_ref() - .and_then(Self::extract_integrity_slug_from_header) + fn extract_integrity_slug_from_jsonl_line(line: &str) -> Result, DomainError> { + let header: Value = serde_json::from_str(line).map_err(|error| { + DomainError::InvalidData(format!("Failed to parse chat payload header: {}", error)) + })?; + Ok(Self::extract_integrity_slug_from_header(&header)) } async fn read_integrity_slug_from_existing_file( @@ -106,7 +115,7 @@ impl FileChatRepository { } if let Some(line) = read_first_non_empty_jsonl_line(path).await? { - return Ok(Self::extract_integrity_slug_from_jsonl_line(&line)); + return Self::extract_integrity_slug_from_jsonl_line(&line); } Ok(None) @@ -142,48 +151,6 @@ impl FileChatRepository { Ok(()) } - fn read_incoming_integrity_from_bytes(payload: &[u8]) -> Result, DomainError> { - if payload.is_empty() { - return Err(DomainError::InvalidData( - "Chat payload is empty".to_string(), - )); - } - - let text = std::str::from_utf8(payload) - .map_err(|e| DomainError::InvalidData(format!("Invalid UTF-8 payload: {}", e)))?; - let Some(first_line) = text.lines().find(|line| !line.trim().is_empty()) else { - return Err(DomainError::InvalidData( - "Chat payload is empty".to_string(), - )); - }; - - Ok(Self::extract_integrity_slug_from_jsonl_line(first_line)) - } - - async fn verify_chat_integrity_bytes_if_needed( - &self, - path: &Path, - payload: &[u8], - force: bool, - ) -> Result<(), DomainError> { - if force { - return Ok(()); - } - - let Some(incoming_integrity) = Self::read_incoming_integrity_from_bytes(payload)? else { - return Ok(()); - }; - - let existing_integrity = self.read_integrity_slug_from_existing_file(path).await?; - if let Some(existing) = existing_integrity { - if existing != incoming_integrity { - return Err(DomainError::InvalidData("integrity".to_string())); - } - } - - Ok(()) - } - async fn read_incoming_integrity_from_file( payload_path: &Path, ) -> Result, DomainError> { @@ -193,7 +160,7 @@ impl FileChatRepository { )); }; - Ok(Self::extract_integrity_slug_from_jsonl_line(&line)) + Self::extract_integrity_slug_from_jsonl_line(&line) } async fn verify_chat_integrity_file_if_needed( @@ -244,28 +211,6 @@ impl FileChatRepository { Ok(()) } - pub(super) async fn write_payload_bytes_to_path( - &self, - path: &Path, - payload: &[u8], - force: bool, - backup_name: &str, - backup_key: &str, - ) -> Result<(), DomainError> { - if payload.is_empty() { - return Err(DomainError::InvalidData( - "Chat payload is empty".to_string(), - )); - } - - self.verify_chat_integrity_bytes_if_needed(path, payload, force) - .await?; - write_jsonl_bytes_file(path, payload).await?; - self.backup_chat_file(path, backup_name, backup_key).await?; - - Ok(()) - } - pub(super) async fn write_payload_file_to_path( &self, path: &Path, @@ -309,7 +254,7 @@ impl FileChatRepository { path: &Path, ) -> Result, DomainError> { fs::read(path).await.map_err(|e| { - DomainError::InternalError(format!("Failed to read chat payload bytes: {}", e)) + DomainError::InternalError(format!("Failed to read chat payload bytes {:?}: {}", path, e)) }) } @@ -358,7 +303,7 @@ impl FileChatRepository { } let path = self.get_chat_path(&chat.character_name, file_name); - let objects = Self::build_payload_from_chat(chat); + let objects = Self::build_payload_from_chat(chat)?; let backup_key = self.get_cache_key(&chat.character_name, file_name); self.write_payload_to_path(&path, &objects, force, &chat.character_name, &backup_key) diff --git a/src-tauri/src/infrastructure/repositories/file_chat_repository/repository_impl.rs b/src-tauri/src/infrastructure/repositories/file_chat_repository/repository_impl.rs index 582255db..284c265f 100644 --- a/src-tauri/src/infrastructure/repositories/file_chat_repository/repository_impl.rs +++ b/src-tauri/src/infrastructure/repositories/file_chat_repository/repository_impl.rs @@ -107,7 +107,7 @@ impl FileChatRepository { #[async_trait] impl ChatRepository for FileChatRepository { async fn save(&self, chat: &Chat) -> Result<(), DomainError> { - self.save_with_options(chat, true).await + self.save_with_options(chat, false).await } async fn save_with_options(&self, chat: &Chat, force: bool) -> Result<(), DomainError> { @@ -163,22 +163,12 @@ impl ChatRepository for FileChatRepository { .unwrap_or("") .to_string(); - match self.get_chat(character_name, &file_name).await { - Ok(mut chat) => { - // Keep the internal character ID stable for list/read-model flows. - // Chat metadata may contain a mutable display name, but filesystem - // layout and routing logic are keyed by directory (character_name). - chat.character_name = character_name.to_string(); - chats.push(chat); - } - Err(e) => { - logger::error(&format!( - "Failed to load chat {}/{}: {}", - character_name, file_name, e - )); - // Continue loading other chats - } - } + let mut chat = self.get_chat(character_name, &file_name).await?; + // Keep the internal character ID stable for list/read-model flows. + // Chat metadata may contain a mutable display name, but filesystem + // layout and routing logic are keyed by directory (character_name). + chat.character_name = character_name.to_string(); + chats.push(chat); } // Sort chats by last message date (newest first) @@ -217,17 +207,8 @@ impl ChatRepository for FileChatRepository { .unwrap_or("") .to_string(); - match self.get_character_chats(&character_name).await { - Ok(chats) => { - all_chats.extend(chats); - } - Err(e) => { - logger::error(&format!( - "Failed to load chats for character {}: {}", - character_name, e - )); - } - } + let chats = self.get_character_chats(&character_name).await?; + all_chats.extend(chats); } else if path .extension() .and_then(|ext| ext.to_str()) @@ -240,19 +221,9 @@ impl ChatRepository for FileChatRepository { .unwrap_or("") .to_string(); - match read_jsonl_file(&path).await { - Ok(payload) => { - if let Ok(chat) = self.parse_chat_from_payload("", &file_name, &payload) { - all_chats.push(chat); - } - } - Err(e) => { - logger::error(&format!( - "Failed to read root chat file {}: {}", - file_name, e - )); - } - } + let payload = read_jsonl_file(&path).await?; + let chat = self.parse_chat_from_payload("", &file_name, &payload)?; + all_chats.push(chat); } } @@ -769,72 +740,6 @@ impl ChatRepository for FileChatRepository { Ok(path) } - async fn save_chat_payload( - &self, - character_name: &str, - file_name: &str, - payload: &[Value], - force: bool, - ) -> Result<(), DomainError> { - self.ensure_directory_exists().await?; - - let character_dir = self.get_character_dir(character_name); - if !character_dir.exists() { - fs::create_dir_all(&character_dir).await.map_err(|e| { - DomainError::InternalError(format!( - "Failed to create character chat directory: {}", - e - )) - })?; - } - - let path = self.get_chat_path(character_name, file_name); - let backup_key = self.get_cache_key(character_name, file_name); - self.write_payload_to_path(&path, payload, force, character_name, &backup_key) - .await?; - - { - let mut cache = self.memory_cache.lock().await; - cache.remove(&backup_key); - } - self.remove_summary_cache_for_path(&path).await; - - Ok(()) - } - - async fn save_chat_payload_bytes( - &self, - character_name: &str, - file_name: &str, - payload: &[u8], - force: bool, - ) -> Result<(), DomainError> { - self.ensure_directory_exists().await?; - - let character_dir = self.get_character_dir(character_name); - if !character_dir.exists() { - fs::create_dir_all(&character_dir).await.map_err(|e| { - DomainError::InternalError(format!( - "Failed to create character chat directory: {}", - e - )) - })?; - } - - let path = self.get_chat_path(character_name, file_name); - let backup_key = self.get_cache_key(character_name, file_name); - self.write_payload_bytes_to_path(&path, payload, force, character_name, &backup_key) - .await?; - - { - let mut cache = self.memory_cache.lock().await; - cache.remove(&backup_key); - } - self.remove_summary_cache_for_path(&path).await; - - Ok(()) - } - async fn save_chat_payload_from_path( &self, character_name: &str, @@ -868,23 +773,6 @@ impl ChatRepository for FileChatRepository { Ok(()) } - async fn get_group_chat_payload(&self, chat_id: &str) -> Result, DomainError> { - let bytes = self.get_group_chat_payload_bytes(chat_id).await?; - parse_jsonl_bytes(&bytes) - } - - async fn get_group_chat_payload_bytes(&self, chat_id: &str) -> Result, DomainError> { - let path = self.get_group_chat_path(chat_id); - if !path.exists() { - return Err(DomainError::NotFound(format!( - "Group chat not found: {}", - chat_id - ))); - } - - self.read_payload_bytes_from_path(&path).await - } - async fn get_group_chat_payload_path( &self, chat_id: &str, @@ -900,36 +788,6 @@ impl ChatRepository for FileChatRepository { Ok(path) } - async fn save_group_chat_payload( - &self, - chat_id: &str, - payload: &[Value], - force: bool, - ) -> Result<(), DomainError> { - self.ensure_directory_exists().await?; - let path = self.get_group_chat_path(chat_id); - let backup_key = format!("group:{}", Self::strip_jsonl_extension(chat_id)); - self.write_payload_to_path(&path, payload, force, chat_id, &backup_key) - .await?; - self.remove_summary_cache_for_path(&path).await; - Ok(()) - } - - async fn save_group_chat_payload_bytes( - &self, - chat_id: &str, - payload: &[u8], - force: bool, - ) -> Result<(), DomainError> { - self.ensure_directory_exists().await?; - let path = self.get_group_chat_path(chat_id); - let backup_key = format!("group:{}", Self::strip_jsonl_extension(chat_id)); - self.write_payload_bytes_to_path(&path, payload, force, chat_id, &backup_key) - .await?; - self.remove_summary_cache_for_path(&path).await; - Ok(()) - } - async fn save_group_chat_payload_from_path( &self, chat_id: &str, diff --git a/src-tauri/src/infrastructure/repositories/file_chat_repository/tests.rs b/src-tauri/src/infrastructure/repositories/file_chat_repository/tests.rs index fd7e7eca..eb80af11 100644 --- a/src-tauri/src/infrastructure/repositories/file_chat_repository/tests.rs +++ b/src-tauri/src/infrastructure/repositories/file_chat_repository/tests.rs @@ -136,67 +136,19 @@ fn normalize_backup_file_name_uses_leaf_name() { assert_eq!(normalized, "chat_alice_20260101-000000.jsonl"); } -#[tokio::test] -async fn save_chat_payload_enforces_integrity_when_not_forced() { - let (repository, root) = setup_repository().await; - - let first_payload = payload_with_integrity("slug-a"); - repository - .save_chat_payload("alice", "session", &first_payload, false) - .await - .expect("initial save should succeed"); - - let second_payload = payload_with_integrity("slug-b"); - let error = repository - .save_chat_payload("alice", "session", &second_payload, false) - .await - .expect_err("save should fail on integrity mismatch"); - - assert!(matches!(error, DomainError::InvalidData(message) if message == "integrity")); - - repository - .save_chat_payload("alice", "session", &second_payload, true) - .await - .expect("forced overwrite should bypass integrity check"); - - let _ = fs::remove_dir_all(&root).await; -} - -#[tokio::test] -async fn save_chat_payload_bytes_enforces_integrity_when_not_forced() { - let (repository, root) = setup_repository().await; - - let first_payload = payload_to_jsonl(&payload_with_integrity("slug-a")); - repository - .save_chat_payload_bytes("alice", "session", first_payload.as_bytes(), false) - .await - .expect("initial save should succeed"); - - let second_payload = payload_to_jsonl(&payload_with_integrity("slug-b")); - let error = repository - .save_chat_payload_bytes("alice", "session", second_payload.as_bytes(), false) - .await - .expect_err("save should fail on integrity mismatch"); - - assert!(matches!(error, DomainError::InvalidData(message) if message == "integrity")); - - repository - .save_chat_payload_bytes("alice", "session", second_payload.as_bytes(), true) - .await - .expect("forced overwrite should bypass integrity check"); - - let _ = fs::remove_dir_all(&root).await; -} - #[tokio::test] async fn chat_payload_bytes_roundtrip_and_path() { let (repository, root) = setup_repository().await; let raw_payload = payload_to_jsonl(&payload_with_integrity("bytes-a")); - repository - .save_chat_payload_bytes("alice", "session", raw_payload.as_bytes(), false) + let source = root.join("chat-source.jsonl"); + fs::write(&source, &raw_payload) .await - .expect("save raw payload bytes"); + .expect("write chat source payload"); + repository + .save_chat_payload_from_path("alice", "session", &source, false) + .await + .expect("save payload from source file"); let loaded_bytes = repository .get_chat_payload_bytes("alice", "session") @@ -218,17 +170,21 @@ async fn chat_payload_bytes_roundtrip_and_path() { } #[tokio::test] -async fn save_chat_payload_bytes_sanitizes_windows_unsafe_path_segments() { +async fn save_chat_payload_from_path_sanitizes_windows_unsafe_path_segments() { let (repository, root) = setup_repository().await; let character_name = "ali:ce"; let file_name = "session:2026/02*21?"; let raw_payload = payload_to_jsonl(&payload_with_integrity("bytes-safe-path")); + let source = root.join("unsafe-path-source.jsonl"); + fs::write(&source, &raw_payload) + .await + .expect("write unsafe chat payload source"); repository - .save_chat_payload_bytes(character_name, file_name, raw_payload.as_bytes(), false) + .save_chat_payload_from_path(character_name, file_name, &source, false) .await - .expect("save raw payload bytes with unsafe path segments"); + .expect("save payload from source file with unsafe path segments"); let expected_path = root .join("chats") @@ -312,8 +268,7 @@ async fn save_and_load_chat_preserves_additional_fields() { }), ]; - repository - .save_chat_payload("alice", "session", &payload, false) + save_chat_payload_from_values(&repository, &root, "alice", "session", &payload, false) .await .expect("save payload"); @@ -354,22 +309,25 @@ async fn group_chat_payload_bytes_roundtrip_and_path() { let (repository, root) = setup_repository().await; let raw_payload = payload_to_jsonl(&payload_with_integrity("group-bytes-a")); + let source = root.join("group-source.jsonl"); + fs::write(&source, &raw_payload) + .await + .expect("write group source payload"); repository - .save_group_chat_payload_bytes("group-session", raw_payload.as_bytes(), false) + .save_group_chat_payload_from_path("group-session", &source, false) .await - .expect("save group raw payload bytes"); - - let loaded_bytes = repository - .get_group_chat_payload_bytes("group-session") - .await - .expect("load group raw payload bytes"); - assert_eq!(loaded_bytes, raw_payload.as_bytes()); + .expect("save group payload from source file"); let payload_path = repository .get_group_chat_payload_path("group-session") .await .expect("get group payload path"); assert!(payload_path.exists()); + + let loaded_bytes = fs::read(&payload_path) + .await + .expect("load group payload bytes"); + assert_eq!(loaded_bytes, raw_payload.as_bytes()); assert_eq!( payload_path.file_name().and_then(|name| name.to_str()), Some("group-session.jsonl") @@ -379,26 +337,29 @@ async fn group_chat_payload_bytes_roundtrip_and_path() { } #[tokio::test] -async fn save_group_chat_payload_bytes_sanitizes_windows_unsafe_id() { +async fn save_group_chat_payload_from_path_sanitizes_windows_unsafe_id() { let (repository, root) = setup_repository().await; let group_id = "group:one/2026*02?21"; let raw_payload = payload_to_jsonl(&payload_with_integrity("group-safe-path")); + let source = root.join("group-unsafe-id-source.jsonl"); + fs::write(&source, &raw_payload) + .await + .expect("write group payload source"); repository - .save_group_chat_payload_bytes(group_id, raw_payload.as_bytes(), false) + .save_group_chat_payload_from_path(group_id, &source, false) .await - .expect("save group raw payload bytes with unsafe id"); + .expect("save group payload from source file with unsafe id"); let expected_path = root .join("group chats") .join(format!("{}.jsonl", sanitize_filename(group_id))); assert!(expected_path.exists()); - let loaded_bytes = repository - .get_group_chat_payload_bytes(group_id) + let loaded_bytes = fs::read(&expected_path) .await - .expect("load group raw payload bytes via unsanitized id"); + .expect("load group payload bytes via unsanitized id"); assert_eq!(loaded_bytes, raw_payload.as_bytes()); let _ = fs::remove_dir_all(&root).await; @@ -436,8 +397,11 @@ async fn save_group_chat_payload_from_path_enforces_integrity() { .await .expect("forced group save should bypass integrity check"); - let loaded_bytes = repository - .get_group_chat_payload_bytes("group-session") + let payload_path = repository + .get_group_chat_payload_path("group-session") + .await + .expect("get group payload path"); + let loaded_bytes = fs::read(&payload_path) .await .expect("load group payload bytes"); assert_eq!(loaded_bytes, payload_b.as_bytes()); @@ -450,15 +414,24 @@ async fn group_chat_payload_roundtrip_and_delete() { let (repository, root) = setup_repository().await; let payload = payload_with_integrity("group-a"); + let source = root.join("group-roundtrip.jsonl"); + fs::write(&source, payload_to_jsonl(&payload)) + .await + .expect("write group payload source"); repository - .save_group_chat_payload("group-session", &payload, false) + .save_group_chat_payload_from_path("group-session", &source, false) .await - .expect("save group chat payload"); + .expect("save group payload from source file"); - let saved = repository - .get_group_chat_payload("group-session") + let payload_path = repository + .get_group_chat_payload_path("group-session") .await - .expect("read group chat payload"); + .expect("get group payload path"); + let bytes = fs::read(&payload_path) + .await + .expect("read group payload bytes"); + let saved = crate::infrastructure::persistence::jsonl_utils::parse_jsonl_bytes(&bytes) + .expect("parse group payload"); assert_eq!(saved.len(), payload.len()); repository @@ -466,7 +439,7 @@ async fn group_chat_payload_roundtrip_and_delete() { .await .expect("delete group chat payload"); - let deleted = repository.get_group_chat_payload("group-session").await; + let deleted = repository.get_group_chat_payload_path("group-session").await; assert!(matches!(deleted, Err(DomainError::NotFound(_)))); let _ = fs::remove_dir_all(&root).await; @@ -523,8 +496,7 @@ async fn rename_chat_keeps_raw_header_fields_intact() { }), ]; - repository - .save_chat_payload("alice", "session", &payload, false) + save_chat_payload_from_values(&repository, &root, "alice", "session", &payload, false) .await .expect("save payload"); @@ -581,8 +553,7 @@ async fn list_chat_summaries_returns_streamed_metadata() { }), ]; - repository - .save_chat_payload("alice", "session", &payload, false) + save_chat_payload_from_values(&repository, &root, "alice", "session", &payload, false) .await .expect("save payload"); @@ -646,12 +617,10 @@ async fn search_group_chats_respects_query_and_chat_filter() { }), ]; - repository - .save_group_chat_payload("group-one", &group_one, false) + save_group_chat_payload_from_values(&repository, &root, "group-one", &group_one, false) .await .expect("save group one"); - repository - .save_group_chat_payload("group-two", &group_two, false) + save_group_chat_payload_from_values(&repository, &root, "group-two", &group_two, false) .await .expect("save group two"); @@ -691,8 +660,7 @@ async fn summary_cache_is_invalidated_after_payload_save() { "extra": {}, }), ]; - repository - .save_chat_payload("alice", "session", &first_payload, false) + save_chat_payload_from_values(&repository, &root, "alice", "session", &first_payload, false) .await .expect("save first payload"); @@ -718,8 +686,7 @@ async fn summary_cache_is_invalidated_after_payload_save() { "extra": {}, }), ]; - repository - .save_chat_payload("alice", "session", &updated_payload, true) + save_chat_payload_from_values(&repository, &root, "alice", "session", &updated_payload, true) .await .expect("save updated payload"); @@ -752,8 +719,7 @@ async fn search_cache_is_invalidated_when_new_chat_file_is_saved() { "extra": {}, }), ]; - repository - .save_chat_payload("alice", "session-a", &first_payload, false) + save_chat_payload_from_values(&repository, &root, "alice", "session-a", &first_payload, false) .await .expect("save first payload"); @@ -779,8 +745,7 @@ async fn search_cache_is_invalidated_when_new_chat_file_is_saved() { "extra": {}, }), ]; - repository - .save_chat_payload("alice", "session-b", &second_payload, false) + save_chat_payload_from_values(&repository, &root, "alice", "session-b", &second_payload, false) .await .expect("save second payload"); @@ -859,8 +824,7 @@ async fn summary_index_is_persisted_and_reloaded() { "extra": {}, }), ]; - repository - .save_chat_payload("alice", "session", &payload, false) + save_chat_payload_from_values(&repository, &root, "alice", "session", &payload, false) .await .expect("save payload"); @@ -946,13 +910,8 @@ async fn list_chat_summaries_without_filter_keeps_character_directories_with_car .await .expect("create character card"); - repository - .save_chat_payload( - "alice", - "session", - &payload_with_integrity("normal-a"), - false, - ) + let payload = payload_with_integrity("normal-a"); + save_chat_payload_from_values(&repository, &root, "alice", "session", &payload, false) .await .expect("save normal character chat"); @@ -981,31 +940,21 @@ async fn list_recent_chat_summaries_limits_results_and_keeps_pinned() { .await .expect("create bob card"); - repository - .save_chat_payload( - "alice", - "session-old", - &payload_with_message("recent-old", "2026-01-01T00:00:00.000Z", "old", "Alice"), - false, - ) + let old_payload = + payload_with_message("recent-old", "2026-01-01T00:00:00.000Z", "old", "Alice"); + save_chat_payload_from_values(&repository, &root, "alice", "session-old", &old_payload, false) .await .expect("save old chat"); - repository - .save_chat_payload( - "alice", - "session-mid", - &payload_with_message("recent-mid", "2026-01-02T00:00:00.000Z", "mid", "Alice"), - false, - ) + + let mid_payload = + payload_with_message("recent-mid", "2026-01-02T00:00:00.000Z", "mid", "Alice"); + save_chat_payload_from_values(&repository, &root, "alice", "session-mid", &mid_payload, false) .await .expect("save middle chat"); - repository - .save_chat_payload( - "bob", - "session-new", - &payload_with_message("recent-new", "2026-01-03T00:00:00.000Z", "new", "Bob"), - false, - ) + + let new_payload = + payload_with_message("recent-new", "2026-01-03T00:00:00.000Z", "new", "Bob"); + save_chat_payload_from_values(&repository, &root, "bob", "session-new", &new_payload, false) .await .expect("save new chat"); @@ -1037,30 +986,23 @@ async fn list_recent_chat_summaries_limits_results_and_keeps_pinned() { async fn list_recent_group_chat_summaries_limits_results_and_keeps_pinned() { let (repository, root) = setup_repository().await; - repository - .save_group_chat_payload( - "group-old", - &payload_with_message( - "group-recent-old", - "2026-01-01T00:00:00.000Z", - "old group", - "Group", - ), - false, - ) + let old_group_payload = payload_with_message( + "group-recent-old", + "2026-01-01T00:00:00.000Z", + "old group", + "Group", + ); + save_group_chat_payload_from_values(&repository, &root, "group-old", &old_group_payload, false) .await .expect("save old group chat"); - repository - .save_group_chat_payload( - "group-new", - &payload_with_message( - "group-recent-new", - "2026-01-03T00:00:00.000Z", - "new group", - "Group", - ), - false, - ) + + let new_group_payload = payload_with_message( + "group-recent-new", + "2026-01-03T00:00:00.000Z", + "new group", + "Group", + ); + save_group_chat_payload_from_values(&repository, &root, "group-new", &new_group_payload, false) .await .expect("save new group chat"); @@ -1091,18 +1033,13 @@ async fn list_recent_group_chat_summaries_limits_results_and_keeps_pinned() { async fn recent_summary_skips_fingerprint_and_search_builds_it_lazily() { let (repository, root) = setup_repository().await; - repository - .save_chat_payload( - "alice", - "session", - &payload_with_message( - "lazy-fingerprint", - "2026-01-05T00:00:00.000Z", - "dragon keyword", - "Alice", - ), - false, - ) + let payload = payload_with_message( + "lazy-fingerprint", + "2026-01-05T00:00:00.000Z", + "dragon keyword", + "Alice", + ); + save_chat_payload_from_values(&repository, &root, "alice", "session", &payload, false) .await .expect("save payload"); @@ -1160,6 +1097,41 @@ async fn recent_summary_skips_fingerprint_and_search_builds_it_lazily() { let _ = fs::remove_dir_all(&root).await; } +async fn save_chat_payload_from_values( + repository: &FileChatRepository, + root: &PathBuf, + character_name: &str, + file_name: &str, + payload: &[Value], + force: bool, +) -> Result<(), DomainError> { + let source_path = root.join(format!("chat-payload-{}.jsonl", random::())); + fs::write(&source_path, payload_to_jsonl(payload)) + .await + .expect("write chat payload source file"); + + repository + .save_chat_payload_from_path(character_name, file_name, &source_path, force) + .await +} + +async fn save_group_chat_payload_from_values( + repository: &FileChatRepository, + root: &PathBuf, + chat_id: &str, + payload: &[Value], + force: bool, +) -> Result<(), DomainError> { + let source_path = root.join(format!("group-chat-payload-{}.jsonl", random::())); + fs::write(&source_path, payload_to_jsonl(payload)) + .await + .expect("write group chat payload source file"); + + repository + .save_group_chat_payload_from_path(chat_id, &source_path, force) + .await +} + fn payload_to_jsonl(payload: &[Value]) -> String { payload .iter() diff --git a/src-tauri/src/presentation/commands/chat_commands.rs b/src-tauri/src/presentation/commands/chat_commands.rs index 9fac3e54..7ce9faff 100644 --- a/src-tauri/src/presentation/commands/chat_commands.rs +++ b/src-tauri/src/presentation/commands/chat_commands.rs @@ -1,108 +1,18 @@ use std::sync::Arc; use tauri::State; -use tauri::http::HeaderMap; -use tauri::ipc::{InvokeBody, Request as InvokeRequest, Response as InvokeResponse}; +use tauri::ipc::Response as InvokeResponse; use crate::app::AppState; use crate::application::dto::chat_dto::{ AddMessageDto, ChatDto, ChatSearchResultDto, CreateChatDto, DeleteGroupChatDto, ExportChatDto, - GetGroupChatDto, ImportCharacterChatsDto, ImportChatDto, ImportGroupChatDto, - PinnedCharacterChatDto, PinnedGroupChatDto, RenameChatDto, RenameGroupChatDto, SaveChatDto, - SaveChatFromFileDto, SaveGroupChatDto, SaveGroupChatFromFileDto, + ImportCharacterChatsDto, ImportChatDto, ImportGroupChatDto, + PinnedCharacterChatDto, PinnedGroupChatDto, RenameChatDto, RenameGroupChatDto, + SaveChatFromFileDto, SaveGroupChatFromFileDto, }; use crate::application::errors::ApplicationError; -use crate::infrastructure::logging::logger; use crate::presentation::commands::helpers::{log_command, map_command_error}; use crate::presentation::errors::CommandError; -use serde_json::Value; - -fn hex_value(byte: u8) -> Option { - match byte { - b'0'..=b'9' => Some(byte - b'0'), - b'a'..=b'f' => Some(byte - b'a' + 10), - b'A'..=b'F' => Some(byte - b'A' + 10), - _ => None, - } -} - -fn maybe_percent_decode(value: &str) -> String { - let bytes = value.as_bytes(); - if !bytes.contains(&b'%') { - return value.to_string(); - } - - let mut decoded = Vec::with_capacity(bytes.len()); - let mut index = 0; - - while index < bytes.len() { - if bytes[index] == b'%' { - if index + 2 >= bytes.len() { - return value.to_string(); - } - - let hi = hex_value(bytes[index + 1]); - let lo = hex_value(bytes[index + 2]); - let (Some(hi), Some(lo)) = (hi, lo) else { - return value.to_string(); - }; - - decoded.push((hi << 4) | lo); - index += 3; - continue; - } - - decoded.push(bytes[index]); - index += 1; - } - - String::from_utf8(decoded).unwrap_or_else(|_| value.to_string()) -} - -fn header_required(headers: &HeaderMap, key: &str) -> Result { - headers - .get(key) - .and_then(|value| value.to_str().ok()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(maybe_percent_decode) - .ok_or_else(|| CommandError::BadRequest(format!("Missing header: {}", key))) -} - -fn header_bool(headers: &HeaderMap, key: &str) -> bool { - headers - .get(key) - .and_then(|value| value.to_str().ok()) - .map(|value| { - let normalized = value.trim().to_ascii_lowercase(); - normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "on" - }) - .unwrap_or(false) -} - -fn bytes_from_request_body(body: &InvokeBody) -> Result, CommandError> { - match body { - InvokeBody::Raw(bytes) => Ok(bytes.clone()), - InvokeBody::Json(Value::Array(values)) => values - .iter() - .map(|value| { - value - .as_u64() - .filter(|number| *number <= u8::MAX as u64) - .map(|number| number as u8) - .ok_or_else(|| { - CommandError::BadRequest( - "Invalid raw byte payload; expected an array of numbers".to_string(), - ) - }) - }) - .collect(), - InvokeBody::Json(Value::String(text)) => Ok(text.as_bytes().to_vec()), - _ => Err(CommandError::BadRequest( - "Invalid raw payload body".to_string(), - )), - } -} #[tauri::command] pub async fn get_all_chats( @@ -436,149 +346,30 @@ pub async fn clear_chat_cache(app_state: State<'_, Arc>) -> Result<(), .map_err(map_command_error("Failed to clear chat cache")) } -#[tauri::command] -pub async fn save_chat( - dto: SaveChatDto, - app_state: State<'_, Arc>, -) -> Result<(), CommandError> { - log_command(format!( - "save_chat {}/{}", - dto.character_name, dto.file_name - )); - - app_state - .chat_service - .save_chat(dto) - .await - .map_err(map_command_error("Failed to save chat")) -} - -#[tauri::command] -pub async fn get_chat_payload( - character_name: String, - file_name: String, - allow_not_found: Option, - app_state: State<'_, Arc>, -) -> Result, CommandError> { - log_command(format!("get_chat_payload {}/{}", character_name, file_name)); - - match app_state - .chat_service - .get_chat_payload(&character_name, &file_name) - .await - { - Ok(payload) => Ok(payload), - Err(ApplicationError::NotFound(_)) if allow_not_found.unwrap_or(false) => Ok(Vec::new()), - Err(error) => { - let context = format!( - "Failed to get chat payload {}/{}", - character_name, file_name - ); - logger::error(&format!("{}: {}", context, error)); - Err(error.into()) - } - } -} - -#[tauri::command] -pub async fn get_chat_payload_raw( - character_name: String, - file_name: String, - allow_not_found: Option, - app_state: State<'_, Arc>, -) -> Result { - log_command(format!( - "get_chat_payload_raw {}/{}", - character_name, file_name - )); - - match app_state - .chat_service - .get_chat_payload_bytes(&character_name, &file_name) - .await - { - Ok(payload) => Ok(InvokeResponse::new(payload)), - Err(ApplicationError::NotFound(_)) if allow_not_found.unwrap_or(false) => { - Ok(InvokeResponse::new(Vec::::new())) - } - Err(error) => { - let context = format!( - "Failed to get raw chat payload {}/{}", - character_name, file_name - ); - logger::error(&format!("{}: {}", context, error)); - Err(error.into()) - } - } -} - #[tauri::command] pub async fn get_chat_payload_path( - character_name: String, - file_name: String, - app_state: State<'_, Arc>, -) -> Result { - log_command(format!( - "get_chat_payload_path {}/{}", - character_name, file_name - )); - - app_state - .chat_service - .get_chat_payload_path(&character_name, &file_name) - .await - .map_err(map_command_error("Failed to get chat payload path")) -} - -#[tauri::command] -pub async fn get_chat_payload_text( character_name: String, file_name: String, allow_not_found: Option, app_state: State<'_, Arc>, ) -> Result { - log_command(format!( - "get_chat_payload_text {}/{}", - character_name, file_name - )); + log_command(format!("get_chat_payload_path {}/{}", character_name, file_name)); + let allow_not_found = allow_not_found.unwrap_or(false); match app_state .chat_service - .get_chat_payload_text(&character_name, &file_name) + .get_chat_payload_path(&character_name, &file_name) .await { - Ok(text) => Ok(text), - Err(ApplicationError::NotFound(_)) if allow_not_found.unwrap_or(false) => Ok(String::new()), - Err(error) => { - let context = format!( - "Failed to get chat payload text {}/{}", - character_name, file_name - ); - logger::error(&format!("{}: {}", context, error)); - Err(error.into()) - } + Ok(path) => Ok(path), + Err(ApplicationError::NotFound(_)) if allow_not_found => Ok(String::new()), + Err(error) => Err(map_command_error(format!( + "Failed to get chat payload path {}/{}", + character_name, file_name + ))(error)), } } -#[tauri::command] -pub async fn save_chat_payload_raw( - request: InvokeRequest<'_>, - app_state: State<'_, Arc>, -) -> Result<(), CommandError> { - log_command("save_chat_payload_raw"); - - let character_name = header_required(request.headers(), "x-character-name")?; - let file_name = header_required(request.headers(), "x-file-name")?; - let force = header_bool(request.headers(), "x-force"); - let payload = bytes_from_request_body(request.body())?; - - app_state - .chat_service - .save_chat_payload_bytes(&character_name, &file_name, &payload, force) - .await - .map_err(map_command_error("Failed to save raw chat payload")) -} - #[tauri::command] pub async fn save_chat_payload_from_file( dto: SaveChatFromFileDto, @@ -596,118 +387,25 @@ pub async fn save_chat_payload_from_file( .map_err(map_command_error("Failed to save chat payload from file")) } -#[tauri::command] -pub async fn get_group_chat( - dto: GetGroupChatDto, - allow_not_found: Option, - app_state: State<'_, Arc>, -) -> Result, CommandError> { - let chat_id = dto.id.clone(); - log_command(format!("get_group_chat {}", chat_id)); - - match app_state.chat_service.get_group_chat(dto).await { - Ok(payload) => Ok(payload), - Err(ApplicationError::NotFound(_)) if allow_not_found.unwrap_or(false) => Ok(Vec::new()), - Err(error) => { - let context = format!("Failed to get group chat payload {}", chat_id); - logger::error(&format!("{}: {}", context, error)); - Err(error.into()) - } - } -} - -#[tauri::command] -pub async fn get_group_chat_raw( - dto: GetGroupChatDto, - allow_not_found: Option, - app_state: State<'_, Arc>, -) -> Result { - let chat_id = dto.id.clone(); - log_command(format!("get_group_chat_raw {}", chat_id)); - - match app_state - .chat_service - .get_group_chat_payload_bytes(&chat_id) - .await - { - Ok(payload) => Ok(InvokeResponse::new(payload)), - Err(ApplicationError::NotFound(_)) if allow_not_found.unwrap_or(false) => { - Ok(InvokeResponse::new(Vec::::new())) - } - Err(error) => { - let context = format!("Failed to get raw group chat payload {}", chat_id); - logger::error(&format!("{}: {}", context, error)); - Err(error.into()) - } - } -} - #[tauri::command] pub async fn get_group_chat_path( - dto: GetGroupChatDto, - app_state: State<'_, Arc>, -) -> Result { - log_command(format!("get_group_chat_path {}", dto.id)); - - app_state - .chat_service - .get_group_chat_payload_path(&dto.id) - .await - .map_err(map_command_error("Failed to get group chat payload path")) -} - -#[tauri::command] -pub async fn get_group_chat_text( - dto: GetGroupChatDto, + id: String, allow_not_found: Option, app_state: State<'_, Arc>, ) -> Result { - let chat_id = dto.id.clone(); - log_command(format!("get_group_chat_text {}", chat_id)); + log_command(format!("get_group_chat_path {}", id)); - match app_state.chat_service.get_group_chat_text(&chat_id).await { - Ok(text) => Ok(text), - Err(ApplicationError::NotFound(_)) if allow_not_found.unwrap_or(false) => Ok(String::new()), - Err(error) => { - let context = format!("Failed to get group chat payload text {}", chat_id); - logger::error(&format!("{}: {}", context, error)); - Err(error.into()) - } + let allow_not_found = allow_not_found.unwrap_or(false); + match app_state.chat_service.get_group_chat_payload_path(&id).await { + Ok(path) => Ok(path), + Err(ApplicationError::NotFound(_)) if allow_not_found => Ok(String::new()), + Err(error) => Err(map_command_error(format!( + "Failed to get group chat payload path {}", + id + ))(error)), } } -#[tauri::command] -pub async fn save_group_chat( - dto: SaveGroupChatDto, - app_state: State<'_, Arc>, -) -> Result<(), CommandError> { - log_command(format!("save_group_chat {}", dto.id)); - - app_state - .chat_service - .save_group_chat(dto) - .await - .map_err(map_command_error("Failed to save group chat payload")) -} - -#[tauri::command] -pub async fn save_group_chat_raw( - request: InvokeRequest<'_>, - app_state: State<'_, Arc>, -) -> Result<(), CommandError> { - log_command("save_group_chat_raw"); - - let chat_id = header_required(request.headers(), "x-chat-id")?; - let force = header_bool(request.headers(), "x-force"); - let payload = bytes_from_request_body(request.body())?; - - app_state - .chat_service - .save_group_chat_payload_bytes(&chat_id, &payload, force) - .await - .map_err(map_command_error("Failed to save raw group chat payload")) -} - #[tauri::command] pub async fn save_group_chat_from_file( dto: SaveGroupChatFromFileDto, @@ -782,26 +480,3 @@ pub async fn import_group_chat_payload( .await .map_err(map_command_error("Failed to import group chat payload")) } - -#[cfg(test)] -mod tests { - use super::maybe_percent_decode; - - #[test] - fn maybe_percent_decode_handles_uri_encoded_utf8() { - let encoded = "%E8%A7%92%E8%89%B2%201"; - assert_eq!(maybe_percent_decode(encoded), "角色 1"); - } - - #[test] - fn maybe_percent_decode_keeps_plain_ascii() { - let value = "alice-session"; - assert_eq!(maybe_percent_decode(value), "alice-session"); - } - - #[test] - fn maybe_percent_decode_falls_back_on_invalid_sequences() { - let value = "bad%zzvalue"; - assert_eq!(maybe_percent_decode(value), "bad%zzvalue"); - } -} diff --git a/src-tauri/src/presentation/commands/registry.rs b/src-tauri/src/presentation/commands/registry.rs index 218bd58b..4fe7bbaf 100644 --- a/src-tauri/src/presentation/commands/registry.rs +++ b/src-tauri/src/presentation/commands/registry.rs @@ -35,19 +35,9 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen super::chat_commands::get_chat_backup_raw, super::chat_commands::delete_chat_backup, super::chat_commands::clear_chat_cache, - super::chat_commands::save_chat, - super::chat_commands::get_chat_payload, - super::chat_commands::get_chat_payload_raw, super::chat_commands::get_chat_payload_path, - super::chat_commands::get_chat_payload_text, - super::chat_commands::save_chat_payload_raw, super::chat_commands::save_chat_payload_from_file, - super::chat_commands::get_group_chat, - super::chat_commands::get_group_chat_raw, super::chat_commands::get_group_chat_path, - super::chat_commands::get_group_chat_text, - super::chat_commands::save_group_chat, - super::chat_commands::save_group_chat_raw, super::chat_commands::save_group_chat_from_file, super::chat_commands::delete_group_chat, super::chat_commands::rename_group_chat, diff --git a/src/scripts/chat-payload-transport.js b/src/scripts/chat-payload-transport.js index 538bde90..e6e2cebb 100644 --- a/src/scripts/chat-payload-transport.js +++ b/src/scripts/chat-payload-transport.js @@ -1,442 +1,8 @@ -import { invoke, isTauriEnv } from '../tauri-bridge.js'; - -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); - -function normalizeInvokeErrorMessage(error, fallback) { - const extract = (value, depth = 0) => { - if (depth > 4 || value === null || value === undefined) { - return ''; - } - - if (typeof value === 'string') { - return value.trim(); - } - - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - - if (value instanceof Error) { - const nested = extract(value.message, depth + 1); - return nested || String(value).trim(); - } - - if (Array.isArray(value)) { - for (const item of value) { - const nested = extract(item, depth + 1); - if (nested) { - return nested; - } - } - return ''; - } - - if (typeof value === 'object') { - const keys = ['message', 'error', 'details', 'reason', 'cause', 'data']; - for (const key of keys) { - if (Object.prototype.hasOwnProperty.call(value, key)) { - const nested = extract(value[key], depth + 1); - if (nested) { - return nested; - } - } - } - } - - return ''; - }; - - const extracted = extract(error); - if (extracted && extracted !== '[object Object]') { - return extracted; - } - - try { - const serialized = JSON.stringify(error); - if (serialized && serialized !== '{}') { - return serialized; - } - } catch { - // Ignore serialization failures. - } - - const stringified = String(error || '').trim(); - if (stringified && stringified !== '[object Object]') { - return stringified; - } - - return fallback; -} - -function normalizeChatFileName(fileName) { - const value = String(fileName || '').trim(); - if (!value) { - return ''; - } - - return value.replace(/\.jsonl$/i, ''); -} - -function normalizeAvatarFileName(avatar) { - if (avatar === null || avatar === undefined) { - return null; - } - - let value = String(avatar).trim(); - if (!value) { - return null; - } - - if (value.includes('?')) { - try { - const parsed = new URL(value, 'http://localhost'); - value = parsed.searchParams.get('file') || parsed.pathname || value; - } catch { - // Keep original value when URL parsing fails. - } - } - - try { - value = decodeURIComponent(value); - } catch { - // Keep original value when decodeURIComponent fails. - } - - value = value.split('?')[0].split('#')[0]; - if (!value) { - return null; - } - - const normalized = value.replace(/[\\/]+/g, '/'); - const fileName = normalized.split('/').pop(); - return fileName || null; -} - -function getAvatarInternalId(avatar) { - const fileName = normalizeAvatarFileName(avatar); - if (!fileName) { - return null; - } - - return fileName.replace(/\.[^/.]+$/, '') || null; -} - -function resolveCharacterDirectoryId(characterName, avatarUrl) { - const fromAvatar = getAvatarInternalId(avatarUrl); - if (fromAvatar) { - return fromAvatar; - } - - return String(characterName || '').trim(); -} - -function normalizePayload(payload) { - if (!Array.isArray(payload)) { - return []; - } - - return payload.filter((entry) => entry && typeof entry === 'object'); -} - -export function payloadToJsonl(payload) { - return normalizePayload(payload).map((entry) => JSON.stringify(entry)).join('\n'); -} - -export function jsonlToPayload(text) { - if (!text) { - return []; - } - - const lines = String(text) - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - - const payload = []; - for (const line of lines) { - try { - payload.push(JSON.parse(line)); - } catch { - // Ignore malformed lines to preserve existing tolerant behavior. - } - } - - return payload; -} - -function isAndroidRuntime() { - if (typeof navigator === 'undefined' || typeof navigator.userAgent !== 'string') { - return false; - } - - return /android/i.test(navigator.userAgent); -} - -function normalizeBinaryPayload(value) { - if (value instanceof Uint8Array) { - return value; - } - - if (value instanceof ArrayBuffer) { - return new Uint8Array(value); - } - - if (ArrayBuffer.isView(value)) { - return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - } - - if (Array.isArray(value)) { - return Uint8Array.from( - value.map((entry) => { - const number = Number(entry); - if (!Number.isFinite(number)) { - return 0; - } - return Math.max(0, Math.min(255, Math.trunc(number))); - }), - ); - } - - if (typeof value === 'string') { - return textEncoder.encode(value); - } - - return new Uint8Array(); -} - -function wrapTransportError(error, fallbackMessage) { - const message = normalizeInvokeErrorMessage(error, fallbackMessage); - const wrapped = new Error(message); - if (String(message).toLowerCase().includes('integrity')) { - wrapped.code = 'integrity'; - } - return wrapped; -} - -async function resolveWritableTempDir(pathApi) { - const getAppCacheDir = typeof pathApi?.appCacheDir === 'function' - ? () => pathApi.appCacheDir() - : null; - const getTempDir = typeof pathApi?.tempDir === 'function' - ? () => pathApi.tempDir() - : null; - - const candidates = []; - if (isAndroidRuntime() && getAppCacheDir) { - candidates.push(getAppCacheDir); - } - if (getTempDir) { - candidates.push(getTempDir); - } - if (getAppCacheDir && !candidates.includes(getAppCacheDir)) { - candidates.push(getAppCacheDir); - } - - let lastError = null; - for (const candidate of candidates) { - try { - const directory = await candidate(); - if (typeof directory === 'string' && directory.trim()) { - return directory; - } - } catch (error) { - lastError = error; - } - } - - if (lastError) { - throw lastError; - } - - throw new Error('No writable temp directory is available'); -} - -async function writeTempJsonlFile(jsonlText, prefix) { - const tauri = window.__TAURI__; - const pathApi = tauri?.path; - const invokeApi = tauri?.core?.invoke; - if (!pathApi || typeof pathApi.join !== 'function') { - throw new Error('Tauri path API is unavailable'); - } - if (typeof invokeApi !== 'function') { - throw new Error('Tauri invoke API is unavailable'); - } - - const tempDir = await resolveWritableTempDir(pathApi); - const fileName = `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}.jsonl`; - const filePath = await pathApi.join(tempDir, fileName); - const bytes = textEncoder.encode(jsonlText); - - await invokeApi('plugin:fs|write_file', bytes, { - headers: { - path: encodeURIComponent(filePath), - options: '{}', - }, - }); - - return { - filePath, - cleanup: async () => { - try { - await invokeApi('plugin:fs|remove', { path: filePath }); - } catch { - // Ignore cleanup failures. - } - }, - }; -} - -export function isTauriChatPayloadTransportEnabled() { - return Boolean(isTauriEnv && typeof invoke === 'function'); -} - -export async function loadCharacterChatPayload({ characterName, avatarUrl, fileName, allowNotFound = true }) { - const normalizedCharacter = resolveCharacterDirectoryId(characterName, avatarUrl); - const normalizedFile = normalizeChatFileName(fileName); - if (!normalizedCharacter || !normalizedFile) { - return []; - } - - try { - if (isAndroidRuntime()) { - const text = await invoke('get_chat_payload_text', { - characterName: normalizedCharacter, - fileName: normalizedFile, - allowNotFound: Boolean(allowNotFound), - }); - return jsonlToPayload(String(text || '')); - } - - const rawPayload = await invoke('get_chat_payload_raw', { - characterName: normalizedCharacter, - fileName: normalizedFile, - allowNotFound: Boolean(allowNotFound), - }); - const bytes = normalizeBinaryPayload(rawPayload); - return jsonlToPayload(textDecoder.decode(bytes)); - } catch (error) { - const wrapped = wrapTransportError(error, 'Failed to load chat payload'); - if (allowNotFound && /not found|enoent|os error 2/i.test(wrapped.message)) { - return []; - } - throw wrapped; - } -} - -export async function saveCharacterChatPayload({ characterName, avatarUrl, fileName, payload, force = false }) { - const normalizedCharacter = resolveCharacterDirectoryId(characterName, avatarUrl); - const normalizedFile = normalizeChatFileName(fileName); - const normalizedPayload = normalizePayload(payload); - if (!normalizedCharacter || !normalizedFile || normalizedPayload.length === 0) { - throw new Error('Invalid chat payload'); - } - - const jsonlText = payloadToJsonl(normalizedPayload); - - try { - if (isAndroidRuntime()) { - const tempFile = await writeTempJsonlFile(jsonlText, 'tauritavern-chat'); - try { - await invoke('save_chat_payload_from_file', { - dto: { - ch_name: normalizedCharacter, - file_name: normalizedFile, - file_path: tempFile.filePath, - force: Boolean(force), - }, - }); - } finally { - await tempFile.cleanup(); - } - return; - } - - const payloadBytes = textEncoder.encode(jsonlText); - await invoke( - 'save_chat_payload_raw', - payloadBytes, - { - headers: { - 'x-character-name': encodeURIComponent(normalizedCharacter), - 'x-file-name': encodeURIComponent(normalizedFile), - 'x-force': String(Boolean(force)), - }, - }, - ); - } catch (error) { - throw wrapTransportError(error, 'Failed to save chat payload'); - } -} - -export async function loadGroupChatPayload({ id, allowNotFound = true }) { - const normalizedId = normalizeChatFileName(id); - if (!normalizedId) { - return []; - } - - try { - if (isAndroidRuntime()) { - const text = await invoke('get_group_chat_text', { - dto: { id: normalizedId }, - allowNotFound: Boolean(allowNotFound), - }); - return jsonlToPayload(String(text || '')); - } - - const rawPayload = await invoke('get_group_chat_raw', { - dto: { id: normalizedId }, - allowNotFound: Boolean(allowNotFound), - }); - const bytes = normalizeBinaryPayload(rawPayload); - return jsonlToPayload(textDecoder.decode(bytes)); - } catch (error) { - const wrapped = wrapTransportError(error, 'Failed to load group chat payload'); - if (allowNotFound && /not found|enoent|os error 2/i.test(wrapped.message)) { - return []; - } - throw wrapped; - } -} - -export async function saveGroupChatPayload({ id, payload, force = false }) { - const normalizedId = normalizeChatFileName(id); - const normalizedPayload = normalizePayload(payload); - if (!normalizedId || normalizedPayload.length === 0) { - throw new Error('Invalid group chat payload'); - } - - const jsonlText = payloadToJsonl(normalizedPayload); - - try { - if (isAndroidRuntime()) { - const tempFile = await writeTempJsonlFile(jsonlText, 'tauritavern-group-chat'); - try { - await invoke('save_group_chat_from_file', { - dto: { - id: normalizedId, - file_path: tempFile.filePath, - force: Boolean(force), - }, - }); - } finally { - await tempFile.cleanup(); - } - return; - } - - const payloadBytes = textEncoder.encode(jsonlText); - await invoke( - 'save_group_chat_raw', - payloadBytes, - { - headers: { - 'x-chat-id': encodeURIComponent(normalizedId), - 'x-force': String(Boolean(force)), - }, - }, - ); - } catch (error) { - throw wrapTransportError(error, 'Failed to save group chat payload'); - } -} +export { payloadToJsonl, jsonlToPayload } from './chat/jsonl.js'; +export { + isTauriChatPayloadTransportEnabled, + loadCharacterChatPayload, + saveCharacterChatPayload, + loadGroupChatPayload, + saveGroupChatPayload, +} from './chat/transport.js'; diff --git a/src/scripts/chat/asset-io.js b/src/scripts/chat/asset-io.js new file mode 100644 index 00000000..ec7f6326 --- /dev/null +++ b/src/scripts/chat/asset-io.js @@ -0,0 +1,184 @@ +import { convertFileSrc } from '../../tauri-bridge.js'; +import { isAndroidRuntime } from './platform.js'; + +function requireTauri() { + if (typeof window === 'undefined' || typeof window.__TAURI__ !== 'object') { + throw new Error('Tauri runtime is required'); + } + + return window.__TAURI__; +} + +async function resolveTempDir(pathApi) { + const candidates = isAndroidRuntime() + ? [pathApi.appCacheDir, pathApi.tempDir] + : [pathApi.tempDir, pathApi.appCacheDir]; + + for (const candidate of candidates) { + if (typeof candidate === 'function') { + return candidate(); + } + } + + throw new Error('No writable temp directory is available'); +} + +async function fsWriteFileChunk(invokeApi, filePath, bytes, { append }) { + const writeOptions = { + append: Boolean(append), + create: true, + }; + + await invokeApi('plugin:fs|write_file', bytes, { + headers: { + path: encodeURIComponent(filePath), + options: JSON.stringify(writeOptions), + }, + }); +} + +export async function writeTempFileFromBytesIterable(bytesIterable, { prefix, extension = 'jsonl' } = {}) { + const normalizedPrefix = String(prefix || 'temp').trim() || 'temp'; + const normalizedExtension = String(extension || '').trim().replace(/^\./, ''); + const tauri = requireTauri(); + const pathApi = tauri.path; + const invokeApi = tauri.core?.invoke; + + if (!pathApi || typeof pathApi.join !== 'function') { + throw new Error('Tauri path API is unavailable'); + } + + if (typeof invokeApi !== 'function') { + throw new Error('Tauri invoke API is unavailable'); + } + + const tempDir = await resolveTempDir(pathApi); + const suffix = normalizedExtension ? `.${normalizedExtension}` : ''; + const fileName = `${normalizedPrefix}-${Date.now()}-${Math.random().toString(16).slice(2)}${suffix}`; + const filePath = await pathApi.join(tempDir, fileName); + + let append = false; + for (const chunk of bytesIterable) { + if (chunk.byteLength === 0) { + continue; + } + + await fsWriteFileChunk(invokeApi, filePath, chunk, { append }); + append = true; + } + + if (!append) { + await fsWriteFileChunk(invokeApi, filePath, new Uint8Array(0), { append: false }); + } + + return { + filePath, + cleanup: async () => { + await invokeApi('plugin:fs|remove', { path: filePath }); + }, + }; +} + +const FS_READ_CHUNK_BYTES = 512 * 1024; + +function readBigEndianUint64(bytes) { + let value = 0; + + for (let i = 0; i < bytes.length; i += 1) { + value *= 0x100; + value += bytes[i]; + } + + return value; +} + +function normalizeFsReadResponse(data) { + if (data instanceof Uint8Array) { + return data; + } + + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + + throw new Error('Unexpected fs read response'); +} + +async function fsReadIntoBuffer(invokeApi, rid, buffer) { + const data = await invokeApi('plugin:fs|read', { rid, len: buffer.byteLength }); + const bytes = normalizeFsReadResponse(data); + const trailer = bytes.subarray(bytes.byteLength - 8); + const bytesRead = readBigEndianUint64(trailer); + buffer.set(bytes.subarray(0, bytes.byteLength - 8)); + return bytesRead === 0 ? null : bytesRead; +} + +function createFsReadableStream(filePath) { + const tauri = requireTauri(); + const invokeApi = tauri.core?.invoke; + + if (typeof invokeApi !== 'function') { + throw new Error('Tauri invoke API is unavailable'); + } + + const ridPromise = invokeApi('plugin:fs|open', { + path: filePath, + options: { read: true }, + }); + let isClosed = false; + + const closeOnce = async () => { + if (isClosed) { + return; + } + + isClosed = true; + const rid = await ridPromise; + await invokeApi('plugin:resources|close', { rid }); + }; + + return new ReadableStream({ + async pull(controller) { + const rid = await ridPromise; + + try { + const buffer = new Uint8Array(FS_READ_CHUNK_BYTES); + const bytesRead = await fsReadIntoBuffer(invokeApi, rid, buffer); + + if (bytesRead === null) { + await closeOnce(); + controller.close(); + return; + } + + if (bytesRead > 0) { + controller.enqueue(buffer.subarray(0, bytesRead)); + } + } catch (error) { + await closeOnce(); + throw error; + } + }, + async cancel() { + await closeOnce(); + }, + }); +} + +export async function fetchAssetStream(filePath) { + if (isAndroidRuntime()) { + return createFsReadableStream(filePath); + } + + const assetUrl = convertFileSrc(filePath, 'asset'); + const response = await fetch(assetUrl, { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`Failed to fetch asset payload: ${response.status}`); + } + + if (!response.body) { + throw new Error('Asset response body is unavailable'); + } + + return response.body; +} diff --git a/src/scripts/chat/jsonl.js b/src/scripts/chat/jsonl.js new file mode 100644 index 00000000..7c7b7226 --- /dev/null +++ b/src/scripts/chat/jsonl.js @@ -0,0 +1,210 @@ +const textEncoder = new TextEncoder(); + +function trimAsciiWhitespaceRange(value) { + let start = 0; + let end = value.length; + + while (start < end) { + const code = value.charCodeAt(start); + if (code > 32) { + break; + } + start += 1; + } + + while (end > start) { + const code = value.charCodeAt(end - 1); + if (code > 32) { + break; + } + end -= 1; + } + + return [start, end]; +} + +function parseJsonlLine(line, { isFirstPayloadLine, lineNumber }) { + const [start, end] = trimAsciiWhitespaceRange(line); + if (end <= start) { + return undefined; + } + + let jsonStart = start; + if (isFirstPayloadLine && line.charCodeAt(jsonStart) === 0xFEFF) { + jsonStart += 1; + } + + if (end <= jsonStart) { + return undefined; + } + + try { + return JSON.parse(line.slice(jsonStart, end)); + } catch (error) { + throw new Error(`Invalid JSONL at line ${lineNumber}`, { cause: error }); + } +} + +function assertPayloadArray(payload) { + if (!Array.isArray(payload)) { + throw new Error('Chat payload must be an array'); + } + + return payload; +} + +export function payloadToJsonl(payload) { + const normalized = assertPayloadArray(payload); + let result = ''; + + for (let index = 0; index < normalized.length; index += 1) { + const entry = normalized[index]; + if (!entry || typeof entry !== 'object') { + throw new Error(`Chat payload entry at index ${index} must be an object`); + } + + if (index > 0) { + result += '\n'; + } + result += JSON.stringify(entry); + } + + return result; +} + +export function jsonlToPayload(text) { + if (!text) { + return []; + } + + const input = String(text); + const payload = []; + let cursor = 0; + let lineNumber = 0; + let isFirstPayloadLine = true; + + while (cursor <= input.length) { + const nextNewline = input.indexOf('\n', cursor); + const end = nextNewline === -1 ? input.length : nextNewline; + const line = input.slice(cursor, end); + lineNumber += 1; + const parsed = parseJsonlLine(line, { isFirstPayloadLine, lineNumber }); + if (parsed !== undefined) { + payload.push(parsed); + isFirstPayloadLine = false; + } + + if (nextNewline === -1) { + break; + } + + cursor = nextNewline + 1; + } + + return payload; +} + +export async function jsonlStreamToPayload(stream) { + if (!stream || typeof stream.getReader !== 'function') { + throw new Error('JSONL stream must be a ReadableStream'); + } + + const reader = stream.getReader(); + const decoder = new TextDecoder(); + const payload = []; + let carry = ''; + let lineNumber = 0; + let isFirstPayloadLine = true; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + carry += decoder.decode(value, { stream: true }); + + while (true) { + const newlineIndex = carry.indexOf('\n'); + if (newlineIndex === -1) { + break; + } + + const rawLine = carry.slice(0, newlineIndex); + carry = carry.slice(newlineIndex + 1); + + lineNumber += 1; + const parsed = parseJsonlLine(rawLine, { isFirstPayloadLine, lineNumber }); + if (parsed !== undefined) { + payload.push(parsed); + isFirstPayloadLine = false; + } + } + } + } catch (error) { + try { + await reader.cancel(); + } catch { + // ignore cancellation errors + } + throw error; + } finally { + reader.releaseLock(); + } + + carry += decoder.decode(); + if (carry) { + lineNumber += 1; + const parsed = parseJsonlLine(carry, { isFirstPayloadLine, lineNumber }); + if (parsed !== undefined) { + payload.push(parsed); + } + } + + return payload; +} + +function concatChunks(chunks, totalLength) { + const output = new Uint8Array(totalLength); + let offset = 0; + + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + + return output; +} + +export function* payloadToJsonlByteChunks(payload, { maxChunkBytes = 4 * 1024 * 1024 } = {}) { + const normalized = assertPayloadArray(payload); + const chunks = []; + let totalLength = 0; + let isFirstLine = true; + + for (let index = 0; index < normalized.length; index += 1) { + const entry = normalized[index]; + if (!entry || typeof entry !== 'object') { + throw new Error(`Chat payload entry at index ${index} must be an object`); + } + + const line = JSON.stringify(entry); + const text = isFirstLine ? line : `\n${line}`; + isFirstLine = false; + const bytes = textEncoder.encode(text); + + if (totalLength > 0 && totalLength + bytes.byteLength > maxChunkBytes) { + yield concatChunks(chunks, totalLength); + chunks.length = 0; + totalLength = 0; + } + + chunks.push(bytes); + totalLength += bytes.byteLength; + } + + if (totalLength > 0) { + yield concatChunks(chunks, totalLength); + } +} diff --git a/src/scripts/chat/platform.js b/src/scripts/chat/platform.js new file mode 100644 index 00000000..ad68bab0 --- /dev/null +++ b/src/scripts/chat/platform.js @@ -0,0 +1,6 @@ +export function isAndroidRuntime() { + return typeof navigator !== 'undefined' + && typeof navigator.userAgent === 'string' + && /android/i.test(navigator.userAgent); +} + diff --git a/src/scripts/chat/transport.js b/src/scripts/chat/transport.js new file mode 100644 index 00000000..d30c3488 --- /dev/null +++ b/src/scripts/chat/transport.js @@ -0,0 +1,175 @@ +import { invoke, isTauriEnv } from '../../tauri-bridge.js'; +import { fetchAssetStream, writeTempFileFromBytesIterable } from './asset-io.js'; +import { jsonlStreamToPayload, payloadToJsonlByteChunks } from './jsonl.js'; + +function normalizeChatFileName(fileName) { + const value = String(fileName || '').trim(); + if (!value) { + return ''; + } + + return value.replace(/\.jsonl$/i, ''); +} + +function normalizeAvatarFileName(avatar) { + if (avatar === null || avatar === undefined) { + return null; + } + + let value = String(avatar).trim(); + if (!value) { + return null; + } + + if (value.includes('?')) { + const parsed = new URL(value, 'http://localhost'); + value = parsed.searchParams.get('file') || parsed.pathname || value; + } + + value = decodeURIComponent(value); + + value = value.split('?')[0].split('#')[0]; + if (!value) { + return null; + } + + const normalized = value.replace(/[\\/]+/g, '/'); + const fileName = normalized.split('/').pop(); + return fileName || null; +} + +function getAvatarInternalId(avatar) { + const fileName = normalizeAvatarFileName(avatar); + if (!fileName) { + return null; + } + + return fileName.replace(/\.[^/.]+$/, '') || null; +} + +function resolveCharacterDirectoryId(characterName, avatarUrl) { + const fromAvatar = getAvatarInternalId(avatarUrl); + if (fromAvatar) { + return fromAvatar; + } + + return String(characterName || '').trim(); +} + +async function withTempFile(bytesIterable, options, handler) { + const tempFile = await writeTempFileFromBytesIterable(bytesIterable, options); + + let result; + let handlerError; + + try { + result = await handler(tempFile.filePath); + } catch (error) { + handlerError = error; + } + + try { + await tempFile.cleanup(); + } catch (cleanupError) { + if (handlerError) { + const handlerMessage = String(handlerError?.message || handlerError || 'Temp file handler failed'); + throw new AggregateError([handlerError, cleanupError], handlerMessage); + } + + throw cleanupError; + } + + if (handlerError) { + throw handlerError; + } + + return result; +} + +export function isTauriChatPayloadTransportEnabled() { + return isTauriEnv; +} + +export async function loadCharacterChatPayload({ characterName, avatarUrl, fileName, allowNotFound = true }) { + const normalizedCharacter = resolveCharacterDirectoryId(characterName, avatarUrl); + const normalizedFile = normalizeChatFileName(fileName); + if (!normalizedCharacter || !normalizedFile) { + return []; + } + + const path = await invoke('get_chat_payload_path', { + characterName: normalizedCharacter, + fileName: normalizedFile, + allowNotFound, + }); + + if (!path) { + if (allowNotFound) { + return []; + } + throw new Error('Chat payload path is empty'); + } + + const stream = await fetchAssetStream(path); + return jsonlStreamToPayload(stream); +} + +export async function saveCharacterChatPayload({ characterName, avatarUrl, fileName, payload, force = false }) { + const normalizedCharacter = resolveCharacterDirectoryId(characterName, avatarUrl); + const normalizedFile = normalizeChatFileName(fileName); + if (!Array.isArray(payload) || payload.length === 0 || !normalizedCharacter || !normalizedFile) { + throw new Error('Invalid chat payload'); + } + + await withTempFile(payloadToJsonlByteChunks(payload), { + prefix: 'tauritavern-chat', + extension: 'jsonl', + }, (filePath) => invoke('save_chat_payload_from_file', { + dto: { + ch_name: normalizedCharacter, + file_name: normalizedFile, + file_path: filePath, + force, + }, + })); +} + +export async function loadGroupChatPayload({ id, allowNotFound = true }) { + const normalizedId = normalizeChatFileName(id); + if (!normalizedId) { + return []; + } + + const path = await invoke('get_group_chat_path', { + id: normalizedId, + allowNotFound, + }); + + if (!path) { + if (allowNotFound) { + return []; + } + throw new Error('Group chat payload path is empty'); + } + + const stream = await fetchAssetStream(path); + return jsonlStreamToPayload(stream); +} + +export async function saveGroupChatPayload({ id, payload, force = false }) { + const normalizedId = normalizeChatFileName(id); + if (!Array.isArray(payload) || payload.length === 0 || !normalizedId) { + throw new Error('Invalid group chat payload'); + } + + await withTempFile(payloadToJsonlByteChunks(payload), { + prefix: 'tauritavern-group-chat', + extension: 'jsonl', + }, (filePath) => invoke('save_group_chat_from_file', { + dto: { + id: normalizedId, + file_path: filePath, + force, + }, + })); +} diff --git a/src/scripts/group-chats.js b/src/scripts/group-chats.js index dfc33ef4..3f2ffa6a 100644 --- a/src/scripts/group-chats.js +++ b/src/scripts/group-chats.js @@ -204,16 +204,11 @@ async function loadGroupChat(chatId) { } if (isTauriChatPayloadTransportEnabled()) { - try { - const data = await loadGroupChatPayload({ - id: normalizedChatId, - allowNotFound: true, - }); - return Array.isArray(data) ? data : []; - } catch (error) { - console.error('Failed to load group chat payload from Tauri transport', error); - return []; - } + const data = await loadGroupChatPayload({ + id: normalizedChatId, + allowNotFound: true, + }); + return data; } const response = await fetch('/api/chats/group/get', { diff --git a/src/tauri/main/context.js b/src/tauri/main/context.js index 954740e8..759a83f1 100644 --- a/src/tauri/main/context.js +++ b/src/tauri/main/context.js @@ -1603,19 +1603,6 @@ export function createTauriMainContext({ invoke, convertFileSrc }) { } async function writeTempUploadFile(filePath, file, invokeApi) { - if (typeof file?.stream === 'function' && typeof ReadableStream !== 'undefined') { - try { - const { writeFile } = await import('@tauri-apps/plugin-fs'); - await writeFile(filePath, file.stream(), { - create: true, - truncate: true, - }); - return; - } catch (error) { - console.warn('Streamed temp file write failed; falling back to chunked write:', error); - } - } - await writeTempUploadFileChunked(filePath, file, invokeApi); } diff --git a/src/tauri/main/routes/chat-routes.js b/src/tauri/main/routes/chat-routes.js index b2412864..fc715f63 100644 --- a/src/tauri/main/routes/chat-routes.js +++ b/src/tauri/main/routes/chat-routes.js @@ -1,18 +1,17 @@ -function normalizePayload(payload) { - if (!Array.isArray(payload)) { - return []; - } - - return payload.filter((entry) => entry && typeof entry === 'object'); -} - -function payloadToJsonl(payload) { - return normalizePayload(payload).map((entry) => JSON.stringify(entry)).join('\n'); -} +import { + loadCharacterChatPayload, + loadGroupChatPayload, + saveCharacterChatPayload, + saveGroupChatPayload, +} from '../../../scripts/chat/transport.js'; +import { payloadToJsonl } from '../../../scripts/chat/jsonl.js'; function payloadMessages(payload) { - const normalized = normalizePayload(payload); - return normalized.filter((entry, index) => index > 0 && typeof entry?.mes === 'string'); + if (!Array.isArray(payload)) { + throw new Error('Chat payload must be an array'); + } + + return payload.filter((entry, index) => index > 0 && typeof entry?.mes === 'string'); } function previewMessage(messages) { @@ -81,30 +80,6 @@ function isPinnedRecentChat(chat, pinnedChats) { } export function registerChatRoutes(router, context, { jsonResponse }) { - const isChatNotFoundError = (error) => { - const serialized = (() => { - try { - return JSON.stringify(error); - } catch { - return ''; - } - })(); - - const message = [error?.message, error, serialized] - .map((value) => String(value || '')) - .join(' ') - .toLowerCase(); - return ( - message.includes('not found') || - message.includes('no such file') || - message.includes('enoent') || - message.includes('os error 2') || - message.includes('failed to open jsonl file') || - message.includes('没有那个文件或目录') || - message.includes('找不到') - ); - }; - const isIntegrityError = (error) => { const serialized = (() => { try { @@ -133,17 +108,14 @@ export function registerChatRoutes(router, context, { jsonResponse }) { } try { - const payload = await context.safeInvoke('get_chat_payload', { - character_name: characterId, - file_name: fileName, - allow_not_found: true, + const payload = await loadCharacterChatPayload({ + characterName: characterId, + avatarUrl: body?.avatar_url, + fileName, + allowNotFound: true, }); - return jsonResponse(Array.isArray(payload) ? payload : []); + return jsonResponse(payload); } catch (error) { - if (isChatNotFoundError(error)) { - return jsonResponse([]); - } - return jsonResponse( { error: 'Failed to load chat', @@ -166,13 +138,12 @@ export function registerChatRoutes(router, context, { jsonResponse }) { } try { - await context.safeInvoke('save_chat', { - dto: { - ch_name: characterId, - file_name: fileName, - chat: body.chat, - force: Boolean(body?.force), - }, + await saveCharacterChatPayload({ + characterName: characterId, + avatarUrl: body?.avatar_url, + fileName, + payload: body.chat, + force: Boolean(body?.force), }); return jsonResponse({ ok: true }); } catch (error) { @@ -483,9 +454,7 @@ export function registerChatRoutes(router, context, { jsonResponse }) { let payload; try { if (isGroup) { - payload = await context.safeInvoke('get_group_chat', { - dto: { id: fileName }, - }); + payload = await loadGroupChatPayload({ id: fileName, allowNotFound: false }); } else { const characterId = await context.resolveCharacterId({ avatar: body?.avatar_url, @@ -496,22 +465,26 @@ export function registerChatRoutes(router, context, { jsonResponse }) { return jsonResponse({ message: 'Invalid export payload' }, 400); } - payload = await context.safeInvoke('get_chat_payload', { - character_name: characterId, - file_name: fileName, + payload = await loadCharacterChatPayload({ + characterName: characterId, + avatarUrl: body?.avatar_url, + fileName, + allowNotFound: false, }); } - } catch { + } catch (error) { + const details = String(error?.message || error || ''); return jsonResponse( - { message: `Could not find JSONL file to export. Source chat file: ${fileName}.` }, - 404, + { + message: details ? `Failed to export chat: ${details}` : 'Failed to export chat', + }, + 500, ); } - const normalizedPayload = normalizePayload(payload); const result = format === 'jsonl' - ? payloadToJsonl(normalizedPayload) - : context.exportChatAsText(normalizedPayload); + ? payloadToJsonl(payload) + : context.exportChatAsText(payload); return jsonResponse({ message: exportFilename ? `Chat saved to ${exportFilename}` : 'Chat exported', @@ -582,16 +555,16 @@ export function registerChatRoutes(router, context, { jsonResponse }) { } try { - const payload = await context.safeInvoke('get_group_chat', { - dto: { id }, - allow_not_found: true, - }); - return jsonResponse(Array.isArray(payload) ? payload : []); + const payload = await loadGroupChatPayload({ id, allowNotFound: true }); + return jsonResponse(payload); } catch (error) { - if (isChatNotFoundError(error)) { - return jsonResponse([]); - } - return jsonResponse([], 500); + return jsonResponse( + { + error: 'Failed to load group chat', + details: String(error?.message || error || ''), + }, + 500, + ); } }); @@ -602,12 +575,10 @@ export function registerChatRoutes(router, context, { jsonResponse }) { } try { - await context.safeInvoke('save_group_chat', { - dto: { - id, - chat: body.chat, - force: Boolean(body?.force), - }, + await saveGroupChatPayload({ + id, + payload: body.chat, + force: Boolean(body?.force), }); return jsonResponse({ ok: true }); } catch (error) {