Compare commits
15 Commits
9b4bc63e1a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 10d95951a3 | |||
| 50d3177e9e | |||
| 86a9d54e70 | |||
| 600b50f239 | |||
| a7c9657ff1 | |||
| e47bd3bf87 | |||
| 41437e1751 | |||
| 8c70e0558f | |||
| 0bd1590681 | |||
| 26d1430d6a | |||
| bc05747f5f | |||
| 71bac12cd9 | |||
| 9da17c824d | |||
| d8cb4a768b | |||
| b9230772ed |
3631
CELIA 3.8.json
Normal file
3631
CELIA 3.8.json
Normal file
File diff suppressed because one or more lines are too long
115
README.md
115
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,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
|
||||
|
||||
218
ROADMAP.md
218
ROADMAP.md
@@ -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:
|
||||
|
||||
@@ -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![] });
|
||||
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 msg in &mut history.messages {
|
||||
for messages in branched.branch_messages.values_mut() {
|
||||
for msg in messages {
|
||||
msg.migrate();
|
||||
}
|
||||
history
|
||||
} else {
|
||||
ChatHistory { messages: vec![] }
|
||||
}
|
||||
return branched;
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn save_history(character_id: &str, history: &ChatHistory) -> Result<(), String> {
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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");
|
||||
|
||||
121
src/index.html
121
src/index.html
@@ -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>
|
||||
|
||||
1844
src/main.js
1844
src/main.js
File diff suppressed because it is too large
Load Diff
819
src/styles.css
819
src/styles.css
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user