Bug gixes, features added, GUI updates and more
This commit is contained in:
10
oAI/Info.plist
Normal file
10
oAI/Info.plist
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleHelpBookFolder</key>
|
||||
<string>oAI.help</string>
|
||||
<key>CFBundleHelpBookName</key>
|
||||
<string>oAI Help</string>
|
||||
</dict>
|
||||
</plist>
|
||||
35
oAI/Models/HistoryEntry.swift
Normal file
35
oAI/Models/HistoryEntry.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// HistoryEntry.swift
|
||||
// oAI
|
||||
//
|
||||
// Command history entry model
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct HistoryEntry: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let input: String
|
||||
let timestamp: Date
|
||||
|
||||
/// Format timestamp in European format (dd.MM.yyyy HH:mm:ss)
|
||||
var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "dd.MM.yyyy HH:mm:ss"
|
||||
return formatter.string(from: timestamp)
|
||||
}
|
||||
|
||||
/// Short date without time (dd.MM.yyyy)
|
||||
var shortDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "dd.MM.yyyy"
|
||||
return formatter.string(from: timestamp)
|
||||
}
|
||||
|
||||
/// Just the time (HH:mm:ss)
|
||||
var timeOnly: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
return formatter.string(from: timestamp)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ struct Message: Identifiable, Codable, Equatable {
|
||||
var cost: Double?
|
||||
let timestamp: Date
|
||||
let attachments: [FileAttachment]?
|
||||
|
||||
var responseTime: TimeInterval? // Time taken to generate response in seconds
|
||||
var wasInterrupted: Bool = false // Whether generation was cancelled
|
||||
|
||||
// Streaming state (not persisted)
|
||||
var isStreaming: Bool = false
|
||||
|
||||
@@ -36,6 +38,8 @@ struct Message: Identifiable, Codable, Equatable {
|
||||
cost: Double? = nil,
|
||||
timestamp: Date = Date(),
|
||||
attachments: [FileAttachment]? = nil,
|
||||
responseTime: TimeInterval? = nil,
|
||||
wasInterrupted: Bool = false,
|
||||
isStreaming: Bool = false,
|
||||
generatedImages: [Data]? = nil
|
||||
) {
|
||||
@@ -46,12 +50,14 @@ struct Message: Identifiable, Codable, Equatable {
|
||||
self.cost = cost
|
||||
self.timestamp = timestamp
|
||||
self.attachments = attachments
|
||||
self.responseTime = responseTime
|
||||
self.wasInterrupted = wasInterrupted
|
||||
self.isStreaming = isStreaming
|
||||
self.generatedImages = generatedImages
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, role, content, tokens, cost, timestamp, attachments
|
||||
case id, role, content, tokens, cost, timestamp, attachments, responseTime, wasInterrupted
|
||||
}
|
||||
|
||||
static func == (lhs: Message, rhs: Message) -> Bool {
|
||||
@@ -59,6 +65,8 @@ struct Message: Identifiable, Codable, Equatable {
|
||||
lhs.content == rhs.content &&
|
||||
lhs.tokens == rhs.tokens &&
|
||||
lhs.cost == rhs.cost &&
|
||||
lhs.responseTime == rhs.responseTime &&
|
||||
lhs.wasInterrupted == rhs.wasInterrupted &&
|
||||
lhs.isStreaming == rhs.isStreaming &&
|
||||
lhs.generatedImages == rhs.generatedImages
|
||||
}
|
||||
|
||||
30
oAI/Resources/oAI.help/Contents/Info.plist
Normal file
30
oAI/Resources/oAI.help/Contents/Info.plist
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.rune.oAI.help</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>oAI Help</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>hbwr</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>HPDBookAccessPath</key>
|
||||
<string>index.html</string>
|
||||
<key>HPDBookIconPath</key>
|
||||
<string>images/icon.png</string>
|
||||
<key>HPDBookTitle</key>
|
||||
<string>oAI Help</string>
|
||||
<key>HPDBookType</key>
|
||||
<string>3</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
550
oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html
Normal file
550
oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html
Normal file
@@ -0,0 +1,550 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="AppleTitle" content="oAI Help">
|
||||
<meta name="AppleIcon" content="images/icon.png">
|
||||
<meta name="description" content="oAI - AI Chat Assistant for macOS">
|
||||
<meta name="keywords" content="oAI, AI, chat, assistant, OpenAI, Anthropic, Claude, GPT, commands, help">
|
||||
<title>oAI Help</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<img src="images/icon.png" alt="oAI Icon" class="app-icon">
|
||||
<h1>oAI Help</h1>
|
||||
<p class="subtitle">AI Chat Assistant for macOS</p>
|
||||
</header>
|
||||
|
||||
<nav class="toc">
|
||||
<h2>Contents</h2>
|
||||
<ul>
|
||||
<li><a href="#getting-started">Getting Started</a></li>
|
||||
<li><a href="#providers">AI Providers & API Keys</a></li>
|
||||
<li><a href="#models">Selecting Models</a></li>
|
||||
<li><a href="#sending-messages">Sending Messages</a></li>
|
||||
<li><a href="#file-attachments">File Attachments</a></li>
|
||||
<li><a href="#commands">Slash Commands</a></li>
|
||||
<li><a href="#memory">Memory & Context</a></li>
|
||||
<li><a href="#online-mode">Online Mode (Web Search)</a></li>
|
||||
<li><a href="#mcp">MCP (File Access)</a></li>
|
||||
<li><a href="#conversations">Managing Conversations</a></li>
|
||||
<li><a href="#keyboard-shortcuts">Keyboard Shortcuts</a></li>
|
||||
<li><a href="#settings">Settings</a></li>
|
||||
<li><a href="#system-prompts">System Prompts</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<!-- Getting Started -->
|
||||
<section id="getting-started">
|
||||
<h2>Getting Started</h2>
|
||||
<p>oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama.</p>
|
||||
|
||||
<div class="steps">
|
||||
<h3>Quick Start</h3>
|
||||
<ol>
|
||||
<li><strong>Add an API key</strong>: Press <kbd>⌘,</kbd> to open Settings, then add your API key for your preferred provider</li>
|
||||
<li><strong>Select a model</strong>: Press <kbd>⌘M</kbd> or type <code>/model</code> to choose an AI model</li>
|
||||
<li><strong>Start chatting</strong>: Type your message and press <kbd>Return</kbd> to send</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="tip">
|
||||
<strong>💡 Tip:</strong> Type <code>/</code> in the message input to see all available commands with autocomplete suggestions.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Providers -->
|
||||
<section id="providers">
|
||||
<h2>AI Providers & API Keys</h2>
|
||||
<p>oAI supports multiple AI providers. You'll need an API key from at least one provider to use the app.</p>
|
||||
|
||||
<h3>Supported Providers</h3>
|
||||
<ul class="provider-list">
|
||||
<li><strong>OpenRouter</strong> - Access to 300+ models through a single API (<a href="https://openrouter.ai">openrouter.ai</a>)</li>
|
||||
<li><strong>Anthropic</strong> - Claude models (Opus, Sonnet, Haiku) (<a href="https://console.anthropic.com">console.anthropic.com</a>)</li>
|
||||
<li><strong>OpenAI</strong> - GPT-4, GPT-4 Turbo, GPT-3.5 models (<a href="https://platform.openai.com">platform.openai.com</a>)</li>
|
||||
<li><strong>Ollama</strong> - Run models locally on your Mac (<a href="https://ollama.ai">ollama.ai</a>)</li>
|
||||
</ul>
|
||||
|
||||
<h3>Adding an API Key</h3>
|
||||
<ol>
|
||||
<li>Press <kbd>⌘,</kbd> or type <code>/config</code> to open Settings</li>
|
||||
<li>Select the <strong>Providers</strong> tab</li>
|
||||
<li>Enter your API key for the desired provider</li>
|
||||
<li>Click <strong>Save</strong></li>
|
||||
</ol>
|
||||
|
||||
<h3>Switching Providers</h3>
|
||||
<p>Use the provider dropdown in the header or type:</p>
|
||||
<code class="command">/provider anthropic</code>
|
||||
<code class="command">/provider openai</code>
|
||||
<code class="command">/provider openrouter</code>
|
||||
</section>
|
||||
|
||||
<!-- Models -->
|
||||
<section id="models">
|
||||
<h2>Selecting Models</h2>
|
||||
<p>Different models have different capabilities, speeds, and costs. Choose the right model for your task.</p>
|
||||
|
||||
<h3>Opening the Model Selector</h3>
|
||||
<ul>
|
||||
<li>Press <kbd>⌘M</kbd></li>
|
||||
<li>Type <code>/model</code></li>
|
||||
<li>Click the model name in the header</li>
|
||||
</ul>
|
||||
|
||||
<h3>Model Information</h3>
|
||||
<p>View details about any model:</p>
|
||||
<code class="command">/info</code>
|
||||
<p class="note">Shows information about the currently selected model including context length, pricing, and capabilities.</p>
|
||||
|
||||
<h3>Default Model</h3>
|
||||
<p>Your selected model is automatically saved and will be restored when you restart the app.</p>
|
||||
</section>
|
||||
|
||||
<!-- Sending Messages -->
|
||||
<section id="sending-messages">
|
||||
<h2>Sending Messages</h2>
|
||||
|
||||
<h3>Basic Usage</h3>
|
||||
<ul>
|
||||
<li>Type your message in the input field</li>
|
||||
<li>Press <kbd>Return</kbd> to send (single-line messages)</li>
|
||||
<li>Press <kbd>⇧Return</kbd> to add a new line without sending</li>
|
||||
</ul>
|
||||
|
||||
<h3>During Generation</h3>
|
||||
<p>While the AI is responding:</p>
|
||||
<ul>
|
||||
<li>Press <kbd>Esc</kbd> to cancel generation</li>
|
||||
<li>Click the <strong>Stop</strong> button (red circle)</li>
|
||||
<li>Cancelled responses show a <strong>⚠️ interrupted</strong> indicator</li>
|
||||
</ul>
|
||||
|
||||
<h3>Retrying Messages</h3>
|
||||
<p>If you're not satisfied with a response:</p>
|
||||
<code class="command">/retry</code>
|
||||
<p class="note">Resends your last message to generate a new response.</p>
|
||||
</section>
|
||||
|
||||
<!-- File Attachments -->
|
||||
<section id="file-attachments">
|
||||
<h2>File Attachments</h2>
|
||||
<p>Attach files to your messages for the AI to analyze. Supports images, PDFs, and text files.</p>
|
||||
|
||||
<h3>Attaching Files</h3>
|
||||
<ul>
|
||||
<li>Click the <strong>📎 paperclip icon</strong> to browse and select files</li>
|
||||
<li>Type <code>@/path/to/file.txt</code> in your message</li>
|
||||
<li>Type <code>@~/Documents/image.png</code> for files in your home directory</li>
|
||||
</ul>
|
||||
|
||||
<h3>Supported File Types</h3>
|
||||
<ul>
|
||||
<li><strong>Images</strong>: PNG, JPEG, GIF, WebP, BMP, SVG (max 10 MB)</li>
|
||||
<li><strong>Documents</strong>: PDF (max 10 MB)</li>
|
||||
<li><strong>Text</strong>: TXT, code files, logs, etc. (max 50 KB)</li>
|
||||
</ul>
|
||||
|
||||
<div class="tip">
|
||||
<strong>💡 Tip:</strong> Large text files are automatically truncated to prevent token limits.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Commands -->
|
||||
<section id="commands">
|
||||
<h2>Slash Commands</h2>
|
||||
<p>Commands start with <code>/</code> and provide quick access to features. Type <code>/</code> to see suggestions.</p>
|
||||
|
||||
<h3>Chat Commands</h3>
|
||||
<dl class="commands">
|
||||
<dt>/history</dt>
|
||||
<dd>View command history with timestamps (European format: dd.MM.yyyy). Search by text or date to find previous messages</dd>
|
||||
|
||||
<dt>/clear</dt>
|
||||
<dd>Clear all messages from the current session</dd>
|
||||
|
||||
<dt>/retry</dt>
|
||||
<dd>Retry your last message to get a new response</dd>
|
||||
|
||||
<dt>/memory on|off</dt>
|
||||
<dd>Toggle conversation memory. When off, only your latest message is sent (no conversation history)</dd>
|
||||
|
||||
<dt>/online on|off</dt>
|
||||
<dd>Enable/disable web search. When on, the AI can search the web for current information</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Model & Provider Commands</h3>
|
||||
<dl class="commands">
|
||||
<dt>/model</dt>
|
||||
<dd>Open the model selector</dd>
|
||||
|
||||
<dt>/provider [name]</dt>
|
||||
<dd>Switch AI provider or show current provider</dd>
|
||||
|
||||
<dt>/info</dt>
|
||||
<dd>Display information about the current model</dd>
|
||||
|
||||
<dt>/credits</dt>
|
||||
<dd>Check your account balance (OpenRouter only)</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Conversation Commands</h3>
|
||||
<dl class="commands">
|
||||
<dt>/save <name></dt>
|
||||
<dd>Save the current conversation</dd>
|
||||
|
||||
<dt>/load</dt>
|
||||
<dd>Browse and load a saved conversation</dd>
|
||||
|
||||
<dt>/list</dt>
|
||||
<dd>Show all saved conversations</dd>
|
||||
|
||||
<dt>/delete <name></dt>
|
||||
<dd>Delete a saved conversation</dd>
|
||||
|
||||
<dt>/export md|json</dt>
|
||||
<dd>Export conversation as Markdown or JSON</dd>
|
||||
</dl>
|
||||
|
||||
<h3>MCP Commands</h3>
|
||||
<dl class="commands">
|
||||
<dt>/mcp on|off</dt>
|
||||
<dd>Enable/disable file access for the AI</dd>
|
||||
|
||||
<dt>/mcp add <path></dt>
|
||||
<dd>Grant AI access to a folder</dd>
|
||||
|
||||
<dt>/mcp remove <path></dt>
|
||||
<dd>Revoke AI access to a folder</dd>
|
||||
|
||||
<dt>/mcp list</dt>
|
||||
<dd>Show all accessible folders</dd>
|
||||
|
||||
<dt>/mcp status</dt>
|
||||
<dd>Display MCP status and permissions</dd>
|
||||
|
||||
<dt>/mcp write on|off</dt>
|
||||
<dd>Enable/disable write permissions</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Settings Commands</h3>
|
||||
<dl class="commands">
|
||||
<dt>/config, /settings</dt>
|
||||
<dd>Open the settings panel</dd>
|
||||
|
||||
<dt>/stats</dt>
|
||||
<dd>Show session statistics (messages, tokens, cost)</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- Memory -->
|
||||
<section id="memory">
|
||||
<h2>Memory & Context</h2>
|
||||
<p>Control how much conversation history the AI remembers.</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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<!-- Online Mode -->
|
||||
<section id="online-mode">
|
||||
<h2>Online Mode (Web Search)</h2>
|
||||
<p>Enable the AI to search the web for current information before responding.</p>
|
||||
|
||||
<h3>When to Use Online Mode</h3>
|
||||
<ul>
|
||||
<li>Questions about current events</li>
|
||||
<li>Recent news or updates</li>
|
||||
<li>Real-time data (weather, stocks, etc.)</li>
|
||||
<li>Information beyond the AI's training cutoff</li>
|
||||
</ul>
|
||||
|
||||
<h3>Enabling Online Mode</h3>
|
||||
<code class="command">/online on</code>
|
||||
|
||||
<h3>How It Works</h3>
|
||||
<p>When online mode is enabled:</p>
|
||||
<ol>
|
||||
<li>Your question is used to search the web</li>
|
||||
<li>Relevant results are retrieved</li>
|
||||
<li>Search results are added to your message context</li>
|
||||
<li>The AI uses the web results to inform its response</li>
|
||||
</ol>
|
||||
|
||||
<div class="note">
|
||||
<strong>Note:</strong> Online mode is shown in the input bar with a 🌐 badge when active.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- MCP -->
|
||||
<section id="mcp">
|
||||
<h2>MCP (File Access)</h2>
|
||||
<p>MCP (Model Context Protocol) allows the AI to access, read, and optionally modify files on your computer.</p>
|
||||
|
||||
<div class="warning">
|
||||
<strong>⚠️ Security:</strong> Only grant access to folders you trust the AI to work with. Review permissions carefully.
|
||||
</div>
|
||||
|
||||
<h3>Setting Up MCP</h3>
|
||||
<ol>
|
||||
<li>Enable MCP: <code>/mcp on</code></li>
|
||||
<li>Add a folder: <code>/mcp add ~/Projects/myapp</code></li>
|
||||
<li>Ask the AI questions about your files</li>
|
||||
</ol>
|
||||
|
||||
<h3>What the AI Can Do</h3>
|
||||
<p><strong>Read permissions (always enabled when MCP is on):</strong></p>
|
||||
<ul>
|
||||
<li>Read file contents</li>
|
||||
<li>List directory contents</li>
|
||||
<li>Search for files</li>
|
||||
<li>Get file information (size, modified date, etc.)</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Write permissions (optional, disabled by default):</strong></p>
|
||||
<ul>
|
||||
<li>Write and edit files</li>
|
||||
<li>Create new files</li>
|
||||
<li>Delete files</li>
|
||||
<li>Create directories</li>
|
||||
<li>Move and copy files</li>
|
||||
</ul>
|
||||
|
||||
<h3>Managing Permissions</h3>
|
||||
<p>Toggle all write permissions:</p>
|
||||
<code class="command">/mcp write on</code>
|
||||
<code class="command">/mcp write off</code>
|
||||
|
||||
<p>For fine-grained control, open Settings → MCP to enable/disable individual permissions.</p>
|
||||
|
||||
<h3>Gitignore Respect</h3>
|
||||
<p>When enabled in Settings, MCP will ignore files and folders listed in <code>.gitignore</code> files.</p>
|
||||
|
||||
<h3>Example Usage</h3>
|
||||
<div class="example">
|
||||
<p><strong>You:</strong> "What files are in my project folder?"</p>
|
||||
<p><strong>AI:</strong> <em>(uses MCP to list files)</em> "I found 15 files including src/main.py, README.md..."</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Conversations -->
|
||||
<section id="conversations">
|
||||
<h2>Managing Conversations</h2>
|
||||
<p>Save, load, and export your chat conversations.</p>
|
||||
|
||||
<h3>Saving Conversations</h3>
|
||||
<code class="command">/save my-project-chat</code>
|
||||
<p class="note">Saves all current messages under the specified name.</p>
|
||||
|
||||
<h3>Loading Conversations</h3>
|
||||
<code class="command">/load</code>
|
||||
<p class="note">Opens a list of saved conversations. Select one to load.</p>
|
||||
|
||||
<h3>Viewing Saved Conversations</h3>
|
||||
<ul>
|
||||
<li>Press <kbd>⌘L</kbd></li>
|
||||
<li>Type <code>/list</code></li>
|
||||
</ul>
|
||||
|
||||
<h3>Deleting Conversations</h3>
|
||||
<code class="command">/delete old-chat</code>
|
||||
<p class="warning"><strong>Warning:</strong> This action cannot be undone.</p>
|
||||
|
||||
<h3>Exporting Conversations</h3>
|
||||
<p>Export to Markdown or JSON format:</p>
|
||||
<code class="command">/export md</code>
|
||||
<code class="command">/export json</code>
|
||||
<p class="note">Files are saved to your Downloads folder.</p>
|
||||
</section>
|
||||
|
||||
<!-- Keyboard Shortcuts -->
|
||||
<section id="keyboard-shortcuts">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<p>Work faster with these keyboard shortcuts.</p>
|
||||
|
||||
<h3>General</h3>
|
||||
<dl class="shortcuts">
|
||||
<dt><kbd>⌘,</kbd></dt>
|
||||
<dd>Open Settings</dd>
|
||||
|
||||
<dt><kbd>⌘/</kbd></dt>
|
||||
<dd>Show in-app Help</dd>
|
||||
|
||||
<dt><kbd>⌘?</kbd></dt>
|
||||
<dd>Open this Help (macOS Help)</dd>
|
||||
|
||||
<dt><kbd>⌘L</kbd></dt>
|
||||
<dd>Browse Conversations</dd>
|
||||
|
||||
<dt><kbd>⌘H</kbd></dt>
|
||||
<dd>Command History</dd>
|
||||
|
||||
<dt><kbd>⌘M</kbd></dt>
|
||||
<dd>Model Selector</dd>
|
||||
|
||||
<dt><kbd>⌘K</kbd></dt>
|
||||
<dd>Clear Chat</dd>
|
||||
|
||||
<dt><kbd>⇧⌘S</kbd></dt>
|
||||
<dd>Show Statistics</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Message Input</h3>
|
||||
<dl class="shortcuts">
|
||||
<dt><kbd>Return</kbd></dt>
|
||||
<dd>Send message (single-line messages)</dd>
|
||||
|
||||
<dt><kbd>⇧Return</kbd></dt>
|
||||
<dd>New line (multi-line messages)</dd>
|
||||
|
||||
<dt><kbd>Esc</kbd></dt>
|
||||
<dd>Cancel generation or close command dropdown</dd>
|
||||
|
||||
<dt><kbd>↑</kbd> <kbd>↓</kbd></dt>
|
||||
<dd>Navigate command suggestions (when typing /)</dd>
|
||||
|
||||
<dt><kbd>Return</kbd></dt>
|
||||
<dd>Select highlighted command (when dropdown is open)</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- Settings -->
|
||||
<section id="settings">
|
||||
<h2>Settings</h2>
|
||||
<p>Customize oAI to your preferences. Press <kbd>⌘,</kbd> to open Settings.</p>
|
||||
|
||||
<h3>Providers Tab</h3>
|
||||
<ul>
|
||||
<li>Add and manage API keys for different providers</li>
|
||||
<li>Switch between providers</li>
|
||||
<li>Set default models</li>
|
||||
</ul>
|
||||
|
||||
<h3>MCP Tab</h3>
|
||||
<ul>
|
||||
<li>Manage folder access permissions</li>
|
||||
<li>Enable/disable write operations</li>
|
||||
<li>Configure gitignore respect</li>
|
||||
</ul>
|
||||
|
||||
<h3>Appearance Tab</h3>
|
||||
<ul>
|
||||
<li>Adjust text sizes for input and dialog</li>
|
||||
<li>Customize UI preferences</li>
|
||||
</ul>
|
||||
|
||||
<h3>Advanced Tab</h3>
|
||||
<ul>
|
||||
<li>Enable/disable streaming responses</li>
|
||||
<li>Set maximum tokens (response length limit)</li>
|
||||
<li>Adjust temperature (creativity vs focus)</li>
|
||||
<li>Configure system prompts (see below)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- System Prompts -->
|
||||
<section id="system-prompts">
|
||||
<h2>System Prompts</h2>
|
||||
<p>System prompts are instructions that define how the AI should behave and respond. They set the "personality" and guidelines for the AI before it sees any user messages.</p>
|
||||
|
||||
<div class="tip">
|
||||
<strong>📚 Learn More:</strong> For a detailed explanation of system prompts and how they work, see <a href="https://en.wikipedia.org/wiki/Prompt_engineering" target="_blank">Prompt Engineering on Wikipedia</a> or the <a href="https://platform.openai.com/docs/guides/prompt-engineering" target="_blank">OpenAI Prompt Engineering Guide</a>.
|
||||
</div>
|
||||
|
||||
<h3>What Are System Prompts?</h3>
|
||||
<p>System prompts are special instructions sent to the AI with every conversation. Unlike your regular messages, system prompts:</p>
|
||||
<ul>
|
||||
<li>Are invisible to you in the chat interface</li>
|
||||
<li>Set behavioral guidelines for the AI</li>
|
||||
<li>Establish the AI's role, tone, and constraints</li>
|
||||
<li>Are sent automatically with every message</li>
|
||||
<li>Help ensure consistent, reliable responses</li>
|
||||
</ul>
|
||||
|
||||
<h3>Default System Prompt</h3>
|
||||
<p>oAI includes a carefully crafted default system prompt that emphasizes:</p>
|
||||
<ul>
|
||||
<li><strong>Accuracy First</strong> - Never invent information or make assumptions</li>
|
||||
<li><strong>Ask for Clarification</strong> - Request details when requests are ambiguous</li>
|
||||
<li><strong>Honest About Limitations</strong> - Clearly state when it cannot help</li>
|
||||
<li><strong>Stay Grounded</strong> - Base responses on facts, not speculation</li>
|
||||
<li><strong>Be Direct</strong> - Provide concise, relevant answers</li>
|
||||
</ul>
|
||||
|
||||
<p class="note"><strong>Note:</strong> The default prompt is always active and cannot be disabled. It ensures the AI provides accurate, helpful responses and doesn't fabricate information.</p>
|
||||
|
||||
<h3>Custom System Prompt</h3>
|
||||
<p>You can add your own custom instructions that will be appended to the default prompt. Use custom prompts to:</p>
|
||||
<ul>
|
||||
<li>Define a specific role (e.g., "You are a Python expert")</li>
|
||||
<li>Set output format preferences (e.g., "Always provide code examples")</li>
|
||||
<li>Add domain-specific knowledge or context</li>
|
||||
<li>Establish tone or communication style</li>
|
||||
<li>Create task-specific guidelines</li>
|
||||
</ul>
|
||||
|
||||
<h3>How Prompts Are Combined</h3>
|
||||
<p>When you send a message, oAI constructs the complete system prompt like this:</p>
|
||||
|
||||
<div class="example">
|
||||
<p><strong>Complete System Prompt =</strong></p>
|
||||
<ol>
|
||||
<li>Default Prompt (always included)</li>
|
||||
<li>+ Your Custom Prompt (if set)</li>
|
||||
<li>+ MCP Instructions (if MCP is enabled)</li>
|
||||
</ol>
|
||||
<p><em>This combined prompt is sent with every message to ensure consistent behavior.</em></p>
|
||||
</div>
|
||||
|
||||
<h3>Editing System Prompts</h3>
|
||||
<p>To view or edit system prompts:</p>
|
||||
<ol>
|
||||
<li>Press <kbd>⌘,</kbd> to open Settings</li>
|
||||
<li>Go to the <strong>Advanced</strong> tab</li>
|
||||
<li>Scroll to the <strong>System Prompts</strong> section</li>
|
||||
<li>View the default prompt (read-only)</li>
|
||||
<li>Add your custom instructions in the editable field below</li>
|
||||
</ol>
|
||||
|
||||
<h3>Best Practices</h3>
|
||||
<ul>
|
||||
<li><strong>Be Specific</strong> - Clear instructions produce better results</li>
|
||||
<li><strong>Keep It Simple</strong> - Don't overcomplicate your custom prompt</li>
|
||||
<li><strong>Test Changes</strong> - Try your prompt with a few messages to see if it works</li>
|
||||
<li><strong>Start Empty</strong> - The default prompt works well; only add custom instructions if needed</li>
|
||||
<li><strong>Avoid Contradictions</strong> - Don't contradict the default prompt's guidelines</li>
|
||||
</ul>
|
||||
|
||||
<div class="warning">
|
||||
<strong>⚠️ Important:</strong> Custom prompts affect all conversations. Changes apply immediately to new messages. If you experience unexpected behavior, try removing your custom prompt.
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 oAI. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a>.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
389
oAI/Resources/oAI.help/Contents/Resources/en.lproj/styles.css
Normal file
389
oAI/Resources/oAI.help/Contents/Resources/en.lproj/styles.css
Normal file
@@ -0,0 +1,389 @@
|
||||
/* oAI Help Stylesheet - Apple Human Interface Guidelines */
|
||||
|
||||
:root {
|
||||
--primary-color: #007AFF;
|
||||
--secondary-color: #5856D6;
|
||||
--success-color: #34C759;
|
||||
--warning-color: #FF9500;
|
||||
--error-color: #FF3B30;
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #6E6E73;
|
||||
--background: #FFFFFF;
|
||||
--surface: #F5F5F7;
|
||||
--border: #D1D1D6;
|
||||
--code-bg: #F5F5F7;
|
||||
--tip-bg: #E3F2FD;
|
||||
--warning-bg: #FFF3E0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--primary-color: #0A84FF;
|
||||
--secondary-color: #5E5CE6;
|
||||
--success-color: #32D74B;
|
||||
--warning-color: #FF9F0A;
|
||||
--error-color: #FF453A;
|
||||
--text-primary: #FFFFFF;
|
||||
--text-secondary: #A1A1A6;
|
||||
--background: #1C1C1E;
|
||||
--surface: #2C2C2E;
|
||||
--border: #38383A;
|
||||
--code-bg: #2C2C2E;
|
||||
--tip-bg: #1A2631;
|
||||
--warning-bg: #2B2116;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background: var(--background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin: 0 auto 24px;
|
||||
display: block;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 19px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Table of Contents */
|
||||
nav.toc {
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 40px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
nav.toc h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
nav.toc ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
nav.toc li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
nav.toc a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
nav.toc a:hover {
|
||||
opacity: 0.7;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
main {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 56px;
|
||||
scroll-margin-top: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ul, ol {
|
||||
margin-bottom: 16px;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
ul.provider-list {
|
||||
list-style: none;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
ul.provider-list li {
|
||||
padding-left: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ul.provider-list li::before {
|
||||
content: "•";
|
||||
color: var(--primary-color);
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Steps */
|
||||
.steps {
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.steps h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.steps ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.steps li {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.steps li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Code and Commands */
|
||||
code {
|
||||
font-family: "SF Mono", Monaco, Menlo, Consolas, "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
background: var(--code-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
code.command {
|
||||
display: block;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Keyboard Shortcuts */
|
||||
kbd {
|
||||
display: inline-block;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 3px 8px;
|
||||
background: linear-gradient(to bottom, var(--surface) 0%, var(--background) 100%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Definition Lists (for commands) */
|
||||
dl.commands,
|
||||
dl.shortcuts {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
dl.commands dt,
|
||||
dl.shortcuts dt {
|
||||
font-family: "SF Mono", Monaco, Menlo, monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: var(--code-bg);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
dl.commands dt:first-child,
|
||||
dl.shortcuts dt:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
dl.commands dd,
|
||||
dl.shortcuts dd {
|
||||
background: var(--surface);
|
||||
padding: 12px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Callout Boxes */
|
||||
.tip,
|
||||
.note,
|
||||
.warning {
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
margin: 20px 0;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.tip {
|
||||
background: var(--tip-bg);
|
||||
border-left-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.note {
|
||||
background: var(--surface);
|
||||
border-left-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--warning-bg);
|
||||
border-left-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.tip strong,
|
||||
.note strong,
|
||||
.warning strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Examples */
|
||||
.example {
|
||||
background: var(--surface);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin: 20px 0;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.example p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.example p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.example em {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
section {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
nav.toc {
|
||||
page-break-after: always;
|
||||
}
|
||||
}
|
||||
71
oAI/Resources/oAI.help/README.md
Normal file
71
oAI/Resources/oAI.help/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# oAI Help Book
|
||||
|
||||
This folder contains the Apple Help Book for oAI.
|
||||
|
||||
## Adding to Xcode Project
|
||||
|
||||
1. **Add the help folder to your project:**
|
||||
- In Xcode, right-click on the `Resources` folder in the Project Navigator
|
||||
- Select "Add Files to 'oAI'..."
|
||||
- Select the `oAI.help` folder
|
||||
- **Important:** Check "Create folder references" (NOT "Create groups")
|
||||
- Click "Add"
|
||||
|
||||
2. **Configure the help book in build settings:**
|
||||
- Select your oAI target in Xcode
|
||||
- Go to the "Info" tab
|
||||
- Add a new key: `CFBundleHelpBookName` with value: `oAI Help`
|
||||
- Add another key: `CFBundleHelpBookFolder` with value: `oAI.help`
|
||||
|
||||
3. **Build and test:**
|
||||
- Build the app (⌘B)
|
||||
- Run the app (⌘R)
|
||||
- Press ⌘? or select Help → oAI Help to open the help
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
oAI.help/
|
||||
├── Contents/
|
||||
│ ├── Info.plist # Help book metadata
|
||||
│ └── Resources/
|
||||
│ └── en.lproj/
|
||||
│ ├── index.html # Main help page
|
||||
│ └── styles.css # Stylesheet
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Comprehensive coverage of all oAI features
|
||||
- ✅ Searchable via macOS Help menu search
|
||||
- ✅ Dark mode support
|
||||
- ✅ Organized by topic with table of contents
|
||||
- ✅ Keyboard shortcuts reference
|
||||
- ✅ Command reference with examples
|
||||
- ✅ Follows Apple Human Interface Guidelines
|
||||
|
||||
## Customization
|
||||
|
||||
To update the help content:
|
||||
1. Edit `Contents/Resources/en.lproj/index.html`
|
||||
2. Update styles in `Contents/Resources/en.lproj/styles.css`
|
||||
3. Rebuild the app
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If help doesn't open:
|
||||
- Make sure you added the folder as a "folder reference" (blue folder icon in Xcode)
|
||||
- Check that `CFBundleHelpBookName` and `CFBundleHelpBookFolder` are set in Info
|
||||
- Clean build folder (⇧⌘K) and rebuild
|
||||
|
||||
## Adding More Pages
|
||||
|
||||
To add additional help pages:
|
||||
1. Create new HTML files in `Contents/Resources/en.lproj/`
|
||||
2. Link to them from `index.html`
|
||||
3. Include proper meta tags for searchability:
|
||||
```html
|
||||
<meta name="AppleTitle" content="Page Title">
|
||||
<meta name="keywords" content="keyword1, keyword2">
|
||||
```
|
||||
@@ -40,6 +40,14 @@ struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
var value: String
|
||||
}
|
||||
|
||||
struct HistoryRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
static let databaseTableName = "command_history"
|
||||
|
||||
var id: String
|
||||
var input: String
|
||||
var timestamp: String
|
||||
}
|
||||
|
||||
// MARK: - DatabaseService
|
||||
|
||||
final class DatabaseService: Sendable {
|
||||
@@ -48,6 +56,9 @@ final class DatabaseService: Sendable {
|
||||
private let dbQueue: DatabaseQueue
|
||||
private let isoFormatter: ISO8601DateFormatter
|
||||
|
||||
// Command history limit - keep most recent 5000 entries
|
||||
private static let maxHistoryEntries = 5000
|
||||
|
||||
nonisolated private init() {
|
||||
isoFormatter = ISO8601DateFormatter()
|
||||
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
@@ -102,6 +113,20 @@ final class DatabaseService: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
migrator.registerMigration("v3") { db in
|
||||
try db.create(table: "command_history") { t in
|
||||
t.primaryKey("id", .text)
|
||||
t.column("input", .text).notNull()
|
||||
t.column("timestamp", .text).notNull()
|
||||
}
|
||||
|
||||
try db.create(
|
||||
index: "command_history_on_timestamp",
|
||||
on: "command_history",
|
||||
columns: ["timestamp"]
|
||||
)
|
||||
}
|
||||
|
||||
return migrator
|
||||
}
|
||||
|
||||
@@ -315,4 +340,68 @@ final class DatabaseService: Sendable {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command History Operations
|
||||
|
||||
nonisolated func saveCommandHistory(input: String) {
|
||||
let now = Date()
|
||||
let record = HistoryRecord(
|
||||
id: UUID().uuidString,
|
||||
input: input,
|
||||
timestamp: isoFormatter.string(from: now)
|
||||
)
|
||||
|
||||
try? dbQueue.write { db in
|
||||
try record.insert(db)
|
||||
|
||||
// Clean up old entries if we exceed the limit
|
||||
let count = try HistoryRecord.fetchCount(db)
|
||||
if count > Self.maxHistoryEntries {
|
||||
// Delete oldest entries to get back to limit
|
||||
let excess = count - Self.maxHistoryEntries
|
||||
try db.execute(
|
||||
sql: """
|
||||
DELETE FROM command_history
|
||||
WHERE id IN (
|
||||
SELECT id FROM command_history
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
arguments: [excess]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func loadCommandHistory() throws -> [(input: String, timestamp: Date)] {
|
||||
try dbQueue.read { db in
|
||||
let records = try HistoryRecord
|
||||
.order(Column("timestamp").desc)
|
||||
.fetchAll(db)
|
||||
|
||||
return records.compactMap { record in
|
||||
guard let date = isoFormatter.date(from: record.timestamp) else {
|
||||
return nil
|
||||
}
|
||||
return (input: record.input, timestamp: date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func searchCommandHistory(query: String) throws -> [(input: String, timestamp: Date)] {
|
||||
try dbQueue.read { db in
|
||||
let records = try HistoryRecord
|
||||
.filter(Column("input").like("%\(query)%"))
|
||||
.order(Column("timestamp").desc)
|
||||
.fetchAll(db)
|
||||
|
||||
return records.compactMap { record in
|
||||
guard let date = isoFormatter.date(from: record.timestamp) else {
|
||||
return nil
|
||||
}
|
||||
return (input: record.input, timestamp: date)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,19 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
var systemPrompt: String? {
|
||||
get { cache["systemPrompt"] }
|
||||
set {
|
||||
if let value = newValue, !value.isEmpty {
|
||||
cache["systemPrompt"] = value
|
||||
DatabaseService.shared.setSetting(key: "systemPrompt", value: value)
|
||||
} else {
|
||||
cache.removeValue(forKey: "systemPrompt")
|
||||
DatabaseService.shared.deleteSetting(key: "systemPrompt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Feature Settings
|
||||
|
||||
var onlineMode: Bool {
|
||||
|
||||
@@ -32,16 +32,43 @@ class ChatViewModel {
|
||||
var showStats: Bool = false
|
||||
var showHelp: Bool = false
|
||||
var showCredits: Bool = false
|
||||
var showHistory: Bool = false
|
||||
var modelInfoTarget: ModelInfo? = nil
|
||||
var commandHistory: [String] = []
|
||||
var historyIndex: Int = 0
|
||||
|
||||
// MARK: - Private State
|
||||
|
||||
private var commandHistory: [String] = []
|
||||
private var historyIndex: Int = -1
|
||||
|
||||
private var streamingTask: Task<Void, Never>?
|
||||
private let settings = SettingsService.shared
|
||||
private let providerRegistry = ProviderRegistry.shared
|
||||
|
||||
|
||||
// Default system prompt
|
||||
private let defaultSystemPrompt = """
|
||||
You are a helpful AI assistant. Follow these guidelines:
|
||||
|
||||
1. **Accuracy First**: Never invent information or make assumptions. If you're unsure about something, say so clearly.
|
||||
|
||||
2. **Ask for Clarification**: When a request is ambiguous or lacks necessary details, ask clarifying questions before proceeding.
|
||||
|
||||
3. **Be Honest About Limitations**: If you cannot or should not help with a request, explain why clearly and politely. Don't attempt tasks outside your capabilities.
|
||||
|
||||
4. **Stay Grounded**: Base responses on facts and known information. Clearly distinguish between facts, informed opinions, and speculation.
|
||||
|
||||
5. **Be Direct**: Provide concise, relevant answers. Avoid unnecessary preambles or apologies.
|
||||
|
||||
Remember: It's better to ask questions or admit uncertainty than to provide incorrect or fabricated information.
|
||||
"""
|
||||
|
||||
/// Builds the complete system prompt by combining default + custom
|
||||
private var effectiveSystemPrompt: String {
|
||||
var prompt = defaultSystemPrompt
|
||||
if let customPrompt = settings.systemPrompt, !customPrompt.isEmpty {
|
||||
prompt += "\n\n---\n\nAdditional Instructions:\n" + customPrompt
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
@@ -50,6 +77,17 @@ class ChatViewModel {
|
||||
self.onlineMode = settings.onlineMode
|
||||
self.memoryEnabled = settings.memoryEnabled
|
||||
self.mcpEnabled = settings.mcpEnabled
|
||||
|
||||
// Load command history from database
|
||||
if let history = try? DatabaseService.shared.loadCommandHistory() {
|
||||
self.commandHistory = history.map { $0.input }
|
||||
self.historyIndex = self.commandHistory.count
|
||||
}
|
||||
|
||||
// Load models on startup
|
||||
Task {
|
||||
await loadAvailableModels()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
@@ -102,7 +140,12 @@ class ChatViewModel {
|
||||
|
||||
let models = try await provider.listModels()
|
||||
availableModels = models
|
||||
if selectedModel == nil, let firstModel = models.first {
|
||||
|
||||
// Select model priority: saved default > current selection > first available
|
||||
if let defaultModelId = settings.defaultModel,
|
||||
let defaultModel = models.first(where: { $0.id == defaultModelId }) {
|
||||
selectedModel = defaultModel
|
||||
} else if selectedModel == nil, let firstModel = models.first {
|
||||
selectedModel = firstModel
|
||||
}
|
||||
isLoadingModels = false
|
||||
@@ -116,18 +159,22 @@ class ChatViewModel {
|
||||
|
||||
func sendMessage() {
|
||||
guard !inputText.trimmingCharacters(in: .whitespaces).isEmpty else { return }
|
||||
|
||||
|
||||
let trimmedInput = inputText.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Check if it's a slash command
|
||||
if trimmedInput.hasPrefix("/") {
|
||||
handleCommand(trimmedInput)
|
||||
|
||||
// Handle slash escape: "//" becomes "/"
|
||||
var effectiveInput = trimmedInput
|
||||
if effectiveInput.hasPrefix("//") {
|
||||
effectiveInput = String(effectiveInput.dropFirst())
|
||||
} else if effectiveInput.hasPrefix("/") {
|
||||
// Check if it's a slash command
|
||||
handleCommand(effectiveInput)
|
||||
inputText = ""
|
||||
return
|
||||
}
|
||||
|
||||
// Parse file attachments
|
||||
let (cleanText, filePaths) = trimmedInput.parseFileAttachments()
|
||||
let (cleanText, filePaths) = effectiveInput.parseFileAttachments()
|
||||
|
||||
// Read file attachments from disk
|
||||
let attachments: [FileAttachment]? = filePaths.isEmpty ? nil : readFileAttachments(filePaths)
|
||||
@@ -141,17 +188,18 @@ class ChatViewModel {
|
||||
timestamp: Date(),
|
||||
attachments: attachments
|
||||
)
|
||||
|
||||
|
||||
messages.append(userMessage)
|
||||
sessionStats.addMessage(inputTokens: userMessage.tokens, outputTokens: nil, cost: nil)
|
||||
|
||||
// Clear input
|
||||
inputText = ""
|
||||
|
||||
// Add to command history
|
||||
|
||||
// Add to command history (in-memory and database)
|
||||
commandHistory.append(trimmedInput)
|
||||
historyIndex = commandHistory.count
|
||||
|
||||
DatabaseService.shared.saveCommandHistory(input: trimmedInput)
|
||||
|
||||
// Clear input
|
||||
inputText = ""
|
||||
|
||||
// Generate real AI response
|
||||
generateAIResponse(to: cleanText, attachments: userMessage.attachments)
|
||||
}
|
||||
@@ -220,6 +268,9 @@ class ChatViewModel {
|
||||
case "/help":
|
||||
showHelp = true
|
||||
|
||||
case "/history":
|
||||
showHistory = true
|
||||
|
||||
case "/model":
|
||||
showModelSelector = true
|
||||
|
||||
@@ -367,6 +418,8 @@ class ChatViewModel {
|
||||
|
||||
// Start streaming
|
||||
streamingTask = Task {
|
||||
let startTime = Date()
|
||||
var messageId: UUID?
|
||||
do {
|
||||
// Create empty assistant message for streaming
|
||||
let assistantMessage = Message(
|
||||
@@ -378,7 +431,8 @@ class ChatViewModel {
|
||||
attachments: nil,
|
||||
isStreaming: true
|
||||
)
|
||||
|
||||
messageId = assistantMessage.id
|
||||
|
||||
// Already on MainActor
|
||||
messages.append(assistantMessage)
|
||||
|
||||
@@ -411,14 +465,12 @@ class ChatViewModel {
|
||||
maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
|
||||
temperature: settings.temperature > 0 ? settings.temperature : nil,
|
||||
topP: nil,
|
||||
systemPrompt: nil,
|
||||
systemPrompt: effectiveSystemPrompt,
|
||||
tools: nil,
|
||||
onlineMode: onlineMode,
|
||||
imageGeneration: isImageGen
|
||||
)
|
||||
|
||||
let messageId = assistantMessage.id
|
||||
|
||||
if isImageGen {
|
||||
// Image generation: use non-streaming request
|
||||
// Image models don't reliably support streaming
|
||||
@@ -435,11 +487,13 @@ class ChatViewModel {
|
||||
imageGeneration: true
|
||||
)
|
||||
let response = try await provider.chat(request: nonStreamRequest)
|
||||
let responseTime = Date().timeIntervalSince(startTime)
|
||||
|
||||
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||
messages[index].content = response.content
|
||||
messages[index].isStreaming = false
|
||||
messages[index].generatedImages = response.generatedImages
|
||||
messages[index].responseTime = responseTime
|
||||
|
||||
if let usage = response.usage {
|
||||
messages[index].tokens = usage.completionTokens
|
||||
@@ -455,9 +509,13 @@ class ChatViewModel {
|
||||
// Regular text: stream response
|
||||
var fullContent = ""
|
||||
var totalTokens: ChatResponse.Usage? = nil
|
||||
var wasCancelled = false
|
||||
|
||||
for try await chunk in provider.streamChat(request: chatRequest) {
|
||||
if Task.isCancelled { break }
|
||||
if Task.isCancelled {
|
||||
wasCancelled = true
|
||||
break
|
||||
}
|
||||
|
||||
if let content = chunk.deltaContent {
|
||||
fullContent += content
|
||||
@@ -471,9 +529,19 @@ class ChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cancellation one more time after loop exits
|
||||
// (in case it was cancelled after the last chunk)
|
||||
if Task.isCancelled {
|
||||
wasCancelled = true
|
||||
}
|
||||
|
||||
let responseTime = Date().timeIntervalSince(startTime)
|
||||
|
||||
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||
messages[index].content = fullContent
|
||||
messages[index].isStreaming = false
|
||||
messages[index].responseTime = responseTime
|
||||
messages[index].wasInterrupted = wasCancelled
|
||||
|
||||
if let usage = totalTokens {
|
||||
messages[index].tokens = usage.completionTokens
|
||||
@@ -491,13 +559,27 @@ class ChatViewModel {
|
||||
streamingTask = nil
|
||||
|
||||
} catch {
|
||||
// Remove the empty streaming message
|
||||
if let index = messages.lastIndex(where: { $0.role == .assistant && $0.content.isEmpty }) {
|
||||
messages.remove(at: index)
|
||||
let responseTime = Date().timeIntervalSince(startTime)
|
||||
|
||||
// Check if this was a cancellation (either by checking Task state or error type)
|
||||
let isCancellation = Task.isCancelled || error is CancellationError
|
||||
|
||||
if isCancellation, let msgId = messageId {
|
||||
// Mark the message as interrupted instead of removing it
|
||||
if let index = messages.firstIndex(where: { $0.id == msgId }) {
|
||||
messages[index].isStreaming = false
|
||||
messages[index].wasInterrupted = true
|
||||
messages[index].responseTime = responseTime
|
||||
}
|
||||
} else if let msgId = messageId {
|
||||
// For real errors, remove the empty streaming message
|
||||
if let index = messages.firstIndex(where: { $0.id == msgId && $0.content.isEmpty }) {
|
||||
messages.remove(at: index)
|
||||
}
|
||||
Log.api.error("Generation failed: \(error.localizedDescription)")
|
||||
showSystemMessage("❌ \(friendlyErrorMessage(from: error))")
|
||||
}
|
||||
|
||||
Log.api.error("Generation failed: \(error.localizedDescription)")
|
||||
showSystemMessage("❌ \(friendlyErrorMessage(from: error))")
|
||||
isGenerating = false
|
||||
streamingTask = nil
|
||||
}
|
||||
@@ -682,6 +764,8 @@ class ChatViewModel {
|
||||
streamingTask?.cancel()
|
||||
|
||||
streamingTask = Task {
|
||||
let startTime = Date()
|
||||
var wasCancelled = false
|
||||
do {
|
||||
let tools = mcp.getToolSchemas()
|
||||
|
||||
@@ -702,7 +786,10 @@ class ChatViewModel {
|
||||
if !writeCapabilities.isEmpty {
|
||||
capabilities += " You can also \(writeCapabilities.joined(separator: ", "))."
|
||||
}
|
||||
let systemContent = "You have access to the user's filesystem through tool calls. \(capabilities) The user has granted you access to these folders:\n - \(folderList)\n\nWhen the user asks about their files, use the tools proactively with the allowed paths. Always use absolute paths."
|
||||
var systemContent = "You have access to the user's filesystem through tool calls. \(capabilities) The user has granted you access to these folders:\n - \(folderList)\n\nWhen the user asks about their files, use the tools proactively with the allowed paths. Always use absolute paths."
|
||||
|
||||
// Append the complete system prompt (default + custom)
|
||||
systemContent += "\n\n---\n\n" + effectiveSystemPrompt
|
||||
|
||||
var messagesToSend: [Message] = memoryEnabled
|
||||
? messages.filter { $0.role != .system }
|
||||
@@ -736,7 +823,10 @@ class ChatViewModel {
|
||||
var totalUsage: ChatResponse.Usage?
|
||||
|
||||
for iteration in 0..<maxIterations {
|
||||
if Task.isCancelled { break }
|
||||
if Task.isCancelled {
|
||||
wasCancelled = true
|
||||
break
|
||||
}
|
||||
|
||||
let response = try await provider.chatWithToolMessages(
|
||||
model: effectiveModelId,
|
||||
@@ -779,7 +869,10 @@ class ChatViewModel {
|
||||
|
||||
// Execute each tool and append results
|
||||
for tc in toolCalls {
|
||||
if Task.isCancelled { break }
|
||||
if Task.isCancelled {
|
||||
wasCancelled = true
|
||||
break
|
||||
}
|
||||
|
||||
let result = mcp.executeTool(name: tc.functionName, arguments: tc.arguments)
|
||||
let resultJSON: String
|
||||
@@ -806,13 +899,22 @@ class ChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cancellation one more time after loop exits
|
||||
if Task.isCancelled {
|
||||
wasCancelled = true
|
||||
}
|
||||
|
||||
// Display the final response as an assistant message
|
||||
let responseTime = Date().timeIntervalSince(startTime)
|
||||
let assistantMessage = Message(
|
||||
role: .assistant,
|
||||
content: finalContent,
|
||||
tokens: totalUsage?.completionTokens,
|
||||
cost: nil,
|
||||
timestamp: Date()
|
||||
timestamp: Date(),
|
||||
attachments: nil,
|
||||
responseTime: responseTime,
|
||||
wasInterrupted: wasCancelled
|
||||
)
|
||||
messages.append(assistantMessage)
|
||||
|
||||
@@ -834,8 +936,26 @@ class ChatViewModel {
|
||||
streamingTask = nil
|
||||
|
||||
} catch {
|
||||
Log.api.error("Tool generation failed: \(error.localizedDescription)")
|
||||
showSystemMessage("❌ \(friendlyErrorMessage(from: error))")
|
||||
let responseTime = Date().timeIntervalSince(startTime)
|
||||
|
||||
// Check if this was a cancellation
|
||||
let isCancellation = Task.isCancelled || wasCancelled || error is CancellationError
|
||||
|
||||
if isCancellation {
|
||||
// Create an interrupted message
|
||||
let assistantMessage = Message(
|
||||
role: .assistant,
|
||||
content: "",
|
||||
timestamp: Date(),
|
||||
responseTime: responseTime,
|
||||
wasInterrupted: true
|
||||
)
|
||||
messages.append(assistantMessage)
|
||||
} else {
|
||||
Log.api.error("Tool generation failed: \(error.localizedDescription)")
|
||||
showSystemMessage("❌ \(friendlyErrorMessage(from: error))")
|
||||
}
|
||||
|
||||
isGenerating = false
|
||||
streamingTask = nil
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ struct ChatView: View {
|
||||
// Input bar
|
||||
InputBar(
|
||||
text: $viewModel.inputText,
|
||||
commandHistory: $viewModel.commandHistory,
|
||||
historyIndex: $viewModel.historyIndex,
|
||||
isGenerating: viewModel.isGenerating,
|
||||
mcpStatus: viewModel.mcpStatus,
|
||||
onlineMode: viewModel.onlineMode,
|
||||
|
||||
@@ -42,6 +42,7 @@ struct ContentView: View {
|
||||
selectedModel: chatViewModel.selectedModel,
|
||||
onSelect: { model in
|
||||
chatViewModel.selectedModel = model
|
||||
SettingsService.shared.defaultModel = model.id
|
||||
chatViewModel.showModelSelector = false
|
||||
}
|
||||
)
|
||||
@@ -77,6 +78,11 @@ struct ContentView: View {
|
||||
.sheet(item: $vm.modelInfoTarget) { model in
|
||||
ModelInfoView(model: model)
|
||||
}
|
||||
.sheet(isPresented: $vm.showHistory) {
|
||||
HistoryView(onSelect: { input in
|
||||
chatViewModel.inputText = input
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@@ -91,11 +97,17 @@ struct ContentView: View {
|
||||
.help("New conversation")
|
||||
|
||||
Button(action: { chatViewModel.showConversations = true }) {
|
||||
Label("History", systemImage: "clock.arrow.circlepath")
|
||||
Label("Conversations", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
.keyboardShortcut("l", modifiers: .command)
|
||||
.help("Saved conversations (Cmd+L)")
|
||||
|
||||
Button(action: { chatViewModel.showHistory = true }) {
|
||||
Label("History", systemImage: "list.bullet")
|
||||
}
|
||||
.keyboardShortcut("h", modifiers: .command)
|
||||
.help("Command history (Cmd+H)")
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { chatViewModel.showModelSelector = true }) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import SwiftUI
|
||||
|
||||
struct InputBar: View {
|
||||
@Binding var text: String
|
||||
@Binding var commandHistory: [String]
|
||||
@Binding var historyIndex: Int
|
||||
let isGenerating: Bool
|
||||
let mcpStatus: String?
|
||||
let onlineMode: Bool
|
||||
@@ -22,7 +24,7 @@ struct InputBar: View {
|
||||
|
||||
/// Commands that execute immediately without additional arguments
|
||||
private static let immediateCommands: Set<String> = [
|
||||
"/help", "/model", "/clear", "/retry", "/stats", "/config",
|
||||
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
|
||||
"/settings", "/credits", "/list", "/load",
|
||||
"/memory on", "/memory off", "/online on", "/online off",
|
||||
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
|
||||
@@ -79,28 +81,56 @@ struct InputBar: View {
|
||||
.onChange(of: text) {
|
||||
showCommandDropdown = text.hasPrefix("/")
|
||||
selectedSuggestionIndex = 0
|
||||
// Reset history index when user types
|
||||
historyIndex = commandHistory.count
|
||||
}
|
||||
#if os(macOS)
|
||||
.onKeyPress(.upArrow) {
|
||||
guard showCommandDropdown else { return .ignored }
|
||||
if selectedSuggestionIndex > 0 {
|
||||
selectedSuggestionIndex -= 1
|
||||
// If command dropdown is showing, navigate dropdown
|
||||
if showCommandDropdown {
|
||||
if selectedSuggestionIndex > 0 {
|
||||
selectedSuggestionIndex -= 1
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
// Otherwise, navigate command history
|
||||
if historyIndex > 0 {
|
||||
historyIndex -= 1
|
||||
text = commandHistory[historyIndex]
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.downArrow) {
|
||||
guard showCommandDropdown else { return .ignored }
|
||||
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
||||
if selectedSuggestionIndex < count - 1 {
|
||||
selectedSuggestionIndex += 1
|
||||
// If command dropdown is showing, navigate dropdown
|
||||
if showCommandDropdown {
|
||||
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
||||
if selectedSuggestionIndex < count - 1 {
|
||||
selectedSuggestionIndex += 1
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
// Otherwise, navigate command history
|
||||
if historyIndex < commandHistory.count - 1 {
|
||||
historyIndex += 1
|
||||
text = commandHistory[historyIndex]
|
||||
} else if historyIndex == commandHistory.count - 1 {
|
||||
// At the end of history, clear text and move to "new" position
|
||||
historyIndex = commandHistory.count
|
||||
text = ""
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.escape) {
|
||||
// If command dropdown is showing, close it
|
||||
if showCommandDropdown {
|
||||
showCommandDropdown = false
|
||||
return .handled
|
||||
}
|
||||
// If model is generating, cancel it
|
||||
if isGenerating {
|
||||
onCancel()
|
||||
return .handled
|
||||
}
|
||||
return .ignored
|
||||
}
|
||||
.onKeyPress(.return) {
|
||||
@@ -227,6 +257,7 @@ struct CommandSuggestionsView: View {
|
||||
|
||||
static let allCommands: [(command: String, description: String)] = [
|
||||
("/help", "Show help and available commands"),
|
||||
("/history", "View command history"),
|
||||
("/model", "Select AI model"),
|
||||
("/clear", "Clear chat history"),
|
||||
("/retry", "Retry last message"),
|
||||
@@ -316,6 +347,8 @@ struct CommandSuggestionsView: View {
|
||||
Spacer()
|
||||
InputBar(
|
||||
text: .constant(""),
|
||||
commandHistory: .constant([]),
|
||||
historyIndex: .constant(0),
|
||||
isGenerating: false,
|
||||
mcpStatus: "📁 Files",
|
||||
onlineMode: true,
|
||||
|
||||
@@ -16,7 +16,7 @@ struct MarkdownContentView: View {
|
||||
|
||||
var body: some View {
|
||||
let segments = parseSegments(content)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(segments.indices, id: \.self) { index in
|
||||
switch segments[index] {
|
||||
case .text(let text):
|
||||
@@ -25,6 +25,8 @@ struct MarkdownContentView: View {
|
||||
}
|
||||
case .codeBlock(let language, let code):
|
||||
CodeBlockView(language: language, code: code, fontSize: fontSize)
|
||||
case .table(let tableText):
|
||||
TableView(content: tableText, fontSize: fontSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,16 +34,18 @@ struct MarkdownContentView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func markdownText(_ text: String) -> some View {
|
||||
if let attrString = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
|
||||
if let attrString = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .full)) {
|
||||
Text(attrString)
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.lineSpacing(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
Text(text)
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.lineSpacing(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
@@ -52,6 +56,7 @@ struct MarkdownContentView: View {
|
||||
enum Segment {
|
||||
case text(String)
|
||||
case codeBlock(language: String?, code: String)
|
||||
case table(String)
|
||||
}
|
||||
|
||||
private func parseSegments(_ content: String) -> [Segment] {
|
||||
@@ -61,10 +66,17 @@ struct MarkdownContentView: View {
|
||||
var inCodeBlock = false
|
||||
var codeLanguage: String? = nil
|
||||
var codeContent = ""
|
||||
var inTable = false
|
||||
var tableLines: [String] = []
|
||||
|
||||
for line in lines {
|
||||
if !inCodeBlock && line.hasPrefix("```") {
|
||||
// Start of code block
|
||||
if inTable {
|
||||
segments.append(.table(tableLines.joined(separator: "\n")))
|
||||
tableLines = []
|
||||
inTable = false
|
||||
}
|
||||
if !currentText.isEmpty {
|
||||
segments.append(.text(currentText))
|
||||
currentText = ""
|
||||
@@ -75,7 +87,6 @@ struct MarkdownContentView: View {
|
||||
codeContent = ""
|
||||
} else if inCodeBlock && line.hasPrefix("```") {
|
||||
// End of code block
|
||||
// Remove trailing newline from code
|
||||
if codeContent.hasSuffix("\n") {
|
||||
codeContent = String(codeContent.dropLast())
|
||||
}
|
||||
@@ -85,7 +96,25 @@ struct MarkdownContentView: View {
|
||||
codeContent = ""
|
||||
} else if inCodeBlock {
|
||||
codeContent += line + "\n"
|
||||
} else if line.contains("|") && (line.hasPrefix("|") || line.filter({ $0 == "|" }).count >= 2) {
|
||||
// Markdown table line
|
||||
if !inTable {
|
||||
// Start of table
|
||||
if !currentText.isEmpty {
|
||||
segments.append(.text(currentText))
|
||||
currentText = ""
|
||||
}
|
||||
inTable = true
|
||||
}
|
||||
tableLines.append(line)
|
||||
} else {
|
||||
// Regular text
|
||||
if inTable {
|
||||
// End of table
|
||||
segments.append(.table(tableLines.joined(separator: "\n")))
|
||||
tableLines = []
|
||||
inTable = false
|
||||
}
|
||||
currentText += line + "\n"
|
||||
}
|
||||
}
|
||||
@@ -98,9 +127,13 @@ struct MarkdownContentView: View {
|
||||
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
|
||||
}
|
||||
|
||||
// Handle unclosed table
|
||||
if inTable {
|
||||
segments.append(.table(tableLines.joined(separator: "\n")))
|
||||
}
|
||||
|
||||
// Remaining text
|
||||
if !currentText.isEmpty {
|
||||
// Remove trailing newline
|
||||
if currentText.hasSuffix("\n") {
|
||||
currentText = String(currentText.dropLast())
|
||||
}
|
||||
@@ -113,6 +146,136 @@ struct MarkdownContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Table View
|
||||
|
||||
struct TableView: View {
|
||||
let content: String
|
||||
let fontSize: Double
|
||||
|
||||
private struct TableData {
|
||||
let headers: [String]
|
||||
let alignments: [TextAlignment]
|
||||
let rows: [[String]]
|
||||
}
|
||||
|
||||
private var tableData: TableData {
|
||||
parseTable(content)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let data = tableData
|
||||
|
||||
guard !data.headers.isEmpty else {
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
|
||||
return AnyView(
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Headers
|
||||
HStack(spacing: 0) {
|
||||
ForEach(data.headers.indices, id: \.self) { index in
|
||||
if index < data.headers.count {
|
||||
Text(data.headers[index].trimmingCharacters(in: .whitespaces))
|
||||
.font(.system(size: fontSize, weight: .semibold))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: alignmentFor(
|
||||
index < data.alignments.count ? data.alignments[index] : .leading
|
||||
))
|
||||
.padding(8)
|
||||
.background(Color.oaiSecondary.opacity(0.1))
|
||||
|
||||
if index < data.headers.count - 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.stroke(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
|
||||
// Rows
|
||||
ForEach(data.rows.indices, id: \.self) { rowIndex in
|
||||
if rowIndex < data.rows.count {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(0..<data.headers.count, id: \.self) { colIndex in
|
||||
let cellContent = colIndex < data.rows[rowIndex].count ? data.rows[rowIndex][colIndex] : ""
|
||||
let alignment = colIndex < data.alignments.count ? data.alignments[colIndex] : .leading
|
||||
|
||||
Text(cellContent.trimmingCharacters(in: .whitespaces))
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: alignmentFor(alignment))
|
||||
.padding(8)
|
||||
|
||||
if colIndex < data.headers.count - 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(rowIndex % 2 == 0 ? Color.clear : Color.oaiSecondary.opacity(0.05))
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.stroke(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func alignmentFor(_ textAlignment: TextAlignment) -> Alignment {
|
||||
switch textAlignment {
|
||||
case .leading: return .leading
|
||||
case .center: return .center
|
||||
case .trailing: return .trailing
|
||||
}
|
||||
}
|
||||
|
||||
private func parseTable(_ content: String) -> TableData {
|
||||
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
guard lines.count >= 2 else {
|
||||
return TableData(headers: [], alignments: [], rows: [])
|
||||
}
|
||||
|
||||
// Parse headers (first line)
|
||||
let headers = lines[0].components(separatedBy: "|")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
// Parse alignment row (second line with dashes)
|
||||
let alignmentLine = lines[1]
|
||||
let alignments = alignmentLine.components(separatedBy: "|")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
.map { cell -> TextAlignment in
|
||||
let trimmed = cell.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix(":") && trimmed.hasSuffix(":") {
|
||||
return .center
|
||||
} else if trimmed.hasSuffix(":") {
|
||||
return .trailing
|
||||
} else {
|
||||
return .leading
|
||||
}
|
||||
}
|
||||
|
||||
// Parse data rows (remaining lines)
|
||||
let rows = lines.dropFirst(2).map { line in
|
||||
line.components(separatedBy: "|")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
return TableData(headers: headers, alignments: alignments, rows: rows)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Code Block View
|
||||
|
||||
struct CodeBlockView: View {
|
||||
|
||||
@@ -84,19 +84,38 @@ struct MessageRow: View {
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Token/cost info
|
||||
if let tokens = message.tokens, let cost = message.cost {
|
||||
// Token/cost/time info
|
||||
if message.role == .assistant && (message.tokens != nil || message.cost != nil || message.responseTime != nil || message.wasInterrupted) {
|
||||
HStack(spacing: 8) {
|
||||
Label("\(tokens)", systemImage: "chart.bar.xaxis")
|
||||
Text("\u{2022}")
|
||||
Text(String(format: "$%.4f", cost))
|
||||
if let tokens = message.tokens {
|
||||
Label("\(tokens)", systemImage: "chart.bar.xaxis")
|
||||
if message.cost != nil || message.responseTime != nil || message.wasInterrupted {
|
||||
Text("\u{2022}")
|
||||
}
|
||||
}
|
||||
if let cost = message.cost {
|
||||
Text(String(format: "$%.4f", cost))
|
||||
if message.responseTime != nil || message.wasInterrupted {
|
||||
Text("\u{2022}")
|
||||
}
|
||||
}
|
||||
if let responseTime = message.responseTime {
|
||||
Text(String(format: "%.1fs", responseTime))
|
||||
if message.wasInterrupted {
|
||||
Text("\u{2022}")
|
||||
}
|
||||
}
|
||||
if message.wasInterrupted {
|
||||
Text("⚠️ interrupted")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.padding(16)
|
||||
.background(Color.messageBackground(for: message.role))
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
|
||||
@@ -12,6 +12,8 @@ struct ConversationListView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var searchText = ""
|
||||
@State private var conversations: [Conversation] = []
|
||||
@State private var selectedConversations: Set<UUID> = []
|
||||
@State private var isSelecting = false
|
||||
var onLoad: ((Conversation) -> Void)?
|
||||
|
||||
private var filteredConversations: [Conversation] {
|
||||
@@ -30,13 +32,42 @@ struct ConversationListView: View {
|
||||
Text("Conversations")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
Spacer()
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if isSelecting {
|
||||
Button("Cancel") {
|
||||
isSelecting = false
|
||||
selectedConversations.removeAll()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if !selectedConversations.isEmpty {
|
||||
Button(role: .destructive) {
|
||||
deleteSelected()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "trash")
|
||||
Text("Delete (\(selectedConversations.count))")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
} else {
|
||||
if !conversations.isEmpty {
|
||||
Button("Select") {
|
||||
isSelecting = true
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
@@ -81,26 +112,57 @@ struct ConversationListView: View {
|
||||
} else {
|
||||
List {
|
||||
ForEach(filteredConversations) { conversation in
|
||||
ConversationRow(conversation: conversation)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onLoad?(conversation)
|
||||
dismiss()
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
deleteConversation(conversation)
|
||||
HStack(spacing: 12) {
|
||||
if isSelecting {
|
||||
Button {
|
||||
toggleSelection(conversation.id)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary)
|
||||
.font(.title2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
ConversationRow(conversation: conversation)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if isSelecting {
|
||||
toggleSelection(conversation.id)
|
||||
} else {
|
||||
onLoad?(conversation)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !isSelecting {
|
||||
Button {
|
||||
exportConversation(conversation)
|
||||
deleteConversation(conversation)
|
||||
} label: {
|
||||
Label("Export", systemImage: "square.and.arrow.up")
|
||||
Image(systemName: "trash")
|
||||
.foregroundStyle(.red)
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.tint(.blue)
|
||||
.buttonStyle(.plain)
|
||||
.help("Delete conversation")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
deleteConversation(conversation)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
|
||||
Button {
|
||||
exportConversation(conversation)
|
||||
} label: {
|
||||
Label("Export", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
@@ -123,7 +185,7 @@ struct ConversationListView: View {
|
||||
.onAppear {
|
||||
loadConversations()
|
||||
}
|
||||
.frame(minWidth: 500, minHeight: 400)
|
||||
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600)
|
||||
}
|
||||
|
||||
private func loadConversations() {
|
||||
@@ -135,6 +197,29 @@ struct ConversationListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleSelection(_ id: UUID) {
|
||||
if selectedConversations.contains(id) {
|
||||
selectedConversations.remove(id)
|
||||
} else {
|
||||
selectedConversations.insert(id)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteSelected() {
|
||||
for id in selectedConversations {
|
||||
do {
|
||||
let _ = try DatabaseService.shared.deleteConversation(id: id)
|
||||
} catch {
|
||||
Log.db.error("Failed to delete conversation: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
withAnimation {
|
||||
conversations.removeAll { selectedConversations.contains($0.id) }
|
||||
selectedConversations.removeAll()
|
||||
isSelecting = false
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteConversation(_ conversation: Conversation) {
|
||||
do {
|
||||
let _ = try DatabaseService.shared.deleteConversation(id: conversation.id)
|
||||
@@ -167,20 +252,28 @@ struct ConversationListView: View {
|
||||
struct ConversationRow: View {
|
||||
let conversation: Conversation
|
||||
|
||||
private var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "dd.MM.yyyy HH:mm:ss"
|
||||
return formatter.string(from: conversation.updatedAt)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(conversation.name)
|
||||
.font(.headline)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Label("\(conversation.messageCount)", systemImage: "message")
|
||||
.font(.system(size: 13))
|
||||
Text("\u{2022}")
|
||||
Text(conversation.updatedAt, style: .relative)
|
||||
.font(.system(size: 13))
|
||||
Text(formattedDate)
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ struct CommandCategory: Identifiable {
|
||||
|
||||
private let helpCategories: [CommandCategory] = [
|
||||
CommandCategory(name: "Chat", icon: "bubble.left.and.bubble.right", commands: [
|
||||
CommandDetail(
|
||||
command: "/history",
|
||||
brief: "View command history",
|
||||
detail: "Opens a searchable modal showing all your previous messages with timestamps in European format (dd.MM.yyyy HH:mm:ss). Search by text content or date to find specific messages. Click any entry to reuse it.",
|
||||
examples: ["/history"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/clear",
|
||||
brief: "Clear chat history",
|
||||
@@ -170,10 +176,11 @@ private let keyboardShortcuts: [(key: String, description: String)] = [
|
||||
("Shift + Return", "New line"),
|
||||
("\u{2318}M", "Model Selector"),
|
||||
("\u{2318}K", "Clear Chat"),
|
||||
("\u{2318}H", "Command History"),
|
||||
("\u{2318}L", "Conversations"),
|
||||
("\u{21E7}\u{2318}S", "Statistics"),
|
||||
("\u{2318},", "Settings"),
|
||||
("\u{2318}/", "Help"),
|
||||
("\u{2318}L", "Conversations"),
|
||||
]
|
||||
|
||||
// MARK: - HelpView
|
||||
|
||||
144
oAI/Views/Screens/HistoryView.swift
Normal file
144
oAI/Views/Screens/HistoryView.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
//
|
||||
// HistoryView.swift
|
||||
// oAI
|
||||
//
|
||||
// Command history viewer with search
|
||||
//
|
||||
|
||||
import os
|
||||
import SwiftUI
|
||||
|
||||
struct HistoryView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var searchText = ""
|
||||
@State private var historyEntries: [HistoryEntry] = []
|
||||
var onSelect: ((String) -> Void)?
|
||||
|
||||
private var filteredHistory: [HistoryEntry] {
|
||||
if searchText.isEmpty {
|
||||
return historyEntries
|
||||
}
|
||||
return historyEntries.filter {
|
||||
$0.input.lowercased().contains(searchText.lowercased()) ||
|
||||
$0.formattedDate.contains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Command History")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
Spacer()
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
// Search bar
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search by text or date (dd.mm.yyyy)...", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
if !searchText.isEmpty {
|
||||
Button { searchText = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
// Content
|
||||
if filteredHistory.isEmpty {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: searchText.isEmpty ? "list.bullet" : "magnifyingglass")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(searchText.isEmpty ? "No Command History" : "No Matches")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(searchText.isEmpty ? "Your command history will appear here" : "Try a different search term or date")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
List {
|
||||
ForEach(filteredHistory) { entry in
|
||||
HistoryRow(entry: entry)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onSelect?(entry.input)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 400)
|
||||
.background(Color.oaiBackground)
|
||||
.task {
|
||||
loadHistory()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadHistory() {
|
||||
do {
|
||||
let records = try DatabaseService.shared.loadCommandHistory()
|
||||
historyEntries = records.map { HistoryEntry(input: $0.input, timestamp: $0.timestamp) }
|
||||
} catch {
|
||||
Log.db.error("Failed to load command history: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HistoryRow: View {
|
||||
let entry: HistoryEntry
|
||||
private let settings = SettingsService.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Input text
|
||||
Text(entry.input)
|
||||
.font(.system(size: settings.dialogTextSize))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(3)
|
||||
|
||||
// Timestamp
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "clock")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(entry.formattedDate)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HistoryView { input in
|
||||
print("Selected: \(input)")
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,30 @@ struct SettingsView: View {
|
||||
@State private var showOAuthCodeField = false
|
||||
private var oauthService = AnthropicOAuthService.shared
|
||||
|
||||
private let labelWidth: CGFloat = 140
|
||||
private let labelWidth: CGFloat = 160
|
||||
|
||||
// Default system prompt
|
||||
private let defaultSystemPrompt = """
|
||||
You are a helpful AI assistant. Follow these guidelines:
|
||||
|
||||
1. **Accuracy First**: Never invent information or make assumptions. If you're unsure about something, say so clearly.
|
||||
|
||||
2. **Ask for Clarification**: When a request is ambiguous or lacks necessary details, ask clarifying questions before proceeding.
|
||||
|
||||
3. **Be Honest About Limitations**: If you cannot or should not help with a request, explain why clearly and politely. Don't attempt tasks outside your capabilities.
|
||||
|
||||
4. **Stay Grounded**: Base responses on facts and known information. Clearly distinguish between facts, informed opinions, and speculation.
|
||||
|
||||
5. **Be Direct**: Provide concise, relevant answers. Avoid unnecessary preambles or apologies.
|
||||
|
||||
Remember: It's better to ask questions or admit uncertainty than to provide incorrect or fabricated information.
|
||||
"""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Title
|
||||
Text("Settings")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
@@ -42,6 +59,7 @@ struct SettingsView: View {
|
||||
Text("General").tag(0)
|
||||
Text("MCP").tag(1)
|
||||
Text("Appearance").tag(2)
|
||||
Text("Advanced").tag(3)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 24)
|
||||
@@ -58,6 +76,8 @@ struct SettingsView: View {
|
||||
mcpTab
|
||||
case 2:
|
||||
appearanceTab
|
||||
case 3:
|
||||
advancedTab
|
||||
default:
|
||||
generalTab
|
||||
}
|
||||
@@ -80,7 +100,7 @@ struct SettingsView: View {
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.frame(minWidth: 550, idealWidth: 600, minHeight: 500, idealHeight: 650)
|
||||
.frame(minWidth: 700, idealWidth: 800, minHeight: 600, idealHeight: 750)
|
||||
}
|
||||
|
||||
// MARK: - General Tab
|
||||
@@ -106,6 +126,7 @@ struct SettingsView: View {
|
||||
row("OpenRouter") {
|
||||
SecureField("sk-or-...", text: $openrouterKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 13))
|
||||
.onAppear { openrouterKey = settingsService.openrouterAPIKey ?? "" }
|
||||
.onChange(of: openrouterKey) {
|
||||
settingsService.openrouterAPIKey = openrouterKey.isEmpty ? nil : openrouterKey
|
||||
@@ -121,13 +142,13 @@ struct SettingsView: View {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("Logged in via Claude Pro/Max")
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14))
|
||||
Spacer()
|
||||
Button("Logout") {
|
||||
oauthService.logout()
|
||||
ProviderRegistry.shared.clearCache()
|
||||
}
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
} else if showOAuthCodeField {
|
||||
@@ -144,11 +165,11 @@ struct SettingsView: View {
|
||||
oauthCode = ""
|
||||
oauthError = nil
|
||||
}
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
if let error = oauthError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
} else {
|
||||
@@ -161,13 +182,13 @@ struct SettingsView: View {
|
||||
Image(systemName: "person.circle")
|
||||
Text("Login with Claude Pro/Max")
|
||||
}
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
|
||||
Text("or")
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -251,9 +272,6 @@ struct SettingsView: View {
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
row("") {
|
||||
Toggle("Streaming Responses", isOn: $settingsService.streamEnabled)
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
@@ -274,7 +292,7 @@ struct SettingsView: View {
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Controls which messages are written to ~/Library/Logs/oAI.log")
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
@@ -284,10 +302,41 @@ struct SettingsView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var mcpTab: some View {
|
||||
// Enable toggle
|
||||
sectionHeader("MCP")
|
||||
// Description header
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "folder.badge.gearshape")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.blue)
|
||||
Text("Model Context Protocol")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
}
|
||||
Text("MCP gives the AI controlled access to read and optionally write files on your computer. The AI can search, read, and analyze files in allowed folders to help with coding, analysis, and other tasks.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
divider()
|
||||
|
||||
// Enable toggle with status
|
||||
sectionHeader("Status")
|
||||
row("") {
|
||||
Toggle("MCP Enabled (File Access)", isOn: $settingsService.mcpEnabled)
|
||||
Toggle("Enable MCP", isOn: $settingsService.mcpEnabled)
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: settingsService.mcpEnabled ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(settingsService.mcpEnabled ? .green : .secondary)
|
||||
.font(.system(size: 13))
|
||||
Text(settingsService.mcpEnabled ? "Active - AI can access allowed folders" : "Disabled - No file access")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if settingsService.mcpEnabled {
|
||||
@@ -297,12 +346,22 @@ struct SettingsView: View {
|
||||
sectionHeader("Allowed Folders")
|
||||
|
||||
if mcpService.allowedFolders.isEmpty {
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("No folders added")
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "folder.badge.plus")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("No folders added yet")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
Text("Click 'Add Folder' below to grant AI access to a folder")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
.background(Color.gray.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
.padding(.horizontal, labelWidth + 24)
|
||||
} else {
|
||||
ForEach(Array(mcpService.allowedFolders.enumerated()), id: \.offset) { index, folder in
|
||||
HStack(spacing: 0) {
|
||||
@@ -314,7 +373,7 @@ struct SettingsView: View {
|
||||
Text((folder as NSString).lastPathComponent)
|
||||
.font(.body)
|
||||
Text(abbreviatePath(folder))
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
@@ -324,7 +383,7 @@ struct SettingsView: View {
|
||||
} label: {
|
||||
Image(systemName: "trash.fill")
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -340,7 +399,7 @@ struct SettingsView: View {
|
||||
Image(systemName: "plus")
|
||||
Text("Add Folder...")
|
||||
}
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Spacer()
|
||||
@@ -362,20 +421,46 @@ struct SettingsView: View {
|
||||
|
||||
// Permissions
|
||||
sectionHeader("Permissions")
|
||||
row("") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Write & Edit Files", isOn: $settingsService.mcpCanWriteFiles)
|
||||
Toggle("Delete Files", isOn: $settingsService.mcpCanDeleteFiles)
|
||||
Toggle("Create Directories", isOn: $settingsService.mcpCanCreateDirectories)
|
||||
Toggle("Move & Copy Files", isOn: $settingsService.mcpCanMoveFiles)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.system(size: 12))
|
||||
Text("Read access (always enabled)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("The AI can read and search files in allowed folders")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.leading, 18)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Write Permissions (optional)")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Write & Edit Files", isOn: $settingsService.mcpCanWriteFiles)
|
||||
Toggle("Delete Files", isOn: $settingsService.mcpCanDeleteFiles)
|
||||
Toggle("Create Directories", isOn: $settingsService.mcpCanCreateDirectories)
|
||||
Toggle("Move & Copy Files", isOn: $settingsService.mcpCanMoveFiles)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Read access is always available when MCP is enabled. Write permissions must be explicitly enabled.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
divider()
|
||||
@@ -394,7 +479,7 @@ struct SettingsView: View {
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("When enabled, listing and searching skip gitignored files. Write operations always ignore .gitignore.")
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
@@ -411,7 +496,7 @@ struct SettingsView: View {
|
||||
Slider(value: $settingsService.guiTextSize, in: 10...20, step: 1)
|
||||
.frame(maxWidth: 200)
|
||||
Text("\(Int(settingsService.guiTextSize)) pt")
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 40)
|
||||
}
|
||||
@@ -421,7 +506,7 @@ struct SettingsView: View {
|
||||
Slider(value: $settingsService.dialogTextSize, in: 10...24, step: 1)
|
||||
.frame(maxWidth: 200)
|
||||
Text("\(Int(settingsService.dialogTextSize)) pt")
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 40)
|
||||
}
|
||||
@@ -431,18 +516,188 @@ struct SettingsView: View {
|
||||
Slider(value: $settingsService.inputTextSize, in: 10...24, step: 1)
|
||||
.frame(maxWidth: 200)
|
||||
Text("\(Int(settingsService.inputTextSize)) pt")
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Advanced Tab
|
||||
|
||||
@ViewBuilder
|
||||
private var advancedTab: some View {
|
||||
sectionHeader("Response Generation")
|
||||
row("") {
|
||||
Toggle("Enable Streaming Responses", isOn: $settingsService.streamEnabled)
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Stream responses as they're generated. Disable for single, complete responses.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
sectionHeader("Model Parameters")
|
||||
|
||||
// Max Tokens
|
||||
row("Max Tokens") {
|
||||
HStack(spacing: 8) {
|
||||
Slider(value: Binding(
|
||||
get: { Double(settingsService.maxTokens) },
|
||||
set: { settingsService.maxTokens = Int($0) }
|
||||
), in: 0...32000, step: 256)
|
||||
.frame(maxWidth: 250)
|
||||
Text(settingsService.maxTokens == 0 ? "Default" : "\(settingsService.maxTokens)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 70, alignment: .leading)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Maximum tokens in the response. Set to 0 to use model default. Higher values allow longer responses but cost more.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
// Temperature
|
||||
row("Temperature") {
|
||||
HStack(spacing: 8) {
|
||||
Slider(value: $settingsService.temperature, in: 0...2, step: 0.1)
|
||||
.frame(maxWidth: 250)
|
||||
Text(settingsService.temperature == 0.0 ? "Default" : String(format: "%.1f", settingsService.temperature))
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 70, alignment: .leading)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Controls randomness. Set to 0 to use model default.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("• Lower (0.0-0.7): More focused, deterministic")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("• Higher (0.8-2.0): More creative, random")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
sectionHeader("System Prompts")
|
||||
|
||||
// Default prompt (read-only)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
HStack(spacing: 4) {
|
||||
Text("Default Prompt")
|
||||
.font(.system(size: 14))
|
||||
.fontWeight(.medium)
|
||||
Text("(always used)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
ScrollView {
|
||||
Text(defaultSystemPrompt)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
}
|
||||
.frame(height: 160)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("This default prompt is always included to ensure accurate, helpful responses.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Custom prompt (editable)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
HStack(spacing: 4) {
|
||||
Text("Your Custom Prompt")
|
||||
.font(.system(size: 14))
|
||||
.fontWeight(.medium)
|
||||
Text("(optional)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
TextEditor(text: Binding(
|
||||
get: { settingsService.systemPrompt ?? "" },
|
||||
set: { settingsService.systemPrompt = $0.isEmpty ? nil : $0 }
|
||||
))
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.frame(height: 120)
|
||||
.padding(8)
|
||||
.background(Color(NSColor.textBackgroundColor))
|
||||
.cornerRadius(6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Add additional instructions here. This will be appended to the default prompt. Leave empty if you don't need custom instructions.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
sectionHeader("Info")
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("⚠️ These are advanced settings")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.orange)
|
||||
.fontWeight(.medium)
|
||||
Text("Changing these values affects how the AI generates responses. The defaults work well for most use cases. Only adjust if you know what you're doing.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout Helpers
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
@@ -450,7 +705,7 @@ struct SettingsView: View {
|
||||
private func row<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Text(label)
|
||||
.font(.body)
|
||||
.font(.system(size: 14))
|
||||
.frame(width: labelWidth, alignment: .trailing)
|
||||
content()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@main
|
||||
struct oAIApp: App {
|
||||
@@ -32,7 +35,25 @@ struct oAIApp: App {
|
||||
showAbout = true
|
||||
}
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .help) {
|
||||
Button("oAI Help") {
|
||||
openHelp()
|
||||
}
|
||||
.keyboardShortcut("?", modifiers: .command)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func openHelp() {
|
||||
if let helpBookURL = Bundle.main.url(forResource: "oAI.help", withExtension: nil) {
|
||||
NSWorkspace.shared.open(helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html"))
|
||||
} else {
|
||||
// Fallback to Apple Help if help book not found
|
||||
NSHelpManager.shared.openHelpAnchor("", inBook: Bundle.main.object(forInfoDictionaryKey: "CFBundleHelpBookName") as? String)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user