Small feature changes and bug fixes

This commit is contained in:
2026-02-16 13:17:08 +01:00
parent 04c9b8da1e
commit 25bcca213e
20 changed files with 2193 additions and 125 deletions

369
README.md
View File

@@ -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.
![oAI Main Interface](Screenshots/1.png)
## 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
![Settings Interface](Screenshots/2.png)
### 🔧 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
![Model Selector](Screenshots/3.png)
### 📧 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
![Advanced Features](Screenshots/4.png)
## 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

BIN
Screenshots/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

BIN
Screenshots/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

BIN
Screenshots/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

View File

@@ -247,26 +247,109 @@
<!-- Memory -->
<section id="memory">
<h2>Memory &amp; 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>

View 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
}
}

View File

@@ -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
}
}
}

View File

@@ -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>

View 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."
}
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}
}
}

View File

@@ -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)
}

View File

@@ -60,7 +60,7 @@ struct ContentView: View {
.sheet(isPresented: $vm.showSettings, onDismiss: {
chatViewModel.syncFromSettings()
}) {
SettingsView()
SettingsView(chatViewModel: chatViewModel)
}
.sheet(isPresented: $vm.showStats) {
StatsView(

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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") {

View File

@@ -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 {