Compare commits

...

5 Commits

Author SHA1 Message Date
cdb7baa197 feat: enhance roleplay features with regex, exports, and validation
This commit implements Options B, C, and D for enhanced roleplay capabilities:

**Option B: Enhanced Roleplay Features**
- Add regex pattern matching for World Info keywords with fallback to literal matching
- Make scan depth configurable (default: 20, range: 1-100 messages)
- Make Author's Note insertion depth configurable (default: 3, range: 1-50 messages)
- Add World Info import/export functionality with merge/replace options
- Update WorldInfoEntry struct with use_regex field
- Update RoleplaySettings with scan_depth and authors_note_depth fields
- Modify build_roleplay_context() to return configurable note_depth
- Update all 4 chat functions to use configurable Author's Note depth

**Option C: Chat Export Enhancements**
- Add export_chat_as_markdown: Clean markdown format with bold role labels
- Add export_chat_as_text: Simple plain text format with decorative separator
- Add export_chat_as_html: Styled HTML with responsive design and color-coded messages
- All exports use native file dialogs with appropriate file filters

**Option D: Polish & Quality of Life**
- Add validate_regex_pattern command for real-time regex validation
- Add update_roleplay_depths command with range validation
- Enhance add_world_info_entry with input validation (regex, empty checks)
- Enhance update_world_info_entry with input validation
- Add detailed error messages for all validation failures

**Technical Details:**
- Add regex dependency to Cargo.toml
- Use #[serde(default)] attributes for backward compatibility
- Graceful error handling with fallback strategies
- HTML export includes proper escaping for XSS protection
- All new commands registered in Tauri invoke_handler

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 17:01:16 -07:00
9707121a59 feat: implement Phase 2 roleplay context injection
Add full context injection system for World Info, Author's Note, and Persona:

- Add scan_for_world_info() function to detect keywords in last 20 messages
- Add build_roleplay_context() to assemble all roleplay additions
- Inject Persona into system prompt as [{{user}}'s Persona: name - desc]
- Inject activated World Info entries into system prompt by priority
- Inject Author's Note as system message before last 3 messages
- Apply context injection to all 4 chat functions:
  - chat() - regular non-streaming
  - chat_stream() - streaming
  - generate_response_only() - regenerate non-streaming
  - generate_response_stream() - regenerate streaming

All roleplay features now fully functional and affecting AI generation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 21:11:42 -07:00
e364ecfc51 feat: add World Info/Lorebook, Author's Note, and Persona systems
Backend changes:
- Add RoleplaySettings and WorldInfoEntry data structures
- Implement per-character roleplay settings storage in ~/.config/claudia/roleplay_{id}.json
- Add Tauri commands for CRUD operations on World Info entries
- Add commands for saving Author's Note and Persona settings

Frontend changes:
- Add World Info entry management UI with add/edit/delete functionality
- Implement keyword-triggered context injection system with priority ordering
- Add Author's Note textarea with enable toggle
- Add Persona name and description fields with enable toggle
- Load roleplay settings when opening the roleplay panel
- Add CSS styles for World Info entry cards with hover effects

Features:
- World Info entries support multiple keywords, priority levels, and enable/disable
- Settings are per-character and persist across sessions
- Entries sorted by priority (higher priority injected first)
- Clean UI with edit/delete buttons and visual feedback
2025-10-14 19:55:08 -07:00
5d32489c3c feat: add roleplay tools sidebar UI
Created left-sliding sidebar for roleplay features:
- Slides in from left (opposite of settings sidebar from right)
- Three tabs: World Info, Author's Note, Persona
- Hamburger menu button in header
- Overlay backdrop closes sidebar
- Tab switching with smooth transitions
- Responsive design matching settings panel

UI structure ready for implementation of:
- World Info/Lorebook entries with keyword triggers
- Author's Note for prompt injection
- Persona system for user character description
2025-10-14 18:07:19 -07:00
83e9793dce 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>
2025-10-14 18:03:34 -07:00
6 changed files with 1431 additions and 7 deletions

1
src-tauri/Cargo.lock generated
View File

@@ -4197,6 +4197,7 @@ dependencies = [
"futures",
"image",
"png",
"regex",
"reqwest",
"serde",
"serde_json",

View File

@@ -31,4 +31,5 @@ bytes = "1"
png = "0.17"
base64 = "0.21"
image = "0.24"
regex = "1"

View File

@@ -6,6 +6,7 @@ use uuid::Uuid;
use futures::StreamExt;
use tauri::Emitter;
use base64::Engine;
use regex::Regex;
#[derive(Debug, Clone, Serialize, Deserialize)]
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)]
struct ChatHistory {
messages: Vec<Message>,
@@ -330,6 +390,30 @@ fn get_avatar_path(filename: &str) -> PathBuf {
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
// 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())
}
// 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]
async fn chat(message: String) -> Result<String, 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)
};
// 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
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();
// Add history messages with current swipe content
@@ -807,6 +1004,16 @@ async fn chat(message: String) -> Result<String, String> {
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 {
model: config.model.clone(),
max_tokens: 4096,
@@ -863,8 +1070,13 @@ async fn chat_stream(app_handle: tauri::AppHandle, message: String) -> Result<St
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
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();
// 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);
}
// 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 {
model: config.model.clone(),
max_tokens: 4096,
@@ -1023,8 +1245,13 @@ async fn generate_response_only() -> Result<String, String> {
format!("{}/v1/chat/completions", base)
};
// Build messages with system prompt first
let mut api_messages = vec![Message::new_user(character.system_prompt.clone())];
// 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 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();
// 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);
}
// 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 {
model: config.model.clone(),
max_tokens: 4096,
@@ -1081,8 +1318,13 @@ async fn generate_response_stream(app_handle: tauri::AppHandle) -> Result<String
format!("{}/v1/chat/completions", base)
};
// Build messages with system prompt first
let mut api_messages = vec![Message::new_user(character.system_prompt.clone())];
// 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 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();
// 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);
}
// 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 {
model: config.model.clone(),
max_tokens: 4096,
@@ -1431,6 +1683,500 @@ async fn import_character_card(app_handle: tauri::AppHandle) -> Result<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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
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
#[tauri::command]
async fn export_character_card(app_handle: tauri::AppHandle, character_id: String) -> Result<String, String> {
@@ -1514,7 +2260,22 @@ pub fn run() {
delete_character,
set_active_character,
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!())
.expect("error while running tauri application");

View File

@@ -15,6 +15,13 @@
<div class="app-container">
<header class="app-header">
<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="avatar-circle"></div>
<span id="character-header-name"></span>
@@ -30,6 +37,18 @@
</button>
</div>
<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">
<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"/>
@@ -55,6 +74,103 @@
</div>
</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 -->
<div class="settings-overlay" id="settings-overlay"></div>

View File

@@ -201,6 +201,47 @@ function loadSavedFontSize() {
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
async function getAvatarUrl(avatarFilename) {
if (!avatarFilename) return null;
@@ -1020,8 +1061,27 @@ function hideSettings() {
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
function setupTabs() {
// Settings tabs
const tabBtns = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
@@ -1038,6 +1098,24 @@ function setupTabs() {
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
@@ -1205,7 +1283,12 @@ function setupAppControls() {
document.getElementById('settings-btn').addEventListener('click', showSettings);
document.getElementById('close-settings-btn').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('export-chat-btn').addEventListener('click', exportChatHistory);
document.getElementById('import-chat-btn').addEventListener('click', importChatHistory);
characterSelect.addEventListener('change', handleCharacterSwitch);
newCharacterBtn.addEventListener('click', handleNewCharacter);
document.getElementById('delete-character-btn').addEventListener('click', handleDeleteCharacter);
@@ -1250,6 +1333,11 @@ function setupAppControls() {
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
@@ -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
async function loadExistingConfig() {
console.log('Loading existing config...');

View File

@@ -1505,6 +1505,202 @@ body.view-comfortable .message-content pre {
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 */
@media (max-width: 600px) {
.messages-list {
@@ -1527,4 +1723,13 @@ body.view-comfortable .message-content pre {
.settings-panel.open {
right: 0;
}
.roleplay-panel {
width: 100%;
left: -100%;
}
.roleplay-panel.open {
left: 0;
}
}