Compare commits
5 Commits
84d3e0df67
...
cdb7baa197
| Author | SHA1 | Date | |
|---|---|---|---|
| cdb7baa197 | |||
| 9707121a59 | |||
| e364ecfc51 | |||
| 5d32489c3c | |||
| 83e9793dce |
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -4197,6 +4197,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"image",
|
"image",
|
||||||
"png",
|
"png",
|
||||||
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -31,4 +31,5 @@ bytes = "1"
|
|||||||
png = "0.17"
|
png = "0.17"
|
||||||
base64 = "0.21"
|
base64 = "0.21"
|
||||||
image = "0.24"
|
image = "0.24"
|
||||||
|
regex = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use uuid::Uuid;
|
|||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct ApiConfig {
|
struct ApiConfig {
|
||||||
@@ -241,6 +242,65 @@ impl Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// World Info / Lorebook Entry
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct WorldInfoEntry {
|
||||||
|
id: String,
|
||||||
|
keys: Vec<String>, // Keywords that trigger this entry
|
||||||
|
content: String, // The content to inject
|
||||||
|
enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
case_sensitive: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
priority: i32, // Higher priority entries are injected first
|
||||||
|
#[serde(default)]
|
||||||
|
use_regex: bool, // Use regex matching instead of literal string matching
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roleplay Settings (Author's Note, Persona, World Info)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct RoleplaySettings {
|
||||||
|
#[serde(default)]
|
||||||
|
authors_note: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
authors_note_enabled: bool,
|
||||||
|
#[serde(default = "default_authors_note_depth")]
|
||||||
|
authors_note_depth: usize, // Insert before last N messages (default 3)
|
||||||
|
#[serde(default)]
|
||||||
|
persona_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
persona_description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
persona_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
world_info: Vec<WorldInfoEntry>,
|
||||||
|
#[serde(default = "default_scan_depth")]
|
||||||
|
scan_depth: usize, // Scan last N messages for keywords (default 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_authors_note_depth() -> usize {
|
||||||
|
3
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_scan_depth() -> usize {
|
||||||
|
20
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RoleplaySettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
authors_note: None,
|
||||||
|
authors_note_enabled: false,
|
||||||
|
authors_note_depth: default_authors_note_depth(),
|
||||||
|
persona_name: None,
|
||||||
|
persona_description: None,
|
||||||
|
persona_enabled: false,
|
||||||
|
world_info: Vec::new(),
|
||||||
|
scan_depth: default_scan_depth(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct ChatHistory {
|
struct ChatHistory {
|
||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
@@ -330,6 +390,30 @@ fn get_avatar_path(filename: &str) -> PathBuf {
|
|||||||
get_avatars_dir().join(filename)
|
get_avatars_dir().join(filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_roleplay_settings_path(character_id: &str) -> PathBuf {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||||
|
PathBuf::from(home).join(format!(".config/claudia/roleplay_{}.json", character_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_roleplay_settings(character_id: &str) -> RoleplaySettings {
|
||||||
|
let path = get_roleplay_settings_path(character_id);
|
||||||
|
if let Ok(contents) = fs::read_to_string(path) {
|
||||||
|
serde_json::from_str(&contents).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
RoleplaySettings::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_roleplay_settings(character_id: &str, settings: &RoleplaySettings) -> Result<(), String> {
|
||||||
|
let path = get_roleplay_settings_path(character_id);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
let contents = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?;
|
||||||
|
fs::write(path, contents).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// PNG Character Card Utilities
|
// PNG Character Card Utilities
|
||||||
|
|
||||||
// Manual PNG chunk parser - more reliable than relying on png crate's text chunk exposure
|
// Manual PNG chunk parser - more reliable than relying on png crate's text chunk exposure
|
||||||
@@ -779,6 +863,114 @@ fn get_api_config() -> Result<ApiConfig, String> {
|
|||||||
load_config().ok_or_else(|| "No config found".to_string())
|
load_config().ok_or_else(|| "No config found".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Roleplay Context Injection Logic
|
||||||
|
|
||||||
|
// Scan messages for World Info keywords and return activated entries
|
||||||
|
fn scan_for_world_info(messages: &[Message], world_info: &[WorldInfoEntry], scan_depth: usize) -> Vec<WorldInfoEntry> {
|
||||||
|
let mut activated_entries = Vec::new();
|
||||||
|
|
||||||
|
// Scan the last N messages (scan_depth)
|
||||||
|
let messages_to_scan: Vec<&Message> = messages.iter()
|
||||||
|
.rev()
|
||||||
|
.take(scan_depth)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for entry in world_info {
|
||||||
|
if !entry.enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any keyword matches in the scanned messages
|
||||||
|
let mut activated = false;
|
||||||
|
for message in &messages_to_scan {
|
||||||
|
let content = message.get_content();
|
||||||
|
|
||||||
|
for keyword in &entry.keys {
|
||||||
|
let matches = if entry.use_regex {
|
||||||
|
// Use regex matching
|
||||||
|
if let Ok(re) = Regex::new(keyword) {
|
||||||
|
re.is_match(content)
|
||||||
|
} else {
|
||||||
|
// Invalid regex - fall back to literal matching
|
||||||
|
eprintln!("Invalid regex pattern '{}' for entry {}: falling back to literal match", keyword, entry.id);
|
||||||
|
if entry.case_sensitive {
|
||||||
|
content.contains(keyword)
|
||||||
|
} else {
|
||||||
|
content.to_lowercase().contains(&keyword.to_lowercase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use literal string matching
|
||||||
|
if entry.case_sensitive {
|
||||||
|
content.contains(keyword)
|
||||||
|
} else {
|
||||||
|
content.to_lowercase().contains(&keyword.to_lowercase())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches {
|
||||||
|
activated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if activated {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if activated {
|
||||||
|
activated_entries.push(entry.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by priority (higher first)
|
||||||
|
activated_entries.sort_by(|a, b| b.priority.cmp(&a.priority));
|
||||||
|
|
||||||
|
activated_entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build injected context from roleplay settings
|
||||||
|
fn build_roleplay_context(
|
||||||
|
_character: &Character,
|
||||||
|
messages: &[Message],
|
||||||
|
settings: &RoleplaySettings,
|
||||||
|
) -> (String, Option<String>, usize) {
|
||||||
|
let mut system_additions = String::new();
|
||||||
|
let mut authors_note_content = None;
|
||||||
|
|
||||||
|
// 1. Add Persona to system prompt
|
||||||
|
if settings.persona_enabled {
|
||||||
|
if let Some(name) = &settings.persona_name {
|
||||||
|
if let Some(desc) = &settings.persona_description {
|
||||||
|
system_additions.push_str(&format!("\n\n[{{{{user}}}}'s Persona: {} - {}]", name, desc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Scan for World Info and add to system prompt
|
||||||
|
let activated_entries = scan_for_world_info(messages, &settings.world_info, settings.scan_depth);
|
||||||
|
|
||||||
|
if !activated_entries.is_empty() {
|
||||||
|
system_additions.push_str("\n\n[Relevant World Information:");
|
||||||
|
for entry in activated_entries {
|
||||||
|
system_additions.push_str(&format!("\n- {}", entry.content));
|
||||||
|
}
|
||||||
|
system_additions.push_str("]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Store Author's Note for later injection
|
||||||
|
if settings.authors_note_enabled {
|
||||||
|
if let Some(note) = &settings.authors_note {
|
||||||
|
if !note.is_empty() {
|
||||||
|
authors_note_content = Some(note.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(system_additions, authors_note_content, settings.authors_note_depth)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn chat(message: String) -> Result<String, String> {
|
async fn chat(message: String) -> Result<String, String> {
|
||||||
let config = load_config().ok_or_else(|| "API not configured".to_string())?;
|
let config = load_config().ok_or_else(|| "API not configured".to_string())?;
|
||||||
@@ -796,8 +988,13 @@ async fn chat(message: String) -> Result<String, String> {
|
|||||||
format!("{}/v1/chat/completions", base)
|
format!("{}/v1/chat/completions", base)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load roleplay settings and build context
|
||||||
|
let roleplay_settings = load_roleplay_settings(&character.id);
|
||||||
|
let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, &history.messages, &roleplay_settings);
|
||||||
|
|
||||||
// Build messages with system prompt first - use simple Message for API
|
// Build messages with system prompt first - use simple Message for API
|
||||||
let mut api_messages = vec![Message::new_user(character.system_prompt.clone())];
|
let enhanced_system_prompt = format!("{}{}", character.system_prompt, system_additions);
|
||||||
|
let mut api_messages = vec![Message::new_user(enhanced_system_prompt)];
|
||||||
api_messages[0].role = "system".to_string();
|
api_messages[0].role = "system".to_string();
|
||||||
|
|
||||||
// Add history messages with current swipe content
|
// Add history messages with current swipe content
|
||||||
@@ -807,6 +1004,16 @@ async fn chat(message: String) -> Result<String, String> {
|
|||||||
api_messages.push(api_msg);
|
api_messages.push(api_msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert Author's Note before last N messages if it exists (configurable depth)
|
||||||
|
if let Some(note) = authors_note {
|
||||||
|
if api_messages.len() > (note_depth + 1) { // system + at least note_depth messages
|
||||||
|
let insert_pos = api_messages.len().saturating_sub(note_depth);
|
||||||
|
let mut note_msg = Message::new_user(format!("[Author's Note: {}]", note));
|
||||||
|
note_msg.role = "system".to_string();
|
||||||
|
api_messages.insert(insert_pos, note_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let request = ChatRequest {
|
let request = ChatRequest {
|
||||||
model: config.model.clone(),
|
model: config.model.clone(),
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
@@ -863,8 +1070,13 @@ async fn chat_stream(app_handle: tauri::AppHandle, message: String) -> Result<St
|
|||||||
format!("{}/v1/chat/completions", base)
|
format!("{}/v1/chat/completions", base)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load roleplay settings and build context
|
||||||
|
let roleplay_settings = load_roleplay_settings(&character.id);
|
||||||
|
let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, &history.messages, &roleplay_settings);
|
||||||
|
|
||||||
// Build messages with system prompt first - use simple Message for API
|
// Build messages with system prompt first - use simple Message for API
|
||||||
let mut api_messages = vec![Message::new_user(character.system_prompt.clone())];
|
let enhanced_system_prompt = format!("{}{}", character.system_prompt, system_additions);
|
||||||
|
let mut api_messages = vec![Message::new_user(enhanced_system_prompt)];
|
||||||
api_messages[0].role = "system".to_string();
|
api_messages[0].role = "system".to_string();
|
||||||
|
|
||||||
// Add history messages with current swipe content
|
// Add history messages with current swipe content
|
||||||
@@ -874,6 +1086,16 @@ async fn chat_stream(app_handle: tauri::AppHandle, message: String) -> Result<St
|
|||||||
api_messages.push(api_msg);
|
api_messages.push(api_msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert Author's Note before last N messages if it exists (configurable depth)
|
||||||
|
if let Some(note) = authors_note {
|
||||||
|
if api_messages.len() > (note_depth + 1) { // system + at least note_depth messages
|
||||||
|
let insert_pos = api_messages.len().saturating_sub(note_depth);
|
||||||
|
let mut note_msg = Message::new_user(format!("[Author's Note: {}]", note));
|
||||||
|
note_msg.role = "system".to_string();
|
||||||
|
api_messages.insert(insert_pos, note_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let request = StreamChatRequest {
|
let request = StreamChatRequest {
|
||||||
model: config.model.clone(),
|
model: config.model.clone(),
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
@@ -1023,8 +1245,13 @@ async fn generate_response_only() -> Result<String, String> {
|
|||||||
format!("{}/v1/chat/completions", base)
|
format!("{}/v1/chat/completions", base)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build messages with system prompt first
|
// Load roleplay settings and build context
|
||||||
let mut api_messages = vec![Message::new_user(character.system_prompt.clone())];
|
let roleplay_settings = load_roleplay_settings(&character.id);
|
||||||
|
let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, &history.messages, &roleplay_settings);
|
||||||
|
|
||||||
|
// Build messages with enhanced system prompt first
|
||||||
|
let enhanced_system_prompt = format!("{}{}", character.system_prompt, system_additions);
|
||||||
|
let mut api_messages = vec![Message::new_user(enhanced_system_prompt)];
|
||||||
api_messages[0].role = "system".to_string();
|
api_messages[0].role = "system".to_string();
|
||||||
|
|
||||||
// Add existing history (which already includes the user message)
|
// Add existing history (which already includes the user message)
|
||||||
@@ -1034,6 +1261,16 @@ async fn generate_response_only() -> Result<String, String> {
|
|||||||
api_messages.push(api_msg);
|
api_messages.push(api_msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert Author's Note before last N messages if it exists (configurable depth)
|
||||||
|
if let Some(note) = authors_note {
|
||||||
|
if api_messages.len() > (note_depth + 1) { // system + at least note_depth messages
|
||||||
|
let insert_pos = api_messages.len().saturating_sub(note_depth);
|
||||||
|
let mut note_msg = Message::new_user(format!("[Author's Note: {}]", note));
|
||||||
|
note_msg.role = "system".to_string();
|
||||||
|
api_messages.insert(insert_pos, note_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let request = ChatRequest {
|
let request = ChatRequest {
|
||||||
model: config.model.clone(),
|
model: config.model.clone(),
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
@@ -1081,8 +1318,13 @@ async fn generate_response_stream(app_handle: tauri::AppHandle) -> Result<String
|
|||||||
format!("{}/v1/chat/completions", base)
|
format!("{}/v1/chat/completions", base)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build messages with system prompt first
|
// Load roleplay settings and build context
|
||||||
let mut api_messages = vec![Message::new_user(character.system_prompt.clone())];
|
let roleplay_settings = load_roleplay_settings(&character.id);
|
||||||
|
let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, &history.messages, &roleplay_settings);
|
||||||
|
|
||||||
|
// Build messages with enhanced system prompt first
|
||||||
|
let enhanced_system_prompt = format!("{}{}", character.system_prompt, system_additions);
|
||||||
|
let mut api_messages = vec![Message::new_user(enhanced_system_prompt)];
|
||||||
api_messages[0].role = "system".to_string();
|
api_messages[0].role = "system".to_string();
|
||||||
|
|
||||||
// Add existing history (which already includes the user message)
|
// Add existing history (which already includes the user message)
|
||||||
@@ -1092,6 +1334,16 @@ async fn generate_response_stream(app_handle: tauri::AppHandle) -> Result<String
|
|||||||
api_messages.push(api_msg);
|
api_messages.push(api_msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert Author's Note before last N messages if it exists (configurable depth)
|
||||||
|
if let Some(note) = authors_note {
|
||||||
|
if api_messages.len() > (note_depth + 1) { // system + at least note_depth messages
|
||||||
|
let insert_pos = api_messages.len().saturating_sub(note_depth);
|
||||||
|
let mut note_msg = Message::new_user(format!("[Author's Note: {}]", note));
|
||||||
|
note_msg.role = "system".to_string();
|
||||||
|
api_messages.insert(insert_pos, note_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let request = StreamChatRequest {
|
let request = StreamChatRequest {
|
||||||
model: config.model.clone(),
|
model: config.model.clone(),
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
@@ -1431,6 +1683,500 @@ 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export chat history as Markdown
|
||||||
|
#[tauri::command]
|
||||||
|
async fn export_chat_as_markdown(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("Markdown", &["md"])
|
||||||
|
.set_file_name(&format!("chat_{}.md", 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());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build markdown content
|
||||||
|
let mut contents = String::new();
|
||||||
|
contents.push_str(&format!("# Chat with {}\n\n", character.name));
|
||||||
|
|
||||||
|
for msg in &history.messages {
|
||||||
|
let role_label = if msg.role == "user" { "**User**" } else { "**Assistant**" };
|
||||||
|
contents.push_str(&format!("{}: {}\n\n", role_label, msg.get_content()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(&output_path, contents)
|
||||||
|
.map_err(|e| format!("Failed to write file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(output_path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export chat history as plain text
|
||||||
|
#[tauri::command]
|
||||||
|
async fn export_chat_as_text(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("Text", &["txt"])
|
||||||
|
.set_file_name(&format!("chat_{}.txt", 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());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build plain text content
|
||||||
|
let mut contents = String::new();
|
||||||
|
contents.push_str(&format!("Chat with {}\n", character.name));
|
||||||
|
contents.push_str(&"=".repeat(50));
|
||||||
|
contents.push_str("\n\n");
|
||||||
|
|
||||||
|
for msg in &history.messages {
|
||||||
|
let role_label = if msg.role == "user" { "User" } else { "Assistant" };
|
||||||
|
contents.push_str(&format!("{}: {}\n\n", role_label, msg.get_content()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(&output_path, contents)
|
||||||
|
.map_err(|e| format!("Failed to write file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(output_path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export chat history as HTML
|
||||||
|
#[tauri::command]
|
||||||
|
async fn export_chat_as_html(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("HTML", &["html"])
|
||||||
|
.set_file_name(&format!("chat_{}.html", 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());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build HTML content with styling
|
||||||
|
let mut contents = String::new();
|
||||||
|
contents.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
|
||||||
|
contents.push_str(" <meta charset=\"UTF-8\">\n");
|
||||||
|
contents.push_str(&format!(" <title>Chat with {}</title>\n", character.name));
|
||||||
|
contents.push_str(" <style>\n");
|
||||||
|
contents.push_str(" body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; background: #f5f5f5; }\n");
|
||||||
|
contents.push_str(" h1 { color: #333; border-bottom: 2px solid #ddd; padding-bottom: 10px; }\n");
|
||||||
|
contents.push_str(" .message { margin: 20px 0; padding: 15px; border-radius: 8px; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }\n");
|
||||||
|
contents.push_str(" .user { border-left: 4px solid #4CAF50; }\n");
|
||||||
|
contents.push_str(" .assistant { border-left: 4px solid #2196F3; }\n");
|
||||||
|
contents.push_str(" .role { font-weight: bold; margin-bottom: 8px; color: #555; }\n");
|
||||||
|
contents.push_str(" .content { line-height: 1.6; color: #333; white-space: pre-wrap; }\n");
|
||||||
|
contents.push_str(" </style>\n");
|
||||||
|
contents.push_str("</head>\n<body>\n");
|
||||||
|
contents.push_str(&format!(" <h1>Chat with {}</h1>\n", character.name));
|
||||||
|
|
||||||
|
for msg in &history.messages {
|
||||||
|
let role_class = if msg.role == "user" { "user" } else { "assistant" };
|
||||||
|
let role_label = if msg.role == "user" { "User" } else { "Assistant" };
|
||||||
|
let content = msg.get_content().replace("&", "&").replace("<", "<").replace(">", ">");
|
||||||
|
|
||||||
|
contents.push_str(&format!(" <div class=\"message {}\">\n", role_class));
|
||||||
|
contents.push_str(&format!(" <div class=\"role\">{}</div>\n", role_label));
|
||||||
|
contents.push_str(&format!(" <div class=\"content\">{}</div>\n", content));
|
||||||
|
contents.push_str(" </div>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
contents.push_str("</body>\n</html>");
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roleplay Settings Commands
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_roleplay_settings(character_id: String) -> Result<RoleplaySettings, String> {
|
||||||
|
Ok(load_roleplay_settings(&character_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a regex pattern
|
||||||
|
#[tauri::command]
|
||||||
|
fn validate_regex_pattern(pattern: String) -> Result<bool, String> {
|
||||||
|
match Regex::new(&pattern) {
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(e) => Err(format!("Invalid regex pattern: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update roleplay settings with validation
|
||||||
|
#[tauri::command]
|
||||||
|
fn update_roleplay_depths(
|
||||||
|
character_id: String,
|
||||||
|
scan_depth: Option<usize>,
|
||||||
|
authors_note_depth: Option<usize>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut settings = load_roleplay_settings(&character_id);
|
||||||
|
|
||||||
|
// Validate scan_depth (should be between 1 and 100)
|
||||||
|
if let Some(depth) = scan_depth {
|
||||||
|
if depth < 1 || depth > 100 {
|
||||||
|
return Err("Scan depth must be between 1 and 100".to_string());
|
||||||
|
}
|
||||||
|
settings.scan_depth = depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate authors_note_depth (should be between 1 and 50)
|
||||||
|
if let Some(depth) = authors_note_depth {
|
||||||
|
if depth < 1 || depth > 50 {
|
||||||
|
return Err("Author's Note depth must be between 1 and 50".to_string());
|
||||||
|
}
|
||||||
|
settings.authors_note_depth = depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
save_roleplay_settings(&character_id, &settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn update_authors_note(
|
||||||
|
character_id: String,
|
||||||
|
content: Option<String>,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut settings = load_roleplay_settings(&character_id);
|
||||||
|
settings.authors_note = content;
|
||||||
|
settings.authors_note_enabled = enabled;
|
||||||
|
save_roleplay_settings(&character_id, &settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn update_persona(
|
||||||
|
character_id: String,
|
||||||
|
name: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut settings = load_roleplay_settings(&character_id);
|
||||||
|
settings.persona_name = name;
|
||||||
|
settings.persona_description = description;
|
||||||
|
settings.persona_enabled = enabled;
|
||||||
|
save_roleplay_settings(&character_id, &settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn add_world_info_entry(
|
||||||
|
character_id: String,
|
||||||
|
keys: Vec<String>,
|
||||||
|
content: String,
|
||||||
|
priority: i32,
|
||||||
|
case_sensitive: bool,
|
||||||
|
use_regex: bool,
|
||||||
|
) -> Result<WorldInfoEntry, String> {
|
||||||
|
let mut settings = load_roleplay_settings(&character_id);
|
||||||
|
|
||||||
|
// Validate regex patterns if use_regex is true
|
||||||
|
if use_regex {
|
||||||
|
for key in &keys {
|
||||||
|
if let Err(e) = Regex::new(key) {
|
||||||
|
return Err(format!("Invalid regex pattern '{}': {}", key, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that keys is not empty
|
||||||
|
if keys.is_empty() {
|
||||||
|
return Err("At least one keyword is required".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that content is not empty
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
return Err("Content cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = WorldInfoEntry {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
keys,
|
||||||
|
content,
|
||||||
|
enabled: true,
|
||||||
|
case_sensitive,
|
||||||
|
priority,
|
||||||
|
use_regex,
|
||||||
|
};
|
||||||
|
|
||||||
|
settings.world_info.push(entry.clone());
|
||||||
|
save_roleplay_settings(&character_id, &settings)?;
|
||||||
|
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn update_world_info_entry(
|
||||||
|
character_id: String,
|
||||||
|
entry_id: String,
|
||||||
|
keys: Vec<String>,
|
||||||
|
content: String,
|
||||||
|
enabled: bool,
|
||||||
|
priority: i32,
|
||||||
|
case_sensitive: bool,
|
||||||
|
use_regex: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut settings = load_roleplay_settings(&character_id);
|
||||||
|
|
||||||
|
// Validate regex patterns if use_regex is true
|
||||||
|
if use_regex {
|
||||||
|
for key in &keys {
|
||||||
|
if let Err(e) = Regex::new(key) {
|
||||||
|
return Err(format!("Invalid regex pattern '{}': {}", key, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that keys is not empty
|
||||||
|
if keys.is_empty() {
|
||||||
|
return Err("At least one keyword is required".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that content is not empty
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
return Err("Content cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(entry) = settings.world_info.iter_mut().find(|e| e.id == entry_id) {
|
||||||
|
entry.keys = keys;
|
||||||
|
entry.content = content;
|
||||||
|
entry.enabled = enabled;
|
||||||
|
entry.priority = priority;
|
||||||
|
entry.case_sensitive = case_sensitive;
|
||||||
|
entry.use_regex = use_regex;
|
||||||
|
save_roleplay_settings(&character_id, &settings)
|
||||||
|
} else {
|
||||||
|
Err("World Info entry not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_world_info_entry(
|
||||||
|
character_id: String,
|
||||||
|
entry_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut settings = load_roleplay_settings(&character_id);
|
||||||
|
settings.world_info.retain(|e| e.id != entry_id);
|
||||||
|
save_roleplay_settings(&character_id, &settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export World Info entries to JSON
|
||||||
|
#[tauri::command]
|
||||||
|
async fn export_world_info(app_handle: tauri::AppHandle, character_id: String) -> Result<String, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let settings = load_roleplay_settings(&character_id);
|
||||||
|
let character = load_character(&character_id)
|
||||||
|
.ok_or_else(|| "Character not found".to_string())?;
|
||||||
|
|
||||||
|
// Open save dialog
|
||||||
|
let save_path = app_handle
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.add_filter("World Info", &["json"])
|
||||||
|
.set_file_name(&format!("worldinfo_{}.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 world info to JSON file
|
||||||
|
let contents = serde_json::to_string_pretty(&settings.world_info)
|
||||||
|
.map_err(|e| format!("Failed to serialize World Info: {}", e))?;
|
||||||
|
|
||||||
|
fs::write(&output_path, contents)
|
||||||
|
.map_err(|e| format!("Failed to write file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(output_path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import World Info entries from JSON
|
||||||
|
#[tauri::command]
|
||||||
|
async fn import_world_info(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
character_id: String,
|
||||||
|
merge: bool,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
// Open file picker for JSON files
|
||||||
|
let file_path = app_handle
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.add_filter("World Info", &["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 world info file
|
||||||
|
let contents = fs::read_to_string(&json_path)
|
||||||
|
.map_err(|e| format!("Failed to read file: {}", e))?;
|
||||||
|
|
||||||
|
let imported_entries: Vec<WorldInfoEntry> = serde_json::from_str(&contents)
|
||||||
|
.map_err(|e| format!("Failed to parse World Info: {}", e))?;
|
||||||
|
|
||||||
|
let entry_count = imported_entries.len();
|
||||||
|
|
||||||
|
// Load current settings
|
||||||
|
let mut settings = load_roleplay_settings(&character_id);
|
||||||
|
|
||||||
|
if merge {
|
||||||
|
// Merge: Add imported entries to existing ones (regenerate IDs to avoid conflicts)
|
||||||
|
for mut entry in imported_entries {
|
||||||
|
entry.id = Uuid::new_v4().to_string(); // Generate new ID
|
||||||
|
settings.world_info.push(entry);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Replace: Replace all world info with imported entries
|
||||||
|
settings.world_info = imported_entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
save_roleplay_settings(&character_id, &settings)?;
|
||||||
|
|
||||||
|
Ok(entry_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 +2260,22 @@ 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,
|
||||||
|
export_chat_as_markdown,
|
||||||
|
export_chat_as_text,
|
||||||
|
export_chat_as_html,
|
||||||
|
import_chat_history,
|
||||||
|
get_roleplay_settings,
|
||||||
|
validate_regex_pattern,
|
||||||
|
update_roleplay_depths,
|
||||||
|
update_authors_note,
|
||||||
|
update_persona,
|
||||||
|
add_world_info_entry,
|
||||||
|
update_world_info_entry,
|
||||||
|
delete_world_info_entry,
|
||||||
|
export_world_info,
|
||||||
|
import_world_info
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
116
src/index.html
116
src/index.html
@@ -15,6 +15,13 @@
|
|||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
|
<div class="header-left-controls">
|
||||||
|
<button id="roleplay-btn" class="icon-btn" title="Roleplay Tools">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="character-display">
|
<div class="character-display">
|
||||||
<div class="avatar-circle"></div>
|
<div class="avatar-circle"></div>
|
||||||
<span id="character-header-name"></span>
|
<span id="character-header-name"></span>
|
||||||
@@ -30,6 +37,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"/>
|
||||||
@@ -55,6 +74,103 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Roleplay sidebar overlay -->
|
||||||
|
<div class="roleplay-overlay" id="roleplay-overlay"></div>
|
||||||
|
|
||||||
|
<!-- Roleplay sidebar (left) -->
|
||||||
|
<div class="roleplay-panel" id="roleplay-panel">
|
||||||
|
<div class="roleplay-header">
|
||||||
|
<h2>Roleplay Tools</h2>
|
||||||
|
<button id="close-roleplay-btn" class="icon-btn">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<line x1="4" y1="4" x2="12" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<line x1="12" y1="4" x2="4" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="roleplay-tabs">
|
||||||
|
<button class="roleplay-tab-btn active" data-tab="worldinfo">World Info</button>
|
||||||
|
<button class="roleplay-tab-btn" data-tab="authorsnote">Author's Note</button>
|
||||||
|
<button class="roleplay-tab-btn" data-tab="persona">Persona</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="worldinfo-tab" class="roleplay-tab-content active">
|
||||||
|
<div class="roleplay-content">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>World Info / Lorebook</label>
|
||||||
|
<p style="color: var(--text-secondary); font-size: 12px; margin-bottom: 12px;">
|
||||||
|
Create entries that inject context when keywords are mentioned.
|
||||||
|
</p>
|
||||||
|
<button type="button" id="add-worldinfo-btn" class="btn-secondary" style="width: 100%;">
|
||||||
|
+ Add Entry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="worldinfo-list" class="worldinfo-list">
|
||||||
|
<!-- World info entries will be added here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="authorsnote-tab" class="roleplay-tab-content">
|
||||||
|
<div class="roleplay-content">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="authors-note-text">Author's Note</label>
|
||||||
|
<p style="color: var(--text-secondary); font-size: 12px; margin-bottom: 8px;">
|
||||||
|
Instructions inserted near the end of the prompt before the latest messages.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
id="authors-note-text"
|
||||||
|
placeholder="Write in present tense. Focus on sensory details..."
|
||||||
|
rows="6"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="authors-note-enabled" />
|
||||||
|
Enable Author's Note
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="save-authors-note-btn" class="btn-primary" style="width: 100%;">
|
||||||
|
Save Author's Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="persona-tab" class="roleplay-tab-content">
|
||||||
|
<div class="roleplay-content">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="persona-name">Persona Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="persona-name"
|
||||||
|
placeholder="Your character name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="persona-description">Persona Description</label>
|
||||||
|
<p style="color: var(--text-secondary); font-size: 12px; margin-bottom: 8px;">
|
||||||
|
Describe yourself as the user in this roleplay.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
id="persona-description"
|
||||||
|
placeholder="Describe your character's appearance, personality, background..."
|
||||||
|
rows="8"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="persona-enabled" />
|
||||||
|
Enable Persona
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="save-persona-btn" class="btn-primary" style="width: 100%;">
|
||||||
|
Save Persona
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Settings overlay backdrop -->
|
<!-- Settings overlay backdrop -->
|
||||||
<div class="settings-overlay" id="settings-overlay"></div>
|
<div class="settings-overlay" id="settings-overlay"></div>
|
||||||
|
|
||||||
|
|||||||
340
src/main.js
340
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;
|
||||||
@@ -1020,8 +1061,27 @@ function hideSettings() {
|
|||||||
overlay.classList.remove('show');
|
overlay.classList.remove('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show/hide roleplay panel
|
||||||
|
async function showRoleplayPanel() {
|
||||||
|
const panel = document.getElementById('roleplay-panel');
|
||||||
|
const overlay = document.getElementById('roleplay-overlay');
|
||||||
|
panel.classList.add('open');
|
||||||
|
overlay.classList.add('show');
|
||||||
|
|
||||||
|
// Load roleplay settings when panel opens
|
||||||
|
await loadRoleplaySettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideRoleplayPanel() {
|
||||||
|
const panel = document.getElementById('roleplay-panel');
|
||||||
|
const overlay = document.getElementById('roleplay-overlay');
|
||||||
|
panel.classList.remove('open');
|
||||||
|
overlay.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
function setupTabs() {
|
function setupTabs() {
|
||||||
|
// Settings tabs
|
||||||
const tabBtns = document.querySelectorAll('.tab-btn');
|
const tabBtns = document.querySelectorAll('.tab-btn');
|
||||||
const tabContents = document.querySelectorAll('.tab-content');
|
const tabContents = document.querySelectorAll('.tab-content');
|
||||||
|
|
||||||
@@ -1038,6 +1098,24 @@ function setupTabs() {
|
|||||||
document.getElementById(`${targetTab}-tab`).classList.add('active');
|
document.getElementById(`${targetTab}-tab`).classList.add('active');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Roleplay tabs
|
||||||
|
const roleplayTabBtns = document.querySelectorAll('.roleplay-tab-btn');
|
||||||
|
const roleplayTabContents = document.querySelectorAll('.roleplay-tab-content');
|
||||||
|
|
||||||
|
roleplayTabBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const targetTab = btn.getAttribute('data-tab');
|
||||||
|
|
||||||
|
// Remove active class from all roleplay tabs and contents
|
||||||
|
roleplayTabBtns.forEach(b => b.classList.remove('active'));
|
||||||
|
roleplayTabContents.forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
|
// Add active class to clicked tab and corresponding content
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById(`${targetTab}-tab`).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
@@ -1205,7 +1283,12 @@ function setupAppControls() {
|
|||||||
document.getElementById('settings-btn').addEventListener('click', showSettings);
|
document.getElementById('settings-btn').addEventListener('click', showSettings);
|
||||||
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('roleplay-btn').addEventListener('click', showRoleplayPanel);
|
||||||
|
document.getElementById('close-roleplay-btn').addEventListener('click', hideRoleplayPanel);
|
||||||
|
document.getElementById('roleplay-overlay').addEventListener('click', hideRoleplayPanel);
|
||||||
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);
|
||||||
@@ -1250,6 +1333,11 @@ function setupAppControls() {
|
|||||||
applyFontSize(parseInt(e.target.value));
|
applyFontSize(parseInt(e.target.value));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup roleplay panel buttons
|
||||||
|
document.getElementById('add-worldinfo-btn').addEventListener('click', handleAddWorldInfoEntry);
|
||||||
|
document.getElementById('save-authors-note-btn').addEventListener('click', handleSaveAuthorsNote);
|
||||||
|
document.getElementById('save-persona-btn').addEventListener('click', handleSavePersona);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
@@ -1575,6 +1663,258 @@ async function handleSaveCharacter(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// World Info / Roleplay Settings Management
|
||||||
|
|
||||||
|
let currentRoleplaySettings = null;
|
||||||
|
|
||||||
|
// Load roleplay settings for current character
|
||||||
|
async function loadRoleplaySettings() {
|
||||||
|
if (!currentCharacter) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = await invoke('get_roleplay_settings', { characterId: currentCharacter.id });
|
||||||
|
currentRoleplaySettings = settings;
|
||||||
|
|
||||||
|
// Load World Info entries
|
||||||
|
renderWorldInfoList(settings.world_info || []);
|
||||||
|
|
||||||
|
// Load Author's Note
|
||||||
|
document.getElementById('authors-note-text').value = settings.authors_note || '';
|
||||||
|
document.getElementById('authors-note-enabled').checked = settings.authors_note_enabled || false;
|
||||||
|
|
||||||
|
// Load Persona
|
||||||
|
document.getElementById('persona-name').value = settings.persona_name || '';
|
||||||
|
document.getElementById('persona-description').value = settings.persona_description || '';
|
||||||
|
document.getElementById('persona-enabled').checked = settings.persona_enabled || false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load roleplay settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render World Info entries
|
||||||
|
function renderWorldInfoList(entries) {
|
||||||
|
const listContainer = document.getElementById('worldinfo-list');
|
||||||
|
listContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
const emptyMsg = document.createElement('p');
|
||||||
|
emptyMsg.style.color = 'var(--text-secondary)';
|
||||||
|
emptyMsg.style.fontSize = '14px';
|
||||||
|
emptyMsg.style.textAlign = 'center';
|
||||||
|
emptyMsg.style.padding = '20px';
|
||||||
|
emptyMsg.textContent = 'No entries yet. Click "Add Entry" to create one.';
|
||||||
|
listContainer.appendChild(emptyMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort entries by priority (higher first)
|
||||||
|
const sortedEntries = [...entries].sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||||
|
|
||||||
|
sortedEntries.forEach(entry => {
|
||||||
|
const entryDiv = document.createElement('div');
|
||||||
|
entryDiv.className = 'worldinfo-entry';
|
||||||
|
entryDiv.dataset.entryId = entry.id;
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'worldinfo-entry-header';
|
||||||
|
|
||||||
|
const enableCheckbox = document.createElement('input');
|
||||||
|
enableCheckbox.type = 'checkbox';
|
||||||
|
enableCheckbox.checked = entry.enabled;
|
||||||
|
enableCheckbox.addEventListener('change', () => handleToggleWorldInfoEntry(entry.id, enableCheckbox.checked));
|
||||||
|
|
||||||
|
const keysText = document.createElement('span');
|
||||||
|
keysText.className = 'worldinfo-keys';
|
||||||
|
keysText.textContent = entry.keys.join(', ');
|
||||||
|
|
||||||
|
const priority = document.createElement('span');
|
||||||
|
priority.className = 'worldinfo-priority';
|
||||||
|
priority.textContent = `Priority: ${entry.priority || 0}`;
|
||||||
|
|
||||||
|
const actionsDiv = document.createElement('div');
|
||||||
|
actionsDiv.className = 'worldinfo-entry-actions';
|
||||||
|
|
||||||
|
const editBtn = document.createElement('button');
|
||||||
|
editBtn.className = 'worldinfo-btn';
|
||||||
|
editBtn.textContent = 'Edit';
|
||||||
|
editBtn.addEventListener('click', () => handleEditWorldInfoEntry(entry));
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.className = 'worldinfo-btn worldinfo-btn-danger';
|
||||||
|
deleteBtn.textContent = 'Delete';
|
||||||
|
deleteBtn.addEventListener('click', () => handleDeleteWorldInfoEntry(entry.id));
|
||||||
|
|
||||||
|
actionsDiv.appendChild(editBtn);
|
||||||
|
actionsDiv.appendChild(deleteBtn);
|
||||||
|
|
||||||
|
header.appendChild(enableCheckbox);
|
||||||
|
header.appendChild(keysText);
|
||||||
|
header.appendChild(priority);
|
||||||
|
header.appendChild(actionsDiv);
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'worldinfo-entry-content';
|
||||||
|
content.textContent = entry.content;
|
||||||
|
|
||||||
|
entryDiv.appendChild(header);
|
||||||
|
entryDiv.appendChild(content);
|
||||||
|
listContainer.appendChild(entryDiv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new World Info entry
|
||||||
|
async function handleAddWorldInfoEntry() {
|
||||||
|
const keys = prompt('Enter keywords (comma-separated):\nExample: John, John Smith');
|
||||||
|
if (!keys) return;
|
||||||
|
|
||||||
|
const content = prompt('Enter the content to inject when keywords are found:');
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const priorityStr = prompt('Enter priority (higher = injected first, default 0):', '0');
|
||||||
|
const priority = parseInt(priorityStr) || 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keysArray = keys.split(',').map(k => k.trim()).filter(k => k);
|
||||||
|
await invoke('add_world_info_entry', {
|
||||||
|
characterId: currentCharacter.id,
|
||||||
|
keys: keysArray,
|
||||||
|
content: content.trim(),
|
||||||
|
priority,
|
||||||
|
caseSensitive: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload settings
|
||||||
|
await loadRoleplaySettings();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add World Info entry:', error);
|
||||||
|
alert(`Failed to add entry: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit World Info entry
|
||||||
|
async function handleEditWorldInfoEntry(entry) {
|
||||||
|
const keys = prompt('Edit keywords (comma-separated):', entry.keys.join(', '));
|
||||||
|
if (keys === null) return;
|
||||||
|
|
||||||
|
const content = prompt('Edit content:', entry.content);
|
||||||
|
if (content === null) return;
|
||||||
|
|
||||||
|
const priorityStr = prompt('Edit priority:', entry.priority.toString());
|
||||||
|
if (priorityStr === null) return;
|
||||||
|
const priority = parseInt(priorityStr) || 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keysArray = keys.split(',').map(k => k.trim()).filter(k => k);
|
||||||
|
await invoke('update_world_info_entry', {
|
||||||
|
characterId: currentCharacter.id,
|
||||||
|
entryId: entry.id,
|
||||||
|
keys: keysArray,
|
||||||
|
content: content.trim(),
|
||||||
|
enabled: entry.enabled,
|
||||||
|
priority,
|
||||||
|
caseSensitive: entry.case_sensitive
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload settings
|
||||||
|
await loadRoleplaySettings();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update World Info entry:', error);
|
||||||
|
alert(`Failed to update entry: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle World Info entry enabled state
|
||||||
|
async function handleToggleWorldInfoEntry(entryId, enabled) {
|
||||||
|
if (!currentRoleplaySettings) return;
|
||||||
|
|
||||||
|
const entry = currentRoleplaySettings.world_info.find(e => e.id === entryId);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('update_world_info_entry', {
|
||||||
|
characterId: currentCharacter.id,
|
||||||
|
entryId: entryId,
|
||||||
|
keys: entry.keys,
|
||||||
|
content: entry.content,
|
||||||
|
enabled: enabled,
|
||||||
|
priority: entry.priority,
|
||||||
|
caseSensitive: entry.case_sensitive
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local settings
|
||||||
|
entry.enabled = enabled;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle World Info entry:', error);
|
||||||
|
alert(`Failed to toggle entry: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete World Info entry
|
||||||
|
async function handleDeleteWorldInfoEntry(entryId) {
|
||||||
|
if (!confirm('Delete this World Info entry? This cannot be undone.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('delete_world_info_entry', {
|
||||||
|
characterId: currentCharacter.id,
|
||||||
|
entryId: entryId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload settings
|
||||||
|
await loadRoleplaySettings();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete World Info entry:', error);
|
||||||
|
alert(`Failed to delete entry: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save Author's Note
|
||||||
|
async function handleSaveAuthorsNote() {
|
||||||
|
if (!currentCharacter) return;
|
||||||
|
|
||||||
|
const content = document.getElementById('authors-note-text').value.trim() || null;
|
||||||
|
const enabled = document.getElementById('authors-note-enabled').checked;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('update_authors_note', {
|
||||||
|
characterId: currentCharacter.id,
|
||||||
|
content,
|
||||||
|
enabled
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
setStatus('Author\'s Note saved', 'success');
|
||||||
|
setTimeout(() => setStatus('Ready'), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save Author\'s Note:', error);
|
||||||
|
setStatus('Failed to save Author\'s Note', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save Persona
|
||||||
|
async function handleSavePersona() {
|
||||||
|
if (!currentCharacter) return;
|
||||||
|
|
||||||
|
const name = document.getElementById('persona-name').value.trim() || null;
|
||||||
|
const description = document.getElementById('persona-description').value.trim() || null;
|
||||||
|
const enabled = document.getElementById('persona-enabled').checked;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('update_persona', {
|
||||||
|
characterId: currentCharacter.id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
enabled
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
setStatus('Persona saved', 'success');
|
||||||
|
setTimeout(() => setStatus('Ready'), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save Persona:', error);
|
||||||
|
setStatus('Failed to save Persona', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load existing config if available
|
// Load existing config if available
|
||||||
async function loadExistingConfig() {
|
async function loadExistingConfig() {
|
||||||
console.log('Loading existing config...');
|
console.log('Loading existing config...');
|
||||||
|
|||||||
205
src/styles.css
205
src/styles.css
@@ -1505,6 +1505,202 @@ body.view-comfortable .message-content pre {
|
|||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Roleplay Panel - Slide-in from Left */
|
||||||
|
.roleplay-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: -500px;
|
||||||
|
width: 500px;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
transition: left 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleplay-panel.open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleplay-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 999;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleplay-overlay.show {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleplay-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleplay-header h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleplay-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleplay-tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleplay-tab-btn:hover {
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleplay-tab-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleplay-tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleplay-tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleplay-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldinfo-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldinfo-entry {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldinfo-entry:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldinfo-entry-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldinfo-entry-header input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldinfo-keys {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldinfo-priority {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldinfo-entry-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldinfo-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldinfo-btn:hover {
|
||||||
|
background: var(--border);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldinfo-btn-danger {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-color: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldinfo-btn-danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldinfo-entry-content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.messages-list {
|
.messages-list {
|
||||||
@@ -1527,4 +1723,13 @@ body.view-comfortable .message-content pre {
|
|||||||
.settings-panel.open {
|
.settings-panel.open {
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.roleplay-panel {
|
||||||
|
width: 100%;
|
||||||
|
left: -100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleplay-panel.open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user