Initial commit

This commit is contained in:
2026-02-11 22:22:55 +01:00
commit 42f54954c1
58 changed files with 10639 additions and 0 deletions

114
.gitignore vendored Normal file
View File

@@ -0,0 +1,114 @@
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
*.xccheckout
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
build/
DerivedData/
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
## Obj-C/Swift specific
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build/
# Accio dependency management
Dependencies/
.accio/
# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# Code Injection
#
# After new code Injection tools there's a generated folder /iOSInjectionProject
# https://github.com/johnno1962/injectionforxcode
iOSInjectionProject/
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk

7
LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright 2025 Rune Olsen
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

150
README.md Normal file
View File

@@ -0,0 +1,150 @@
# oAI
A native macOS AI chat application with support for multiple providers and advanced features.
## Features
### Multi-Provider Support
- **OpenAI** - GPT models with native API support
- **Anthropic** - Claude models with OAuth integration
- **OpenRouter** - Access to 100+ AI models
- **Ollama** - Local model inference
### Core Capabilities
- **Streaming Responses** - Real-time token streaming for faster interactions
- **Conversation Management** - Save, load, and delete chat conversations
- **File Attachments** - Support for text files, images, and PDFs
- **Image Generation** - Create images with supported models
- **Online Mode** - Web search integration for up-to-date information
- **Session Statistics** - Track token usage and costs
- **Model Context Protocol (MCP)** - Filesystem access for AI models with configurable permissions
### UI/UX
- Native macOS interface with dark mode support
- Markdown rendering with syntax highlighting
- Command history navigation
- Model selector with detailed information
- Footer stats display (tokens, cost, response time)
## Installation
1. Clone this repository
2. Open `oAI.xcodeproj` in Xcode
3. Build and run (⌘R)
## Configuration
### API Keys
Add your API keys in Settings (⌘,):
- OpenAI API key
- Anthropic API key (or use OAuth)
- OpenRouter API key
- Ollama base URL (default: http://localhost:11434)
### Settings
- **Provider** - Select default AI provider
- **Streaming** - Enable/disable response streaming
- **Memory** - Control conversation context (on/off)
- **Online Mode** - Enable web search integration
- **Max Tokens** - Set maximum response length
- **Temperature** - Control response randomness
## Slash Commands
### Model & Chat
- `/help` - Show help and available commands
- `/model` - Open model selector (⌘M)
- `/clear` - Clear current conversation
- `/retry` - Regenerate last response
- `/info [model]` - Display model information
### Conversation Management
- `/save <name>` - Save current conversation
- `/load` or `/list` - List and load saved conversations
- `/delete <name>` - Delete a saved conversation
- `/export <md|json> [filename]` - Export conversation
### Provider & Settings
- `/provider [name]` - Switch or display current provider
- `/config` or `/settings` - Open settings (⌘,)
- `/stats` - View session statistics
- `/credits` - Check API credits/balance
### Features
- `/memory <on|off>` - Toggle conversation memory
- `/online <on|off>` - Toggle online/web search mode
- `/mcp <on|off|status|add|remove|list>` - Manage MCP filesystem access
### MCP (Model Context Protocol)
- `/mcp add <path>` - Grant AI access to a folder
- `/mcp remove <index|path>` - Revoke folder access
- `/mcp list` - Show allowed folders
- `/mcp write <on|off>` - Enable/disable file write permissions
- `/mcp status` - Display MCP configuration
## File Attachments
Attach files to your messages using the syntax: `@/path/to/file`
Example:
```
Can you review this code? @~/project/main.swift
```
Supported formats:
- **Text files** - Any UTF-8 text file (.txt, .md, .swift, .py, etc.)
- **Images** - PNG, JPG, WebP (for vision-capable models)
- **PDFs** - Document analysis with vision models
Limits:
- Maximum file size: 10 MB
- Text files truncated after 50 KB (head + tail shown)
## Keyboard Shortcuts
- `⌘M` - Open model selector
- `⌘,` - Open settings
- `⌘N` - New conversation
- `↑/↓` - Navigate command history
## Development
### Project Structure
```
oAI/
├── Models/ # Data models (Message, Conversation, Settings)
├── Views/ # SwiftUI views
│ ├── Main/ # Chat, header, footer, input
│ └── Screens/ # Settings, stats, model selector
├── ViewModels/ # ChatViewModel
├── Providers/ # AI provider implementations
├── Services/ # Database, MCP, web search, settings
└── Utilities/ # Extensions, logging, syntax highlighting
```
### Key Components
- **ChatViewModel** - Main state management and message handling
- **ProviderRegistry** - Provider selection and initialization
- **AIProvider Protocol** - Common interface for all AI providers
- **MCPService** - Filesystem tool integration
- **DatabaseService** - Conversation persistence
- **WebSearchService** - Online search integration
## Requirements
- macOS 14.0+
- Xcode 15.0+
- Swift 5.9+
## License
[Add your license here]
## Contributing
[Add contribution guidelines here]
## Support
For issues or questions, please [open an issue](link-to-issues).

View File

@@ -0,0 +1,380 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
A550A8342F3C5C9300136F2B /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = A550A6812F3B730000136F2B /* GRDB */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
A550A6622F3B72EA00136F2B /* oAI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = oAI.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
A550A6642F3B72EA00136F2B /* oAI */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = oAI;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
A550A65F2F3B72EA00136F2B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A550A8342F3C5C9300136F2B /* GRDB in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
A550A6592F3B72EA00136F2B = {
isa = PBXGroup;
children = (
A550A6642F3B72EA00136F2B /* oAI */,
A550A6632F3B72EA00136F2B /* Products */,
);
sourceTree = "<group>";
};
A550A6632F3B72EA00136F2B /* Products */ = {
isa = PBXGroup;
children = (
A550A6622F3B72EA00136F2B /* oAI.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
A550A6612F3B72EA00136F2B /* oAI */ = {
isa = PBXNativeTarget;
buildConfigurationList = A550A66D2F3B72EC00136F2B /* Build configuration list for PBXNativeTarget "oAI" */;
buildPhases = (
A550A65E2F3B72EA00136F2B /* Sources */,
A550A65F2F3B72EA00136F2B /* Frameworks */,
A550A6602F3B72EA00136F2B /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
A550A6642F3B72EA00136F2B /* oAI */,
);
name = oAI;
packageProductDependencies = (
A550A6812F3B730000136F2B /* GRDB */,
);
productName = oAI;
productReference = A550A6622F3B72EA00136F2B /* oAI.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
A550A65A2F3B72EA00136F2B /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2620;
LastUpgradeCheck = 2620;
TargetAttributes = {
A550A6612F3B72EA00136F2B = {
CreatedOnToolsVersion = 26.2;
};
};
};
buildConfigurationList = A550A65D2F3B72EA00136F2B /* Build configuration list for PBXProject "oAI" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = A550A6592F3B72EA00136F2B;
minimizedProjectReferenceProxies = 1;
packageReferences = (
A550A6802F3B730000136F2B /* XCRemoteSwiftPackageReference "GRDB" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = A550A6632F3B72EA00136F2B /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
A550A6612F3B72EA00136F2B /* oAI */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A550A6602F3B72EA00136F2B /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
A550A65E2F3B72EA00136F2B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
A550A66B2F3B72EC00136F2B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
A550A66C2F3B72EC00136F2B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
A550A66E2F3B72EC00136F2B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_APP_SANDBOX = NO;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 26.2;
};
name = Debug;
};
A550A66F2F3B72EC00136F2B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_APP_SANDBOX = NO;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 26.2;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
A550A65D2F3B72EA00136F2B /* Build configuration list for PBXProject "oAI" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A550A66B2F3B72EC00136F2B /* Debug */,
A550A66C2F3B72EC00136F2B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A550A66D2F3B72EC00136F2B /* Build configuration list for PBXNativeTarget "oAI" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A550A66E2F3B72EC00136F2B /* Debug */,
A550A66F2F3B72EC00136F2B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
A550A6802F3B730000136F2B /* XCRemoteSwiftPackageReference "GRDB" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/groue/GRDB.swift";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 7.0.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
A550A6812F3B730000136F2B /* GRDB */ = {
isa = XCSwiftPackageProductDependency;
package = A550A6802F3B730000136F2B /* XCRemoteSwiftPackageReference "GRDB" */;
productName = GRDB;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = A550A65A2F3B72EA00136F2B /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,15 @@
{
"originHash" : "d77223ea3cadaebd2154378ec5005b6ebefcef3b34a4dafa368b0c4f16c0561c",
"pins" : [
{
"identity" : "grdb.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/groue/GRDB.swift",
"state" : {
"revision" : "aa0079aeb82a4bf00324561a40bffe68c6fe1c26",
"version" : "7.9.0"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,74 @@
{
"images" : [
{
"filename" : "icon_1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"filename" : "icon_16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "icon_32.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "icon_32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "icon_64.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon_128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "icon_256.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "icon_256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "icon_512.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "icon_512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon_1024.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "AppLogo.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
//
// Conversation.swift
// oAI
//
// Model for saved conversations
//
import Foundation
struct Conversation: Identifiable, Codable {
let id: UUID
var name: String
var messages: [Message]
let createdAt: Date
var updatedAt: Date
init(
id: UUID = UUID(),
name: String,
messages: [Message] = [],
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.id = id
self.name = name
self.messages = messages
self.createdAt = createdAt
self.updatedAt = updatedAt
}
var messageCount: Int {
messages.count
}
var lastMessageDate: Date {
messages.last?.timestamp ?? updatedAt
}
}

125
oAI/Models/Message.swift Normal file
View File

@@ -0,0 +1,125 @@
//
// Message.swift
// oAI
//
// Core message model for chat conversations
//
import Foundation
enum MessageRole: String, Codable {
case user
case assistant
case system
}
struct Message: Identifiable, Codable, Equatable {
let id: UUID
let role: MessageRole
var content: String
var tokens: Int?
var cost: Double?
let timestamp: Date
let attachments: [FileAttachment]?
// Streaming state (not persisted)
var isStreaming: Bool = false
// Generated images from image-output models (base64-decoded PNG/JPEG data)
var generatedImages: [Data]? = nil
init(
id: UUID = UUID(),
role: MessageRole,
content: String,
tokens: Int? = nil,
cost: Double? = nil,
timestamp: Date = Date(),
attachments: [FileAttachment]? = nil,
isStreaming: Bool = false,
generatedImages: [Data]? = nil
) {
self.id = id
self.role = role
self.content = content
self.tokens = tokens
self.cost = cost
self.timestamp = timestamp
self.attachments = attachments
self.isStreaming = isStreaming
self.generatedImages = generatedImages
}
enum CodingKeys: String, CodingKey {
case id, role, content, tokens, cost, timestamp, attachments
}
static func == (lhs: Message, rhs: Message) -> Bool {
lhs.id == rhs.id &&
lhs.content == rhs.content &&
lhs.tokens == rhs.tokens &&
lhs.cost == rhs.cost &&
lhs.isStreaming == rhs.isStreaming &&
lhs.generatedImages == rhs.generatedImages
}
}
struct FileAttachment: Codable, Equatable {
let path: String
let type: AttachmentType
let data: Data? // file contents: raw bytes for images/PDFs, UTF-8 for text
enum AttachmentType: String, Codable {
case image
case pdf
case text
}
/// Detect attachment type from file extension
static func typeFromExtension(_ path: String) -> AttachmentType {
let ext = (path as NSString).pathExtension.lowercased()
switch ext {
case "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg":
return .image
case "pdf":
return .pdf
default:
return .text
}
}
/// MIME type string for the file (used in base64 data URLs)
var mimeType: String {
let ext = (path as NSString).pathExtension.lowercased()
switch ext {
case "png": return "image/png"
case "jpg", "jpeg": return "image/jpeg"
case "gif": return "image/gif"
case "webp": return "image/webp"
case "bmp": return "image/bmp"
case "svg": return "image/svg+xml"
case "pdf": return "application/pdf"
default: return "text/plain"
}
}
}
// MARK: - Display Helpers
extension MessageRole {
var displayName: String {
switch self {
case .user: return "You"
case .assistant: return "Assistant"
case .system: return "System"
}
}
var iconName: String {
switch self {
case .user: return "person.circle.fill"
case .assistant: return "cpu"
case .system: return "info.circle.fill"
}
}
}

240
oAI/Models/MockData.swift Normal file
View File

@@ -0,0 +1,240 @@
//
// MockData.swift
// oAI
//
// Mock data for Phase 1 testing
//
import Foundation
extension Message {
static let mockUser1 = Message(
role: .user,
content: "What is SwiftUI and how does it compare to UIKit?",
tokens: 12,
cost: 0.00002
)
static let mockAssistant1 = Message(
role: .assistant,
content: """
SwiftUI is Apple's modern declarative framework for building user interfaces across all Apple platforms. Here's how it compares to UIKit:
## Key Differences
**SwiftUI (Declarative)**
- Describe WHAT the UI should look like
- State-driven updates
- Less boilerplate code
- Cross-platform by default
**UIKit (Imperative)**
- Describe HOW to build the UI step-by-step
- Manual state management
- More verbose
- iOS/tvOS focused
## Example
```swift
// SwiftUI
struct ContentView: View {
@State private var count = 0
var body: some View {
VStack {
Text("Count: \\(count)")
Button("Increment") {
count += 1
}
}
}
}
```
```swift
// UIKit equivalent
class ViewController: UIViewController {
var count = 0
let label = UILabel()
let button = UIButton()
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
@objc func incrementTapped() {
count += 1
label.text = "Count: \\(count)"
}
func setupUI() {
// ... lots more setup code
}
}
```
SwiftUI is recommended for new projects, especially if targeting iOS 14+ and multiple platforms.
""",
tokens: 245,
cost: 0.00049
)
static let mockUser2 = Message(
role: .user,
content: "Can you explain async/await in Swift?",
tokens: 8,
cost: 0.00001
)
static let mockAssistant2 = Message(
role: .assistant,
content: """
Async/await is Swift's modern concurrency model introduced in Swift 5.5. It makes asynchronous code look and behave like synchronous code.
## Basic Example
```swift
// Old way with completion handlers
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
} else if let data = data {
completion(.success(data))
}
}.resume()
}
// New way with async/await
func fetchData() async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
```
## Key Benefits
1. **Readability**: No callback pyramids
2. **Error handling**: Standard try/catch
3. **Sequential logic**: Reads top-to-bottom
4. **Cancellation**: Built-in with Task
## Usage
```swift
Task {
do {
let data = try await fetchData()
await MainActor.run {
// Update UI
}
} catch {
print("Error: \\(error)")
}
}
```
Much cleaner than completion handlers!
""",
tokens: 189,
cost: 0.00038
)
static let mockSystem = Message(
role: .system,
content: "Conversation cleared. Starting fresh.",
tokens: nil,
cost: nil
)
static let mockMessages = [mockUser1, mockAssistant1, mockUser2, mockAssistant2]
}
extension ModelInfo {
static let mockModels = [
ModelInfo(
id: "anthropic/claude-sonnet-4",
name: "Claude Sonnet 4",
description: "Balanced intelligence and speed for most tasks",
contextLength: 200_000,
pricing: Pricing(prompt: 3.0, completion: 15.0),
capabilities: ModelCapabilities(vision: true, tools: true, online: true)
),
ModelInfo(
id: "anthropic/claude-opus-4",
name: "Claude Opus 4",
description: "Most capable model for complex tasks",
contextLength: 200_000,
pricing: Pricing(prompt: 15.0, completion: 75.0),
capabilities: ModelCapabilities(vision: true, tools: true, online: true)
),
ModelInfo(
id: "anthropic/claude-haiku-4",
name: "Claude Haiku 4",
description: "Fast and efficient for simple tasks",
contextLength: 200_000,
pricing: Pricing(prompt: 0.8, completion: 4.0),
capabilities: ModelCapabilities(vision: true, tools: true, online: true)
),
ModelInfo(
id: "openai/gpt-4o",
name: "GPT-4o",
description: "OpenAI's flagship multimodal model",
contextLength: 128_000,
pricing: Pricing(prompt: 2.5, completion: 10.0),
capabilities: ModelCapabilities(vision: true, tools: true, online: false)
),
ModelInfo(
id: "openai/gpt-4o-mini",
name: "GPT-4o Mini",
description: "Faster and cheaper GPT-4o variant",
contextLength: 128_000,
pricing: Pricing(prompt: 0.15, completion: 0.6),
capabilities: ModelCapabilities(vision: true, tools: true, online: false)
),
ModelInfo(
id: "openai/o1",
name: "o1",
description: "Advanced reasoning model for complex problems",
contextLength: 200_000,
pricing: Pricing(prompt: 15.0, completion: 60.0),
capabilities: ModelCapabilities(vision: false, tools: false, online: false)
),
ModelInfo(
id: "google/gemini-pro-1.5",
name: "Gemini Pro 1.5",
description: "Google's advanced multimodal model",
contextLength: 2_000_000,
pricing: Pricing(prompt: 1.25, completion: 5.0),
capabilities: ModelCapabilities(vision: true, tools: true, online: false)
),
ModelInfo(
id: "meta-llama/llama-3.1-405b",
name: "Llama 3.1 405B",
description: "Meta's largest open source model",
contextLength: 128_000,
pricing: Pricing(prompt: 2.7, completion: 2.7),
capabilities: ModelCapabilities(vision: false, tools: true, online: false)
)
]
}
extension Conversation {
static let mockConversation1 = Conversation(
name: "SwiftUI Discussion",
messages: [Message.mockUser1, Message.mockAssistant1],
createdAt: Date().addingTimeInterval(-86400), // 1 day ago
updatedAt: Date().addingTimeInterval(-3600) // 1 hour ago
)
static let mockConversation2 = Conversation(
name: "Async/Await Tutorial",
messages: [Message.mockUser2, Message.mockAssistant2],
createdAt: Date().addingTimeInterval(-172800), // 2 days ago
updatedAt: Date().addingTimeInterval(-7200) // 2 hours ago
)
static let mockConversations = [mockConversation1, mockConversation2]
}

View File

@@ -0,0 +1,56 @@
//
// ModelInfo.swift
// oAI
//
// Model information and capabilities
//
import Foundation
struct ModelInfo: Identifiable, Codable, Hashable {
let id: String
let name: String
let description: String?
let contextLength: Int
let pricing: Pricing
let capabilities: ModelCapabilities
var architecture: Architecture? = nil
var topProvider: String? = nil
struct Pricing: Codable, Hashable {
let prompt: Double // per 1M tokens
let completion: Double
}
struct ModelCapabilities: Codable, Hashable {
let vision: Bool // Images/PDFs
let tools: Bool // Function calling
let online: Bool // Web search
var imageGeneration: Bool = false // Image output
}
struct Architecture: Codable, Hashable {
let tokenizer: String?
let instructType: String?
let modality: String?
}
// Computed properties
var contextLengthDisplay: String {
if contextLength >= 1_000_000 {
return "\(contextLength / 1_000_000)M"
} else if contextLength >= 1000 {
return "\(contextLength / 1000)K"
} else {
return "\(contextLength)"
}
}
var promptPriceDisplay: String {
String(format: "$%.2f", pricing.prompt)
}
var completionPriceDisplay: String {
String(format: "$%.2f", pricing.completion)
}
}

View File

@@ -0,0 +1,58 @@
//
// SessionStats.swift
// oAI
//
// Session statistics tracking
//
import Foundation
struct SessionStats {
var totalInputTokens: Int = 0
var totalOutputTokens: Int = 0
var totalCost: Double = 0.0
var messageCount: Int = 0
var totalTokens: Int {
totalInputTokens + totalOutputTokens
}
var totalTokensDisplay: String {
if totalTokens >= 1_000_000 {
return String(format: "%.1fM", Double(totalTokens) / 1_000_000)
} else if totalTokens >= 1000 {
return String(format: "%.1fK", Double(totalTokens) / 1000)
} else {
return "\(totalTokens)"
}
}
var totalCostDisplay: String {
String(format: "$%.4f", totalCost)
}
var averageCostPerMessage: Double {
guard messageCount > 0 else { return 0.0 }
return totalCost / Double(messageCount)
}
mutating func addMessage(inputTokens: Int?, outputTokens: Int?, cost: Double?) {
if let input = inputTokens {
totalInputTokens += input
}
if let output = outputTokens {
totalOutputTokens += output
}
if let messageCost = cost {
totalCost += messageCost
}
messageCount += 1
}
mutating func reset() {
totalInputTokens = 0
totalOutputTokens = 0
totalCost = 0.0
messageCount = 0
}
}

90
oAI/Models/Settings.swift Normal file
View File

@@ -0,0 +1,90 @@
//
// Settings.swift
// oAI
//
// Application settings and configuration
//
import Foundation
struct Settings: Codable {
// Provider settings
var defaultProvider: Provider
var openrouterAPIKey: String?
var anthropicAPIKey: String?
var openaiAPIKey: String?
var ollamaBaseURL: String
// Model settings
var defaultModel: String?
var streamEnabled: Bool
var maxTokens: Int
var systemPrompt: String?
// Feature flags
var onlineMode: Bool
var memoryEnabled: Bool
var mcpEnabled: Bool
// Web search
var searchProvider: SearchProvider
var googleAPIKey: String?
var googleSearchEngineID: String?
// UI
var costWarningThreshold: Double
enum Provider: String, Codable, CaseIterable {
case openrouter
case anthropic
case openai
case ollama
var displayName: String {
rawValue.capitalized
}
var iconName: String {
switch self {
case .openrouter: return "network"
case .anthropic: return "brain"
case .openai: return "sparkles"
case .ollama: return "server.rack"
}
}
}
enum SearchProvider: String, Codable, CaseIterable {
case anthropicNative = "anthropic_native"
case duckduckgo
case google
var displayName: String {
switch self {
case .anthropicNative: return "Anthropic Native"
case .duckduckgo: return "DuckDuckGo"
case .google: return "Google"
}
}
}
// Default settings
static let `default` = Settings(
defaultProvider: .openrouter,
openrouterAPIKey: nil,
anthropicAPIKey: nil,
openaiAPIKey: nil,
ollamaBaseURL: "http://localhost:11434",
defaultModel: nil,
streamEnabled: true,
maxTokens: 4096,
systemPrompt: nil,
onlineMode: false,
memoryEnabled: true,
mcpEnabled: false,
searchProvider: .duckduckgo,
googleAPIKey: nil,
googleSearchEngineID: nil,
costWarningThreshold: 1.0
)
}

View File

@@ -0,0 +1,241 @@
//
// AIProvider.swift
// oAI
//
// Protocol for AI provider implementations
//
import Foundation
// MARK: - Provider Protocol
protocol AIProvider {
var name: String { get }
var capabilities: ProviderCapabilities { get }
func listModels() async throws -> [ModelInfo]
func getModel(_ id: String) async throws -> ModelInfo?
func chat(request: ChatRequest) async throws -> ChatResponse
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error>
func getCredits() async throws -> Credits?
/// Chat completion with pre-encoded messages for the MCP tool call loop.
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse
}
// MARK: - Provider Capabilities
struct ProviderCapabilities: Codable {
let supportsStreaming: Bool
let supportsVision: Bool
let supportsTools: Bool
let supportsOnlineSearch: Bool
let maxContextLength: Int?
static let `default` = ProviderCapabilities(
supportsStreaming: true,
supportsVision: false,
supportsTools: false,
supportsOnlineSearch: false,
maxContextLength: nil
)
}
// MARK: - Chat Request
struct ChatRequest {
let messages: [Message]
let model: String
let stream: Bool
let maxTokens: Int?
let temperature: Double?
let topP: Double?
let systemPrompt: String?
let tools: [Tool]?
let onlineMode: Bool
let imageGeneration: Bool
init(
messages: [Message],
model: String,
stream: Bool = true,
maxTokens: Int? = nil,
temperature: Double? = nil,
topP: Double? = nil,
systemPrompt: String? = nil,
tools: [Tool]? = nil,
onlineMode: Bool = false,
imageGeneration: Bool = false
) {
self.messages = messages
self.model = model
self.stream = stream
self.maxTokens = maxTokens
self.temperature = temperature
self.topP = topP
self.systemPrompt = systemPrompt
self.tools = tools
self.onlineMode = onlineMode
self.imageGeneration = imageGeneration
}
}
// MARK: - Chat Response
struct ToolCallInfo {
let id: String
let type: String
let functionName: String
let arguments: String
}
struct ChatResponse: Codable {
let id: String
let model: String
let content: String
let role: String
let finishReason: String?
let usage: Usage?
let created: Date
let toolCalls: [ToolCallInfo]?
let generatedImages: [Data]?
struct Usage: Codable {
let promptTokens: Int
let completionTokens: Int
let totalTokens: Int
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
}
}
// Custom Codable since ToolCallInfo/generatedImages are not from API directly
enum CodingKeys: String, CodingKey {
case id, model, content, role, finishReason, usage, created
}
init(id: String, model: String, content: String, role: String, finishReason: String?, usage: Usage?, created: Date, toolCalls: [ToolCallInfo]? = nil, generatedImages: [Data]? = nil) {
self.id = id
self.model = model
self.content = content
self.role = role
self.finishReason = finishReason
self.usage = usage
self.created = created
self.toolCalls = toolCalls
self.generatedImages = generatedImages
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
model = try container.decode(String.self, forKey: .model)
content = try container.decode(String.self, forKey: .content)
role = try container.decode(String.self, forKey: .role)
finishReason = try container.decodeIfPresent(String.self, forKey: .finishReason)
usage = try container.decodeIfPresent(Usage.self, forKey: .usage)
created = try container.decode(Date.self, forKey: .created)
toolCalls = nil
generatedImages = nil
}
}
// MARK: - Stream Chunk
struct StreamChunk {
let id: String
let model: String
let delta: Delta
let finishReason: String?
let usage: ChatResponse.Usage?
struct Delta {
let content: String?
let role: String?
let images: [Data]?
}
var deltaContent: String? {
delta.content
}
}
// MARK: - Tool Definition
struct Tool: Codable {
let type: String
let function: Function
struct Function: Codable {
let name: String
let description: String
let parameters: Parameters
struct Parameters: Codable {
let type: String
let properties: [String: Property]
let required: [String]?
struct Property: Codable {
let type: String
let description: String
let `enum`: [String]?
}
}
}
}
// MARK: - Credits
struct Credits: Codable {
let balance: Double
let currency: String
let usage: Double?
let limit: Double?
var balanceDisplay: String {
String(format: "$%.2f", balance)
}
var usageDisplay: String? {
guard let usage = usage else { return nil }
return String(format: "$%.2f", usage)
}
}
// MARK: - Provider Errors
enum ProviderError: LocalizedError {
case invalidAPIKey
case networkError(Error)
case invalidResponse
case rateLimitExceeded
case modelNotFound(String)
case insufficientCredits
case timeout
case unknown(String)
var errorDescription: String? {
switch self {
case .invalidAPIKey:
return "Invalid API key. Please check your settings."
case .networkError(let error):
return "Network error: \(error.localizedDescription)"
case .invalidResponse:
return "Received invalid response from API"
case .rateLimitExceeded:
return "Rate limit exceeded. Please try again later."
case .modelNotFound(let model):
return "Model '\(model)' not found"
case .insufficientCredits:
return "Insufficient credits"
case .timeout:
return "Request timed out"
case .unknown(let message):
return "Unknown error: \(message)"
}
}
}

View File

@@ -0,0 +1,534 @@
//
// AnthropicProvider.swift
// oAI
//
// Anthropic Messages API provider with SSE streaming and tool support
//
import Foundation
import os
class AnthropicProvider: AIProvider {
let name = "Anthropic"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: true,
supportsTools: true,
supportsOnlineSearch: false,
maxContextLength: nil
)
enum AuthMode {
case apiKey(String)
case oauth
}
private let authMode: AuthMode
private let baseURL = "https://api.anthropic.com/v1"
private let apiVersion = "2023-06-01"
private let session: URLSession
/// Create with a standard API key
init(apiKey: String) {
self.authMode = .apiKey(apiKey)
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
}
/// Create with OAuth (Pro/Max subscription)
init(oauth: Bool) {
self.authMode = .oauth
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
}
/// Whether this provider is using OAuth authentication
var isOAuth: Bool {
if case .oauth = authMode { return true }
return false
}
// MARK: - Models (hardcoded Anthropic has no public models list endpoint)
private static let knownModels: [ModelInfo] = [
ModelInfo(
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
description: "Most capable and intelligent model",
contextLength: 200_000,
pricing: .init(prompt: 15.0, completion: 75.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5",
description: "Previous generation Opus",
contextLength: 200_000,
pricing: .init(prompt: 15.0, completion: 75.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
description: "Best balance of speed and capability",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-haiku-4-5-20251001",
name: "Claude Haiku 4.5",
description: "Fastest and most affordable",
contextLength: 200_000,
pricing: .init(prompt: 0.80, completion: 4.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-3-7-sonnet-20250219",
name: "Claude 3.7 Sonnet",
description: "Previous generation Sonnet",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-3-haiku-20240307",
name: "Claude 3 Haiku",
description: "Previous generation Haiku",
contextLength: 200_000,
pricing: .init(prompt: 0.25, completion: 1.25),
capabilities: .init(vision: true, tools: true, online: true)
),
]
func listModels() async throws -> [ModelInfo] {
return Self.knownModels
}
func getModel(_ id: String) async throws -> ModelInfo? {
return Self.knownModels.first { $0.id == id }
}
// MARK: - Chat Completion
func chat(request: ChatRequest) async throws -> ChatResponse {
Log.api.info("Anthropic chat request: model=\(request.model), messages=\(request.messages.count)")
var (urlRequest, _) = try buildURLRequest(from: request, stream: false)
try await applyAuth(to: &urlRequest)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Anthropic chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorObj["error"] as? [String: Any],
let message = error["message"] as? String {
Log.api.error("Anthropic chat HTTP \(httpResponse.statusCode): \(message)")
throw ProviderError.unknown(message)
}
Log.api.error("Anthropic chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
return try parseResponse(data: data)
}
// MARK: - Chat with raw tool messages
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
Log.api.info("Anthropic tool chat: model=\(model), messages=\(messages.count)")
let url = messagesURL
// Separate system message from conversation messages
var systemText: String? = nil
var conversationMessages: [[String: Any]] = []
for msg in messages {
let role = msg["role"] as? String ?? ""
if role == "system" {
systemText = msg["content"] as? String
} else if role == "tool" {
// Convert OpenAI tool result format to Anthropic tool_result format
let toolCallId = msg["tool_call_id"] as? String ?? ""
let content = msg["content"] as? String ?? ""
conversationMessages.append([
"role": "user",
"content": [
["type": "tool_result", "tool_use_id": toolCallId, "content": content]
]
])
} else if role == "assistant" {
// Check for tool_calls convert to Anthropic content blocks
if let toolCalls = msg["tool_calls"] as? [[String: Any]], !toolCalls.isEmpty {
var contentBlocks: [[String: Any]] = []
if let text = msg["content"] as? String, !text.isEmpty {
contentBlocks.append(["type": "text", "text": text])
}
for tc in toolCalls {
let fn = tc["function"] as? [String: Any] ?? [:]
let name = fn["name"] as? String ?? ""
let argsStr = fn["arguments"] as? String ?? "{}"
let argsObj = (try? JSONSerialization.jsonObject(with: Data(argsStr.utf8))) ?? [:]
contentBlocks.append([
"type": "tool_use",
"id": tc["id"] as? String ?? UUID().uuidString,
"name": name,
"input": argsObj
])
}
conversationMessages.append(["role": "assistant", "content": contentBlocks])
} else {
conversationMessages.append(["role": "assistant", "content": msg["content"] as? String ?? ""])
}
} else {
conversationMessages.append(["role": role, "content": msg["content"] as? String ?? ""])
}
}
var body: [String: Any] = [
"model": model,
"messages": conversationMessages,
"max_tokens": maxTokens ?? 4096,
"stream": false
]
if let systemText = systemText {
body["system"] = systemText
}
if let temperature = temperature {
body["temperature"] = temperature
}
if let tools = tools {
body["tools"] = tools.map { tool -> [String: Any] in
[
"name": tool.function.name,
"description": tool.function.description,
"input_schema": convertParametersToDict(tool.function.parameters)
]
}
body["tool_choice"] = ["type": "auto"]
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
try await applyAuth(to: &urlRequest)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Anthropic tool chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorObj["error"] as? [String: Any],
let message = error["message"] as? String {
Log.api.error("Anthropic tool chat HTTP \(httpResponse.statusCode): \(message)")
throw ProviderError.unknown(message)
}
Log.api.error("Anthropic tool chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
return try parseResponse(data: data)
}
// MARK: - Streaming Chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
Log.api.info("Anthropic stream request: model=\(request.model), messages=\(request.messages.count)")
return AsyncThrowingStream { continuation in
Task {
do {
var (urlRequest, _) = try buildURLRequest(from: request, stream: true)
try await self.applyAuth(to: &urlRequest)
let (bytes, response) = try await session.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Anthropic stream: invalid response (not HTTP)")
continuation.finish(throwing: ProviderError.invalidResponse)
return
}
guard httpResponse.statusCode == 200 else {
Log.api.error("Anthropic stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
return
}
var currentId = ""
var currentModel = request.model
for try await line in bytes.lines {
// Anthropic SSE: "event: ..." and "data: {...}"
guard line.hasPrefix("data: ") else { continue }
let jsonString = String(line.dropFirst(6))
guard let jsonData = jsonString.data(using: .utf8),
let event = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let eventType = event["type"] as? String else {
continue
}
switch eventType {
case "message_start":
if let message = event["message"] as? [String: Any] {
currentId = message["id"] as? String ?? ""
currentModel = message["model"] as? String ?? request.model
}
case "content_block_delta":
if let delta = event["delta"] as? [String: Any],
let deltaType = delta["type"] as? String,
deltaType == "text_delta",
let text = delta["text"] as? String {
continuation.yield(StreamChunk(
id: currentId,
model: currentModel,
delta: .init(content: text, role: nil, images: nil),
finishReason: nil,
usage: nil
))
}
case "message_delta":
let delta = event["delta"] as? [String: Any]
let stopReason = delta?["stop_reason"] as? String
var usage: ChatResponse.Usage? = nil
if let usageDict = event["usage"] as? [String: Any] {
let outputTokens = usageDict["output_tokens"] as? Int ?? 0
usage = ChatResponse.Usage(promptTokens: 0, completionTokens: outputTokens, totalTokens: outputTokens)
}
continuation.yield(StreamChunk(
id: currentId,
model: currentModel,
delta: .init(content: nil, role: nil, images: nil),
finishReason: stopReason,
usage: usage
))
case "message_stop":
continuation.finish()
return
default:
break
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Credits
func getCredits() async throws -> Credits? {
// Anthropic doesn't have a public credits API
return nil
}
// MARK: - Auth Helpers
/// Apply auth headers based on mode (API key or OAuth Bearer)
private func applyAuth(to request: inout URLRequest) async throws {
switch authMode {
case .apiKey(let key):
request.addValue(key, forHTTPHeaderField: "x-api-key")
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
case .oauth:
let token = try await AnthropicOAuthService.shared.getValidAccessToken()
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
request.addValue("oauth-2025-04-20,interleaved-thinking-2025-05-14", forHTTPHeaderField: "anthropic-beta")
}
}
/// Build the messages endpoint URL, appending ?beta=true for OAuth
private var messagesURL: URL {
switch authMode {
case .apiKey:
return URL(string: "\(baseURL)/messages")!
case .oauth:
return URL(string: "\(baseURL)/messages?beta=true")!
}
}
// MARK: - Request Building
private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> (URLRequest, Data) {
let url = messagesURL
// Separate system message
var systemText: String? = request.systemPrompt
var apiMessages: [[String: Any]] = []
for msg in request.messages {
if msg.role == .system {
systemText = msg.content
continue
}
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
if hasAttachments, let attachments = msg.attachments {
var contentBlocks: [[String: Any]] = []
contentBlocks.append(["type": "text", "text": msg.content])
for attachment in attachments {
guard let data = attachment.data else { continue }
switch attachment.type {
case .image, .pdf:
let base64 = data.base64EncodedString()
contentBlocks.append([
"type": "image",
"source": [
"type": "base64",
"media_type": attachment.mimeType,
"data": base64
]
])
case .text:
let filename = (attachment.path as NSString).lastPathComponent
let textContent = String(data: data, encoding: .utf8) ?? ""
contentBlocks.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"])
}
}
apiMessages.append(["role": msg.role.rawValue, "content": contentBlocks])
} else {
apiMessages.append(["role": msg.role.rawValue, "content": msg.content])
}
}
var body: [String: Any] = [
"model": request.model,
"messages": apiMessages,
"max_tokens": request.maxTokens ?? 4096,
"stream": stream
]
if let systemText = systemText {
body["system"] = systemText
}
if let temperature = request.temperature {
body["temperature"] = temperature
}
var toolsArray: [[String: Any]] = []
if let tools = request.tools {
toolsArray += tools.map { tool -> [String: Any] in
[
"name": tool.function.name,
"description": tool.function.description,
"input_schema": convertParametersToDict(tool.function.parameters)
]
}
}
if request.onlineMode {
toolsArray.append([
"type": "web_search_20250305",
"name": "web_search",
"max_uses": 5
])
}
if !toolsArray.isEmpty {
body["tools"] = toolsArray
if request.tools != nil {
body["tool_choice"] = ["type": "auto"]
}
}
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
if stream {
urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept")
}
urlRequest.httpBody = bodyData
// Auth is applied async in the caller (chat/streamChat)
return (urlRequest, bodyData)
}
private func parseResponse(data: Data) throws -> ChatResponse {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw ProviderError.invalidResponse
}
let id = json["id"] as? String ?? ""
let model = json["model"] as? String ?? ""
let contentBlocks = json["content"] as? [[String: Any]] ?? []
var textContent = ""
var toolCalls: [ToolCallInfo] = []
for block in contentBlocks {
let blockType = block["type"] as? String ?? ""
switch blockType {
case "text":
textContent += block["text"] as? String ?? ""
case "tool_use":
let tcId = block["id"] as? String ?? UUID().uuidString
let tcName = block["name"] as? String ?? ""
let tcInput = block["input"] ?? [:]
let argsData = try JSONSerialization.data(withJSONObject: tcInput)
let argsStr = String(data: argsData, encoding: .utf8) ?? "{}"
toolCalls.append(ToolCallInfo(id: tcId, type: "function", functionName: tcName, arguments: argsStr))
default:
break
}
}
let usageDict = json["usage"] as? [String: Any]
let inputTokens = usageDict?["input_tokens"] as? Int ?? 0
let outputTokens = usageDict?["output_tokens"] as? Int ?? 0
return ChatResponse(
id: id,
model: model,
content: textContent,
role: "assistant",
finishReason: json["stop_reason"] as? String,
usage: ChatResponse.Usage(
promptTokens: inputTokens,
completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens
),
created: Date(),
toolCalls: toolCalls.isEmpty ? nil : toolCalls
)
}
private func convertParametersToDict(_ params: Tool.Function.Parameters) -> [String: Any] {
var props: [String: Any] = [:]
for (key, prop) in params.properties {
var propDict: [String: Any] = [
"type": prop.type,
"description": prop.description
]
if let enumVals = prop.enum {
propDict["enum"] = enumVals
}
props[key] = propDict
}
var dict: [String: Any] = [
"type": params.type,
"properties": props
]
if let required = params.required {
dict["required"] = required
}
return dict
}
}

View File

@@ -0,0 +1,308 @@
//
// OllamaProvider.swift
// oAI
//
// Ollama local AI provider with JSON-lines streaming
//
import Foundation
import os
class OllamaProvider: AIProvider {
let name = "Ollama"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: false,
supportsTools: false,
supportsOnlineSearch: false,
maxContextLength: nil
)
private let baseURL: String
private let session: URLSession
init(baseURL: String = "http://localhost:11434") {
self.baseURL = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 120
config.timeoutIntervalForResource = 600
self.session = URLSession(configuration: config)
}
// MARK: - Models
func listModels() async throws -> [ModelInfo] {
Log.api.info("Fetching model list from Ollama at \(self.baseURL)")
let url = URL(string: "\(baseURL)/api/tags")!
var request = URLRequest(url: url)
request.timeoutInterval = 5
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
Log.api.warning("Cannot connect to Ollama at \(self.baseURL). Is Ollama running?")
throw ProviderError.unknown("Cannot connect to Ollama at \(baseURL). Is Ollama running? Start it with: ollama serve")
}
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw ProviderError.unknown("Ollama returned an error. Is it running?")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let modelsArray = json["models"] as? [[String: Any]] else {
return []
}
return modelsArray.compactMap { model -> ModelInfo? in
guard let name = model["name"] as? String else { return nil }
let sizeBytes = model["size"] as? Int64 ?? 0
let sizeGB = String(format: "%.1f GB", Double(sizeBytes) / 1_073_741_824)
return ModelInfo(
id: name,
name: name,
description: "Local model (\(sizeGB))",
contextLength: 0,
pricing: .init(prompt: 0, completion: 0),
capabilities: .init(vision: false, tools: false, online: false)
)
}
}
func getModel(_ id: String) async throws -> ModelInfo? {
let models = try await listModels()
return models.first { $0.id == id }
}
// MARK: - Chat Completion
func chat(request: ChatRequest) async throws -> ChatResponse {
Log.api.info("Ollama chat request: model=\(request.model), messages=\(request.messages.count)")
let url = URL(string: "\(baseURL)/api/chat")!
let body = buildRequestBody(from: request, stream: false)
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = bodyData
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Ollama chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorMsg = errorObj["error"] as? String {
Log.api.error("Ollama chat HTTP \(httpResponse.statusCode): \(errorMsg)")
throw ProviderError.unknown(errorMsg)
}
Log.api.error("Ollama chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("Ollama HTTP \(httpResponse.statusCode)")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw ProviderError.invalidResponse
}
return parseOllamaResponse(json, model: request.model)
}
// MARK: - Chat with raw tool messages
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
// Ollama doesn't support tool calls natively just send messages as plain chat
let url = URL(string: "\(baseURL)/api/chat")!
// Convert messages, stripping tool-specific fields
var ollamaMessages: [[String: Any]] = []
for msg in messages {
let role = msg["role"] as? String ?? "user"
let content = msg["content"] as? String ?? ""
if role == "tool" {
// Convert tool results to assistant context
let toolName = msg["name"] as? String ?? "tool"
ollamaMessages.append(["role": "user", "content": "[\(toolName) result]: \(content)"])
} else if role == "assistant" {
// Strip tool_calls, just keep content
if let tc = msg["tool_calls"] as? [[String: Any]], !tc.isEmpty {
let toolNames = tc.compactMap { ($0["function"] as? [String: Any])?["name"] as? String }
let text = (msg["content"] as? String) ?? ""
let combined = text.isEmpty ? "Calling: \(toolNames.joined(separator: ", "))" : text
ollamaMessages.append(["role": "assistant", "content": combined])
} else {
ollamaMessages.append(["role": "assistant", "content": content])
}
} else {
ollamaMessages.append(["role": role, "content": content])
}
}
var body: [String: Any] = [
"model": model,
"messages": ollamaMessages,
"stream": false
]
var options: [String: Any] = [:]
if let maxTokens = maxTokens { options["num_predict"] = maxTokens }
if let temperature = temperature { options["temperature"] = temperature }
if !options.isEmpty { body["options"] = options }
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = bodyData
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw ProviderError.unknown("Ollama error")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw ProviderError.invalidResponse
}
return parseOllamaResponse(json, model: model)
}
// MARK: - Streaming Chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
Log.api.info("Ollama stream request: model=\(request.model), messages=\(request.messages.count)")
return AsyncThrowingStream { continuation in
Task {
do {
let url = URL(string: "\(baseURL)/api/chat")!
let body = buildRequestBody(from: request, stream: true)
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = bodyData
let (bytes, response) = try await session.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Ollama stream: invalid response (not HTTP)")
continuation.finish(throwing: ProviderError.invalidResponse)
return
}
guard httpResponse.statusCode == 200 else {
Log.api.error("Ollama stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("Ollama HTTP \(httpResponse.statusCode)"))
return
}
// Ollama streams JSON lines (one complete JSON object per line)
for try await line in bytes.lines {
guard !line.isEmpty,
let lineData = line.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else {
continue
}
let done = json["done"] as? Bool ?? false
let message = json["message"] as? [String: Any]
let content = message?["content"] as? String
if done {
// Final chunk has usage stats
let promptTokens = json["prompt_eval_count"] as? Int ?? 0
let completionTokens = json["eval_count"] as? Int ?? 0
continuation.yield(StreamChunk(
id: "",
model: request.model,
delta: .init(content: content, role: nil, images: nil),
finishReason: "stop",
usage: ChatResponse.Usage(
promptTokens: promptTokens,
completionTokens: completionTokens,
totalTokens: promptTokens + completionTokens
)
))
continuation.finish()
return
} else if let content = content {
continuation.yield(StreamChunk(
id: "",
model: request.model,
delta: .init(content: content, role: nil, images: nil),
finishReason: nil,
usage: nil
))
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Credits
func getCredits() async throws -> Credits? {
// Local models no credits needed
return nil
}
// MARK: - Helpers
private func buildRequestBody(from request: ChatRequest, stream: Bool) -> [String: Any] {
var messages: [[String: Any]] = []
// Add system prompt as a system message
if let systemPrompt = request.systemPrompt {
messages.append(["role": "system", "content": systemPrompt])
}
for msg in request.messages {
messages.append(["role": msg.role.rawValue, "content": msg.content])
}
var body: [String: Any] = [
"model": request.model,
"messages": messages,
"stream": stream
]
var options: [String: Any] = [:]
if let maxTokens = request.maxTokens { options["num_predict"] = maxTokens }
if let temperature = request.temperature { options["temperature"] = temperature }
if !options.isEmpty { body["options"] = options }
return body
}
private func parseOllamaResponse(_ json: [String: Any], model: String) -> ChatResponse {
let message = json["message"] as? [String: Any]
let content = message?["content"] as? String ?? ""
let promptTokens = json["prompt_eval_count"] as? Int ?? 0
let completionTokens = json["eval_count"] as? Int ?? 0
return ChatResponse(
id: UUID().uuidString,
model: model,
content: content,
role: "assistant",
finishReason: "stop",
usage: ChatResponse.Usage(
promptTokens: promptTokens,
completionTokens: completionTokens,
totalTokens: promptTokens + completionTokens
),
created: Date()
)
}
}

View File

@@ -0,0 +1,367 @@
//
// OpenAIProvider.swift
// oAI
//
// OpenAI API provider with SSE streaming and tool support
//
import Foundation
import os
class OpenAIProvider: AIProvider {
let name = "OpenAI"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: true,
supportsTools: true,
supportsOnlineSearch: false,
maxContextLength: nil
)
private let apiKey: String
private let baseURL = "https://api.openai.com/v1"
private let session: URLSession
init(apiKey: String) {
self.apiKey = apiKey
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
}
// MARK: - Models
/// Known models with pricing, used as fallback and for enrichment
private static let knownModels: [String: (name: String, desc: String?, ctx: Int, prompt: Double, completion: Double, vision: Bool)] = [
"gpt-4o": ("GPT-4o", "Most capable GPT-4 model", 128_000, 2.50, 10.0, true),
"gpt-4o-mini": ("GPT-4o Mini", "Affordable and fast", 128_000, 0.15, 0.60, true),
"gpt-4-turbo": ("GPT-4 Turbo", "GPT-4 Turbo with vision", 128_000, 10.0, 30.0, true),
"gpt-3.5-turbo": ("GPT-3.5 Turbo", "Fast and affordable", 16_385, 0.50, 1.50, false),
"o1": ("o1", "Advanced reasoning model", 200_000, 15.0, 60.0, true),
"o1-mini": ("o1 Mini", "Fast reasoning model", 128_000, 3.0, 12.0, false),
"o3-mini": ("o3 Mini", "Latest fast reasoning model", 200_000, 1.10, 4.40, false),
]
func listModels() async throws -> [ModelInfo] {
Log.api.info("Fetching model list from OpenAI")
let url = URL(string: "\(baseURL)/models")!
var request = URLRequest(url: url)
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
do {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
Log.api.warning("OpenAI models endpoint failed, using fallback models")
return fallbackModels()
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let modelsArray = json["data"] as? [[String: Any]] else {
return fallbackModels()
}
// Filter to chat models
let chatModelIds = modelsArray
.compactMap { $0["id"] as? String }
.filter { id in
id.contains("gpt") || id.hasPrefix("o1") || id.hasPrefix("o3")
}
.sorted()
var models: [ModelInfo] = []
for id in chatModelIds {
if let known = Self.knownModels[id] {
models.append(ModelInfo(
id: id,
name: known.name,
description: known.desc,
contextLength: known.ctx,
pricing: .init(prompt: known.prompt, completion: known.completion),
capabilities: .init(vision: known.vision, tools: true, online: false)
))
} else {
models.append(ModelInfo(
id: id,
name: id,
description: nil,
contextLength: 128_000,
pricing: .init(prompt: 0, completion: 0),
capabilities: .init(vision: false, tools: true, online: false)
))
}
}
Log.api.info("OpenAI loaded \(models.count) models")
return models.isEmpty ? fallbackModels() : models
} catch {
Log.api.warning("OpenAI models fetch failed: \(error.localizedDescription), using fallback")
return fallbackModels()
}
}
private func fallbackModels() -> [ModelInfo] {
Self.knownModels.map { id, info in
ModelInfo(
id: id,
name: info.name,
description: info.desc,
contextLength: info.ctx,
pricing: .init(prompt: info.prompt, completion: info.completion),
capabilities: .init(vision: info.vision, tools: true, online: false)
)
}.sorted { $0.name < $1.name }
}
func getModel(_ id: String) async throws -> ModelInfo? {
let models = try await listModels()
return models.first { $0.id == id }
}
// MARK: - Chat Completion
func chat(request: ChatRequest) async throws -> ChatResponse {
Log.api.info("OpenAI chat request: model=\(request.model), messages=\(request.messages.count)")
let urlRequest = try buildURLRequest(from: request, stream: false)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenAI chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorObj["error"] as? [String: Any],
let message = error["message"] as? String {
Log.api.error("OpenAI chat HTTP \(httpResponse.statusCode): \(message)")
throw ProviderError.unknown(message)
}
Log.api.error("OpenAI chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
// Reuse the OpenRouter response format OpenAI is identical
let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
return convertToChatResponse(apiResponse)
}
// MARK: - Chat with raw tool messages
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
Log.api.info("OpenAI tool chat: model=\(model), messages=\(messages.count)")
let url = URL(string: "\(baseURL)/chat/completions")!
var body: [String: Any] = [
"model": model,
"messages": messages,
"stream": false
]
if let tools = tools {
let toolsData = try JSONEncoder().encode(tools)
body["tools"] = try JSONSerialization.jsonObject(with: toolsData)
body["tool_choice"] = "auto"
}
if let maxTokens = maxTokens { body["max_tokens"] = maxTokens }
// o1/o3 models don't support temperature
if let temperature = temperature, !model.hasPrefix("o1"), !model.hasPrefix("o3") {
body["temperature"] = temperature
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenAI tool chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorObj["error"] as? [String: Any],
let message = error["message"] as? String {
Log.api.error("OpenAI tool chat HTTP \(httpResponse.statusCode): \(message)")
throw ProviderError.unknown(message)
}
Log.api.error("OpenAI tool chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
return convertToChatResponse(apiResponse)
}
// MARK: - Streaming Chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
Log.api.info("OpenAI stream request: model=\(request.model), messages=\(request.messages.count)")
return AsyncThrowingStream { continuation in
Task {
do {
let urlRequest = try buildURLRequest(from: request, stream: true)
let (bytes, response) = try await session.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenAI stream: invalid response (not HTTP)")
continuation.finish(throwing: ProviderError.invalidResponse)
return
}
guard httpResponse.statusCode == 200 else {
Log.api.error("OpenAI stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
return
}
var buffer = ""
for try await line in bytes.lines {
guard line.hasPrefix("data: ") else { continue }
let jsonString = String(line.dropFirst(6))
if jsonString == "[DONE]" {
continuation.finish()
return
}
buffer += jsonString
if let jsonData = buffer.data(using: .utf8) {
do {
let chunk = try JSONDecoder().decode(OpenRouterStreamChunk.self, from: jsonData)
guard let choice = chunk.choices.first else { continue }
continuation.yield(StreamChunk(
id: chunk.id,
model: chunk.model,
delta: .init(content: choice.delta.content, role: choice.delta.role, images: nil),
finishReason: choice.finishReason,
usage: chunk.usage.map {
ChatResponse.Usage(promptTokens: $0.promptTokens, completionTokens: $0.completionTokens, totalTokens: $0.totalTokens)
}
))
buffer = ""
} catch {
continue // Partial JSON, keep buffering
}
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Credits
func getCredits() async throws -> Credits? {
// OpenAI doesn't have a public credits API
return nil
}
// MARK: - Helpers
private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> URLRequest {
let url = URL(string: "\(baseURL)/chat/completions")!
var apiMessages: [[String: Any]] = []
// Add system prompt if present
if let systemPrompt = request.systemPrompt {
apiMessages.append(["role": "system", "content": systemPrompt])
}
for msg in request.messages {
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
if hasAttachments, let attachments = msg.attachments {
// Multi-part content (OpenAI vision format)
var contentArray: [[String: Any]] = [
["type": "text", "text": msg.content]
]
for attachment in attachments {
guard let data = attachment.data else { continue }
switch attachment.type {
case .image, .pdf:
let base64 = data.base64EncodedString()
let dataURL = "data:\(attachment.mimeType);base64,\(base64)"
contentArray.append([
"type": "image_url",
"image_url": ["url": dataURL]
])
case .text:
let filename = (attachment.path as NSString).lastPathComponent
let textContent = String(data: data, encoding: .utf8) ?? ""
contentArray.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"])
}
}
apiMessages.append(["role": msg.role.rawValue, "content": contentArray])
} else {
apiMessages.append(["role": msg.role.rawValue, "content": msg.content])
}
}
var body: [String: Any] = [
"model": request.model,
"messages": apiMessages,
"stream": stream
]
if let maxTokens = request.maxTokens { body["max_tokens"] = maxTokens }
// o1/o3 reasoning models don't support temperature
if let temperature = request.temperature, !request.model.hasPrefix("o1"), !request.model.hasPrefix("o3") {
body["temperature"] = temperature
}
if let tools = request.tools {
let toolsData = try JSONEncoder().encode(tools)
body["tools"] = try JSONSerialization.jsonObject(with: toolsData)
body["tool_choice"] = "auto"
}
if stream {
// Request usage in streaming mode
body["stream_options"] = ["include_usage": true]
}
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
if stream {
urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept")
}
urlRequest.httpBody = bodyData
return urlRequest
}
private func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) -> ChatResponse {
guard let choice = apiResponse.choices.first else {
return ChatResponse(id: apiResponse.id, model: apiResponse.model, content: "", role: "assistant", finishReason: nil, usage: nil, created: Date())
}
let toolCalls: [ToolCallInfo]? = choice.message.toolCalls?.map { tc in
ToolCallInfo(id: tc.id, type: tc.type, functionName: tc.function.name, arguments: tc.function.arguments)
}
return ChatResponse(
id: apiResponse.id,
model: apiResponse.model,
content: choice.message.content ?? "",
role: choice.message.role,
finishReason: choice.finishReason,
usage: apiResponse.usage.map {
ChatResponse.Usage(promptTokens: $0.promptTokens, completionTokens: $0.completionTokens, totalTokens: $0.totalTokens)
},
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
toolCalls: toolCalls
)
}
}

View File

@@ -0,0 +1,313 @@
//
// OpenRouterModels.swift
// oAI
//
// OpenRouter API request and response models
//
import Foundation
// MARK: - API Request
struct OpenRouterChatRequest: Codable {
let model: String
let messages: [APIMessage]
var stream: Bool
let maxTokens: Int?
let temperature: Double?
let topP: Double?
let tools: [Tool]?
let toolChoice: String?
let modalities: [String]?
struct APIMessage: Codable {
let role: String
let content: MessageContent
enum MessageContent: Codable {
case string(String)
case array([ContentItem])
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let str = try? container.decode(String.self) {
self = .string(str)
} else if let arr = try? container.decode([ContentItem].self) {
self = .array(arr)
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid content")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let str):
try container.encode(str)
case .array(let arr):
try container.encode(arr)
}
}
}
enum ContentItem: Codable {
case text(String)
case image(ImageContent)
struct TextContent: Codable {
let type: String // "text"
let text: String
}
struct ImageContent: Codable {
let type: String // "image_url"
let imageUrl: ImageURL
struct ImageURL: Codable {
let url: String
}
enum CodingKeys: String, CodingKey {
case type
case imageUrl = "image_url"
}
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let textContent = try? container.decode(TextContent.self), textContent.type == "text" {
self = .text(textContent.text)
} else if let image = try? container.decode(ImageContent.self) {
self = .image(image)
} else if let str = try? container.decode(String.self) {
self = .text(str)
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid content item")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .text(let text):
try container.encode(TextContent(type: "text", text: text))
case .image(let image):
try container.encode(image)
}
}
}
}
enum CodingKeys: String, CodingKey {
case model
case messages
case stream
case maxTokens = "max_tokens"
case temperature
case topP = "top_p"
case tools
case toolChoice = "tool_choice"
case modalities
}
}
// MARK: - API Response
struct OpenRouterChatResponse: Codable {
let id: String
let model: String
let choices: [Choice]
let usage: Usage?
let created: Int
struct Choice: Codable {
let index: Int
let message: MessageContent
let finishReason: String?
struct MessageContent: Codable {
let role: String
let content: String?
let toolCalls: [APIToolCall]?
let images: [ImageOutput]?
enum CodingKeys: String, CodingKey {
case role
case content
case toolCalls = "tool_calls"
case images
}
}
enum CodingKeys: String, CodingKey {
case index
case message
case finishReason = "finish_reason"
}
}
struct ImageOutput: Codable {
let imageUrl: ImageURL
struct ImageURL: Codable {
let url: String
}
enum CodingKeys: String, CodingKey {
case imageUrl = "image_url"
}
}
struct Usage: Codable {
let promptTokens: Int
let completionTokens: Int
let totalTokens: Int
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
}
}
}
// MARK: - Streaming Response
struct OpenRouterStreamChunk: Codable {
let id: String
let model: String
let choices: [StreamChoice]
let usage: OpenRouterChatResponse.Usage?
struct StreamChoice: Codable {
let index: Int
let delta: Delta
let finishReason: String?
struct Delta: Codable {
let role: String?
let content: String?
let images: [OpenRouterChatResponse.ImageOutput]?
}
enum CodingKeys: String, CodingKey {
case index
case delta
case finishReason = "finish_reason"
}
}
}
// MARK: - Models List
struct OpenRouterModelsResponse: Codable {
let data: [ModelData]
struct ModelData: Codable {
let id: String
let name: String
let description: String?
let contextLength: Int
let pricing: PricingData
let architecture: Architecture?
let supportedParameters: [String]?
let outputModalities: [String]?
struct PricingData: Codable {
let prompt: String
let completion: String
}
struct Architecture: Codable {
let modality: String?
let tokenizer: String?
let instructType: String?
enum CodingKeys: String, CodingKey {
case modality
case tokenizer
case instructType = "instruct_type"
}
}
enum CodingKeys: String, CodingKey {
case id
case name
case description
case contextLength = "context_length"
case pricing
case architecture
case supportedParameters = "supported_parameters"
case outputModalities = "output_modalities"
}
}
}
// MARK: - Credits Response
struct OpenRouterCreditsResponse: Codable {
let data: CreditsData
struct CreditsData: Codable {
let totalCredits: Double?
let totalUsage: Double?
enum CodingKeys: String, CodingKey {
case totalCredits = "total_credits"
case totalUsage = "total_usage"
}
}
}
// MARK: - Tool Call Models
struct APIToolCall: Codable {
let id: String
let type: String
let function: FunctionCall
struct FunctionCall: Codable {
let name: String
let arguments: String
}
}
/// Message shape for encoding assistant messages that contain tool calls
struct AssistantToolCallMessage: Encodable {
let role: String
let content: String?
let toolCalls: [APIToolCall]
enum CodingKeys: String, CodingKey {
case role
case content
case toolCalls = "tool_calls"
}
}
/// Message shape for encoding tool result messages back to the API
struct ToolResultMessage: Encodable {
let role: String // "tool"
let toolCallId: String
let name: String
let content: String
enum CodingKeys: String, CodingKey {
case role
case toolCallId = "tool_call_id"
case name
case content
}
}
// MARK: - Error Response
struct OpenRouterErrorResponse: Codable {
let error: ErrorDetail
struct ErrorDetail: Codable {
let message: String
let type: String?
let code: String?
}
}

View File

@@ -0,0 +1,433 @@
//
// OpenRouterProvider.swift
// oAI
//
// OpenRouter AI provider implementation with SSE streaming
//
import Foundation
import os
class OpenRouterProvider: AIProvider {
let name = "OpenRouter"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: true,
supportsTools: true,
supportsOnlineSearch: true,
maxContextLength: nil
)
private let apiKey: String
private let baseURL = "https://openrouter.ai/api/v1"
private let session: URLSession
init(apiKey: String) {
self.apiKey = apiKey
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
}
// MARK: - List Models
func listModels() async throws -> [ModelInfo] {
Log.api.info("Fetching model list from OpenRouter")
let url = URL(string: "\(baseURL)/models")!
var request = URLRequest(url: url)
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenRouter models: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
throw ProviderError.unknown(errorResponse.error.message)
}
Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: data)
Log.api.info("OpenRouter loaded \(modelsResponse.data.count) models")
return modelsResponse.data.map { modelData in
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
return ModelInfo(
id: modelData.id,
name: modelData.name,
description: modelData.description,
contextLength: modelData.contextLength,
pricing: ModelInfo.Pricing(
prompt: promptPrice * 1_000_000, // Convert to per 1M tokens
completion: completionPrice * 1_000_000
),
capabilities: ModelInfo.ModelCapabilities(
vision: {
let mod = modelData.architecture?.modality ?? ""
return mod == "multimodal" || mod.hasPrefix("text+image")
}(),
tools: modelData.supportedParameters?.contains("tools") ?? false,
online: {
// OpenRouter supports :online suffix for all text models
let mod = modelData.architecture?.modality ?? ""
if let arrow = mod.range(of: "->") {
return !mod[arrow.upperBound...].contains("image")
}
return true
}(),
imageGeneration: {
if let mod = modelData.architecture?.modality,
let arrow = mod.range(of: "->") {
let output = mod[arrow.upperBound...]
return output.contains("image")
}
return false
}()
),
architecture: modelData.architecture.map { arch in
ModelInfo.Architecture(
tokenizer: arch.tokenizer,
instructType: arch.instructType,
modality: arch.modality
)
},
topProvider: modelData.id.components(separatedBy: "/").first
)
}
}
func getModel(_ id: String) async throws -> ModelInfo? {
let models = try await listModels()
return models.first { $0.id == id }
}
// MARK: - Chat Completion
func chat(request: ChatRequest) async throws -> ChatResponse {
Log.api.info("OpenRouter chat request: model=\(request.model), messages=\(request.messages.count)")
let apiRequest = try buildAPIRequest(from: request)
let url = URL(string: "\(baseURL)/chat/completions")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
urlRequest.httpBody = try JSONEncoder().encode(apiRequest)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
Log.api.error("OpenRouter chat HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
throw ProviderError.unknown(errorResponse.error.message)
}
Log.api.error("OpenRouter chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
return try convertToChatResponse(apiResponse)
}
// MARK: - Chat with raw tool messages
/// Chat completion that accepts pre-encoded messages (for the tool call loop where
/// message shapes vary: user, assistant+tool_calls, tool results).
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
let url = URL(string: "\(baseURL)/chat/completions")!
var body: [String: Any] = [
"model": model,
"messages": messages,
"stream": false
]
if let tools = tools {
let toolsData = try JSONEncoder().encode(tools)
body["tools"] = try JSONSerialization.jsonObject(with: toolsData)
body["tool_choice"] = "auto"
}
if let maxTokens = maxTokens { body["max_tokens"] = maxTokens }
if let temperature = temperature { body["temperature"] = temperature }
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
Log.api.error("OpenRouter tool chat HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
throw ProviderError.unknown(errorResponse.error.message)
}
Log.api.error("OpenRouter tool chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
return try convertToChatResponse(apiResponse)
}
// MARK: - Streaming Chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
Log.api.info("OpenRouter stream request: model=\(request.model), messages=\(request.messages.count)")
return AsyncThrowingStream { continuation in
Task {
do {
var apiRequest = try buildAPIRequest(from: request)
apiRequest.stream = true
let url = URL(string: "\(baseURL)/chat/completions")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept")
urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
urlRequest.httpBody = try JSONEncoder().encode(apiRequest)
let (bytes, response) = try await session.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenRouter stream: invalid response (not HTTP)")
continuation.finish(throwing: ProviderError.invalidResponse)
return
}
guard httpResponse.statusCode == 200 else {
Log.api.error("OpenRouter stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
return
}
var buffer = ""
for try await line in bytes.lines {
if line.hasPrefix("data: ") {
let jsonString = String(line.dropFirst(6))
if jsonString == "[DONE]" {
continuation.finish()
return
}
buffer += jsonString
if let jsonData = buffer.data(using: .utf8) {
do {
let chunk = try JSONDecoder().decode(OpenRouterStreamChunk.self, from: jsonData)
let streamChunk = try convertToStreamChunk(chunk)
continuation.yield(streamChunk)
buffer = ""
} catch {
// Partial JSON, keep buffering
continue
}
}
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Credits
func getCredits() async throws -> Credits? {
Log.api.info("Fetching OpenRouter credits")
let url = URL(string: "\(baseURL)/credits")!
var request = URLRequest(url: url)
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
return nil
}
let creditsResponse = try JSONDecoder().decode(OpenRouterCreditsResponse.self, from: data)
let totalCredits = creditsResponse.data.totalCredits ?? 0
let totalUsage = creditsResponse.data.totalUsage ?? 0
let remaining = totalCredits - totalUsage
return Credits(
balance: remaining,
currency: "USD",
usage: totalUsage,
limit: totalCredits
)
}
// MARK: - Helper Methods
private func buildAPIRequest(from request: ChatRequest) throws -> OpenRouterChatRequest {
let apiMessages = request.messages.map { message -> OpenRouterChatRequest.APIMessage in
let hasAttachments = message.attachments?.contains(where: { $0.data != nil }) ?? false
let content: OpenRouterChatRequest.APIMessage.MessageContent
if hasAttachments {
// Use array format for messages with attachments
var contentArray: [OpenRouterChatRequest.APIMessage.ContentItem] = []
// Add main text content
contentArray.append(.text(message.content))
// Add attachments
if let attachments = message.attachments {
for attachment in attachments {
guard let data = attachment.data else { continue }
switch attachment.type {
case .image, .pdf:
// Send as base64 data URL with correct MIME type
let base64String = data.base64EncodedString()
let dataURL = "data:\(attachment.mimeType);base64,\(base64String)"
let imageContent = OpenRouterChatRequest.APIMessage.ContentItem.ImageContent(
type: "image_url",
imageUrl: .init(url: dataURL)
)
contentArray.append(.image(imageContent))
case .text:
// Inline text file content
let filename = (attachment.path as NSString).lastPathComponent
let textContent = String(data: data, encoding: .utf8) ?? ""
contentArray.append(.text("File: \(filename)\n\n\(textContent)"))
}
}
}
content = .array(contentArray)
} else {
// Use simple string format for text-only messages
content = .string(message.content)
}
return OpenRouterChatRequest.APIMessage(
role: message.role.rawValue,
content: content
)
}
// Append :online suffix for web search when online mode is enabled
let effectiveModel: String
if request.onlineMode && !request.imageGeneration && !request.model.hasSuffix(":online") {
effectiveModel = request.model + ":online"
} else {
effectiveModel = request.model
}
return OpenRouterChatRequest(
model: effectiveModel,
messages: apiMessages,
stream: request.stream,
maxTokens: request.maxTokens,
temperature: request.temperature,
topP: request.topP,
tools: request.tools,
toolChoice: request.tools != nil ? "auto" : nil,
modalities: request.imageGeneration ? ["text", "image"] : nil
)
}
private func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) throws -> ChatResponse {
guard let choice = apiResponse.choices.first else {
throw ProviderError.invalidResponse
}
let toolCalls: [ToolCallInfo]? = choice.message.toolCalls?.map { tc in
ToolCallInfo(id: tc.id, type: tc.type, functionName: tc.function.name, arguments: tc.function.arguments)
}
let images = choice.message.images.flatMap { decodeImageOutputs($0) }
return ChatResponse(
id: apiResponse.id,
model: apiResponse.model,
content: choice.message.content ?? "",
role: choice.message.role,
finishReason: choice.finishReason,
usage: apiResponse.usage.map { usage in
ChatResponse.Usage(
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens
)
},
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
toolCalls: toolCalls,
generatedImages: images
)
}
private func convertToStreamChunk(_ apiChunk: OpenRouterStreamChunk) throws -> StreamChunk {
guard let choice = apiChunk.choices.first else {
throw ProviderError.invalidResponse
}
let images = choice.delta.images.flatMap { decodeImageOutputs($0) }
return StreamChunk(
id: apiChunk.id,
model: apiChunk.model,
delta: StreamChunk.Delta(
content: choice.delta.content,
role: choice.delta.role,
images: images
),
finishReason: choice.finishReason,
usage: apiChunk.usage.map { usage in
ChatResponse.Usage(
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens
)
}
)
}
/// Decode base64 data URL images from API response
private func decodeImageOutputs(_ outputs: [OpenRouterChatResponse.ImageOutput]) -> [Data]? {
let decoded = outputs.compactMap { output -> Data? in
let url = output.imageUrl.url
// Strip "data:image/...;base64," prefix
guard let commaIndex = url.firstIndex(of: ",") else { return nil }
let base64String = String(url[url.index(after: commaIndex)...])
return Data(base64Encoded: base64String)
}
return decoded.isEmpty ? nil : decoded
}
}

View File

@@ -0,0 +1,102 @@
//
// ProviderRegistry.swift
// oAI
//
// Registry for managing multiple AI providers
//
import Foundation
import os
class ProviderRegistry {
static let shared = ProviderRegistry()
private var providers: [Settings.Provider: AIProvider] = [:]
private let settings = SettingsService.shared
private init() {}
// MARK: - Get Provider
func getProvider(for providerType: Settings.Provider) -> AIProvider? {
// Return cached provider if exists
if let provider = providers[providerType] {
return provider
}
// Create new provider based on type
let provider: AIProvider?
switch providerType {
case .openrouter:
guard let apiKey = settings.openrouterAPIKey, !apiKey.isEmpty else {
Log.api.warning("No API key configured for OpenRouter")
return nil
}
provider = OpenRouterProvider(apiKey: apiKey)
case .anthropic:
if AnthropicOAuthService.shared.isAuthenticated {
// OAuth (Pro/Max subscription) takes precedence
provider = AnthropicProvider(oauth: true)
} else if let apiKey = settings.anthropicAPIKey, !apiKey.isEmpty {
provider = AnthropicProvider(apiKey: apiKey)
} else {
Log.api.warning("No API key or OAuth configured for Anthropic")
return nil
}
case .openai:
guard let apiKey = settings.openaiAPIKey, !apiKey.isEmpty else {
Log.api.warning("No API key configured for OpenAI")
return nil
}
provider = OpenAIProvider(apiKey: apiKey)
case .ollama:
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
}
// Cache and return
if let provider = provider {
Log.api.info("Created \(providerType.rawValue) provider")
providers[providerType] = provider
}
return provider
}
// MARK: - Current Provider
func getCurrentProvider() -> AIProvider? {
let currentProviderType = settings.defaultProvider
return getProvider(for: currentProviderType)
}
// MARK: - Clear Cache
func clearCache() {
providers.removeAll()
}
// MARK: - Validate API Key
func hasValidAPIKey(for providerType: Settings.Provider) -> Bool {
switch providerType {
case .openrouter:
return settings.openrouterAPIKey != nil && !settings.openrouterAPIKey!.isEmpty
case .anthropic:
return AnthropicOAuthService.shared.isAuthenticated
|| (settings.anthropicAPIKey != nil && !settings.anthropicAPIKey!.isEmpty)
case .openai:
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
case .ollama:
return settings.ollamaConfigured
}
}
/// Providers that have credentials configured (API key or, for Ollama, a saved URL)
var configuredProviders: [Settings.Provider] {
Settings.Provider.allCases.filter { hasValidAPIKey(for: $0) }
}
}

View File

@@ -0,0 +1,298 @@
//
// AnthropicOAuthService.swift
// oAI
//
// OAuth 2.0 PKCE flow for Anthropic Pro/Max subscription login
//
import Foundation
import CryptoKit
import Security
@Observable
class AnthropicOAuthService {
static let shared = AnthropicOAuthService()
// OAuth configuration (matches Claude Code CLI)
private let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
private let redirectURI = "https://console.anthropic.com/oauth/code/callback"
private let scope = "org:create_api_key user:profile user:inference"
private let tokenEndpoint = "https://console.anthropic.com/v1/oauth/token"
// Keychain keys
private enum Keys {
static let accessToken = "com.oai.anthropic.oauth.accessToken"
static let refreshToken = "com.oai.anthropic.oauth.refreshToken"
static let expiresAt = "com.oai.anthropic.oauth.expiresAt"
}
// PKCE state for current flow
private var currentVerifier: String?
// Observable state
var isAuthenticated: Bool { accessToken != nil }
var isLoggingIn = false
// MARK: - Token Access
var accessToken: String? {
getKeychainValue(for: Keys.accessToken)
}
private var refreshToken: String? {
getKeychainValue(for: Keys.refreshToken)
}
private var expiresAt: Date? {
guard let str = getKeychainValue(for: Keys.expiresAt),
let interval = Double(str) else { return nil }
return Date(timeIntervalSince1970: interval)
}
var isTokenExpired: Bool {
guard let expires = expiresAt else { return true }
return Date() >= expires
}
// MARK: - Step 1: Generate Authorization URL
func generateAuthorizationURL() -> URL {
let verifier = generateCodeVerifier()
currentVerifier = verifier
let challenge = generateCodeChallenge(from: verifier)
var components = URLComponents(string: "https://claude.ai/oauth/authorize")!
components.queryItems = [
URLQueryItem(name: "code", value: "true"),
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "redirect_uri", value: redirectURI),
URLQueryItem(name: "scope", value: scope),
URLQueryItem(name: "code_challenge", value: challenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
URLQueryItem(name: "state", value: verifier),
]
return components.url!
}
// MARK: - Step 2: Exchange Code for Tokens
func exchangeCode(_ pastedCode: String) async throws {
guard let verifier = currentVerifier else {
throw OAuthError.noVerifier
}
// Code format: "auth_code#state"
let parts = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "#")
let authCode: String
let state: String
if parts.count >= 2 {
authCode = parts[0]
state = parts.dropFirst().joined(separator: "#")
} else {
// If no # separator, treat entire string as the code
authCode = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines)
state = verifier
}
Log.api.info("Exchanging OAuth code for tokens")
let body: [String: String] = [
"code": authCode,
"state": state,
"grant_type": "authorization_code",
"client_id": clientId,
"redirect_uri": redirectURI,
"code_verifier": verifier,
]
let tokenResponse = try await postTokenRequest(body)
saveTokens(tokenResponse)
currentVerifier = nil
Log.api.info("OAuth login successful, token expires in \(tokenResponse.expiresIn)s")
}
// MARK: - Token Refresh
func refreshAccessToken() async throws {
guard let refresh = refreshToken else {
throw OAuthError.noRefreshToken
}
Log.api.info("Refreshing OAuth access token")
let body: [String: String] = [
"grant_type": "refresh_token",
"refresh_token": refresh,
"client_id": clientId,
]
let tokenResponse = try await postTokenRequest(body)
saveTokens(tokenResponse)
Log.api.info("OAuth token refreshed successfully")
}
/// Returns a valid access token, refreshing if needed
func getValidAccessToken() async throws -> String {
guard let token = accessToken else {
throw OAuthError.notAuthenticated
}
if isTokenExpired {
try await refreshAccessToken()
guard let newToken = accessToken else {
throw OAuthError.notAuthenticated
}
return newToken
}
return token
}
// MARK: - Logout
func logout() {
deleteKeychainValue(for: Keys.accessToken)
deleteKeychainValue(for: Keys.refreshToken)
deleteKeychainValue(for: Keys.expiresAt)
currentVerifier = nil
Log.api.info("OAuth logout complete")
}
// MARK: - PKCE Helpers
private func generateCodeVerifier() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
return Data(bytes).base64URLEncoded()
}
private func generateCodeChallenge(from verifier: String) -> String {
let data = Data(verifier.utf8)
let hash = SHA256.hash(data: data)
return Data(hash).base64URLEncoded()
}
// MARK: - Token Request
private func postTokenRequest(_ body: [String: String]) async throws -> TokenResponse {
var request = URLRequest(url: URL(string: tokenEndpoint)!)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw OAuthError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
Log.api.error("OAuth token exchange failed HTTP \(httpResponse.statusCode): \(errorBody)")
throw OAuthError.tokenExchangeFailed(httpResponse.statusCode, errorBody)
}
return try JSONDecoder().decode(TokenResponse.self, from: data)
}
// MARK: - Token Storage
private func saveTokens(_ response: TokenResponse) {
setKeychainValue(response.accessToken, for: Keys.accessToken)
if let refresh = response.refreshToken {
setKeychainValue(refresh, for: Keys.refreshToken)
}
let expiresAt = Date().addingTimeInterval(TimeInterval(response.expiresIn))
setKeychainValue(String(expiresAt.timeIntervalSince1970), for: Keys.expiresAt)
}
// MARK: - Keychain Helpers
private func getKeychainValue(for key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var ref: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &ref) == errSecSuccess,
let data = ref as? Data,
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value
}
private func setKeychainValue(_ value: String, for key: String) {
guard let data = value.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
let attrs: [String: Any] = [kSecValueData as String: data]
let status = SecItemUpdate(query as CFDictionary, attrs as CFDictionary)
if status == errSecItemNotFound {
var newItem = query
newItem[kSecValueData as String] = data
SecItemAdd(newItem as CFDictionary, nil)
}
}
private func deleteKeychainValue(for key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
// MARK: - Types
struct TokenResponse: Decodable {
let accessToken: String
let refreshToken: String?
let expiresIn: Int
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case expiresIn = "expires_in"
}
}
enum OAuthError: LocalizedError {
case noVerifier
case noRefreshToken
case notAuthenticated
case invalidResponse
case tokenExchangeFailed(Int, String)
var errorDescription: String? {
switch self {
case .noVerifier: return "No PKCE verifier — start the login flow first."
case .noRefreshToken: return "No refresh token available. Please log in again."
case .notAuthenticated: return "Not authenticated. Please log in."
case .invalidResponse: return "Invalid response from Anthropic OAuth server."
case .tokenExchangeFailed(let code, let body):
return "Token exchange failed (HTTP \(code)): \(body)"
}
}
}
}
// MARK: - Base64URL Encoding
private extension Data {
func base64URLEncoded() -> String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}

View File

@@ -0,0 +1,318 @@
//
// DatabaseService.swift
// oAI
//
// SQLite persistence layer for conversations using GRDB
//
import Foundation
import GRDB
import os
// MARK: - Database Record Types
struct ConversationRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "conversations"
var id: String
var name: String
var createdAt: String
var updatedAt: String
}
struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "messages"
var id: String
var conversationId: String
var role: String
var content: String
var tokens: Int?
var cost: Double?
var timestamp: String
var sortOrder: Int
}
struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "settings"
var key: String
var value: String
}
// MARK: - DatabaseService
final class DatabaseService: Sendable {
nonisolated static let shared = DatabaseService()
private let dbQueue: DatabaseQueue
private let isoFormatter: ISO8601DateFormatter
nonisolated private init() {
isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let fileManager = FileManager.default
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
try! fileManager.createDirectory(at: dbDirectory, withIntermediateDirectories: true)
let dbPath = dbDirectory.appendingPathComponent("oai_conversations.db").path
Log.db.info("Opening database at \(dbPath)")
dbQueue = try! DatabaseQueue(path: dbPath)
try! migrator.migrate(dbQueue)
}
private var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator()
migrator.registerMigration("v1") { db in
try db.create(table: "conversations") { t in
t.primaryKey("id", .text)
t.column("name", .text).notNull()
t.column("createdAt", .text).notNull()
t.column("updatedAt", .text).notNull()
}
try db.create(table: "messages") { t in
t.primaryKey("id", .text)
t.column("conversationId", .text).notNull()
.references("conversations", onDelete: .cascade)
t.column("role", .text).notNull()
t.column("content", .text).notNull()
t.column("tokens", .integer)
t.column("cost", .double)
t.column("timestamp", .text).notNull()
t.column("sortOrder", .integer).notNull()
}
try db.create(
index: "messages_on_conversationId",
on: "messages",
columns: ["conversationId"]
)
}
migrator.registerMigration("v2") { db in
try db.create(table: "settings") { t in
t.primaryKey("key", .text)
t.column("value", .text).notNull()
}
}
return migrator
}
// MARK: - Settings Operations
nonisolated func loadAllSettings() throws -> [String: String] {
try dbQueue.read { db in
let records = try SettingRecord.fetchAll(db)
return Dictionary(uniqueKeysWithValues: records.map { ($0.key, $0.value) })
}
}
nonisolated func setSetting(key: String, value: String) {
try? dbQueue.write { db in
let record = SettingRecord(key: key, value: value)
try record.save(db)
}
}
nonisolated func deleteSetting(key: String) {
try? dbQueue.write { db in
_ = try SettingRecord.deleteOne(db, key: key)
}
}
// MARK: - Conversation Operations
nonisolated func saveConversation(name: String, messages: [Message]) throws -> Conversation {
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages")
let convId = UUID()
let now = Date()
let nowString = isoFormatter.string(from: now)
let convRecord = ConversationRecord(
id: convId.uuidString,
name: name,
createdAt: nowString,
updatedAt: nowString
)
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
guard msg.role != .system else { return nil }
return MessageRecord(
id: UUID().uuidString,
conversationId: convId.uuidString,
role: msg.role.rawValue,
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
timestamp: isoFormatter.string(from: msg.timestamp),
sortOrder: index
)
}
try dbQueue.write { db in
try convRecord.insert(db)
for record in messageRecords {
try record.insert(db)
}
}
let savedMessages = messages.filter { $0.role != .system }
return Conversation(
id: convId,
name: name,
messages: savedMessages,
createdAt: now,
updatedAt: now
)
}
nonisolated func loadConversation(id: UUID) throws -> (Conversation, [Message])? {
try dbQueue.read { db in
guard let convRecord = try ConversationRecord.fetchOne(db, key: id.uuidString) else {
return nil
}
let messageRecords = try MessageRecord
.filter(Column("conversationId") == id.uuidString)
.order(Column("sortOrder"))
.fetchAll(db)
let messages = messageRecords.compactMap { record -> Message? in
guard let msgId = UUID(uuidString: record.id),
let role = MessageRole(rawValue: record.role),
let timestamp = self.isoFormatter.date(from: record.timestamp)
else { return nil }
return Message(
id: msgId,
role: role,
content: record.content,
tokens: record.tokens,
cost: record.cost,
timestamp: timestamp
)
}
guard let convId = UUID(uuidString: convRecord.id),
let createdAt = self.isoFormatter.date(from: convRecord.createdAt),
let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt)
else { return nil }
let conversation = Conversation(
id: convId,
name: convRecord.name,
messages: messages,
createdAt: createdAt,
updatedAt: updatedAt
)
return (conversation, messages)
}
}
nonisolated func listConversations() throws -> [Conversation] {
try dbQueue.read { db in
let records = try ConversationRecord
.order(Column("updatedAt").desc)
.fetchAll(db)
return records.compactMap { record -> Conversation? in
guard let id = UUID(uuidString: record.id),
let createdAt = self.isoFormatter.date(from: record.createdAt),
let updatedAt = self.isoFormatter.date(from: record.updatedAt)
else { return nil }
// Fetch message count without loading all messages
let messageCount = (try? MessageRecord
.filter(Column("conversationId") == record.id)
.fetchCount(db)) ?? 0
// Get last message date
let lastMsg = try? MessageRecord
.filter(Column("conversationId") == record.id)
.order(Column("sortOrder").desc)
.fetchOne(db)
let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt
// Create conversation with empty messages array but correct metadata
var conv = Conversation(
id: id,
name: record.name,
messages: Array(repeating: Message(role: .user, content: ""), count: messageCount),
createdAt: createdAt,
updatedAt: lastDate
)
// We store placeholder messages just for the count; lastMessageDate uses updatedAt
conv.updatedAt = lastDate
return conv
}
}
}
nonisolated func deleteConversation(id: UUID) throws -> Bool {
Log.db.info("Deleting conversation \(id.uuidString)")
return try dbQueue.write { db in
try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db)
return try ConversationRecord.deleteOne(db, key: id.uuidString)
}
}
nonisolated func deleteConversation(name: String) throws -> Bool {
try dbQueue.write { db in
guard let record = try ConversationRecord
.filter(Column("name") == name)
.fetchOne(db)
else { return false }
try MessageRecord.filter(Column("conversationId") == record.id).deleteAll(db)
return try ConversationRecord.deleteOne(db, key: record.id)
}
}
nonisolated func updateConversation(id: UUID, name: String?, messages: [Message]?) throws -> Bool {
try dbQueue.write { db in
guard var convRecord = try ConversationRecord.fetchOne(db, key: id.uuidString) else {
return false
}
if let name = name {
convRecord.name = name
}
convRecord.updatedAt = self.isoFormatter.string(from: Date())
try convRecord.update(db)
if let messages = messages {
try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db)
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
guard msg.role != .system else { return nil }
return MessageRecord(
id: UUID().uuidString,
conversationId: id.uuidString,
role: msg.role.rawValue,
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
timestamp: self.isoFormatter.string(from: msg.timestamp),
sortOrder: index
)
}
for record in messageRecords {
try record.insert(db)
}
}
return true
}
}
}

View File

@@ -0,0 +1,840 @@
//
// MCPService.swift
// oAI
//
// MCP (Model Context Protocol) service for filesystem tool execution
//
import Foundation
import os
@Observable
class MCPService {
static let shared = MCPService()
private(set) var allowedFolders: [String] = []
private let settings = SettingsService.shared
private let fm = FileManager.default
private let maxFileSize = 10 * 1024 * 1024 // 10 MB
private let maxTextDisplay = 50 * 1024 // 50 KB before truncation
private let maxDirItems = 1000
private let maxSearchResults = 100
private let skipPatterns: Set<String> = [
".git", "node_modules", ".DS_Store", "__pycache__",
".build", ".swiftpm", "Pods", ".Trash", ".Spotlight-V100"
]
/// Cached gitignore rules per allowed folder
private var gitignoreRules: [String: GitignoreParser] = [:]
private init() {
allowedFolders = settings.mcpAllowedFolders
if settings.mcpRespectGitignore {
loadGitignores()
}
}
// MARK: - Folder Management
func addFolder(_ rawPath: String) -> String? {
let expanded = (rawPath as NSString).expandingTildeInPath
let resolved = (expanded as NSString).standardizingPath
var isDir: ObjCBool = false
guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else {
return "Path is not a directory: \(rawPath)"
}
if allowedFolders.contains(resolved) {
return "Folder already added: \(resolved)"
}
allowedFolders.append(resolved)
settings.mcpAllowedFolders = allowedFolders
if settings.mcpRespectGitignore {
loadGitignoreForFolder(resolved)
}
return nil
}
func removeFolder(at index: Int) -> Bool {
guard index >= 0 && index < allowedFolders.count else { return false }
let removed = allowedFolders.remove(at: index)
settings.mcpAllowedFolders = allowedFolders
gitignoreRules.removeValue(forKey: removed)
return true
}
func removeFolder(path: String) -> Bool {
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
if let index = allowedFolders.firstIndex(of: resolved) {
allowedFolders.remove(at: index)
settings.mcpAllowedFolders = allowedFolders
gitignoreRules.removeValue(forKey: resolved)
return true
}
return false
}
func isPathAllowed(_ path: String) -> Bool {
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
return allowedFolders.contains { resolved.hasPrefix($0) }
}
// MARK: - Permission Helpers
var canWriteFiles: Bool { settings.mcpCanWriteFiles }
var canDeleteFiles: Bool { settings.mcpCanDeleteFiles }
var canCreateDirectories: Bool { settings.mcpCanCreateDirectories }
var canMoveFiles: Bool { settings.mcpCanMoveFiles }
var respectGitignore: Bool { settings.mcpRespectGitignore }
// MARK: - Tool Schema Generation
func getToolSchemas() -> [Tool] {
var tools: [Tool] = [
makeTool(
name: "read_file",
description: "Read the contents of a file. Returns the text content of the file. Maximum file size is 10MB.",
properties: [
"file_path": prop("string", "The absolute path to the file to read")
],
required: ["file_path"]
),
makeTool(
name: "list_directory",
description: "List the contents of a directory. Returns file and directory names. Skips hidden/build directories like .git, node_modules, etc.",
properties: [
"dir_path": prop("string", "The absolute path to the directory to list"),
"recursive": prop("boolean", "Whether to list recursively (default: false)")
],
required: ["dir_path"]
),
makeTool(
name: "search_files",
description: "Search for files by name pattern or content. Use 'pattern' for filename glob matching (e.g. '*.swift'). Use 'content_search' for searching inside file contents.",
properties: [
"pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"),
"search_path": prop("string", "Directory to search in (defaults to first allowed folder)"),
"content_search": prop("string", "Optional text to search for inside files")
],
required: ["pattern"]
)
]
if canWriteFiles {
tools.append(makeTool(
name: "write_file",
description: "Create or overwrite a file with the given content. Parent directories are created automatically.",
properties: [
"file_path": prop("string", "The absolute path to the file to write"),
"content": prop("string", "The text content to write to the file")
],
required: ["file_path", "content"]
))
tools.append(makeTool(
name: "edit_file",
description: "Find and replace text in a file. The old_text must appear exactly once in the file.",
properties: [
"file_path": prop("string", "The absolute path to the file to edit"),
"old_text": prop("string", "The exact text to find (must be a unique match)"),
"new_text": prop("string", "The replacement text")
],
required: ["file_path", "old_text", "new_text"]
))
}
if canDeleteFiles {
tools.append(makeTool(
name: "delete_file",
description: "Delete a file at the given path.",
properties: [
"file_path": prop("string", "The absolute path to the file to delete")
],
required: ["file_path"]
))
}
if canCreateDirectories {
tools.append(makeTool(
name: "create_directory",
description: "Create a directory (and any intermediate directories) at the given path.",
properties: [
"dir_path": prop("string", "The absolute path to the directory to create")
],
required: ["dir_path"]
))
}
if canMoveFiles {
tools.append(makeTool(
name: "move_file",
description: "Move or rename a file or directory.",
properties: [
"source": prop("string", "The absolute path of the file/directory to move"),
"destination": prop("string", "The absolute destination path")
],
required: ["source", "destination"]
))
tools.append(makeTool(
name: "copy_file",
description: "Copy a file or directory to a new location.",
properties: [
"source": prop("string", "The absolute path of the file/directory to copy"),
"destination": prop("string", "The absolute destination path for the copy")
],
required: ["source", "destination"]
))
}
return tools
}
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
Tool(
type: "function",
function: Tool.Function(
name: name,
description: description,
parameters: Tool.Function.Parameters(
type: "object",
properties: properties,
required: required
)
)
)
}
private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
}
// MARK: - Tool Execution
func executeTool(name: String, arguments: String) -> [String: Any] {
Log.mcp.info("Executing tool: \(name)")
guard let argData = arguments.data(using: .utf8),
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
Log.mcp.error("Invalid arguments JSON for tool \(name)")
return ["error": "Invalid arguments JSON"]
}
switch name {
case "read_file":
guard let filePath = args["file_path"] as? String else {
return ["error": "Missing required parameter: file_path"]
}
return readFile(filePath: filePath)
case "list_directory":
guard let dirPath = args["dir_path"] as? String else {
return ["error": "Missing required parameter: dir_path"]
}
let recursive = args["recursive"] as? Bool ?? false
return listDirectory(dirPath: dirPath, recursive: recursive)
case "search_files":
guard let pattern = args["pattern"] as? String else {
return ["error": "Missing required parameter: pattern"]
}
let searchPath = args["search_path"] as? String
let contentSearch = args["content_search"] as? String
return searchFiles(pattern: pattern, searchPath: searchPath, contentSearch: contentSearch)
case "write_file":
guard canWriteFiles else {
return ["error": "Permission denied: write_file is not enabled. Enable 'Write & Edit Files' in Settings > MCP."]
}
guard let filePath = args["file_path"] as? String,
let content = args["content"] as? String else {
return ["error": "Missing required parameters: file_path, content"]
}
return writeFile(filePath: filePath, content: content)
case "edit_file":
guard canWriteFiles else {
return ["error": "Permission denied: edit_file is not enabled. Enable 'Write & Edit Files' in Settings > MCP."]
}
guard let filePath = args["file_path"] as? String,
let oldText = args["old_text"] as? String,
let newText = args["new_text"] as? String else {
return ["error": "Missing required parameters: file_path, old_text, new_text"]
}
return editFile(filePath: filePath, oldText: oldText, newText: newText)
case "delete_file":
guard canDeleteFiles else {
return ["error": "Permission denied: delete_file is not enabled. Enable 'Delete Files' in Settings > MCP."]
}
guard let filePath = args["file_path"] as? String else {
return ["error": "Missing required parameter: file_path"]
}
return deleteFile(filePath: filePath)
case "create_directory":
guard canCreateDirectories else {
return ["error": "Permission denied: create_directory is not enabled. Enable 'Create Directories' in Settings > MCP."]
}
guard let dirPath = args["dir_path"] as? String else {
return ["error": "Missing required parameter: dir_path"]
}
return createDirectory(dirPath: dirPath)
case "move_file":
guard canMoveFiles else {
return ["error": "Permission denied: move_file is not enabled. Enable 'Move & Copy Files' in Settings > MCP."]
}
guard let source = args["source"] as? String,
let destination = args["destination"] as? String else {
return ["error": "Missing required parameters: source, destination"]
}
return moveFile(source: source, destination: destination)
case "copy_file":
guard canMoveFiles else {
return ["error": "Permission denied: copy_file is not enabled. Enable 'Move & Copy Files' in Settings > MCP."]
}
guard let source = args["source"] as? String,
let destination = args["destination"] as? String else {
return ["error": "Missing required parameters: source, destination"]
}
return copyFile(source: source, destination: destination)
default:
return ["error": "Unknown tool: \(name)"]
}
}
// MARK: - Read Tool Implementations
private func readFile(filePath: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Read denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolved) else {
return ["error": "File not found: \(filePath)"]
}
guard let attrs = try? fm.attributesOfItem(atPath: resolved),
let fileSize = attrs[.size] as? Int else {
return ["error": "Cannot read file attributes: \(filePath)"]
}
if fileSize > maxFileSize {
let sizeMB = String(format: "%.1f", Double(fileSize) / 1_000_000)
return ["error": "File too large (\(sizeMB) MB, max 10 MB)"]
}
guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else {
return ["error": "Cannot read file as UTF-8 text: \(filePath)"]
}
var finalContent = content
if content.utf8.count > maxTextDisplay {
let lines = content.components(separatedBy: "\n")
if lines.count > 600 {
let head = lines.prefix(500).joined(separator: "\n")
let tail = lines.suffix(100).joined(separator: "\n")
let omitted = lines.count - 600
finalContent = head + "\n\n... [\(omitted) lines omitted] ...\n\n" + tail
}
}
return ["content": finalContent, "path": resolved, "size": fileSize]
}
private func listDirectory(dirPath: String, recursive: Bool) -> [String: Any] {
let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
return ["error": "Access denied: path is outside allowed folders"]
}
var isDir: ObjCBool = false
guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else {
return ["error": "Directory not found: \(dirPath)"]
}
var items: [String] = []
if recursive {
guard let enumerator = fm.enumerator(atPath: resolved) else {
return ["error": "Cannot enumerate directory"]
}
while let item = enumerator.nextObject() as? String {
let components = item.components(separatedBy: "/")
if components.contains(where: { skipPatterns.contains($0) }) {
enumerator.skipDescendants()
continue
}
let fullPath = (resolved as NSString).appendingPathComponent(item)
if respectGitignore && isGitignored(fullPath) {
// Skip gitignored directories entirely
var itemIsDir: ObjCBool = false
if fm.fileExists(atPath: fullPath, isDirectory: &itemIsDir), itemIsDir.boolValue {
enumerator.skipDescendants()
}
continue
}
items.append(item)
if items.count >= maxDirItems { break }
}
} else {
guard let contents = try? fm.contentsOfDirectory(atPath: resolved) else {
return ["error": "Cannot list directory"]
}
items = contents.filter { !skipPatterns.contains($0) }
.filter { entry in
if respectGitignore {
let fullPath = (resolved as NSString).appendingPathComponent(entry)
return !isGitignored(fullPath)
}
return true
}
.prefix(maxDirItems).map { entry in
var entryIsDir: ObjCBool = false
let fullPath = (resolved as NSString).appendingPathComponent(entry)
fm.fileExists(atPath: fullPath, isDirectory: &entryIsDir)
return entryIsDir.boolValue ? "\(entry)/" : entry
}
}
let truncated = items.count >= maxDirItems
return ["items": items, "count": items.count, "truncated": truncated, "path": resolved]
}
private func searchFiles(pattern: String, searchPath: String?, contentSearch: String?) -> [String: Any] {
let basePath: String
if let sp = searchPath {
basePath = ((sp as NSString).expandingTildeInPath as NSString).standardizingPath
} else if let first = allowedFolders.first {
basePath = first
} else {
return ["error": "No search path specified and no allowed folders configured"]
}
guard isPathAllowed(basePath) else {
return ["error": "Access denied: search path is outside allowed folders"]
}
guard let enumerator = fm.enumerator(atPath: basePath) else {
return ["error": "Cannot enumerate directory: \(basePath)"]
}
var results: [String] = []
while let item = enumerator.nextObject() as? String {
let components = item.components(separatedBy: "/")
if components.contains(where: { skipPatterns.contains($0) }) {
enumerator.skipDescendants()
continue
}
let fullPath = (basePath as NSString).appendingPathComponent(item)
if respectGitignore && isGitignored(fullPath) {
var itemIsDir: ObjCBool = false
if fm.fileExists(atPath: fullPath, isDirectory: &itemIsDir), itemIsDir.boolValue {
enumerator.skipDescendants()
}
continue
}
let filename = (item as NSString).lastPathComponent
// Filename pattern match
if fnmatch(pattern, filename, 0) != 0 {
continue
}
// Content search if requested
if let searchText = contentSearch, !searchText.isEmpty {
guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else {
continue
}
if !content.localizedCaseInsensitiveContains(searchText) {
continue
}
}
results.append(item)
if results.count >= maxSearchResults { break }
}
let truncated = results.count >= maxSearchResults
return ["matches": results, "count": results.count, "truncated": truncated, "base_path": basePath]
}
// MARK: - Write Tool Implementations
private func writeFile(filePath: String, content: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Write denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
// Create parent directories if needed
let parentDir = (resolved as NSString).deletingLastPathComponent
if !fm.fileExists(atPath: parentDir) {
do {
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create parent directories: \(error.localizedDescription)"]
}
}
do {
try content.write(toFile: resolved, atomically: true, encoding: .utf8)
} catch {
Log.mcp.error("Failed to write file \(resolved): \(error.localizedDescription)")
return ["error": "Cannot write file: \(error.localizedDescription)"]
}
Log.mcp.info("Wrote \(content.utf8.count) bytes to \(resolved)")
return ["success": true, "path": resolved, "bytes_written": content.utf8.count]
}
private func editFile(filePath: String, oldText: String, newText: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Edit denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolved) else {
return ["error": "File not found: \(filePath)"]
}
guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else {
return ["error": "Cannot read file as UTF-8 text: \(filePath)"]
}
// Count occurrences
let occurrences = content.components(separatedBy: oldText).count - 1
if occurrences == 0 {
return ["error": "old_text not found in file"]
}
if occurrences > 1 {
return ["error": "old_text appears \(occurrences) times in the file — it must be unique (exactly 1 match). Provide more surrounding context to make it unique."]
}
let newContent = content.replacingOccurrences(of: oldText, with: newText)
do {
try newContent.write(toFile: resolved, atomically: true, encoding: .utf8)
} catch {
return ["error": "Cannot write file: \(error.localizedDescription)"]
}
return ["success": true, "path": resolved]
}
private func deleteFile(filePath: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Delete denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolved) else {
return ["error": "File not found: \(filePath)"]
}
do {
try fm.removeItem(atPath: resolved)
} catch {
Log.mcp.error("Failed to delete \(resolved): \(error.localizedDescription)")
return ["error": "Cannot delete file: \(error.localizedDescription)"]
}
Log.mcp.info("Deleted \(resolved)")
return ["success": true, "path": resolved]
}
private func createDirectory(dirPath: String) -> [String: Any] {
let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
return ["error": "Access denied: path is outside allowed folders"]
}
do {
try fm.createDirectory(atPath: resolved, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create directory: \(error.localizedDescription)"]
}
return ["success": true, "path": resolved]
}
private func moveFile(source: String, destination: String) -> [String: Any] {
let resolvedSrc = ((source as NSString).expandingTildeInPath as NSString).standardizingPath
let resolvedDst = ((destination as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolvedSrc) else {
return ["error": "Access denied: source path is outside allowed folders"]
}
guard isPathAllowed(resolvedDst) else {
return ["error": "Access denied: destination path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolvedSrc) else {
return ["error": "Source not found: \(source)"]
}
// Create parent directory of destination if needed
let parentDir = (resolvedDst as NSString).deletingLastPathComponent
if !fm.fileExists(atPath: parentDir) {
do {
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create destination directory: \(error.localizedDescription)"]
}
}
do {
try fm.moveItem(atPath: resolvedSrc, toPath: resolvedDst)
} catch {
return ["error": "Cannot move file: \(error.localizedDescription)"]
}
return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
}
private func copyFile(source: String, destination: String) -> [String: Any] {
let resolvedSrc = ((source as NSString).expandingTildeInPath as NSString).standardizingPath
let resolvedDst = ((destination as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolvedSrc) else {
return ["error": "Access denied: source path is outside allowed folders"]
}
guard isPathAllowed(resolvedDst) else {
return ["error": "Access denied: destination path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolvedSrc) else {
return ["error": "Source not found: \(source)"]
}
// Create parent directory of destination if needed
let parentDir = (resolvedDst as NSString).deletingLastPathComponent
if !fm.fileExists(atPath: parentDir) {
do {
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create destination directory: \(error.localizedDescription)"]
}
}
do {
try fm.copyItem(atPath: resolvedSrc, toPath: resolvedDst)
} catch {
return ["error": "Cannot copy file: \(error.localizedDescription)"]
}
return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
}
// MARK: - Gitignore Support
/// Reload gitignore rules for all allowed folders
func reloadGitignores() {
gitignoreRules.removeAll()
if settings.mcpRespectGitignore {
loadGitignores()
}
}
private func loadGitignores() {
for folder in allowedFolders {
loadGitignoreForFolder(folder)
}
}
private func loadGitignoreForFolder(_ folder: String) {
var parser = GitignoreParser(rootPath: folder)
parser.loadRules(fileManager: fm)
gitignoreRules[folder] = parser
}
/// Check if an absolute path is gitignored by any loaded gitignore rules
func isGitignored(_ absolutePath: String) -> Bool {
guard settings.mcpRespectGitignore else { return false }
for (folder, parser) in gitignoreRules {
if absolutePath.hasPrefix(folder) {
let relativePath = String(absolutePath.dropFirst(folder.count).drop(while: { $0 == "/" }))
if !relativePath.isEmpty && parser.isIgnored(relativePath) {
return true
}
}
}
return false
}
}
// MARK: - GitignoreParser
/// Parses .gitignore files and checks paths against the patterns.
/// Supports: wildcards (*), double wildcards (**), directory patterns (/), negation (!), comments (#).
struct GitignoreParser {
let rootPath: String
private var rules: [GitignoreRule] = []
struct GitignoreRule {
let pattern: String
let isNegation: Bool
let isDirectoryOnly: Bool
/// Regex compiled from the gitignore glob pattern
let regex: NSRegularExpression?
}
init(rootPath: String) {
self.rootPath = rootPath
}
/// Load .gitignore from the root path (non-recursive only the root .gitignore)
mutating func loadRules(fileManager fm: FileManager) {
let gitignorePath = (rootPath as NSString).appendingPathComponent(".gitignore")
guard let content = try? String(contentsOfFile: gitignorePath, encoding: .utf8) else {
return
}
parseContent(content)
}
mutating func parseContent(_ content: String) {
let lines = content.components(separatedBy: .newlines)
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Skip empty lines and comments
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
var pattern = trimmed
let isNegation = pattern.hasPrefix("!")
if isNegation {
pattern = String(pattern.dropFirst())
}
// Remove trailing spaces (unless escaped)
while pattern.hasSuffix(" ") && !pattern.hasSuffix("\\ ") {
pattern = String(pattern.dropLast())
}
let isDirectoryOnly = pattern.hasSuffix("/")
if isDirectoryOnly {
pattern = String(pattern.dropLast())
}
// Remove leading slash (anchors to root, but we match relative paths)
if pattern.hasPrefix("/") {
pattern = String(pattern.dropFirst())
}
guard !pattern.isEmpty else { continue }
let regex = gitignorePatternToRegex(pattern)
rules.append(GitignoreRule(
pattern: pattern,
isNegation: isNegation,
isDirectoryOnly: isDirectoryOnly,
regex: regex
))
}
}
/// Check if a relative path (from rootPath) is ignored
func isIgnored(_ relativePath: String) -> Bool {
var ignored = false
for rule in rules {
let matches: Bool
if let regex = rule.regex {
let range = NSRange(relativePath.startIndex..., in: relativePath)
matches = regex.firstMatch(in: relativePath, range: range) != nil
} else {
// Fallback: simple contains check for the pattern basename
let basename = (relativePath as NSString).lastPathComponent
matches = basename == rule.pattern
}
if matches {
if rule.isNegation {
ignored = false
} else {
ignored = true
}
}
}
return ignored
}
/// Convert a gitignore glob pattern to a regex
private func gitignorePatternToRegex(_ pattern: String) -> NSRegularExpression? {
var regex = ""
let chars = Array(pattern)
var i = 0
// If the pattern contains no slash, it matches against the filename only
let matchesPath = pattern.contains("/")
if !matchesPath {
// Match against any path component the pattern can appear as the last component
regex += "(?:^|/)"
} else {
regex += "^"
}
while i < chars.count {
let c = chars[i]
switch c {
case "*":
if i + 1 < chars.count && chars[i + 1] == "*" {
// **
if i + 2 < chars.count && chars[i + 2] == "/" {
// **/ matches zero or more directories
regex += "(?:.+/)?"
i += 3
continue
} else {
// ** at end matches everything
regex += ".*"
i += 2
continue
}
} else {
// Single * matches anything except /
regex += "[^/]*"
}
case "?":
regex += "[^/]"
case ".":
regex += "\\."
case "[":
// Character class pass through
regex += "["
case "]":
regex += "]"
case "\\":
// Escape next character
if i + 1 < chars.count {
i += 1
regex += NSRegularExpression.escapedPattern(for: String(chars[i]))
}
default:
regex += NSRegularExpression.escapedPattern(for: String(c))
}
i += 1
}
// Allow matching as a prefix (directory) or exact match
regex += "(?:/.*)?$"
return try? NSRegularExpression(pattern: regex, options: [])
}
}

View File

@@ -0,0 +1,408 @@
//
// SettingsService.swift
// oAI
//
// Settings persistence: SQLite for preferences, Keychain for API keys
//
import Foundation
import os
import Security
@Observable
class SettingsService {
static let shared = SettingsService()
// In-memory cache of DB settings for fast reads
private var cache: [String: String] = [:]
// Keychain keys (secrets only)
private enum KeychainKeys {
static let openrouterAPIKey = "com.oai.apikey.openrouter"
static let anthropicAPIKey = "com.oai.apikey.anthropic"
static let openaiAPIKey = "com.oai.apikey.openai"
static let googleAPIKey = "com.oai.apikey.google"
static let googleSearchEngineID = "com.oai.google.searchEngineID"
}
private init() {
// Load all settings from DB into cache
cache = (try? DatabaseService.shared.loadAllSettings()) ?? [:]
Log.settings.info("Settings initialized with \(self.cache.count) cached entries")
// Migrate from UserDefaults on first launch
migrateFromUserDefaultsIfNeeded()
}
// MARK: - Provider Settings
var defaultProvider: Settings.Provider {
get {
if let raw = cache["defaultProvider"],
let provider = Settings.Provider(rawValue: raw) {
return provider
}
return .openrouter
}
set {
cache["defaultProvider"] = newValue.rawValue
DatabaseService.shared.setSetting(key: "defaultProvider", value: newValue.rawValue)
}
}
var defaultModel: String? {
get { cache["defaultModel"] }
set {
if let value = newValue {
cache["defaultModel"] = value
DatabaseService.shared.setSetting(key: "defaultModel", value: value)
} else {
cache.removeValue(forKey: "defaultModel")
DatabaseService.shared.deleteSetting(key: "defaultModel")
}
}
}
// MARK: - Model Settings
var streamEnabled: Bool {
get { cache["streamEnabled"].map { $0 == "true" } ?? true }
set {
cache["streamEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "streamEnabled", value: String(newValue))
}
}
var maxTokens: Int {
get { cache["maxTokens"].flatMap(Int.init) ?? 0 }
set {
cache["maxTokens"] = String(newValue)
DatabaseService.shared.setSetting(key: "maxTokens", value: String(newValue))
}
}
var temperature: Double {
get { cache["temperature"].flatMap(Double.init) ?? 0.0 }
set {
cache["temperature"] = String(newValue)
DatabaseService.shared.setSetting(key: "temperature", value: String(newValue))
}
}
// MARK: - Feature Settings
var onlineMode: Bool {
get { cache["onlineMode"] == "true" }
set {
cache["onlineMode"] = String(newValue)
DatabaseService.shared.setSetting(key: "onlineMode", value: String(newValue))
}
}
var memoryEnabled: Bool {
get { cache["memoryEnabled"].map { $0 == "true" } ?? true }
set {
cache["memoryEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "memoryEnabled", value: String(newValue))
}
}
var mcpEnabled: Bool {
get { cache["mcpEnabled"] == "true" }
set {
cache["mcpEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "mcpEnabled", value: String(newValue))
}
}
// MARK: - Text Size Settings
/// GUI text size (headers, labels, buttons) default 13
var guiTextSize: Double {
get { cache["guiTextSize"].flatMap(Double.init) ?? 13.0 }
set {
cache["guiTextSize"] = String(newValue)
DatabaseService.shared.setSetting(key: "guiTextSize", value: String(newValue))
}
}
/// Dialog/chat message text size default 14
var dialogTextSize: Double {
get { cache["dialogTextSize"].flatMap(Double.init) ?? 14.0 }
set {
cache["dialogTextSize"] = String(newValue)
DatabaseService.shared.setSetting(key: "dialogTextSize", value: String(newValue))
}
}
/// Input box text size default 14
var inputTextSize: Double {
get { cache["inputTextSize"].flatMap(Double.init) ?? 14.0 }
set {
cache["inputTextSize"] = String(newValue)
DatabaseService.shared.setSetting(key: "inputTextSize", value: String(newValue))
}
}
// MARK: - MCP Permissions
var mcpCanWriteFiles: Bool {
get { cache["mcpCanWriteFiles"] == "true" }
set {
cache["mcpCanWriteFiles"] = String(newValue)
DatabaseService.shared.setSetting(key: "mcpCanWriteFiles", value: String(newValue))
}
}
var mcpCanDeleteFiles: Bool {
get { cache["mcpCanDeleteFiles"] == "true" }
set {
cache["mcpCanDeleteFiles"] = String(newValue)
DatabaseService.shared.setSetting(key: "mcpCanDeleteFiles", value: String(newValue))
}
}
var mcpCanCreateDirectories: Bool {
get { cache["mcpCanCreateDirectories"] == "true" }
set {
cache["mcpCanCreateDirectories"] = String(newValue)
DatabaseService.shared.setSetting(key: "mcpCanCreateDirectories", value: String(newValue))
}
}
var mcpCanMoveFiles: Bool {
get { cache["mcpCanMoveFiles"] == "true" }
set {
cache["mcpCanMoveFiles"] = String(newValue)
DatabaseService.shared.setSetting(key: "mcpCanMoveFiles", value: String(newValue))
}
}
var mcpRespectGitignore: Bool {
get { cache["mcpRespectGitignore"].map { $0 == "true" } ?? true }
set {
cache["mcpRespectGitignore"] = String(newValue)
DatabaseService.shared.setSetting(key: "mcpRespectGitignore", value: String(newValue))
}
}
// MARK: - MCP Allowed Folders
var mcpAllowedFolders: [String] {
get {
guard let json = cache["mcpAllowedFolders"],
let data = json.data(using: .utf8),
let folders = try? JSONDecoder().decode([String].self, from: data) else {
return []
}
return folders
}
set {
if let data = try? JSONEncoder().encode(newValue),
let json = String(data: data, encoding: .utf8) {
cache["mcpAllowedFolders"] = json
DatabaseService.shared.setSetting(key: "mcpAllowedFolders", value: json)
}
}
}
// MARK: - Search Settings
var searchProvider: Settings.SearchProvider {
get {
if let raw = cache["searchProvider"],
let provider = Settings.SearchProvider(rawValue: raw) {
return provider
}
return .duckduckgo
}
set {
cache["searchProvider"] = newValue.rawValue
DatabaseService.shared.setSetting(key: "searchProvider", value: newValue.rawValue)
}
}
// MARK: - Ollama Settings
var ollamaBaseURL: String {
get { cache["ollamaBaseURL"] ?? "" }
set {
let trimmed = newValue.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
cache.removeValue(forKey: "ollamaBaseURL")
DatabaseService.shared.deleteSetting(key: "ollamaBaseURL")
} else {
cache["ollamaBaseURL"] = trimmed
DatabaseService.shared.setSetting(key: "ollamaBaseURL", value: trimmed)
}
}
}
/// Resolved Ollama URL returns the user value or the default
var ollamaEffectiveURL: String {
let url = ollamaBaseURL
return url.isEmpty ? "http://localhost:11434" : url
}
/// Whether the user has explicitly configured an Ollama URL
var ollamaConfigured: Bool {
cache["ollamaBaseURL"] != nil && !(cache["ollamaBaseURL"]!.isEmpty)
}
// MARK: - API Keys (Keychain)
var openrouterAPIKey: String? {
get { getKeychainValue(for: KeychainKeys.openrouterAPIKey) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.openrouterAPIKey)
} else {
deleteKeychainValue(for: KeychainKeys.openrouterAPIKey)
}
}
}
var anthropicAPIKey: String? {
get { getKeychainValue(for: KeychainKeys.anthropicAPIKey) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.anthropicAPIKey)
} else {
deleteKeychainValue(for: KeychainKeys.anthropicAPIKey)
}
}
}
var openaiAPIKey: String? {
get { getKeychainValue(for: KeychainKeys.openaiAPIKey) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.openaiAPIKey)
} else {
deleteKeychainValue(for: KeychainKeys.openaiAPIKey)
}
}
}
var googleAPIKey: String? {
get { getKeychainValue(for: KeychainKeys.googleAPIKey) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.googleAPIKey)
} else {
deleteKeychainValue(for: KeychainKeys.googleAPIKey)
}
}
}
var googleSearchEngineID: String? {
get { getKeychainValue(for: KeychainKeys.googleSearchEngineID) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.googleSearchEngineID)
} else {
deleteKeychainValue(for: KeychainKeys.googleSearchEngineID)
}
}
}
// MARK: - UserDefaults Migration
private func migrateFromUserDefaultsIfNeeded() {
// Skip if already migrated
guard cache["_migrated"] == nil else { return }
let defaults = UserDefaults.standard
let migrations: [(udKey: String, dbKey: String)] = [
("defaultProvider", "defaultProvider"),
("defaultModel", "defaultModel"),
("streamEnabled", "streamEnabled"),
("maxTokens", "maxTokens"),
("temperature", "temperature"),
("onlineMode", "onlineMode"),
("memoryEnabled", "memoryEnabled"),
("mcpEnabled", "mcpEnabled"),
("searchProvider", "searchProvider"),
("ollamaBaseURL", "ollamaBaseURL"),
]
for (udKey, dbKey) in migrations {
guard cache[dbKey] == nil else { continue }
if let stringVal = defaults.string(forKey: udKey) {
cache[dbKey] = stringVal
DatabaseService.shared.setSetting(key: dbKey, value: stringVal)
} else if defaults.object(forKey: udKey) != nil {
// Handle bool/int/double stored as non-string
let value: String
if let boolVal = defaults.object(forKey: udKey) as? Bool {
value = String(boolVal)
} else if defaults.integer(forKey: udKey) != 0 {
value = String(defaults.integer(forKey: udKey))
} else if defaults.double(forKey: udKey) != 0.0 {
value = String(defaults.double(forKey: udKey))
} else {
continue
}
cache[dbKey] = value
DatabaseService.shared.setSetting(key: dbKey, value: value)
}
}
// Mark migration complete
cache["_migrated"] = "true"
DatabaseService.shared.setSetting(key: "_migrated", value: "true")
}
// MARK: - Keychain Helpers
private func getKeychainValue(for key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var dataTypeRef: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
guard status == errSecSuccess,
let data = dataTypeRef as? Data,
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value
}
private func setKeychainValue(_ value: String, for key: String) {
guard let data = value.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let attributes: [String: Any] = [
kSecValueData as String: data
]
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if updateStatus == errSecItemNotFound {
var newItem = query
newItem[kSecValueData as String] = data
SecItemAdd(newItem as CFDictionary, nil)
}
}
private func deleteKeychainValue(for key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}

View File

@@ -0,0 +1,143 @@
//
// WebSearchService.swift
// oAI
//
// DuckDuckGo web search for non-OpenRouter providers
//
import Foundation
import os
struct SearchResult: Sendable {
let title: String
let url: String
let snippet: String
}
final class WebSearchService: Sendable {
nonisolated static let shared = WebSearchService()
private let session: URLSession
nonisolated private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 10
session = URLSession(configuration: config)
}
/// Search DuckDuckGo HTML interface (no API key needed)
nonisolated func search(query: String, maxResults: Int = 5) async -> [SearchResult] {
Log.search.info("Web search: \(query)")
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "https://html.duckduckgo.com/html/?q=\(encoded)")
else { return [] }
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
forHTTPHeaderField: "User-Agent"
)
do {
let (data, _) = try await session.data(for: request)
guard let html = String(data: data, encoding: .utf8) else { return [] }
return parseResults(from: html, maxResults: maxResults)
} catch {
Log.search.error("Web search failed: \(error.localizedDescription)")
return []
}
}
/// Format search results as markdown for prompt injection
nonisolated func formatResults(_ results: [SearchResult], maxLength: Int = 2000) -> String {
if results.isEmpty { return "No search results found." }
var formatted = "**Web Search Results:**\n\n"
for (i, result) in results.enumerated() {
var entry = "\(i + 1). **\(result.title)**\n"
entry += " URL: \(result.url)\n"
if !result.snippet.isEmpty {
entry += " \(result.snippet)\n"
}
entry += "\n"
if formatted.count + entry.count > maxLength {
formatted += "... (\(results.count - i) more results truncated)\n"
break
}
formatted += entry
}
return formatted.trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - HTML Parsing
private nonisolated func parseResults(from html: String, maxResults: Int) -> [SearchResult] {
var results: [SearchResult] = []
// Match result blocks: <div class="result results_links ...">
let blockPattern = #"<div class="result results_links.*?(?=<div class="result results_links|<div id="links")"#
guard let blockRegex = try? NSRegularExpression(pattern: blockPattern, options: .dotMatchesLineSeparators) else {
return []
}
let range = NSRange(html.startIndex..., in: html)
let blocks = blockRegex.matches(in: html, range: range)
for match in blocks.prefix(maxResults) {
guard let blockRange = Range(match.range, in: html) else { continue }
let block = String(html[blockRange])
// Extract title and URL from <a class="result__a" href="...">Title</a>
let titlePattern = #"<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]+)</a>"#
guard let titleRegex = try? NSRegularExpression(pattern: titlePattern),
let titleMatch = titleRegex.firstMatch(in: block, range: NSRange(block.startIndex..., in: block)),
let urlRange = Range(titleMatch.range(at: 1), in: block),
let titleRange = Range(titleMatch.range(at: 2), in: block)
else { continue }
var resultURL = String(block[urlRange])
let title = decodeHTMLEntities(String(block[titleRange]).trimmingCharacters(in: .whitespaces))
// Extract snippet from <a class="result__snippet" ...>text</a>
let snippetPattern = #"<a[^>]*class="result__snippet"[^>]*>([^<]+)</a>"#
var snippet = ""
if let snippetRegex = try? NSRegularExpression(pattern: snippetPattern),
let snippetMatch = snippetRegex.firstMatch(in: block, range: NSRange(block.startIndex..., in: block)),
let snippetRange = Range(snippetMatch.range(at: 1), in: block) {
snippet = decodeHTMLEntities(String(block[snippetRange]).trimmingCharacters(in: .whitespaces))
}
// Decode DDG redirect URL
if resultURL.contains("uddg=") {
let uddgPattern = #"uddg=([^&]+)"#
if let uddgRegex = try? NSRegularExpression(pattern: uddgPattern),
let uddgMatch = uddgRegex.firstMatch(in: resultURL, range: NSRange(resultURL.startIndex..., in: resultURL)),
let uddgRange = Range(uddgMatch.range(at: 1), in: resultURL) {
resultURL = String(resultURL[uddgRange]).removingPercentEncoding ?? resultURL
}
}
results.append(SearchResult(title: title, url: resultURL, snippet: snippet))
}
return results
}
private nonisolated func decodeHTMLEntities(_ string: String) -> String {
var result = string
let entities: [(String, String)] = [
("&amp;", "&"), ("&lt;", "<"), ("&gt;", ">"),
("&quot;", "\""), ("&#39;", "'"), ("&apos;", "'"),
("&#x27;", "'"), ("&#x2F;", "/"), ("&nbsp;", " "),
]
for (entity, char) in entities {
result = result.replacingOccurrences(of: entity, with: char)
}
return result
}
}

View File

@@ -0,0 +1,78 @@
//
// Color+Extensions.swift
// oAI
//
// Color scheme matching Python TUI dark theme
//
import SwiftUI
extension Color {
// MARK: - oAI Color Palette (Matching Python TUI)
static let oaiBackground = Color(hex: "#1e1e1e") // Main background
static let oaiSurface = Color(hex: "#2d2d2d") // Cards, surfaces
static let oaiPrimary = Color(hex: "#cccccc") // Primary text
static let oaiSecondary = Color(hex: "#888888") // Secondary text
static let oaiAccent = Color(hex: "#0a7aca") // Blue accent (assistant)
static let oaiSuccess = Color(hex: "#90ee90") // Green (user messages)
static let oaiError = Color(hex: "#ff6b6b") // Red (errors)
static let oaiWarning = Color(hex: "#ffaa00") // Orange (warnings)
static let oaiBorder = Color(hex: "#555555") // Borders, dividers
// MARK: - Message Role Colors
static func messageColor(for role: MessageRole) -> Color {
switch role {
case .user: return .oaiSuccess
case .assistant: return .oaiAccent
case .system: return .oaiSecondary
}
}
static func messageBackground(for role: MessageRole) -> Color {
switch role {
case .user: return .oaiSurface
case .assistant: return .oaiBackground
case .system: return Color(hex: "#2a2a2a")
}
}
// MARK: - Provider Colors
static func providerColor(_ provider: Settings.Provider) -> Color {
switch provider {
case .openrouter: return Color(hex: "#7c3aed") // Purple
case .anthropic: return Color(hex: "#d4895a") // Orange
case .openai: return Color(hex: "#10a37f") // Green
case .ollama: return Color(hex: "#ffffff") // White
}
}
// MARK: - Hex Initializer
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}

View File

@@ -0,0 +1,87 @@
//
// String+Extensions.swift
// oAI
//
// String utility extensions
//
import Foundation
extension String {
// MARK: - Command Parsing
var isSlashCommand: Bool {
hasPrefix("/")
}
func parseCommand() -> (command: String, args: [String])? {
guard isSlashCommand else { return nil }
let parts = self.split(separator: " ", omittingEmptySubsequences: true)
.map(String.init)
guard let command = parts.first else { return nil }
let args = Array(parts.dropFirst())
return (command, args)
}
// MARK: - File Attachment Parsing
func parseFileAttachments() -> (cleanText: String, filePaths: [String]) {
var cleanText = self
var filePaths: [String] = []
// Pattern 1: @<filepath>
let anglePattern = #"@<([^>]+)>"#
if let regex = try? NSRegularExpression(pattern: anglePattern) {
let matches = regex.matches(in: self, range: NSRange(self.startIndex..., in: self))
for match in matches.reversed() {
if let range = Range(match.range(at: 1), in: self) {
let path = String(self[range])
filePaths.insert(path, at: 0)
}
if let fullRange = Range(match.range, in: self) {
cleanText.removeSubrange(fullRange)
}
}
}
// Pattern 2: @filepath (starting with /, ~, ., or drive letter)
let directPattern = #"@([~/.][\S]+|[A-Za-z]:[\\\/][\S]+)"#
if let regex = try? NSRegularExpression(pattern: directPattern) {
let matches = regex.matches(in: cleanText, range: NSRange(cleanText.startIndex..., in: cleanText))
for match in matches.reversed() {
if let range = Range(match.range(at: 1), in: cleanText) {
let path = String(cleanText[range])
if !filePaths.contains(path) {
filePaths.insert(path, at: 0)
}
}
if let fullRange = Range(match.range, in: cleanText) {
cleanText.removeSubrange(fullRange)
}
}
}
return (cleanText.trimmingCharacters(in: .whitespaces), filePaths)
}
// MARK: - Token Estimation
func estimateTokens() -> Int {
// Rough estimation: ~4 characters per token
// This is approximate; Phase 2 will use proper tokenizer
return max(1, count / 4)
}
// MARK: - Truncation
func truncated(to length: Int, trailing: String = "...") -> String {
if count <= length {
return self
}
let endIndex = index(startIndex, offsetBy: length - trailing.count)
return String(self[..<endIndex]) + trailing
}
}

View File

@@ -0,0 +1,81 @@
//
// View+Extensions.swift
// oAI
//
// SwiftUI view helpers and modifiers
//
import SwiftUI
extension View {
// MARK: - Conditional Modifiers
@ViewBuilder
func `if`<Transform: View>(_ condition: Bool, transform: (Self) -> Transform) -> some View {
if condition {
transform(self)
} else {
self
}
}
@ViewBuilder
func ifLet<Value, Transform: View>(_ value: Value?, transform: (Self, Value) -> Transform) -> some View {
if let value = value {
transform(self, value)
} else {
self
}
}
// MARK: - Platform-Specific Helpers
#if os(macOS)
func onCommandReturn(perform action: @escaping () -> Void) -> some View {
self
// Note: onKeyPress modifiers don't work in command-line Swift build
// This will be implemented when running in actual Xcode project
// For now, using keyboard shortcuts in toolbar instead
}
#endif
// MARK: - Common Styling
func oaiCardStyle() -> some View {
self
.background(Color.oaiSurface)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.oaiBorder, lineWidth: 1)
)
}
func oaiButton() -> some View {
self
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.oaiSurface)
.foregroundColor(.oaiPrimary)
.cornerRadius(6)
}
func oaiTextField() -> some View {
self
.padding(8)
.background(Color.oaiBackground)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.oaiBorder, lineWidth: 1)
)
}
}
// MARK: - Frame Helpers
extension View {
func frame(square size: CGFloat) -> some View {
self.frame(width: size, height: size)
}
}

136
oAI/Utilities/Logging.swift Normal file
View File

@@ -0,0 +1,136 @@
//
// Logging.swift
// oAI
//
// Dual logging: os.Logger (unified log) + file (~Library/Logs/oAI.log)
//
import Foundation
import os
// MARK: - Log Level
enum LogLevel: Int, Comparable, CaseIterable, Sendable {
case debug = 0
case info = 1
case warning = 2
case error = 3
var label: String {
switch self {
case .debug: return "DEBUG"
case .info: return "INFO"
case .warning: return "WARN"
case .error: return "ERROR"
}
}
var displayName: String {
switch self {
case .debug: return "Debug"
case .info: return "Info"
case .warning: return "Warning"
case .error: return "Error"
}
}
static func < (lhs: LogLevel, rhs: LogLevel) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
// MARK: - File Logger
final class FileLogger: @unchecked Sendable {
static let shared = FileLogger()
private let fileHandle: FileHandle?
private let queue = DispatchQueue(label: "com.oai.filelogger")
private let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
return f
}()
/// Current minimum log level (read from UserDefaults for thread safety)
var minimumLevel: LogLevel {
get {
let raw = UserDefaults.standard.integer(forKey: "logLevel")
return LogLevel(rawValue: raw) ?? .info
}
set {
UserDefaults.standard.set(newValue.rawValue, forKey: "logLevel")
}
}
private init() {
let logsDir = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Logs")
let logFile = logsDir.appendingPathComponent("oAI.log")
// Ensure file exists
if !FileManager.default.fileExists(atPath: logFile.path) {
FileManager.default.createFile(atPath: logFile.path, contents: nil)
}
fileHandle = try? FileHandle(forWritingTo: logFile)
fileHandle?.seekToEndOfFile()
}
func write(_ level: LogLevel, category: String, message: String) {
guard level >= minimumLevel else { return }
queue.async { [weak self] in
guard let self, let fh = self.fileHandle else { return }
let timestamp = self.dateFormatter.string(from: Date())
let line = "[\(timestamp)] [\(level.label)] [\(category)] \(message)\n"
if let data = line.data(using: .utf8) {
fh.write(data)
}
}
}
deinit {
fileHandle?.closeFile()
}
}
// MARK: - App Logger (wraps os.Logger + file)
struct AppLogger {
let osLogger: Logger
let category: String
func debug(_ message: String) {
FileLogger.shared.write(.debug, category: category, message: message)
osLogger.debug("\(message, privacy: .public)")
}
func info(_ message: String) {
FileLogger.shared.write(.info, category: category, message: message)
osLogger.info("\(message, privacy: .public)")
}
func warning(_ message: String) {
FileLogger.shared.write(.warning, category: category, message: message)
osLogger.warning("\(message, privacy: .public)")
}
func error(_ message: String) {
FileLogger.shared.write(.error, category: category, message: message)
osLogger.error("\(message, privacy: .public)")
}
}
// MARK: - Log Namespace
enum Log {
private static let subsystem = "com.oai.oAI"
static let api = AppLogger(osLogger: Logger(subsystem: subsystem, category: "api"), category: "api")
static let db = AppLogger(osLogger: Logger(subsystem: subsystem, category: "database"), category: "database")
static let mcp = AppLogger(osLogger: Logger(subsystem: subsystem, category: "mcp"), category: "mcp")
static let settings = AppLogger(osLogger: Logger(subsystem: subsystem, category: "settings"), category: "settings")
static let search = AppLogger(osLogger: Logger(subsystem: subsystem, category: "search"), category: "search")
static let ui = AppLogger(osLogger: Logger(subsystem: subsystem, category: "ui"), category: "ui")
static let general = AppLogger(osLogger: Logger(subsystem: subsystem, category: "general"), category: "general")
}

View File

@@ -0,0 +1,294 @@
//
// SyntaxHighlighter.swift
// oAI
//
// Keyword-based syntax highlighting using AttributedString
//
import SwiftUI
struct SyntaxHighlighter {
// MARK: - Token Colors (dark theme)
static let keywordColor = Color(hex: "#569cd6") // Blue
static let stringColor = Color(hex: "#ce9178") // Orange
static let commentColor = Color(hex: "#6a9955") // Green
static let numberColor = Color(hex: "#b5cea8") // Light green
static let typeColor = Color(hex: "#4ec9b0") // Teal
static let functionColor = Color(hex: "#dcdcaa") // Yellow
static let defaultColor = Color(hex: "#d4d4d4") // Light gray
static let punctuationColor = Color(hex: "#808080") // Gray
// MARK: - Language Keywords
private static let keywords: [String: Set<String>] = [
"swift": ["import", "func", "class", "struct", "enum", "protocol", "extension",
"var", "let", "if", "else", "guard", "switch", "case", "default",
"for", "while", "repeat", "return", "break", "continue", "throw",
"throws", "try", "catch", "do", "async", "await", "in", "where",
"self", "Self", "super", "init", "deinit", "nil", "true", "false",
"public", "private", "internal", "fileprivate", "open", "static",
"override", "mutating", "weak", "unowned", "lazy", "some", "any",
"typealias", "associatedtype", "inout", "as", "is", "defer"],
"python": ["import", "from", "def", "class", "if", "elif", "else", "for",
"while", "return", "yield", "break", "continue", "pass", "raise",
"try", "except", "finally", "with", "as", "lambda", "and", "or",
"not", "in", "is", "True", "False", "None", "self", "async", "await",
"global", "nonlocal", "del", "assert", "print"],
"javascript": ["function", "const", "let", "var", "if", "else", "for", "while",
"do", "switch", "case", "default", "return", "break", "continue",
"throw", "try", "catch", "finally", "class", "extends", "new",
"this", "super", "import", "export", "from", "async", "await",
"yield", "of", "in", "typeof", "instanceof", "delete", "void",
"true", "false", "null", "undefined", "debugger"],
"typescript": ["function", "const", "let", "var", "if", "else", "for", "while",
"do", "switch", "case", "default", "return", "break", "continue",
"throw", "try", "catch", "finally", "class", "extends", "new",
"this", "super", "import", "export", "from", "async", "await",
"yield", "of", "in", "typeof", "instanceof", "delete", "void",
"true", "false", "null", "undefined", "type", "interface",
"enum", "implements", "abstract", "readonly", "as", "keyof",
"namespace", "declare", "module"],
"go": ["package", "import", "func", "type", "struct", "interface", "var",
"const", "if", "else", "for", "range", "switch", "case", "default",
"return", "break", "continue", "go", "defer", "select", "chan",
"map", "make", "new", "append", "len", "cap", "nil", "true", "false",
"fallthrough", "goto"],
"rust": ["fn", "let", "mut", "const", "if", "else", "match", "for", "while",
"loop", "return", "break", "continue", "struct", "enum", "impl",
"trait", "pub", "use", "mod", "crate", "self", "super", "as", "in",
"ref", "move", "async", "await", "where", "type", "dyn", "unsafe",
"extern", "true", "false", "Some", "None", "Ok", "Err"],
"java": ["class", "interface", "enum", "extends", "implements", "import",
"package", "public", "private", "protected", "static", "final",
"abstract", "void", "int", "long", "double", "float", "boolean",
"char", "byte", "short", "if", "else", "for", "while", "do",
"switch", "case", "default", "return", "break", "continue",
"throw", "throws", "try", "catch", "finally", "new", "this",
"super", "null", "true", "false", "synchronized", "volatile"],
"c": ["if", "else", "for", "while", "do", "switch", "case", "default",
"return", "break", "continue", "goto", "typedef", "struct", "union",
"enum", "const", "static", "extern", "volatile", "register", "auto",
"void", "int", "long", "short", "char", "float", "double", "unsigned",
"signed", "sizeof", "NULL", "include", "define", "ifdef", "ifndef",
"endif", "pragma"],
"cpp": ["if", "else", "for", "while", "do", "switch", "case", "default",
"return", "break", "continue", "goto", "typedef", "struct", "union",
"enum", "const", "static", "extern", "volatile", "class", "public",
"private", "protected", "virtual", "override", "template", "typename",
"namespace", "using", "new", "delete", "throw", "try", "catch",
"nullptr", "true", "false", "auto", "constexpr", "inline",
"void", "int", "long", "short", "char", "float", "double", "bool",
"include", "define", "ifdef", "ifndef", "endif"],
"sql": ["SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE",
"SET", "DELETE", "CREATE", "TABLE", "ALTER", "DROP", "INDEX",
"JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "ON", "AND", "OR",
"NOT", "NULL", "IS", "IN", "LIKE", "BETWEEN", "EXISTS", "AS",
"ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "UNION",
"DISTINCT", "COUNT", "SUM", "AVG", "MAX", "MIN", "PRIMARY",
"KEY", "FOREIGN", "REFERENCES", "CASCADE", "CONSTRAINT",
"select", "from", "where", "insert", "into", "values", "update",
"set", "delete", "create", "table", "alter", "drop", "index",
"join", "left", "right", "inner", "outer", "on", "and", "or",
"not", "null", "is", "in", "like", "between", "exists", "as",
"order", "by", "group", "having", "limit", "offset", "union",
"distinct", "primary", "key", "foreign", "references"],
"shell": ["if", "then", "else", "elif", "fi", "for", "while", "do", "done",
"case", "esac", "function", "return", "exit", "echo", "export",
"local", "readonly", "shift", "set", "unset", "source", "eval",
"exec", "cd", "pwd", "ls", "cp", "mv", "rm", "mkdir", "cat",
"grep", "sed", "awk", "find", "xargs", "pipe", "true", "false",
"in", "sudo", "chmod", "chown"],
"html": ["html", "head", "body", "div", "span", "p", "a", "img", "ul", "ol",
"li", "table", "tr", "td", "th", "form", "input", "button", "select",
"option", "textarea", "script", "style", "link", "meta", "title",
"header", "footer", "nav", "main", "section", "article", "aside",
"class", "id", "href", "src", "alt", "type", "value", "name"],
"css": ["color", "background", "margin", "padding", "border", "font",
"display", "position", "width", "height", "top", "left", "right",
"bottom", "flex", "grid", "align", "justify", "transform", "transition",
"animation", "opacity", "overflow", "z-index", "important",
"none", "block", "inline", "absolute", "relative", "fixed", "sticky"],
"ruby": ["def", "end", "class", "module", "if", "elsif", "else", "unless",
"while", "until", "for", "do", "begin", "rescue", "ensure", "raise",
"return", "yield", "block_given?", "require", "include", "extend",
"attr_accessor", "attr_reader", "attr_writer", "self", "super",
"nil", "true", "false", "puts", "print", "lambda", "proc"],
]
// MARK: - Comment Styles
private static let lineCommentPrefixes: [String: String] = [
"swift": "//", "python": "#", "javascript": "//", "typescript": "//",
"go": "//", "rust": "//", "java": "//", "c": "//", "cpp": "//",
"shell": "#", "bash": "#", "ruby": "#", "yaml": "#", "toml": "#",
]
// MARK: - Language Aliases
private static let languageAliases: [String: String] = [
"js": "javascript", "ts": "typescript", "py": "python",
"sh": "shell", "bash": "shell", "zsh": "shell",
"c++": "cpp", "objective-c": "c", "objc": "c",
"yml": "yaml", "md": "markdown", "rb": "ruby",
"h": "c", "hpp": "cpp", "m": "c",
]
// MARK: - Public API
static func highlight(code: String, language: String?) -> AttributedString {
let lang = resolveLanguage(language)
let langKeywords = keywords[lang] ?? Set()
let commentPrefix = lineCommentPrefixes[lang]
var result = AttributedString()
let lines = code.components(separatedBy: "\n")
for (lineIndex, line) in lines.enumerated() {
let highlightedLine = highlightLine(line, keywords: langKeywords, commentPrefix: commentPrefix, language: lang)
result.append(highlightedLine)
if lineIndex < lines.count - 1 {
result.append(AttributedString("\n"))
}
}
return result
}
// MARK: - Private
private static func resolveLanguage(_ lang: String?) -> String {
guard let lang = lang?.lowercased().trimmingCharacters(in: .whitespaces), !lang.isEmpty else {
return ""
}
return languageAliases[lang] ?? lang
}
private static func highlightLine(_ line: String, keywords: Set<String>, commentPrefix: String?, language: String) -> AttributedString {
// Check for line comments
if let prefix = commentPrefix {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix(prefix) {
var attr = AttributedString(line)
attr.foregroundColor = commentColor
return attr
}
}
// Tokenize and colorize
var result = AttributedString()
var i = line.startIndex
while i < line.endIndex {
let c = line[i]
// String literals
if c == "\"" || c == "'" || c == "`" {
let (strAttr, newIndex) = consumeString(line, from: i, quote: c)
result.append(strAttr)
i = newIndex
continue
}
// Block comment start
if c == "/" && line.index(after: i) < line.endIndex && line[line.index(after: i)] == "*" {
// Consume rest of line as comment (simplified no multi-line tracking)
let rest = String(line[i...])
var attr = AttributedString(rest)
attr.foregroundColor = commentColor
result.append(attr)
return result
}
// Numbers
if c.isNumber && (i == line.startIndex || !line[line.index(before: i)].isLetter) {
let (numAttr, newIndex) = consumeNumber(line, from: i)
result.append(numAttr)
i = newIndex
continue
}
// Words (identifiers/keywords)
if c.isLetter || c == "_" || c == "@" || c == "#" {
let (wordAttr, newIndex) = consumeWord(line, from: i, keywords: keywords)
result.append(wordAttr)
i = newIndex
continue
}
// Punctuation/operators
var charAttr = AttributedString(String(c))
charAttr.foregroundColor = defaultColor
result.append(charAttr)
i = line.index(after: i)
}
return result
}
private static func consumeString(_ line: String, from start: String.Index, quote: Character) -> (AttributedString, String.Index) {
var i = line.index(after: start)
var str = String(quote)
while i < line.endIndex {
let c = line[i]
str.append(c)
if c == "\\" && line.index(after: i) < line.endIndex {
// Escaped character
i = line.index(after: i)
str.append(line[i])
i = line.index(after: i)
continue
}
i = line.index(after: i)
if c == quote {
break
}
}
var attr = AttributedString(str)
attr.foregroundColor = stringColor
return (attr, i)
}
private static func consumeNumber(_ line: String, from start: String.Index) -> (AttributedString, String.Index) {
var i = start
var num = ""
while i < line.endIndex && (line[i].isHexDigit || line[i] == "." || line[i] == "x" || line[i] == "X" || line[i] == "_") {
num.append(line[i])
i = line.index(after: i)
}
var attr = AttributedString(num)
attr.foregroundColor = numberColor
return (attr, i)
}
private static func consumeWord(_ line: String, from start: String.Index, keywords: Set<String>) -> (AttributedString, String.Index) {
var i = start
var word = ""
while i < line.endIndex && (line[i].isLetter || line[i].isNumber || line[i] == "_" || line[i] == "@" || line[i] == "#") {
word.append(line[i])
i = line.index(after: i)
}
var attr = AttributedString(word)
if keywords.contains(word) {
attr.foregroundColor = keywordColor
} else if word.first?.isUppercase == true && word.count > 1 {
// Type-like identifier (capitalized)
attr.foregroundColor = typeColor
} else if i < line.endIndex && line[i] == "(" {
// Function call
attr.foregroundColor = functionColor
} else {
attr.foregroundColor = defaultColor
}
return (attr, i)
}
}

View File

@@ -0,0 +1,947 @@
//
// ChatViewModel.swift
// oAI
//
// Main chat view model
//
import Foundation
import os
import SwiftUI
@Observable
@MainActor
class ChatViewModel {
// MARK: - Observable State
var messages: [Message] = []
var inputText: String = ""
var isGenerating: Bool = false
var sessionStats = SessionStats()
var selectedModel: ModelInfo?
var currentProvider: Settings.Provider = .openrouter
var onlineMode: Bool = false
var memoryEnabled: Bool = true
var mcpEnabled: Bool = false
var mcpStatus: String? = nil
var availableModels: [ModelInfo] = []
var isLoadingModels: Bool = false
var showConversations: Bool = false
var showModelSelector: Bool = false
var showSettings: Bool = false
var showStats: Bool = false
var showHelp: Bool = false
var showCredits: Bool = false
var modelInfoTarget: ModelInfo? = nil
// 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
// MARK: - Initialization
init() {
// Load settings
self.currentProvider = settings.defaultProvider
self.onlineMode = settings.onlineMode
self.memoryEnabled = settings.memoryEnabled
self.mcpEnabled = settings.mcpEnabled
}
// MARK: - Public Methods
/// Switch to a different provider (from header dropdown)
func changeProvider(_ newProvider: Settings.Provider) {
guard newProvider != currentProvider else { return }
Log.ui.info("Switching provider to \(newProvider.rawValue)")
settings.defaultProvider = newProvider
currentProvider = newProvider
selectedModel = nil
availableModels = []
Task { await loadAvailableModels() }
}
/// Start a new conversation
func newConversation() {
messages = []
sessionStats = SessionStats()
inputText = ""
}
/// Re-sync local state from SettingsService (called when Settings sheet dismisses)
func syncFromSettings() {
let newProvider = settings.defaultProvider
let providerChanged = currentProvider != newProvider
currentProvider = newProvider
onlineMode = settings.onlineMode
memoryEnabled = settings.memoryEnabled
mcpEnabled = settings.mcpEnabled
mcpStatus = mcpEnabled ? "MCP" : nil
if providerChanged {
selectedModel = nil
availableModels = []
Task { await loadAvailableModels() }
}
}
func loadAvailableModels() async {
isLoadingModels = true
do {
guard let provider = providerRegistry.getCurrentProvider() else {
Log.ui.warning("No API key configured for current provider")
isLoadingModels = false
showSystemMessage("⚠️ No API key configured. Add your API key in Settings to load models.")
return
}
let models = try await provider.listModels()
availableModels = models
if selectedModel == nil, let firstModel = models.first {
selectedModel = firstModel
}
isLoadingModels = false
} catch {
Log.api.error("Failed to load models: \(error.localizedDescription)")
isLoadingModels = false
showSystemMessage("⚠️ Could not load models: \(error.localizedDescription)")
}
}
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)
inputText = ""
return
}
// Parse file attachments
let (cleanText, filePaths) = trimmedInput.parseFileAttachments()
// Read file attachments from disk
let attachments: [FileAttachment]? = filePaths.isEmpty ? nil : readFileAttachments(filePaths)
// Create user message
let userMessage = Message(
role: .user,
content: cleanText,
tokens: cleanText.estimateTokens(),
cost: nil,
timestamp: Date(),
attachments: attachments
)
messages.append(userMessage)
sessionStats.addMessage(inputTokens: userMessage.tokens, outputTokens: nil, cost: nil)
// Clear input
inputText = ""
// Add to command history
commandHistory.append(trimmedInput)
historyIndex = commandHistory.count
// Generate real AI response
generateAIResponse(to: cleanText, attachments: userMessage.attachments)
}
func cancelGeneration() {
streamingTask?.cancel()
streamingTask = nil
isGenerating = false
}
func clearChat() {
messages.removeAll()
sessionStats.reset()
showSystemMessage("Chat cleared")
}
func loadConversation(_ conversation: Conversation) {
do {
guard let (_, loadedMessages) = try DatabaseService.shared.loadConversation(id: conversation.id) else {
showSystemMessage("Could not load conversation '\(conversation.name)'")
return
}
messages.removeAll()
sessionStats.reset()
messages = loadedMessages
// Rebuild session stats from loaded messages
for msg in loadedMessages {
sessionStats.addMessage(
inputTokens: msg.role == .user ? msg.tokens : nil,
outputTokens: msg.role == .assistant ? msg.tokens : nil,
cost: msg.cost
)
}
showSystemMessage("Loaded conversation '\(conversation.name)'")
} catch {
showSystemMessage("Failed to load: \(error.localizedDescription)")
}
}
func retryLastMessage() {
guard let lastUserMessage = messages.last(where: { $0.role == .user }) else {
showSystemMessage("No previous message to retry")
return
}
// Remove last assistant response if exists
if let lastMessage = messages.last, lastMessage.role == .assistant {
messages.removeLast()
}
generateAIResponse(to: lastUserMessage.content, attachments: lastUserMessage.attachments)
}
// MARK: - Command Handling
private func handleCommand(_ command: String) {
guard let (cmd, args) = command.parseCommand() else {
showSystemMessage("Invalid command")
return
}
switch cmd.lowercased() {
case "/help":
showHelp = true
case "/model":
showModelSelector = true
case "/clear":
clearChat()
case "/retry":
retryLastMessage()
case "/memory":
if let arg = args.first?.lowercased() {
memoryEnabled = arg == "on"
showSystemMessage("Memory \(memoryEnabled ? "enabled" : "disabled")")
} else {
showSystemMessage("Usage: /memory on|off")
}
case "/online":
if let arg = args.first?.lowercased() {
onlineMode = arg == "on"
showSystemMessage("Online mode \(onlineMode ? "enabled" : "disabled")")
} else {
showSystemMessage("Usage: /online on|off")
}
case "/stats":
showStats = true
case "/config", "/settings":
showSettings = true
case "/provider":
if let providerName = args.first?.lowercased() {
if let provider = Settings.Provider.allCases.first(where: { $0.rawValue == providerName }) {
currentProvider = provider
showSystemMessage("Switched to \(provider.displayName) provider")
} else {
showSystemMessage("Unknown provider: \(providerName)")
}
} else {
showSystemMessage("Current provider: \(currentProvider.displayName)")
}
case "/save":
if let name = args.first {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else {
showSystemMessage("Nothing to save — no messages in this conversation")
return
}
do {
let _ = try DatabaseService.shared.saveConversation(name: name, messages: chatMessages)
showSystemMessage("Conversation saved as '\(name)'")
} catch {
showSystemMessage("Failed to save: \(error.localizedDescription)")
}
} else {
showSystemMessage("Usage: /save <name>")
}
case "/load", "/list":
showConversations = true
case "/delete":
if let name = args.first {
do {
let deleted = try DatabaseService.shared.deleteConversation(name: name)
if deleted {
showSystemMessage("Deleted conversation '\(name)'")
} else {
showSystemMessage("No conversation found with name '\(name)'")
}
} catch {
showSystemMessage("Failed to delete: \(error.localizedDescription)")
}
} else {
showSystemMessage("Usage: /delete <name>")
}
case "/export":
if args.count >= 1 {
let format = args[0].lowercased()
let filename = args.count >= 2 ? args[1] : "conversation.\(format)"
exportConversation(format: format, filename: filename)
} else {
showSystemMessage("Usage: /export md|json <filename>")
}
case "/info":
if let modelId = args.first {
if let model = availableModels.first(where: { $0.id == modelId || $0.name.lowercased() == modelId.lowercased() }) {
showModelInfo(model)
} else {
showSystemMessage("Model not found: \(modelId)")
}
} else if let model = selectedModel {
showModelInfo(model)
} else {
showSystemMessage("No model selected")
}
case "/credits":
showCredits = true
case "/mcp":
handleMCPCommand(args: args)
default:
showSystemMessage("Unknown command: \(cmd)\nType /help for available commands")
}
}
// MARK: - AI Response Generation
private func generateAIResponse(to prompt: String, attachments: [FileAttachment]?) {
// Get provider
guard let provider = providerRegistry.getCurrentProvider() else {
Log.ui.warning("Cannot generate: no API key configured")
showSystemMessage("❌ No API key configured. Please add your API key in Settings.")
return
}
guard let modelId = selectedModel?.id else {
Log.ui.warning("Cannot generate: no model selected")
showSystemMessage("❌ No model selected. Please select a model first.")
return
}
Log.ui.info("Sending message: model=\(modelId), messages=\(self.messages.count)")
// Dispatch to tool-aware path when MCP is enabled with folders
// Skip for image generation models they don't support tool calling
let mcp = MCPService.shared
let mcpActive = mcpEnabled || settings.mcpEnabled
let modelSupportTools = selectedModel?.capabilities.tools ?? false
if mcpActive && !mcp.allowedFolders.isEmpty && modelSupportTools {
generateAIResponseWithTools(provider: provider, modelId: modelId)
return
}
isGenerating = true
// Cancel any existing task
streamingTask?.cancel()
// Start streaming
streamingTask = Task {
do {
// Create empty assistant message for streaming
let assistantMessage = Message(
role: .assistant,
content: "",
tokens: nil,
cost: nil,
timestamp: Date(),
attachments: nil,
isStreaming: true
)
// Already on MainActor
messages.append(assistantMessage)
// Build chat request AFTER adding the assistant message
// Only include messages up to (but not including) the streaming assistant message
var messagesToSend = Array(messages.dropLast()) // Remove the empty assistant message
// Web search via our WebSearchService (skip Anthropic uses native search tool)
// Append results to last user message content (matching Python oAI approach)
if onlineMode && currentProvider != .anthropic && currentProvider != .openrouter && !messagesToSend.isEmpty {
if let lastUserIdx = messagesToSend.lastIndex(where: { $0.role == .user }) {
Log.search.info("Running web search for \(currentProvider.displayName)")
let results = await WebSearchService.shared.search(query: messagesToSend[lastUserIdx].content)
if !results.isEmpty {
let searchContext = "\n\n\(WebSearchService.shared.formatResults(results))\n\nPlease use the above web search results to help answer the user's question."
messagesToSend[lastUserIdx].content += searchContext
Log.search.info("Injected \(results.count) search results into user message")
}
}
}
let isImageGen = selectedModel?.capabilities.imageGeneration ?? false
if isImageGen {
Log.ui.info("Image generation mode for model \(modelId)")
}
let chatRequest = ChatRequest(
messages: Array(memoryEnabled ? messagesToSend : [messagesToSend.last!]),
model: modelId,
stream: settings.streamEnabled,
maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
temperature: settings.temperature > 0 ? settings.temperature : nil,
topP: nil,
systemPrompt: nil,
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
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = "Generating image..."
}
let nonStreamRequest = ChatRequest(
messages: chatRequest.messages,
model: chatRequest.model,
stream: false,
maxTokens: chatRequest.maxTokens,
temperature: chatRequest.temperature,
imageGeneration: true
)
let response = try await provider.chat(request: nonStreamRequest)
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = response.content
messages[index].isStreaming = false
messages[index].generatedImages = response.generatedImages
if let usage = response.usage {
messages[index].tokens = usage.completionTokens
if let model = selectedModel {
let cost = (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
}
}
} else {
// Regular text: stream response
var fullContent = ""
var totalTokens: ChatResponse.Usage? = nil
for try await chunk in provider.streamChat(request: chatRequest) {
if Task.isCancelled { break }
if let content = chunk.deltaContent {
fullContent += content
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = fullContent
}
}
if let usage = chunk.usage {
totalTokens = usage
}
}
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = fullContent
messages[index].isStreaming = false
if let usage = totalTokens {
messages[index].tokens = usage.completionTokens
if let model = selectedModel {
let cost = (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
}
}
}
isGenerating = false
streamingTask = nil
} catch {
// Remove the empty streaming message
if let index = messages.lastIndex(where: { $0.role == .assistant && $0.content.isEmpty }) {
messages.remove(at: index)
}
Log.api.error("Generation failed: \(error.localizedDescription)")
showSystemMessage("\(friendlyErrorMessage(from: error))")
isGenerating = false
streamingTask = nil
}
}
}
// MARK: - File Attachment Reading
private let maxFileSize: Int = 10 * 1024 * 1024 // 10 MB
private let maxTextSize: Int = 50 * 1024 // 50 KB before truncation
private func readFileAttachments(_ paths: [String]) -> [FileAttachment] {
var attachments: [FileAttachment] = []
let fm = FileManager.default
for rawPath in paths {
// Expand ~ and resolve path
let expanded = (rawPath as NSString).expandingTildeInPath
let resolvedPath = expanded.hasPrefix("/") ? expanded : (fm.currentDirectoryPath as NSString).appendingPathComponent(expanded)
// Check file exists
guard fm.fileExists(atPath: resolvedPath) else {
showSystemMessage("⚠️ File not found: \(rawPath)")
continue
}
// Check file size
guard let attrs = try? fm.attributesOfItem(atPath: resolvedPath),
let fileSize = attrs[.size] as? Int else {
showSystemMessage("⚠️ Cannot read file: \(rawPath)")
continue
}
if fileSize > maxFileSize {
let sizeMB = String(format: "%.1f", Double(fileSize) / 1_000_000)
showSystemMessage("⚠️ File too large (\(sizeMB) MB, max 10 MB): \(rawPath)")
continue
}
let type = FileAttachment.typeFromExtension(resolvedPath)
switch type {
case .image, .pdf:
// Read as raw data
guard let data = fm.contents(atPath: resolvedPath) else {
showSystemMessage("⚠️ Could not read file: \(rawPath)")
continue
}
attachments.append(FileAttachment(path: rawPath, type: type, data: data))
case .text:
// Read as string
guard let content = try? String(contentsOfFile: resolvedPath, encoding: .utf8) else {
showSystemMessage("⚠️ Could not read file as text: \(rawPath)")
continue
}
var finalContent = content
// Truncate large text files
if content.utf8.count > maxTextSize {
let lines = content.components(separatedBy: "\n")
if lines.count > 600 {
let head = lines.prefix(500).joined(separator: "\n")
let tail = lines.suffix(100).joined(separator: "\n")
let omitted = lines.count - 600
finalContent = head + "\n\n... [\(omitted) lines omitted] ...\n\n" + tail
}
}
attachments.append(FileAttachment(path: rawPath, type: .text, data: finalContent.data(using: .utf8)))
}
}
return attachments
}
// MARK: - MCP Command Handling
private func handleMCPCommand(args: [String]) {
let mcp = MCPService.shared
guard let sub = args.first?.lowercased() else {
showSystemMessage("Usage: /mcp on|off|status|add|remove|list")
return
}
switch sub {
case "on":
mcpEnabled = true
settings.mcpEnabled = true
mcpStatus = "MCP"
showSystemMessage("MCP enabled (\(mcp.allowedFolders.count) folder\(mcp.allowedFolders.count == 1 ? "" : "s") registered)")
case "off":
mcpEnabled = false
settings.mcpEnabled = false
mcpStatus = nil
showSystemMessage("MCP disabled")
case "add":
if args.count >= 2 {
let path = args.dropFirst().joined(separator: " ")
if let error = mcp.addFolder(path) {
showSystemMessage("MCP: \(error)")
} else {
showSystemMessage("MCP: Added folder — \(mcp.allowedFolders.count) folder\(mcp.allowedFolders.count == 1 ? "" : "s") registered")
}
} else {
showSystemMessage("Usage: /mcp add <path>")
}
case "remove":
if args.count >= 2 {
let ref = args.dropFirst().joined(separator: " ")
if let index = Int(ref) {
if mcp.removeFolder(at: index) {
showSystemMessage("MCP: Removed folder at index \(index)")
} else {
showSystemMessage("MCP: Invalid index \(index)")
}
} else {
if mcp.removeFolder(path: ref) {
showSystemMessage("MCP: Removed folder")
} else {
showSystemMessage("MCP: Folder not found: \(ref)")
}
}
} else {
showSystemMessage("Usage: /mcp remove <index|path>")
}
case "list":
if mcp.allowedFolders.isEmpty {
showSystemMessage("MCP: No folders registered. Use /mcp add <path>")
} else {
let list = mcp.allowedFolders.enumerated().map { "\($0): \($1)" }.joined(separator: "\n")
showSystemMessage("MCP folders:\n\(list)")
}
case "write":
guard args.count >= 2 else {
showSystemMessage("Usage: /mcp write on|off")
return
}
let toggle = args[1].lowercased()
if toggle == "on" {
settings.mcpCanWriteFiles = true
settings.mcpCanDeleteFiles = true
settings.mcpCanCreateDirectories = true
settings.mcpCanMoveFiles = true
showSystemMessage("MCP: All write permissions enabled (write, edit, delete, create dirs, move, copy)")
} else if toggle == "off" {
settings.mcpCanWriteFiles = false
settings.mcpCanDeleteFiles = false
settings.mcpCanCreateDirectories = false
settings.mcpCanMoveFiles = false
showSystemMessage("MCP: All write permissions disabled")
} else {
showSystemMessage("Usage: /mcp write on|off")
}
case "status":
let enabled = mcpEnabled ? "enabled" : "disabled"
let folders = mcp.allowedFolders.count
var perms: [String] = []
if settings.mcpCanWriteFiles { perms.append("write") }
if settings.mcpCanDeleteFiles { perms.append("delete") }
if settings.mcpCanCreateDirectories { perms.append("mkdir") }
if settings.mcpCanMoveFiles { perms.append("move/copy") }
let permStr = perms.isEmpty ? "read-only" : "read + \(perms.joined(separator: ", "))"
showSystemMessage("MCP: \(enabled), \(folders) folder\(folders == 1 ? "" : "s"), \(permStr), gitignore: \(settings.mcpRespectGitignore ? "on" : "off")")
default:
showSystemMessage("MCP subcommands: on, off, status, add, remove, list, write")
}
}
// MARK: - AI Response with Tool Calls
private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
let mcp = MCPService.shared
isGenerating = true
streamingTask?.cancel()
streamingTask = Task {
do {
let tools = mcp.getToolSchemas()
// Apply :online suffix for OpenRouter when online mode is active
var effectiveModelId = modelId
if onlineMode && currentProvider == .openrouter && !modelId.hasSuffix(":online") {
effectiveModelId = modelId + ":online"
}
// Build initial messages as raw dictionaries for the tool loop
let folderList = mcp.allowedFolders.joined(separator: "\n - ")
var capabilities = "You can read files, list directories, and search for files."
var writeCapabilities: [String] = []
if mcp.canWriteFiles { writeCapabilities.append("write and edit files") }
if mcp.canDeleteFiles { writeCapabilities.append("delete files") }
if mcp.canCreateDirectories { writeCapabilities.append("create directories") }
if mcp.canMoveFiles { writeCapabilities.append("move and copy files") }
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 messagesToSend: [Message] = memoryEnabled
? messages.filter { $0.role != .system }
: [messages.last(where: { $0.role == .user })].compactMap { $0 }
// Web search via our WebSearchService (skip Anthropic uses native search tool)
// Append results to last user message content (matching Python oAI approach)
if onlineMode && currentProvider != .anthropic && currentProvider != .openrouter {
if let lastUserIdx = messagesToSend.lastIndex(where: { $0.role == .user }) {
Log.search.info("Running web search for tool-aware path (\(currentProvider.displayName))")
let results = await WebSearchService.shared.search(query: messagesToSend[lastUserIdx].content)
if !results.isEmpty {
let searchContext = "\n\n\(WebSearchService.shared.formatResults(results))\n\nPlease use the above web search results to help answer the user's question."
messagesToSend[lastUserIdx].content += searchContext
Log.search.info("Injected \(results.count) search results into user message")
}
}
}
let systemPrompt: [String: Any] = [
"role": "system",
"content": systemContent
]
var apiMessages: [[String: Any]] = [systemPrompt] + messagesToSend.map { msg in
["role": msg.role.rawValue, "content": msg.content]
}
let maxIterations = 5
var finalContent = ""
var totalUsage: ChatResponse.Usage?
for iteration in 0..<maxIterations {
if Task.isCancelled { break }
let response = try await provider.chatWithToolMessages(
model: effectiveModelId,
messages: apiMessages,
tools: tools,
maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil,
temperature: settings.temperature > 0 ? settings.temperature : nil
)
if let usage = response.usage { totalUsage = usage }
// Check if the model wants to call tools
guard let toolCalls = response.toolCalls, !toolCalls.isEmpty else {
// No tool calls this is the final text response
finalContent = response.content
break
}
// Show what tools the model is calling
let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
showSystemMessage("🔧 Calling: \(toolNames)")
// Append assistant message with tool_calls to conversation
var assistantMsg: [String: Any] = ["role": "assistant"]
if !response.content.isEmpty {
assistantMsg["content"] = response.content
}
let toolCallDicts: [[String: Any]] = toolCalls.map { tc in
[
"id": tc.id,
"type": tc.type,
"function": [
"name": tc.functionName,
"arguments": tc.arguments
]
]
}
assistantMsg["tool_calls"] = toolCallDicts
apiMessages.append(assistantMsg)
// Execute each tool and append results
for tc in toolCalls {
if Task.isCancelled { break }
let result = mcp.executeTool(name: tc.functionName, arguments: tc.arguments)
let resultJSON: String
if let data = try? JSONSerialization.data(withJSONObject: result),
let str = String(data: data, encoding: .utf8) {
resultJSON = str
} else {
resultJSON = "{\"error\": \"Failed to serialize result\"}"
}
apiMessages.append([
"role": "tool",
"tool_call_id": tc.id,
"name": tc.functionName,
"content": resultJSON
])
}
// If this was the last iteration, note it
if iteration == maxIterations - 1 {
finalContent = response.content.isEmpty
? "[Tool loop reached maximum iterations]"
: response.content
}
}
// Display the final response as an assistant message
let assistantMessage = Message(
role: .assistant,
content: finalContent,
tokens: totalUsage?.completionTokens,
cost: nil,
timestamp: Date()
)
messages.append(assistantMessage)
// Calculate cost
if let usage = totalUsage, let model = selectedModel {
let cost = (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
messages[index].cost = cost
}
sessionStats.addMessage(
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
cost: cost
)
}
isGenerating = false
streamingTask = nil
} catch {
Log.api.error("Tool generation failed: \(error.localizedDescription)")
showSystemMessage("\(friendlyErrorMessage(from: error))")
isGenerating = false
streamingTask = nil
}
}
}
private func showSystemMessage(_ text: String) {
let message = Message(
role: .system,
content: text,
tokens: nil,
cost: nil,
timestamp: Date(),
attachments: nil
)
messages.append(message)
}
// MARK: - Error Helpers
private func friendlyErrorMessage(from error: Error) -> String {
let desc = error.localizedDescription
// Network connectivity
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost:
return "Unable to reach the server. Check your internet connection."
case .timedOut:
return "Request timed out. Try a shorter message or different model."
case .cannotFindHost, .cannotConnectToHost:
return "Cannot connect to the server. Check your network or provider URL."
default:
break
}
}
// HTTP status codes in error messages
if desc.contains("401") || desc.contains("403") || desc.lowercased().contains("unauthorized") || desc.lowercased().contains("invalid.*key") {
return "Invalid API key. Update it in Settings (\u{2318},)."
}
if desc.contains("429") || desc.lowercased().contains("rate limit") {
return "Rate limited. Wait a moment and try again."
}
if desc.contains("404") || desc.lowercased().contains("model not found") || desc.lowercased().contains("not available") {
return "Model not available. Select a different model (\u{2318}M)."
}
if desc.contains("500") || desc.contains("502") || desc.contains("503") {
return "Server error. The provider may be experiencing issues. Try again shortly."
}
// Timeout patterns
if desc.lowercased().contains("timed out") || desc.lowercased().contains("timeout") {
return "Request timed out. Try a shorter message or different model."
}
// Fallback
return desc
}
// MARK: - Helpers
private func showModelInfo(_ model: ModelInfo) {
modelInfoTarget = model
}
private func exportConversation(format: String, filename: String) {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else {
showSystemMessage("Nothing to export — no messages")
return
}
let content: String
switch format {
case "md", "markdown":
content = chatMessages.map { msg in
let header = msg.role == .user ? "**User**" : "**Assistant**"
return "\(header)\n\n\(msg.content)"
}.joined(separator: "\n\n---\n\n")
case "json":
let dicts = chatMessages.map { msg -> [String: String] in
["role": msg.role.rawValue, "content": msg.content]
}
if let data = try? JSONSerialization.data(withJSONObject: dicts, options: .prettyPrinted),
let json = String(data: data, encoding: .utf8) {
content = json
} else {
showSystemMessage("Failed to encode JSON")
return
}
default:
showSystemMessage("Unsupported format: \(format). Use md or json.")
return
}
// Write to Downloads folder
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let fileURL = downloads.appendingPathComponent(filename)
do {
try content.write(to: fileURL, atomically: true, encoding: .utf8)
showSystemMessage("Exported to \(fileURL.path)")
} catch {
showSystemMessage("Export failed: \(error.localizedDescription)")
}
}
}

View File

@@ -0,0 +1,80 @@
//
// ChatView.swift
// oAI
//
// Main chat interface
//
import SwiftUI
struct ChatView: View {
@Environment(ChatViewModel.self) var viewModel
let onModelSelect: () -> Void
let onProviderChange: (Settings.Provider) -> Void
var body: some View {
@Bindable var viewModel = viewModel
VStack(spacing: 0) {
// Header
HeaderView(
provider: viewModel.currentProvider,
model: viewModel.selectedModel,
stats: viewModel.sessionStats,
onlineMode: viewModel.onlineMode,
mcpEnabled: viewModel.mcpEnabled,
mcpStatus: viewModel.mcpStatus,
onModelSelect: onModelSelect,
onProviderChange: onProviderChange
)
// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(viewModel.messages) { message in
MessageRow(message: message)
.id(message.id)
}
// Invisible bottom anchor for auto-scroll
Color.clear
.frame(height: 1)
.id("bottom")
}
.padding()
}
.background(Color.oaiBackground)
.onChange(of: viewModel.messages.count) {
withAnimation {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.onChange(of: viewModel.messages.last?.content) {
// Auto-scroll as streaming content arrives
if viewModel.isGenerating {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
// Input bar
InputBar(
text: $viewModel.inputText,
isGenerating: viewModel.isGenerating,
mcpStatus: viewModel.mcpStatus,
onlineMode: viewModel.onlineMode,
onSend: viewModel.sendMessage,
onCancel: viewModel.cancelGeneration
)
// Footer
FooterView(stats: viewModel.sessionStats)
}
.background(Color.oaiBackground)
}
}
#Preview {
ChatView(onModelSelect: {}, onProviderChange: { _ in })
.environment(ChatViewModel())
}

View File

@@ -0,0 +1,150 @@
//
// ContentView.swift
// oAI
//
// Root navigation container
//
import SwiftUI
struct ContentView: View {
@Environment(ChatViewModel.self) var chatViewModel
var body: some View {
@Bindable var vm = chatViewModel
NavigationStack {
ChatView(
onModelSelect: { chatViewModel.showModelSelector = true },
onProviderChange: { newProvider in
chatViewModel.changeProvider(newProvider)
}
)
.navigationTitle("")
.toolbar {
#if os(macOS)
macOSToolbar
#endif
}
}
.frame(minWidth: 600, minHeight: 400)
#if os(macOS)
.onKeyPress(.return, phases: .down) { press in
if press.modifiers.contains(.command) {
chatViewModel.sendMessage()
return .handled
}
return .ignored
}
#endif
.sheet(isPresented: $vm.showModelSelector) {
ModelSelectorView(
models: chatViewModel.availableModels,
selectedModel: chatViewModel.selectedModel,
onSelect: { model in
chatViewModel.selectedModel = model
chatViewModel.showModelSelector = false
}
)
.task {
if chatViewModel.availableModels.count <= 10 {
await chatViewModel.loadAvailableModels()
}
}
}
.sheet(isPresented: $vm.showSettings, onDismiss: {
chatViewModel.syncFromSettings()
}) {
SettingsView()
}
.sheet(isPresented: $vm.showStats) {
StatsView(
stats: chatViewModel.sessionStats,
model: chatViewModel.selectedModel,
provider: chatViewModel.currentProvider
)
}
.sheet(isPresented: $vm.showHelp) {
HelpView()
}
.sheet(isPresented: $vm.showCredits) {
CreditsView(provider: chatViewModel.currentProvider)
}
.sheet(isPresented: $vm.showConversations) {
ConversationListView(onLoad: { conversation in
chatViewModel.loadConversation(conversation)
})
}
.sheet(item: $vm.modelInfoTarget) { model in
ModelInfoView(model: model)
}
}
#if os(macOS)
@ToolbarContentBuilder
private var macOSToolbar: some ToolbarContent {
ToolbarItemGroup(placement: .automatic) {
// New conversation
Button(action: { chatViewModel.newConversation() }) {
Label("New Chat", systemImage: "square.and.pencil")
}
.keyboardShortcut("n", modifiers: .command)
.help("New conversation")
Button(action: { chatViewModel.showConversations = true }) {
Label("History", systemImage: "clock.arrow.circlepath")
}
.keyboardShortcut("l", modifiers: .command)
.help("Saved conversations (Cmd+L)")
Spacer()
Button(action: { chatViewModel.showModelSelector = true }) {
Label("Model", systemImage: "cpu")
}
.keyboardShortcut("m", modifiers: .command)
.help("Select AI model (Cmd+M)")
Button(action: {
if let model = chatViewModel.selectedModel {
chatViewModel.modelInfoTarget = model
}
}) {
Label("Model Info", systemImage: "info.circle")
}
.keyboardShortcut("i", modifiers: .command)
.help("Model info (Cmd+I)")
.disabled(chatViewModel.selectedModel == nil)
Button(action: { chatViewModel.showStats = true }) {
Label("Stats", systemImage: "chart.bar")
}
.keyboardShortcut("s", modifiers: .command)
.help("Session statistics (Cmd+S)")
Button(action: { chatViewModel.showCredits = true }) {
Label("Credits", systemImage: "creditcard")
}
.help("Check API credits")
Spacer()
Button(action: { chatViewModel.showSettings = true }) {
Label("Settings", systemImage: "gearshape")
}
.keyboardShortcut(",", modifiers: .command)
.help("Settings (Cmd+,)")
Button(action: { chatViewModel.showHelp = true }) {
Label("Help", systemImage: "questionmark.circle")
}
.keyboardShortcut("/", modifiers: .command)
.help("Help & commands (Cmd+/)")
}
}
#endif
}
#Preview {
ContentView()
.environment(ChatViewModel())
}

View File

@@ -0,0 +1,91 @@
//
// FooterView.swift
// oAI
//
// Footer bar with session summary
//
import SwiftUI
struct FooterView: View {
let stats: SessionStats
var body: some View {
HStack(spacing: 20) {
// Session summary
HStack(spacing: 16) {
FooterItem(
icon: "message",
label: "Messages",
value: "\(stats.messageCount)"
)
FooterItem(
icon: "chart.bar.xaxis",
label: "Tokens",
value: "\(stats.totalTokens) (\(stats.totalInputTokens) in, \(stats.totalOutputTokens) out)"
)
FooterItem(
icon: "dollarsign.circle",
label: "Cost",
value: stats.totalCostDisplay
)
}
Spacer()
// Shortcuts hint
#if os(macOS)
Text("⌘M Model • ⌘K Clear • ⌘S Stats")
.font(.caption2)
.foregroundColor(.oaiSecondary)
#endif
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
alignment: .top
)
}
}
struct FooterItem: View {
let icon: String
let label: String
let value: String
private let guiSize = SettingsService.shared.guiTextSize
var body: some View {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: guiSize - 2))
.foregroundColor(.oaiSecondary)
Text(label + ":")
.font(.system(size: guiSize - 2))
.foregroundColor(.oaiSecondary)
Text(value)
.font(.system(size: guiSize - 2, weight: .medium))
.foregroundColor(.oaiPrimary)
}
}
}
#Preview {
VStack {
Spacer()
FooterView(stats: SessionStats(
totalInputTokens: 1250,
totalOutputTokens: 3420,
totalCost: 0.0152,
messageCount: 12
))
}
.background(Color.oaiBackground)
}

View File

@@ -0,0 +1,209 @@
//
// HeaderView.swift
// oAI
//
// Header bar with provider, model, and stats
//
import SwiftUI
struct HeaderView: View {
let provider: Settings.Provider
let model: ModelInfo?
let stats: SessionStats
let onlineMode: Bool
let mcpEnabled: Bool
let mcpStatus: String?
let onModelSelect: () -> Void
let onProviderChange: (Settings.Provider) -> Void
private let settings = SettingsService.shared
private let registry = ProviderRegistry.shared
var body: some View {
HStack(spacing: 12) {
// Provider picker dropdown only shows configured providers
Menu {
ForEach(registry.configuredProviders, id: \.self) { p in
Button {
onProviderChange(p)
} label: {
HStack {
Image(systemName: p.iconName)
Text(p.displayName)
if p == provider {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: provider.iconName)
.font(.system(size: settings.guiTextSize - 2))
Text(provider.displayName)
.font(.system(size: settings.guiTextSize - 2, weight: .medium))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8))
.opacity(0.7)
}
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.providerColor(provider))
.cornerRadius(4)
}
.menuStyle(.borderlessButton)
.fixedSize()
.help("Switch provider")
// Model info (clickable model selector)
Button(action: onModelSelect) {
if let model = model {
HStack(spacing: 6) {
Text(model.name)
.font(.system(size: settings.guiTextSize, weight: .medium))
.foregroundColor(.oaiPrimary)
// Capability badges
HStack(spacing: 3) {
if model.capabilities.vision {
Image(systemName: "eye")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
if model.capabilities.tools {
Image(systemName: "wrench")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
if model.capabilities.online {
Image(systemName: "globe")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
if model.capabilities.imageGeneration {
Image(systemName: "paintbrush")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
}
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
} else {
HStack(spacing: 4) {
Text("No model selected")
.font(.system(size: settings.guiTextSize))
.foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
}
}
.buttonStyle(.plain)
.help("Select model")
Spacer()
// Status indicators
HStack(spacing: 8) {
if model?.capabilities.imageGeneration == true {
StatusPill(icon: "paintbrush", label: "Image", color: .purple)
}
if onlineMode {
StatusPill(icon: "globe", label: "Online", color: .green)
}
if mcpEnabled {
StatusPill(icon: "folder", label: "MCP", color: .blue)
}
}
// Divider between status and stats
if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true {
Divider()
.frame(height: 16)
.opacity(0.5)
}
// Quick stats
HStack(spacing: 16) {
StatItem(icon: "message", value: "\(stats.messageCount)")
StatItem(icon: "arrow.up.arrow.down", value: stats.totalTokensDisplay)
StatItem(icon: "dollarsign", value: stats.totalCostDisplay)
}
.font(.caption)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
alignment: .bottom
)
}
}
struct StatItem: View {
let icon: String
let value: String
private let settings = SettingsService.shared
var body: some View {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: settings.guiTextSize - 3))
.foregroundColor(.oaiSecondary)
Text(value)
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
.foregroundColor(.oaiPrimary)
}
}
}
struct StatusPill: View {
let icon: String
let label: String
let color: Color
var body: some View {
HStack(spacing: 3) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
Text(label)
.font(.system(size: 10, weight: .medium))
.foregroundColor(.oaiSecondary)
}
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.1), in: Capsule())
}
}
#Preview {
VStack {
HeaderView(
provider: .openrouter,
model: ModelInfo.mockModels.first,
stats: SessionStats(
totalInputTokens: 125,
totalOutputTokens: 434,
totalCost: 0.00111,
messageCount: 4
),
onlineMode: true,
mcpEnabled: true,
mcpStatus: "MCP",
onModelSelect: {},
onProviderChange: { _ in }
)
Spacer()
}
.background(Color.oaiBackground)
}

View File

@@ -0,0 +1,327 @@
//
// InputBar.swift
// oAI
//
// Message input bar with status indicators
//
import SwiftUI
struct InputBar: View {
@Binding var text: String
let isGenerating: Bool
let mcpStatus: String?
let onlineMode: Bool
let onSend: () -> Void
let onCancel: () -> Void
private let settings = SettingsService.shared
@State private var showCommandDropdown = false
@State private var selectedSuggestionIndex: Int = 0
@FocusState private var isInputFocused: Bool
/// Commands that execute immediately without additional arguments
private static let immediateCommands: Set<String> = [
"/help", "/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",
"/mcp write on", "/mcp write off",
"/export md", "/export json",
]
var body: some View {
VStack(spacing: 0) {
// Command dropdown (if showing)
if showCommandDropdown && text.hasPrefix("/") {
CommandSuggestionsView(
searchText: text,
selectedIndex: selectedSuggestionIndex,
onSelect: { command in
selectCommand(command)
}
)
.frame(maxHeight: 200)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// Input area
HStack(alignment: .bottom, spacing: 12) {
// Status indicators
HStack(spacing: 6) {
if let mcp = mcpStatus {
StatusBadge(text: mcp, color: .blue)
}
if onlineMode {
StatusBadge(text: "🌐", color: .green)
}
}
.frame(width: 80, alignment: .leading)
// Text input
ZStack(alignment: .topLeading) {
if text.isEmpty {
Text("Type a message or / for commands...")
.font(.system(size: settings.inputTextSize))
.foregroundColor(.oaiSecondary)
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
TextEditor(text: $text)
.font(.system(size: settings.inputTextSize))
.foregroundColor(.oaiPrimary)
.scrollContentBackground(.hidden)
.frame(minHeight: 44, maxHeight: 120)
.padding(.horizontal, 8)
.padding(.vertical, 6)
.focused($isInputFocused)
.onChange(of: text) {
showCommandDropdown = text.hasPrefix("/")
selectedSuggestionIndex = 0
}
#if os(macOS)
.onKeyPress(.upArrow) {
guard showCommandDropdown else { return .ignored }
if selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1
}
return .handled
}
.onKeyPress(.downArrow) {
guard showCommandDropdown else { return .ignored }
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
selectedSuggestionIndex += 1
}
return .handled
}
.onKeyPress(.escape) {
if showCommandDropdown {
showCommandDropdown = false
return .handled
}
return .ignored
}
.onKeyPress(.return) {
// If command dropdown is showing, select the highlighted command
if showCommandDropdown {
let suggestions = CommandSuggestionsView.filteredCommands(for: text)
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
selectCommand(suggestions[selectedSuggestionIndex].command)
return .handled
}
}
// Plain Return on single line: send
if !text.contains("\n") && !text.isEmpty {
onSend()
return .handled
}
// Otherwise: let system handle (insert newline)
return .ignored
}
#endif
}
.background(Color.oaiSurface)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
)
// Action buttons
VStack(spacing: 8) {
#if os(macOS)
// File attach button
Button(action: pickFile) {
Image(systemName: "paperclip")
.font(.title2)
.foregroundColor(.oaiPrimary.opacity(0.7))
}
.buttonStyle(.plain)
.help("Attach file")
#endif
if isGenerating {
Button(action: onCancel) {
Image(systemName: "stop.circle.fill")
.font(.title)
.foregroundColor(.oaiError.opacity(0.9))
}
.buttonStyle(.plain)
.help("Stop generation")
} else {
Button(action: onSend) {
Image(systemName: "arrow.up.circle.fill")
.font(.title)
.foregroundColor(text.isEmpty ? .oaiPrimary.opacity(0.4) : .oaiAccent.opacity(0.9))
}
.buttonStyle(.plain)
.disabled(text.isEmpty)
.help("Send message")
}
}
.frame(width: 40)
}
.padding()
.background(Color.oaiSurface)
}
.onAppear {
isInputFocused = true
}
}
private func selectCommand(_ command: String) {
showCommandDropdown = false
if Self.immediateCommands.contains(command) {
// Execute immediately
text = command
onSend()
} else {
// Put in input for user to complete
text = command + " "
}
}
#if os(macOS)
private func pickFile() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.message = "Select files to attach"
guard panel.runModal() == .OK else { return }
let paths = panel.urls.map { $0.path }
let attachmentText = paths.map { "@\($0)" }.joined(separator: " ")
if text.isEmpty {
text = attachmentText + " "
} else {
text += " " + attachmentText
}
}
#endif
}
struct StatusBadge: View {
let text: String
let color: Color
var body: some View {
Text(text)
.font(.caption)
.foregroundColor(color)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(color.opacity(0.15))
.cornerRadius(4)
}
}
struct CommandSuggestionsView: View {
let searchText: String
let selectedIndex: Int
let onSelect: (String) -> Void
static let allCommands: [(command: String, description: String)] = [
("/help", "Show help and available commands"),
("/model", "Select AI model"),
("/clear", "Clear chat history"),
("/retry", "Retry last message"),
("/memory on", "Enable conversation memory"),
("/memory off", "Disable conversation memory"),
("/online on", "Enable web search"),
("/online off", "Disable web search"),
("/stats", "Show session statistics"),
("/config", "Open settings"),
("/provider", "Switch AI provider"),
("/save", "Save conversation"),
("/load", "Load conversation"),
("/list", "List saved conversations"),
("/export md", "Export as Markdown"),
("/export json", "Export as JSON"),
("/info", "Show model information"),
("/credits", "Check account credits"),
("/mcp on", "Enable MCP (file access)"),
("/mcp off", "Disable MCP"),
("/mcp status", "Show MCP status"),
("/mcp list", "List MCP folders"),
("/mcp add", "Add folder for MCP"),
("/mcp write on", "Enable MCP write permissions"),
("/mcp write off", "Disable MCP write permissions"),
]
static func filteredCommands(for searchText: String) -> [(command: String, description: String)] {
let search = searchText.lowercased()
return allCommands.filter { $0.command.contains(search) || search == "/" }
}
private var suggestions: [(command: String, description: String)] {
Self.filteredCommands(for: searchText)
}
var body: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 0) {
ForEach(Array(suggestions.enumerated()), id: \.element.command) { index, suggestion in
Button(action: { onSelect(suggestion.command) }) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(suggestion.command)
.font(.body)
.foregroundColor(.oaiPrimary)
Text(suggestion.description)
.font(.caption)
.foregroundColor(.oaiSecondary)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(index == selectedIndex ? Color.oaiAccent.opacity(0.2) : Color.oaiSurface)
}
.buttonStyle(.plain)
.id(suggestion.command)
if index < suggestions.count - 1 {
Divider()
.background(Color.oaiBorder)
}
}
}
}
.onChange(of: selectedIndex) {
if selectedIndex < suggestions.count {
withAnimation {
proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center)
}
}
}
}
.background(Color.oaiSurface)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.oaiBorder, lineWidth: 1)
)
.padding(.horizontal)
}
}
#Preview {
VStack {
Spacer()
InputBar(
text: .constant(""),
isGenerating: false,
mcpStatus: "📁 Files",
onlineMode: true,
onSend: {},
onCancel: {}
)
}
.background(Color.oaiBackground)
}

View File

@@ -0,0 +1,183 @@
//
// MarkdownContentView.swift
// oAI
//
// Renders markdown content with syntax-highlighted code blocks
//
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
struct MarkdownContentView: View {
let content: String
let fontSize: Double
var body: some View {
let segments = parseSegments(content)
VStack(alignment: .leading, spacing: 8) {
ForEach(segments.indices, id: \.self) { index in
switch segments[index] {
case .text(let text):
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
markdownText(text)
}
case .codeBlock(let language, let code):
CodeBlockView(language: language, code: code, fontSize: fontSize)
}
}
}
}
@ViewBuilder
private func markdownText(_ text: String) -> some View {
if let attrString = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
Text(attrString)
.font(.system(size: fontSize))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
} else {
Text(text)
.font(.system(size: fontSize))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
}
// MARK: - Parsing
enum Segment {
case text(String)
case codeBlock(language: String?, code: String)
}
private func parseSegments(_ content: String) -> [Segment] {
var segments: [Segment] = []
let lines = content.components(separatedBy: "\n")
var currentText = ""
var inCodeBlock = false
var codeLanguage: String? = nil
var codeContent = ""
for line in lines {
if !inCodeBlock && line.hasPrefix("```") {
// Start of code block
if !currentText.isEmpty {
segments.append(.text(currentText))
currentText = ""
}
inCodeBlock = true
let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
codeLanguage = lang.isEmpty ? nil : lang
codeContent = ""
} else if inCodeBlock && line.hasPrefix("```") {
// End of code block
// Remove trailing newline from code
if codeContent.hasSuffix("\n") {
codeContent = String(codeContent.dropLast())
}
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
inCodeBlock = false
codeLanguage = nil
codeContent = ""
} else if inCodeBlock {
codeContent += line + "\n"
} else {
currentText += line + "\n"
}
}
// Handle unclosed code block
if inCodeBlock {
if codeContent.hasSuffix("\n") {
codeContent = String(codeContent.dropLast())
}
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
}
// Remaining text
if !currentText.isEmpty {
// Remove trailing newline
if currentText.hasSuffix("\n") {
currentText = String(currentText.dropLast())
}
if !currentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
segments.append(.text(currentText))
}
}
return segments
}
}
// MARK: - Code Block View
struct CodeBlockView: View {
let language: String?
let code: String
let fontSize: Double
@State private var copied = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header bar with language label and copy button
HStack {
if let lang = language, !lang.isEmpty {
Text(lang)
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(Color(hex: "#888888"))
}
Spacer()
Button(action: copyCode) {
HStack(spacing: 4) {
Image(systemName: copied ? "checkmark" : "doc.on.doc")
.font(.system(size: 11))
if copied {
Text("Copied!")
.font(.system(size: 11))
}
}
.foregroundColor(copied ? .green : Color(hex: "#888888"))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(hex: "#2d2d2d"))
// Code content
ScrollView(.horizontal, showsIndicators: true) {
Text(SyntaxHighlighter.highlight(code: code, language: language))
.font(.system(size: fontSize - 1, design: .monospaced))
.textSelection(.enabled)
.padding(12)
}
}
.background(Color(hex: "#1e1e1e"))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(hex: "#3e3e3e"), lineWidth: 1)
)
}
private func copyCode() {
#if os(macOS)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(code, forType: .string)
#endif
withAnimation {
copied = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
copied = false
}
}
}
}

View File

@@ -0,0 +1,278 @@
//
// MessageRow.swift
// oAI
//
// Individual message display
//
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
struct MessageRow: View {
let message: Message
private let settings = SettingsService.shared
#if os(macOS)
@State private var isHovering = false
@State private var showCopied = false
#endif
var body: some View {
HStack(alignment: .top, spacing: 12) {
// Role icon
roleIcon
.frame(square: 32)
VStack(alignment: .leading, spacing: 8) {
// Header
HStack {
Text(message.role.displayName)
.font(.headline)
.foregroundColor(Color.messageColor(for: message.role))
Spacer()
#if os(macOS)
// Copy button (assistant messages only, visible on hover)
if message.role == .assistant && isHovering && !message.content.isEmpty {
Button(action: copyContent) {
HStack(spacing: 3) {
Image(systemName: showCopied ? "checkmark" : "doc.on.doc")
.font(.system(size: 11))
if showCopied {
Text("Copied!")
.font(.system(size: 11))
}
}
.foregroundColor(showCopied ? .green : .oaiSecondary)
}
.buttonStyle(.plain)
.transition(.opacity)
}
#endif
Text(message.timestamp, style: .time)
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
// Content
if !message.content.isEmpty {
messageContent
}
// Generated images
if let images = message.generatedImages, !images.isEmpty {
GeneratedImagesView(images: images)
}
// File attachments
if let attachments = message.attachments, !attachments.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(attachments.indices, id: \.self) { index in
HStack(spacing: 6) {
Image(systemName: "paperclip")
.font(.caption)
Text(attachments[index].path)
.font(.caption)
.foregroundColor(.oaiSecondary)
}
}
}
.padding(.top, 4)
}
// Token/cost info
if let tokens = message.tokens, let cost = message.cost {
HStack(spacing: 8) {
Label("\(tokens)", systemImage: "chart.bar.xaxis")
Text("\u{2022}")
Text(String(format: "$%.4f", cost))
}
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
}
}
.padding(12)
.background(Color.messageBackground(for: message.role))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(messageBorderColor, lineWidth: isErrorMessage ? 2 : 2)
)
#if os(macOS)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = hovering
}
}
#endif
}
// MARK: - Message Content
@ViewBuilder
private var messageContent: some View {
switch message.role {
case .assistant:
MarkdownContentView(content: message.content, fontSize: settings.dialogTextSize)
case .system:
if isErrorMessage {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.oaiError)
.font(.system(size: settings.dialogTextSize))
Text(message.content)
.font(.system(size: settings.dialogTextSize))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
} else {
Text(message.content)
.font(.system(size: settings.dialogTextSize))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
case .user:
MarkdownContentView(content: message.content, fontSize: settings.dialogTextSize)
}
}
// MARK: - Error Detection
private var isErrorMessage: Bool {
message.role == .system && message.content.hasPrefix("\u{274C}")
}
private var messageBorderColor: Color {
if isErrorMessage {
return .oaiError.opacity(0.5)
}
return Color.messageColor(for: message.role).opacity(0.3)
}
// MARK: - Copy
#if os(macOS)
private func copyContent() {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(message.content, forType: .string)
withAnimation {
showCopied = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
showCopied = false
}
}
}
#endif
private var roleIcon: some View {
Image(systemName: message.role.iconName)
.font(.title3)
.foregroundColor(Color.messageColor(for: message.role))
.frame(width: 32, height: 32)
.background(
Circle()
.fill(Color.messageColor(for: message.role).opacity(0.15))
)
}
}
// MARK: - Generated Images Display
struct GeneratedImagesView: View {
let images: [Data]
@State private var savedMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(images.indices, id: \.self) { index in
if let nsImage = platformImage(from: images[index]) {
VStack(alignment: .leading, spacing: 4) {
#if os(macOS)
Image(nsImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 512, maxHeight: 512)
.cornerRadius(8)
.shadow(color: .black.opacity(0.3), radius: 4)
.contextMenu {
Button("Save to Downloads") {
saveImage(data: images[index], index: index)
}
Button("Copy Image") {
copyImage(nsImage)
}
}
#else
Image(uiImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 512, maxHeight: 512)
.cornerRadius(8)
#endif
}
}
}
if let msg = savedMessage {
Text(msg)
.font(.caption2)
.foregroundColor(.green)
.transition(.opacity)
}
}
}
#if os(macOS)
private func platformImage(from data: Data) -> NSImage? {
NSImage(data: data)
}
private func saveImage(data: Data, index: Int) {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let timestamp = Int(Date().timeIntervalSince1970)
let filename = "oai_image_\(timestamp)_\(index).png"
let fileURL = downloads.appendingPathComponent(filename)
do {
try data.write(to: fileURL)
withAnimation { savedMessage = "Saved to \(filename)" }
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation { savedMessage = nil }
}
} catch {
withAnimation { savedMessage = "Failed to save: \(error.localizedDescription)" }
}
}
private func copyImage(_ image: NSImage) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.writeObjects([image])
}
#else
private func platformImage(from data: Data) -> UIImage? {
UIImage(data: data)
}
#endif
}
#Preview {
VStack(spacing: 12) {
MessageRow(message: Message.mockUser1)
MessageRow(message: Message.mockAssistant1)
MessageRow(message: Message.mockSystem)
}
.padding()
.background(Color.oaiBackground)
}

View File

@@ -0,0 +1,72 @@
//
// AboutView.swift
// oAI
//
// About modal with app icon and version info
//
import SwiftUI
struct AboutView: View {
@Environment(\.dismiss) var dismiss
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
}
var body: some View {
VStack(spacing: 16) {
Spacer().frame(height: 8)
Image("AppLogo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 128, height: 128)
.clipShape(RoundedRectangle(cornerRadius: 24))
.shadow(color: .cyan.opacity(0.3), radius: 12)
Text("oAI")
.font(.system(size: 28, weight: .bold))
Text("Version \(appVersion) (\(buildNumber))")
.font(.callout)
.foregroundStyle(.secondary)
Text("Multi-provider AI chat client")
.font(.subheadline)
.foregroundStyle(.secondary)
Divider()
.padding(.horizontal, 40)
VStack(spacing: 4) {
Text("&copy; 2026 [Rune Olsen](https://blog.rune.pm)")
.font(.caption)
.foregroundStyle(.secondary)
Text("Built with SwiftUI")
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer().frame(height: 4)
Button("OK") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.keyboardShortcut(.escape, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer().frame(height: 20)
}
.frame(width: 320, height: 370)
}
}
#Preview {
AboutView()
}

View File

@@ -0,0 +1,189 @@
//
// ConversationListView.swift
// oAI
//
// Saved conversations list
//
import os
import SwiftUI
struct ConversationListView: View {
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var conversations: [Conversation] = []
var onLoad: ((Conversation) -> Void)?
private var filteredConversations: [Conversation] {
if searchText.isEmpty {
return conversations
}
return conversations.filter {
$0.name.lowercased().contains(searchText.lowercased())
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Conversations")
.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 conversations...", 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 filteredConversations.isEmpty {
Spacer()
VStack(spacing: 8) {
Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass")
.font(.largeTitle)
.foregroundStyle(.tertiary)
Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches")
.font(.headline)
.foregroundStyle(.secondary)
Text(searchText.isEmpty ? "Conversations you save will appear here" : "Try a different search term")
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer()
} 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)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
exportConversation(conversation)
} label: {
Label("Export", systemImage: "square.and.arrow.up")
}
.tint(.blue)
}
}
}
.listStyle(.plain)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.onAppear {
loadConversations()
}
.frame(minWidth: 500, minHeight: 400)
}
private func loadConversations() {
do {
conversations = try DatabaseService.shared.listConversations()
} catch {
Log.db.error("Failed to load conversations: \(error.localizedDescription)")
conversations = []
}
}
private func deleteConversation(_ conversation: Conversation) {
do {
let _ = try DatabaseService.shared.deleteConversation(id: conversation.id)
withAnimation {
conversations.removeAll { $0.id == conversation.id }
}
} catch {
Log.db.error("Failed to delete conversation: \(error.localizedDescription)")
}
}
private func exportConversation(_ conversation: Conversation) {
guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id),
!loadedMessages.isEmpty else {
return
}
let content = loadedMessages.map { msg in
let header = msg.role == .user ? "**User**" : "**Assistant**"
return "\(header)\n\n\(msg.content)"
}.joined(separator: "\n\n---\n\n")
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let filename = conversation.name.replacingOccurrences(of: " ", with: "_") + ".md"
let fileURL = downloads.appendingPathComponent(filename)
try? content.write(to: fileURL, atomically: true, encoding: .utf8)
}
}
struct ConversationRow: View {
let conversation: Conversation
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(conversation.name)
.font(.headline)
HStack(spacing: 8) {
Label("\(conversation.messageCount)", systemImage: "message")
Text("\u{2022}")
Text(conversation.updatedAt, style: .relative)
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
#Preview {
ConversationListView()
}

View File

@@ -0,0 +1,160 @@
//
// CreditsView.swift
// oAI
//
// Account credits and balance
//
import SwiftUI
struct CreditsView: View {
let provider: Settings.Provider
@Environment(\.dismiss) var dismiss
@State private var credits: Credits?
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// Provider icon
Image(systemName: provider.iconName)
.font(.system(size: 60))
.foregroundColor(Color.providerColor(provider))
Text(provider.displayName)
.font(.title2)
.fontWeight(.semibold)
Divider()
// Credits info based on provider
VStack(spacing: 16) {
switch provider {
case .openrouter:
openRouterCreditsView
case .anthropic:
Text("Anthropic Balance")
.font(.headline)
Text("Check your balance at:")
.font(.caption)
.foregroundColor(.secondary)
Link("console.anthropic.com", destination: URL(string: "https://console.anthropic.com")!)
.font(.body)
case .openai:
Text("OpenAI Balance")
.font(.headline)
Text("Check your usage at:")
.font(.caption)
.foregroundColor(.secondary)
Link("platform.openai.com", destination: URL(string: "https://platform.openai.com/usage")!)
.font(.body)
case .ollama:
Text("Ollama (Local)")
.font(.headline)
Text("Running locally — no credits needed!")
.font(.body)
.foregroundColor(.secondary)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 40))
.foregroundColor(.green)
.padding(.top)
}
}
Spacer()
}
.padding()
.navigationTitle("Credits")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
.task {
await fetchCredits()
}
}
// MARK: - OpenRouter Credits
@ViewBuilder
private var openRouterCreditsView: some View {
Text("OpenRouter Credits")
.font(.headline)
if isLoading {
ProgressView("Loading...")
.padding()
} else if let error = errorMessage {
Text(error)
.font(.caption)
.foregroundColor(.red)
.padding()
Button("Retry") {
Task { await fetchCredits() }
}
} else if let credits = credits {
VStack(spacing: 12) {
CreditRow(label: "Remaining", value: credits.balanceDisplay, highlight: true)
Divider()
if let limit = credits.limit {
CreditRow(label: "Total Credits", value: String(format: "$%.2f", limit))
}
if let usage = credits.usage {
CreditRow(label: "Used", value: String(format: "$%.2f", usage))
}
}
} else {
Text("No credit data available")
.font(.caption)
.foregroundColor(.secondary)
}
}
private func fetchCredits() async {
guard provider == .openrouter else { return }
guard let apiProvider = ProviderRegistry.shared.getCurrentProvider() else {
errorMessage = "No API key configured"
return
}
isLoading = true
errorMessage = nil
do {
credits = try await apiProvider.getCredits()
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
struct CreditRow: View {
let label: String
let value: String
var highlight: Bool = false
var body: some View {
HStack {
Text(label)
.foregroundColor(highlight ? .primary : .secondary)
.fontWeight(highlight ? .semibold : .regular)
Spacer()
Text(value)
.font(highlight ? .title2.monospacedDigit() : .body.monospacedDigit())
.fontWeight(highlight ? .bold : .medium)
.foregroundColor(highlight ? .green : .primary)
}
}
}
#Preview {
CreditsView(provider: .openrouter)
}

View File

@@ -0,0 +1,456 @@
//
// HelpView.swift
// oAI
//
// Help and commands reference with expandable detail and search
//
import SwiftUI
// MARK: - Data Model
struct CommandDetail: Identifiable {
let id = UUID()
let command: String
let brief: String
let detail: String
let examples: [String]
}
struct CommandCategory: Identifiable {
let id = UUID()
let name: String
let icon: String
let commands: [CommandDetail]
}
// MARK: - Help Data
private let helpCategories: [CommandCategory] = [
CommandCategory(name: "Chat", icon: "bubble.left.and.bubble.right", commands: [
CommandDetail(
command: "/clear",
brief: "Clear chat history",
detail: "Removes all messages from the current session and resets the conversation. This does not delete saved conversations.",
examples: ["/clear"]
),
CommandDetail(
command: "/retry",
brief: "Retry last message",
detail: "Resends your last message to the AI. Useful when you get an unsatisfactory response or encounter an error.",
examples: ["/retry"]
),
CommandDetail(
command: "/memory on|off",
brief: "Toggle conversation memory",
detail: "When enabled, the AI remembers all previous messages in the session. When disabled, each message is treated independently — only your latest message is sent.",
examples: ["/memory on", "/memory off"]
),
CommandDetail(
command: "/online on|off",
brief: "Toggle web search",
detail: "Enables or disables online mode. When on, the AI can search the web to find current information before responding.",
examples: ["/online on", "/online off"]
),
]),
CommandCategory(name: "Model & Provider", icon: "cpu", commands: [
CommandDetail(
command: "/model",
brief: "Select AI model",
detail: "Opens the model selector to browse and choose from available models. The list depends on your active provider and API key.",
examples: ["/model"]
),
CommandDetail(
command: "/provider [name]",
brief: "Switch AI provider",
detail: "Without arguments, shows the current provider. With a provider name, switches to that provider. Available providers: openrouter, anthropic, openai, ollama.",
examples: ["/provider", "/provider anthropic", "/provider openai"]
),
CommandDetail(
command: "/info [model]",
brief: "Show model information",
detail: "Displays details about the currently selected model, or a specific model if provided. Shows context length, pricing, and capabilities.",
examples: ["/info", "/info anthropic/claude-sonnet-4"]
),
CommandDetail(
command: "/credits",
brief: "Check account credits",
detail: "Shows your current balance and usage for the active provider (where supported, e.g. OpenRouter).",
examples: ["/credits"]
),
]),
CommandCategory(name: "Conversations", icon: "tray.full", commands: [
CommandDetail(
command: "/save <name>",
brief: "Save current conversation",
detail: "Saves all messages in the current session under the given name. You can load it later to continue the conversation.",
examples: ["/save my-project-chat", "/save debug session"]
),
CommandDetail(
command: "/load",
brief: "Load saved conversation",
detail: "Opens the conversation list to browse and load a previously saved conversation. Replaces the current chat.",
examples: ["/load"]
),
CommandDetail(
command: "/list",
brief: "List saved conversations",
detail: "Opens the conversation list showing all saved conversations with their dates and message counts.",
examples: ["/list"]
),
CommandDetail(
command: "/delete <name>",
brief: "Delete a saved conversation",
detail: "Permanently deletes a saved conversation by name. This cannot be undone.",
examples: ["/delete old-chat", "/delete test"]
),
CommandDetail(
command: "/export md|json",
brief: "Export conversation",
detail: "Exports the current conversation to a file. Supports Markdown (.md) and JSON (.json) formats. Optionally provide a custom filename.",
examples: ["/export md", "/export json", "/export md my-chat.md"]
),
]),
CommandCategory(name: "MCP (File Access)", icon: "folder.badge.gearshape", commands: [
CommandDetail(
command: "/mcp on|off",
brief: "Toggle file access",
detail: "Enables or disables MCP (Model Context Protocol), which gives the AI access to read (and optionally write) files in your allowed folders.",
examples: ["/mcp on", "/mcp off"]
),
CommandDetail(
command: "/mcp add <path>",
brief: "Add folder for access",
detail: "Grants the AI access to a folder on your filesystem. The AI can then read, list, and search files within it. Use absolute paths or ~.",
examples: ["/mcp add ~/Projects/myapp", "/mcp add /Users/me/Documents"]
),
CommandDetail(
command: "/mcp remove <index|path>",
brief: "Remove an allowed folder",
detail: "Revokes AI access to a folder. Specify by index number (from /mcp list) or by path.",
examples: ["/mcp remove 0", "/mcp remove ~/Projects/myapp"]
),
CommandDetail(
command: "/mcp list",
brief: "List allowed folders",
detail: "Shows all folders the AI currently has access to, with their index numbers.",
examples: ["/mcp list"]
),
CommandDetail(
command: "/mcp status",
brief: "Show MCP status",
detail: "Displays whether MCP is enabled, the number of registered folders, active permissions (read/write/delete/move), and gitignore setting.",
examples: ["/mcp status"]
),
CommandDetail(
command: "/mcp write on|off",
brief: "Toggle write permissions",
detail: "Quickly enables or disables all write permissions (write, edit, delete, create directories, move, copy). Fine-grained control is available in Settings > MCP.",
examples: ["/mcp write on", "/mcp write off"]
),
]),
CommandCategory(name: "Settings & Stats", icon: "gearshape", commands: [
CommandDetail(
command: "/config",
brief: "Open settings",
detail: "Opens the settings panel where you can configure providers, API keys, MCP permissions, appearance, and more. Also available via /settings.",
examples: ["/config", "/settings"]
),
CommandDetail(
command: "/stats",
brief: "Show session statistics",
detail: "Displays statistics for the current session: message count, total tokens used, estimated cost, and session duration.",
examples: ["/stats"]
),
]),
]
private let keyboardShortcuts: [(key: String, description: String)] = [
("Return", "Send message"),
("Shift + Return", "New line"),
("\u{2318}M", "Model Selector"),
("\u{2318}K", "Clear Chat"),
("\u{21E7}\u{2318}S", "Statistics"),
("\u{2318},", "Settings"),
("\u{2318}/", "Help"),
("\u{2318}L", "Conversations"),
]
// MARK: - HelpView
struct HelpView: View {
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var expandedCommandID: UUID?
private var filteredCategories: [CommandCategory] {
if searchText.isEmpty { return helpCategories }
let q = searchText.lowercased()
return helpCategories.compactMap { cat in
let matched = cat.commands.filter {
$0.command.lowercased().contains(q) ||
$0.brief.lowercased().contains(q) ||
$0.detail.lowercased().contains(q) ||
$0.examples.contains { $0.lowercased().contains(q) }
}
return matched.isEmpty ? nil : CommandCategory(name: cat.name, icon: cat.icon, commands: matched)
}
}
private var matchingShortcuts: [(key: String, description: String)] {
if searchText.isEmpty { return keyboardShortcuts }
let q = searchText.lowercased()
return keyboardShortcuts.filter {
$0.key.lowercased().contains(q) || $0.description.lowercased().contains(q)
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Help")
.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 commands, shortcuts, features...", 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
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Quick tip (only when not searching)
if searchText.isEmpty {
HStack(spacing: 10) {
Image(systemName: "lightbulb.fill")
.foregroundStyle(.yellow)
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("Type / in the input to see command suggestions")
.font(.callout)
Text("Use @filename to attach files to your message")
.font(.callout)
.foregroundStyle(.secondary)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
}
// Command categories
ForEach(filteredCategories) { category in
CategorySection(
category: category,
expandedCommandID: $expandedCommandID
)
}
// Keyboard shortcuts
#if os(macOS)
if !matchingShortcuts.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Label("Keyboard Shortcuts", systemImage: "keyboard")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
VStack(spacing: 0) {
ForEach(Array(matchingShortcuts.enumerated()), id: \.offset) { idx, shortcut in
HStack {
Text(shortcut.key)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
Spacer()
Text(shortcut.description)
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
if idx < matchingShortcuts.count - 1 {
Divider().padding(.leading, 12)
}
}
}
.background(.background, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary))
}
}
#endif
// No results
if filteredCategories.isEmpty && matchingShortcuts.isEmpty {
VStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.largeTitle)
.foregroundStyle(.tertiary)
Text("No results for \"\(searchText)\"")
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.frame(minWidth: 520, idealWidth: 600, minHeight: 480, idealHeight: 700)
}
}
// MARK: - Category Section
private struct CategorySection: View {
let category: CommandCategory
@Binding var expandedCommandID: UUID?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label(category.name, systemImage: category.icon)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
VStack(spacing: 0) {
ForEach(Array(category.commands.enumerated()), id: \.element.id) { idx, cmd in
CommandRow(
command: cmd,
isExpanded: expandedCommandID == cmd.id,
onTap: {
withAnimation(.easeInOut(duration: 0.2)) {
expandedCommandID = expandedCommandID == cmd.id ? nil : cmd.id
}
}
)
if idx < category.commands.count - 1 {
Divider().padding(.leading, 12)
}
}
}
.background(.background, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary))
}
}
}
// MARK: - Command Row
private struct CommandRow: View {
let command: CommandDetail
let isExpanded: Bool
let onTap: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header (always visible)
Button(action: onTap) {
HStack(spacing: 10) {
Text(command.command)
.font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundStyle(.primary)
Text(command.brief)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
}
.contentShape(Rectangle())
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
.buttonStyle(.plain)
// Expanded detail
if isExpanded {
VStack(alignment: .leading, spacing: 10) {
Text(command.detail)
.font(.callout)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
if !command.examples.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text(command.examples.count == 1 ? "Example" : "Examples")
.font(.caption)
.foregroundStyle(.secondary)
.fontWeight(.medium)
ForEach(command.examples, id: \.self) { example in
Text(example)
.font(.system(size: 12, design: .monospaced))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.blue.opacity(0.06), in: RoundedRectangle(cornerRadius: 6))
}
}
}
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
}
#Preview {
HelpView()
}

View File

@@ -0,0 +1,223 @@
//
// ModelInfoView.swift
// oAI
//
// Rich model information modal
//
import SwiftUI
struct ModelInfoView: View {
let model: ModelInfo
@Environment(\.dismiss) var dismiss
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Model Info")
.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)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Overview
sectionHeader("Overview")
infoRow("Name", model.name)
infoRow("ID", model.id)
if let provider = model.topProvider {
infoRow("Provider", provider)
}
if let desc = model.description {
VStack(alignment: .leading, spacing: 6) {
Text("Description")
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
Text(desc)
.font(.body)
.foregroundColor(.primary)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
}
.padding(.leading, 4)
}
Divider()
// Pricing
sectionHeader("Pricing")
infoRow("Input", model.promptPriceDisplay + " / 1M tokens")
infoRow("Output", model.completionPriceDisplay + " / 1M tokens")
if model.pricing.prompt > 0 {
VStack(alignment: .leading, spacing: 6) {
Text("Cost Examples")
.font(.caption)
.foregroundColor(.secondary)
HStack(spacing: 16) {
costExample(label: "1K tokens", inputTokens: 1_000)
costExample(label: "10K tokens", inputTokens: 10_000)
costExample(label: "100K tokens", inputTokens: 100_000)
}
}
.padding(.leading, 4)
}
Divider()
// Context Window
sectionHeader("Context Window")
infoRow("Max Tokens", model.contextLength.formatted())
if model.contextLength > 0 {
VStack(alignment: .leading, spacing: 4) {
let maxContext = 2_000_000.0
let fraction = min(Double(model.contextLength) / maxContext, 1.0)
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.2))
.frame(height: 16)
GeometryReader { geo in
RoundedRectangle(cornerRadius: 4)
.fill(Color.blue)
.frame(width: geo.size.width * fraction, height: 16)
}
.frame(height: 16)
}
Text(model.contextLengthDisplay + " tokens")
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.leading, 4)
}
Divider()
// Capabilities
sectionHeader("Capabilities")
HStack(spacing: 12) {
capabilityBadge(icon: "eye.fill", label: "Vision", active: model.capabilities.vision)
capabilityBadge(icon: "wrench.fill", label: "Tools", active: model.capabilities.tools)
capabilityBadge(icon: "globe", label: "Online", active: model.capabilities.online)
capabilityBadge(icon: "photo.fill", label: "Image Gen", active: model.capabilities.imageGeneration)
}
// Architecture (if available)
if let arch = model.architecture {
Divider()
sectionHeader("Architecture")
if let modality = arch.modality {
infoRow("Modality", modality)
}
if let tokenizer = arch.tokenizer {
infoRow("Tokenizer", tokenizer)
}
if let instructType = arch.instructType {
infoRow("Instruct Type", instructType)
}
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.frame(minWidth: 550, idealWidth: 650, minHeight: 550, idealHeight: 750)
}
// MARK: - Layout Helpers
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
private func infoRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label)
.font(.body)
Spacer()
Text(value)
.font(.body)
.foregroundColor(.secondary)
.textSelection(.enabled)
}
.padding(.leading, 4)
}
@ViewBuilder
private func costExample(label: String, inputTokens: Int) -> some View {
let cost = (Double(inputTokens) * model.pricing.prompt / 1_000_000) +
(Double(inputTokens) * model.pricing.completion / 1_000_000)
VStack(spacing: 2) {
Text(label)
.font(.caption2)
.foregroundColor(.secondary)
Text(String(format: "$%.4f", cost))
.font(.caption.monospacedDigit())
.foregroundColor(.primary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.gray.opacity(0.1))
.cornerRadius(4)
}
@ViewBuilder
private func capabilityBadge(icon: String, label: String, active: Bool) -> some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.title3)
.foregroundColor(active ? .blue : .gray.opacity(0.4))
Text(label)
.font(.caption2)
.foregroundColor(active ? .primary : .secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(active ? Color.blue.opacity(0.1) : Color.gray.opacity(0.05))
.cornerRadius(8)
}
}
#Preview {
ModelInfoView(model: ModelInfo(
id: "anthropic/claude-sonnet-4",
name: "Claude Sonnet 4",
description: "Balanced intelligence and speed. This is a longer description to test how the modal handles multi-line text that wraps across several lines in the description field.",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: false),
architecture: .init(tokenizer: "claude", instructType: "claude", modality: "text+image->text"),
topProvider: "anthropic"
))
}

View File

@@ -0,0 +1,222 @@
//
// ModelSelectorView.swift
// oAI
//
// Model selection screen
//
import SwiftUI
struct ModelSelectorView: View {
let models: [ModelInfo]
let selectedModel: ModelInfo?
let onSelect: (ModelInfo) -> Void
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var filterVision = false
@State private var filterTools = false
@State private var filterOnline = false
@State private var filterImageGen = false
@State private var keyboardIndex: Int = -1
private var filteredModels: [ModelInfo] {
models.filter { model in
let matchesSearch = searchText.isEmpty ||
model.name.lowercased().contains(searchText.lowercased()) ||
model.id.lowercased().contains(searchText.lowercased())
let matchesVision = !filterVision || model.capabilities.vision
let matchesTools = !filterTools || model.capabilities.tools
let matchesOnline = !filterOnline || model.capabilities.online
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen
}
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
TextField("Search models...", text: $searchText)
.textFieldStyle(.roundedBorder)
.padding()
.onChange(of: searchText) {
// Reset keyboard index when search changes
keyboardIndex = -1
}
// Filters
HStack(spacing: 12) {
FilterToggle(isOn: $filterVision, icon: "\u{1F441}\u{FE0F}", label: "Vision")
FilterToggle(isOn: $filterTools, icon: "\u{1F527}", label: "Tools")
FilterToggle(isOn: $filterOnline, icon: "\u{1F310}", label: "Online")
FilterToggle(isOn: $filterImageGen, icon: "\u{1F3A8}", label: "Image Gen")
}
.padding(.horizontal)
.padding(.bottom, 12)
Divider()
// Model list
if filteredModels.isEmpty {
ContentUnavailableView(
"No Models Found",
systemImage: "magnifyingglass",
description: Text("Try adjusting your search or filters")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollViewReader { proxy in
List(Array(filteredModels.enumerated()), id: \.element.id) { index, model in
ModelRowView(
model: model,
isSelected: model.id == selectedModel?.id,
isKeyboardHighlighted: index == keyboardIndex
)
.id(model.id)
.contentShape(Rectangle())
.onTapGesture {
onSelect(model)
}
}
.listStyle(.plain)
.onChange(of: keyboardIndex) { _, newIndex in
if newIndex >= 0 && newIndex < filteredModels.count {
withAnimation {
proxy.scrollTo(filteredModels[newIndex].id, anchor: .center)
}
}
}
}
}
}
.frame(minWidth: 600, minHeight: 500)
.navigationTitle("Select Model")
#if os(macOS)
.onKeyPress(.downArrow) {
if keyboardIndex < filteredModels.count - 1 {
keyboardIndex += 1
}
return .handled
}
.onKeyPress(.upArrow) {
if keyboardIndex > 0 {
keyboardIndex -= 1
} else if keyboardIndex == -1 && !filteredModels.isEmpty {
keyboardIndex = 0
}
return .handled
}
.onKeyPress(.return) {
if keyboardIndex >= 0 && keyboardIndex < filteredModels.count {
onSelect(filteredModels[keyboardIndex])
return .handled
}
return .ignored
}
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.onAppear {
// Initialize keyboard index to current selection
if let selected = selectedModel,
let index = filteredModels.firstIndex(where: { $0.id == selected.id }) {
keyboardIndex = index
}
}
}
}
}
struct FilterToggle: View {
@Binding var isOn: Bool
let icon: String
let label: String
var body: some View {
Button(action: { isOn.toggle() }) {
HStack(spacing: 4) {
Text(icon)
Text(label)
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isOn ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
.foregroundColor(isOn ? .blue : .secondary)
.cornerRadius(6)
}
.buttonStyle(.plain)
}
}
struct ModelRowView: View {
let model: ModelInfo
let isSelected: Bool
var isKeyboardHighlighted: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(model.name)
.font(.headline)
.foregroundColor(isSelected ? .blue : .primary)
Spacer()
// Capabilities
HStack(spacing: 4) {
if model.capabilities.vision { Text("\u{1F441}\u{FE0F}").font(.caption) }
if model.capabilities.tools { Text("\u{1F527}").font(.caption) }
if model.capabilities.online { Text("\u{1F310}").font(.caption) }
if model.capabilities.imageGeneration { Text("\u{1F3A8}").font(.caption) }
}
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
}
}
Text(model.id)
.font(.caption)
.foregroundColor(.secondary)
if let description = model.description {
Text(description)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
HStack(spacing: 12) {
Label(model.contextLengthDisplay, systemImage: "text.alignleft")
Label(model.promptPriceDisplay + "/1M", systemImage: "dollarsign.circle")
}
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.vertical, 6)
.padding(.horizontal, isKeyboardHighlighted ? 4 : 0)
.background(
isKeyboardHighlighted
? RoundedRectangle(cornerRadius: 6).fill(Color.accentColor.opacity(0.15))
: nil
)
}
}
#Preview {
ModelSelectorView(
models: ModelInfo.mockModels,
selectedModel: ModelInfo.mockModels.first,
onSelect: { _ in }
)
}

View File

@@ -0,0 +1,500 @@
//
// SettingsView.swift
// oAI
//
// Settings and configuration screen
//
import SwiftUI
import UniformTypeIdentifiers
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@Bindable private var settingsService = SettingsService.shared
private var mcpService = MCPService.shared
@State private var openrouterKey = ""
@State private var anthropicKey = ""
@State private var openaiKey = ""
@State private var googleKey = ""
@State private var googleEngineID = ""
@State private var showFolderPicker = false
@State private var selectedTab = 0
// OAuth state
@State private var oauthCode = ""
@State private var oauthError: String?
@State private var showOAuthCodeField = false
private var oauthService = AnthropicOAuthService.shared
private let labelWidth: CGFloat = 140
var body: some View {
VStack(spacing: 0) {
// Title
Text("Settings")
.font(.system(size: 18, weight: .bold))
.padding(.top, 20)
.padding(.bottom, 12)
// Tab picker
Picker("", selection: $selectedTab) {
Text("General").tag(0)
Text("MCP").tag(1)
Text("Appearance").tag(2)
}
.pickerStyle(.segmented)
.padding(.horizontal, 24)
.padding(.bottom, 12)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch selectedTab {
case 0:
generalTab
case 1:
mcpTab
case 2:
appearanceTab
default:
generalTab
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.frame(minWidth: 550, idealWidth: 600, minHeight: 500, idealHeight: 650)
}
// MARK: - General Tab
@ViewBuilder
private var generalTab: some View {
// Provider
sectionHeader("Provider")
row("Default Provider") {
Picker("", selection: $settingsService.defaultProvider) {
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
Text(provider.displayName).tag(provider)
}
}
.labelsHidden()
.fixedSize()
}
divider()
// API Keys
sectionHeader("API Keys")
row("OpenRouter") {
SecureField("sk-or-...", text: $openrouterKey)
.textFieldStyle(.roundedBorder)
.onAppear { openrouterKey = settingsService.openrouterAPIKey ?? "" }
.onChange(of: openrouterKey) {
settingsService.openrouterAPIKey = openrouterKey.isEmpty ? nil : openrouterKey
ProviderRegistry.shared.clearCache()
}
}
// Anthropic: OAuth or API key
row("Anthropic") {
VStack(alignment: .leading, spacing: 8) {
if oauthService.isAuthenticated {
// Logged in via OAuth
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Logged in via Claude Pro/Max")
.font(.subheadline)
Spacer()
Button("Logout") {
oauthService.logout()
ProviderRegistry.shared.clearCache()
}
.font(.subheadline)
.foregroundStyle(.red)
}
} else if showOAuthCodeField {
// Waiting for code paste
HStack(spacing: 8) {
TextField("Paste authorization code...", text: $oauthCode)
.textFieldStyle(.roundedBorder)
Button("Submit") {
Task { await submitOAuthCode() }
}
.disabled(oauthCode.isEmpty || oauthService.isLoggingIn)
Button("Cancel") {
showOAuthCodeField = false
oauthCode = ""
oauthError = nil
}
.font(.subheadline)
}
if let error = oauthError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
} else {
// Login button + API key field
HStack(spacing: 8) {
Button {
startOAuthLogin()
} label: {
HStack(spacing: 4) {
Image(systemName: "person.circle")
Text("Login with Claude Pro/Max")
}
.font(.subheadline)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
Text("or")
.font(.caption)
.foregroundStyle(.secondary)
}
SecureField("sk-ant-... (API key)", text: $anthropicKey)
.textFieldStyle(.roundedBorder)
.onAppear { anthropicKey = settingsService.anthropicAPIKey ?? "" }
.onChange(of: anthropicKey) {
settingsService.anthropicAPIKey = anthropicKey.isEmpty ? nil : anthropicKey
ProviderRegistry.shared.clearCache()
}
}
}
}
row("OpenAI") {
SecureField("sk-...", text: $openaiKey)
.textFieldStyle(.roundedBorder)
.onAppear { openaiKey = settingsService.openaiAPIKey ?? "" }
.onChange(of: openaiKey) {
settingsService.openaiAPIKey = openaiKey.isEmpty ? nil : openaiKey
ProviderRegistry.shared.clearCache()
}
}
row("Ollama URL") {
TextField("http://localhost:11434", text: $settingsService.ollamaBaseURL)
.textFieldStyle(.roundedBorder)
.help("Enter your Ollama server URL to enable the Ollama provider")
}
divider()
// Features
sectionHeader("Features")
row("") {
VStack(alignment: .leading, spacing: 6) {
Toggle("Online Mode (Web Search)", isOn: $settingsService.onlineMode)
Toggle("Conversation Memory", isOn: $settingsService.memoryEnabled)
Toggle("MCP (File Access)", isOn: $settingsService.mcpEnabled)
}
}
divider()
// Web Search
sectionHeader("Web Search")
row("Search Provider") {
Picker("", selection: $settingsService.searchProvider) {
ForEach(Settings.SearchProvider.allCases, id: \.self) { provider in
Text(provider.displayName).tag(provider)
}
}
.labelsHidden()
.fixedSize()
}
if settingsService.searchProvider == .google {
row("Google API Key") {
SecureField("", text: $googleKey)
.textFieldStyle(.roundedBorder)
.onAppear { googleKey = settingsService.googleAPIKey ?? "" }
.onChange(of: googleKey) {
settingsService.googleAPIKey = googleKey.isEmpty ? nil : googleKey
}
}
row("Search Engine ID") {
TextField("", text: $googleEngineID)
.textFieldStyle(.roundedBorder)
.onAppear { googleEngineID = settingsService.googleSearchEngineID ?? "" }
.onChange(of: googleEngineID) {
settingsService.googleSearchEngineID = googleEngineID.isEmpty ? nil : googleEngineID
}
}
}
divider()
// Model Settings
sectionHeader("Model Settings")
row("Default Model ID") {
TextField("e.g. anthropic/claude-sonnet-4", text: Binding(
get: { settingsService.defaultModel ?? "" },
set: { settingsService.defaultModel = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
}
row("") {
Toggle("Streaming Responses", isOn: $settingsService.streamEnabled)
}
divider()
// Logging
sectionHeader("Logging")
row("Log Level") {
Picker("", selection: Binding(
get: { FileLogger.shared.minimumLevel },
set: { FileLogger.shared.minimumLevel = $0 }
)) {
ForEach(LogLevel.allCases, id: \.self) { level in
Text(level.displayName).tag(level)
}
}
.labelsHidden()
.fixedSize()
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Controls which messages are written to ~/Library/Logs/oAI.log")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - MCP Tab
@ViewBuilder
private var mcpTab: some View {
// Enable toggle
sectionHeader("MCP")
row("") {
Toggle("MCP Enabled (File Access)", isOn: $settingsService.mcpEnabled)
}
if settingsService.mcpEnabled {
divider()
// Folders
sectionHeader("Allowed Folders")
if mcpService.allowedFolders.isEmpty {
HStack {
Spacer().frame(width: labelWidth + 12)
Text("No folders added")
.foregroundStyle(.secondary)
.font(.subheadline)
}
} else {
ForEach(Array(mcpService.allowedFolders.enumerated()), id: \.offset) { index, folder in
HStack(spacing: 0) {
Spacer().frame(width: labelWidth + 12)
Image(systemName: "folder.fill")
.foregroundStyle(.blue)
.frame(width: 20)
VStack(alignment: .leading, spacing: 0) {
Text((folder as NSString).lastPathComponent)
.font(.body)
Text(abbreviatePath(folder))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.leading, 6)
Spacer()
Button {
withAnimation { _ = mcpService.removeFolder(at: index) }
} label: {
Image(systemName: "trash.fill")
.foregroundStyle(.red)
.font(.caption)
}
.buttonStyle(.plain)
}
}
}
HStack(spacing: 0) {
Spacer().frame(width: labelWidth + 12)
Button {
showFolderPicker = true
} label: {
HStack(spacing: 4) {
Image(systemName: "plus")
Text("Add Folder...")
}
.font(.subheadline)
}
.buttonStyle(.borderless)
Spacer()
}
.fileImporter(
isPresented: $showFolderPicker,
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
if case .success(let urls) = result, let url = urls.first {
if url.startAccessingSecurityScopedResource() {
withAnimation { _ = mcpService.addFolder(url.path) }
url.stopAccessingSecurityScopedResource()
}
}
}
divider()
// 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)
}
}
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()
// Filtering
sectionHeader("Filtering")
row("") {
Toggle("Respect .gitignore", isOn: Binding(
get: { settingsService.mcpRespectGitignore },
set: { newValue in
settingsService.mcpRespectGitignore = newValue
mcpService.reloadGitignores()
}
))
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("When enabled, listing and searching skip gitignored files. Write operations always ignore .gitignore.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Appearance Tab
@ViewBuilder
private var appearanceTab: some View {
sectionHeader("Text Sizes")
row("GUI Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.guiTextSize, in: 10...20, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.guiTextSize)) pt")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
row("Dialog Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.dialogTextSize, in: 10...24, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.dialogTextSize)) pt")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
row("Input Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.inputTextSize, in: 10...24, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.inputTextSize)) pt")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
}
// MARK: - Layout Helpers
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
private func row<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
HStack(alignment: .center, spacing: 12) {
Text(label)
.font(.body)
.frame(width: labelWidth, alignment: .trailing)
content()
}
}
private func divider() -> some View {
Divider().padding(.vertical, 2)
}
private func abbreviatePath(_ path: String) -> String {
let home = NSHomeDirectory()
if path.hasPrefix(home) {
return "~" + path.dropFirst(home.count)
}
return path
}
// MARK: - OAuth Helpers
private func startOAuthLogin() {
let url = oauthService.generateAuthorizationURL()
#if os(macOS)
NSWorkspace.shared.open(url)
#endif
showOAuthCodeField = true
oauthError = nil
oauthCode = ""
}
private func submitOAuthCode() async {
oauthService.isLoggingIn = true
oauthError = nil
do {
try await oauthService.exchangeCode(oauthCode)
showOAuthCodeField = false
oauthCode = ""
ProviderRegistry.shared.clearCache()
} catch {
oauthError = error.localizedDescription
}
oauthService.isLoggingIn = false
}
}
#Preview {
SettingsView()
}

View File

@@ -0,0 +1,148 @@
//
// StatsView.swift
// oAI
//
// Session statistics screen
//
import SwiftUI
struct StatsView: View {
let stats: SessionStats
let model: ModelInfo?
let provider: Settings.Provider
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
List {
Section("Session Info") {
StatRow(label: "Provider", value: provider.displayName)
StatRow(label: "Model", value: model?.name ?? "None selected")
StatRow(label: "Messages", value: "\(stats.messageCount)")
}
Section("Token Usage") {
StatRow(label: "Input Tokens", value: stats.totalInputTokens.formatted())
StatRow(label: "Output Tokens", value: stats.totalOutputTokens.formatted())
StatRow(label: "Total Tokens", value: stats.totalTokens.formatted())
if stats.totalTokens > 0 {
HStack {
Text("Token Distribution")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
GeometryReader { geo in
HStack(spacing: 0) {
Rectangle()
.fill(Color.blue)
.frame(width: geo.size.width * CGFloat(stats.totalInputTokens) / CGFloat(stats.totalTokens))
Rectangle()
.fill(Color.green)
.frame(width: geo.size.width * CGFloat(stats.totalOutputTokens) / CGFloat(stats.totalTokens))
}
}
.frame(height: 20)
.cornerRadius(4)
}
}
}
Section("Costs") {
StatRow(label: "Total Cost", value: stats.totalCostDisplay)
if stats.messageCount > 0 {
StatRow(label: "Avg per Message", value: String(format: "$%.4f", stats.averageCostPerMessage))
}
}
if let model = model {
Section("Model Details") {
StatRow(label: "Context Length", value: model.contextLengthDisplay)
StatRow(label: "Prompt Price", value: model.promptPriceDisplay + "/1M tokens")
StatRow(label: "Completion Price", value: model.completionPriceDisplay + "/1M tokens")
HStack {
Text("Capabilities")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
HStack(spacing: 8) {
if model.capabilities.vision {
CapabilityBadge(icon: "👁️", label: "Vision")
}
if model.capabilities.tools {
CapabilityBadge(icon: "🔧", label: "Tools")
}
if model.capabilities.online {
CapabilityBadge(icon: "🌐", label: "Online")
}
}
}
}
}
}
#if os(iOS)
.listStyle(.insetGrouped)
#else
.listStyle(.sidebar)
#endif
.navigationTitle("Statistics")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
.frame(minWidth: 500, idealWidth: 550, minHeight: 450, idealHeight: 500)
}
}
}
struct StatRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
.font(.body)
Spacer()
Text(value)
.font(.body.monospacedDigit())
.foregroundColor(.secondary)
}
}
}
struct CapabilityBadge: View {
let icon: String
let label: String
var body: some View {
HStack(spacing: 2) {
Text(icon)
Text(label)
}
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color.blue.opacity(0.1))
.cornerRadius(4)
}
}
#Preview {
StatsView(
stats: SessionStats(
totalInputTokens: 1250,
totalOutputTokens: 3420,
totalCost: 0.0152,
messageCount: 12
),
model: ModelInfo.mockModels.first,
provider: .openrouter
)
}

12
oAI/oAI.entitlements Normal file
View File

@@ -0,0 +1,12 @@
<?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>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<false/>
</dict>
</plist>

38
oAI/oAIApp.swift Normal file
View File

@@ -0,0 +1,38 @@
//
// oAIApp.swift
// oAI
//
// Main app entry point
//
import SwiftUI
@main
struct oAIApp: App {
@State private var chatViewModel = ChatViewModel()
@State private var showAbout = false
var body: some Scene {
WindowGroup {
ContentView()
.environment(chatViewModel)
.preferredColorScheme(.dark)
.sheet(isPresented: $showAbout) {
AboutView()
}
}
#if os(macOS)
.windowStyle(.hiddenTitleBar)
.windowToolbarStyle(.unified)
.defaultSize(width: 1024, height: 800)
.windowResizability(.contentMinSize)
.commands {
CommandGroup(replacing: .appInfo) {
Button("About oAI") {
showAbout = true
}
}
}
#endif
}
}