Compare commits

...

4 Commits

Author SHA1 Message Date
71bac12cd9 docs: rewrite README to be more concise and professional
Removed excessive emojis, marketing fluff, and outdated information. Updated to reflect actually implemented features and current development focus.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 17:37:39 -07:00
9da17c824d docs: update roadmap to mark message examples as complete
Updated current focus to Chat Branching/Checkpoints as next feature. Added Message Examples and Token Counter to implemented features list.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 17:34:45 -07:00
d8cb4a768b feat: implement message examples usage from character cards
Add support for using mes_example field from character cards to teach the AI the character's voice and writing style. Examples are parsed, processed with template variable replacement, and injected into the context at a configurable position.

Backend changes:
- Extended RoleplaySettings with examples_enabled and examples_position fields
- Implemented parse_message_examples() to parse <START>-delimited example blocks
- Added example injection in build_api_messages() with position control
- Integrated examples into token counter with accurate counting
- Created update_examples_settings command for saving settings

Frontend changes:
- Added Message Examples UI controls in Author's Note tab
- Checkbox to enable/disable examples
- Dropdown to select injection position (after_system/before_history)
- Save button with success/error feedback
- Token breakdown now shows examples token count
- Settings load/save integrated with roleplay panel

Message examples help the AI understand character personality, speaking patterns, and response style by providing concrete examples of how the character should respond.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 17:33:50 -07:00
b9230772ed feat: implement enhanced message controls with regenerate any message
Added comprehensive message control features for fine-grained conversation management:

Backend (Rust):
- Extended Message struct with 'pinned' and 'hidden' boolean fields
- Added delete_message_at_index() command for removing any message
- Added toggle_message_pin() command to mark important messages
- Added toggle_message_hidden() command to temporarily hide messages
- Added continue_message() command to append AI continuations
- Added regenerate_at_index() command to regenerate ANY message (not just last)

Frontend (JavaScript):
- Added delete, pin, hide buttons to all messages (user & assistant)
- Added continue button for assistant messages
- Updated regenerate to work on any message, not just the last one
- Implemented state persistence for pinned/hidden in chat history
- Added dynamic icon changes for hide/unhide states
- Integrated with token counter for real-time updates

UI/UX (CSS):
- Pinned messages: accent-colored left border with glow effect
- Hidden messages: 40% opacity with blur effect (70% on hover)
- Delete button: red hover warning (#ef4444)
- Active state indicators for pin/hide buttons
- Always-visible controls on hidden messages for quick access

Features:
- Delete any message with confirmation dialog
- Pin messages to always keep them in context
- Hide messages with visual blur (still in context but dimmed)
- Continue incomplete assistant responses
- Regenerate any assistant message (creates new swipe)
- All states persist in chat history JSON

This completes Phase 3.2 "Enhanced Message Controls" from the roadmap,
providing users with complete control over their conversation history.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 17:19:07 -07:00
6 changed files with 815 additions and 96 deletions

108
README.md
View File

@@ -1,37 +1,36 @@
# Claudia # Claudia
Beautiful AI roleplay desktop companion built with Tauri and Rust. Desktop AI chat application built with Tauri and Rust, focused on roleplay and character-based interactions.
## Vision
Claudia aims to be a lightweight, desktop-native alternative to SillyTavern, focusing on roleplay and character-based interactions while maintaining a clean, modern interface.
## Features ## Features
### Core Chat Features ### Chat
- 🎨 **Beautiful glassmorphic UI** - Modern design with gradient backgrounds - Streaming responses with real-time display
- 🔧 **Bring-your-own-API** - Supports any Anthropic-compatible API - Full markdown rendering with syntax highlighting
- **API validation** - Automatic model detection via /v1/models - Message swipes (multiple response alternatives)
- 💬 **Full conversation context** - AI remembers your entire conversation - Edit and regenerate from any message
- 💾 **Persistent chat history** - Conversations saved per character - Per-character conversation history
- 🎯 **Streaming responses** - Real-time token display (optional) - Copy code blocks with one click
### Character System ### Characters
- 🎭 **Multiple characters** - Switch between different AI personas - V2/V3 character card import/export (PNG format)
- 🖼️ **Character avatars** - Upload custom images with zoom preview - Multiple characters with avatar support
- 📇 **V2/V3 character cards** - Import/export Tavern-compatible cards - Full character editor (description, personality, scenario, examples, etc.)
- ✏️ **Full character editor** - All v2/v3 fields supported (description, scenario, examples, etc.) - Character-specific chat history
### Advanced Chat Features ### Roleplay Tools
- 🔄 **Message swipes** - Generate multiple responses and swipe between them - World Info/Lorebook system with keyword detection and priority
- ✏️ **Message editing** - Edit messages and regenerate from any point - Author's Note with configurable positioning
- 🔀 **Chat branching** - Explore alternate conversation paths - User Personas with chat/character locking
- Prompt Presets with instruction blocks
- Message Examples from character cards
- Regex Scripts for text transformations
- Token counter with per-section breakdown
### Message Display ### API
- 📝 **Full markdown rendering** - Headers, lists, tables, links, blockquotes - Bring-your-own-API (Anthropic-compatible)
- 🎨 **Syntax highlighting** - Beautiful code blocks with highlight.js - Automatic model detection via /v1/models
- 📋 **Copy code blocks** - One-click copy button on hover - API validation and error handling
-**Smooth animations** - Elegant message transitions
## Running ## Running
@@ -40,59 +39,44 @@ npm install
npm run dev npm run dev
``` ```
Build: Build for production:
```bash ```bash
npm run build npm run build
``` ```
**Note**: The dev script includes `WEBKIT_DISABLE_DMABUF_RENDERER=1` to fix Wayland compatibility issues on KDE Plasma.
## Configuration ## Configuration
On first launch, click settings and configure: On first launch, configure in Settings:
- Base URL (e.g., https://api.anthropic.com) - Base URL (e.g., https://api.anthropic.com)
- API Key - API Key
- Model (validated from /v1/models endpoint) - Model
- Config stored in `~/.config/claudia/config.json` Config stored in `~/.config/claudia/config.json`
- Chat history stored in `~/.config/claudia/history.json`
## Usage ## Keyboard Shortcuts
### Keyboard Shortcuts
- **Enter** - Send message - **Enter** - Send message
- **Shift+Enter** - New line in message - **Shift+Enter** - New line
- **Up Arrow** - Edit last user message - **Up Arrow** - Edit last user message
- **Left/Right Arrow** - Swipe between alternative responses - **Left/Right Arrow** - Swipe between responses
### Character Management
- **Character Dropdown** - Switch between characters
- **Settings → Character Tab** - Edit current character
- **Import v2 Card** - Import Tavern character cards (PNG format)
- **Export v2 Card** - Export character as Tavern-compatible card
### Interface
- **Drag header** - Move window around your desktop
- **Trash icon** - Clear conversation history
- **Settings icon** - Configure API settings
- **Minimize/Maximize** - Window controls
## Roadmap ## Roadmap
Claudia is being developed to become a full-featured roleplay platform comparable to SillyTavern. See [ROADMAP.md](ROADMAP.md) for detailed plans including: See [ROADMAP.md](ROADMAP.md) for detailed development plans.
**Coming Soon:** **Current Focus:** Chat Branching/Checkpoints for non-linear conversation exploration
- 📚 World Info/Lorebooks for dynamic context
- 📝 Author's Note for better prompt control
- 👤 User Personas for identity management
- 😊 Character Expression Sprites
- 🔢 Token Counter and context visualization
- 👥 Group Chats with multiple characters
- ⚡ Quick Replies and macro system
**Current Version:** v0.1.0 - Basic character chat with swipes and card import/export **Upcoming:**
**Next Version:** v0.2.0 - Roleplay Foundation (World Info, Author's Note, Token Counter) - Chat branching with timeline visualization
- Character expression sprites
- Group chats with multiple characters
- Quick replies and macro system
- Context templates for different model formats
## Contributing ## Development
This is a personal project, but feedback and suggestions are welcome! If you encounter bugs or have feature requests, please open an issue on GitHub. Built with:
- Tauri 2.0
- Rust backend
- Vanilla JavaScript frontend
- tiktoken-rs for token counting

View File

@@ -16,11 +16,14 @@
- User Personas (identity management with chat/character locking) - User Personas (identity management with chat/character locking)
- Regex Scripts (global and character-scoped text transformations) - Regex Scripts (global and character-scoped text transformations)
- Chat History Import/Export (JSON format) - Chat History Import/Export (JSON format)
- Enhanced Message Controls (delete, pin, hide, continue, regenerate any message)
- Token Counter (real-time display with per-section breakdown)
- Message Examples (character card examples injected into context)
### 🎯 Current Focus: Token Counter & Context Management ### 🎯 Current Focus: Advanced Chat Management
**Next Up:** Implementing token counter with real-time display and per-section breakdown to provide visibility into context usage. This is a critical feature for debugging prompt issues and optimizing context allocation. **Next Up:** Implementing Chat Branching/Checkpoints to enable non-linear conversation exploration with the ability to save conversation states, create branches from any point, and switch between different conversation paths.
**Recent Completion:** Prompt Presets System with editable built-in presets, instruction block management, and restore-to-default functionality. **Recent Completion:** Message Examples - character card message examples are now parsed, processed with template variable replacement, and injected into context at configurable positions to teach the AI the character's voice and writing style.
## Phase 1: Core Roleplay Infrastructure (High Priority) ## Phase 1: Core Roleplay Infrastructure (High Priority)
**Goal: Enable basic roleplay-focused prompt engineering** **Goal: Enable basic roleplay-focused prompt engineering**
@@ -43,11 +46,11 @@
**Why Important:** Author's Note is considered better than system prompts for roleplay because it appears closer to the actual conversation, reducing AI tendency to ignore or forget instructions. **Why Important:** Author's Note is considered better than system prompts for roleplay because it appears closer to the actual conversation, reducing AI tendency to ignore or forget instructions.
### 3. Jailbreak Templates ### 3. Jailbreak Templates ✅ (Implemented as Prompt Presets)
- [ ] Add jailbreak template field in settings - [x] Add jailbreak template field in settings (Prompt Presets with system additions)
- [ ] Preset jailbreak templates for roleplay - [x] Preset jailbreak templates for roleplay (Built-in presets: Default, Roleplay, Creative Writing, Assistant)
- [ ] Per-character jailbreak override option - [x] Per-character jailbreak override option (Active preset per character)
- [ ] Template preview and testing - [x] Template preview and testing (Editable instruction blocks with live preview)
**Why Important:** Many roleplay scenarios require specific prompting to work well with API safety filters and to maintain character consistency. **Why Important:** Many roleplay scenarios require specific prompting to work well with API safety filters and to maintain character consistency.
@@ -73,11 +76,11 @@
**Why Important:** Visual representation of character emotions dramatically enhances immersion and makes conversations feel more alive. **Why Important:** Visual representation of character emotions dramatically enhances immersion and makes conversations feel more alive.
### 3. Message Examples in Context ### 3. Message Examples in Context
- [ ] Actually use mes_example field from character cards - [x] Actually use mes_example field from character cards
- [ ] Format and inject into prompt properly - [x] Format and inject into prompt properly
- [ ] Position control in context - [x] Position control in context
- [ ] Token budget allocation for examples - [x] Token budget allocation for examples
**Why Important:** Message examples help the AI understand the character's voice and writing style, leading to more accurate portrayals. **Why Important:** Message examples help the AI understand the character's voice and writing style, leading to more accurate portrayals.
@@ -94,13 +97,13 @@
**Why Important:** Roleplay often involves exploring "what if" scenarios. Branching lets you explore different conversation paths without losing previous progress. **Why Important:** Roleplay often involves exploring "what if" scenarios. Branching lets you explore different conversation paths without losing previous progress.
### 2. Enhanced Message Controls ### 2. Enhanced Message Controls
- [ ] Delete individual messages (not just clearing all) - [x] Delete individual messages (not just clearing all)
- [ ] Regenerate any message (not just last) - [x] Regenerate any message (not just last)
- [ ] Continue incomplete messages - [x] Continue incomplete messages
- [ ] Message pinning (keep certain messages in context) - [x] Message pinning (keep certain messages in context)
- [ ] Message folding/hiding - [x] Message folding/hiding
- [ ] Bulk message operations - [ ] Bulk message operations (deferred - nice to have)
**Why Important:** Fine-grained control over conversation history allows users to craft the perfect roleplay session. **Why Important:** Fine-grained control over conversation history allows users to craft the perfect roleplay session.
@@ -147,14 +150,14 @@
## Phase 5: Context & Token Management (Medium Priority) ## Phase 5: Context & Token Management (Medium Priority)
**Goal: Visibility and control over context usage** **Goal: Visibility and control over context usage**
### 1. Token Counter ### 1. Token Counter
- [ ] Real-time token count display - [x] Real-time token count display
- [ ] Per-section breakdown (system, history, WI, etc.) - [x] Per-section breakdown (system, history, WI, etc.)
- [ ] Visual context budget indicator - [ ] Visual context budget indicator (deferred)
- [ ] Dotted line showing context cutoff in chat - [ ] Dotted line showing context cutoff in chat (deferred)
- [ ] Warning when approaching limit - [ ] Warning when approaching limit (deferred)
**Why Important:** Understanding what's in context and what's being cut is crucial for debugging issues and optimizing prompts. **Why Important:** Understanding what's in context and what's being cut is crucial for debugging issues and optimizing prompts. Core functionality complete - visual enhancements can be added later.
### 2. Context Templates ### 2. Context Templates
- [ ] Customizable prompt assembly order - [ ] Customizable prompt assembly order

View File

@@ -180,6 +180,10 @@ struct Message {
current_swipe: usize, current_swipe: usize,
#[serde(default)] #[serde(default)]
timestamp: i64, // Unix timestamp in milliseconds timestamp: i64, // Unix timestamp in milliseconds
#[serde(default)]
pinned: bool, // Whether this message is pinned to always stay in context
#[serde(default)]
hidden: bool, // Whether this message is temporarily hidden from view
} }
impl Message { impl Message {
@@ -195,6 +199,8 @@ impl Message {
swipes: vec![content], swipes: vec![content],
current_swipe: 0, current_swipe: 0,
timestamp, timestamp,
pinned: false,
hidden: false,
} }
} }
@@ -210,6 +216,8 @@ impl Message {
swipes: vec![content], swipes: vec![content],
current_swipe: 0, current_swipe: 0,
timestamp, timestamp,
pinned: false,
hidden: false,
} }
} }
@@ -283,6 +291,10 @@ struct RoleplaySettings {
recursion_depth: usize, // Max depth for recursive World Info activation (default 3) recursion_depth: usize, // Max depth for recursive World Info activation (default 3)
#[serde(default)] #[serde(default)]
active_preset_id: Option<String>, // Selected prompt preset for this character active_preset_id: Option<String>, // Selected prompt preset for this character
#[serde(default)]
examples_enabled: bool, // Whether to include message examples from character card
#[serde(default = "default_examples_position")]
examples_position: String, // Where to insert examples: "after_system" or "before_history"
} }
fn default_authors_note_depth() -> usize { fn default_authors_note_depth() -> usize {
@@ -293,6 +305,10 @@ fn default_scan_depth() -> usize {
20 20
} }
fn default_examples_position() -> String {
"after_system".to_string() // Insert examples after system prompt, before history
}
fn default_recursion_depth() -> usize { fn default_recursion_depth() -> usize {
3 3
} }
@@ -310,6 +326,8 @@ impl Default for RoleplaySettings {
scan_depth: default_scan_depth(), scan_depth: default_scan_depth(),
recursion_depth: default_recursion_depth(), recursion_depth: default_recursion_depth(),
active_preset_id: None, // No preset selected by default active_preset_id: None, // No preset selected by default
examples_enabled: false, // Message examples disabled by default
examples_position: default_examples_position(), // After system prompt by default
} }
} }
} }
@@ -1302,6 +1320,74 @@ fn replace_template_variables(
result result
} }
// Parse mes_example field from character card into Message objects
fn parse_message_examples(
mes_example: &str,
character: &Character,
settings: &RoleplaySettings,
) -> Vec<Message> {
let mut examples = Vec::new();
// Split by <START> tag to get individual example blocks
let blocks: Vec<&str> = mes_example
.split("<START>")
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
for block in blocks {
// Process each line in the block
for line in block.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
// Replace template variables
let processed_line = replace_template_variables(line, character, settings);
// Determine role based on prefix ({{user}}: or {{char}}:)
// After replacement, it will be the actual names
let user_name = if settings.persona_enabled {
settings.persona_name.as_deref().unwrap_or("User")
} else {
"User"
};
if processed_line.starts_with(&format!("{}:", user_name)) {
// User message
let content = processed_line
.trim_start_matches(&format!("{}:", user_name))
.trim()
.to_string();
examples.push(Message::new_user(content));
} else if processed_line.starts_with(&format!("{}:", character.name)) {
// Assistant message
let content = processed_line
.trim_start_matches(&format!("{}:", character.name))
.trim()
.to_string();
examples.push(Message::new_assistant(content));
} else if processed_line.contains(':') {
// Fallback: split on first colon
let parts: Vec<&str> = processed_line.splitn(2, ':').collect();
if parts.len() == 2 {
let speaker = parts[0].trim();
let content = parts[1].trim().to_string();
if speaker == user_name {
examples.push(Message::new_user(content));
} else {
examples.push(Message::new_assistant(content));
}
}
}
}
}
examples
}
// Build injected context from roleplay settings // Build injected context from roleplay settings
fn build_roleplay_context( fn build_roleplay_context(
character: &Character, character: &Character,
@@ -1390,6 +1476,29 @@ fn build_api_messages(
let mut api_messages = vec![Message::new_user(enhanced_system_prompt)]; 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();
// Insert message examples if enabled
if roleplay_settings.examples_enabled {
if let Some(ref mes_example) = character.mes_example {
if !mes_example.is_empty() {
let examples = parse_message_examples(mes_example, character, roleplay_settings);
// Insert examples based on position setting
match roleplay_settings.examples_position.as_str() {
"after_system" => {
// Insert right after system message (position 1)
for (i, example) in examples.into_iter().enumerate() {
api_messages.insert(1 + i, example);
}
}
"before_history" | _ => {
// Insert at end (before history gets added)
api_messages.extend(examples);
}
}
}
}
}
// Add history messages with current swipe content // Add history messages with current swipe content
for msg in &history.messages { for msg in &history.messages {
let mut api_msg = Message::new_user(msg.get_content().to_string()); let mut api_msg = Message::new_user(msg.get_content().to_string());
@@ -1632,6 +1741,242 @@ fn get_last_user_message() -> Result<String, String> {
Ok(last_user_msg) Ok(last_user_msg)
} }
#[tauri::command]
fn delete_message_at_index(message_index: usize) -> Result<(), String> {
let character = get_active_character();
let mut history = load_history(&character.id);
if message_index >= history.messages.len() {
return Err(format!("Message index {} out of bounds", message_index));
}
history.messages.remove(message_index);
save_history(&character.id, &history)?;
Ok(())
}
#[tauri::command]
fn toggle_message_pin(message_index: usize) -> Result<bool, String> {
let character = get_active_character();
let mut history = load_history(&character.id);
if message_index >= history.messages.len() {
return Err(format!("Message index {} out of bounds", message_index));
}
history.messages[message_index].pinned = !history.messages[message_index].pinned;
let new_state = history.messages[message_index].pinned;
save_history(&character.id, &history)?;
Ok(new_state)
}
#[tauri::command]
fn toggle_message_hidden(message_index: usize) -> Result<bool, String> {
let character = get_active_character();
let mut history = load_history(&character.id);
if message_index >= history.messages.len() {
return Err(format!("Message index {} out of bounds", message_index));
}
history.messages[message_index].hidden = !history.messages[message_index].hidden;
let new_state = history.messages[message_index].hidden;
save_history(&character.id, &history)?;
Ok(new_state)
}
#[tauri::command]
async fn continue_message(message_index: usize) -> Result<String, String> {
let config = load_config().ok_or_else(|| "API not configured".to_string())?;
let character = get_active_character();
let mut history = load_history(&character.id);
if message_index >= history.messages.len() {
return Err(format!("Message index {} out of bounds", message_index));
}
// Make sure we're continuing an assistant message
if history.messages[message_index].role != "assistant" {
return Err("Can only continue assistant messages".to_string());
}
let client = reqwest::Client::new();
let base = config.base_url.trim_end_matches('/');
let url = if base.ends_with("/v1") {
format!("{}/chat/completions", base)
} else {
format!("{}/v1/chat/completions", base)
};
// Load roleplay settings and build context up to the message we're continuing
let roleplay_settings = load_roleplay_settings(&character.id);
let messages_up_to = &history.messages[..=message_index];
let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, messages_up_to, &roleplay_settings);
// Build API messages (same pattern as generate_response_only)
let processed_system_prompt = replace_template_variables(&character.system_prompt, &character, &roleplay_settings);
let enhanced_system_prompt = format!("{}{}", processed_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 up to the message we're continuing
for msg in messages_up_to {
let mut api_msg = Message::new_user(msg.get_content().to_string());
api_msg.role = msg.role.clone();
api_messages.push(api_msg);
}
// Insert Author's Note
if let Some(note) = authors_note {
if api_messages.len() > (note_depth + 1) {
let insert_pos = api_messages.len().saturating_sub(note_depth);
let mut note_msg = Message::new_user(note);
note_msg.role = "system".to_string();
api_messages.insert(insert_pos, note_msg);
}
}
// Convert to API format
let api_request = ChatRequest {
model: config.model.clone(),
messages: api_messages,
max_tokens: 4096,
};
let response = client
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", config.api_key))
.json(&api_request)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("API error: {}", error_text));
}
let response_json: ChatResponse = response.json().await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let content = response_json.choices.first()
.map(|c| &c.message.content)
.ok_or_else(|| "No content in response".to_string())?
.to_string();
// Append the new content to the existing message
let current_content = history.messages[message_index].get_content().to_string();
let continued_content = format!("{}{}", current_content, content);
// Update the current swipe
let swipe_index = history.messages[message_index].current_swipe;
history.messages[message_index].swipes[swipe_index] = continued_content.clone();
history.messages[message_index].content = continued_content.clone();
save_history(&character.id, &history)?;
Ok(content)
}
#[tauri::command]
async fn regenerate_at_index(message_index: usize) -> Result<SwipeInfo, String> {
let config = load_config().ok_or_else(|| "API not configured".to_string())?;
let character = get_active_character();
let mut history = load_history(&character.id);
if message_index >= history.messages.len() {
return Err(format!("Message index {} out of bounds", message_index));
}
// Make sure we're regenerating an assistant message
if history.messages[message_index].role != "assistant" {
return Err("Can only regenerate assistant messages".to_string());
}
let client = reqwest::Client::new();
let base = config.base_url.trim_end_matches('/');
let url = if base.ends_with("/v1") {
format!("{}/chat/completions", base)
} else {
format!("{}/v1/chat/completions", base)
};
// Load roleplay settings and build context up to (but not including) the message we're regenerating
let roleplay_settings = load_roleplay_settings(&character.id);
let messages_before = &history.messages[..message_index];
let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, messages_before, &roleplay_settings);
// Build API messages
let processed_system_prompt = replace_template_variables(&character.system_prompt, &character, &roleplay_settings);
let enhanced_system_prompt = format!("{}{}", processed_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 up to the message we're regenerating
for msg in messages_before {
let mut api_msg = Message::new_user(msg.get_content().to_string());
api_msg.role = msg.role.clone();
api_messages.push(api_msg);
}
// Insert Author's Note
if let Some(note) = authors_note {
if api_messages.len() > (note_depth + 1) {
let insert_pos = api_messages.len().saturating_sub(note_depth);
let mut note_msg = Message::new_user(note);
note_msg.role = "system".to_string();
api_messages.insert(insert_pos, note_msg);
}
}
// Convert to API format
let api_request = ChatRequest {
model: config.model.clone(),
messages: api_messages,
max_tokens: 4096,
};
let response = client
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", config.api_key))
.json(&api_request)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("API error: {}", error_text));
}
let response_json: ChatResponse = response.json().await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let content = response_json.choices.first()
.map(|c| &c.message.content)
.ok_or_else(|| "No content in response".to_string())?
.to_string();
// Add as a new swipe to this message
history.messages[message_index].swipes.push(content.clone());
let new_swipe_index = history.messages[message_index].swipes.len() - 1;
history.messages[message_index].current_swipe = new_swipe_index;
history.messages[message_index].content = content.clone();
save_history(&character.id, &history)?;
Ok(SwipeInfo {
content,
current: new_swipe_index,
total: history.messages[message_index].swipes.len(),
})
}
#[tauri::command] #[tauri::command]
async fn generate_response_only() -> Result<String, String> { async fn generate_response_only() -> 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())?;
@@ -2386,6 +2731,18 @@ fn update_persona(
save_roleplay_settings(&character_id, &settings) save_roleplay_settings(&character_id, &settings)
} }
#[tauri::command]
fn update_examples_settings(
character_id: String,
enabled: bool,
position: String,
) -> Result<(), String> {
let mut settings = load_roleplay_settings(&character_id);
settings.examples_enabled = enabled;
settings.examples_position = position;
save_roleplay_settings(&character_id, &settings)
}
#[tauri::command] #[tauri::command]
fn update_recursion_depth( fn update_recursion_depth(
character_id: String, character_id: String,
@@ -2553,6 +2910,7 @@ struct TokenBreakdown {
persona: usize, persona: usize,
world_info: usize, world_info: usize,
authors_note: usize, authors_note: usize,
message_examples: usize,
message_history: usize, message_history: usize,
current_input: usize, current_input: usize,
estimated_max_tokens: usize, estimated_max_tokens: usize,
@@ -2643,6 +3001,19 @@ fn get_token_count(character_id: Option<String>, current_input: String) -> Resul
0 0
}; };
// Count message examples
let mut examples_tokens = 0;
if roleplay_settings.examples_enabled {
if let Some(ref mes_example) = character.mes_example {
if !mes_example.is_empty() {
let examples = parse_message_examples(mes_example, &character, &roleplay_settings);
for example in examples {
examples_tokens += count_tokens(example.get_content());
}
}
}
}
// Count message history // Count message history
let mut history_tokens = 0; let mut history_tokens = 0;
for msg in &history.messages { for msg in &history.messages {
@@ -2654,7 +3025,7 @@ fn get_token_count(character_id: Option<String>, current_input: String) -> Resul
// Calculate total // Calculate total
let total = system_prompt_tokens + preset_tokens + persona_tokens + world_info_tokens + let total = system_prompt_tokens + preset_tokens + persona_tokens + world_info_tokens +
authors_note_tokens + history_tokens + input_tokens; authors_note_tokens + examples_tokens + history_tokens + input_tokens;
// Estimate remaining tokens for response (assuming 16k context with 4k max response) // Estimate remaining tokens for response (assuming 16k context with 4k max response)
let estimated_max_tokens = if total < 12000 { 4096 } else { 16384 - total }; let estimated_max_tokens = if total < 12000 { 4096 } else { 16384 - total };
@@ -2666,6 +3037,7 @@ fn get_token_count(character_id: Option<String>, current_input: String) -> Resul
persona: persona_tokens, persona: persona_tokens,
world_info: world_info_tokens, world_info: world_info_tokens,
authors_note: authors_note_tokens, authors_note: authors_note_tokens,
message_examples: examples_tokens,
message_history: history_tokens, message_history: history_tokens,
current_input: input_tokens, current_input: input_tokens,
estimated_max_tokens, estimated_max_tokens,
@@ -2938,6 +3310,11 @@ pub fn run() {
truncate_history_from, truncate_history_from,
remove_last_assistant_message, remove_last_assistant_message,
get_last_user_message, get_last_user_message,
delete_message_at_index,
toggle_message_pin,
toggle_message_hidden,
continue_message,
regenerate_at_index,
add_swipe_to_last_assistant, add_swipe_to_last_assistant,
navigate_swipe, navigate_swipe,
get_swipe_info, get_swipe_info,
@@ -2962,6 +3339,7 @@ pub fn run() {
update_roleplay_depths, update_roleplay_depths,
update_authors_note, update_authors_note,
update_persona, update_persona,
update_examples_settings,
update_recursion_depth, update_recursion_depth,
get_presets, get_presets,
get_preset, get_preset,

View File

@@ -153,9 +153,34 @@
Enable Author's Note Enable Author's Note
</label> </label>
</div> </div>
<button type="button" id="save-authors-note-btn" class="btn-primary" style="width: 100%;"> <button type="button" id="save-authors-note-btn" class="btn-primary" style="width: 100%; margin-bottom: 20px;">
Save Author's Note Save Author's Note
</button> </button>
<!-- Message Examples Section -->
<div class="form-group" style="border-top: 1px solid var(--border); padding-top: 16px;">
<label>Message Examples</label>
<p style="color: var(--text-secondary); font-size: 12px; margin-bottom: 8px;">
Use character card's message examples to teach the AI the character's voice and style.
</p>
<label>
<input type="checkbox" id="examples-enabled" />
Enable Message Examples
</label>
</div>
<div class="form-group">
<label for="examples-position">Examples Position</label>
<select id="examples-position" style="width: 100%;">
<option value="after_system">After System Prompt (Recommended)</option>
<option value="before_history">Before Message History</option>
</select>
<p style="color: var(--text-secondary); font-size: 11px; margin-top: 4px;">
Where to inject examples in the context. After system prompt works best for most models.
</p>
</div>
<button type="button" id="save-examples-btn" class="btn-primary" style="width: 100%;">
Save Examples Settings
</button>
</div> </div>
</div> </div>
@@ -671,6 +696,10 @@
<span>Author's Note:</span> <span>Author's Note:</span>
<span id="token-authorsnote">0</span> <span id="token-authorsnote">0</span>
</div> </div>
<div class="token-breakdown-item">
<span>Message Examples:</span>
<span id="token-examples">0</span>
</div>
<div class="token-breakdown-item"> <div class="token-breakdown-item">
<span>Message History:</span> <span>Message History:</span>
<span id="token-history">0</span> <span id="token-history">0</span>

View File

@@ -481,6 +481,37 @@ function addMessage(content, isUser = false, skipActions = false, timestamp = nu
editBtn.addEventListener('click', () => handleEditMessage(messageDiv, content)); editBtn.addEventListener('click', () => handleEditMessage(messageDiv, content));
actionsDiv.appendChild(editBtn); actionsDiv.appendChild(editBtn);
// Pin button
const pinBtn = document.createElement('button');
pinBtn.className = 'message-action-btn message-pin-btn';
pinBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 11V5M4.5 5L7 2.5L9.5 5M7 11L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
pinBtn.title = 'Pin message';
pinBtn.addEventListener('click', () => handleTogglePin(messageDiv));
actionsDiv.appendChild(pinBtn);
// Hide button
const hideBtn = document.createElement('button');
hideBtn.className = 'message-action-btn message-hide-btn';
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1 7C1 7 3 3 7 3C11 3 13 7 13 7C13 7 11 11 7 11C3 11 1 7 1 7Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="7" cy="7" r="2" stroke="currentColor" stroke-width="1.5"/>
</svg>`;
hideBtn.title = 'Hide message';
hideBtn.addEventListener('click', () => handleToggleHidden(messageDiv));
actionsDiv.appendChild(hideBtn);
// Delete button
const deleteBtn = document.createElement('button');
deleteBtn.className = 'message-action-btn message-delete-btn';
deleteBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 4H12M5 4V3C5 2.44772 5.44772 2 6 2H8C8.55228 2 9 2.44772 9 3V4M11 4L10.5 11C10.5 11.5523 10.0523 12 9.5 12H4.5C3.94772 12 3.5 11.5523 3.5 11L3 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
deleteBtn.title = 'Delete message';
deleteBtn.addEventListener('click', () => handleDeleteMessage(messageDiv));
actionsDiv.appendChild(deleteBtn);
messageDiv.appendChild(contentDiv); messageDiv.appendChild(contentDiv);
messageDiv.appendChild(actionsDiv); messageDiv.appendChild(actionsDiv);
} else { } else {
@@ -495,6 +526,47 @@ function addMessage(content, isUser = false, skipActions = false, timestamp = nu
regenerateBtn.addEventListener('click', () => handleRegenerateMessage(messageDiv)); regenerateBtn.addEventListener('click', () => handleRegenerateMessage(messageDiv));
actionsDiv.appendChild(regenerateBtn); actionsDiv.appendChild(regenerateBtn);
// Continue button
const continueBtn = document.createElement('button');
continueBtn.className = 'message-action-btn message-continue-btn';
continueBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M3 7H11M11 7L7 3M11 7L7 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
continueBtn.title = 'Continue message';
continueBtn.addEventListener('click', () => handleContinueMessage(messageDiv));
actionsDiv.appendChild(continueBtn);
// Pin button
const pinBtn = document.createElement('button');
pinBtn.className = 'message-action-btn message-pin-btn';
pinBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 11V5M4.5 5L7 2.5L9.5 5M7 11L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
pinBtn.title = 'Pin message';
pinBtn.addEventListener('click', () => handleTogglePin(messageDiv));
actionsDiv.appendChild(pinBtn);
// Hide button
const hideBtn = document.createElement('button');
hideBtn.className = 'message-action-btn message-hide-btn';
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1 7C1 7 3 3 7 3C11 3 13 7 13 7C13 7 11 11 7 11C3 11 1 7 1 7Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="7" cy="7" r="2" stroke="currentColor" stroke-width="1.5"/>
</svg>`;
hideBtn.title = 'Hide message';
hideBtn.addEventListener('click', () => handleToggleHidden(messageDiv));
actionsDiv.appendChild(hideBtn);
// Delete button
const deleteBtn = document.createElement('button');
deleteBtn.className = 'message-action-btn message-delete-btn';
deleteBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 4H12M5 4V3C5 2.44772 5.44772 2 6 2H8C8.55228 2 9 2.44772 9 3V4M11 4L10.5 11C10.5 11.5523 10.0523 12 9.5 12H4.5C3.94772 12 3.5 11.5523 3.5 11L3 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
deleteBtn.title = 'Delete message';
deleteBtn.addEventListener('click', () => handleDeleteMessage(messageDiv));
actionsDiv.appendChild(deleteBtn);
// Create swipe wrapper // Create swipe wrapper
const swipeWrapper = document.createElement('div'); const swipeWrapper = document.createElement('div');
swipeWrapper.style.display = 'flex'; swipeWrapper.style.display = 'flex';
@@ -695,21 +767,39 @@ async function handleEditMessage(messageDiv, originalContent) {
// Handle regenerating an assistant message // Handle regenerating an assistant message
async function handleRegenerateMessage(messageDiv) { async function handleRegenerateMessage(messageDiv) {
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
const messageIndex = allMessages.indexOf(messageDiv);
if (messageIndex === -1) {
console.error('Message not found in list');
return;
}
const regenerateBtn = messageDiv.querySelector('.message-action-btn'); const regenerateBtn = messageDiv.querySelector('.message-action-btn');
regenerateBtn.disabled = true; regenerateBtn.disabled = true;
regenerateBtn.classList.add('loading'); regenerateBtn.classList.add('loading');
try { try {
// Get the last user message setStatus('Regenerating response...', 'default');
const lastUserMessage = await invoke('get_last_user_message');
// Generate new response // Use the new regenerate_at_index command which works on any message
await generateSwipe(messageDiv, lastUserMessage); const swipeInfo = await invoke('regenerate_at_index', { messageIndex });
// Update the message content
const contentDiv = messageDiv.querySelector('.message-content');
renderAssistantContent(contentDiv, swipeInfo.content);
// Update swipe controls
updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total);
setStatus('Regeneration complete', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) { } catch (error) {
console.error('Failed to regenerate message:', error); console.error('Failed to regenerate message:', error);
setStatus(`Regeneration failed: ${error}`, 'error');
} finally {
regenerateBtn.disabled = false; regenerateBtn.disabled = false;
regenerateBtn.classList.remove('loading'); regenerateBtn.classList.remove('loading');
addMessage(`Error regenerating message: ${error}`, false);
} }
} }
@@ -853,6 +943,139 @@ function addCopyButtonToCode(block) {
} }
} }
// Handle deleting a message
async function handleDeleteMessage(messageDiv) {
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
const messageIndex = allMessages.indexOf(messageDiv);
if (messageIndex === -1) {
console.error('Message not found in list');
return;
}
// Confirm deletion
if (!confirm('Are you sure you want to delete this message? This cannot be undone.')) {
return;
}
try {
await invoke('delete_message_at_index', { messageIndex });
messageDiv.remove();
await updateTokenCount();
setStatus('Message deleted', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) {
console.error('Failed to delete message:', error);
setStatus(`Delete failed: ${error}`, 'error');
}
}
// Handle toggling message pin status
async function handleTogglePin(messageDiv) {
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
const messageIndex = allMessages.indexOf(messageDiv);
if (messageIndex === -1) {
console.error('Message not found in list');
return;
}
try {
const isPinned = await invoke('toggle_message_pin', { messageIndex });
// Update visual indicator
const pinBtn = messageDiv.querySelector('.message-pin-btn');
if (isPinned) {
messageDiv.classList.add('pinned');
pinBtn.classList.add('active');
pinBtn.title = 'Unpin message';
} else {
messageDiv.classList.remove('pinned');
pinBtn.classList.remove('active');
pinBtn.title = 'Pin message';
}
} catch (error) {
console.error('Failed to toggle pin:', error);
setStatus(`Pin toggle failed: ${error}`, 'error');
}
}
// Handle toggling message hidden status
async function handleToggleHidden(messageDiv) {
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
const messageIndex = allMessages.indexOf(messageDiv);
if (messageIndex === -1) {
console.error('Message not found in list');
return;
}
try {
const isHidden = await invoke('toggle_message_hidden', { messageIndex });
// Update visual indicator
const hideBtn = messageDiv.querySelector('.message-hide-btn');
if (isHidden) {
messageDiv.classList.add('hidden-message');
hideBtn.classList.add('active');
hideBtn.title = 'Unhide message';
// Update icon to "eye-off"
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M10 5L11.5 3.5M3.5 10.5L5 9M1 13L13 1M5.5 6C5.19 6.31 5 6.74 5 7.22C5 8.2 5.8 9 6.78 9C7.26 9 7.69 8.81 8 8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>`;
} else {
messageDiv.classList.remove('hidden-message');
hideBtn.classList.remove('active');
hideBtn.title = 'Hide message';
// Update icon back to "eye"
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1 7C1 7 3 3 7 3C11 3 13 7 13 7C13 7 11 11 7 11C3 11 1 7 1 7Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="7" cy="7" r="2" stroke="currentColor" stroke-width="1.5"/>
</svg>`;
}
await updateTokenCount();
} catch (error) {
console.error('Failed to toggle hidden:', error);
setStatus(`Hide toggle failed: ${error}`, 'error');
}
}
// Handle continuing an incomplete message
async function handleContinueMessage(messageDiv) {
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
const messageIndex = allMessages.indexOf(messageDiv);
if (messageIndex === -1) {
console.error('Message not found in list');
return;
}
const continueBtn = messageDiv.querySelector('.message-continue-btn');
continueBtn.disabled = true;
continueBtn.classList.add('loading');
try {
setStatus('Continuing message...', 'default');
const continuedText = await invoke('continue_message', { messageIndex });
// The backend appends to the message, so we just need to reload the content
const contentDiv = messageDiv.querySelector('.message-content');
const swipeInfo = await invoke('get_swipe_info', { messageIndex });
renderAssistantContent(contentDiv, swipeInfo.content);
setStatus('Message continued', 'success');
setTimeout(() => setStatus('Ready'), 2000);
await updateTokenCount();
} catch (error) {
console.error('Failed to continue message:', error);
setStatus(`Continue failed: ${error}`, 'error');
} finally {
continueBtn.disabled = false;
continueBtn.classList.remove('loading');
}
}
// Extract message sending logic into separate function // Extract message sending logic into separate function
async function sendMessage(message, isRegenerate = false) { async function sendMessage(message, isRegenerate = false) {
if (!isRegenerate) { if (!isRegenerate) {
@@ -1338,6 +1561,7 @@ function setupAppControls() {
document.getElementById('add-worldinfo-btn').addEventListener('click', handleAddWorldInfoEntry); document.getElementById('add-worldinfo-btn').addEventListener('click', handleAddWorldInfoEntry);
document.getElementById('save-authors-note-btn').addEventListener('click', handleSaveAuthorsNote); document.getElementById('save-authors-note-btn').addEventListener('click', handleSaveAuthorsNote);
document.getElementById('save-persona-btn').addEventListener('click', handleSavePersona); document.getElementById('save-persona-btn').addEventListener('click', handleSavePersona);
document.getElementById('save-examples-btn').addEventListener('click', handleSaveExamples);
// Setup recursion depth change handler // Setup recursion depth change handler
document.getElementById('recursion-depth').addEventListener('change', handleRecursionDepthChange); document.getElementById('recursion-depth').addEventListener('change', handleRecursionDepthChange);
@@ -1399,6 +1623,7 @@ async function updateTokenCount() {
document.getElementById('token-persona').textContent = tokenData.persona; document.getElementById('token-persona').textContent = tokenData.persona;
document.getElementById('token-worldinfo').textContent = tokenData.world_info; document.getElementById('token-worldinfo').textContent = tokenData.world_info;
document.getElementById('token-authorsnote').textContent = tokenData.authors_note; document.getElementById('token-authorsnote').textContent = tokenData.authors_note;
document.getElementById('token-examples').textContent = tokenData.message_examples;
document.getElementById('token-history').textContent = tokenData.message_history; document.getElementById('token-history').textContent = tokenData.message_history;
document.getElementById('token-input').textContent = tokenData.current_input; document.getElementById('token-input').textContent = tokenData.current_input;
document.getElementById('token-total-detail').textContent = tokenData.total; document.getElementById('token-total-detail').textContent = tokenData.total;
@@ -1582,6 +1807,29 @@ async function loadChatHistory() {
history.forEach((msg, index) => { history.forEach((msg, index) => {
const messageDiv = addMessage(msg.content, msg.role === 'user', false, msg.timestamp); const messageDiv = addMessage(msg.content, msg.role === 'user', false, msg.timestamp);
// Apply pinned state
if (msg.pinned && messageDiv) {
messageDiv.classList.add('pinned');
const pinBtn = messageDiv.querySelector('.message-pin-btn');
if (pinBtn) {
pinBtn.classList.add('active');
pinBtn.title = 'Unpin message';
}
}
// Apply hidden state
if (msg.hidden && messageDiv) {
messageDiv.classList.add('hidden-message');
const hideBtn = messageDiv.querySelector('.message-hide-btn');
if (hideBtn) {
hideBtn.classList.add('active');
hideBtn.title = 'Unhide message';
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M10 5L11.5 3.5M3.5 10.5L5 9M1 13L13 1M5.5 6C5.19 6.31 5 6.74 5 7.22C5 8.2 5.8 9 6.78 9C7.26 9 7.69 8.81 8 8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>`;
}
}
// Update swipe controls for assistant messages with swipe info // Update swipe controls for assistant messages with swipe info
if (msg.role === 'assistant' && messageDiv && msg.swipes && msg.swipes.length > 0) { if (msg.role === 'assistant' && messageDiv && msg.swipes && msg.swipes.length > 0) {
updateSwipeControls(messageDiv, msg.current_swipe || 0, msg.swipes.length); updateSwipeControls(messageDiv, msg.current_swipe || 0, msg.swipes.length);
@@ -1766,6 +2014,10 @@ async function loadRoleplaySettings() {
document.getElementById('persona-description').value = settings.persona_description || ''; document.getElementById('persona-description').value = settings.persona_description || '';
document.getElementById('persona-enabled').checked = settings.persona_enabled || false; document.getElementById('persona-enabled').checked = settings.persona_enabled || false;
// Load Message Examples
document.getElementById('examples-enabled').checked = settings.examples_enabled || false;
document.getElementById('examples-position').value = settings.examples_position || 'after_system';
// Load Presets // Load Presets
await loadPresets(); await loadPresets();
} catch (error) { } catch (error) {
@@ -1997,6 +2249,29 @@ async function handleSavePersona() {
} }
} }
// Save Message Examples Settings
async function handleSaveExamples() {
if (!currentCharacter) return;
const enabled = document.getElementById('examples-enabled').checked;
const position = document.getElementById('examples-position').value;
try {
await invoke('update_examples_settings', {
characterId: currentCharacter.id,
enabled,
position
});
// Show success message
setStatus('Message Examples settings saved', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) {
console.error('Failed to save Message Examples settings:', error);
setStatus('Failed to save Message Examples settings', 'error');
}
}
// Handle recursion depth change // Handle recursion depth change
async function handleRecursionDepthChange() { async function handleRecursionDepthChange() {
if (!currentCharacter) return; if (!currentCharacter) return;

View File

@@ -383,6 +383,56 @@ body {
height: 14px; height: 14px;
} }
/* Enhanced message control buttons */
.message-delete-btn:hover {
background: rgba(239, 68, 68, 0.8) !important;
color: white !important;
}
.message-pin-btn.active,
.message-hide-btn.active {
background: var(--accent);
color: white;
}
.message-pin-btn.active:hover,
.message-hide-btn.active:hover {
background: var(--accent-hover);
}
/* Pinned message indicator */
.message.pinned::before {
content: '';
position: absolute;
left: -8px;
top: 0;
bottom: 0;
width: 3px;
background: var(--accent);
border-radius: 2px;
box-shadow: 0 0 8px var(--accent);
}
.message.pinned .message-content {
border-left: 2px solid var(--accent);
}
/* Hidden message styling */
.message.hidden-message {
opacity: 0.4;
filter: blur(2px);
transition: all 0.2s ease;
}
.message.hidden-message:hover {
opacity: 0.7;
filter: blur(1px);
}
.message.hidden-message .message-actions {
opacity: 1 !important;
}
/* Swipe navigation */ /* Swipe navigation */
.swipe-controls { .swipe-controls {
display: flex; display: flex;