Compare commits

..

10 Commits

Author SHA1 Message Date
84d3e0df67 feat: add font size customization with slider control
Implemented global font size scaling with range slider:
- Adjustable from 80% to 140% in 10% increments
- Slider in Appearance tab with live value display
- Persistent preference saved to localStorage
- Updates CSS custom property --base-font-size
- Scales entire UI proportionally from 11.2px to 19.6px
- Smooth transitions with styled slider thumb
- Live preview as you drag the slider

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:34:09 -07:00
30e6af61ca feat: add view mode toggle with compact/cozy/comfortable options
Implemented message density control with three view modes:
- Compact: Tight spacing (10px gaps), smaller text (13px), small avatars (24px)
- Cozy: Balanced spacing (16px gaps), default text (14px) - default mode
- Comfortable: Spacious layout (24px gaps), larger text (15px), larger avatars (32px)

Features:
- View mode selector in Appearance tab
- Persistent preference saved to localStorage
- Dynamic CSS classes applied to body element
- Adjusts message padding, gaps, font sizes, avatar sizes
- Scales headings, code blocks, and all UI elements proportionally

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:32:07 -07:00
32fc57fab0 feat: add theme customization with 6 color schemes
Added comprehensive theme system with:
- 6 themes: Dark (default), Darker, Midnight Blue, Forest, Sunset, Light
- New Appearance tab in settings with theme selector
- Live theme preview showing user/assistant message styles
- Theme persistence using localStorage
- Dynamic CSS variable updates for instant theme switching
- Each theme includes custom gradients, accent colors, and text colors

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:30:06 -07:00
efa3ccbd26 feat: redesign settings as slide-in sidebar with collapsible sections
- Changed from full-screen overlay to 500px slide-in sidebar from right
- Added dark overlay backdrop that dims chat
- Organized character settings into collapsible sections:
  * Basic Information (name, avatar, system prompt, greeting)
  * Roleplay Details (personality, description, scenario, examples)
  * Advanced Settings (post-history, alternate greetings)
  * Metadata (tags, creator, version, notes)
- Smooth slide and collapse animations
- Click overlay to close sidebar
- Responsive: full width on mobile
- More compact button layouts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:14:51 -07:00
b9ea771ff0 feat: add better loading states and animations
- Added spinning loading indicators on all buttons
- Fade-in animations for newly revealed elements
- Loading states with visual feedback (opacity, cursor)
- Smooth CSS animations for spinners and fades
- Loading indicators on: API validation, save operations,
  regenerate buttons, and character operations
- Improved UX with clear visual feedback during async operations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:11:11 -07:00
4866c11245 feat: improve status feedback with detailed messages and colors
- Added color-coded status indicators (success, error, streaming)
- Detailed status messages for all operations
- Success messages auto-reset to "Ready" after 2 seconds
- Error messages show context for debugging
- Status feedback for: API calls, character switching, history clearing,
  configuration saving, message generation, and regeneration
- Smooth color transitions for better UX

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:09:57 -07:00
e0239aceda feat: add character name indicators to assistant messages
- Character names display in accent color above all assistant messages
- Created renderAssistantContent() helper for consistent rendering
- Character names show on new messages, history, swipes, and regenerations
- Styled with small, bold text for clear attribution
- Enhances multi-character roleplay clarity

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:08:39 -07:00
4694114ff9 feat: add message timestamps with smart formatting
- Added timestamp field to Message struct in Rust backend
- Timestamps automatically captured on message creation
- Smart relative time formatting (Just now, Xm ago, time, date)
- Timestamps display below message content with subtle styling
- Fixed avatar squishing issue with flex-shrink: 0
- Backward compatible with existing messages via serde(default)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:07:19 -07:00
ab6ae14bbc docs: note current focus on UI/UX improvements before roleplay features 2025-10-14 09:01:58 -07:00
a9b686f0d1 docs: add comprehensive roleplay enhancement roadmap
Created ROADMAP.md with detailed plan to transform Claudia into a full-featured
roleplay platform comparable to SillyTavern. Based on extensive research of
SillyTavern's features and roleplay community needs.

Key planned features organized in 7 phases:
- Phase 1: World Info/Lorebooks, Author's Note, Jailbreak Templates
- Phase 2: User Personas, Character Expressions, Message Examples
- Phase 3: Chat Branching, Enhanced Message Controls, Timeline Viz
- Phase 4: Group Chats with multi-character support
- Phase 5: Token Counter, Context Templates, Smart Management
- Phase 6: Quick Replies, Macros, Regex Scripts, Hotkeys
- Phase 7: Polish and UX improvements

Updated README.md to:
- Reflect current roleplay-focused vision
- Document all implemented features
- Reference the detailed roadmap
- Add version milestones (v0.1.0 → v0.2.0)

Next target: v0.2.0 "Roleplay Foundation" with World Info, Author's Note,
and Token Counter as the core infrastructure for quality roleplay.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:56:55 -07:00
6 changed files with 1635 additions and 308 deletions

View File

@@ -1,28 +1,37 @@
# Claudia # 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 ## Features
### Core Features ### Core Chat Features
- 🎨 **Beautiful glassmorphic UI** with gradient backgrounds and blur effects - 🎨 **Beautiful glassmorphic UI** - Modern design with gradient backgrounds
- 🔧 **Bring-your-own-API** - supports any Anthropic-compatible API - 🔧 **Bring-your-own-API** - Supports any Anthropic-compatible API
-**API validation** via /v1/models endpoint -**API validation** - Automatic model detection via /v1/models
- 💬 **Full conversation context** - AI remembers your entire conversation - 💬 **Full conversation context** - AI remembers your entire conversation
- 💾 **Persistent chat history** - conversations saved between sessions - 💾 **Persistent chat history** - Conversations saved per character
- 🎯 **Custom window controls** - drag, minimize, maximize, close - 🎯 **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 ### Message Display
- 📝 **Full markdown rendering** - headers, lists, tables, links, blockquotes - 📝 **Full markdown rendering** - Headers, lists, tables, links, blockquotes
- 🎨 **Syntax highlighting** - beautiful code blocks with highlight.js - 🎨 **Syntax highlighting** - Beautiful code blocks with highlight.js
- 📋 **Copy code blocks** - one-click copy button on hover - 📋 **Copy code blocks** - One-click copy button on hover
-**Smooth animations** - elegant message transitions -**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
## Running ## Running
@@ -53,9 +62,37 @@ On first launch, click settings and configure:
### Keyboard Shortcuts ### Keyboard Shortcuts
- **Enter** - Send message - **Enter** - Send message
- **Shift+Enter** - New line in 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 ### Interface
- **Drag header** - Move window around your desktop - **Drag header** - Move window around your desktop
- **Trash icon** - Clear conversation history - **Trash icon** - Clear conversation history
- **Settings icon** - Configure API settings - **Settings icon** - Configure API settings
- **Minimize/Maximize** - Window controls - **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
View 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

View File

@@ -174,24 +174,38 @@ struct Message {
swipes: Vec<String>, swipes: Vec<String>,
#[serde(default)] #[serde(default)]
current_swipe: usize, current_swipe: usize,
#[serde(default)]
timestamp: i64, // Unix timestamp in milliseconds
} }
impl Message { impl Message {
fn new_user(content: String) -> Self { fn new_user(content: String) -> Self {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
Self { Self {
role: "user".to_string(), role: "user".to_string(),
content: content.clone(), content: content.clone(),
swipes: vec![content], swipes: vec![content],
current_swipe: 0, current_swipe: 0,
timestamp,
} }
} }
fn new_assistant(content: String) -> Self { fn new_assistant(content: String) -> Self {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
Self { Self {
role: "assistant".to_string(), role: "assistant".to_string(),
content: content.clone(), content: content.clone(),
swipes: vec![content], swipes: vec![content],
current_swipe: 0, current_swipe: 0,
timestamp,
} }
} }

View File

@@ -55,7 +55,10 @@
</div> </div>
</main> </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"> <div class="settings-header">
<h2>Settings</h2> <h2>Settings</h2>
<button id="close-settings-btn" class="icon-btn"> <button id="close-settings-btn" class="icon-btn">
@@ -69,6 +72,7 @@
<div class="settings-tabs"> <div class="settings-tabs">
<button class="tab-btn active" data-tab="api">API</button> <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="character">Character</button>
<button class="tab-btn" data-tab="appearance">Appearance</button>
</div> </div>
<div id="api-tab" class="tab-content active"> <div id="api-tab" class="tab-content active">
@@ -125,168 +129,279 @@
<label for="character-settings-select">Select Character</label> <label for="character-settings-select">Select Character</label>
<select id="character-settings-select"></select> <select id="character-settings-select"></select>
</div> </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"> <!-- Basic Info Section -->
<label for="character-avatar">Avatar (Optional)</label> <div class="settings-section" data-section="basic">
<div class="avatar-upload"> <div class="settings-section-header">
<div id="avatar-preview" class="avatar-preview"> <div class="settings-section-title">
<div class="avatar-circle-large"></div> <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> </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> </div>
<div class="form-group"> <!-- Roleplay Details Section -->
<label for="character-system-prompt">System Prompt</label> <div class="settings-section collapsed" data-section="roleplay">
<textarea <div class="settings-section-header">
id="character-system-prompt" <div class="settings-section-title">
placeholder="You are a helpful AI assistant..." <svg class="settings-section-icon" viewBox="0 0 16 16" fill="none">
rows="6" <path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
required </svg>
></textarea> 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>
<div class="form-group"> <!-- Advanced Settings Section -->
<label for="character-greeting">Greeting (Optional)</label> <div class="settings-section collapsed" data-section="advanced">
<textarea <div class="settings-section-header">
id="character-greeting" <div class="settings-section-title">
placeholder="Hello! How can I help you today?" <svg class="settings-section-icon" viewBox="0 0 16 16" fill="none">
rows="2" <path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
></textarea> </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>
<div class="form-group"> <!-- Metadata Section -->
<label for="character-personality">Personality Tags (Optional)</label> <div class="settings-section collapsed" data-section="metadata">
<input <div class="settings-section-header">
type="text" <div class="settings-section-title">
id="character-personality" <svg class="settings-section-icon" viewBox="0 0 16 16" fill="none">
placeholder="helpful, friendly, knowledgeable" <path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
/> </svg>
</div> 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"> <div class="form-group">
<label for="character-description">Description (Optional)</label> <label for="character-creator">Creator</label>
<textarea <input
id="character-description" type="text"
placeholder="Detailed character description, appearance, background..." id="character-creator"
rows="10" placeholder="Card creator name"
></textarea> />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="character-scenario">Scenario (Optional)</label> <label for="character-version">Character Version</label>
<textarea <input
id="character-scenario" type="text"
placeholder="The setting or situation where the character exists..." id="character-version"
rows="4" placeholder="1.0"
></textarea> />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="character-mes-example">Message Example (Optional)</label> <label for="character-creator-notes">Creator Notes</label>
<textarea <textarea
id="character-mes-example" id="character-creator-notes"
placeholder="Example dialogue from the character..." placeholder="Notes from the creator..."
rows="4" rows="2"
></textarea> ></textarea>
</div> </div>
</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> </div>
<div id="character-message" class="validation-message"></div> <div id="character-message" class="validation-message"></div>
<button type="submit" id="save-character-btn" class="btn-primary"> <div style="display: flex; gap: 8px;">
Save Character <button type="submit" id="save-character-btn" class="btn-primary" style="flex: 1;">
</button> Save Character
<button type="button" id="delete-character-btn" class="btn-danger"> </button>
Delete Character <button type="button" id="delete-character-btn" class="btn-danger">
</button> Delete
</button>
</div>
<div class="form-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border-color);"> <div style="display: flex; gap: 8px;">
<label>Character Card Import/Export</label> <button type="button" id="import-character-btn" class="btn-secondary" style="flex: 1;">
<div style="display: flex; gap: 0.5rem;"> Import v2 Card
<button type="button" id="import-character-btn" class="btn-secondary" style="flex: 1;"> </button>
Import v2 Card <button type="button" id="export-character-btn" class="btn-secondary" style="flex: 1;">
</button> Export v2 Card
<button type="button" id="export-character-btn" class="btn-secondary" style="flex: 1;"> </button>
Export v2 Card
</button>
</div>
</div> </div>
</form> </form>
</div> </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> </div>
<footer class="input-container"> <footer class="input-container">

View File

@@ -14,6 +14,193 @@ let newCharacterBtn;
let currentCharacter = null; let currentCharacter = null;
let pendingAvatarPath = 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 // Helper function to get avatar URL
async function getAvatarUrl(avatarFilename) { async function getAvatarUrl(avatarFilename) {
if (!avatarFilename) return null; 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 // Auto-resize textarea
function autoResize(textarea) { function autoResize(textarea) {
textarea.style.height = 'auto'; textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; 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 // 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'); const messageDiv = document.createElement('div');
messageDiv.className = `message ${isUser ? 'user' : 'assistant'}`; messageDiv.className = `message ${isUser ? 'user' : 'assistant'}`;
@@ -105,12 +360,30 @@ function addMessage(content, isUser = false, skipActions = false) {
const p = document.createElement('p'); const p = document.createElement('p');
p.textContent = content; p.textContent = content;
contentDiv.appendChild(p); 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 { } else {
// Assistant messages: render as markdown // 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 // Apply syntax highlighting to code blocks
contentDiv.querySelectorAll('pre code').forEach((block) => { messageContent.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block); hljs.highlightElement(block);
// Add copy button to code blocks // Add copy button to code blocks
@@ -141,6 +414,14 @@ function addMessage(content, isUser = false, skipActions = false) {
pre.appendChild(copyBtn); 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 // Build message structure
@@ -269,40 +550,7 @@ async function handleSwipeNavigation(messageDiv, direction) {
const contentDiv = messageDiv.querySelector('.message-content'); const contentDiv = messageDiv.querySelector('.message-content');
console.log('Found contentDiv:', contentDiv); console.log('Found contentDiv:', contentDiv);
console.log('Setting content to:', swipeInfo.content); console.log('Setting content to:', swipeInfo.content);
contentDiv.innerHTML = marked.parse(swipeInfo.content); renderAssistantContent(contentDiv, 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);
}
});
// Update swipe controls // Update swipe controls
updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total); updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total);
@@ -408,6 +656,7 @@ async function handleEditMessage(messageDiv, originalContent) {
async function handleRegenerateMessage(messageDiv) { async function handleRegenerateMessage(messageDiv) {
const regenerateBtn = messageDiv.querySelector('.message-action-btn'); const regenerateBtn = messageDiv.querySelector('.message-action-btn');
regenerateBtn.disabled = true; regenerateBtn.disabled = true;
regenerateBtn.classList.add('loading');
try { try {
// Get the last user message // Get the last user message
@@ -418,13 +667,14 @@ async function handleRegenerateMessage(messageDiv) {
} catch (error) { } catch (error) {
console.error('Failed to regenerate message:', error); console.error('Failed to regenerate message:', error);
regenerateBtn.disabled = false; regenerateBtn.disabled = false;
regenerateBtn.classList.remove('loading');
addMessage(`Error regenerating message: ${error}`, false); addMessage(`Error regenerating message: ${error}`, false);
} }
} }
// Generate a new swipe for an existing assistant message // Generate a new swipe for an existing assistant message
async function generateSwipe(messageDiv, userMessage) { async function generateSwipe(messageDiv, userMessage) {
setStatus('Regenerating...'); setStatus('Regenerating response...', 'default');
// Check if streaming is enabled // Check if streaming is enabled
let streamEnabled = false; let streamEnabled = false;
@@ -452,32 +702,32 @@ async function generateSwipeNonStream(messageDiv, userMessage) {
// Update the message content // Update the message content
const contentDiv = messageDiv.querySelector('.message-content'); const contentDiv = messageDiv.querySelector('.message-content');
contentDiv.innerHTML = marked.parse(swipeInfo.content); renderAssistantContent(contentDiv, swipeInfo.content);
// Apply syntax highlighting
contentDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
addCopyButtonToCode(block);
});
// Update swipe controls // Update swipe controls
updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total); updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total);
setStatus('Ready'); setStatus('Regeneration complete', 'success');
setTimeout(() => setStatus('Ready'), 2000);
const regenerateBtn = messageDiv.querySelector('.message-action-btn'); const regenerateBtn = messageDiv.querySelector('.message-action-btn');
if (regenerateBtn) regenerateBtn.disabled = false; if (regenerateBtn) {
regenerateBtn.disabled = false;
regenerateBtn.classList.remove('loading');
}
} catch (error) { } catch (error) {
setStatus('Error'); setStatus(`Regeneration failed: ${error.substring(0, 40)}...`, 'error');
const regenerateBtn = messageDiv.querySelector('.message-action-btn'); 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); addMessage(`Error regenerating message: ${error}`, false);
} }
} }
// Generate swipe using streaming // Generate swipe using streaming
async function generateSwipeStream(messageDiv, userMessage) { async function generateSwipeStream(messageDiv, userMessage) {
setStatus('Streaming...'); setStatus('Streaming regeneration...', 'streaming');
statusText.classList.add('streaming');
let fullContent = ''; let fullContent = '';
const contentDiv = messageDiv.querySelector('.message-content'); const contentDiv = messageDiv.querySelector('.message-content');
@@ -490,13 +740,7 @@ async function generateSwipeStream(messageDiv, userMessage) {
fullContent += token; fullContent += token;
// Update content with markdown rendering // Update content with markdown rendering
contentDiv.innerHTML = marked.parse(fullContent); renderAssistantContent(contentDiv, fullContent);
// Apply syntax highlighting
contentDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
addCopyButtonToCode(block);
});
messagesContainer.scrollTop = messagesContainer.scrollHeight; messagesContainer.scrollTop = messagesContainer.scrollHeight;
}); });
@@ -512,10 +756,13 @@ async function generateSwipeStream(messageDiv, userMessage) {
console.error('Failed to add swipe:', error); console.error('Failed to add swipe:', error);
} }
setStatus('Ready'); setStatus('Regeneration complete', 'success');
statusText.classList.remove('streaming'); setTimeout(() => setStatus('Ready'), 2000);
const regenerateBtn = messageDiv.querySelector('.message-action-btn'); const regenerateBtn = messageDiv.querySelector('.message-action-btn');
if (regenerateBtn) regenerateBtn.disabled = false; if (regenerateBtn) {
regenerateBtn.disabled = false;
regenerateBtn.classList.remove('loading');
}
tokenUnlisten(); tokenUnlisten();
completeUnlisten(); completeUnlisten();
}); });
@@ -525,10 +772,12 @@ async function generateSwipeStream(messageDiv, userMessage) {
} catch (error) { } catch (error) {
tokenUnlisten(); tokenUnlisten();
completeUnlisten(); completeUnlisten();
statusText.classList.remove('streaming'); setStatus(`Regeneration failed: ${error.substring(0, 40)}...`, 'error');
setStatus('Error');
const regenerateBtn = messageDiv.querySelector('.message-action-btn'); 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); addMessage(`Error: ${error}`, false);
} }
} }
@@ -566,12 +815,12 @@ function addCopyButtonToCode(block) {
// Extract message sending logic into separate function // Extract message sending logic into separate function
async function sendMessage(message, isRegenerate = false) { async function sendMessage(message, isRegenerate = false) {
if (!isRegenerate) { if (!isRegenerate) {
addMessage(message, true); addMessage(message, true, false, Date.now());
} }
sendBtn.disabled = true; sendBtn.disabled = true;
messageInput.disabled = true; messageInput.disabled = true;
setStatus('Thinking...'); setStatus('Connecting to API...', 'default');
// Check if streaming is enabled // Check if streaming is enabled
let streamEnabled = false; let streamEnabled = false;
@@ -584,8 +833,7 @@ async function sendMessage(message, isRegenerate = false) {
if (streamEnabled) { if (streamEnabled) {
// Use streaming // Use streaming
setStatus('Streaming...'); setStatus('Streaming response...', 'streaming');
statusText.classList.add('streaming');
let streamingMessageDiv = null; let streamingMessageDiv = null;
let streamingContentDiv = null; let streamingContentDiv = null;
@@ -635,40 +883,7 @@ async function sendMessage(message, isRegenerate = false) {
fullContent += token; fullContent += token;
// Update content with markdown rendering // Update content with markdown rendering
streamingContentDiv.innerHTML = marked.parse(fullContent); renderAssistantContent(streamingContentDiv, 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);
}
});
messagesContainer.scrollTop = messagesContainer.scrollHeight; messagesContainer.scrollTop = messagesContainer.scrollHeight;
}); });
@@ -689,8 +904,8 @@ async function sendMessage(message, isRegenerate = false) {
actionsDiv.appendChild(regenerateBtn); actionsDiv.appendChild(regenerateBtn);
streamingMessageDiv.appendChild(actionsDiv); streamingMessageDiv.appendChild(actionsDiv);
setStatus('Ready'); setStatus('Response complete', 'success');
statusText.classList.remove('streaming'); setTimeout(() => setStatus('Ready'), 2000);
sendBtn.disabled = false; sendBtn.disabled = false;
messageInput.disabled = false; messageInput.disabled = false;
messageInput.focus(); messageInput.focus();
@@ -703,17 +918,17 @@ async function sendMessage(message, isRegenerate = false) {
} catch (error) { } catch (error) {
tokenUnlisten(); tokenUnlisten();
completeUnlisten(); completeUnlisten();
statusText.classList.remove('streaming');
if (streamingMessageDiv) { if (streamingMessageDiv) {
streamingMessageDiv.remove(); streamingMessageDiv.remove();
} }
if (error.includes('not configured')) { if (error.includes('not configured')) {
addMessage('API not configured. Please configure your API settings.', false); addMessage('API not configured. Please configure your API settings.', false);
setStatus('API not configured', 'error');
setTimeout(showSettings, 1000); setTimeout(showSettings, 1000);
} else { } else {
addMessage(`Error: ${error}`, false); addMessage(`Error: ${error}`, false);
setStatus(`Error: ${error.substring(0, 50)}...`, 'error');
} }
setStatus('Error');
sendBtn.disabled = false; sendBtn.disabled = false;
messageInput.disabled = false; messageInput.disabled = false;
messageInput.focus(); messageInput.focus();
@@ -726,16 +941,18 @@ async function sendMessage(message, isRegenerate = false) {
const response = await invoke('chat', { message }); const response = await invoke('chat', { message });
removeTypingIndicator(); removeTypingIndicator();
addMessage(response, false); addMessage(response, false);
setStatus('Ready'); setStatus('Response complete', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) { } catch (error) {
removeTypingIndicator(); removeTypingIndicator();
if (error.includes('not configured')) { if (error.includes('not configured')) {
addMessage('API not configured. Please configure your API settings.', false); addMessage('API not configured. Please configure your API settings.', false);
setStatus('API not configured', 'error');
setTimeout(showSettings, 1000); setTimeout(showSettings, 1000);
} else { } else {
addMessage(`Error: ${error}`, false); addMessage(`Error: ${error}`, false);
setStatus(`Error: ${error.substring(0, 50)}...`, 'error');
} }
setStatus('Error');
} finally { } finally {
sendBtn.disabled = false; sendBtn.disabled = false;
messageInput.disabled = false; messageInput.disabled = false;
@@ -772,21 +989,35 @@ function removeTypingIndicator() {
} }
} }
// Update status // Update status with optional styling
function setStatus(text) { function setStatus(text, type = 'default') {
statusText.textContent = text; 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 // Show/hide settings
async function showSettings() { async function showSettings() {
settingsPanel.style.display = 'block'; const overlay = document.getElementById('settings-overlay');
chatView.style.display = 'none'; settingsPanel.classList.add('open');
overlay.classList.add('show');
await loadCharacterSettings(); await loadCharacterSettings();
} }
function hideSettings() { function hideSettings() {
settingsPanel.style.display = 'none'; const overlay = document.getElementById('settings-overlay');
chatView.style.display = 'flex'; settingsPanel.classList.remove('open');
overlay.classList.remove('show');
} }
// Tab switching // Tab switching
@@ -839,14 +1070,18 @@ async function handleValidate() {
} }
validateBtn.disabled = true; validateBtn.disabled = true;
validateBtn.classList.add('loading');
validateBtn.textContent = 'Validating...'; validateBtn.textContent = 'Validating...';
validationMsg.style.display = 'none'; validationMsg.style.display = 'none';
setStatus('Validating API...', 'default');
try { try {
const models = await invoke('validate_api', { baseUrl, apiKey }); const models = await invoke('validate_api', { baseUrl, apiKey });
validationMsg.textContent = `Found ${models.length} models`; validationMsg.textContent = `Found ${models.length} models`;
validationMsg.className = 'validation-message success'; validationMsg.className = 'validation-message success';
setStatus('API validated successfully', 'success');
setTimeout(() => setStatus('Ready'), 2000);
modelSelect.innerHTML = '<option value="">Select a model</option>'; modelSelect.innerHTML = '<option value="">Select a model</option>';
models.forEach(model => { models.forEach(model => {
@@ -857,14 +1092,17 @@ async function handleValidate() {
}); });
modelsGroup.style.display = 'flex'; modelsGroup.style.display = 'flex';
modelsGroup.classList.add('fade-in');
saveBtn.disabled = false; saveBtn.disabled = false;
} catch (error) { } catch (error) {
validationMsg.textContent = `Validation failed: ${error}`; validationMsg.textContent = `Validation failed: ${error}`;
validationMsg.className = 'validation-message error'; validationMsg.className = 'validation-message error';
setStatus('API validation failed', 'error');
modelsGroup.style.display = 'none'; modelsGroup.style.display = 'none';
saveBtn.disabled = true; saveBtn.disabled = true;
} finally { } finally {
validateBtn.disabled = false; validateBtn.disabled = false;
validateBtn.classList.remove('loading');
validateBtn.textContent = 'Validate'; validateBtn.textContent = 'Validate';
} }
} }
@@ -886,23 +1124,29 @@ async function handleSaveSettings(e) {
} }
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.classList.add('loading');
saveBtn.textContent = 'Saving...'; saveBtn.textContent = 'Saving...';
setStatus('Saving configuration...', 'default');
try { try {
await invoke('save_api_config', { baseUrl, apiKey, model, stream }); await invoke('save_api_config', { baseUrl, apiKey, model, stream });
validationMsg.textContent = 'Configuration saved successfully'; validationMsg.textContent = 'Configuration saved successfully';
validationMsg.className = 'validation-message success'; validationMsg.className = 'validation-message success';
setStatus('Configuration saved', 'success');
setTimeout(() => { setTimeout(() => {
hideSettings(); hideSettings();
messagesContainer.innerHTML = ''; messagesContainer.innerHTML = '';
addMessage('API configured. Ready to chat.', false, true); addMessage('API configured. Ready to chat.', false, true);
setStatus('Ready');
}, 1000); }, 1000);
} catch (error) { } catch (error) {
validationMsg.textContent = `Failed to save: ${error}`; validationMsg.textContent = `Failed to save: ${error}`;
validationMsg.className = 'validation-message error'; validationMsg.className = 'validation-message error';
setStatus('Failed to save configuration', 'error');
} finally { } finally {
saveBtn.disabled = false; saveBtn.disabled = false;
saveBtn.classList.remove('loading');
saveBtn.textContent = 'Save Configuration'; saveBtn.textContent = 'Save Configuration';
} }
} }
@@ -960,6 +1204,7 @@ function handleAvatarRemove() {
function setupAppControls() { function setupAppControls() {
document.getElementById('settings-btn').addEventListener('click', showSettings); document.getElementById('settings-btn').addEventListener('click', showSettings);
document.getElementById('close-settings-btn').addEventListener('click', hideSettings); document.getElementById('close-settings-btn').addEventListener('click', hideSettings);
document.getElementById('settings-overlay').addEventListener('click', hideSettings);
document.getElementById('clear-btn').addEventListener('click', clearHistory); document.getElementById('clear-btn').addEventListener('click', clearHistory);
characterSelect.addEventListener('change', handleCharacterSwitch); characterSelect.addEventListener('change', handleCharacterSwitch);
newCharacterBtn.addEventListener('click', handleNewCharacter); newCharacterBtn.addEventListener('click', handleNewCharacter);
@@ -973,6 +1218,38 @@ function setupAppControls() {
document.getElementById('remove-avatar-btn').addEventListener('click', handleAvatarRemove); document.getElementById('remove-avatar-btn').addEventListener('click', handleAvatarRemove);
document.getElementById('import-character-btn').addEventListener('click', handleImportCharacter); document.getElementById('import-character-btn').addEventListener('click', handleImportCharacter);
document.getElementById('export-character-btn').addEventListener('click', handleExportCharacter); 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 // Keyboard shortcuts
@@ -1032,12 +1309,16 @@ async function loadCharacters() {
// Handle character switching // Handle character switching
async function handleCharacterSwitch() { async function handleCharacterSwitch() {
const characterId = characterSelect.value; const characterId = characterSelect.value;
setStatus('Switching character...', 'default');
try { try {
await invoke('set_active_character', { characterId }); await invoke('set_active_character', { characterId });
messagesContainer.innerHTML = ''; messagesContainer.innerHTML = '';
await loadCharacters(); await loadCharacters();
setStatus('Character switched', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) { } catch (error) {
console.error('Failed to switch character:', error); console.error('Failed to switch character:', error);
setStatus('Failed to switch character', 'error');
addMessage(`Failed to switch character: ${error}`, false); addMessage(`Failed to switch character: ${error}`, false);
} }
} }
@@ -1138,7 +1419,7 @@ async function loadChatHistory() {
} }
} else { } else {
history.forEach((msg, index) => { 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 // Update swipe controls for assistant messages with swipe info
if (msg.role === 'assistant' && messageDiv && msg.swipes && msg.swipes.length > 0) { if (msg.role === 'assistant' && messageDiv && msg.swipes && msg.swipes.length > 0) {
@@ -1159,6 +1440,7 @@ async function clearHistory() {
return; return;
} }
setStatus('Clearing history...', 'default');
try { try {
await invoke('clear_chat_history'); await invoke('clear_chat_history');
messagesContainer.innerHTML = ''; messagesContainer.innerHTML = '';
@@ -1167,7 +1449,10 @@ async function clearHistory() {
} else { } else {
addMessage('Conversation cleared. Ready to chat.', false, true); addMessage('Conversation cleared. Ready to chat.', false, true);
} }
setStatus('History cleared', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) { } catch (error) {
setStatus('Failed to clear history', 'error');
addMessage(`Failed to clear history: ${error}`, false); addMessage(`Failed to clear history: ${error}`, false);
} }
} }
@@ -1252,6 +1537,7 @@ async function handleSaveCharacter(e) {
} }
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.classList.add('loading');
saveBtn.textContent = 'Saving...'; saveBtn.textContent = 'Saving...';
try { try {
@@ -1284,6 +1570,7 @@ async function handleSaveCharacter(e) {
characterMsg.className = 'validation-message error'; characterMsg.className = 'validation-message error';
} finally { } finally {
saveBtn.disabled = false; saveBtn.disabled = false;
saveBtn.classList.remove('loading');
saveBtn.textContent = 'Save Character'; saveBtn.textContent = 'Save Character';
} }
} }
@@ -1357,5 +1644,10 @@ window.addEventListener('DOMContentLoaded', () => {
messageInput.focus(); messageInput.focus();
setStatus('Ready'); setStatus('Ready');
// Load saved preferences before anything else
loadSavedTheme();
loadSavedViewMode();
loadSavedFontSize();
loadExistingConfig(); loadExistingConfig();
}); });

View File

@@ -86,6 +86,7 @@ body {
border: 1px solid var(--border); border: 1px solid var(--border);
background-size: cover; background-size: cover;
background-position: center; background-position: center;
flex-shrink: 0;
} }
.avatar-circle-large { .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 { .message.user {
justify-content: flex-end; justify-content: flex-end;
} }
@@ -287,6 +301,34 @@ body {
margin-top: 12px; 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 action buttons */
.message-actions { .message-actions {
position: absolute; position: absolute;
@@ -703,6 +745,7 @@ body {
.status-text { .status-text {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
transition: color 0.2s ease;
} }
.status-text.streaming { .status-text.streaming {
@@ -710,6 +753,14 @@ body {
animation: pulse 2s ease-in-out infinite; animation: pulse 2s ease-in-out infinite;
} }
.status-text.error {
color: #ef4444;
}
.status-text.success {
color: #22c55e;
}
@keyframes pulse { @keyframes pulse {
0%, 100% { 0%, 100% {
opacity: 1; opacity: 1;
@@ -719,49 +770,210 @@ body {
} }
} }
/* Light mode support */ /* Loading spinner */
@media (prefers-color-scheme: light) { .loading-spinner {
:root { display: inline-block;
--bg-primary: #ffffff; width: 14px;
--bg-secondary: #f5f5f5; height: 14px;
--bg-tertiary: #e8e8e8; border: 2px solid rgba(255, 255, 255, 0.3);
--text-primary: #1a1a1a; border-radius: 50%;
--text-secondary: #666666; border-top-color: currentColor;
--accent: #6366f1; animation: spin 0.8s linear infinite;
--accent-hover: #4f46e5; margin-left: 8px;
--user-msg: #6366f1; vertical-align: middle;
--assistant-msg: #f5f5f5;
--border: #e0e0e0;
--shadow: rgba(0, 0, 0, 0.1);
}
.message.assistant .message-content {
border: 1px solid var(--border);
}
} }
/* Settings Panel */ @keyframes spin {
.settings-panel { 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; 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; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: var(--bg-primary); background: rgba(0, 0, 0, 0.5);
z-index: 1000; z-index: 999;
overflow-y: auto; opacity: 0;
padding: 60px 20px 20px; pointer-events: none;
transition: opacity 0.3s ease;
}
.settings-overlay.show {
opacity: 1;
pointer-events: auto;
} }
.settings-header { .settings-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px; margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
} }
.settings-header h2 { .settings-header h2 {
font-size: 20px; font-size: 24px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin: 0; margin: 0;
@@ -771,9 +983,6 @@ body {
display: flex; display: flex;
gap: 8px; gap: 8px;
margin-bottom: 24px; margin-bottom: 24px;
max-width: 400px;
margin-left: auto;
margin-right: auto;
} }
.tab-btn { .tab-btn {
@@ -810,13 +1019,68 @@ body {
} }
.settings-form { .settings-form {
max-width: 400px;
margin: 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; 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 { .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -988,6 +1252,259 @@ body {
transform: scale(0.95); 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 */ /* Responsive */
@media (max-width: 600px) { @media (max-width: 600px) {
.messages-list { .messages-list {
@@ -1003,6 +1520,11 @@ body {
} }
.settings-panel { .settings-panel {
padding: 60px 16px 16px; width: 100%;
right: -100%;
}
.settings-panel.open {
right: 0;
} }
} }