Initial commit
114
.gitignore
vendored
Normal 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
@@ -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
@@ -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).
|
||||
380
oAI.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
oAI.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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
|
||||
}
|
||||
74
oAI/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
oAI/Assets.xcassets/AppIcon.appiconset/icon_1024.png
Normal file
|
After Width: | Height: | Size: 709 KiB |
BIN
oAI/Assets.xcassets/AppIcon.appiconset/icon_128.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
oAI/Assets.xcassets/AppIcon.appiconset/icon_16.png
Normal file
|
After Width: | Height: | Size: 755 B |
BIN
oAI/Assets.xcassets/AppIcon.appiconset/icon_256.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
oAI/Assets.xcassets/AppIcon.appiconset/icon_32.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
oAI/Assets.xcassets/AppIcon.appiconset/icon_512.png
Normal file
|
After Width: | Height: | Size: 214 KiB |
BIN
oAI/Assets.xcassets/AppIcon.appiconset/icon_64.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
oAI/Assets.xcassets/AppLogo.imageset/AppLogo.png
vendored
Normal file
|
After Width: | Height: | Size: 818 KiB |
13
oAI/Assets.xcassets/AppLogo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppLogo.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
oAI/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
38
oAI/Models/Conversation.swift
Normal 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
@@ -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
@@ -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]
|
||||
}
|
||||
56
oAI/Models/ModelInfo.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
58
oAI/Models/SessionStats.swift
Normal 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
@@ -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
|
||||
)
|
||||
}
|
||||
241
oAI/Providers/AIProvider.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
534
oAI/Providers/AnthropicProvider.swift
Normal 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
|
||||
}
|
||||
}
|
||||
308
oAI/Providers/OllamaProvider.swift
Normal 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
367
oAI/Providers/OpenAIProvider.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
313
oAI/Providers/OpenRouterModels.swift
Normal 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?
|
||||
}
|
||||
}
|
||||
433
oAI/Providers/OpenRouterProvider.swift
Normal 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
|
||||
}
|
||||
}
|
||||
102
oAI/Providers/ProviderRegistry.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
298
oAI/Services/AnthropicOAuthService.swift
Normal 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: "")
|
||||
}
|
||||
}
|
||||
318
oAI/Services/DatabaseService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
840
oAI/Services/MCPService.swift
Normal 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: [])
|
||||
}
|
||||
}
|
||||
408
oAI/Services/SettingsService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
143
oAI/Services/WebSearchService.swift
Normal 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)] = [
|
||||
("&", "&"), ("<", "<"), (">", ">"),
|
||||
(""", "\""), ("'", "'"), ("'", "'"),
|
||||
("'", "'"), ("/", "/"), (" ", " "),
|
||||
]
|
||||
for (entity, char) in entities {
|
||||
result = result.replacingOccurrences(of: entity, with: char)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
78
oAI/Utilities/Extensions/Color+Extensions.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
87
oAI/Utilities/Extensions/String+Extensions.swift
Normal 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
|
||||
}
|
||||
}
|
||||
81
oAI/Utilities/Extensions/View+Extensions.swift
Normal 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
@@ -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")
|
||||
}
|
||||
294
oAI/Utilities/SyntaxHighlighter.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
947
oAI/ViewModels/ChatViewModel.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
80
oAI/Views/Main/ChatView.swift
Normal 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())
|
||||
}
|
||||
150
oAI/Views/Main/ContentView.swift
Normal 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())
|
||||
}
|
||||
91
oAI/Views/Main/FooterView.swift
Normal 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)
|
||||
}
|
||||
209
oAI/Views/Main/HeaderView.swift
Normal 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)
|
||||
}
|
||||
327
oAI/Views/Main/InputBar.swift
Normal 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)
|
||||
}
|
||||
183
oAI/Views/Main/MarkdownContentView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
278
oAI/Views/Main/MessageRow.swift
Normal 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)
|
||||
}
|
||||
72
oAI/Views/Screens/AboutView.swift
Normal 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("© 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()
|
||||
}
|
||||
189
oAI/Views/Screens/ConversationListView.swift
Normal 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()
|
||||
}
|
||||
160
oAI/Views/Screens/CreditsView.swift
Normal 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)
|
||||
}
|
||||
456
oAI/Views/Screens/HelpView.swift
Normal 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()
|
||||
}
|
||||
223
oAI/Views/Screens/ModelInfoView.swift
Normal 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"
|
||||
))
|
||||
}
|
||||
222
oAI/Views/Screens/ModelSelectorView.swift
Normal 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 }
|
||||
)
|
||||
}
|
||||
500
oAI/Views/Screens/SettingsView.swift
Normal 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()
|
||||
}
|
||||
148
oAI/Views/Screens/StatsView.swift
Normal 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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||