feat: add chat history import and export functionality
Implemented full chat history import/export with JSON format: - Export button saves current conversation to JSON file - Import button loads conversation from JSON file - File dialog integration using tauri-plugin-dialog - Message count feedback on successful import - Automatic history reload after import - Preserves all message data including swipes and timestamps - Smart error handling (ignores cancelled dialogs) Backend (Rust): - export_chat_history: Opens save dialog, writes JSON to selected path - import_chat_history: Opens file picker, parses JSON, saves to current character - Message migration for backward compatibility - Returns helpful feedback (file path on export, message count on import) Frontend (JavaScript): - Export/import buttons in header with up/down arrow icons - Status updates during operations - Auto-reload chat view after import - Error handling with user-friendly messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1431,6 +1431,87 @@ async fn import_character_card(app_handle: tauri::AppHandle) -> Result<Character
|
|||||||
Ok(character)
|
Ok(character)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export chat history to JSON
|
||||||
|
#[tauri::command]
|
||||||
|
async fn export_chat_history(app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let character = get_active_character();
|
||||||
|
let history = load_history(&character.id);
|
||||||
|
|
||||||
|
// Open save dialog
|
||||||
|
let save_path = app_handle
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.add_filter("Chat History", &["json"])
|
||||||
|
.set_file_name(&format!("chat_{}.json", character.name))
|
||||||
|
.blocking_save_file();
|
||||||
|
|
||||||
|
let output_path = if let Some(path) = save_path {
|
||||||
|
PathBuf::from(
|
||||||
|
path.as_path()
|
||||||
|
.ok_or_else(|| "Could not get file path".to_string())?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return Err("Save cancelled".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write history to JSON file
|
||||||
|
let contents = serde_json::to_string_pretty(&history)
|
||||||
|
.map_err(|e| format!("Failed to serialize history: {}", e))?;
|
||||||
|
|
||||||
|
fs::write(&output_path, contents)
|
||||||
|
.map_err(|e| format!("Failed to write file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(output_path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import chat history from JSON
|
||||||
|
#[tauri::command]
|
||||||
|
async fn import_chat_history(app_handle: tauri::AppHandle) -> Result<usize, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
// Open file picker for JSON files
|
||||||
|
let file_path = app_handle
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.add_filter("Chat History", &["json"])
|
||||||
|
.blocking_pick_file();
|
||||||
|
|
||||||
|
let json_path = if let Some(path) = file_path {
|
||||||
|
PathBuf::from(
|
||||||
|
path.as_path()
|
||||||
|
.ok_or_else(|| "Could not get file path".to_string())?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return Err("No file selected".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read and parse history file
|
||||||
|
let contents = fs::read_to_string(&json_path)
|
||||||
|
.map_err(|e| format!("Failed to read file: {}", e))?;
|
||||||
|
|
||||||
|
let mut history: ChatHistory = serde_json::from_str(&contents)
|
||||||
|
.map_err(|e| format!("Failed to parse history: {}", e))?;
|
||||||
|
|
||||||
|
// Migrate messages to ensure compatibility
|
||||||
|
for msg in &mut history.messages {
|
||||||
|
msg.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
let message_count = history.messages.len();
|
||||||
|
|
||||||
|
// Save history for current character
|
||||||
|
let character = get_active_character();
|
||||||
|
save_history(&character.id, &history)?;
|
||||||
|
|
||||||
|
Ok(message_count)
|
||||||
|
}
|
||||||
|
|
||||||
// Export character card to PNG
|
// Export character card to PNG
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn export_character_card(app_handle: tauri::AppHandle, character_id: String) -> Result<String, String> {
|
async fn export_character_card(app_handle: tauri::AppHandle, character_id: String) -> Result<String, String> {
|
||||||
@@ -1514,7 +1595,9 @@ pub fn run() {
|
|||||||
delete_character,
|
delete_character,
|
||||||
set_active_character,
|
set_active_character,
|
||||||
import_character_card,
|
import_character_card,
|
||||||
export_character_card
|
export_character_card,
|
||||||
|
export_chat_history,
|
||||||
|
import_chat_history
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -30,6 +30,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
|
<button id="import-chat-btn" class="icon-btn" title="Import conversation">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M8 11V3M5 8l3 3 3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 13h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button id="export-chat-btn" class="icon-btn" title="Export conversation">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M8 3v8M5 6l3-3 3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 13h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button id="clear-btn" class="icon-btn" title="Clear conversation">
|
<button id="clear-btn" class="icon-btn" title="Clear conversation">
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
<path d="M3 4h10M6 4V3a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1M5 4v8a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
<path d="M3 4h10M6 4V3a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1M5 4v8a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
|||||||
43
src/main.js
43
src/main.js
@@ -201,6 +201,47 @@ function loadSavedFontSize() {
|
|||||||
applyFontSize(savedSize);
|
applyFontSize(savedSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export chat history
|
||||||
|
async function exportChatHistory() {
|
||||||
|
try {
|
||||||
|
setStatus('Exporting chat...', 'default');
|
||||||
|
const filePath = await invoke('export_chat_history');
|
||||||
|
setStatus('Chat exported successfully!', 'success');
|
||||||
|
setTimeout(() => setStatus('Ready'), 2000);
|
||||||
|
console.log('Chat exported to:', filePath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export failed:', error);
|
||||||
|
if (error && !error.toString().includes('cancelled')) {
|
||||||
|
setStatus(`Export failed: ${error}`, 'error');
|
||||||
|
setTimeout(() => setStatus('Ready'), 3000);
|
||||||
|
} else {
|
||||||
|
setStatus('Ready');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import chat history
|
||||||
|
async function importChatHistory() {
|
||||||
|
try {
|
||||||
|
setStatus('Importing chat...', 'default');
|
||||||
|
const messageCount = await invoke('import_chat_history');
|
||||||
|
|
||||||
|
// Reload the chat history
|
||||||
|
await loadChatHistory();
|
||||||
|
|
||||||
|
setStatus(`Imported ${messageCount} messages successfully!`, 'success');
|
||||||
|
setTimeout(() => setStatus('Ready'), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Import failed:', error);
|
||||||
|
if (error === 'No file selected' || error.toString().includes('cancelled')) {
|
||||||
|
setStatus('Ready');
|
||||||
|
} else {
|
||||||
|
setStatus(`Import failed: ${error}`, 'error');
|
||||||
|
setTimeout(() => setStatus('Ready'), 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to get avatar URL
|
// Helper function to get avatar URL
|
||||||
async function getAvatarUrl(avatarFilename) {
|
async function getAvatarUrl(avatarFilename) {
|
||||||
if (!avatarFilename) return null;
|
if (!avatarFilename) return null;
|
||||||
@@ -1206,6 +1247,8 @@ function setupAppControls() {
|
|||||||
document.getElementById('close-settings-btn').addEventListener('click', hideSettings);
|
document.getElementById('close-settings-btn').addEventListener('click', hideSettings);
|
||||||
document.getElementById('settings-overlay').addEventListener('click', hideSettings);
|
document.getElementById('settings-overlay').addEventListener('click', hideSettings);
|
||||||
document.getElementById('clear-btn').addEventListener('click', clearHistory);
|
document.getElementById('clear-btn').addEventListener('click', clearHistory);
|
||||||
|
document.getElementById('export-chat-btn').addEventListener('click', exportChatHistory);
|
||||||
|
document.getElementById('import-chat-btn').addEventListener('click', importChatHistory);
|
||||||
characterSelect.addEventListener('change', handleCharacterSwitch);
|
characterSelect.addEventListener('change', handleCharacterSwitch);
|
||||||
newCharacterBtn.addEventListener('click', handleNewCharacter);
|
newCharacterBtn.addEventListener('click', handleNewCharacter);
|
||||||
document.getElementById('delete-character-btn').addEventListener('click', handleDeleteCharacter);
|
document.getElementById('delete-character-btn').addEventListener('click', handleDeleteCharacter);
|
||||||
|
|||||||
Reference in New Issue
Block a user