Compare commits
10 Commits
0712b1c422
...
84d3e0df67
| Author | SHA1 | Date | |
|---|---|---|---|
| 84d3e0df67 | |||
| 30e6af61ca | |||
| 32fc57fab0 | |||
| efa3ccbd26 | |||
| b9ea771ff0 | |||
| 4866c11245 | |||
| e0239aceda | |||
| 4694114ff9 | |||
| ab6ae14bbc | |||
| a9b686f0d1 |
71
README.md
71
README.md
@@ -1,28 +1,37 @@
|
||||
# Claudia
|
||||
|
||||
Beautiful AI desktop companion built with Tauri and Rust.
|
||||
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.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Features
|
||||
- 🎨 **Beautiful glassmorphic UI** with gradient backgrounds and blur effects
|
||||
- 🔧 **Bring-your-own-API** - supports any Anthropic-compatible API
|
||||
- ✅ **API validation** via /v1/models endpoint
|
||||
### 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 between sessions
|
||||
- 🎯 **Custom window controls** - drag, minimize, maximize, close
|
||||
- 💾 **Persistent chat history** - Conversations saved per character
|
||||
- 🎯 **Streaming responses** - Real-time token display (optional)
|
||||
|
||||
### 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.)
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
### User Experience
|
||||
- ⌨️ **Keyboard shortcuts** - Enter or Ctrl+Enter to send, Shift+Enter for new lines
|
||||
- 🗑️ **Clear conversations** - easily start fresh
|
||||
- 🎯 **Auto-resizing input** - textarea grows with your message
|
||||
- 🎭 **Light/dark mode** - automatic based on system preferences
|
||||
- 📝 **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
|
||||
|
||||
## Running
|
||||
|
||||
@@ -53,9 +62,37 @@ On first launch, click settings and configure:
|
||||
### 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
|
||||
|
||||
## Roadmap
|
||||
|
||||
Claudia is being developed to become a full-featured roleplay platform comparable to SillyTavern. See [ROADMAP.md](ROADMAP.md) for detailed plans including:
|
||||
|
||||
**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 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)
|
||||
|
||||
## Contributing
|
||||
|
||||
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.
|
||||
|
||||
347
ROADMAP.md
Normal file
347
ROADMAP.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# Claudia Roleplay Enhancement Roadmap
|
||||
|
||||
## Current Status
|
||||
|
||||
### ✅ Implemented Features
|
||||
- V2/V3 Character Card Import/Export
|
||||
- Message Swipes (multiple response alternatives)
|
||||
- Streaming Responses with toggle
|
||||
- Character Management (multiple characters)
|
||||
- Character Avatars with upload and zoom
|
||||
- Expanded Character Editor (all v2/v3 fields)
|
||||
|
||||
### 🎯 Current Focus: UI/UX Improvements
|
||||
**Decision:** Before adding complex roleplay features, we're focusing on polishing the existing UI/UX to establish a solid foundation. This includes better visual design, improved workflows, and enhanced user experience.
|
||||
|
||||
See "Phase 7: Polish & UX" section for details on UI improvements being prioritized.
|
||||
|
||||
## Phase 1: Core Roleplay Infrastructure (High Priority)
|
||||
**Goal: Enable basic roleplay-focused prompt engineering**
|
||||
|
||||
### 1. World Info/Lorebook System
|
||||
- [ ] Create UI for managing lorebook entries (keyword, content, priority)
|
||||
- [ ] Implement keyword detection in recent messages
|
||||
- [ ] Add context injection before message generation
|
||||
- [ ] Support recursive entry activation
|
||||
- [ ] Per-character lorebook assignment
|
||||
- [ ] Import/export lorebook files
|
||||
|
||||
**Why Important:** World Info is the foundation of consistent roleplay. It allows dynamic context injection based on what's currently relevant in the conversation, saving tokens while maintaining world consistency.
|
||||
|
||||
### 2. Author's Note
|
||||
- [ ] Add configurable Author's Note field (inserted at depth 1-5)
|
||||
- [ ] Position control (after system, before/after examples, etc.)
|
||||
- [ ] Per-character Author's Note support
|
||||
- [ ] Template variables in Author's Note
|
||||
|
||||
**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
|
||||
|
||||
**Why Important:** Many roleplay scenarios require specific prompting to work well with API safety filters and to maintain character consistency.
|
||||
|
||||
## Phase 2: Enhanced Character Features (High Priority)
|
||||
**Goal: Better character representation and user identity**
|
||||
|
||||
### 1. User Personas
|
||||
- [ ] Create persona management UI (name, description, avatar)
|
||||
- [ ] Chat-level persona locking
|
||||
- [ ] Character-level persona locking
|
||||
- [ ] Default persona setting
|
||||
- [ ] Quick persona switching
|
||||
|
||||
**Why Important:** Allows users to have multiple identities for different roleplay scenarios without manually changing their name and description each time.
|
||||
|
||||
### 2. Character Expressions/Sprites
|
||||
- [ ] Support for emotion-based character images
|
||||
- [ ] Sentiment analysis of AI responses (local model)
|
||||
- [ ] 28+ emotion presets (happy, sad, angry, neutral, etc.)
|
||||
- [ ] Expression sprite packs (import/export)
|
||||
- [ ] Manual expression override with /emote command
|
||||
- [ ] Sprite positioning options (beside chat, behind chat, etc.)
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
|
||||
**Why Important:** Fine-grained control over conversation history allows users to craft the perfect roleplay session.
|
||||
|
||||
### 3. Timeline Visualization
|
||||
- [ ] Visual tree of chat branches
|
||||
- [ ] Quick navigation between branches
|
||||
- [ ] Branch metadata (creation date, message count, etc.)
|
||||
- [ ] Visual diff between branches
|
||||
- [ ] Merge branch capability
|
||||
|
||||
**Why Important:** Makes managing complex branching conversations intuitive and prevents users from getting lost.
|
||||
|
||||
## Phase 4: Multi-Character/Group Chats (Medium Priority)
|
||||
**Goal: Enable complex multi-character scenarios**
|
||||
|
||||
### 1. Group Chat Foundation
|
||||
- [ ] Create group chat data structure
|
||||
- [ ] UI for managing group members
|
||||
- [ ] Add/remove characters from groups
|
||||
- [ ] Group chat history management
|
||||
- [ ] Per-group settings
|
||||
|
||||
**Why Important:** Many roleplay scenarios involve multiple characters interacting. Group chats enable DM-style gameplay and complex social scenarios.
|
||||
|
||||
### 2. Reply Management
|
||||
- [ ] Manual character selection
|
||||
- [ ] Natural order (mention-based)
|
||||
- [ ] Talkativeness settings per character (0-100%)
|
||||
- [ ] Auto-mode (characters respond automatically)
|
||||
- [ ] Character muting/unmuting
|
||||
- [ ] Reply order presets
|
||||
|
||||
**Why Important:** Gives users control over conversation flow while allowing for spontaneous multi-character interactions.
|
||||
|
||||
### 3. Group Chat UI
|
||||
- [ ] Character indicators on messages
|
||||
- [ ] Character list sidebar
|
||||
- [ ] Mute/unmute controls
|
||||
- [ ] Character ordering/priority
|
||||
- [ ] Group-wide lorebook support
|
||||
|
||||
**Why Important:** Clear visual indicators make group conversations easy to follow.
|
||||
|
||||
## 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
|
||||
|
||||
**Why Important:** Understanding what's in context and what's being cut is crucial for debugging issues and optimizing prompts.
|
||||
|
||||
### 2. Context Templates
|
||||
- [ ] Customizable prompt assembly order
|
||||
- [ ] Handlebars template support
|
||||
- [ ] Presets for different model types (Alpaca, ChatML, Llama, etc.)
|
||||
- [ ] Template preview
|
||||
- [ ] Variable substitution visualization
|
||||
|
||||
**Why Important:** Different models expect different prompt formats. Templates ensure prompts are formatted correctly for each model.
|
||||
|
||||
### 3. Smart Context Management
|
||||
- [ ] Summarization of old messages
|
||||
- [ ] Automatic message trimming
|
||||
- [ ] Priority-based context allocation
|
||||
- [ ] Context budget per source (system, WI, history, etc.)
|
||||
- [ ] Smart message selection (keep important messages)
|
||||
|
||||
**Why Important:** Efficient context usage means longer, more coherent conversations without running out of tokens.
|
||||
|
||||
## Phase 6: Power User Features (Low Priority)
|
||||
**Goal: Advanced customization and automation**
|
||||
|
||||
### 1. Quick Replies
|
||||
- [ ] Preset message buttons
|
||||
- [ ] Macro support in quick replies
|
||||
- [ ] Import/export QR sets
|
||||
- [ ] Character-specific QR sets
|
||||
- [ ] Conditional quick replies
|
||||
- [ ] Quick reply categories/folders
|
||||
|
||||
**Why Important:** Speeds up common actions and reduces repetitive typing in roleplay scenarios.
|
||||
|
||||
### 2. Macro System
|
||||
- [ ] Basic macros ({{user}}, {{char}}, {{random}}, etc.)
|
||||
- [ ] Date/time macros
|
||||
- [ ] Conditional macros
|
||||
- [ ] Custom macro definitions
|
||||
- [ ] Nested macro support
|
||||
- [ ] Macro debugging
|
||||
|
||||
**Why Important:** Makes prompts and messages dynamic and reusable across different scenarios.
|
||||
|
||||
### 3. Regex Scripts
|
||||
- [ ] Global and character-scoped scripts
|
||||
- [ ] Text transformation on messages
|
||||
- [ ] Auto-markdown formatting
|
||||
- [ ] Import/export regex presets
|
||||
- [ ] Regex testing interface
|
||||
- [ ] Script priority/ordering
|
||||
|
||||
**Why Important:** Allows automatic text formatting, correction, and enhancement without manual intervention.
|
||||
|
||||
### 4. Hotkey System
|
||||
- [ ] Customizable keyboard shortcuts
|
||||
- [ ] Quick actions (regen, edit, delete, etc.)
|
||||
- [ ] Markdown formatting hotkeys
|
||||
- [ ] Quick Reply hotkeys
|
||||
- [ ] Navigation hotkeys
|
||||
- [ ] Hotkey conflict detection
|
||||
|
||||
**Why Important:** Power users rely on keyboard shortcuts for efficient workflow.
|
||||
|
||||
## Phase 7: Polish & UX (Ongoing)
|
||||
**Goal: Better user experience for roleplay**
|
||||
|
||||
### 1. Instruct Mode Support
|
||||
- [ ] Preset templates (Alpaca, ChatML, Llama, etc.)
|
||||
- [ ] Custom template creation
|
||||
- [ ] Auto-detect model format from API
|
||||
- [ ] Instruction wrapping for system/user/assistant messages
|
||||
|
||||
**Why Important:** Ensures compatibility with instruction-tuned models that expect specific formats.
|
||||
|
||||
### 2. Export/Import Improvements
|
||||
- [ ] Export chats as markdown
|
||||
- [ ] Export chats as formatted text
|
||||
- [ ] Export chats as JSON with metadata
|
||||
- [ ] Import chats from other formats
|
||||
- [ ] Bulk character import
|
||||
- [ ] Character pack support (multiple characters + lorebooks)
|
||||
|
||||
**Why Important:** Sharing and migrating content between platforms and backing up work.
|
||||
|
||||
### 3. UI Enhancements
|
||||
- [ ] Message timestamps
|
||||
- [ ] Character indicators in messages
|
||||
- [ ] Better settings organization (categories, search)
|
||||
- [ ] Theme customization (colors, fonts, etc.)
|
||||
- [ ] Compact/cozy view modes
|
||||
- [ ] Responsive design for different screen sizes
|
||||
- [ ] Accessibility improvements
|
||||
|
||||
**Why Important:** Better UI means less friction and more immersion in roleplay.
|
||||
|
||||
## Implementation Priority Ranking
|
||||
|
||||
### Must-Have for Basic Roleplay:
|
||||
1. **World Info/Lorebooks** - Core feature for consistent roleplay
|
||||
2. **Author's Note** - Better prompt control than system prompts alone
|
||||
3. **Token Counter** - Visibility into what's happening
|
||||
4. **Message Examples Usage** - Better character accuracy
|
||||
|
||||
### Important for Good Roleplay:
|
||||
5. **User Personas** - Identity management
|
||||
6. **Chat Branching** - Non-linear exploration
|
||||
7. **Enhanced Message Controls** - Fine-grained editing
|
||||
8. **Jailbreak Templates** - Handle various scenarios
|
||||
|
||||
### Great for Enhanced Experience:
|
||||
9. **Expression Sprites** - Visual immersion
|
||||
10. **Quick Replies + Macros** - Efficiency
|
||||
11. **Context Templates** - Model compatibility
|
||||
12. **Group Chats** - Complex scenarios
|
||||
|
||||
### Nice to Have:
|
||||
13. **Timeline Visualization** - Advanced branch management
|
||||
14. **Regex Scripts** - Automation
|
||||
15. **Hotkeys** - Power user efficiency
|
||||
16. **Smart Context Management** - Optimization
|
||||
|
||||
## Research Sources
|
||||
|
||||
This roadmap is based on research into SillyTavern's features and best practices from the roleplay AI community:
|
||||
- SillyTavern official documentation (docs.sillytavern.app)
|
||||
- Character card specifications (V2/V3 format)
|
||||
- Community presets and guides on HuggingFace
|
||||
- Roleplay community feedback and feature requests
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### Data Structures Needed:
|
||||
- Lorebook entries (keyword, content, priority, insertion order, depth)
|
||||
- Personas (name, description, avatar, chat/character locks)
|
||||
- Chat branches (branch point, parent branch, metadata)
|
||||
- Expression mappings (emotion → image file)
|
||||
- Quick replies (text, macros, conditions, categories)
|
||||
- Context templates (format strings, variables, presets)
|
||||
|
||||
### Backend Changes Required:
|
||||
- Context assembly refactor (modular system for injecting different sources)
|
||||
- Token counting integration (model-specific tokenizers)
|
||||
- Sentiment analysis (local model or API integration)
|
||||
- Branching chat storage (tree structure instead of linear)
|
||||
- Group chat message routing (multi-character generation)
|
||||
|
||||
### UI Additions Needed:
|
||||
- Lorebook editor panel
|
||||
- Persona management panel
|
||||
- Branch visualization widget
|
||||
- Token counter display
|
||||
- Group chat member list
|
||||
- Quick reply buttons
|
||||
- Expression sprite overlay
|
||||
- Context template editor
|
||||
|
||||
## Version Milestones
|
||||
|
||||
### v0.2.0 - "Roleplay Foundation"
|
||||
- World Info/Lorebooks
|
||||
- Author's Note
|
||||
- Token Counter
|
||||
- Better Message Controls
|
||||
|
||||
### v0.3.0 - "Character Enhancement"
|
||||
- User Personas
|
||||
- Expression Sprites
|
||||
- Message Examples Usage
|
||||
- Jailbreak Templates
|
||||
|
||||
### v0.4.0 - "Advanced Chat"
|
||||
- Chat Branching
|
||||
- Timeline Visualization
|
||||
- Group Chats (basic)
|
||||
|
||||
### v0.5.0 - "Power User"
|
||||
- Quick Replies
|
||||
- Macros
|
||||
- Regex Scripts
|
||||
- Hotkeys
|
||||
|
||||
### v1.0.0 - "Feature Complete"
|
||||
- All planned features implemented
|
||||
- Polished UI
|
||||
- Comprehensive documentation
|
||||
- Import/Export from SillyTavern
|
||||
|
||||
## Notes
|
||||
|
||||
- Focus on **compatibility with SillyTavern** where possible (character cards, lorebooks, etc.)
|
||||
- Keep **performance** in mind - roleplay sessions can be long
|
||||
- Maintain **desktop-first** design - power users prefer desktop interfaces
|
||||
- Consider **offline-first** approach - local models are popular for roleplay
|
||||
- Remember **privacy** - roleplay content is often sensitive
|
||||
|
||||
---
|
||||
|
||||
Last updated: 2025-10-14
|
||||
@@ -174,24 +174,38 @@ struct Message {
|
||||
swipes: Vec<String>,
|
||||
#[serde(default)]
|
||||
current_swipe: usize,
|
||||
#[serde(default)]
|
||||
timestamp: i64, // Unix timestamp in milliseconds
|
||||
}
|
||||
|
||||
impl Message {
|
||||
fn new_user(content: String) -> Self {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64;
|
||||
|
||||
Self {
|
||||
role: "user".to_string(),
|
||||
content: content.clone(),
|
||||
swipes: vec![content],
|
||||
current_swipe: 0,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
fn new_assistant(content: String) -> Self {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64;
|
||||
|
||||
Self {
|
||||
role: "assistant".to_string(),
|
||||
content: content.clone(),
|
||||
swipes: vec![content],
|
||||
current_swipe: 0,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
401
src/index.html
401
src/index.html
@@ -55,7 +55,10 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="settings-panel" id="settings-panel" style="display: none;">
|
||||
<!-- Settings overlay backdrop -->
|
||||
<div class="settings-overlay" id="settings-overlay"></div>
|
||||
|
||||
<div class="settings-panel" id="settings-panel">
|
||||
<div class="settings-header">
|
||||
<h2>Settings</h2>
|
||||
<button id="close-settings-btn" class="icon-btn">
|
||||
@@ -69,6 +72,7 @@
|
||||
<div class="settings-tabs">
|
||||
<button class="tab-btn active" data-tab="api">API</button>
|
||||
<button class="tab-btn" data-tab="character">Character</button>
|
||||
<button class="tab-btn" data-tab="appearance">Appearance</button>
|
||||
</div>
|
||||
|
||||
<div id="api-tab" class="tab-content active">
|
||||
@@ -125,168 +129,279 @@
|
||||
<label for="character-settings-select">Select Character</label>
|
||||
<select id="character-settings-select"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="character-name">Character Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="character-name"
|
||||
placeholder="Assistant"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-avatar">Avatar (Optional)</label>
|
||||
<div class="avatar-upload">
|
||||
<div id="avatar-preview" class="avatar-preview">
|
||||
<div class="avatar-circle-large"></div>
|
||||
<!-- Basic Info Section -->
|
||||
<div class="settings-section" data-section="basic">
|
||||
<div class="settings-section-header">
|
||||
<div class="settings-section-title">
|
||||
<svg class="settings-section-icon" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Basic Information
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section-content">
|
||||
<div class="form-group">
|
||||
<label for="character-name">Character Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="character-name"
|
||||
placeholder="Assistant"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-avatar">Avatar (Optional)</label>
|
||||
<div class="avatar-upload">
|
||||
<div id="avatar-preview" class="avatar-preview">
|
||||
<div class="avatar-circle-large"></div>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
id="character-avatar"
|
||||
accept="image/png,image/jpeg,image/jpg,image/webp"
|
||||
style="display: none;"
|
||||
/>
|
||||
<button type="button" id="upload-avatar-btn" class="btn-secondary">
|
||||
Choose Image
|
||||
</button>
|
||||
<button type="button" id="remove-avatar-btn" class="btn-secondary" style="display: none;">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-system-prompt">System Prompt</label>
|
||||
<textarea
|
||||
id="character-system-prompt"
|
||||
placeholder="You are a helpful AI assistant..."
|
||||
rows="6"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-greeting">Greeting (Optional)</label>
|
||||
<textarea
|
||||
id="character-greeting"
|
||||
placeholder="Hello! How can I help you today?"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
id="character-avatar"
|
||||
accept="image/png,image/jpeg,image/jpg,image/webp"
|
||||
style="display: none;"
|
||||
/>
|
||||
<button type="button" id="upload-avatar-btn" class="btn-secondary">
|
||||
Choose Image
|
||||
</button>
|
||||
<button type="button" id="remove-avatar-btn" class="btn-secondary" style="display: none;">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-system-prompt">System Prompt</label>
|
||||
<textarea
|
||||
id="character-system-prompt"
|
||||
placeholder="You are a helpful AI assistant..."
|
||||
rows="6"
|
||||
required
|
||||
></textarea>
|
||||
<!-- Roleplay Details Section -->
|
||||
<div class="settings-section collapsed" data-section="roleplay">
|
||||
<div class="settings-section-header">
|
||||
<div class="settings-section-title">
|
||||
<svg class="settings-section-icon" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Roleplay Details
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section-content">
|
||||
<div class="form-group">
|
||||
<label for="character-personality">Personality Tags</label>
|
||||
<input
|
||||
type="text"
|
||||
id="character-personality"
|
||||
placeholder="helpful, friendly, knowledgeable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-description">Description</label>
|
||||
<textarea
|
||||
id="character-description"
|
||||
placeholder="Detailed character description, appearance, background..."
|
||||
rows="8"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-scenario">Scenario</label>
|
||||
<textarea
|
||||
id="character-scenario"
|
||||
placeholder="The setting or situation where the character exists..."
|
||||
rows="4"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-mes-example">Message Examples</label>
|
||||
<textarea
|
||||
id="character-mes-example"
|
||||
placeholder="Example dialogue from the character..."
|
||||
rows="4"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-greeting">Greeting (Optional)</label>
|
||||
<textarea
|
||||
id="character-greeting"
|
||||
placeholder="Hello! How can I help you today?"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<!-- Advanced Settings Section -->
|
||||
<div class="settings-section collapsed" data-section="advanced">
|
||||
<div class="settings-section-header">
|
||||
<div class="settings-section-title">
|
||||
<svg class="settings-section-icon" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Advanced Settings
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section-content">
|
||||
<div class="form-group">
|
||||
<label for="character-post-history">Post-History Instructions</label>
|
||||
<textarea
|
||||
id="character-post-history"
|
||||
placeholder="Instructions to apply after chat history..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-alt-greetings">Alternate Greetings</label>
|
||||
<textarea
|
||||
id="character-alt-greetings"
|
||||
placeholder="One greeting per line..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-personality">Personality Tags (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="character-personality"
|
||||
placeholder="helpful, friendly, knowledgeable"
|
||||
/>
|
||||
</div>
|
||||
<!-- Metadata Section -->
|
||||
<div class="settings-section collapsed" data-section="metadata">
|
||||
<div class="settings-section-header">
|
||||
<div class="settings-section-title">
|
||||
<svg class="settings-section-icon" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Metadata
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section-content">
|
||||
<div class="form-group">
|
||||
<label for="character-tags">Tags</label>
|
||||
<input
|
||||
type="text"
|
||||
id="character-tags"
|
||||
placeholder="fantasy, adventure, comedy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-description">Description (Optional)</label>
|
||||
<textarea
|
||||
id="character-description"
|
||||
placeholder="Detailed character description, appearance, background..."
|
||||
rows="10"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="character-creator">Creator</label>
|
||||
<input
|
||||
type="text"
|
||||
id="character-creator"
|
||||
placeholder="Card creator name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-scenario">Scenario (Optional)</label>
|
||||
<textarea
|
||||
id="character-scenario"
|
||||
placeholder="The setting or situation where the character exists..."
|
||||
rows="4"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="character-version">Character Version</label>
|
||||
<input
|
||||
type="text"
|
||||
id="character-version"
|
||||
placeholder="1.0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-mes-example">Message Example (Optional)</label>
|
||||
<textarea
|
||||
id="character-mes-example"
|
||||
placeholder="Example dialogue from the character..."
|
||||
rows="4"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-post-history">Post-History Instructions (Optional)</label>
|
||||
<textarea
|
||||
id="character-post-history"
|
||||
placeholder="Instructions to apply after chat history..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-alt-greetings">Alternate Greetings (Optional)</label>
|
||||
<textarea
|
||||
id="character-alt-greetings"
|
||||
placeholder="One greeting per line..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-tags">Tags (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="character-tags"
|
||||
placeholder="fantasy, adventure, comedy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-creator">Creator (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="character-creator"
|
||||
placeholder="Card creator name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-version">Character Version (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="character-version"
|
||||
placeholder="1.0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="character-creator-notes">Creator Notes (Optional)</label>
|
||||
<textarea
|
||||
id="character-creator-notes"
|
||||
placeholder="Notes from the creator..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="form-group">
|
||||
<label for="character-creator-notes">Creator Notes</label>
|
||||
<textarea
|
||||
id="character-creator-notes"
|
||||
placeholder="Notes from the creator..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="character-message" class="validation-message"></div>
|
||||
|
||||
<button type="submit" id="save-character-btn" class="btn-primary">
|
||||
Save Character
|
||||
</button>
|
||||
<button type="button" id="delete-character-btn" class="btn-danger">
|
||||
Delete Character
|
||||
</button>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button type="submit" id="save-character-btn" class="btn-primary" style="flex: 1;">
|
||||
Save Character
|
||||
</button>
|
||||
<button type="button" id="delete-character-btn" class="btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<label>Character Card Import/Export</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button type="button" id="import-character-btn" class="btn-secondary" style="flex: 1;">
|
||||
Import v2 Card
|
||||
</button>
|
||||
<button type="button" id="export-character-btn" class="btn-secondary" style="flex: 1;">
|
||||
Export v2 Card
|
||||
</button>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button type="button" id="import-character-btn" class="btn-secondary" style="flex: 1;">
|
||||
Import v2 Card
|
||||
</button>
|
||||
<button type="button" id="export-character-btn" class="btn-secondary" style="flex: 1;">
|
||||
Export v2 Card
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="appearance-tab" class="tab-content">
|
||||
<div class="settings-form">
|
||||
<div class="form-group">
|
||||
<label for="theme-select">Theme</label>
|
||||
<select id="theme-select" class="theme-select">
|
||||
<option value="dark">Dark (Default)</option>
|
||||
<option value="darker">Darker</option>
|
||||
<option value="midnight">Midnight Blue</option>
|
||||
<option value="forest">Forest</option>
|
||||
<option value="sunset">Sunset</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
<small style="color: var(--text-secondary); margin-top: 4px; display: block;">Choose your preferred color scheme</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="view-mode-select">View Mode</label>
|
||||
<select id="view-mode-select" class="view-mode-select">
|
||||
<option value="compact">Compact - Tight spacing, smaller text</option>
|
||||
<option value="cozy">Cozy - Balanced spacing (Default)</option>
|
||||
<option value="comfortable">Comfortable - Spacious layout</option>
|
||||
</select>
|
||||
<small style="color: var(--text-secondary); margin-top: 4px; display: block;">Adjust message density and spacing</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="font-size-slider">
|
||||
Font Size: <span id="font-size-value">100%</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
id="font-size-slider"
|
||||
min="80"
|
||||
max="140"
|
||||
value="100"
|
||||
step="10"
|
||||
class="font-size-slider"
|
||||
/>
|
||||
<div style="display: flex; justify-content: space-between; margin-top: 4px;">
|
||||
<small style="color: var(--text-secondary);">Small (80%)</small>
|
||||
<small style="color: var(--text-secondary);">Large (140%)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="theme-preview-container">
|
||||
<div class="theme-preview-label">Preview</div>
|
||||
<div class="theme-preview">
|
||||
<div class="theme-preview-message user-preview">
|
||||
<div class="theme-preview-content">User message</div>
|
||||
</div>
|
||||
<div class="theme-preview-message assistant-preview">
|
||||
<div class="theme-preview-avatar"></div>
|
||||
<div class="theme-preview-content">Assistant response</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="input-container">
|
||||
|
||||
522
src/main.js
522
src/main.js
@@ -14,6 +14,193 @@ let newCharacterBtn;
|
||||
let currentCharacter = null;
|
||||
let pendingAvatarPath = null;
|
||||
|
||||
// Theme definitions
|
||||
const themes = {
|
||||
dark: {
|
||||
name: 'Dark (Default)',
|
||||
bgPrimary: '#1a1a1a',
|
||||
bgSecondary: '#252525',
|
||||
bgTertiary: '#2f2f2f',
|
||||
textPrimary: '#e8e8e8',
|
||||
textSecondary: '#a0a0a0',
|
||||
accent: '#6366f1',
|
||||
accentHover: '#4f46e5',
|
||||
userMsg: '#4f46e5',
|
||||
assistantMsg: '#2f2f2f',
|
||||
border: '#3a3a3a',
|
||||
gradient: 'linear-gradient(135deg, #1a1a1a 0%, #2a1a2a 100%)',
|
||||
glow: 'rgba(99, 102, 241, 0.1)'
|
||||
},
|
||||
darker: {
|
||||
name: 'Darker',
|
||||
bgPrimary: '#0a0a0a',
|
||||
bgSecondary: '#141414',
|
||||
bgTertiary: '#1a1a1a',
|
||||
textPrimary: '#e0e0e0',
|
||||
textSecondary: '#909090',
|
||||
accent: '#7c3aed',
|
||||
accentHover: '#6d28d9',
|
||||
userMsg: '#6d28d9',
|
||||
assistantMsg: '#1a1a1a',
|
||||
border: '#2a2a2a',
|
||||
gradient: 'linear-gradient(135deg, #0a0a0a 0%, #1a0a1a 100%)',
|
||||
glow: 'rgba(124, 58, 237, 0.1)'
|
||||
},
|
||||
midnight: {
|
||||
name: 'Midnight Blue',
|
||||
bgPrimary: '#0f1419',
|
||||
bgSecondary: '#1a2332',
|
||||
bgTertiary: '#243447',
|
||||
textPrimary: '#e6f1ff',
|
||||
textSecondary: '#8892a0',
|
||||
accent: '#3b82f6',
|
||||
accentHover: '#2563eb',
|
||||
userMsg: '#1e40af',
|
||||
assistantMsg: '#243447',
|
||||
border: '#2d3e54',
|
||||
gradient: 'linear-gradient(135deg, #0f1419 0%, #1a2845 100%)',
|
||||
glow: 'rgba(59, 130, 246, 0.1)'
|
||||
},
|
||||
forest: {
|
||||
name: 'Forest',
|
||||
bgPrimary: '#0d1b14',
|
||||
bgSecondary: '#162820',
|
||||
bgTertiary: '#1f352b',
|
||||
textPrimary: '#e8f5e9',
|
||||
textSecondary: '#90a89f',
|
||||
accent: '#10b981',
|
||||
accentHover: '#059669',
|
||||
userMsg: '#047857',
|
||||
assistantMsg: '#1f352b',
|
||||
border: '#2d4a3a',
|
||||
gradient: 'linear-gradient(135deg, #0d1b14 0%, #1a2820 100%)',
|
||||
glow: 'rgba(16, 185, 129, 0.1)'
|
||||
},
|
||||
sunset: {
|
||||
name: 'Sunset',
|
||||
bgPrimary: '#1a1214',
|
||||
bgSecondary: '#261a1e',
|
||||
bgTertiary: '#332228',
|
||||
textPrimary: '#fde8e8',
|
||||
textSecondary: '#b89090',
|
||||
accent: '#f97316',
|
||||
accentHover: '#ea580c',
|
||||
userMsg: '#c2410c',
|
||||
assistantMsg: '#332228',
|
||||
border: '#4a3238',
|
||||
gradient: 'linear-gradient(135deg, #1a1214 0%, #2a1a1e 100%)',
|
||||
glow: 'rgba(249, 115, 22, 0.1)'
|
||||
},
|
||||
light: {
|
||||
name: 'Light',
|
||||
bgPrimary: '#ffffff',
|
||||
bgSecondary: '#f5f5f5',
|
||||
bgTertiary: '#e8e8e8',
|
||||
textPrimary: '#1a1a1a',
|
||||
textSecondary: '#666666',
|
||||
accent: '#6366f1',
|
||||
accentHover: '#4f46e5',
|
||||
userMsg: '#6366f1',
|
||||
assistantMsg: '#f0f0f0',
|
||||
border: '#d0d0d0',
|
||||
gradient: 'linear-gradient(135deg, #ffffff 0%, #f5f0ff 100%)',
|
||||
glow: 'rgba(99, 102, 241, 0.05)'
|
||||
}
|
||||
};
|
||||
|
||||
// Apply theme
|
||||
function applyTheme(themeName) {
|
||||
const theme = themes[themeName];
|
||||
if (!theme) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--bg-primary', theme.bgPrimary);
|
||||
root.style.setProperty('--bg-secondary', theme.bgSecondary);
|
||||
root.style.setProperty('--bg-tertiary', theme.bgTertiary);
|
||||
root.style.setProperty('--text-primary', theme.textPrimary);
|
||||
root.style.setProperty('--text-secondary', theme.textSecondary);
|
||||
root.style.setProperty('--accent', theme.accent);
|
||||
root.style.setProperty('--accent-hover', theme.accentHover);
|
||||
root.style.setProperty('--user-msg', theme.userMsg);
|
||||
root.style.setProperty('--assistant-msg', theme.assistantMsg);
|
||||
root.style.setProperty('--border', theme.border);
|
||||
|
||||
// Update gradient and glow
|
||||
const appContainer = document.querySelector('.app-container');
|
||||
if (appContainer) {
|
||||
appContainer.style.background = theme.gradient;
|
||||
const glow = appContainer.querySelector('::before');
|
||||
}
|
||||
|
||||
// Store preference
|
||||
localStorage.setItem('claudia-theme', themeName);
|
||||
}
|
||||
|
||||
// Load saved theme
|
||||
function loadSavedTheme() {
|
||||
const savedTheme = localStorage.getItem('claudia-theme') || 'dark';
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
if (themeSelect) {
|
||||
themeSelect.value = savedTheme;
|
||||
}
|
||||
applyTheme(savedTheme);
|
||||
}
|
||||
|
||||
// Apply view mode
|
||||
function applyViewMode(mode) {
|
||||
const body = document.body;
|
||||
|
||||
// Remove all view mode classes
|
||||
body.classList.remove('view-compact', 'view-cozy', 'view-comfortable');
|
||||
|
||||
// Add the selected mode
|
||||
body.classList.add(`view-${mode}`);
|
||||
|
||||
// Store preference
|
||||
localStorage.setItem('claudia-view-mode', mode);
|
||||
}
|
||||
|
||||
// Load saved view mode
|
||||
function loadSavedViewMode() {
|
||||
const savedMode = localStorage.getItem('claudia-view-mode') || 'cozy';
|
||||
const viewModeSelect = document.getElementById('view-mode-select');
|
||||
if (viewModeSelect) {
|
||||
viewModeSelect.value = savedMode;
|
||||
}
|
||||
applyViewMode(savedMode);
|
||||
}
|
||||
|
||||
// Apply font size
|
||||
function applyFontSize(scale) {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Calculate font size based on scale (80-140%)
|
||||
const baseFontSize = 14; // Default base size in px
|
||||
const newFontSize = (baseFontSize * scale) / 100;
|
||||
|
||||
root.style.setProperty('--base-font-size', `${newFontSize}px`);
|
||||
root.style.fontSize = `${newFontSize}px`;
|
||||
|
||||
// Update the display value
|
||||
const fontSizeValue = document.getElementById('font-size-value');
|
||||
if (fontSizeValue) {
|
||||
fontSizeValue.textContent = `${scale}%`;
|
||||
}
|
||||
|
||||
// Store preference
|
||||
localStorage.setItem('claudia-font-size', scale.toString());
|
||||
}
|
||||
|
||||
// Load saved font size
|
||||
function loadSavedFontSize() {
|
||||
const savedSize = parseInt(localStorage.getItem('claudia-font-size') || '100');
|
||||
const fontSizeSlider = document.getElementById('font-size-slider');
|
||||
if (fontSizeSlider) {
|
||||
fontSizeSlider.value = savedSize;
|
||||
}
|
||||
applyFontSize(savedSize);
|
||||
}
|
||||
|
||||
// Helper function to get avatar URL
|
||||
async function getAvatarUrl(avatarFilename) {
|
||||
if (!avatarFilename) return null;
|
||||
@@ -73,14 +260,82 @@ function makeAvatarClickable(avatarElement, avatarUrl) {
|
||||
});
|
||||
}
|
||||
|
||||
// Format timestamp for display
|
||||
function formatTimestamp(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
// Just now (less than 1 minute)
|
||||
if (seconds < 60) {
|
||||
return 'Just now';
|
||||
}
|
||||
|
||||
// Minutes ago (less than 1 hour)
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
|
||||
// Today (show time)
|
||||
if (days === 0) {
|
||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
}
|
||||
|
||||
// Yesterday
|
||||
if (days === 1) {
|
||||
return `Yesterday at ${date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })}`;
|
||||
}
|
||||
|
||||
// This week (show day name)
|
||||
if (days < 7) {
|
||||
return date.toLocaleDateString('en-US', { weekday: 'short', hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
}
|
||||
|
||||
// Older (show date)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
}
|
||||
|
||||
// Auto-resize textarea
|
||||
function autoResize(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
||||
}
|
||||
|
||||
// Helper function to render assistant message content with character name
|
||||
function renderAssistantContent(contentDiv, messageText) {
|
||||
// Clear existing content
|
||||
contentDiv.innerHTML = '';
|
||||
|
||||
// Add character name indicator
|
||||
if (currentCharacter && currentCharacter.name) {
|
||||
const nameIndicator = document.createElement('div');
|
||||
nameIndicator.className = 'character-name-indicator';
|
||||
nameIndicator.textContent = currentCharacter.name;
|
||||
contentDiv.appendChild(nameIndicator);
|
||||
}
|
||||
|
||||
// Add message content
|
||||
const messageContent = document.createElement('div');
|
||||
messageContent.innerHTML = marked.parse(messageText);
|
||||
contentDiv.appendChild(messageContent);
|
||||
|
||||
// Apply syntax highlighting to code blocks
|
||||
messageContent.querySelectorAll('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
addCopyButtonToCode(block);
|
||||
});
|
||||
|
||||
return messageContent;
|
||||
}
|
||||
|
||||
// Add message to chat
|
||||
function addMessage(content, isUser = false, skipActions = false) {
|
||||
function addMessage(content, isUser = false, skipActions = false, timestamp = null) {
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = `message ${isUser ? 'user' : 'assistant'}`;
|
||||
|
||||
@@ -105,12 +360,30 @@ function addMessage(content, isUser = false, skipActions = false) {
|
||||
const p = document.createElement('p');
|
||||
p.textContent = content;
|
||||
contentDiv.appendChild(p);
|
||||
|
||||
// Add timestamp if provided
|
||||
if (timestamp) {
|
||||
const timestampDiv = document.createElement('div');
|
||||
timestampDiv.className = 'message-timestamp';
|
||||
timestampDiv.textContent = formatTimestamp(timestamp);
|
||||
contentDiv.appendChild(timestampDiv);
|
||||
}
|
||||
} else {
|
||||
// Assistant messages: render as markdown
|
||||
contentDiv.innerHTML = marked.parse(content);
|
||||
// Add character name indicator if character exists
|
||||
if (currentCharacter && currentCharacter.name) {
|
||||
const nameIndicator = document.createElement('div');
|
||||
nameIndicator.className = 'character-name-indicator';
|
||||
nameIndicator.textContent = currentCharacter.name;
|
||||
contentDiv.appendChild(nameIndicator);
|
||||
}
|
||||
|
||||
const messageContent = document.createElement('div');
|
||||
messageContent.innerHTML = marked.parse(content);
|
||||
contentDiv.appendChild(messageContent);
|
||||
|
||||
// Apply syntax highlighting to code blocks
|
||||
contentDiv.querySelectorAll('pre code').forEach((block) => {
|
||||
messageContent.querySelectorAll('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
|
||||
// Add copy button to code blocks
|
||||
@@ -141,6 +414,14 @@ function addMessage(content, isUser = false, skipActions = false) {
|
||||
pre.appendChild(copyBtn);
|
||||
}
|
||||
});
|
||||
|
||||
// Add timestamp if provided
|
||||
if (timestamp) {
|
||||
const timestampDiv = document.createElement('div');
|
||||
timestampDiv.className = 'message-timestamp';
|
||||
timestampDiv.textContent = formatTimestamp(timestamp);
|
||||
contentDiv.appendChild(timestampDiv);
|
||||
}
|
||||
}
|
||||
|
||||
// Build message structure
|
||||
@@ -269,40 +550,7 @@ async function handleSwipeNavigation(messageDiv, direction) {
|
||||
const contentDiv = messageDiv.querySelector('.message-content');
|
||||
console.log('Found contentDiv:', contentDiv);
|
||||
console.log('Setting content to:', swipeInfo.content);
|
||||
contentDiv.innerHTML = marked.parse(swipeInfo.content);
|
||||
|
||||
// Apply syntax highlighting to code blocks
|
||||
contentDiv.querySelectorAll('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
|
||||
// Add copy button
|
||||
const pre = block.parentElement;
|
||||
if (!pre.querySelector('.copy-btn')) {
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'copy-btn';
|
||||
copyBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="5" y="5" width="9" height="9" stroke="currentColor" stroke-width="1.5" fill="none" rx="1"/>
|
||||
<path d="M3 11V3a1 1 0 0 1 1-1h8" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
</svg>`;
|
||||
copyBtn.title = 'Copy code';
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(block.textContent);
|
||||
copyBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M3 8l3 3 7-7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`;
|
||||
copyBtn.classList.add('copied');
|
||||
setTimeout(() => {
|
||||
copyBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="5" y="5" width="9" height="9" stroke="currentColor" stroke-width="1.5" fill="none" rx="1"/>
|
||||
<path d="M3 11V3a1 1 0 0 1 1-1h8" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
</svg>`;
|
||||
copyBtn.classList.remove('copied');
|
||||
}, 2000);
|
||||
});
|
||||
pre.style.position = 'relative';
|
||||
pre.appendChild(copyBtn);
|
||||
}
|
||||
});
|
||||
renderAssistantContent(contentDiv, swipeInfo.content);
|
||||
|
||||
// Update swipe controls
|
||||
updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total);
|
||||
@@ -408,6 +656,7 @@ async function handleEditMessage(messageDiv, originalContent) {
|
||||
async function handleRegenerateMessage(messageDiv) {
|
||||
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
|
||||
regenerateBtn.disabled = true;
|
||||
regenerateBtn.classList.add('loading');
|
||||
|
||||
try {
|
||||
// Get the last user message
|
||||
@@ -418,13 +667,14 @@ async function handleRegenerateMessage(messageDiv) {
|
||||
} catch (error) {
|
||||
console.error('Failed to regenerate message:', error);
|
||||
regenerateBtn.disabled = false;
|
||||
regenerateBtn.classList.remove('loading');
|
||||
addMessage(`Error regenerating message: ${error}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a new swipe for an existing assistant message
|
||||
async function generateSwipe(messageDiv, userMessage) {
|
||||
setStatus('Regenerating...');
|
||||
setStatus('Regenerating response...', 'default');
|
||||
|
||||
// Check if streaming is enabled
|
||||
let streamEnabled = false;
|
||||
@@ -452,32 +702,32 @@ async function generateSwipeNonStream(messageDiv, userMessage) {
|
||||
|
||||
// Update the message content
|
||||
const contentDiv = messageDiv.querySelector('.message-content');
|
||||
contentDiv.innerHTML = marked.parse(swipeInfo.content);
|
||||
|
||||
// Apply syntax highlighting
|
||||
contentDiv.querySelectorAll('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
addCopyButtonToCode(block);
|
||||
});
|
||||
renderAssistantContent(contentDiv, swipeInfo.content);
|
||||
|
||||
// Update swipe controls
|
||||
updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total);
|
||||
|
||||
setStatus('Ready');
|
||||
setStatus('Regeneration complete', 'success');
|
||||
setTimeout(() => setStatus('Ready'), 2000);
|
||||
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
|
||||
if (regenerateBtn) regenerateBtn.disabled = false;
|
||||
if (regenerateBtn) {
|
||||
regenerateBtn.disabled = false;
|
||||
regenerateBtn.classList.remove('loading');
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus('Error');
|
||||
setStatus(`Regeneration failed: ${error.substring(0, 40)}...`, 'error');
|
||||
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
|
||||
if (regenerateBtn) regenerateBtn.disabled = false;
|
||||
if (regenerateBtn) {
|
||||
regenerateBtn.disabled = false;
|
||||
regenerateBtn.classList.remove('loading');
|
||||
}
|
||||
addMessage(`Error regenerating message: ${error}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate swipe using streaming
|
||||
async function generateSwipeStream(messageDiv, userMessage) {
|
||||
setStatus('Streaming...');
|
||||
statusText.classList.add('streaming');
|
||||
setStatus('Streaming regeneration...', 'streaming');
|
||||
|
||||
let fullContent = '';
|
||||
const contentDiv = messageDiv.querySelector('.message-content');
|
||||
@@ -490,13 +740,7 @@ async function generateSwipeStream(messageDiv, userMessage) {
|
||||
fullContent += token;
|
||||
|
||||
// Update content with markdown rendering
|
||||
contentDiv.innerHTML = marked.parse(fullContent);
|
||||
|
||||
// Apply syntax highlighting
|
||||
contentDiv.querySelectorAll('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
addCopyButtonToCode(block);
|
||||
});
|
||||
renderAssistantContent(contentDiv, fullContent);
|
||||
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
});
|
||||
@@ -512,10 +756,13 @@ async function generateSwipeStream(messageDiv, userMessage) {
|
||||
console.error('Failed to add swipe:', error);
|
||||
}
|
||||
|
||||
setStatus('Ready');
|
||||
statusText.classList.remove('streaming');
|
||||
setStatus('Regeneration complete', 'success');
|
||||
setTimeout(() => setStatus('Ready'), 2000);
|
||||
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
|
||||
if (regenerateBtn) regenerateBtn.disabled = false;
|
||||
if (regenerateBtn) {
|
||||
regenerateBtn.disabled = false;
|
||||
regenerateBtn.classList.remove('loading');
|
||||
}
|
||||
tokenUnlisten();
|
||||
completeUnlisten();
|
||||
});
|
||||
@@ -525,10 +772,12 @@ async function generateSwipeStream(messageDiv, userMessage) {
|
||||
} catch (error) {
|
||||
tokenUnlisten();
|
||||
completeUnlisten();
|
||||
statusText.classList.remove('streaming');
|
||||
setStatus('Error');
|
||||
setStatus(`Regeneration failed: ${error.substring(0, 40)}...`, 'error');
|
||||
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
|
||||
if (regenerateBtn) regenerateBtn.disabled = false;
|
||||
if (regenerateBtn) {
|
||||
regenerateBtn.disabled = false;
|
||||
regenerateBtn.classList.remove('loading');
|
||||
}
|
||||
addMessage(`Error: ${error}`, false);
|
||||
}
|
||||
}
|
||||
@@ -566,12 +815,12 @@ function addCopyButtonToCode(block) {
|
||||
// Extract message sending logic into separate function
|
||||
async function sendMessage(message, isRegenerate = false) {
|
||||
if (!isRegenerate) {
|
||||
addMessage(message, true);
|
||||
addMessage(message, true, false, Date.now());
|
||||
}
|
||||
|
||||
sendBtn.disabled = true;
|
||||
messageInput.disabled = true;
|
||||
setStatus('Thinking...');
|
||||
setStatus('Connecting to API...', 'default');
|
||||
|
||||
// Check if streaming is enabled
|
||||
let streamEnabled = false;
|
||||
@@ -584,8 +833,7 @@ async function sendMessage(message, isRegenerate = false) {
|
||||
|
||||
if (streamEnabled) {
|
||||
// Use streaming
|
||||
setStatus('Streaming...');
|
||||
statusText.classList.add('streaming');
|
||||
setStatus('Streaming response...', 'streaming');
|
||||
|
||||
let streamingMessageDiv = null;
|
||||
let streamingContentDiv = null;
|
||||
@@ -635,40 +883,7 @@ async function sendMessage(message, isRegenerate = false) {
|
||||
fullContent += token;
|
||||
|
||||
// Update content with markdown rendering
|
||||
streamingContentDiv.innerHTML = marked.parse(fullContent);
|
||||
|
||||
// Apply syntax highlighting
|
||||
streamingContentDiv.querySelectorAll('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
|
||||
// Add copy button
|
||||
const pre = block.parentElement;
|
||||
if (!pre.querySelector('.copy-btn')) {
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'copy-btn';
|
||||
copyBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="5" y="5" width="9" height="9" stroke="currentColor" stroke-width="1.5" fill="none" rx="1"/>
|
||||
<path d="M3 11V3a1 1 0 0 1 1-1h8" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
</svg>`;
|
||||
copyBtn.title = 'Copy code';
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(block.textContent);
|
||||
copyBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M3 8l3 3 7-7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`;
|
||||
copyBtn.classList.add('copied');
|
||||
setTimeout(() => {
|
||||
copyBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="5" y="5" width="9" height="9" stroke="currentColor" stroke-width="1.5" fill="none" rx="1"/>
|
||||
<path d="M3 11V3a1 1 0 0 1 1-1h8" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
</svg>`;
|
||||
copyBtn.classList.remove('copied');
|
||||
}, 2000);
|
||||
});
|
||||
pre.style.position = 'relative';
|
||||
pre.appendChild(copyBtn);
|
||||
}
|
||||
});
|
||||
renderAssistantContent(streamingContentDiv, fullContent);
|
||||
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
});
|
||||
@@ -689,8 +904,8 @@ async function sendMessage(message, isRegenerate = false) {
|
||||
actionsDiv.appendChild(regenerateBtn);
|
||||
streamingMessageDiv.appendChild(actionsDiv);
|
||||
|
||||
setStatus('Ready');
|
||||
statusText.classList.remove('streaming');
|
||||
setStatus('Response complete', 'success');
|
||||
setTimeout(() => setStatus('Ready'), 2000);
|
||||
sendBtn.disabled = false;
|
||||
messageInput.disabled = false;
|
||||
messageInput.focus();
|
||||
@@ -703,17 +918,17 @@ async function sendMessage(message, isRegenerate = false) {
|
||||
} catch (error) {
|
||||
tokenUnlisten();
|
||||
completeUnlisten();
|
||||
statusText.classList.remove('streaming');
|
||||
if (streamingMessageDiv) {
|
||||
streamingMessageDiv.remove();
|
||||
}
|
||||
if (error.includes('not configured')) {
|
||||
addMessage('API not configured. Please configure your API settings.', false);
|
||||
setStatus('API not configured', 'error');
|
||||
setTimeout(showSettings, 1000);
|
||||
} else {
|
||||
addMessage(`Error: ${error}`, false);
|
||||
setStatus(`Error: ${error.substring(0, 50)}...`, 'error');
|
||||
}
|
||||
setStatus('Error');
|
||||
sendBtn.disabled = false;
|
||||
messageInput.disabled = false;
|
||||
messageInput.focus();
|
||||
@@ -726,16 +941,18 @@ async function sendMessage(message, isRegenerate = false) {
|
||||
const response = await invoke('chat', { message });
|
||||
removeTypingIndicator();
|
||||
addMessage(response, false);
|
||||
setStatus('Ready');
|
||||
setStatus('Response complete', 'success');
|
||||
setTimeout(() => setStatus('Ready'), 2000);
|
||||
} catch (error) {
|
||||
removeTypingIndicator();
|
||||
if (error.includes('not configured')) {
|
||||
addMessage('API not configured. Please configure your API settings.', false);
|
||||
setStatus('API not configured', 'error');
|
||||
setTimeout(showSettings, 1000);
|
||||
} else {
|
||||
addMessage(`Error: ${error}`, false);
|
||||
setStatus(`Error: ${error.substring(0, 50)}...`, 'error');
|
||||
}
|
||||
setStatus('Error');
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
messageInput.disabled = false;
|
||||
@@ -772,21 +989,35 @@ function removeTypingIndicator() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update status
|
||||
function setStatus(text) {
|
||||
// Update status with optional styling
|
||||
function setStatus(text, type = 'default') {
|
||||
statusText.textContent = text;
|
||||
|
||||
// Remove all status classes
|
||||
statusText.classList.remove('streaming', 'error', 'success');
|
||||
|
||||
// Add appropriate class based on type
|
||||
if (type === 'streaming') {
|
||||
statusText.classList.add('streaming');
|
||||
} else if (type === 'error') {
|
||||
statusText.classList.add('error');
|
||||
} else if (type === 'success') {
|
||||
statusText.classList.add('success');
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide settings
|
||||
async function showSettings() {
|
||||
settingsPanel.style.display = 'block';
|
||||
chatView.style.display = 'none';
|
||||
const overlay = document.getElementById('settings-overlay');
|
||||
settingsPanel.classList.add('open');
|
||||
overlay.classList.add('show');
|
||||
await loadCharacterSettings();
|
||||
}
|
||||
|
||||
function hideSettings() {
|
||||
settingsPanel.style.display = 'none';
|
||||
chatView.style.display = 'flex';
|
||||
const overlay = document.getElementById('settings-overlay');
|
||||
settingsPanel.classList.remove('open');
|
||||
overlay.classList.remove('show');
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
@@ -839,14 +1070,18 @@ async function handleValidate() {
|
||||
}
|
||||
|
||||
validateBtn.disabled = true;
|
||||
validateBtn.classList.add('loading');
|
||||
validateBtn.textContent = 'Validating...';
|
||||
validationMsg.style.display = 'none';
|
||||
setStatus('Validating API...', 'default');
|
||||
|
||||
try {
|
||||
const models = await invoke('validate_api', { baseUrl, apiKey });
|
||||
|
||||
validationMsg.textContent = `Found ${models.length} models`;
|
||||
validationMsg.className = 'validation-message success';
|
||||
setStatus('API validated successfully', 'success');
|
||||
setTimeout(() => setStatus('Ready'), 2000);
|
||||
|
||||
modelSelect.innerHTML = '<option value="">Select a model</option>';
|
||||
models.forEach(model => {
|
||||
@@ -857,14 +1092,17 @@ async function handleValidate() {
|
||||
});
|
||||
|
||||
modelsGroup.style.display = 'flex';
|
||||
modelsGroup.classList.add('fade-in');
|
||||
saveBtn.disabled = false;
|
||||
} catch (error) {
|
||||
validationMsg.textContent = `Validation failed: ${error}`;
|
||||
validationMsg.className = 'validation-message error';
|
||||
setStatus('API validation failed', 'error');
|
||||
modelsGroup.style.display = 'none';
|
||||
saveBtn.disabled = true;
|
||||
} finally {
|
||||
validateBtn.disabled = false;
|
||||
validateBtn.classList.remove('loading');
|
||||
validateBtn.textContent = 'Validate';
|
||||
}
|
||||
}
|
||||
@@ -886,23 +1124,29 @@ async function handleSaveSettings(e) {
|
||||
}
|
||||
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.classList.add('loading');
|
||||
saveBtn.textContent = 'Saving...';
|
||||
setStatus('Saving configuration...', 'default');
|
||||
|
||||
try {
|
||||
await invoke('save_api_config', { baseUrl, apiKey, model, stream });
|
||||
validationMsg.textContent = 'Configuration saved successfully';
|
||||
validationMsg.className = 'validation-message success';
|
||||
setStatus('Configuration saved', 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
hideSettings();
|
||||
messagesContainer.innerHTML = '';
|
||||
addMessage('API configured. Ready to chat.', false, true);
|
||||
setStatus('Ready');
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
validationMsg.textContent = `Failed to save: ${error}`;
|
||||
validationMsg.className = 'validation-message error';
|
||||
setStatus('Failed to save configuration', 'error');
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.classList.remove('loading');
|
||||
saveBtn.textContent = 'Save Configuration';
|
||||
}
|
||||
}
|
||||
@@ -960,6 +1204,7 @@ function handleAvatarRemove() {
|
||||
function setupAppControls() {
|
||||
document.getElementById('settings-btn').addEventListener('click', showSettings);
|
||||
document.getElementById('close-settings-btn').addEventListener('click', hideSettings);
|
||||
document.getElementById('settings-overlay').addEventListener('click', hideSettings);
|
||||
document.getElementById('clear-btn').addEventListener('click', clearHistory);
|
||||
characterSelect.addEventListener('change', handleCharacterSwitch);
|
||||
newCharacterBtn.addEventListener('click', handleNewCharacter);
|
||||
@@ -973,6 +1218,38 @@ function setupAppControls() {
|
||||
document.getElementById('remove-avatar-btn').addEventListener('click', handleAvatarRemove);
|
||||
document.getElementById('import-character-btn').addEventListener('click', handleImportCharacter);
|
||||
document.getElementById('export-character-btn').addEventListener('click', handleExportCharacter);
|
||||
|
||||
// Setup collapsible sections
|
||||
document.querySelectorAll('.settings-section-header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const section = header.parentElement;
|
||||
section.classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
|
||||
// Setup theme selector
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
if (themeSelect) {
|
||||
themeSelect.addEventListener('change', (e) => {
|
||||
applyTheme(e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Setup view mode selector
|
||||
const viewModeSelect = document.getElementById('view-mode-select');
|
||||
if (viewModeSelect) {
|
||||
viewModeSelect.addEventListener('change', (e) => {
|
||||
applyViewMode(e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Setup font size slider
|
||||
const fontSizeSlider = document.getElementById('font-size-slider');
|
||||
if (fontSizeSlider) {
|
||||
fontSizeSlider.addEventListener('input', (e) => {
|
||||
applyFontSize(parseInt(e.target.value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
@@ -1032,12 +1309,16 @@ async function loadCharacters() {
|
||||
// Handle character switching
|
||||
async function handleCharacterSwitch() {
|
||||
const characterId = characterSelect.value;
|
||||
setStatus('Switching character...', 'default');
|
||||
try {
|
||||
await invoke('set_active_character', { characterId });
|
||||
messagesContainer.innerHTML = '';
|
||||
await loadCharacters();
|
||||
setStatus('Character switched', 'success');
|
||||
setTimeout(() => setStatus('Ready'), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to switch character:', error);
|
||||
setStatus('Failed to switch character', 'error');
|
||||
addMessage(`Failed to switch character: ${error}`, false);
|
||||
}
|
||||
}
|
||||
@@ -1138,7 +1419,7 @@ async function loadChatHistory() {
|
||||
}
|
||||
} else {
|
||||
history.forEach((msg, index) => {
|
||||
const messageDiv = addMessage(msg.content, msg.role === 'user');
|
||||
const messageDiv = addMessage(msg.content, msg.role === 'user', false, msg.timestamp);
|
||||
|
||||
// Update swipe controls for assistant messages with swipe info
|
||||
if (msg.role === 'assistant' && messageDiv && msg.swipes && msg.swipes.length > 0) {
|
||||
@@ -1159,6 +1440,7 @@ async function clearHistory() {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Clearing history...', 'default');
|
||||
try {
|
||||
await invoke('clear_chat_history');
|
||||
messagesContainer.innerHTML = '';
|
||||
@@ -1167,7 +1449,10 @@ async function clearHistory() {
|
||||
} else {
|
||||
addMessage('Conversation cleared. Ready to chat.', false, true);
|
||||
}
|
||||
setStatus('History cleared', 'success');
|
||||
setTimeout(() => setStatus('Ready'), 2000);
|
||||
} catch (error) {
|
||||
setStatus('Failed to clear history', 'error');
|
||||
addMessage(`Failed to clear history: ${error}`, false);
|
||||
}
|
||||
}
|
||||
@@ -1252,6 +1537,7 @@ async function handleSaveCharacter(e) {
|
||||
}
|
||||
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.classList.add('loading');
|
||||
saveBtn.textContent = 'Saving...';
|
||||
|
||||
try {
|
||||
@@ -1284,6 +1570,7 @@ async function handleSaveCharacter(e) {
|
||||
characterMsg.className = 'validation-message error';
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.classList.remove('loading');
|
||||
saveBtn.textContent = 'Save Character';
|
||||
}
|
||||
}
|
||||
@@ -1357,5 +1644,10 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
messageInput.focus();
|
||||
setStatus('Ready');
|
||||
|
||||
// Load saved preferences before anything else
|
||||
loadSavedTheme();
|
||||
loadSavedViewMode();
|
||||
loadSavedFontSize();
|
||||
|
||||
loadExistingConfig();
|
||||
});
|
||||
|
||||
588
src/styles.css
588
src/styles.css
@@ -86,6 +86,7 @@ body {
|
||||
border: 1px solid var(--border);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-circle-large {
|
||||
@@ -245,6 +246,19 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -287,6 +301,34 @@ body {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Message timestamp */
|
||||
.message-timestamp {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.6;
|
||||
margin-top: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.message.user .message-timestamp {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message.assistant .message-timestamp {
|
||||
text-align: left;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Character name indicator */
|
||||
.character-name-indicator {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin-bottom: 4px;
|
||||
opacity: 0.9;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Message action buttons */
|
||||
.message-actions {
|
||||
position: absolute;
|
||||
@@ -703,6 +745,7 @@ body {
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.status-text.streaming {
|
||||
@@ -710,6 +753,14 @@ body {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-text.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.status-text.success {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
@@ -719,49 +770,210 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Light mode support */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f5f5f5;
|
||||
--bg-tertiary: #e8e8e8;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #666666;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #4f46e5;
|
||||
--user-msg: #6366f1;
|
||||
--assistant-msg: #f5f5f5;
|
||||
--border: #e0e0e0;
|
||||
--shadow: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.message.assistant .message-content {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
/* Loading spinner */
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: currentColor;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Settings Panel */
|
||||
.settings-panel {
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Button loading state */
|
||||
.btn-primary.loading,
|
||||
.btn-secondary.loading,
|
||||
.message-action-btn.loading {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn-primary.loading::after,
|
||||
.btn-secondary.loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Theme System */
|
||||
/* Dark theme (default) */
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #252525;
|
||||
--bg-tertiary: #2f2f2f;
|
||||
--text-primary: #e8e8e8;
|
||||
--text-secondary: #a0a0a0;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #4f46e5;
|
||||
--user-msg: #4f46e5;
|
||||
--assistant-msg: #2f2f2f;
|
||||
--border: #3a3a3a;
|
||||
--shadow: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f5f5f5;
|
||||
--bg-tertiary: #e8e8e8;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #666666;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #4f46e5;
|
||||
--user-msg: #6366f1;
|
||||
--assistant-msg: #f5f5f5;
|
||||
--border: #e0e0e0;
|
||||
--shadow: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="light"] .app-container {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f5f5f5 100%);
|
||||
}
|
||||
|
||||
[data-theme="light"] .app-container::before {
|
||||
background: radial-gradient(circle at top center, rgba(99, 102, 241, 0.05) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
[data-theme="light"] .message.assistant .message-content {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Abyss theme - Deep purple/blue */
|
||||
[data-theme="abyss"] {
|
||||
--bg-primary: #0f0f1e;
|
||||
--bg-secondary: #1a1a2e;
|
||||
--bg-tertiary: #252538;
|
||||
--text-primary: #e0e0ff;
|
||||
--text-secondary: #8888bb;
|
||||
--accent: #9d4edd;
|
||||
--accent-hover: #7b2cbf;
|
||||
--user-msg: #7b2cbf;
|
||||
--assistant-msg: #252538;
|
||||
--border: #2f2f48;
|
||||
--shadow: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
[data-theme="abyss"] .app-container {
|
||||
background: linear-gradient(135deg, #0f0f1e 0%, #1a1033 100%);
|
||||
}
|
||||
|
||||
[data-theme="abyss"] .app-container::before {
|
||||
background: radial-gradient(circle at top center, rgba(157, 78, 221, 0.15) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
/* Nord theme - Cool blues and grays */
|
||||
[data-theme="nord"] {
|
||||
--bg-primary: #2e3440;
|
||||
--bg-secondary: #3b4252;
|
||||
--bg-tertiary: #434c5e;
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary: #d8dee9;
|
||||
--accent: #88c0d0;
|
||||
--accent-hover: #81a1c1;
|
||||
--user-msg: #5e81ac;
|
||||
--assistant-msg: #434c5e;
|
||||
--border: #4c566a;
|
||||
--shadow: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="nord"] .app-container {
|
||||
background: linear-gradient(135deg, #2e3440 0%, #3b4252 100%);
|
||||
}
|
||||
|
||||
[data-theme="nord"] .app-container::before {
|
||||
background: radial-gradient(circle at top center, rgba(136, 192, 208, 0.1) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
/* Mocha theme - Warm browns */
|
||||
[data-theme="mocha"] {
|
||||
--bg-primary: #1e1e2e;
|
||||
--bg-secondary: #2a2837;
|
||||
--bg-tertiary: #3a3850;
|
||||
--text-primary: #cdd6f4;
|
||||
--text-secondary: #bac2de;
|
||||
--accent: #f5c2e7;
|
||||
--accent-hover: #cba6f7;
|
||||
--user-msg: #cba6f7;
|
||||
--assistant-msg: #3a3850;
|
||||
--border: #45475a;
|
||||
--shadow: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
[data-theme="mocha"] .app-container {
|
||||
background: linear-gradient(135deg, #1e1e2e 0%, #2a2440 100%);
|
||||
}
|
||||
|
||||
[data-theme="mocha"] .app-container::before {
|
||||
background: radial-gradient(circle at top center, rgba(245, 194, 231, 0.1) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
/* Settings Panel - Slide-in Sidebar */
|
||||
.settings-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -500px;
|
||||
width: 500px;
|
||||
height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
transition: right 0.3s ease;
|
||||
}
|
||||
|
||||
.settings-panel.open {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Settings overlay backdrop */
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-primary);
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
padding: 60px 20px 20px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.settings-overlay.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
font-size: 20px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
@@ -771,9 +983,6 @@ body {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
@@ -810,13 +1019,68 @@ body {
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Collapsible sections */
|
||||
.settings-section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.settings-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-section-header:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.settings-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-section-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-section.collapsed .settings-section-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.settings-section-content {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 2000px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease, padding 0.3s ease;
|
||||
}
|
||||
|
||||
.settings-section.collapsed .settings-section-content {
|
||||
max-height: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -988,6 +1252,259 @@ body {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Theme Preview */
|
||||
.theme-preview-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.theme-preview-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.theme-preview-message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.theme-preview-message.user-preview {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.theme-preview-message.assistant-preview {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.theme-preview-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.theme-preview-content {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.theme-preview-message.user-preview .theme-preview-content {
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.theme-preview-message.assistant-preview .theme-preview-content {
|
||||
background: var(--assistant-msg);
|
||||
color: var(--text-primary);
|
||||
border-bottom-left-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.theme-select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Font size slider */
|
||||
.font-size-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.font-size-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.font-size-slider::-webkit-slider-thumb:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.font-size-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.font-size-slider::-moz-range-thumb:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.font-size-slider::-moz-range-track {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
#font-size-value {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* View Mode Styles */
|
||||
|
||||
/* Compact Mode */
|
||||
body.view-compact .messages-list {
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
body.view-compact .message-content {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
body.view-compact .avatar-circle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
body.view-compact .character-name-indicator {
|
||||
font-size: 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
body.view-compact .message-timestamp {
|
||||
font-size: 9px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
body.view-compact .swipe-controls {
|
||||
margin-top: 6px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
body.view-compact .message-content h1 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
body.view-compact .message-content h2 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
body.view-compact .message-content h3 {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
body.view-compact .message-content code {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Cozy Mode (Default) - Already defined in base styles */
|
||||
body.view-cozy .messages-list {
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Comfortable Mode */
|
||||
body.view-comfortable .messages-list {
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
body.view-comfortable .message-content {
|
||||
padding: 16px 20px;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
body.view-comfortable .avatar-circle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
body.view-comfortable .character-name-indicator {
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
body.view-comfortable .message-timestamp {
|
||||
font-size: 11px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
body.view-comfortable .swipe-controls {
|
||||
margin-top: 10px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
body.view-comfortable .message-content h1 {
|
||||
font-size: 1.6em;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
body.view-comfortable .message-content h2 {
|
||||
font-size: 1.4em;
|
||||
margin-top: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
body.view-comfortable .message-content h3 {
|
||||
font-size: 1.2em;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
body.view-comfortable .message-content p + p {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
body.view-comfortable .message-content code {
|
||||
font-size: 0.95em;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
body.view-comfortable .message-content pre {
|
||||
padding: 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.messages-list {
|
||||
@@ -1003,6 +1520,11 @@ body {
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
padding: 60px 16px 16px;
|
||||
width: 100%;
|
||||
right: -100%;
|
||||
}
|
||||
|
||||
.settings-panel.open {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user