Compare commits

...

15 Commits

Author SHA1 Message Date
10d95951a3 feat: implement QoL features (toast notifications, command palette, auto-save)
Add three major quality-of-life features to improve user experience:

Toast Notification System:
- Non-blocking notifications for all major actions
- Four variants: success, error, warning, info
- Auto-dismiss with progress bar
- Bottom-right positioning with smooth animations
- Replaced old status messages throughout the app

Command Palette (Ctrl+P):
- Keyboard-driven quick access to all actions
- 14 built-in commands across 5 categories
- Real-time fuzzy search with arrow key navigation
- Shows keyboard shortcuts for each command
- Grouped by category with visual feedback

Auto-save & Recovery:
- Automatic draft saving (1s debounce)
- Per-character draft storage in localStorage
- Auto-recovery on app restart or character switch
- Draft age display (e.g., "2 hours ago")
- Auto-cleanup of drafts older than 7 days
- Clears draft when message is sent

Updated README with new Ctrl+P keyboard shortcut
Updated ROADMAP with Phase 8: Quality of Life & Polish section

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 12:27:20 -07:00
50d3177e9e feat: implement chat branching and checkpoints
Add conversation branching system that allows creating and exploring alternate conversation paths from any message point. Each branch maintains its own complete message history.

Backend (Rust):
- Branch data structures with backward-compatible storage migration
- Tauri commands for create, switch, delete, rename, and list operations
- Automatic cleanup of child branches when parent is deleted

Frontend:
- Branch button on all messages for creating new branches
- Branch indicator badge in header showing active branch
- Branch manager modal with full branch list and controls
- Visual improvements to message action toolbar (more opaque, positioned above messages)

Branches are character-specific and persist across sessions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 08:17:08 -07:00
86a9d54e70 feat: replace character creation prompts with themed modal
Replace native prompt() dialogs with a themed inline form modal for character creation:
- Modal displays with name input and system prompt textarea
- Includes Cancel and Create buttons
- Escape key closes the modal
- Auto-focuses name input when opened
- Proper event listener cleanup
- Matches existing UI theme and styling

This provides a much better user experience with the application's dark theme, compared to the browser's default prompt dialogs which looked out of place.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 22:28:42 -07:00
600b50f239 feat: add active features indicator badges in header
Add visual badges in header showing which roleplay features are currently active:
- World Info: Shows count of enabled entries (e.g., "WI: 3")
- Persona: Shows persona name when enabled
- Preset: Shows active preset name
- Examples: Shows when message examples are enabled
- Author's Note: Shows when author's note is enabled (displays as "A/N")

Badges update dynamically when features are toggled, providing at-a-glance visibility of active roleplay features without opening the Roleplay Tools panel.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 22:26:39 -07:00
a7c9657ff1 feat: make context limit configurable in API settings
Add context limit field to API settings that:
- Stores context limit in config (defaults to 200000)
- Allows users to set custom limits for different models
- Uses configured limit in token counter display
- Shows format like "2.5k / 200k tokens" using actual limit

This allows proper token tracking for non-Claude models with
different context windows.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 22:20:13 -07:00
e47bd3bf87 refactor: remove color coding from token counter
Keep simple clean format: '2.5k / 200k tokens' without color changes
2025-10-16 22:16:25 -07:00
41437e1751 feat: make token counter visible by default with color coding
Changes:
- Remove display:none from token counter (always visible)
- Add compact formatting (2.5k / 200k tokens instead of 2500 tokens)
- Color coding based on usage percentage:
  - Green: < 50% usage
  - Yellow: 50-80% usage
  - Red: > 80% usage
- Keep counter visible even on error (shows 0 / 200k)
- Improve discoverability of token tracking feature

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 22:13:15 -07:00
8c70e0558f feat: add keyboard shortcuts and copy message buttons
Keyboard shortcuts:
- Up Arrow: Edit last user message (when input at start)
- Left/Right Arrow: Navigate swipes (when not in input)
- Escape: Close panels/modals, cancel editing
- Ctrl+K: Focus message input
- Ctrl+/: Toggle Roleplay Tools
- Ctrl+Enter: Send message (alternative)

Message controls:
- Add Copy Message button for both user and assistant messages
- Visual feedback (checkmark) after copying
- 2-second feedback duration

Updated README to reflect actual implemented shortcuts.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 22:12:14 -07:00
0bd1590681 feat: replace preset prompts with inline forms
Replace popup dialogs with themed inline forms for preset creation and
duplication. Both operations now use consistent UI styling with proper
theme colors, validation, and cancel/save workflows.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 22:03:37 -07:00
26d1430d6a feat: replace popup dialogs with inline editing for World Info and Instructions
Replaced all prompt() dialogs with themed inline forms for better UX:
- World Info entries now have inline add/edit forms
- Instruction blocks now have inline add/edit forms
- Forms match app theme and stay within the interface
- Added CSS styling for edit forms

Still using prompts for preset creation/duplication - will replace in next commit.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 17:54:28 -07:00
bc05747f5f fix: add proper theming for preset textareas
Fixed black text on white background in preset system additions and authors note textareas by adding CSS rules with !important to override inline styles.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 17:46:43 -07:00
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
7 changed files with 7268 additions and 327 deletions

3631
CELIA 3.8.json Normal file

File diff suppressed because one or more lines are too long

115
README.md
View File

@@ -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,49 @@ 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
- **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
- **Shift+Enter** - New line
- **Ctrl+Enter** - Send message (alternative)
- **Up Arrow** - Edit last user message (when input is at start)
- **Left/Right Arrow** - Navigate between response alternatives
- **Escape** - Close panels/modals, cancel editing
- **Ctrl+K** - Focus message input
- **Ctrl+P** - Open command palette (quick access to all actions)
- **Ctrl+/** - Toggle Roleplay Tools panel
## 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

View File

@@ -16,11 +16,15 @@
- 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)
- Chat Branching/Checkpoints (create, switch, delete, rename branches from any message)
### 🎯 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: Quality of Life & Polish
**Next Up:** Implementing high-impact QoL features to reduce friction and improve user experience - starting with Toast Notifications, Command Palette, Auto-save, Drag & Drop, and Chat Search.
**Recent Completion:** Prompt Presets System with editable built-in presets, instruction block management, and restore-to-default functionality.
**Recent Completion:** Chat Branching/Checkpoints - Full conversation branching system allowing users to create and explore alternate conversation paths from any message point. Each branch maintains its own complete message history with a branch manager modal for easy navigation.
## Phase 1: Core Roleplay Infrastructure (High Priority)
**Goal: Enable basic roleplay-focused prompt engineering**
@@ -43,11 +47,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,34 +77,35 @@
**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.
## Phase 3: Advanced Chat Management (Medium Priority)
**Goal: Non-linear conversation control**
### 1. Chat Branching/Checkpoints
- [ ] Save conversation state at any message
- [ ] Create branches from any point
- [ ] Switch between branches
- [ ] Visual branch indicator in UI
- [ ] Branch naming and organization
- [ ] Delete/merge branches
### 1. Chat Branching/Checkpoints
- [x] Save conversation state at any message
- [x] Create branches from any point
- [x] Switch between branches
- [x] Visual branch indicator in UI
- [x] Branch naming and organization
- [x] Delete branches
- [ ] Merge branches (deferred - nice to have)
**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 +152,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
@@ -249,6 +254,151 @@
**Why Important:** Better UI means less friction and more immersion in roleplay.
## Phase 8: Quality of Life & Polish (High Priority)
**Goal: Reduce friction, improve feedback, and enhance overall user experience**
### 1. Toast Notification System
- [ ] Create toast component (bottom-right positioning)
- [ ] Success/error/info/warning variants
- [ ] Auto-dismiss with configurable timeout
- [ ] Queue multiple toasts
- [ ] Hook into all major actions (save, delete, import, export, etc.)
**Why Important:** Users currently have no immediate feedback when actions succeed or fail. Toasts provide instant visual confirmation without blocking workflow.
### 2. Command Palette
- [ ] Ctrl+P to open command palette modal
- [ ] Fuzzy search for all actions
- [ ] Keyboard navigation (arrow keys, enter, escape)
- [ ] Recent/frequent actions at top
- [ ] Show keyboard shortcuts in results
- [ ] Categories (Chat, Character, Settings, etc.)
**Why Important:** Power users want keyboard-first workflow. Command palette dramatically speeds up common actions without memorizing shortcuts.
### 3. Auto-save & Recovery
- [ ] Auto-save unsent message in input field
- [ ] Restore unsent message after app restart
- [ ] Draft system for in-progress edits
- [ ] Session recovery (restore scroll position, open panels)
- [ ] Crash recovery with last known state
**Why Important:** Losing work due to crashes or accidental closes is extremely frustrating. Auto-save provides a safety net for all user work.
### 4. Drag & Drop Support
- [ ] Drag character card PNGs to import
- [ ] Drag lorebook JSON files to import
- [ ] Drag chat history JSON to import
- [ ] Drag images to set as character avatar
- [ ] Drop zone overlay with visual feedback
- [ ] Support for multiple file drops
**Why Important:** Drag & drop feels natural and is much faster than navigate-click-select workflow. Modern desktop apps are expected to support this.
### 5. Search in Chat History
- [ ] Ctrl+F to open search bar
- [ ] Highlight all matches in messages
- [ ] Navigate between results (prev/next buttons)
- [ ] Case-insensitive search
- [ ] Search counter (e.g., "3 of 42 matches")
- [ ] Clear search and restore view
**Why Important:** Long roleplay sessions can span hundreds of messages. Finding specific content without search is tedious and time-consuming.
### 6. Context Menus (Right-Click)
- [ ] Right-click messages for actions (edit, delete, regenerate, branch, copy)
- [ ] Right-click character dropdown for quick actions
- [ ] Right-click World Info entries for edit/delete
- [ ] Right-click in message input for paste/clear/templates
- [ ] Context-aware menu items
**Why Important:** Right-click is muscle memory for desktop users. Faster than hovering to reveal action buttons.
### 7. Better Feedback & Confirmations
- [ ] Confirmation dialogs for destructive actions (delete character, clear chat)
- [ ] Loading spinners for API calls
- [ ] Progress bars for file imports
- [ ] "Saving..." / "Saved" indicators
- [ ] Success messages for completed actions
**Why Important:** Users should never wonder if an action succeeded or is still processing. Clear feedback prevents confusion and repeated clicks.
### 8. Undo/Redo System
- [ ] Undo message edit (Ctrl+Z)
- [ ] Undo message delete
- [ ] Undo character field changes
- [ ] Undo World Info changes
- [ ] Action history panel (optional)
- [ ] Redo support (Ctrl+Shift+Z)
**Why Important:** Mistakes happen. An undo system provides a safety net and encourages experimentation without fear of losing work.
### 9. Settings Search
- [ ] Search bar at top of settings panel
- [ ] Fuzzy search across all setting names and descriptions
- [ ] Highlight matching settings
- [ ] Collapse/expand sections based on matches
- [ ] "Recently changed" section
**Why Important:** With 22+ features, finding specific settings is tedious. Search makes configuration much faster.
### 10. Character Management Enhancements
- [ ] Recent characters quick-switch dropdown
- [ ] Character search/filter by name or tags
- [ ] Character folders/categories
- [ ] Duplicate character (as template)
- [ ] Favorite/star characters
- [ ] Sort options (name, date created, last used)
**Why Important:** Managing 10+ characters becomes messy. Better organization tools scale with user's character collection.
### 11. Enhanced Keyboard Support
- [ ] Full keyboard navigation in all modals (Tab, Arrow keys, Enter)
- [ ] Escape to close any open panel/modal
- [ ] Vim-style navigation mode (optional, j/k for scroll)
- [ ] Keyboard shortcut hints on hover
- [ ] Focus indicators for keyboard navigation
**Why Important:** Keyboard navigation should work everywhere. Current implementation is inconsistent across different UI sections.
### 12. Export/Share Enhancements
- [ ] Export conversation as formatted HTML
- [ ] Export conversation as formatted PDF
- [ ] Export as markdown with proper formatting
- [ ] Copy conversation to clipboard (formatted)
- [ ] Export individual messages
**Why Important:** Users want to share and archive conversations in readable formats, not just JSON.
### 13. Accessibility Improvements
- [ ] ARIA labels for all interactive elements
- [ ] Screen reader support
- [ ] High contrast mode option
- [ ] Larger click targets option (accessibility mode)
- [ ] Reduced motion mode (respect prefers-reduced-motion)
- [ ] Focus indicators for keyboard navigation
**Why Important:** Accessibility makes the app usable for everyone, including users with disabilities. It's also often legally required.
### 14. Better Visual Feedback
- [ ] Smooth transitions for panel open/close
- [ ] Hover states for all interactive elements
- [ ] Active state indicators (focused panel)
- [ ] Better empty states with helpful text
- [ ] Skeleton loaders for content loading
- [ ] Micro-animations for actions (delete, save, etc.)
**Why Important:** Visual polish makes the app feel responsive and professional. Small animations provide context for state changes.
### 15. Smart Defaults & Templates
- [ ] Scenario templates (fantasy RPG, sci-fi, modern, etc.)
- [ ] Pre-filled World Info templates
- [ ] Character card templates
- [ ] Quick-start wizard for new users
- [ ] Import from popular character repositories
**Why Important:** Reduces friction for new users and speeds up common tasks. Templates provide starting points for customization.
## Implementation Priority Ranking
### Must-Have for Basic Roleplay:

View File

@@ -20,6 +20,12 @@ struct ApiConfig {
active_character_id: Option<String>,
#[serde(default)]
stream: bool,
#[serde(default = "default_context_limit")]
context_limit: u32,
}
fn default_context_limit() -> u32 {
200000
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -180,6 +186,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 +205,8 @@ impl Message {
swipes: vec![content],
current_swipe: 0,
timestamp,
pinned: false,
hidden: false,
}
}
@@ -210,6 +222,8 @@ impl Message {
swipes: vec![content],
current_swipe: 0,
timestamp,
pinned: false,
hidden: false,
}
}
@@ -283,6 +297,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 +311,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 +332,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
}
}
}
@@ -483,6 +507,47 @@ struct ChatHistory {
messages: Vec<Message>,
}
// New branching structures
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Branch {
id: String,
name: String,
created_at: i64, // Unix timestamp in milliseconds
#[serde(default)]
parent_branch_id: Option<String>,
#[serde(default)]
diverge_at_index: usize, // Message index in parent where this branch diverged
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BranchedChatHistory {
#[serde(default = "default_branches")]
branches: Vec<Branch>,
#[serde(default = "default_active_branch")]
active_branch_id: String,
#[serde(default)]
branch_messages: std::collections::HashMap<String, Vec<Message>>,
}
fn default_branches() -> Vec<Branch> {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
vec![Branch {
id: "main".to_string(),
name: "Main".to_string(),
created_at: timestamp,
parent_branch_id: None,
diverge_at_index: 0,
}]
}
fn default_active_branch() -> String {
"main".to_string()
}
#[derive(Debug, Serialize, Deserialize)]
struct ChatRequest {
model: String,
@@ -927,21 +992,61 @@ fn save_config(config: &ApiConfig) -> Result<(), String> {
Ok(())
}
fn load_history(character_id: &str) -> ChatHistory {
// Load branched history (with backward compatibility)
fn load_branched_history(character_id: &str) -> BranchedChatHistory {
let path = get_character_history_path(character_id);
if let Ok(contents) = fs::read_to_string(path) {
let mut history: ChatHistory = serde_json::from_str(&contents).unwrap_or(ChatHistory { messages: vec![] });
// Migrate old messages to new format
for msg in &mut history.messages {
msg.migrate();
if let Ok(contents) = fs::read_to_string(&path) {
// Try to load as new branched format first
if let Ok(mut branched) = serde_json::from_str::<BranchedChatHistory>(&contents) {
// Migrate old messages to new format
for messages in branched.branch_messages.values_mut() {
for msg in messages {
msg.migrate();
}
}
return branched;
}
history
} else {
ChatHistory { messages: vec![] }
// Fall back to old linear format and migrate
if let Ok(mut old_history) = serde_json::from_str::<ChatHistory>(&contents) {
for msg in &mut old_history.messages {
msg.migrate();
}
// Convert to branched format
let mut branch_messages = HashMap::new();
branch_messages.insert("main".to_string(), old_history.messages);
return BranchedChatHistory {
branches: default_branches(),
active_branch_id: "main".to_string(),
branch_messages,
};
}
}
// Return empty history with main branch
let mut branch_messages = HashMap::new();
branch_messages.insert("main".to_string(), vec![]);
BranchedChatHistory {
branches: default_branches(),
active_branch_id: "main".to_string(),
branch_messages,
}
}
fn save_history(character_id: &str, history: &ChatHistory) -> Result<(), String> {
// Legacy function - returns active branch messages
fn load_history(character_id: &str) -> ChatHistory {
let branched = load_branched_history(character_id);
let messages = branched.branch_messages
.get(&branched.active_branch_id)
.cloned()
.unwrap_or_default();
ChatHistory { messages }
}
fn save_branched_history(character_id: &str, history: &BranchedChatHistory) -> Result<(), String> {
let path = get_character_history_path(character_id);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
@@ -951,6 +1056,13 @@ fn save_history(character_id: &str, history: &ChatHistory) -> Result<(), String>
Ok(())
}
// Legacy function - saves to active branch
fn save_history(character_id: &str, history: &ChatHistory) -> Result<(), String> {
let mut branched = load_branched_history(character_id);
branched.branch_messages.insert(branched.active_branch_id.clone(), history.messages.clone());
save_branched_history(character_id, &branched)
}
fn load_character(character_id: &str) -> Option<Character> {
let path = get_character_path(character_id);
if let Ok(contents) = fs::read_to_string(path) {
@@ -1143,7 +1255,7 @@ async fn validate_api(base_url: String, api_key: String) -> Result<Vec<String>,
}
#[tauri::command]
async fn save_api_config(base_url: String, api_key: String, model: String, stream: bool) -> Result<(), String> {
async fn save_api_config(base_url: String, api_key: String, model: String, stream: bool, context_limit: u32) -> Result<(), String> {
// Preserve existing active_character_id if it exists
let active_character_id = load_config().and_then(|c| c.active_character_id);
@@ -1153,6 +1265,7 @@ async fn save_api_config(base_url: String, api_key: String, model: String, strea
model,
active_character_id,
stream,
context_limit,
};
save_config(&config)
}
@@ -1302,6 +1415,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 +1571,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 +1836,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 +2826,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 +3005,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 +3096,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 +3120,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 +3132,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,
@@ -2920,6 +3387,153 @@ async fn export_character_card(app_handle: tauri::AppHandle, character_id: Strin
Ok(output_path.to_string_lossy().to_string())
}
// ============================================================================
// Branch Management Commands
// ============================================================================
#[tauri::command]
fn create_branch(message_index: usize, branch_name: String) -> Result<Branch, String> {
let character = get_active_character();
let mut branched = load_branched_history(&character.id);
// Generate new branch ID
let branch_id = Uuid::new_v4().to_string();
// Get current branch messages
let current_messages = branched.branch_messages
.get(&branched.active_branch_id)
.ok_or_else(|| "Active branch not found".to_string())?;
// Validate message index
if message_index > current_messages.len() {
return Err(format!("Invalid message index: {}", message_index));
}
// Create new branch
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let new_branch = Branch {
id: branch_id.clone(),
name: branch_name,
created_at: timestamp,
parent_branch_id: Some(branched.active_branch_id.clone()),
diverge_at_index: message_index,
};
// Copy messages up to divergence point
let branch_messages: Vec<Message> = current_messages[..message_index].to_vec();
// Add branch and its messages
branched.branches.push(new_branch.clone());
branched.branch_messages.insert(branch_id.clone(), branch_messages);
// Save and return
save_branched_history(&character.id, &branched)?;
Ok(new_branch)
}
#[tauri::command]
fn switch_branch(branch_id: String) -> Result<Vec<Message>, String> {
let character = get_active_character();
let mut branched = load_branched_history(&character.id);
// Verify branch exists
if !branched.branch_messages.contains_key(&branch_id) {
return Err(format!("Branch '{}' not found", branch_id));
}
// Switch active branch
branched.active_branch_id = branch_id.clone();
save_branched_history(&character.id, &branched)?;
// Return messages for the new active branch
let messages = branched.branch_messages
.get(&branch_id)
.cloned()
.unwrap_or_default();
Ok(messages)
}
#[tauri::command]
fn delete_branch(branch_id: String) -> Result<(), String> {
let character = get_active_character();
let mut branched = load_branched_history(&character.id);
// Cannot delete main branch
if branch_id == "main" {
return Err("Cannot delete main branch".to_string());
}
// Cannot delete active branch
if branch_id == branched.active_branch_id {
return Err("Cannot delete active branch. Switch to another branch first.".to_string());
}
// Remove branch
branched.branches.retain(|b| b.id != branch_id);
branched.branch_messages.remove(&branch_id);
// Also remove any child branches that depended on this one
let mut branches_to_remove = vec![];
for branch in &branched.branches {
if branch.parent_branch_id.as_ref() == Some(&branch_id) {
branches_to_remove.push(branch.id.clone());
}
}
for child_id in branches_to_remove {
branched.branches.retain(|b| b.id != child_id);
branched.branch_messages.remove(&child_id);
}
save_branched_history(&character.id, &branched)?;
Ok(())
}
#[tauri::command]
fn list_branches() -> Result<Vec<Branch>, String> {
let character = get_active_character();
let branched = load_branched_history(&character.id);
Ok(branched.branches)
}
#[tauri::command]
fn rename_branch(branch_id: String, new_name: String) -> Result<(), String> {
let character = get_active_character();
let mut branched = load_branched_history(&character.id);
// Find and rename the branch
let branch = branched.branches.iter_mut()
.find(|b| b.id == branch_id)
.ok_or_else(|| format!("Branch '{}' not found", branch_id))?;
branch.name = new_name;
save_branched_history(&character.id, &branched)?;
Ok(())
}
#[tauri::command]
fn get_active_branch_id() -> Result<String, String> {
let character = get_active_character();
let branched = load_branched_history(&character.id);
Ok(branched.active_branch_id)
}
#[tauri::command]
fn get_branch_info(branch_id: String) -> Result<Branch, String> {
let character = get_active_character();
let branched = load_branched_history(&character.id);
branched.branches.iter()
.find(|b| b.id == branch_id)
.cloned()
.ok_or_else(|| format!("Branch '{}' not found", branch_id))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -2938,6 +3552,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 +3581,7 @@ pub fn run() {
update_roleplay_depths,
update_authors_note,
update_persona,
update_examples_settings,
update_recursion_depth,
get_presets,
get_preset,
@@ -2977,7 +3597,14 @@ pub fn run() {
update_world_info_entry,
delete_world_info_entry,
export_world_info,
import_world_info
import_world_info,
create_branch,
switch_branch,
delete_branch,
list_branches,
rename_branch,
get_active_branch_id,
get_branch_info
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -26,6 +26,9 @@
<div class="avatar-circle"></div>
<span id="character-header-name"></span>
</div>
<div id="feature-badges" class="feature-badges">
<!-- Feature badges will be added here dynamically -->
</div>
<div class="character-controls">
<div class="select-wrapper">
<select id="character-select" class="character-select"></select>
@@ -153,9 +156,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>
@@ -325,6 +353,19 @@
</select>
</div>
<div class="form-group">
<label for="context-limit">Context Limit (tokens)</label>
<input
type="number"
id="context-limit"
placeholder="200000"
value="200000"
min="1000"
step="1000"
/>
<small style="color: var(--text-secondary); margin-top: 4px; display: block;">Maximum tokens for model context (e.g., 200000 for Claude)</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="stream-toggle" />
@@ -637,7 +678,7 @@
</form>
<div class="status-bar">
<span id="status-text" class="status-text">Ready</span>
<div id="token-counter" class="token-counter" style="display: none;">
<div id="token-counter" class="token-counter">
<span id="token-count-total" class="token-count">0 tokens</span>
<button id="token-details-btn" class="token-details-btn" title="Show breakdown">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
@@ -671,6 +712,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>
@@ -688,6 +733,33 @@
</footer>
</div>
<!-- Toast Container -->
<div id="toast-container" class="toast-container"></div>
<!-- Command Palette -->
<div id="command-palette-modal" class="command-palette-modal" style="display: none;">
<div class="command-palette-overlay"></div>
<div class="command-palette-content">
<div class="command-palette-search">
<svg class="command-palette-search-icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="2"/>
<path d="M12.5 12.5L17 17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<input
type="text"
id="command-palette-input"
class="command-palette-input"
placeholder="Type a command or search..."
autocomplete="off"
/>
<kbd class="command-palette-hint">Esc to close</kbd>
</div>
<div id="command-palette-results" class="command-palette-results">
<!-- Command results will be dynamically populated here -->
</div>
</div>
</div>
<!-- Avatar zoom modal -->
<div id="avatar-modal" class="avatar-modal" style="display: none;">
<div class="avatar-modal-overlay"></div>
@@ -695,5 +767,50 @@
<img id="avatar-modal-img" src="" alt="Avatar" />
</div>
</div>
<!-- New Character modal -->
<div id="new-character-modal" class="new-character-modal" style="display: none;">
<div class="new-character-overlay"></div>
<div class="new-character-content">
<div class="new-character-header">
<h3>Create New Character</h3>
<button id="close-new-character-btn" class="icon-btn">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<line x1="4" y1="4" x2="12" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="12" y1="4" x2="4" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<form id="new-character-form">
<div class="form-group">
<label for="new-character-name">Character Name</label>
<input
type="text"
id="new-character-name"
placeholder="Enter a name for the new character"
required
autofocus
/>
</div>
<div class="form-group">
<label for="new-character-system-prompt">System Prompt</label>
<textarea
id="new-character-system-prompt"
placeholder="You are a helpful AI assistant..."
rows="6"
required
></textarea>
</div>
<div class="new-character-actions">
<button type="button" id="cancel-new-character-btn" class="btn-secondary">
Cancel
</button>
<button type="submit" class="btn-primary">
Create
</button>
</div>
</form>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -172,6 +172,190 @@ body {
gap: 8px;
}
/* Feature Badges */
.feature-badges {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.feature-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 12px;
font-size: 11px;
font-weight: 500;
color: var(--accent);
white-space: nowrap;
transition: all 0.2s ease;
}
.feature-badge:hover {
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.5);
}
.feature-badge-icon {
width: 10px;
height: 10px;
opacity: 0.8;
}
.feature-badge-count {
font-weight: 600;
color: var(--accent);
}
/* Branch Badge */
.branch-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 12px;
font-size: 11px;
font-weight: 500;
color: #22c55e;
white-space: nowrap;
transition: all 0.2s ease;
}
.branch-badge:hover {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.5);
}
.branch-badge svg {
width: 12px;
height: 12px;
opacity: 0.8;
}
/* Branch Manager Modal */
.branch-manager-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10002;
display: flex;
align-items: center;
justify-content: center;
}
.branch-manager-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
cursor: pointer;
}
.branch-manager-content {
position: relative;
z-index: 1;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
width: 90%;
max-width: 600px;
max-height: 80vh;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
}
.branch-manager-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.branch-manager-header h3 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.branch-list {
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
max-height: 60vh;
}
.branch-item {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
transition: all 0.2s ease;
}
.branch-item:hover {
border-color: var(--accent);
}
.branch-item.active {
background: rgba(99, 102, 241, 0.1);
border-color: var(--accent);
}
.branch-info {
flex: 1;
min-width: 0;
}
.branch-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.branch-meta {
font-size: 11px;
color: var(--text-secondary);
}
.branch-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.branch-active-label {
font-size: 12px;
padding: 4px 12px;
background: var(--accent);
color: white;
border-radius: 6px;
font-weight: 500;
}
.icon-btn {
width: 28px;
height: 28px;
@@ -332,19 +516,24 @@ body {
/* Message action buttons */
.message-actions {
position: absolute;
top: 8px;
top: -4px;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease;
background: rgba(0, 0, 0, 0.6);
padding: 4px;
border-radius: 8px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.message.user .message-actions {
right: 8px;
right: 4px;
}
.message.assistant .message-actions {
right: 8px;
right: 4px;
}
.message:hover .message-actions {
@@ -352,26 +541,28 @@ body {
}
.message-action-btn {
width: 24px;
height: 24px;
border: none;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
width: 28px;
height: 28px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 6px;
color: var(--text-secondary);
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
.message-action-btn:hover {
background: rgba(0, 0, 0, 0.7);
color: var(--text-primary);
transform: scale(1.1);
background: rgba(0, 0, 0, 0.95);
border-color: rgba(255, 255, 255, 0.3);
color: white;
transform: scale(1.05);
}
.message-action-btn:active {
@@ -379,8 +570,58 @@ body {
}
.message-action-btn svg {
width: 14px;
height: 14px;
width: 16px;
height: 16px;
}
/* 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 */
@@ -1211,6 +1452,24 @@ body {
border-color: var(--accent);
}
/* Fix theming for textareas with inline styles */
#preset-system-editable,
#preset-authors-note-editable {
background: var(--bg-tertiary) !important;
border: 1px solid var(--border) !important;
border-radius: 8px;
padding: 12px;
color: var(--text-primary) !important;
font-family: inherit;
resize: vertical;
}
#preset-system-editable:focus,
#preset-authors-note-editable:focus {
outline: none;
border-color: var(--accent) !important;
}
.form-group select {
-webkit-appearance: none;
-moz-appearance: none;
@@ -1343,6 +1602,69 @@ body {
transform: scale(0.95);
}
/* New Character Modal */
.new-character-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10001;
display: flex;
align-items: center;
justify-content: center;
}
.new-character-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
cursor: pointer;
}
.new-character-content {
position: relative;
z-index: 1;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
width: 90%;
max-width: 500px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.new-character-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.new-character-header h3 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.new-character-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.new-character-actions button {
flex: 1;
}
/* Theme Preview */
.theme-preview-container {
margin-top: 20px;
@@ -1787,6 +2109,41 @@ body.view-comfortable .message-content pre {
word-wrap: break-word;
}
.worldinfo-edit-form,
.worldinfo-inline-edit {
background: var(--bg-secondary);
border: 2px solid var(--accent);
border-radius: 8px;
padding: 16px;
}
.worldinfo-edit-form input,
.worldinfo-edit-form textarea,
.worldinfo-inline-edit input,
.worldinfo-inline-edit textarea {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
}
.worldinfo-edit-form input:focus,
.worldinfo-edit-form textarea:focus,
.worldinfo-inline-edit input:focus,
.worldinfo-inline-edit textarea:focus {
outline: none;
border-color: var(--accent);
}
.worldinfo-edit-form textarea,
.worldinfo-inline-edit textarea {
resize: vertical;
min-height: 80px;
}
.header-left-controls {
display: flex;
gap: 8px;
@@ -1824,3 +2181,435 @@ body.view-comfortable .message-content pre {
left: 0;
}
}
/* Toast Notification System */
.toast-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 10003;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
max-width: 400px;
}
.toast {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
gap: 12px;
min-width: 300px;
max-width: 400px;
pointer-events: auto;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
animation: slideInRight 0.3s ease;
transition: all 0.3s ease;
}
.toast.removing {
animation: slideOutRight 0.3s ease forwards;
}
@keyframes slideInRight {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOutRight {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
.toast-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.toast-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.toast-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.toast-message {
font-size: 13px;
color: var(--text-secondary);
margin: 0;
line-height: 1.4;
}
.toast-close {
width: 20px;
height: 20px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
padding: 0;
flex-shrink: 0;
}
.toast-close:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
/* Toast Variants */
.toast.success {
border-color: rgba(34, 197, 94, 0.5);
background: rgba(34, 197, 94, 0.1);
}
.toast.success .toast-icon {
color: #22c55e;
}
.toast.success .toast-title {
color: #22c55e;
}
.toast.error {
border-color: rgba(239, 68, 68, 0.5);
background: rgba(239, 68, 68, 0.1);
}
.toast.error .toast-icon {
color: #ef4444;
}
.toast.error .toast-title {
color: #ef4444;
}
.toast.warning {
border-color: rgba(249, 115, 22, 0.5);
background: rgba(249, 115, 22, 0.1);
}
.toast.warning .toast-icon {
color: #f97316;
}
.toast.warning .toast-title {
color: #f97316;
}
.toast.info {
border-color: rgba(99, 102, 241, 0.5);
background: rgba(99, 102, 241, 0.1);
}
.toast.info .toast-icon {
color: var(--accent);
}
.toast.info .toast-title {
color: var(--accent);
}
/* Progress bar for auto-dismiss */
.toast-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(255, 255, 255, 0.2);
border-radius: 0 0 12px 12px;
overflow: hidden;
}
.toast-progress-bar {
height: 100%;
background: currentColor;
animation: progressShrink var(--duration) linear forwards;
}
@keyframes progressShrink {
from {
width: 100%;
}
to {
width: 0%;
}
}
/* Responsive toast positioning */
@media (max-width: 600px) {
.toast-container {
left: 12px;
right: 12px;
bottom: 12px;
max-width: none;
}
.toast {
min-width: 0;
max-width: none;
}
}
/* Command Palette */
.command-palette-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10004;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
animation: fadeIn 0.15s ease;
}
.command-palette-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
cursor: pointer;
}
.command-palette-content {
position: relative;
z-index: 1;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 12px;
width: 90%;
max-width: 600px;
max-height: 60vh;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideInDown 0.2s ease;
}
@keyframes slideInDown {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.command-palette-search {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
.command-palette-search-icon {
color: var(--text-secondary);
flex-shrink: 0;
}
.command-palette-input {
flex: 1;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 16px;
font-family: inherit;
outline: none;
}
.command-palette-input::placeholder {
color: var(--text-secondary);
}
.command-palette-hint {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
font-family: monospace;
color: var(--text-secondary);
flex-shrink: 0;
}
.command-palette-results {
overflow-y: auto;
max-height: 50vh;
padding: 8px;
}
.command-palette-results::-webkit-scrollbar {
width: 8px;
}
.command-palette-results::-webkit-scrollbar-track {
background: var(--bg-primary);
}
.command-palette-results::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 4px;
}
.command-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
}
.command-item:hover {
background: var(--bg-secondary);
border-color: var(--border);
}
.command-item.selected {
background: rgba(99, 102, 241, 0.1);
border-color: var(--accent);
}
.command-item-icon {
width: 20px;
height: 20px;
color: var(--accent);
flex-shrink: 0;
}
.command-item-content {
flex: 1;
min-width: 0;
}
.command-item-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 2px 0;
}
.command-item-description {
font-size: 12px;
color: var(--text-secondary);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-item-shortcut {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.command-item-shortcut kbd {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 6px;
font-size: 11px;
font-family: monospace;
color: var(--text-secondary);
}
.command-palette-empty {
padding: 40px 20px;
text-align: center;
color: var(--text-secondary);
}
.command-palette-empty-icon {
width: 48px;
height: 48px;
margin: 0 auto 12px;
opacity: 0.5;
}
.command-palette-empty-text {
font-size: 14px;
margin: 0;
}
.command-palette-section {
padding: 8px 12px 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 8px;
}
.command-palette-section:first-child {
margin-top: 0;
}
/* Responsive command palette */
@media (max-width: 600px) {
.command-palette-modal {
padding-top: 10vh;
}
.command-palette-content {
width: 95%;
max-height: 70vh;
}
.command-item {
padding: 10px;
}
.command-item-shortcut {
display: none;
}
}