Compare commits
4 Commits
9b4bc63e1a
...
71bac12cd9
| Author | SHA1 | Date | |
|---|---|---|---|
| 71bac12cd9 | |||
| 9da17c824d | |||
| d8cb4a768b | |||
| b9230772ed |
108
README.md
108
README.md
@@ -1,37 +1,36 @@
|
||||
# Claudia
|
||||
|
||||
Beautiful AI roleplay desktop companion built with Tauri and Rust.
|
||||
|
||||
## 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.
|
||||
Desktop AI chat application built with Tauri and Rust, focused on roleplay and character-based interactions.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Chat Features
|
||||
- 🎨 **Beautiful glassmorphic UI** - Modern design with gradient backgrounds
|
||||
- 🔧 **Bring-your-own-API** - Supports any Anthropic-compatible API
|
||||
- ✅ **API validation** - Automatic model detection via /v1/models
|
||||
- 💬 **Full conversation context** - AI remembers your entire conversation
|
||||
- 💾 **Persistent chat history** - Conversations saved per character
|
||||
- 🎯 **Streaming responses** - Real-time token display (optional)
|
||||
### Chat
|
||||
- Streaming responses with real-time display
|
||||
- Full markdown rendering with syntax highlighting
|
||||
- Message swipes (multiple response alternatives)
|
||||
- Edit and regenerate from any message
|
||||
- Per-character conversation history
|
||||
- Copy code blocks with one click
|
||||
|
||||
### Character System
|
||||
- 🎭 **Multiple characters** - Switch between different AI personas
|
||||
- 🖼️ **Character avatars** - Upload custom images with zoom preview
|
||||
- 📇 **V2/V3 character cards** - Import/export Tavern-compatible cards
|
||||
- ✏️ **Full character editor** - All v2/v3 fields supported (description, scenario, examples, etc.)
|
||||
### Characters
|
||||
- V2/V3 character card import/export (PNG format)
|
||||
- Multiple characters with avatar support
|
||||
- Full character editor (description, personality, scenario, examples, etc.)
|
||||
- Character-specific chat history
|
||||
|
||||
### Advanced Chat Features
|
||||
- 🔄 **Message swipes** - Generate multiple responses and swipe between them
|
||||
- ✏️ **Message editing** - Edit messages and regenerate from any point
|
||||
- 🔀 **Chat branching** - Explore alternate conversation paths
|
||||
### Roleplay Tools
|
||||
- World Info/Lorebook system with keyword detection and priority
|
||||
- Author's Note with configurable positioning
|
||||
- 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
|
||||
- 📝 **Full markdown rendering** - Headers, lists, tables, links, blockquotes
|
||||
- 🎨 **Syntax highlighting** - Beautiful code blocks with highlight.js
|
||||
- 📋 **Copy code blocks** - One-click copy button on hover
|
||||
- ✨ **Smooth animations** - Elegant message transitions
|
||||
### API
|
||||
- Bring-your-own-API (Anthropic-compatible)
|
||||
- Automatic model detection via /v1/models
|
||||
- API validation and error handling
|
||||
|
||||
## Running
|
||||
|
||||
@@ -40,59 +39,44 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Build:
|
||||
Build for production:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
**Note**: The dev script includes `WEBKIT_DISABLE_DMABUF_RENDERER=1` to fix Wayland compatibility issues on KDE Plasma.
|
||||
|
||||
## Configuration
|
||||
|
||||
On first launch, click settings and configure:
|
||||
On first launch, configure in Settings:
|
||||
- Base URL (e.g., https://api.anthropic.com)
|
||||
- API Key
|
||||
- Model (validated from /v1/models endpoint)
|
||||
- Model
|
||||
|
||||
- Config stored in `~/.config/claudia/config.json`
|
||||
- Chat history stored in `~/.config/claudia/history.json`
|
||||
Config stored in `~/.config/claudia/config.json`
|
||||
|
||||
## Usage
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### Keyboard Shortcuts
|
||||
- **Enter** - Send message
|
||||
- **Shift+Enter** - New line in message
|
||||
- **Shift+Enter** - New line
|
||||
- **Up Arrow** - Edit last user message
|
||||
- **Left/Right Arrow** - Swipe between alternative 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
|
||||
- **Left/Right Arrow** - Swipe between responses
|
||||
|
||||
## 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:**
|
||||
- 📚 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 Focus:** Chat Branching/Checkpoints for non-linear conversation exploration
|
||||
|
||||
**Current Version:** v0.1.0 - Basic character chat with swipes and card import/export
|
||||
**Next Version:** v0.2.0 - Roleplay Foundation (World Info, Author's Note, Token Counter)
|
||||
**Upcoming:**
|
||||
- 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
|
||||
|
||||
57
ROADMAP.md
57
ROADMAP.md
@@ -16,11 +16,14 @@
|
||||
- User Personas (identity management with chat/character locking)
|
||||
- Regex Scripts (global and character-scoped text transformations)
|
||||
- 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
|
||||
**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.
|
||||
### 🎯 Current Focus: Advanced Chat Management
|
||||
**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)
|
||||
**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.
|
||||
|
||||
### 3. Jailbreak Templates
|
||||
- [ ] Add jailbreak template field in settings
|
||||
- [ ] Preset jailbreak templates for roleplay
|
||||
- [ ] Per-character jailbreak override option
|
||||
- [ ] Template preview and testing
|
||||
### 3. Jailbreak Templates ✅ (Implemented as Prompt Presets)
|
||||
- [x] Add jailbreak template field in settings (Prompt Presets with system additions)
|
||||
- [x] Preset jailbreak templates for roleplay (Built-in presets: Default, Roleplay, Creative Writing, Assistant)
|
||||
- [x] Per-character jailbreak override option (Active preset per character)
|
||||
- [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.
|
||||
|
||||
@@ -73,11 +76,11 @@
|
||||
|
||||
**Why Important:** Visual representation of character emotions dramatically enhances immersion and makes conversations feel more alive.
|
||||
|
||||
### 3. Message Examples in Context
|
||||
- [ ] Actually use mes_example field from character cards
|
||||
- [ ] Format and inject into prompt properly
|
||||
- [ ] Position control in context
|
||||
- [ ] Token budget allocation for examples
|
||||
### 3. Message Examples in Context ✅
|
||||
- [x] Actually use mes_example field from character cards
|
||||
- [x] Format and inject into prompt properly
|
||||
- [x] Position control in context
|
||||
- [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.
|
||||
|
||||
@@ -94,13 +97,13 @@
|
||||
|
||||
**Why Important:** Roleplay often involves exploring "what if" scenarios. Branching lets you explore different conversation paths without losing previous progress.
|
||||
|
||||
### 2. Enhanced Message Controls
|
||||
- [ ] Delete individual messages (not just clearing all)
|
||||
- [ ] Regenerate any message (not just last)
|
||||
- [ ] Continue incomplete messages
|
||||
- [ ] Message pinning (keep certain messages in context)
|
||||
- [ ] Message folding/hiding
|
||||
- [ ] Bulk message operations
|
||||
### 2. Enhanced Message Controls ✅
|
||||
- [x] Delete individual messages (not just clearing all)
|
||||
- [x] Regenerate any message (not just last)
|
||||
- [x] Continue incomplete messages
|
||||
- [x] Message pinning (keep certain messages in context)
|
||||
- [x] Message folding/hiding
|
||||
- [ ] Bulk message operations (deferred - nice to have)
|
||||
|
||||
**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)
|
||||
**Goal: Visibility and control over context usage**
|
||||
|
||||
### 1. Token Counter
|
||||
- [ ] Real-time token count display
|
||||
- [ ] Per-section breakdown (system, history, WI, etc.)
|
||||
- [ ] Visual context budget indicator
|
||||
- [ ] Dotted line showing context cutoff in chat
|
||||
- [ ] Warning when approaching limit
|
||||
### 1. Token Counter ✅
|
||||
- [x] Real-time token count display
|
||||
- [x] Per-section breakdown (system, history, WI, etc.)
|
||||
- [ ] Visual context budget indicator (deferred)
|
||||
- [ ] Dotted line showing context cutoff in chat (deferred)
|
||||
- [ ] 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
|
||||
- [ ] Customizable prompt assembly order
|
||||
|
||||
@@ -180,6 +180,10 @@ struct Message {
|
||||
current_swipe: usize,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -195,6 +199,8 @@ impl Message {
|
||||
swipes: vec![content],
|
||||
current_swipe: 0,
|
||||
timestamp,
|
||||
pinned: false,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +216,8 @@ impl Message {
|
||||
swipes: vec![content],
|
||||
current_swipe: 0,
|
||||
timestamp,
|
||||
pinned: false,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +291,10 @@ struct RoleplaySettings {
|
||||
recursion_depth: usize, // Max depth for recursive World Info activation (default 3)
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -293,6 +305,10 @@ fn default_scan_depth() -> usize {
|
||||
20
|
||||
}
|
||||
|
||||
fn default_examples_position() -> String {
|
||||
"after_system".to_string() // Insert examples after system prompt, before history
|
||||
}
|
||||
|
||||
fn default_recursion_depth() -> usize {
|
||||
3
|
||||
}
|
||||
@@ -310,6 +326,8 @@ impl Default for RoleplaySettings {
|
||||
scan_depth: default_scan_depth(),
|
||||
recursion_depth: default_recursion_depth(),
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
fn build_roleplay_context(
|
||||
character: &Character,
|
||||
@@ -1390,6 +1476,29 @@ fn build_api_messages(
|
||||
let mut api_messages = vec![Message::new_user(enhanced_system_prompt)];
|
||||
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
|
||||
for msg in &history.messages {
|
||||
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)
|
||||
}
|
||||
|
||||
#[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]
|
||||
async fn generate_response_only() -> Result<String, 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)
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn update_recursion_depth(
|
||||
character_id: String,
|
||||
@@ -2553,6 +2910,7 @@ struct TokenBreakdown {
|
||||
persona: usize,
|
||||
world_info: usize,
|
||||
authors_note: usize,
|
||||
message_examples: usize,
|
||||
message_history: usize,
|
||||
current_input: usize,
|
||||
estimated_max_tokens: usize,
|
||||
@@ -2643,6 +3001,19 @@ fn get_token_count(character_id: Option<String>, current_input: String) -> Resul
|
||||
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
|
||||
let mut history_tokens = 0;
|
||||
for msg in &history.messages {
|
||||
@@ -2654,7 +3025,7 @@ fn get_token_count(character_id: Option<String>, current_input: String) -> Resul
|
||||
|
||||
// Calculate total
|
||||
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)
|
||||
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,
|
||||
world_info: world_info_tokens,
|
||||
authors_note: authors_note_tokens,
|
||||
message_examples: examples_tokens,
|
||||
message_history: history_tokens,
|
||||
current_input: input_tokens,
|
||||
estimated_max_tokens,
|
||||
@@ -2938,6 +3310,11 @@ pub fn run() {
|
||||
truncate_history_from,
|
||||
remove_last_assistant_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,
|
||||
navigate_swipe,
|
||||
get_swipe_info,
|
||||
@@ -2962,6 +3339,7 @@ pub fn run() {
|
||||
update_roleplay_depths,
|
||||
update_authors_note,
|
||||
update_persona,
|
||||
update_examples_settings,
|
||||
update_recursion_depth,
|
||||
get_presets,
|
||||
get_preset,
|
||||
|
||||
@@ -153,9 +153,34 @@
|
||||
Enable Author's Note
|
||||
</label>
|
||||
</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
|
||||
</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>
|
||||
|
||||
@@ -671,6 +696,10 @@
|
||||
<span>Author's Note:</span>
|
||||
<span id="token-authorsnote">0</span>
|
||||
</div>
|
||||
<div class="token-breakdown-item">
|
||||
<span>Message Examples:</span>
|
||||
<span id="token-examples">0</span>
|
||||
</div>
|
||||
<div class="token-breakdown-item">
|
||||
<span>Message History:</span>
|
||||
<span id="token-history">0</span>
|
||||
|
||||
285
src/main.js
285
src/main.js
@@ -481,6 +481,37 @@ function addMessage(content, isUser = false, skipActions = false, timestamp = nu
|
||||
editBtn.addEventListener('click', () => handleEditMessage(messageDiv, content));
|
||||
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(actionsDiv);
|
||||
} else {
|
||||
@@ -495,6 +526,47 @@ function addMessage(content, isUser = false, skipActions = false, timestamp = nu
|
||||
regenerateBtn.addEventListener('click', () => handleRegenerateMessage(messageDiv));
|
||||
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
|
||||
const swipeWrapper = document.createElement('div');
|
||||
swipeWrapper.style.display = 'flex';
|
||||
@@ -695,21 +767,39 @@ async function handleEditMessage(messageDiv, originalContent) {
|
||||
|
||||
// Handle regenerating an assistant message
|
||||
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');
|
||||
regenerateBtn.disabled = true;
|
||||
regenerateBtn.classList.add('loading');
|
||||
|
||||
try {
|
||||
// Get the last user message
|
||||
const lastUserMessage = await invoke('get_last_user_message');
|
||||
setStatus('Regenerating response...', 'default');
|
||||
|
||||
// Generate new response
|
||||
await generateSwipe(messageDiv, lastUserMessage);
|
||||
// Use the new regenerate_at_index command which works on any message
|
||||
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) {
|
||||
console.error('Failed to regenerate message:', error);
|
||||
setStatus(`Regeneration failed: ${error}`, 'error');
|
||||
} finally {
|
||||
regenerateBtn.disabled = false;
|
||||
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
|
||||
async function sendMessage(message, isRegenerate = false) {
|
||||
if (!isRegenerate) {
|
||||
@@ -1338,6 +1561,7 @@ function setupAppControls() {
|
||||
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);
|
||||
document.getElementById('save-examples-btn').addEventListener('click', handleSaveExamples);
|
||||
|
||||
// Setup recursion depth change handler
|
||||
document.getElementById('recursion-depth').addEventListener('change', handleRecursionDepthChange);
|
||||
@@ -1399,6 +1623,7 @@ async function updateTokenCount() {
|
||||
document.getElementById('token-persona').textContent = tokenData.persona;
|
||||
document.getElementById('token-worldinfo').textContent = tokenData.world_info;
|
||||
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-input').textContent = tokenData.current_input;
|
||||
document.getElementById('token-total-detail').textContent = tokenData.total;
|
||||
@@ -1582,6 +1807,29 @@ async function loadChatHistory() {
|
||||
history.forEach((msg, index) => {
|
||||
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
|
||||
if (msg.role === 'assistant' && messageDiv && msg.swipes && msg.swipes.length > 0) {
|
||||
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-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
|
||||
await loadPresets();
|
||||
} 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
|
||||
async function handleRecursionDepthChange() {
|
||||
if (!currentCharacter) return;
|
||||
|
||||
@@ -383,6 +383,56 @@ body {
|
||||
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-controls {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user