Small feature changes and bug fixes
This commit is contained in:
369
README.md
369
README.md
@@ -1,53 +1,128 @@
|
||||
# oAI
|
||||
|
||||
A native macOS AI chat application with support for multiple providers and advanced features.
|
||||
A powerful native macOS AI chat application with support for multiple providers, advanced memory management, and seamless Git synchronization.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### Multi-Provider Support
|
||||
### 🤖 Multi-Provider Support
|
||||
- **OpenAI** - GPT models with native API support
|
||||
- **Anthropic** - Claude models with OAuth integration
|
||||
- **OpenRouter** - Access to 100+ AI models
|
||||
- **Ollama** - Local model inference
|
||||
- **OpenRouter** - Access to 300+ AI models from multiple providers
|
||||
- **Google** - Gemini models with native integration
|
||||
- **Ollama** - Local model inference for privacy
|
||||
|
||||
### Core Capabilities
|
||||
### 💬 Core Chat Capabilities
|
||||
- **Streaming Responses** - Real-time token streaming for faster interactions
|
||||
- **Conversation Management** - Save, load, and delete chat conversations
|
||||
- **Conversation Management** - Save, load, export, and search conversations
|
||||
- **File Attachments** - Support for text files, images, and PDFs
|
||||
- **Image Generation** - Create images with supported models
|
||||
- **Online Mode** - Web search integration for up-to-date information
|
||||
- **Session Statistics** - Track token usage and costs
|
||||
- **Model Context Protocol (MCP)** - Filesystem access for AI models with configurable permissions
|
||||
- **Image Generation** - Create images with supported models (DALL-E, Flux, etc.)
|
||||
- **Online Mode** - DuckDuckGo and Google web search integration
|
||||
- **Session Statistics** - Track token usage, costs, and response times
|
||||
- **Command History** - Navigate previous commands with searchable modal (⌘H)
|
||||
|
||||
### UI/UX
|
||||
- Native macOS interface with dark mode support
|
||||
### 🧠 Enhanced Memory & Context System
|
||||
- **Smart Context Selection** - Automatically select relevant messages to reduce token usage by 50-80%
|
||||
- **Message Starring** - Mark important messages to always include them in context
|
||||
- **Semantic Search** - AI-powered search across conversations using embeddings
|
||||
- **Progressive Summarization** - Automatically summarize old portions of long conversations
|
||||
- **Multi-Provider Embeddings** - Support for OpenAI, OpenRouter, and Google embeddings
|
||||
|
||||

|
||||
|
||||
### 🔧 Model Context Protocol (MCP)
|
||||
Advanced filesystem access for AI models with fine-grained permissions:
|
||||
- **Read Access** - Allow AI to read files in specified folders
|
||||
- **Write Access** - Optional write permissions for file modifications
|
||||
- **Gitignore Support** - Respects .gitignore patterns when listing/searching
|
||||
- **Folder Management** - Add/remove allowed folders with visual status
|
||||
- **Search Operations** - Find files by name or content across allowed directories
|
||||
|
||||
### 🔄 Git Synchronization
|
||||
Seamless conversation backup and sync across devices:
|
||||
- **Auto-Sync** - Automatic export and push to Git repository
|
||||
- **Smart Triggers** - Sync on app start, idle, goodbye phrases, model switches, or app quit
|
||||
- **Multi-Provider Support** - GitHub, GitLab, Gitea, and custom Git servers
|
||||
- **Conflict Prevention** - Warning system for multi-machine usage
|
||||
- **Manual Sync** - One-click sync with progress indication
|
||||
|
||||

|
||||
|
||||
### 📧 Email Handler (AI Email Assistant)
|
||||
Automated email responses powered by AI:
|
||||
- **IMAP Polling** - Monitor inbox for emails with specific subject identifiers
|
||||
- **AI-Powered Responses** - Generate contextual replies using any AI provider
|
||||
- **SMTP Integration** - Send replies via SMTP with TLS support
|
||||
- **Rate Limiting** - Configurable emails per hour limit
|
||||
- **Email Threading** - Proper In-Reply-To headers for email chains
|
||||
- **Secure Storage** - AES-256-GCM encryption for all credentials
|
||||
- **Email Log** - Track all processed emails with success/error status
|
||||
|
||||
### 🎨 UI/UX
|
||||
- Native macOS interface with dark/light mode support
|
||||
- Markdown rendering with syntax highlighting
|
||||
- Command history navigation
|
||||
- Model selector with detailed information
|
||||
- Footer stats display (tokens, cost, response time)
|
||||
- Customizable text sizes (GUI, dialog, input)
|
||||
- Footer stats display (messages, tokens, cost, sync status)
|
||||
- Header status indicators (MCP, Online mode, Git sync)
|
||||
- Responsive message layout with copy buttons
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone this repository
|
||||
### From Source
|
||||
1. Clone this repository:
|
||||
```bash
|
||||
git clone https://github.com/yourusername/oAI.git
|
||||
cd oAI
|
||||
```
|
||||
2. Open `oAI.xcodeproj` in Xcode
|
||||
3. Build and run (⌘R)
|
||||
4. The app will be built to `~/Library/Developer/Xcode/DerivedData/oAI-*/Build/Products/Debug/oAI.app`
|
||||
5. Copy to `/Applications` for easy access
|
||||
|
||||
### Requirements
|
||||
- macOS 14.0+
|
||||
- Xcode 15.0+
|
||||
- Swift 5.9+
|
||||
|
||||
## Configuration
|
||||
|
||||
### API Keys
|
||||
Add your API keys in Settings (⌘,):
|
||||
- OpenAI API key
|
||||
- Anthropic API key (or use OAuth)
|
||||
- OpenRouter API key
|
||||
- Ollama base URL (default: http://localhost:11434)
|
||||
Add your API keys in Settings (⌘,) → General tab:
|
||||
- **OpenAI** - Get from [OpenAI Platform](https://platform.openai.com/api-keys)
|
||||
- **Anthropic** - Get from [Anthropic Console](https://console.anthropic.com/) or use OAuth
|
||||
- **OpenRouter** - Get from [OpenRouter Keys](https://openrouter.ai/keys)
|
||||
- **Google** - Get from [Google AI Studio](https://makersuite.google.com/app/apikey)
|
||||
- **Ollama** - Base URL (default: http://localhost:11434)
|
||||
|
||||
### Settings
|
||||
- **Provider** - Select default AI provider
|
||||
- **Streaming** - Enable/disable response streaming
|
||||
### Essential Settings
|
||||
|
||||
#### General Tab
|
||||
- **Default Provider** - Select your preferred AI provider
|
||||
- **Streaming** - Enable/disable real-time response streaming
|
||||
- **Memory** - Control conversation context (on/off)
|
||||
- **Online Mode** - Enable web search integration
|
||||
- **Max Tokens** - Set maximum response length
|
||||
- **Temperature** - Control response randomness
|
||||
- **Temperature** - Control response randomness (0.0 - 2.0)
|
||||
|
||||
#### Advanced Tab
|
||||
- **Smart Context Selection** - Reduce token usage automatically
|
||||
- **Semantic Search** - Enable AI-powered conversation search
|
||||
- **Progressive Summarization** - Handle long conversations efficiently
|
||||
|
||||
#### Sync Tab
|
||||
- **Repository URL** - Git repository for conversation backup
|
||||
- **Authentication** - Username/password or access token
|
||||
- **Auto-Save** - Configure automatic save triggers
|
||||
- **Manual Sync** - One-click synchronization
|
||||
|
||||
#### Email Tab
|
||||
- **Email Handler** - Configure automated email responses
|
||||
- **IMAP/SMTP Settings** - Email server configuration
|
||||
- **AI Provider** - Select which AI to use for responses
|
||||
- **Rate Limiting** - Control email processing frequency
|
||||
|
||||
## Slash Commands
|
||||
|
||||
@@ -60,20 +135,21 @@ Add your API keys in Settings (⌘,):
|
||||
|
||||
### Conversation Management
|
||||
- `/save <name>` - Save current conversation
|
||||
- `/load` or `/list` - List and load saved conversations
|
||||
- `/load` or `/list` - List and load saved conversations (⌘L)
|
||||
- `/delete <name>` - Delete a saved conversation
|
||||
- `/export <md|json> [filename]` - Export conversation
|
||||
- `/history` - Open command history modal (⌘H)
|
||||
|
||||
### Provider & Settings
|
||||
- `/provider [name]` - Switch or display current provider
|
||||
- `/config` or `/settings` - Open settings (⌘,)
|
||||
- `/stats` - View session statistics
|
||||
- `/credits` - Check API credits/balance
|
||||
- `/credits` - Check API credits/balance (OpenRouter)
|
||||
|
||||
### Features
|
||||
- `/memory <on|off>` - Toggle conversation memory
|
||||
- `/online <on|off>` - Toggle online/web search mode
|
||||
- `/mcp <on|off|status|add|remove|list>` - Manage MCP filesystem access
|
||||
- `/mcp <on|off|status|add|remove|list|write>` - Manage MCP filesystem access
|
||||
|
||||
### MCP (Model Context Protocol)
|
||||
- `/mcp add <path>` - Grant AI access to a folder
|
||||
@@ -86,56 +162,217 @@ Add your API keys in Settings (⌘,):
|
||||
|
||||
Attach files to your messages using the syntax: `@/path/to/file`
|
||||
|
||||
Example:
|
||||
**Example:**
|
||||
```
|
||||
Can you review this code? @~/project/main.swift
|
||||
```
|
||||
|
||||
Supported formats:
|
||||
- **Text files** - Any UTF-8 text file (.txt, .md, .swift, .py, etc.)
|
||||
**Supported formats:**
|
||||
- **Text files** - Any UTF-8 text file (.txt, .md, .swift, .py, .json, etc.)
|
||||
- **Images** - PNG, JPG, WebP (for vision-capable models)
|
||||
- **PDFs** - Document analysis with vision models
|
||||
|
||||
Limits:
|
||||
**Limits:**
|
||||
- Maximum file size: 10 MB
|
||||
- Text files truncated after 50 KB (head + tail shown)
|
||||
- Image dimensions automatically scaled for optimal processing
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
- `⌘M` - Open model selector
|
||||
- `⌘,` - Open settings
|
||||
- `⌘N` - New conversation
|
||||
- `↑/↓` - Navigate command history
|
||||
- `⌘L` - List saved conversations
|
||||
- `⌘H` - Command history
|
||||
- `Esc` - Cancel generation / Close dropdown
|
||||
- `↑/↓` - Navigate command dropdown (when typing `/`)
|
||||
- `Return` - Send message
|
||||
- `Shift+Return` - Insert newline
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Smart Context Selection
|
||||
Reduce token usage by 50-80% without losing context quality:
|
||||
- Always includes last 10 messages
|
||||
- Prioritizes user-starred messages
|
||||
- Includes high-importance messages (based on cost and length)
|
||||
- Respects model context limits automatically
|
||||
|
||||
### Semantic Search
|
||||
Find conversations by meaning, not just keywords:
|
||||
- AI-powered embeddings using OpenAI, OpenRouter, or Google
|
||||
- Search across all conversations semantically
|
||||
- Cost-effective: ~$0.04 one-time for 10k messages
|
||||
- Toggle semantic search in conversation list
|
||||
|
||||
### Progressive Summarization
|
||||
Handle 100+ message conversations gracefully:
|
||||
- Automatically summarizes old portions of conversations
|
||||
- Keeps last 20 messages in full
|
||||
- Summaries included in context for continuity
|
||||
- Configurable threshold (default: 50 messages)
|
||||
|
||||
### Git Synchronization
|
||||
Backup and sync conversations across devices:
|
||||
- **Export Format**: Markdown files for human readability
|
||||
- **Auto-Sync Options**:
|
||||
- On app start (pull + import only)
|
||||
- On idle (configurable timeout)
|
||||
- After goodbye phrases ("bye", "thanks", "goodbye")
|
||||
- On model switch
|
||||
- On app quit
|
||||
- Minimum message count threshold
|
||||
- **Manual Sync**: One-click full sync (export + pull + push)
|
||||
|
||||
### Email Handler
|
||||
AI-powered email auto-responder:
|
||||
- **Monitoring**: IMAP polling every 30 seconds
|
||||
- **Filtering**: Subject identifier (e.g., `[JARVIS]`)
|
||||
- **Processing**: AI generates contextual responses
|
||||
- **Sending**: SMTP with TLS (port 465 recommended)
|
||||
- **Tracking**: Email log with success/error status
|
||||
- **Security**: AES-256-GCM encrypted credentials
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
oAI/
|
||||
├── Models/ # Data models (Message, Conversation, Settings)
|
||||
├── Views/ # SwiftUI views
|
||||
│ ├── Main/ # Chat, header, footer, input
|
||||
│ └── Screens/ # Settings, stats, model selector
|
||||
├── ViewModels/ # ChatViewModel
|
||||
├── Providers/ # AI provider implementations
|
||||
├── Services/ # Database, MCP, web search, settings
|
||||
└── Utilities/ # Extensions, logging, syntax highlighting
|
||||
├── Models/ # Data models
|
||||
│ ├── Message.swift # Chat message model
|
||||
│ ├── Conversation.swift # Saved conversation model
|
||||
│ ├── ModelInfo.swift # AI model metadata
|
||||
│ └── Settings.swift # App settings enums
|
||||
│
|
||||
├── Views/ # SwiftUI views
|
||||
│ ├── Main/ # Primary UI components
|
||||
│ │ ├── ChatView.swift # Main chat interface
|
||||
│ │ ├── MessageRow.swift # Individual message display
|
||||
│ │ ├── InputBar.swift # Message input with commands
|
||||
│ │ ├── HeaderView.swift # Top bar (provider/model/status)
|
||||
│ │ └── FooterView.swift # Bottom stats bar
|
||||
│ │
|
||||
│ └── Screens/ # Modal/sheet views
|
||||
│ ├── SettingsView.swift # Settings with tabs
|
||||
│ ├── ModelSelectorView.swift
|
||||
│ ├── ConversationListView.swift
|
||||
│ └── HelpView.swift
|
||||
│
|
||||
├── ViewModels/ # Observable view models
|
||||
│ └── ChatViewModel.swift # Main chat logic & state
|
||||
│
|
||||
├── Providers/ # AI provider implementations
|
||||
│ ├── Provider.swift # Protocol definition
|
||||
│ ├── OpenRouterProvider.swift
|
||||
│ ├── AnthropicProvider.swift
|
||||
│ ├── OpenAIProvider.swift
|
||||
│ └── OllamaProvider.swift
|
||||
│
|
||||
├── Services/ # Business logic & data
|
||||
│ ├── DatabaseService.swift # SQLite operations (GRDB)
|
||||
│ ├── SettingsService.swift # Settings persistence
|
||||
│ ├── ProviderRegistry.swift # AI provider management
|
||||
│ ├── MCPService.swift # File access (MCP)
|
||||
│ ├── WebSearchService.swift # DuckDuckGo/Google search
|
||||
│ ├── GitSyncService.swift # Git synchronization
|
||||
│ ├── ContextSelectionService.swift # Smart context
|
||||
│ ├── EmbeddingService.swift # Semantic search
|
||||
│ ├── EmailService.swift # Email monitoring (IMAP)
|
||||
│ └── EmailHandlerService.swift # Email AI responder
|
||||
│
|
||||
└── Resources/
|
||||
└── oAI.help/ # macOS Help Book
|
||||
```
|
||||
|
||||
### Key Components
|
||||
### Key Technologies
|
||||
|
||||
- **ChatViewModel** - Main state management and message handling
|
||||
- **ProviderRegistry** - Provider selection and initialization
|
||||
- **AIProvider Protocol** - Common interface for all AI providers
|
||||
- **MCPService** - Filesystem tool integration
|
||||
- **DatabaseService** - Conversation persistence
|
||||
- **WebSearchService** - Online search integration
|
||||
- **SwiftUI** - Modern declarative UI framework
|
||||
- **GRDB** - SQLite database wrapper for persistence
|
||||
- **MarkdownUI** - Markdown rendering with syntax highlighting
|
||||
- **os.Logger** - Native logging framework
|
||||
- **Network Framework** - Pure Swift IMAP/SMTP implementation
|
||||
- **Security Framework** - Keychain and encryption services
|
||||
|
||||
## Requirements
|
||||
### Database Schema
|
||||
|
||||
- macOS 14.0+
|
||||
- Xcode 15.0+
|
||||
- Swift 5.9+
|
||||
**Conversations**: id, name, createdAt, updatedAt
|
||||
**Messages**: id, conversationId, role, content, tokens, cost, timestamp
|
||||
**Message Metadata**: message_id, importance_score, user_starred, summary
|
||||
**Message Embeddings**: message_id, embedding (BLOB), model, dimension
|
||||
**Conversation Summaries**: id, conversationId, startIndex, endIndex, summary
|
||||
**Email Logs**: id, sender, subject, status, timestamp
|
||||
|
||||
### Building & Debugging
|
||||
|
||||
**Build Commands:**
|
||||
```bash
|
||||
# Clean build
|
||||
xcodebuild clean -scheme oAI
|
||||
|
||||
# Build
|
||||
xcodebuild -scheme oAI -configuration Debug
|
||||
|
||||
# Run tests
|
||||
xcodebuild test -scheme oAI
|
||||
```
|
||||
|
||||
**Logs Location:**
|
||||
```
|
||||
~/Library/Logs/oAI.log
|
||||
```
|
||||
|
||||
**Database Location:**
|
||||
```
|
||||
~/Library/Application Support/oAI/oai_conversations.db
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**API Connection Errors:**
|
||||
- Verify API keys in Settings → General
|
||||
- Check internet connection
|
||||
- Ensure provider is not experiencing outages
|
||||
|
||||
**MCP Not Working:**
|
||||
- Verify folder permissions in Settings → MCP
|
||||
- Check allowed folders list
|
||||
- Ensure files are not in .gitignore (if enabled)
|
||||
|
||||
**Git Sync Errors:**
|
||||
- Verify repository URL and credentials
|
||||
- Check if repository is initialized (clone first)
|
||||
- Ensure proper network access to Git server
|
||||
- Use access token instead of password for GitHub
|
||||
|
||||
**Email Handler Issues:**
|
||||
- Verify IMAP/SMTP settings and credentials
|
||||
- Use port 465 for SMTP (direct TLS recommended)
|
||||
- Check subject identifier matches exactly (case-sensitive)
|
||||
- Review email logs in Settings → Email → View Email Log
|
||||
|
||||
**Embedding Errors:**
|
||||
- Configure API key for OpenAI, OpenRouter, or Google
|
||||
- Check Settings → Advanced → Semantic Search
|
||||
- Verify embedding provider is selected
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- **Context Selection**: 50-80% token reduction for long conversations
|
||||
- **Semantic Search**: ~$0.02-0.15/month for heavy users
|
||||
- **Conversation Export**: Markdown format for human readability
|
||||
- **Database**: Indexed queries for fast conversation retrieval
|
||||
- **Streaming**: Efficient memory usage with AsyncThrowingStream
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Vector index for faster semantic search (sqlite-vss)
|
||||
- [ ] Local embeddings (sentence-transformers, $0 cost)
|
||||
- [ ] Conversation clustering and recommendations
|
||||
- [ ] Multi-modal conversation export (PDF, HTML)
|
||||
- [ ] Plugin system for custom tools
|
||||
- [ ] iOS companion app with CloudKit sync
|
||||
|
||||
## License
|
||||
|
||||
@@ -145,14 +382,36 @@ MIT License - See [LICENSE](LICENSE) for details.
|
||||
|
||||
**Rune Olsen**
|
||||
|
||||
https://blog.rune.pm
|
||||
- Website: [https://blog.rune.pm](https://blog.rune.pm)
|
||||
- GitHub: [@yourusername](https://github.com/yourusername)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please follow these steps:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Submit a pull request
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
Please ensure:
|
||||
- Code follows Swift style guidelines
|
||||
- All tests pass
|
||||
- Documentation is updated
|
||||
- Commit messages are descriptive
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- **MarkdownUI** - Excellent markdown rendering library
|
||||
- **GRDB** - Robust SQLite wrapper for Swift
|
||||
- **Anthropic, OpenAI, OpenRouter** - AI API providers
|
||||
- **macOS Community** - Inspiration and support
|
||||
|
||||
---
|
||||
|
||||
**⭐ Star this project if you find it useful!**
|
||||
|
||||
**🐛 Found a bug?** [Open an issue](https://github.com/yourusername/oAI/issues)
|
||||
|
||||
**💡 Have a feature request?** [Start a discussion](https://github.com/yourusername/oAI/discussions)
|
||||
|
||||
BIN
Screenshots/1.png
Normal file
BIN
Screenshots/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 724 KiB |
BIN
Screenshots/2.png
Normal file
BIN
Screenshots/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 327 KiB |
BIN
Screenshots/3.png
Normal file
BIN
Screenshots/3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 251 KiB |
BIN
Screenshots/4.png
Normal file
BIN
Screenshots/4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
@@ -247,26 +247,109 @@
|
||||
<!-- Memory -->
|
||||
<section id="memory">
|
||||
<h2>Memory & Context</h2>
|
||||
<p>Control how much conversation history the AI remembers.</p>
|
||||
<p>oAI features an enhanced memory and context system with intelligent message selection, semantic search, and automatic summarization.</p>
|
||||
|
||||
<h3>Memory Enabled (Default)</h3>
|
||||
<p>When memory is <strong>on</strong>, the AI remembers all previous messages in your session. This allows for natural, flowing conversations.</p>
|
||||
|
||||
<h3>Memory Disabled</h3>
|
||||
<p>When memory is <strong>off</strong>, only your latest message is sent. Each message is independent. Useful for:</p>
|
||||
<ul>
|
||||
<li>Quick, unrelated questions</li>
|
||||
<li>Reducing token usage and cost</li>
|
||||
<li>Avoiding context pollution</li>
|
||||
</ul>
|
||||
|
||||
<h3>Toggle Memory</h3>
|
||||
<h3>Basic Memory Control</h3>
|
||||
<p>Control whether the AI remembers previous messages:</p>
|
||||
<code class="command">/memory on</code>
|
||||
<code class="command">/memory off</code>
|
||||
|
||||
<div class="note">
|
||||
<strong>Note:</strong> Memory state is shown in the header with a badge when enabled.
|
||||
</div>
|
||||
|
||||
<h3>Smart Context Selection</h3>
|
||||
<p>When enabled, oAI intelligently selects which messages to send instead of sending all history. This reduces token usage by 50-80% while maintaining context quality.</p>
|
||||
|
||||
<h4>How It Works</h4>
|
||||
<ul>
|
||||
<li>Always includes the last 10 messages (recent context)</li>
|
||||
<li>Includes starred messages (user-marked as important)</li>
|
||||
<li>Includes high-importance messages (high cost, detailed content)</li>
|
||||
<li>Respects model context limits</li>
|
||||
</ul>
|
||||
|
||||
<h4>Enabling Smart Context</h4>
|
||||
<ol>
|
||||
<li>Go to Settings → Advanced</li>
|
||||
<li>Enable "Smart Context Selection"</li>
|
||||
<li>Set max context tokens (default: 100,000)</li>
|
||||
</ol>
|
||||
|
||||
<h3>Message Starring</h3>
|
||||
<p>Star important messages to always include them in context, regardless of age:</p>
|
||||
<ol>
|
||||
<li>Hover over any user or assistant message</li>
|
||||
<li>Click the star icon (⭐) in the header</li>
|
||||
<li>Starred messages show a filled yellow star</li>
|
||||
</ol>
|
||||
|
||||
<div class="tip">
|
||||
<strong>💡 Tip:</strong> Star key decisions, important information, or context you want the AI to always remember.
|
||||
</div>
|
||||
|
||||
<h3>Semantic Search</h3>
|
||||
<p>Find conversations by meaning, not just keywords. Powered by AI embeddings.</p>
|
||||
|
||||
<h4>Setup</h4>
|
||||
<ol>
|
||||
<li>Go to Settings → Advanced → Semantic Search</li>
|
||||
<li>Enable "Enable Embeddings"</li>
|
||||
<li>Requires API key for OpenAI, OpenRouter, or Google</li>
|
||||
<li>Choose your preferred embedding provider and model</li>
|
||||
<li>Click "Embed All Conversations" (one-time, ~$0.04 for 10k messages)</li>
|
||||
</ol>
|
||||
|
||||
<div class="tip">
|
||||
<strong>💡 Provider Priority:</strong> If you have multiple API keys configured, the app uses OpenAI → OpenRouter → Google in that order. You can override this by selecting a specific model in settings.
|
||||
</div>
|
||||
|
||||
<h4>Using Semantic Search</h4>
|
||||
<ol>
|
||||
<li>Open conversation list (<code>/list</code> or <kbd>⌘L</kbd>)</li>
|
||||
<li>Type your search query</li>
|
||||
<li>Toggle "Semantic" switch ON</li>
|
||||
<li>Results ranked by meaning, not keyword matching</li>
|
||||
</ol>
|
||||
|
||||
<div class="example">
|
||||
<strong>Example:</strong> Search for "login security methods" to find a conversation titled "Morning Chat" that discussed authentication, even though the title doesn't contain those words.
|
||||
</div>
|
||||
|
||||
<h3>Progressive Summarization</h3>
|
||||
<p>For very long conversations, oAI automatically summarizes older messages to save tokens while preserving context.</p>
|
||||
|
||||
<h4>How It Works</h4>
|
||||
<ul>
|
||||
<li>When conversation exceeds threshold (default: 50 messages)</li>
|
||||
<li>Older messages (0-30) are summarized into 2-3 paragraphs</li>
|
||||
<li>Summary focuses on key topics, decisions, and information</li>
|
||||
<li>Recent messages (last 20) always sent in full</li>
|
||||
<li>Summaries included in system prompt for context</li>
|
||||
</ul>
|
||||
|
||||
<h4>Enabling Summarization</h4>
|
||||
<ol>
|
||||
<li>Go to Settings → Advanced → Progressive Summarization</li>
|
||||
<li>Enable "Enable Summarization"</li>
|
||||
<li>Set message threshold (default: 50)</li>
|
||||
</ol>
|
||||
|
||||
<div class="note">
|
||||
<strong>Cost:</strong> Each summary costs ~$0.001. For a heavy user (10 summaries/month), this is ~$0.01/month.
|
||||
</div>
|
||||
|
||||
<h3>All Three Together</h3>
|
||||
<p>When all features are enabled:</p>
|
||||
<ol>
|
||||
<li><strong>Smart Context Selection</strong>: Picks relevant messages (15-20 from 100)</li>
|
||||
<li><strong>Progressive Summarization</strong>: Provides summaries of excluded old messages</li>
|
||||
<li><strong>Semantic Search</strong>: Find conversations by meaning</li>
|
||||
</ol>
|
||||
|
||||
<div class="tip">
|
||||
<strong>💡 Result:</strong> 50-80% token reduction, better context quality, and ability to handle 100+ message conversations efficiently.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Online Mode -->
|
||||
@@ -353,7 +436,7 @@
|
||||
<!-- Conversations -->
|
||||
<section id="conversations">
|
||||
<h2>Managing Conversations</h2>
|
||||
<p>Save, load, and export your chat conversations.</p>
|
||||
<p>Save, load, search, and export your chat conversations.</p>
|
||||
|
||||
<h3>Saving Conversations</h3>
|
||||
<code class="command">/save my-project-chat</code>
|
||||
@@ -369,10 +452,39 @@
|
||||
<li>Type <code>/list</code></li>
|
||||
</ul>
|
||||
|
||||
<h3>Searching Conversations</h3>
|
||||
<p>Two search modes available in the conversation list:</p>
|
||||
<ul>
|
||||
<li><strong>Keyword Search</strong> (default): Matches conversation titles</li>
|
||||
<li><strong>Semantic Search</strong>: Finds conversations by meaning (requires embeddings enabled)</li>
|
||||
</ul>
|
||||
|
||||
<div class="steps">
|
||||
<h4>Using Semantic Search</h4>
|
||||
<ol>
|
||||
<li>Open conversation list (<kbd>⌘L</kbd>)</li>
|
||||
<li>Type your search query</li>
|
||||
<li>Toggle "Semantic" switch ON</li>
|
||||
<li>Results ranked by relevance</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="example">
|
||||
<strong>Example:</strong> Search "authentication methods" to find conversations about login security, even if the title is just "Chat 2024-01-15".
|
||||
</div>
|
||||
|
||||
<h3>Deleting Conversations</h3>
|
||||
<code class="command">/delete old-chat</code>
|
||||
<p class="warning"><strong>Warning:</strong> This action cannot be undone.</p>
|
||||
|
||||
<h3>Bulk Delete</h3>
|
||||
<p>In the conversation list:</p>
|
||||
<ol>
|
||||
<li>Click "Select" button</li>
|
||||
<li>Click conversations to select (checkbox appears)</li>
|
||||
<li>Click "Delete (N)" button</li>
|
||||
</ol>
|
||||
|
||||
<h3>Exporting Conversations</h3>
|
||||
<p>Export to Markdown or JSON format:</p>
|
||||
<code class="command">/export md</code>
|
||||
@@ -415,6 +527,7 @@
|
||||
|
||||
<h4>Auto-Save Triggers</h4>
|
||||
<ul>
|
||||
<li><strong>On App Start</strong> - Pulls and imports changes when oAI launches (no push)</li>
|
||||
<li><strong>On Model Switch</strong> - Saves when you change AI models</li>
|
||||
<li><strong>On App Quit</strong> - Saves before oAI closes</li>
|
||||
<li><strong>After Idle Timeout</strong> - Saves after 5 seconds of inactivity</li>
|
||||
|
||||
247
oAI/Services/ContextSelectionService.swift
Normal file
247
oAI/Services/ContextSelectionService.swift
Normal file
@@ -0,0 +1,247 @@
|
||||
//
|
||||
// ContextSelectionService.swift
|
||||
// oAI
|
||||
//
|
||||
// Smart context selection for AI conversations
|
||||
// Selects relevant messages instead of sending entire history
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - Context Window
|
||||
|
||||
struct ContextWindow {
|
||||
let messages: [Message]
|
||||
let summaries: [String]
|
||||
let totalTokens: Int
|
||||
let excludedCount: Int
|
||||
}
|
||||
|
||||
// MARK: - Selection Strategy
|
||||
|
||||
enum SelectionStrategy {
|
||||
case allMessages // Memory ON (old behavior): send all messages
|
||||
case lastMessageOnly // Memory OFF: send only last message
|
||||
case smart // NEW: intelligent selection
|
||||
}
|
||||
|
||||
// MARK: - Context Selection Service
|
||||
|
||||
final class ContextSelectionService {
|
||||
static let shared = ContextSelectionService()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Select context messages using the specified strategy
|
||||
func selectContext(
|
||||
allMessages: [Message],
|
||||
strategy: SelectionStrategy,
|
||||
maxTokens: Int?,
|
||||
currentQuery: String? = nil,
|
||||
conversationId: UUID? = nil
|
||||
) -> ContextWindow {
|
||||
switch strategy {
|
||||
case .allMessages:
|
||||
return allMessagesContext(allMessages)
|
||||
|
||||
case .lastMessageOnly:
|
||||
return lastMessageOnlyContext(allMessages)
|
||||
|
||||
case .smart:
|
||||
guard let maxTokens = maxTokens else {
|
||||
// Fallback to all messages if no token limit
|
||||
return allMessagesContext(allMessages)
|
||||
}
|
||||
return smartSelection(allMessages: allMessages, maxTokens: maxTokens, conversationId: conversationId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Simple Strategies
|
||||
|
||||
private func allMessagesContext(_ messages: [Message]) -> ContextWindow {
|
||||
ContextWindow(
|
||||
messages: messages,
|
||||
summaries: [],
|
||||
totalTokens: estimateTokens(messages),
|
||||
excludedCount: 0
|
||||
)
|
||||
}
|
||||
|
||||
private func lastMessageOnlyContext(_ messages: [Message]) -> ContextWindow {
|
||||
guard let last = messages.last else {
|
||||
return ContextWindow(messages: [], summaries: [], totalTokens: 0, excludedCount: 0)
|
||||
}
|
||||
return ContextWindow(
|
||||
messages: [last],
|
||||
summaries: [],
|
||||
totalTokens: estimateTokens([last]),
|
||||
excludedCount: messages.count - 1
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Smart Selection Algorithm
|
||||
|
||||
private func smartSelection(allMessages: [Message], maxTokens: Int, conversationId: UUID? = nil) -> ContextWindow {
|
||||
guard !allMessages.isEmpty else {
|
||||
return ContextWindow(messages: [], summaries: [], totalTokens: 0, excludedCount: 0)
|
||||
}
|
||||
|
||||
// Filter out system messages (tools)
|
||||
let chatMessages = allMessages.filter { $0.role == .user || $0.role == .assistant }
|
||||
|
||||
// Step 1: Always include last N messages (recent context)
|
||||
let recentCount = min(10, chatMessages.count)
|
||||
let recentMessages = Array(chatMessages.suffix(recentCount))
|
||||
var selectedMessages = recentMessages
|
||||
var currentTokens = estimateTokens(recentMessages)
|
||||
|
||||
Log.ui.debug("Smart selection: starting with last \(recentCount) messages (\(currentTokens) tokens)")
|
||||
|
||||
// Step 2: Add starred messages from earlier in conversation
|
||||
let olderMessages = chatMessages.dropLast(recentCount)
|
||||
var starredMessages: [Message] = []
|
||||
|
||||
for message in olderMessages {
|
||||
// Check if message is starred
|
||||
if isMessageStarred(message) {
|
||||
let msgTokens = estimateTokens([message])
|
||||
if currentTokens + msgTokens <= maxTokens {
|
||||
starredMessages.append(message)
|
||||
currentTokens += msgTokens
|
||||
} else {
|
||||
Log.ui.debug("Smart selection: token budget exceeded, stopping at \(selectedMessages.count + starredMessages.count) messages")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Add important messages (high cost, long content)
|
||||
var importantMessages: [Message] = []
|
||||
if currentTokens < maxTokens {
|
||||
for message in olderMessages {
|
||||
// Skip if already starred
|
||||
if starredMessages.contains(where: { $0.id == message.id }) {
|
||||
continue
|
||||
}
|
||||
|
||||
let importance = getImportanceScore(message)
|
||||
if importance > 0.5 { // Threshold for "important"
|
||||
let msgTokens = estimateTokens([message])
|
||||
if currentTokens + msgTokens <= maxTokens {
|
||||
importantMessages.append(message)
|
||||
currentTokens += msgTokens
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine: starred + important + recent (in chronological order)
|
||||
let allSelected = (starredMessages + importantMessages + recentMessages)
|
||||
.sorted { $0.timestamp < $1.timestamp }
|
||||
|
||||
// Remove duplicates while preserving order
|
||||
var seen = Set<UUID>()
|
||||
selectedMessages = allSelected.filter { message in
|
||||
if seen.contains(message.id) {
|
||||
return false
|
||||
}
|
||||
seen.insert(message.id)
|
||||
return true
|
||||
}
|
||||
|
||||
let excludedCount = chatMessages.count - selectedMessages.count
|
||||
|
||||
// Get summaries for excluded message ranges
|
||||
var summaries: [String] = []
|
||||
if excludedCount > 0, let conversationId = conversationId {
|
||||
summaries = getSummariesForExcludedRange(
|
||||
conversationId: conversationId,
|
||||
totalMessages: chatMessages.count,
|
||||
selectedCount: selectedMessages.count
|
||||
)
|
||||
}
|
||||
|
||||
Log.ui.info("Smart selection: selected \(selectedMessages.count)/\(chatMessages.count) messages (\(currentTokens) tokens, excluded: \(excludedCount), summaries: \(summaries.count))")
|
||||
|
||||
return ContextWindow(
|
||||
messages: selectedMessages,
|
||||
summaries: summaries,
|
||||
totalTokens: currentTokens,
|
||||
excludedCount: excludedCount
|
||||
)
|
||||
}
|
||||
|
||||
/// Get summaries for excluded message ranges
|
||||
private func getSummariesForExcludedRange(
|
||||
conversationId: UUID,
|
||||
totalMessages: Int,
|
||||
selectedCount: Int
|
||||
) -> [String] {
|
||||
guard let summaryRecords = try? DatabaseService.shared.getConversationSummaries(conversationId: conversationId) else {
|
||||
return []
|
||||
}
|
||||
|
||||
var summaries: [String] = []
|
||||
for record in summaryRecords {
|
||||
// Only include summaries for messages that were excluded
|
||||
if record.end_message_index < (totalMessages - selectedCount) {
|
||||
summaries.append(record.summary)
|
||||
}
|
||||
}
|
||||
|
||||
return summaries
|
||||
}
|
||||
|
||||
// MARK: - Importance Scoring
|
||||
|
||||
/// Calculate importance score (0.0 - 1.0) for a message
|
||||
private func getImportanceScore(_ message: Message) -> Double {
|
||||
var score = 0.0
|
||||
|
||||
// Factor 1: Cost (expensive calls are important)
|
||||
if let cost = message.cost {
|
||||
let costScore = min(1.0, cost / 0.01) // $0.01+ = max score
|
||||
score += costScore * 0.5
|
||||
}
|
||||
|
||||
// Factor 2: Length (detailed messages are important)
|
||||
let contentLength = Double(message.content.count)
|
||||
let lengthScore = min(1.0, contentLength / 2000.0) // 2000+ chars = max score
|
||||
score += lengthScore * 0.3
|
||||
|
||||
// Factor 3: Token count (if available)
|
||||
if let tokens = message.tokens {
|
||||
let tokenScore = min(1.0, Double(tokens) / 1000.0) // 1000+ tokens = max score
|
||||
score += tokenScore * 0.2
|
||||
}
|
||||
|
||||
return min(1.0, score)
|
||||
}
|
||||
|
||||
/// Check if a message is starred by the user
|
||||
private func isMessageStarred(_ message: Message) -> Bool {
|
||||
guard let metadata = try? DatabaseService.shared.getMessageMetadata(messageId: message.id) else {
|
||||
return false
|
||||
}
|
||||
return metadata.user_starred == 1
|
||||
}
|
||||
|
||||
// MARK: - Token Estimation
|
||||
|
||||
/// Estimate token count for messages (rough approximation)
|
||||
private func estimateTokens(_ messages: [Message]) -> Int {
|
||||
var total = 0
|
||||
for message in messages {
|
||||
if let tokens = message.tokens {
|
||||
total += tokens
|
||||
} else {
|
||||
// Rough estimate: 1 token ≈ 4 characters
|
||||
total += message.content.count / 4
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,49 @@ struct EmailLogRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
var modelId: String?
|
||||
}
|
||||
|
||||
struct MessageMetadataRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
static let databaseTableName = "message_metadata"
|
||||
|
||||
var message_id: String
|
||||
var importance_score: Double
|
||||
var user_starred: Int
|
||||
var summary: String?
|
||||
var chunk_index: Int
|
||||
}
|
||||
|
||||
struct MessageEmbeddingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
static let databaseTableName = "message_embeddings"
|
||||
|
||||
var message_id: String
|
||||
var embedding: Data
|
||||
var embedding_model: String
|
||||
var embedding_dimension: Int
|
||||
var created_at: String
|
||||
}
|
||||
|
||||
struct ConversationEmbeddingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
static let databaseTableName = "conversation_embeddings"
|
||||
|
||||
var conversation_id: String
|
||||
var embedding: Data
|
||||
var embedding_model: String
|
||||
var embedding_dimension: Int
|
||||
var created_at: String
|
||||
}
|
||||
|
||||
struct ConversationSummaryRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
static let databaseTableName = "conversation_summaries"
|
||||
|
||||
var id: String
|
||||
var conversation_id: String
|
||||
var start_message_index: Int
|
||||
var end_message_index: Int
|
||||
var summary: String
|
||||
var token_count: Int?
|
||||
var created_at: String
|
||||
var summary_model: String?
|
||||
}
|
||||
|
||||
// MARK: - DatabaseService
|
||||
|
||||
final class DatabaseService: Sendable {
|
||||
@@ -182,6 +225,73 @@ final class DatabaseService: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
migrator.registerMigration("v6") { db in
|
||||
// Message metadata for smart context selection
|
||||
try db.create(table: "message_metadata") { t in
|
||||
t.primaryKey("message_id", .text)
|
||||
.references("messages", onDelete: .cascade)
|
||||
t.column("importance_score", .double).notNull().defaults(to: 0.0)
|
||||
t.column("user_starred", .integer).notNull().defaults(to: 0)
|
||||
t.column("summary", .text)
|
||||
t.column("chunk_index", .integer).notNull().defaults(to: 0)
|
||||
}
|
||||
|
||||
try db.create(
|
||||
index: "idx_message_metadata_importance",
|
||||
on: "message_metadata",
|
||||
columns: ["importance_score"]
|
||||
)
|
||||
|
||||
try db.create(
|
||||
index: "idx_message_metadata_starred",
|
||||
on: "message_metadata",
|
||||
columns: ["user_starred"]
|
||||
)
|
||||
}
|
||||
|
||||
migrator.registerMigration("v7") { db in
|
||||
// Message embeddings for semantic search
|
||||
try db.create(table: "message_embeddings") { t in
|
||||
t.primaryKey("message_id", .text)
|
||||
.references("messages", onDelete: .cascade)
|
||||
t.column("embedding", .blob).notNull()
|
||||
t.column("embedding_model", .text).notNull()
|
||||
t.column("embedding_dimension", .integer).notNull()
|
||||
t.column("created_at", .text).notNull()
|
||||
}
|
||||
|
||||
// Conversation embeddings (aggregate of all messages)
|
||||
try db.create(table: "conversation_embeddings") { t in
|
||||
t.primaryKey("conversation_id", .text)
|
||||
.references("conversations", onDelete: .cascade)
|
||||
t.column("embedding", .blob).notNull()
|
||||
t.column("embedding_model", .text).notNull()
|
||||
t.column("embedding_dimension", .integer).notNull()
|
||||
t.column("created_at", .text).notNull()
|
||||
}
|
||||
}
|
||||
|
||||
migrator.registerMigration("v8") { db in
|
||||
// Conversation summaries for progressive summarization
|
||||
try db.create(table: "conversation_summaries") { t in
|
||||
t.primaryKey("id", .text)
|
||||
t.column("conversation_id", .text).notNull()
|
||||
.references("conversations", onDelete: .cascade)
|
||||
t.column("start_message_index", .integer).notNull()
|
||||
t.column("end_message_index", .integer).notNull()
|
||||
t.column("summary", .text).notNull()
|
||||
t.column("token_count", .integer)
|
||||
t.column("created_at", .text).notNull()
|
||||
t.column("summary_model", .text)
|
||||
}
|
||||
|
||||
try db.create(
|
||||
index: "idx_conversation_summaries_conv",
|
||||
on: "conversation_summaries",
|
||||
columns: ["conversation_id"]
|
||||
)
|
||||
}
|
||||
|
||||
return migrator
|
||||
}
|
||||
|
||||
@@ -574,4 +684,202 @@ final class DatabaseService: Sendable {
|
||||
try EmailLogRecord.fetchCount(db)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Metadata Operations
|
||||
|
||||
nonisolated func getMessageMetadata(messageId: UUID) throws -> MessageMetadataRecord? {
|
||||
try dbQueue.read { db in
|
||||
try MessageMetadataRecord.fetchOne(db, key: messageId.uuidString)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func setMessageStarred(messageId: UUID, starred: Bool) throws {
|
||||
try dbQueue.write { db in
|
||||
// Check if metadata exists
|
||||
if var existing = try MessageMetadataRecord.fetchOne(db, key: messageId.uuidString) {
|
||||
existing.user_starred = starred ? 1 : 0
|
||||
try existing.update(db)
|
||||
} else {
|
||||
// Create new metadata record
|
||||
let record = MessageMetadataRecord(
|
||||
message_id: messageId.uuidString,
|
||||
importance_score: 0.0,
|
||||
user_starred: starred ? 1 : 0,
|
||||
summary: nil,
|
||||
chunk_index: 0
|
||||
)
|
||||
try record.insert(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func setMessageImportance(messageId: UUID, score: Double) throws {
|
||||
try dbQueue.write { db in
|
||||
if var existing = try MessageMetadataRecord.fetchOne(db, key: messageId.uuidString) {
|
||||
existing.importance_score = score
|
||||
try existing.update(db)
|
||||
} else {
|
||||
let record = MessageMetadataRecord(
|
||||
message_id: messageId.uuidString,
|
||||
importance_score: score,
|
||||
user_starred: 0,
|
||||
summary: nil,
|
||||
chunk_index: 0
|
||||
)
|
||||
try record.insert(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func getStarredMessages(conversationId: UUID) throws -> [String] {
|
||||
try dbQueue.read { db in
|
||||
let sql = """
|
||||
SELECT mm.message_id
|
||||
FROM message_metadata mm
|
||||
JOIN messages m ON m.id = mm.message_id
|
||||
WHERE m.conversationId = ? AND mm.user_starred = 1
|
||||
ORDER BY m.sortOrder
|
||||
"""
|
||||
return try String.fetchAll(db, sql: sql, arguments: [conversationId.uuidString])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Embedding Operations
|
||||
|
||||
nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws {
|
||||
let now = isoFormatter.string(from: Date())
|
||||
let record = MessageEmbeddingRecord(
|
||||
message_id: messageId.uuidString,
|
||||
embedding: embedding,
|
||||
embedding_model: model,
|
||||
embedding_dimension: dimension,
|
||||
created_at: now
|
||||
)
|
||||
try dbQueue.write { db in
|
||||
try record.save(db)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func getMessageEmbedding(messageId: UUID) throws -> Data? {
|
||||
try dbQueue.read { db in
|
||||
try MessageEmbeddingRecord.fetchOne(db, key: messageId.uuidString)?.embedding
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws {
|
||||
let now = isoFormatter.string(from: Date())
|
||||
let record = ConversationEmbeddingRecord(
|
||||
conversation_id: conversationId.uuidString,
|
||||
embedding: embedding,
|
||||
embedding_model: model,
|
||||
embedding_dimension: dimension,
|
||||
created_at: now
|
||||
)
|
||||
try dbQueue.write { db in
|
||||
try record.save(db)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func getConversationEmbedding(conversationId: UUID) throws -> Data? {
|
||||
try dbQueue.read { db in
|
||||
try ConversationEmbeddingRecord.fetchOne(db, key: conversationId.uuidString)?.embedding
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func getAllConversationEmbeddings() throws -> [(UUID, Data)] {
|
||||
try dbQueue.read { db in
|
||||
let records = try ConversationEmbeddingRecord.fetchAll(db)
|
||||
return records.compactMap { record in
|
||||
guard let id = UUID(uuidString: record.conversation_id) else { return nil }
|
||||
return (id, record.embedding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Search conversations by semantic similarity
|
||||
nonisolated func searchConversationsBySemantic(queryEmbedding: [Float], limit: Int = 10) throws -> [(Conversation, Float)] {
|
||||
// Get all conversations
|
||||
let allConversations = try listConversations()
|
||||
|
||||
// Get all conversation embeddings
|
||||
let embeddingData = try getAllConversationEmbeddings()
|
||||
let embeddingMap = Dictionary(uniqueKeysWithValues: embeddingData)
|
||||
|
||||
// Calculate similarity scores
|
||||
var results: [(Conversation, Float)] = []
|
||||
for conv in allConversations {
|
||||
guard let embeddingData = embeddingMap[conv.id] else { continue }
|
||||
|
||||
// Deserialize embedding
|
||||
let embedding = deserializeEmbedding(embeddingData)
|
||||
|
||||
// Calculate cosine similarity
|
||||
let similarity = EmbeddingService.shared.cosineSimilarity(queryEmbedding, embedding)
|
||||
results.append((conv, similarity))
|
||||
}
|
||||
|
||||
// Sort by similarity (highest first) and take top N
|
||||
results.sort { $0.1 > $1.1 }
|
||||
return Array(results.prefix(limit))
|
||||
}
|
||||
|
||||
private func deserializeEmbedding(_ data: Data) -> [Float] {
|
||||
var embedding: [Float] = []
|
||||
embedding.reserveCapacity(data.count / 4)
|
||||
|
||||
for offset in stride(from: 0, to: data.count, by: 4) {
|
||||
let bytes = data.subdata(in: offset..<(offset + 4))
|
||||
let bitPattern = bytes.withUnsafeBytes { $0.load(as: UInt32.self) }
|
||||
let value = Float(bitPattern: UInt32(littleEndian: bitPattern))
|
||||
embedding.append(value)
|
||||
}
|
||||
|
||||
return embedding
|
||||
}
|
||||
|
||||
// MARK: - Conversation Summary Operations
|
||||
|
||||
nonisolated func saveConversationSummary(
|
||||
conversationId: UUID,
|
||||
startIndex: Int,
|
||||
endIndex: Int,
|
||||
summary: String,
|
||||
model: String?,
|
||||
tokenCount: Int?
|
||||
) throws {
|
||||
let now = isoFormatter.string(from: Date())
|
||||
let record = ConversationSummaryRecord(
|
||||
id: UUID().uuidString,
|
||||
conversation_id: conversationId.uuidString,
|
||||
start_message_index: startIndex,
|
||||
end_message_index: endIndex,
|
||||
summary: summary,
|
||||
token_count: tokenCount,
|
||||
created_at: now,
|
||||
summary_model: model
|
||||
)
|
||||
try dbQueue.write { db in
|
||||
try record.insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func getConversationSummaries(conversationId: UUID) throws -> [ConversationSummaryRecord] {
|
||||
try dbQueue.read { db in
|
||||
try ConversationSummaryRecord
|
||||
.filter(Column("conversation_id") == conversationId.uuidString)
|
||||
.order(Column("start_message_index"))
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func hasSummaryForRange(conversationId: UUID, startIndex: Int, endIndex: Int) throws -> Bool {
|
||||
try dbQueue.read { db in
|
||||
let count = try ConversationSummaryRecord
|
||||
.filter(Column("conversation_id") == conversationId.uuidString)
|
||||
.filter(Column("start_message_index") == startIndex)
|
||||
.filter(Column("end_message_index") == endIndex)
|
||||
.fetchCount(db)
|
||||
return count > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,15 +180,32 @@ final class EmailHandlerService {
|
||||
let response = try await provider.chat(request: request)
|
||||
|
||||
let fullResponse = response.content
|
||||
let promptTokens = response.usage?.promptTokens
|
||||
let completionTokens = response.usage?.completionTokens
|
||||
let totalTokens = response.usage.map { $0.promptTokens + $0.completionTokens }
|
||||
let totalCost: Double? = nil // Calculate if provider supports it
|
||||
|
||||
// Calculate cost if we have pricing info
|
||||
var totalCost: Double? = nil
|
||||
if let usage = response.usage,
|
||||
let models = try? await provider.listModels(),
|
||||
let modelInfo = models.first(where: { $0.id == settings.emailHandlerModel }) {
|
||||
totalCost = (Double(usage.promptTokens) * modelInfo.pricing.prompt / 1_000_000) +
|
||||
(Double(usage.completionTokens) * modelInfo.pricing.completion / 1_000_000)
|
||||
}
|
||||
|
||||
let responseTime = Date().timeIntervalSince(startTime)
|
||||
|
||||
log.info("AI response generated in \(String(format: "%.2f", responseTime))s")
|
||||
|
||||
// Generate HTML email
|
||||
let htmlBody = generateHTMLEmail(aiResponse: fullResponse, originalEmail: email)
|
||||
// Generate HTML email with stats
|
||||
let htmlBody = generateHTMLEmail(
|
||||
aiResponse: fullResponse,
|
||||
originalEmail: email,
|
||||
responseTime: responseTime,
|
||||
promptTokens: promptTokens,
|
||||
completionTokens: completionTokens,
|
||||
cost: totalCost
|
||||
)
|
||||
|
||||
// Send response email
|
||||
let replySubject = email.subject.hasPrefix("Re:") ? email.subject : "Re: \(email.subject)"
|
||||
@@ -275,10 +292,22 @@ final class EmailHandlerService {
|
||||
|
||||
// MARK: - HTML Email Generation
|
||||
|
||||
private func generateHTMLEmail(aiResponse: String, originalEmail: IncomingEmail) -> String {
|
||||
private func generateHTMLEmail(
|
||||
aiResponse: String,
|
||||
originalEmail: IncomingEmail,
|
||||
responseTime: TimeInterval,
|
||||
promptTokens: Int?,
|
||||
completionTokens: Int?,
|
||||
cost: Double?
|
||||
) -> String {
|
||||
// Convert markdown to HTML (basic implementation)
|
||||
let htmlContent = markdownToHTML(aiResponse)
|
||||
|
||||
// Format stats
|
||||
let timeFormatted = String(format: "%.2f", responseTime)
|
||||
let totalTokens = (promptTokens ?? 0) + (completionTokens ?? 0)
|
||||
let costFormatted = cost.map { String(format: "$%.4f", $0) } ?? "N/A"
|
||||
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -298,12 +327,34 @@ final class EmailHandlerService {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.footer {
|
||||
.stats {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.stats-label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
.stats-value {
|
||||
color: #666;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
code {
|
||||
background: #f5f5f5;
|
||||
@@ -323,6 +374,17 @@ final class EmailHandlerService {
|
||||
<div class="content">
|
||||
\(htmlContent)
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div style="font-weight: 600; margin-bottom: 6px; color: #555;">📊 Processing Stats</div>
|
||||
<div class="stats-grid">
|
||||
<span class="stats-label">Response Time:</span>
|
||||
<span class="stats-value">\(timeFormatted)s</span>
|
||||
<span class="stats-label">Tokens Used:</span>
|
||||
<span class="stats-value">\(totalTokens.formatted()) (\(promptTokens ?? 0) prompt + \(completionTokens ?? 0) completion)</span>
|
||||
<span class="stats-label">Cost:</span>
|
||||
<span class="stats-value">\(costFormatted)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>🤖 This response was generated by AI using oAI Email Handler</p>
|
||||
</div>
|
||||
|
||||
408
oAI/Services/EmbeddingService.swift
Normal file
408
oAI/Services/EmbeddingService.swift
Normal file
@@ -0,0 +1,408 @@
|
||||
//
|
||||
// EmbeddingService.swift
|
||||
// oAI
|
||||
//
|
||||
// Embedding generation and semantic search
|
||||
// Supports multiple providers: OpenAI, OpenRouter, Google
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - Embedding Provider
|
||||
|
||||
enum EmbeddingProvider {
|
||||
case openai(model: String)
|
||||
case openrouter(model: String)
|
||||
case google(model: String)
|
||||
|
||||
var defaultModel: String {
|
||||
switch self {
|
||||
case .openai: return "text-embedding-3-small"
|
||||
case .openrouter: return "openai/text-embedding-3-small"
|
||||
case .google: return "text-embedding-004"
|
||||
}
|
||||
}
|
||||
|
||||
var dimension: Int {
|
||||
switch self {
|
||||
case .openai(let model):
|
||||
return model == "text-embedding-3-large" ? 3072 : 1536
|
||||
case .openrouter(let model):
|
||||
if model.contains("text-embedding-3-large") {
|
||||
return 3072
|
||||
} else if model.contains("qwen3-embedding-8b") {
|
||||
return 8192
|
||||
} else {
|
||||
return 1536 // Default for most models
|
||||
}
|
||||
case .google:
|
||||
return 768
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .openai: return "OpenAI"
|
||||
case .openrouter: return "OpenRouter"
|
||||
case .google: return "Google"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Embedding Service
|
||||
|
||||
final class EmbeddingService {
|
||||
static let shared = EmbeddingService()
|
||||
|
||||
private let settings = SettingsService.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Provider Detection
|
||||
|
||||
/// Get the embedding provider based on user's selection in settings
|
||||
func getSelectedProvider() -> EmbeddingProvider? {
|
||||
let selectedModel = settings.embeddingProvider
|
||||
|
||||
// Map user's selection to provider
|
||||
switch selectedModel {
|
||||
case "openai-small":
|
||||
guard let key = settings.openaiAPIKey, !key.isEmpty else { return nil }
|
||||
return .openai(model: "text-embedding-3-small")
|
||||
case "openai-large":
|
||||
guard let key = settings.openaiAPIKey, !key.isEmpty else { return nil }
|
||||
return .openai(model: "text-embedding-3-large")
|
||||
case "openrouter-openai-small":
|
||||
guard let key = settings.openrouterAPIKey, !key.isEmpty else { return nil }
|
||||
return .openrouter(model: "openai/text-embedding-3-small")
|
||||
case "openrouter-openai-large":
|
||||
guard let key = settings.openrouterAPIKey, !key.isEmpty else { return nil }
|
||||
return .openrouter(model: "openai/text-embedding-3-large")
|
||||
case "openrouter-qwen":
|
||||
guard let key = settings.openrouterAPIKey, !key.isEmpty else { return nil }
|
||||
return .openrouter(model: "qwen/qwen3-embedding-8b")
|
||||
case "google-gemini":
|
||||
guard let key = settings.googleAPIKey, !key.isEmpty else { return nil }
|
||||
return .google(model: "text-embedding-004")
|
||||
default:
|
||||
// Fall back to best available
|
||||
return getBestAvailableProvider()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the best available embedding provider based on user's API keys (priority: OpenAI → OpenRouter → Google)
|
||||
func getBestAvailableProvider() -> EmbeddingProvider? {
|
||||
// Priority: OpenAI → OpenRouter → Google
|
||||
if let key = settings.openaiAPIKey, !key.isEmpty {
|
||||
return .openai(model: "text-embedding-3-small")
|
||||
}
|
||||
|
||||
if let key = settings.openrouterAPIKey, !key.isEmpty {
|
||||
return .openrouter(model: "openai/text-embedding-3-small")
|
||||
}
|
||||
|
||||
if let key = settings.googleAPIKey, !key.isEmpty {
|
||||
return .google(model: "text-embedding-004")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Check if embeddings are available (user has at least one compatible provider)
|
||||
var isAvailable: Bool {
|
||||
return getBestAvailableProvider() != nil
|
||||
}
|
||||
|
||||
// MARK: - Embedding Generation
|
||||
|
||||
/// Generate embedding for text using the configured provider
|
||||
func generateEmbedding(text: String, provider: EmbeddingProvider) async throws -> [Float] {
|
||||
switch provider {
|
||||
case .openai(let model):
|
||||
return try await generateOpenAIEmbedding(text: text, model: model)
|
||||
case .openrouter(let model):
|
||||
return try await generateOpenRouterEmbedding(text: text, model: model)
|
||||
case .google(let model):
|
||||
return try await generateGoogleEmbedding(text: text, model: model)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate OpenAI embedding
|
||||
private func generateOpenAIEmbedding(text: String, model: String) async throws -> [Float] {
|
||||
guard let apiKey = settings.openaiAPIKey, !apiKey.isEmpty else {
|
||||
throw EmbeddingError.missingAPIKey("OpenAI")
|
||||
}
|
||||
|
||||
let url = URL(string: "https://api.openai.com/v1/embeddings")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body: [String: Any] = [
|
||||
"input": text,
|
||||
"model": model
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw EmbeddingError.invalidResponse
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
Log.api.error("OpenAI embedding error (\(httpResponse.statusCode)): \(errorMessage)")
|
||||
throw EmbeddingError.apiError(httpResponse.statusCode, errorMessage)
|
||||
}
|
||||
|
||||
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
guard let dataArray = json?["data"] as? [[String: Any]],
|
||||
let first = dataArray.first,
|
||||
let embedding = first["embedding"] as? [Double] else {
|
||||
throw EmbeddingError.invalidResponse
|
||||
}
|
||||
|
||||
return embedding.map { Float($0) }
|
||||
}
|
||||
|
||||
/// Generate OpenRouter embedding (OpenAI-compatible API)
|
||||
private func generateOpenRouterEmbedding(text: String, model: String) async throws -> [Float] {
|
||||
guard let apiKey = settings.openrouterAPIKey, !apiKey.isEmpty else {
|
||||
throw EmbeddingError.missingAPIKey("OpenRouter")
|
||||
}
|
||||
|
||||
let url = URL(string: "https://openrouter.ai/api/v1/embeddings")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
|
||||
|
||||
let body: [String: Any] = [
|
||||
"input": text,
|
||||
"model": model
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw EmbeddingError.invalidResponse
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
Log.api.error("OpenRouter embedding error (\(httpResponse.statusCode)): \(errorMessage)")
|
||||
throw EmbeddingError.apiError(httpResponse.statusCode, errorMessage)
|
||||
}
|
||||
|
||||
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
guard let dataArray = json?["data"] as? [[String: Any]],
|
||||
let first = dataArray.first,
|
||||
let embedding = first["embedding"] as? [Double] else {
|
||||
throw EmbeddingError.invalidResponse
|
||||
}
|
||||
|
||||
return embedding.map { Float($0) }
|
||||
}
|
||||
|
||||
/// Generate Google embedding
|
||||
private func generateGoogleEmbedding(text: String, model: String) async throws -> [Float] {
|
||||
guard let apiKey = settings.googleAPIKey, !apiKey.isEmpty else {
|
||||
throw EmbeddingError.missingAPIKey("Google")
|
||||
}
|
||||
|
||||
let url = URL(string: "https://generativelanguage.googleapis.com/v1beta/models/\(model):embedContent?key=\(apiKey)")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body: [String: Any] = [
|
||||
"content": [
|
||||
"parts": [
|
||||
["text": text]
|
||||
]
|
||||
]
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw EmbeddingError.invalidResponse
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
Log.api.error("Google embedding error (\(httpResponse.statusCode)): \(errorMessage)")
|
||||
throw EmbeddingError.apiError(httpResponse.statusCode, errorMessage)
|
||||
}
|
||||
|
||||
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
guard let embedding = json?["embedding"] as? [String: Any],
|
||||
let values = embedding["values"] as? [Double] else {
|
||||
throw EmbeddingError.invalidResponse
|
||||
}
|
||||
|
||||
return values.map { Float($0) }
|
||||
}
|
||||
|
||||
// MARK: - Similarity Calculation
|
||||
|
||||
/// Calculate cosine similarity between two embeddings
|
||||
func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
|
||||
guard a.count == b.count else {
|
||||
Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)")
|
||||
return 0.0
|
||||
}
|
||||
|
||||
var dotProduct: Float = 0.0
|
||||
var magnitudeA: Float = 0.0
|
||||
var magnitudeB: Float = 0.0
|
||||
|
||||
for i in 0..<a.count {
|
||||
dotProduct += a[i] * b[i]
|
||||
magnitudeA += a[i] * a[i]
|
||||
magnitudeB += b[i] * b[i]
|
||||
}
|
||||
|
||||
magnitudeA = sqrt(magnitudeA)
|
||||
magnitudeB = sqrt(magnitudeB)
|
||||
|
||||
guard magnitudeA > 0 && magnitudeB > 0 else {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
return dotProduct / (magnitudeA * magnitudeB)
|
||||
}
|
||||
|
||||
// MARK: - Database Operations
|
||||
|
||||
/// Save message embedding to database
|
||||
func saveMessageEmbedding(messageId: UUID, embedding: [Float], model: String) throws {
|
||||
let data = serializeEmbedding(embedding)
|
||||
try DatabaseService.shared.saveMessageEmbedding(
|
||||
messageId: messageId,
|
||||
embedding: data,
|
||||
model: model,
|
||||
dimension: embedding.count
|
||||
)
|
||||
}
|
||||
|
||||
/// Get message embedding from database
|
||||
func getMessageEmbedding(messageId: UUID) throws -> [Float]? {
|
||||
guard let data = try DatabaseService.shared.getMessageEmbedding(messageId: messageId) else {
|
||||
return nil
|
||||
}
|
||||
return deserializeEmbedding(data)
|
||||
}
|
||||
|
||||
/// Save conversation embedding to database
|
||||
func saveConversationEmbedding(conversationId: UUID, embedding: [Float], model: String) throws {
|
||||
let data = serializeEmbedding(embedding)
|
||||
try DatabaseService.shared.saveConversationEmbedding(
|
||||
conversationId: conversationId,
|
||||
embedding: data,
|
||||
model: model,
|
||||
dimension: embedding.count
|
||||
)
|
||||
}
|
||||
|
||||
/// Get conversation embedding from database
|
||||
func getConversationEmbedding(conversationId: UUID) throws -> [Float]? {
|
||||
guard let data = try DatabaseService.shared.getConversationEmbedding(conversationId: conversationId) else {
|
||||
return nil
|
||||
}
|
||||
return deserializeEmbedding(data)
|
||||
}
|
||||
|
||||
// MARK: - Serialization
|
||||
|
||||
/// Serialize embedding to binary data (4 bytes per float, little-endian)
|
||||
private func serializeEmbedding(_ embedding: [Float]) -> Data {
|
||||
var data = Data(capacity: embedding.count * 4)
|
||||
for value in embedding {
|
||||
var littleEndian = value.bitPattern.littleEndian
|
||||
withUnsafeBytes(of: &littleEndian) { bytes in
|
||||
data.append(contentsOf: bytes)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/// Deserialize embedding from binary data
|
||||
private func deserializeEmbedding(_ data: Data) -> [Float] {
|
||||
var embedding: [Float] = []
|
||||
embedding.reserveCapacity(data.count / 4)
|
||||
|
||||
for offset in stride(from: 0, to: data.count, by: 4) {
|
||||
let bytes = data.subdata(in: offset..<(offset + 4))
|
||||
let bitPattern = bytes.withUnsafeBytes { $0.load(as: UInt32.self) }
|
||||
let value = Float(bitPattern: UInt32(littleEndian: bitPattern))
|
||||
embedding.append(value)
|
||||
}
|
||||
|
||||
return embedding
|
||||
}
|
||||
|
||||
// MARK: - Conversation Embedding Generation
|
||||
|
||||
/// Generate embedding for an entire conversation (aggregate of messages)
|
||||
func generateConversationEmbedding(conversationId: UUID) async throws {
|
||||
// Use user's selected provider, or fall back to best available
|
||||
guard let provider = getSelectedProvider() else {
|
||||
throw EmbeddingError.noProvidersAvailable
|
||||
}
|
||||
|
||||
// Load conversation messages
|
||||
guard let (_, messages) = try? DatabaseService.shared.loadConversation(id: conversationId) else {
|
||||
throw EmbeddingError.conversationNotFound
|
||||
}
|
||||
|
||||
// Combine all message content
|
||||
let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
|
||||
let combinedText = chatMessages.map { $0.content }.joined(separator: "\n\n")
|
||||
|
||||
// Truncate if too long (8191 tokens max for most embedding models)
|
||||
let truncated = String(combinedText.prefix(30000)) // ~7500 tokens
|
||||
|
||||
// Generate embedding
|
||||
let embedding = try await generateEmbedding(text: truncated, provider: provider)
|
||||
|
||||
// Save to database
|
||||
try saveConversationEmbedding(conversationId: conversationId, embedding: embedding, model: provider.defaultModel)
|
||||
|
||||
Log.api.info("Generated conversation embedding for \(conversationId) using \(provider.displayName) (\(embedding.count) dimensions)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum EmbeddingError: LocalizedError {
|
||||
case missingAPIKey(String)
|
||||
case invalidResponse
|
||||
case apiError(Int, String)
|
||||
case providerNotImplemented(String)
|
||||
case conversationNotFound
|
||||
case noProvidersAvailable
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingAPIKey(let provider):
|
||||
return "\(provider) API key not configured"
|
||||
case .invalidResponse:
|
||||
return "Invalid response from embedding API"
|
||||
case .apiError(let code, let message):
|
||||
return "Embedding API error (\(code)): \(message)"
|
||||
case .providerNotImplemented(let message):
|
||||
return message
|
||||
case .conversationNotFound:
|
||||
return "Conversation not found"
|
||||
case .noProvidersAvailable:
|
||||
return "No embedding providers available. Please configure an API key for OpenAI, OpenRouter, or Google."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,6 +363,36 @@ class GitSyncService {
|
||||
|
||||
// MARK: - Auto-Sync
|
||||
|
||||
/// Sync on app startup (pull + import only, no push)
|
||||
/// Runs silently in background to fetch changes from other devices
|
||||
func syncOnStartup() async {
|
||||
// Only run if configured and cloned
|
||||
guard settings.syncConfigured && syncStatus.isCloned else {
|
||||
log.debug("Skipping startup sync (not configured or not cloned)")
|
||||
return
|
||||
}
|
||||
|
||||
log.info("Running startup sync (pull + import)...")
|
||||
|
||||
do {
|
||||
// Pull latest changes
|
||||
try await pull()
|
||||
|
||||
// Import any new/updated conversations
|
||||
let result = try await importAllConversations()
|
||||
|
||||
if result.imported > 0 {
|
||||
log.info("Startup sync: imported \(result.imported) conversations")
|
||||
} else {
|
||||
log.debug("Startup sync: no new conversations to import")
|
||||
}
|
||||
|
||||
} catch {
|
||||
// Don't block app startup on sync errors
|
||||
log.warning("Startup sync failed (non-fatal): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform auto-sync with debouncing (export + push)
|
||||
/// Debounces multiple rapid sync requests to avoid spamming git
|
||||
func autoSync() async {
|
||||
|
||||
@@ -146,6 +146,54 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
var contextSelectionEnabled: Bool {
|
||||
get { cache["contextSelectionEnabled"] == "true" }
|
||||
set {
|
||||
cache["contextSelectionEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "contextSelectionEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var contextMaxTokens: Int {
|
||||
get { cache["contextMaxTokens"].flatMap(Int.init) ?? 100_000 }
|
||||
set {
|
||||
cache["contextMaxTokens"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "contextMaxTokens", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var embeddingsEnabled: Bool {
|
||||
get { cache["embeddingsEnabled"] == "true" }
|
||||
set {
|
||||
cache["embeddingsEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "embeddingsEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var embeddingProvider: String {
|
||||
get { cache["embeddingProvider"] ?? "openai-small" }
|
||||
set {
|
||||
cache["embeddingProvider"] = newValue
|
||||
DatabaseService.shared.setSetting(key: "embeddingProvider", value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
var progressiveSummarizationEnabled: Bool {
|
||||
get { cache["progressiveSummarizationEnabled"] == "true" }
|
||||
set {
|
||||
cache["progressiveSummarizationEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "progressiveSummarizationEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var summarizationThreshold: Int {
|
||||
get { cache["summarizationThreshold"].flatMap(Int.init) ?? 50 }
|
||||
set {
|
||||
cache["summarizationThreshold"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "summarizationThreshold", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var mcpEnabled: Bool {
|
||||
get { cache["mcpEnabled"] == "true" }
|
||||
set {
|
||||
|
||||
@@ -266,6 +266,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
messages.append(userMessage)
|
||||
sessionStats.addMessage(inputTokens: userMessage.tokens, outputTokens: nil, cost: nil)
|
||||
|
||||
// Generate embedding for user message
|
||||
generateEmbeddingForMessage(userMessage)
|
||||
|
||||
// Add to command history (in-memory and database)
|
||||
commandHistory.append(trimmedInput)
|
||||
historyIndex = commandHistory.count
|
||||
@@ -374,15 +377,28 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
showSystemMessage("No previous message to retry")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Remove last assistant response if exists
|
||||
if let lastMessage = messages.last, lastMessage.role == .assistant {
|
||||
messages.removeLast()
|
||||
}
|
||||
|
||||
|
||||
generateAIResponse(to: lastUserMessage.content, attachments: lastUserMessage.attachments)
|
||||
}
|
||||
|
||||
|
||||
func toggleMessageStar(messageId: UUID) {
|
||||
// Get current starred status
|
||||
let isStarred = (try? DatabaseService.shared.getMessageMetadata(messageId: messageId)?.user_starred == 1) ?? false
|
||||
|
||||
// Toggle starred status
|
||||
do {
|
||||
try DatabaseService.shared.setMessageStarred(messageId: messageId, starred: !isStarred)
|
||||
Log.ui.info("Message \(messageId) starred: \(!isStarred)")
|
||||
} catch {
|
||||
Log.ui.error("Failed to toggle star for message \(messageId): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command Handling
|
||||
|
||||
private func handleCommand(_ command: String) {
|
||||
@@ -586,14 +602,46 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
if isImageGen {
|
||||
Log.ui.info("Image generation mode for model \(modelId)")
|
||||
}
|
||||
|
||||
// Smart context selection
|
||||
let contextStrategy: SelectionStrategy
|
||||
if !memoryEnabled {
|
||||
contextStrategy = .lastMessageOnly
|
||||
} else if settings.contextSelectionEnabled {
|
||||
contextStrategy = .smart
|
||||
} else {
|
||||
contextStrategy = .allMessages
|
||||
}
|
||||
|
||||
let contextWindow = ContextSelectionService.shared.selectContext(
|
||||
allMessages: messagesToSend,
|
||||
strategy: contextStrategy,
|
||||
maxTokens: selectedModel?.contextLength ?? settings.contextMaxTokens,
|
||||
currentQuery: messagesToSend.last?.content
|
||||
)
|
||||
|
||||
if contextWindow.excludedCount > 0 {
|
||||
Log.ui.info("Smart context: selected \(contextWindow.messages.count) messages (\(contextWindow.totalTokens) tokens), excluded \(contextWindow.excludedCount)")
|
||||
}
|
||||
|
||||
// Build system prompt with summaries (if any)
|
||||
var finalSystemPrompt = effectiveSystemPrompt
|
||||
if !contextWindow.summaries.isEmpty {
|
||||
let summariesText = contextWindow.summaries.enumerated().map { index, summary in
|
||||
"[Previous conversation summary (part \(index + 1)):]\\n\(summary)"
|
||||
}.joined(separator: "\n\n")
|
||||
|
||||
finalSystemPrompt = summariesText + "\n\n---\n\n" + effectiveSystemPrompt
|
||||
}
|
||||
|
||||
let chatRequest = ChatRequest(
|
||||
messages: Array(memoryEnabled ? messagesToSend : [messagesToSend.last!]),
|
||||
messages: contextWindow.messages,
|
||||
model: modelId,
|
||||
stream: settings.streamEnabled,
|
||||
maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
|
||||
temperature: settings.temperature > 0 ? settings.temperature : nil,
|
||||
topP: nil,
|
||||
systemPrompt: effectiveSystemPrompt,
|
||||
systemPrompt: finalSystemPrompt,
|
||||
tools: nil,
|
||||
onlineMode: onlineMode,
|
||||
imageGeneration: isImageGen
|
||||
@@ -680,6 +728,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate embedding for assistant message
|
||||
generateEmbeddingForMessage(messages[index])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1363,6 +1414,25 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
|
||||
Log.ui.info("Auto-saved conversation: \(conversationName)")
|
||||
|
||||
// Check if progressive summarization is needed
|
||||
Task {
|
||||
await checkAndSummarizeOldMessages(conversationId: conversation.id)
|
||||
}
|
||||
|
||||
// Generate embeddings for messages that don't have them yet
|
||||
if settings.embeddingsEnabled {
|
||||
Task {
|
||||
for message in chatMessages {
|
||||
// Skip if already embedded
|
||||
if let _ = try? EmbeddingService.shared.getMessageEmbedding(messageId: message.id) {
|
||||
continue
|
||||
}
|
||||
// Generate embedding (this will now succeed since message is in DB)
|
||||
generateEmbeddingForMessage(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as saved to prevent duplicate saves
|
||||
let conversationHash = chatMessages.map { $0.content }.joined()
|
||||
settings.syncLastAutoSaveConversationId = conversationHash
|
||||
@@ -1480,4 +1550,207 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
await autoSaveConversation()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Embedding Generation
|
||||
|
||||
/// Generate embedding for a message in the background
|
||||
func generateEmbeddingForMessage(_ message: Message) {
|
||||
guard settings.embeddingsEnabled else { return }
|
||||
guard message.content.count > 20 else { return } // Skip very short messages
|
||||
|
||||
Task {
|
||||
do {
|
||||
// Use user's selected provider, or fall back to best available
|
||||
guard let provider = EmbeddingService.shared.getSelectedProvider() else {
|
||||
Log.api.warning("No embedding providers available - skipping embedding generation")
|
||||
return
|
||||
}
|
||||
|
||||
let embedding = try await EmbeddingService.shared.generateEmbedding(
|
||||
text: message.content,
|
||||
provider: provider
|
||||
)
|
||||
try EmbeddingService.shared.saveMessageEmbedding(
|
||||
messageId: message.id,
|
||||
embedding: embedding,
|
||||
model: provider.defaultModel
|
||||
)
|
||||
Log.api.info("Generated embedding for message \(message.id) using \(provider.displayName)")
|
||||
} catch {
|
||||
// Check if it's a foreign key constraint error (message not saved to DB yet)
|
||||
let errorString = String(describing: error)
|
||||
if errorString.contains("FOREIGN KEY constraint failed") {
|
||||
Log.api.debug("Message \(message.id) not in database yet - will embed later during save or batch operation")
|
||||
} else {
|
||||
Log.api.error("Failed to generate embedding for message \(message.id): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch generate embeddings for all messages in all conversations
|
||||
func batchEmbedAllConversations() async {
|
||||
guard settings.embeddingsEnabled else {
|
||||
showSystemMessage("Embeddings are disabled. Enable them in Settings > Advanced.")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have an embedding provider available
|
||||
guard let provider = EmbeddingService.shared.getSelectedProvider() else {
|
||||
showSystemMessage("⚠️ No embedding provider available. Please configure an API key for OpenAI, OpenRouter, or Google.")
|
||||
return
|
||||
}
|
||||
|
||||
showSystemMessage("Starting batch embedding generation using \(provider.displayName)...")
|
||||
|
||||
let conversations = (try? DatabaseService.shared.listConversations()) ?? []
|
||||
var processedMessages = 0
|
||||
var skippedMessages = 0
|
||||
|
||||
for conv in conversations {
|
||||
guard let (_, messages) = try? DatabaseService.shared.loadConversation(id: conv.id) else {
|
||||
continue
|
||||
}
|
||||
|
||||
for message in messages {
|
||||
// Skip if already embedded
|
||||
if let _ = try? EmbeddingService.shared.getMessageEmbedding(messageId: message.id) {
|
||||
skippedMessages += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip very short messages
|
||||
guard message.content.count > 20 else {
|
||||
skippedMessages += 1
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
let embedding = try await EmbeddingService.shared.generateEmbedding(
|
||||
text: message.content,
|
||||
provider: provider
|
||||
)
|
||||
try EmbeddingService.shared.saveMessageEmbedding(
|
||||
messageId: message.id,
|
||||
embedding: embedding,
|
||||
model: provider.defaultModel
|
||||
)
|
||||
processedMessages += 1
|
||||
|
||||
// Rate limit: 10 embeddings/sec
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
} catch {
|
||||
Log.api.error("Failed to generate embedding for message \(message.id): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// Generate conversation embedding
|
||||
do {
|
||||
try await EmbeddingService.shared.generateConversationEmbedding(conversationId: conv.id)
|
||||
} catch {
|
||||
Log.api.error("Failed to generate conversation embedding for \(conv.id): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
showSystemMessage("Batch embedding complete: \(processedMessages) messages processed, \(skippedMessages) skipped")
|
||||
Log.ui.info("Batch embedding complete: \(processedMessages) messages, \(skippedMessages) skipped")
|
||||
}
|
||||
|
||||
// MARK: - Progressive Summarization
|
||||
|
||||
/// Check if conversation needs summarization and create summaries if needed
|
||||
func checkAndSummarizeOldMessages(conversationId: UUID) async {
|
||||
guard settings.progressiveSummarizationEnabled else { return }
|
||||
|
||||
let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
|
||||
let threshold = settings.summarizationThreshold
|
||||
|
||||
guard chatMessages.count > threshold else { return }
|
||||
|
||||
// Calculate which chunk to summarize (messages 0 to threshold-20)
|
||||
let chunkEnd = threshold - 20
|
||||
guard chunkEnd > 30 else { return } // Need at least 30 messages to summarize
|
||||
|
||||
// Check if already summarized
|
||||
if let hasSummary = try? DatabaseService.shared.hasSummaryForRange(
|
||||
conversationId: conversationId,
|
||||
startIndex: 0,
|
||||
endIndex: chunkEnd
|
||||
), hasSummary {
|
||||
return // Already summarized
|
||||
}
|
||||
|
||||
// Get messages to summarize
|
||||
let messagesToSummarize = Array(chatMessages.prefix(chunkEnd))
|
||||
|
||||
Log.ui.info("Summarizing messages 0-\(chunkEnd) for conversation \(conversationId)")
|
||||
|
||||
// Generate summary
|
||||
guard let summary = await summarizeMessageChunk(messagesToSummarize) else {
|
||||
Log.ui.error("Failed to generate summary for conversation \(conversationId)")
|
||||
return
|
||||
}
|
||||
|
||||
// Save summary
|
||||
do {
|
||||
try DatabaseService.shared.saveConversationSummary(
|
||||
conversationId: conversationId,
|
||||
startIndex: 0,
|
||||
endIndex: chunkEnd,
|
||||
summary: summary,
|
||||
model: selectedModel?.id,
|
||||
tokenCount: summary.estimateTokens()
|
||||
)
|
||||
Log.ui.info("Saved summary for messages 0-\(chunkEnd)")
|
||||
} catch {
|
||||
Log.ui.error("Failed to save summary: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Summarize a chunk of messages into a concise summary
|
||||
private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
|
||||
guard let provider = providerRegistry.getProvider(for: currentProvider),
|
||||
let modelId = selectedModel?.id else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Combine messages into text
|
||||
let combinedText = messages.map { msg in
|
||||
let role = msg.role == .user ? "User" : "Assistant"
|
||||
return "[\(role)]: \(msg.content)"
|
||||
}.joined(separator: "\n\n")
|
||||
|
||||
// Create summarization prompt
|
||||
let summaryPrompt = """
|
||||
Please create a concise 2-3 paragraph summary of the following conversation.
|
||||
Focus on the main topics discussed, key decisions made, and important information exchanged.
|
||||
Do not include unnecessary details or greetings.
|
||||
|
||||
Conversation:
|
||||
\(combinedText)
|
||||
"""
|
||||
|
||||
let summaryMessage = Message(role: .user, content: summaryPrompt)
|
||||
|
||||
let request = ChatRequest(
|
||||
messages: [summaryMessage],
|
||||
model: modelId,
|
||||
stream: false,
|
||||
maxTokens: 500,
|
||||
temperature: 0.3,
|
||||
topP: nil,
|
||||
systemPrompt: "You are a helpful assistant that creates concise, informative summaries of conversations.",
|
||||
tools: nil,
|
||||
onlineMode: false,
|
||||
imageGeneration: false
|
||||
)
|
||||
|
||||
do {
|
||||
let response = try await provider.chat(request: request)
|
||||
return response.content
|
||||
} catch {
|
||||
Log.api.error("Summary generation failed: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ struct ChatView: View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(viewModel.messages) { message in
|
||||
MessageRow(message: message)
|
||||
MessageRow(message: message, viewModel: viewModel)
|
||||
.id(message.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ struct ContentView: View {
|
||||
.sheet(isPresented: $vm.showSettings, onDismiss: {
|
||||
chatViewModel.syncFromSettings()
|
||||
}) {
|
||||
SettingsView()
|
||||
SettingsView(chatViewModel: chatViewModel)
|
||||
}
|
||||
.sheet(isPresented: $vm.showStats) {
|
||||
StatsView(
|
||||
|
||||
@@ -84,6 +84,7 @@ struct FooterItem: View {
|
||||
|
||||
struct SyncStatusFooter: View {
|
||||
private let gitSync = GitSyncService.shared
|
||||
private let settings = SettingsService.shared
|
||||
private let guiSize = SettingsService.shared.guiTextSize
|
||||
@State private var syncText = "Not Synced"
|
||||
@State private var syncColor: Color = .secondary
|
||||
@@ -104,17 +105,23 @@ struct SyncStatusFooter: View {
|
||||
.onChange(of: gitSync.syncStatus.lastSyncTime) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: gitSync.syncStatus.isCloned) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: gitSync.lastSyncError) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: gitSync.isSyncing) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: settings.syncConfigured) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSyncStatus() {
|
||||
if let error = gitSync.lastSyncError {
|
||||
syncText = "Error With Sync"
|
||||
syncText = "Sync Error"
|
||||
syncColor = .red
|
||||
} else if gitSync.isSyncing {
|
||||
syncText = "Syncing..."
|
||||
@@ -123,10 +130,13 @@ struct SyncStatusFooter: View {
|
||||
syncText = "Last Sync: \(timeAgo(lastSync))"
|
||||
syncColor = .green
|
||||
} else if gitSync.syncStatus.isCloned {
|
||||
syncText = "Not Synced"
|
||||
syncText = "Sync: Ready"
|
||||
syncColor = .secondary
|
||||
} else if settings.syncConfigured {
|
||||
syncText = "Sync: Not Initialized"
|
||||
syncColor = .orange
|
||||
} else {
|
||||
syncText = "Not Configured"
|
||||
syncText = "Sync: Off"
|
||||
syncColor = .secondary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,20 @@ import AppKit
|
||||
|
||||
struct MessageRow: View {
|
||||
let message: Message
|
||||
let viewModel: ChatViewModel?
|
||||
private let settings = SettingsService.shared
|
||||
|
||||
#if os(macOS)
|
||||
@State private var isHovering = false
|
||||
@State private var showCopied = false
|
||||
@State private var isStarred = false
|
||||
#endif
|
||||
|
||||
init(message: Message, viewModel: ChatViewModel? = nil) {
|
||||
self.message = message
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Compact layout for system messages (tool calls)
|
||||
if message.role == .system && !isErrorMessage {
|
||||
@@ -45,6 +52,18 @@ struct MessageRow: View {
|
||||
Spacer()
|
||||
|
||||
#if os(macOS)
|
||||
// Star button (user/assistant messages only, visible on hover)
|
||||
if (message.role == .user || message.role == .assistant) && isHovering {
|
||||
Button(action: toggleStar) {
|
||||
Image(systemName: isStarred ? "star.fill" : "star")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(isStarred ? .yellow : .oaiSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.transition(.opacity)
|
||||
.help("Star this message to always include it in context")
|
||||
}
|
||||
|
||||
// Copy button (assistant messages only, visible on hover)
|
||||
if message.role == .assistant && isHovering && !message.content.isEmpty {
|
||||
Button(action: copyContent) {
|
||||
@@ -138,6 +157,9 @@ struct MessageRow: View {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadStarredState()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -235,6 +257,17 @@ struct MessageRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadStarredState() {
|
||||
if let metadata = try? DatabaseService.shared.getMessageMetadata(messageId: message.id) {
|
||||
isStarred = metadata.user_starred == 1
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleStar() {
|
||||
viewModel?.toggleMessageStar(messageId: message.id)
|
||||
isStarred.toggle()
|
||||
}
|
||||
#endif
|
||||
|
||||
private var roleIcon: some View {
|
||||
|
||||
@@ -14,14 +14,23 @@ struct ConversationListView: View {
|
||||
@State private var conversations: [Conversation] = []
|
||||
@State private var selectedConversations: Set<UUID> = []
|
||||
@State private var isSelecting = false
|
||||
@State private var useSemanticSearch = false
|
||||
@State private var semanticResults: [Conversation] = []
|
||||
@State private var isSearching = false
|
||||
private let settings = SettingsService.shared
|
||||
var onLoad: ((Conversation) -> Void)?
|
||||
|
||||
private var filteredConversations: [Conversation] {
|
||||
if searchText.isEmpty {
|
||||
return conversations
|
||||
}
|
||||
return conversations.filter {
|
||||
$0.name.lowercased().contains(searchText.lowercased())
|
||||
|
||||
if useSemanticSearch && settings.embeddingsEnabled {
|
||||
return semanticResults
|
||||
} else {
|
||||
return conversations.filter {
|
||||
$0.name.lowercased().contains(searchText.lowercased())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +88,11 @@ struct ConversationListView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search conversations...", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
.onChange(of: searchText) {
|
||||
if useSemanticSearch && settings.embeddingsEnabled && !searchText.isEmpty {
|
||||
performSemanticSearch()
|
||||
}
|
||||
}
|
||||
if !searchText.isEmpty {
|
||||
Button { searchText = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
@@ -86,6 +100,25 @@ struct ConversationListView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if settings.embeddingsEnabled {
|
||||
Divider()
|
||||
.frame(height: 16)
|
||||
Toggle("Semantic", isOn: $useSemanticSearch)
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.small)
|
||||
.onChange(of: useSemanticSearch) {
|
||||
if useSemanticSearch && !searchText.isEmpty {
|
||||
performSemanticSearch()
|
||||
}
|
||||
}
|
||||
.help("Use AI-powered semantic search instead of keyword matching")
|
||||
}
|
||||
|
||||
if isSearching {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
|
||||
@@ -231,6 +264,52 @@ struct ConversationListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func performSemanticSearch() {
|
||||
guard !searchText.isEmpty else {
|
||||
semanticResults = []
|
||||
return
|
||||
}
|
||||
|
||||
isSearching = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
// Use user's selected provider, or fall back to best available
|
||||
guard let provider = EmbeddingService.shared.getSelectedProvider() else {
|
||||
Log.api.warning("No embedding providers available - skipping semantic search")
|
||||
await MainActor.run {
|
||||
isSearching = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Generate embedding for search query
|
||||
let embedding = try await EmbeddingService.shared.generateEmbedding(
|
||||
text: searchText,
|
||||
provider: provider
|
||||
)
|
||||
|
||||
// Search conversations
|
||||
let results = try DatabaseService.shared.searchConversationsBySemantic(
|
||||
queryEmbedding: embedding,
|
||||
limit: 20
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
semanticResults = results.map { $0.0 }
|
||||
isSearching = false
|
||||
Log.ui.info("Semantic search found \(results.count) results using \(provider.displayName)")
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
semanticResults = []
|
||||
isSearching = false
|
||||
Log.ui.error("Semantic search failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exportConversation(_ conversation: Conversation) {
|
||||
guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id),
|
||||
!loadedMessages.isEmpty else {
|
||||
|
||||
@@ -12,6 +12,12 @@ struct SettingsView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Bindable private var settingsService = SettingsService.shared
|
||||
private var mcpService = MCPService.shared
|
||||
private let gitSync = GitSyncService.shared
|
||||
var chatViewModel: ChatViewModel?
|
||||
|
||||
init(chatViewModel: ChatViewModel? = nil) {
|
||||
self.chatViewModel = chatViewModel
|
||||
}
|
||||
|
||||
@State private var openrouterKey = ""
|
||||
@State private var anthropicKey = ""
|
||||
@@ -31,6 +37,7 @@ struct SettingsView: View {
|
||||
@State private var showSyncToken = false
|
||||
@State private var isTestingSync = false
|
||||
@State private var syncTestResult: String?
|
||||
@State private var isSyncing = false
|
||||
|
||||
// OAuth state
|
||||
@State private var oauthCode = ""
|
||||
@@ -769,6 +776,141 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
|
||||
divider()
|
||||
|
||||
sectionHeader("Memory & Context")
|
||||
|
||||
row("Smart Context Selection") {
|
||||
Toggle("", isOn: $settingsService.contextSelectionEnabled)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
Text("Automatically select relevant messages instead of sending all history. Reduces token usage and improves response quality for long conversations.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if settingsService.contextSelectionEnabled {
|
||||
row("Max Context Tokens") {
|
||||
HStack(spacing: 8) {
|
||||
TextField("", value: $settingsService.contextMaxTokens, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 120)
|
||||
Text("tokens")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Maximum context window size. Default is 100,000 tokens. Smart selection will prioritize recent and starred messages within this limit.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
sectionHeader("Semantic Search")
|
||||
|
||||
row("Enable Embeddings") {
|
||||
Toggle("", isOn: $settingsService.embeddingsEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.disabled(!EmbeddingService.shared.isAvailable)
|
||||
}
|
||||
|
||||
// Show status based on available providers
|
||||
if let provider = EmbeddingService.shared.getBestAvailableProvider() {
|
||||
Text("Enable AI-powered semantic search across conversations using \(provider.displayName) embeddings.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else {
|
||||
Text("⚠️ No embedding providers available. Please configure an API key for OpenAI, OpenRouter, or Google in the General tab.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.orange)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if settingsService.embeddingsEnabled {
|
||||
row("Model") {
|
||||
Picker("", selection: $settingsService.embeddingProvider) {
|
||||
if settingsService.openaiAPIKey != nil && !settingsService.openaiAPIKey!.isEmpty {
|
||||
Text("OpenAI (text-embedding-3-small)").tag("openai-small")
|
||||
Text("OpenAI (text-embedding-3-large)").tag("openai-large")
|
||||
}
|
||||
if settingsService.openrouterAPIKey != nil && !settingsService.openrouterAPIKey!.isEmpty {
|
||||
Text("OpenRouter (OpenAI small)").tag("openrouter-openai-small")
|
||||
Text("OpenRouter (OpenAI large)").tag("openrouter-openai-large")
|
||||
Text("OpenRouter (Qwen 8B)").tag("openrouter-qwen")
|
||||
}
|
||||
if settingsService.googleAPIKey != nil && !settingsService.googleAPIKey!.isEmpty {
|
||||
Text("Google (Gemini embedding)").tag("google-gemini")
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Cost: OpenAI ~$0.02-0.13/1M tokens, OpenRouter similar, Google ~$0.15/1M tokens")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Button("Embed All Conversations") {
|
||||
Task {
|
||||
if let chatVM = chatViewModel {
|
||||
await chatVM.batchEmbedAllConversations()
|
||||
}
|
||||
}
|
||||
}
|
||||
.help("Generate embeddings for all existing messages (one-time operation)")
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("⚠️ This will generate embeddings for all messages in all conversations. Estimated cost: ~$0.04 for 10,000 messages.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.orange)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
sectionHeader("Progressive Summarization")
|
||||
|
||||
row("Enable Summarization") {
|
||||
Toggle("", isOn: $settingsService.progressiveSummarizationEnabled)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
Text("Automatically summarize old portions of long conversations to save tokens and improve context efficiency.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if settingsService.progressiveSummarizationEnabled {
|
||||
row("Message Threshold") {
|
||||
HStack(spacing: 8) {
|
||||
TextField("", value: $settingsService.summarizationThreshold, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 80)
|
||||
Text("messages")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("When a conversation exceeds this many messages, older messages will be summarized. Default: 50 messages.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
sectionHeader("Info")
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
@@ -1066,57 +1208,72 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
|
||||
row("") {
|
||||
HStack(spacing: 12) {
|
||||
Button("Clone Repository") {
|
||||
Task { await cloneRepo() }
|
||||
if !gitSync.syncStatus.isCloned {
|
||||
// Not cloned yet - show initialize button
|
||||
Button {
|
||||
Task { await cloneRepo() }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
if isSyncing {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
.frame(width: 16, height: 16)
|
||||
} else {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
Text("Initialize Repository")
|
||||
}
|
||||
.frame(minWidth: 160)
|
||||
}
|
||||
.disabled(!settingsService.syncConfigured || isSyncing)
|
||||
} else {
|
||||
// Already cloned - show sync button
|
||||
Button {
|
||||
Task { await syncNow() }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
if isSyncing {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
.frame(width: 16, height: 16)
|
||||
} else {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
Text("Sync Now")
|
||||
}
|
||||
.frame(minWidth: 160)
|
||||
}
|
||||
.disabled(isSyncing)
|
||||
}
|
||||
.disabled(!settingsService.syncConfigured)
|
||||
|
||||
Button("Export All") {
|
||||
Task { await exportConversations() }
|
||||
}
|
||||
.disabled(!GitSyncService.shared.syncStatus.isCloned)
|
||||
|
||||
Button("Push") {
|
||||
Task { await pushToGit() }
|
||||
}
|
||||
.disabled(!GitSyncService.shared.syncStatus.isCloned)
|
||||
|
||||
Button("Pull") {
|
||||
Task { await pullFromGit() }
|
||||
}
|
||||
.disabled(!GitSyncService.shared.syncStatus.isCloned)
|
||||
|
||||
Button("Import") {
|
||||
Task { await importConversations() }
|
||||
}
|
||||
.disabled(!GitSyncService.shared.syncStatus.isCloned)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// Status
|
||||
if GitSyncService.shared.syncStatus.isCloned {
|
||||
if gitSync.syncStatus.isCloned {
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let lastSync = GitSyncService.shared.syncStatus.lastSyncTime {
|
||||
if let lastSync = gitSync.syncStatus.lastSyncTime {
|
||||
Text("Last sync: \(timeAgo(lastSync))")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if GitSyncService.shared.syncStatus.uncommittedChanges > 0 {
|
||||
Text("Uncommitted changes: \(GitSyncService.shared.syncStatus.uncommittedChanges)")
|
||||
if gitSync.syncStatus.uncommittedChanges > 0 {
|
||||
Text("Uncommitted changes: \(gitSync.syncStatus.uncommittedChanges)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if let branch = GitSyncService.shared.syncStatus.currentBranch {
|
||||
if let branch = gitSync.syncStatus.currentBranch {
|
||||
Text("Branch: \(branch)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let status = GitSyncService.shared.syncStatus.remoteStatus {
|
||||
if let status = gitSync.syncStatus.remoteStatus {
|
||||
Text("Remote: \(status)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -1133,6 +1290,11 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
syncUsername = settingsService.syncUsername ?? ""
|
||||
syncPassword = settingsService.syncPassword ?? ""
|
||||
syncAccessToken = settingsService.syncAccessToken ?? ""
|
||||
|
||||
// Update sync status to check if repository is already cloned
|
||||
Task {
|
||||
await gitSync.updateStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1691,7 +1853,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
syncTestResult = nil
|
||||
|
||||
do {
|
||||
let result = try await GitSyncService.shared.testConnection()
|
||||
let result = try await gitSync.testConnection()
|
||||
syncTestResult = "✓ \(result)"
|
||||
} catch {
|
||||
syncTestResult = "✗ \(error.localizedDescription)"
|
||||
@@ -1703,27 +1865,27 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
private var syncStatusIcon: String {
|
||||
guard settingsService.syncEnabled else { return "externaldrive.slash" }
|
||||
guard settingsService.syncConfigured else { return "exclamationmark.triangle" }
|
||||
guard GitSyncService.shared.syncStatus.isCloned else { return "externaldrive.badge.questionmark" }
|
||||
guard gitSync.syncStatus.isCloned else { return "externaldrive.badge.questionmark" }
|
||||
return "externaldrive.badge.checkmark"
|
||||
}
|
||||
|
||||
private var syncStatusColor: Color {
|
||||
guard settingsService.syncEnabled else { return .secondary }
|
||||
guard settingsService.syncConfigured else { return .orange }
|
||||
guard GitSyncService.shared.syncStatus.isCloned else { return .orange }
|
||||
guard gitSync.syncStatus.isCloned else { return .orange }
|
||||
return .green
|
||||
}
|
||||
|
||||
private var syncStatusText: String {
|
||||
guard settingsService.syncEnabled else { return "Disabled" }
|
||||
guard settingsService.syncConfigured else { return "Not configured" }
|
||||
guard GitSyncService.shared.syncStatus.isCloned else { return "Not cloned" }
|
||||
guard gitSync.syncStatus.isCloned else { return "Not cloned" }
|
||||
return "Ready"
|
||||
}
|
||||
|
||||
private func cloneRepo() async {
|
||||
do {
|
||||
try await GitSyncService.shared.cloneRepository()
|
||||
try await gitSync.cloneRepository()
|
||||
syncTestResult = "✓ Repository cloned successfully"
|
||||
} catch {
|
||||
syncTestResult = "✗ \(error.localizedDescription)"
|
||||
@@ -1732,7 +1894,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
|
||||
private func exportConversations() async {
|
||||
do {
|
||||
try await GitSyncService.shared.exportAllConversations()
|
||||
try await gitSync.exportAllConversations()
|
||||
syncTestResult = "✓ Conversations exported"
|
||||
} catch {
|
||||
syncTestResult = "✗ \(error.localizedDescription)"
|
||||
@@ -1742,9 +1904,9 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
private func pushToGit() async {
|
||||
do {
|
||||
// First export conversations
|
||||
try await GitSyncService.shared.exportAllConversations()
|
||||
try await gitSync.exportAllConversations()
|
||||
// Then push
|
||||
try await GitSyncService.shared.push()
|
||||
try await gitSync.push()
|
||||
syncTestResult = "✓ Changes pushed successfully"
|
||||
} catch {
|
||||
syncTestResult = "✗ \(error.localizedDescription)"
|
||||
@@ -1753,7 +1915,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
|
||||
private func pullFromGit() async {
|
||||
do {
|
||||
try await GitSyncService.shared.pull()
|
||||
try await gitSync.pull()
|
||||
syncTestResult = "✓ Changes pulled successfully"
|
||||
} catch {
|
||||
syncTestResult = "✗ \(error.localizedDescription)"
|
||||
@@ -1762,13 +1924,44 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
|
||||
private func importConversations() async {
|
||||
do {
|
||||
let result = try await GitSyncService.shared.importAllConversations()
|
||||
let result = try await gitSync.importAllConversations()
|
||||
syncTestResult = "✓ Imported \(result.imported) conversations (skipped \(result.skipped) duplicates)"
|
||||
} catch {
|
||||
syncTestResult = "✗ \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func syncNow() async {
|
||||
isSyncing = true
|
||||
syncTestResult = nil
|
||||
|
||||
do {
|
||||
// Step 1: Export all conversations
|
||||
syncTestResult = "Exporting conversations..."
|
||||
try await gitSync.exportAllConversations()
|
||||
|
||||
// Step 2: Pull from remote
|
||||
syncTestResult = "Pulling changes..."
|
||||
try await gitSync.pull()
|
||||
|
||||
// Step 3: Import any new conversations
|
||||
syncTestResult = "Importing conversations..."
|
||||
let result = try await gitSync.importAllConversations()
|
||||
|
||||
// Step 4: Push to remote
|
||||
syncTestResult = "Pushing changes..."
|
||||
try await gitSync.push()
|
||||
|
||||
// Success
|
||||
await gitSync.updateStatus()
|
||||
syncTestResult = "✓ Sync complete: \(result.imported) imported, \(result.skipped) skipped"
|
||||
} catch {
|
||||
syncTestResult = "✗ Sync failed: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
isSyncing = false
|
||||
}
|
||||
|
||||
private var tokenGenerationURL: String? {
|
||||
let url = settingsService.syncRepoURL.lowercased()
|
||||
if url.contains("github.com") {
|
||||
|
||||
@@ -18,6 +18,11 @@ struct oAIApp: App {
|
||||
init() {
|
||||
// Start email handler on app launch
|
||||
EmailHandlerService.shared.start()
|
||||
|
||||
// Sync Git changes on app launch (pull + import)
|
||||
Task {
|
||||
await GitSyncService.shared.syncOnStartup()
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
Reference in New Issue
Block a user