diff --git a/app/EXO-iOS/EXO-iOS.xcodeproj/project.pbxproj b/app/EXO-iOS/EXO-iOS.xcodeproj/project.pbxproj new file mode 100644 index 00000000..e82f98fb --- /dev/null +++ b/app/EXO-iOS/EXO-iOS.xcodeproj/project.pbxproj @@ -0,0 +1,628 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + E09D17522F44F359009C51A3 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = E09D17512F44F359009C51A3 /* MLXLLM */; }; + E09D17542F44F359009C51A3 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = E09D17532F44F359009C51A3 /* MLXLMCommon */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + E09D167D2F44CA20009C51A3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E09D16672F44CA1E009C51A3 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E09D166E2F44CA1E009C51A3; + remoteInfo = "EXO-iOS"; + }; + E09D16872F44CA20009C51A3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E09D16672F44CA1E009C51A3 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E09D166E2F44CA1E009C51A3; + remoteInfo = "EXO-iOS"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + E09D166F2F44CA1E009C51A3 /* EXO-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "EXO-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + E09D167C2F44CA20009C51A3 /* EXO-iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "EXO-iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + E09D16862F44CA20009C51A3 /* EXO-iOSUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "EXO-iOSUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + E09D169A2F44CA20009C51A3 /* Exceptions for "EXO-iOS" folder in "EXO-iOS" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = E09D166E2F44CA1E009C51A3 /* EXO-iOS */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + E09D16712F44CA1E009C51A3 /* EXO-iOS */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + E09D169A2F44CA20009C51A3 /* Exceptions for "EXO-iOS" folder in "EXO-iOS" target */, + ); + path = "EXO-iOS"; + sourceTree = ""; + }; + E09D167F2F44CA20009C51A3 /* EXO-iOSTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "EXO-iOSTests"; + sourceTree = ""; + }; + E09D16892F44CA20009C51A3 /* EXO-iOSUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "EXO-iOSUITests"; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + E09D166C2F44CA1E009C51A3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E09D17542F44F359009C51A3 /* MLXLMCommon in Frameworks */, + E09D17522F44F359009C51A3 /* MLXLLM in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E09D16792F44CA20009C51A3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E09D16832F44CA20009C51A3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + E09D16662F44CA1E009C51A3 = { + isa = PBXGroup; + children = ( + E09D16712F44CA1E009C51A3 /* EXO-iOS */, + E09D167F2F44CA20009C51A3 /* EXO-iOSTests */, + E09D16892F44CA20009C51A3 /* EXO-iOSUITests */, + E09D16702F44CA1E009C51A3 /* Products */, + ); + sourceTree = ""; + }; + E09D16702F44CA1E009C51A3 /* Products */ = { + isa = PBXGroup; + children = ( + E09D166F2F44CA1E009C51A3 /* EXO-iOS.app */, + E09D167C2F44CA20009C51A3 /* EXO-iOSTests.xctest */, + E09D16862F44CA20009C51A3 /* EXO-iOSUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E09D166E2F44CA1E009C51A3 /* EXO-iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = E09D16902F44CA20009C51A3 /* Build configuration list for PBXNativeTarget "EXO-iOS" */; + buildPhases = ( + E09D166B2F44CA1E009C51A3 /* Sources */, + E09D166C2F44CA1E009C51A3 /* Frameworks */, + E09D166D2F44CA1E009C51A3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + E09D16712F44CA1E009C51A3 /* EXO-iOS */, + ); + name = "EXO-iOS"; + packageProductDependencies = ( + E09D17512F44F359009C51A3 /* MLXLLM */, + E09D17532F44F359009C51A3 /* MLXLMCommon */, + ); + productName = "EXO-iOS"; + productReference = E09D166F2F44CA1E009C51A3 /* EXO-iOS.app */; + productType = "com.apple.product-type.application"; + }; + E09D167B2F44CA20009C51A3 /* EXO-iOSTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E09D16932F44CA20009C51A3 /* Build configuration list for PBXNativeTarget "EXO-iOSTests" */; + buildPhases = ( + E09D16782F44CA20009C51A3 /* Sources */, + E09D16792F44CA20009C51A3 /* Frameworks */, + E09D167A2F44CA20009C51A3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E09D167E2F44CA20009C51A3 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E09D167F2F44CA20009C51A3 /* EXO-iOSTests */, + ); + name = "EXO-iOSTests"; + packageProductDependencies = ( + ); + productName = "EXO-iOSTests"; + productReference = E09D167C2F44CA20009C51A3 /* EXO-iOSTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + E09D16852F44CA20009C51A3 /* EXO-iOSUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E09D16962F44CA20009C51A3 /* Build configuration list for PBXNativeTarget "EXO-iOSUITests" */; + buildPhases = ( + E09D16822F44CA20009C51A3 /* Sources */, + E09D16832F44CA20009C51A3 /* Frameworks */, + E09D16842F44CA20009C51A3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E09D16882F44CA20009C51A3 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E09D16892F44CA20009C51A3 /* EXO-iOSUITests */, + ); + name = "EXO-iOSUITests"; + packageProductDependencies = ( + ); + productName = "EXO-iOSUITests"; + productReference = E09D16862F44CA20009C51A3 /* EXO-iOSUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E09D16672F44CA1E009C51A3 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2620; + TargetAttributes = { + E09D166E2F44CA1E009C51A3 = { + CreatedOnToolsVersion = 26.2; + }; + E09D167B2F44CA20009C51A3 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = E09D166E2F44CA1E009C51A3; + }; + E09D16852F44CA20009C51A3 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = E09D166E2F44CA1E009C51A3; + }; + }; + }; + buildConfigurationList = E09D166A2F44CA1E009C51A3 /* Build configuration list for PBXProject "EXO-iOS" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = E09D16662F44CA1E009C51A3; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + E09D17502F44F359009C51A3 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = E09D16702F44CA1E009C51A3 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E09D166E2F44CA1E009C51A3 /* EXO-iOS */, + E09D167B2F44CA20009C51A3 /* EXO-iOSTests */, + E09D16852F44CA20009C51A3 /* EXO-iOSUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + E09D166D2F44CA1E009C51A3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E09D167A2F44CA20009C51A3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E09D16842F44CA20009C51A3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + E09D166B2F44CA1E009C51A3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E09D16782F44CA20009C51A3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E09D16822F44CA20009C51A3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + E09D167E2F44CA20009C51A3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E09D166E2F44CA1E009C51A3 /* EXO-iOS */; + targetProxy = E09D167D2F44CA20009C51A3 /* PBXContainerItemProxy */; + }; + E09D16882F44CA20009C51A3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E09D166E2F44CA1E009C51A3 /* EXO-iOS */; + targetProxy = E09D16872F44CA20009C51A3 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + E09D168E2F44CA20009C51A3 /* 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; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + E09D168F2F44CA20009C51A3 /* 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; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + E09D16912F44CA20009C51A3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3M3M67U93M; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "EXO-iOS/Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.exo.EXO-iOS"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + 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"; + }; + name = Debug; + }; + E09D16922F44CA20009C51A3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3M3M67U93M; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "EXO-iOS/Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.exo.EXO-iOS"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + 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"; + }; + name = Release; + }; + E09D16942F44CA20009C51A3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.exo.EXO-iOSTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EXO-iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EXO-iOS"; + }; + name = Debug; + }; + E09D16952F44CA20009C51A3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.exo.EXO-iOSTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EXO-iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EXO-iOS"; + }; + name = Release; + }; + E09D16972F44CA20009C51A3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.exo.EXO-iOSUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = "EXO-iOS"; + }; + name = Debug; + }; + E09D16982F44CA20009C51A3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.exo.EXO-iOSUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = "EXO-iOS"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + E09D166A2F44CA1E009C51A3 /* Build configuration list for PBXProject "EXO-iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E09D168E2F44CA20009C51A3 /* Debug */, + E09D168F2F44CA20009C51A3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E09D16902F44CA20009C51A3 /* Build configuration list for PBXNativeTarget "EXO-iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E09D16912F44CA20009C51A3 /* Debug */, + E09D16922F44CA20009C51A3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E09D16932F44CA20009C51A3 /* Build configuration list for PBXNativeTarget "EXO-iOSTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E09D16942F44CA20009C51A3 /* Debug */, + E09D16952F44CA20009C51A3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E09D16962F44CA20009C51A3 /* Build configuration list for PBXNativeTarget "EXO-iOSUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E09D16972F44CA20009C51A3 /* Debug */, + E09D16982F44CA20009C51A3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + E09D17502F44F359009C51A3 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ml-explore/mlx-swift-lm"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.30.3; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + E09D17512F44F359009C51A3 /* MLXLLM */ = { + isa = XCSwiftPackageProductDependency; + package = E09D17502F44F359009C51A3 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */; + productName = MLXLLM; + }; + E09D17532F44F359009C51A3 /* MLXLMCommon */ = { + isa = XCSwiftPackageProductDependency; + package = E09D17502F44F359009C51A3 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */; + productName = MLXLMCommon; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = E09D16672F44CA1E009C51A3 /* Project object */; +} diff --git a/app/EXO-iOS/EXO-iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/EXO-iOS/EXO-iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/EXO-iOS/EXO-iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/app/EXO-iOS/EXO-iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..37f52016 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,60 @@ +{ + "originHash" : "facc0ac7c70363ea20f6cd1235de91dea6b06f0d00190946045a6c8ae753abc2", + "pins" : [ + { + "identity" : "mlx-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ml-explore/mlx-swift", + "state" : { + "revision" : "6ba4827fb82c97d012eec9ab4b2de21f85c3b33d", + "version" : "0.30.6" + } + }, + { + "identity" : "mlx-swift-lm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ml-explore/mlx-swift-lm", + "state" : { + "revision" : "360c5052b81cc154b04ee0933597a4ad6db4b8ae", + "version" : "2.30.3" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-jinja", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-jinja.git", + "state" : { + "revision" : "d81197f35f41445bc10e94600795e68c6f5e94b0", + "version" : "2.3.1" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-transformers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-transformers", + "state" : { + "revision" : "573e5c9036c2f136b3a8a071da8e8907322403d0", + "version" : "1.1.6" + } + } + ], + "version" : 3 +} diff --git a/app/EXO-iOS/EXO-iOS/Assets.xcassets/AccentColor.colorset/Contents.json b/app/EXO-iOS/EXO-iOS/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/EXO-iOS/EXO-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/EXO-iOS/EXO-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/EXO-iOS/EXO-iOS/Assets.xcassets/Contents.json b/app/EXO-iOS/EXO-iOS/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/EXO-iOS/EXO-iOS/EXO-iOS.entitlements b/app/EXO-iOS/EXO-iOS/EXO-iOS.entitlements new file mode 100644 index 00000000..99f47167 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/EXO-iOS.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.kernel.increased-memory-limit + + + diff --git a/app/EXO-iOS/EXO-iOS/EXO_iOSApp.swift b/app/EXO-iOS/EXO-iOS/EXO_iOSApp.swift new file mode 100644 index 00000000..bb8ff050 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/EXO_iOSApp.swift @@ -0,0 +1,42 @@ +import SwiftUI + +@main +struct EXO_iOSApp: App { + @State private var clusterService = ClusterService() + @State private var discoveryService = DiscoveryService() + @State private var localInferenceService = LocalInferenceService() + @State private var chatService: ChatService? + + var body: some Scene { + WindowGroup { + if let chatService { + RootView() + .environment(clusterService) + .environment(discoveryService) + .environment(chatService) + .environment(localInferenceService) + .task { + await clusterService.attemptAutoReconnect() + discoveryService.startBrowsing() + await localInferenceService.prepareModel() + } + .onChange(of: discoveryService.discoveredClusters) { _, clusters in + guard !clusterService.isConnected, + case .disconnected = clusterService.connectionState, + let first = clusters.first + else { return } + Task { + await clusterService.connectToDiscoveredCluster(first, using: discoveryService) + } + } + } else { + Color.clear.onAppear { + chatService = ChatService( + clusterService: clusterService, + localInferenceService: localInferenceService + ) + } + } + } + } +} diff --git a/app/EXO-iOS/EXO-iOS/Info.plist b/app/EXO-iOS/EXO-iOS/Info.plist new file mode 100644 index 00000000..84e859c3 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Info.plist @@ -0,0 +1,17 @@ + + + + + CFBundleDisplayName + EXO + NSLocalNetworkUsageDescription + EXO needs local network access to connect to your EXO cluster. + NSBonjourServices + + _exo._tcp + _p2p._tcp + _p2p._udp + _libp2p._udp + + + diff --git a/app/EXO-iOS/EXO-iOS/Models/ChatCompletionTypes.swift b/app/EXO-iOS/EXO-iOS/Models/ChatCompletionTypes.swift new file mode 100644 index 00000000..28a96d29 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Models/ChatCompletionTypes.swift @@ -0,0 +1,129 @@ +import Foundation + +// MARK: - Request + +struct ChatCompletionRequest: Encodable { + let model: String + let messages: [ChatCompletionMessageParam] + let stream: Bool + let maxTokens: Int? + let temperature: Double? + + enum CodingKeys: String, CodingKey { + case model, messages, stream, temperature + case maxTokens = "max_tokens" + } +} + +struct ChatCompletionMessageParam: Encodable { + let role: String + let content: String +} + +// MARK: - Streaming Response + +struct ChatCompletionChunk: Decodable { + let id: String + let model: String? + let choices: [StreamingChoice] + let usage: ChunkUsage? + + init(id: String, model: String?, choices: [StreamingChoice], usage: ChunkUsage?) { + self.id = id + self.model = model + self.choices = choices + self.usage = usage + } +} + +struct StreamingChoice: Decodable { + let index: Int + let delta: Delta + let finishReason: String? + + enum CodingKeys: String, CodingKey { + case index, delta + case finishReason = "finish_reason" + } + + init(index: Int, delta: Delta, finishReason: String?) { + self.index = index + self.delta = delta + self.finishReason = finishReason + } +} + +struct Delta: Decodable { + let role: String? + let content: String? + + init(role: String?, content: String?) { + self.role = role + self.content = content + } +} + +struct ChunkUsage: Decodable { + 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" + } + + init(promptTokens: Int?, completionTokens: Int?, totalTokens: Int?) { + self.promptTokens = promptTokens + self.completionTokens = completionTokens + self.totalTokens = totalTokens + } +} + +// MARK: - Non-Streaming Response + +struct ChatCompletionResponse: Decodable { + let id: String + let model: String? + let choices: [ResponseChoice] +} + +struct ResponseChoice: Decodable { + let index: Int + let message: ResponseMessage + let finishReason: String? + + enum CodingKeys: String, CodingKey { + case index, message + case finishReason = "finish_reason" + } +} + +struct ResponseMessage: Decodable { + let role: String? + let content: String? +} + +// MARK: - Models List + +struct ModelListResponse: Decodable { + let data: [ModelInfo] +} + +struct ModelInfo: Decodable, Identifiable { + let id: String + let name: String? +} + +// MARK: - Error + +struct APIErrorResponse: Decodable { + let error: APIErrorInfo +} + +struct APIErrorInfo: Decodable { + let message: String + let type: String? + let code: Int? +} diff --git a/app/EXO-iOS/EXO-iOS/Models/ChatMessage.swift b/app/EXO-iOS/EXO-iOS/Models/ChatMessage.swift new file mode 100644 index 00000000..e2177b46 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Models/ChatMessage.swift @@ -0,0 +1,26 @@ +import Foundation + +struct ChatMessage: Identifiable, Equatable { + let id: UUID + let role: Role + var content: String + let timestamp: Date + var isStreaming: Bool + + enum Role: String, Codable { + case user + case assistant + case system + } + + init( + id: UUID = UUID(), role: Role, content: String, timestamp: Date = Date(), + isStreaming: Bool = false + ) { + self.id = id + self.role = role + self.content = content + self.timestamp = timestamp + self.isStreaming = isStreaming + } +} diff --git a/app/EXO-iOS/EXO-iOS/Models/ConnectionInfo.swift b/app/EXO-iOS/EXO-iOS/Models/ConnectionInfo.swift new file mode 100644 index 00000000..e3a0e44b --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Models/ConnectionInfo.swift @@ -0,0 +1,11 @@ +import Foundation + +struct ConnectionInfo: Codable, Equatable { + let host: String + let port: Int + let nodeId: String? + + var baseURL: URL { URL(string: "http://\(host):\(port)")! } + + static let defaultPort = 52415 +} diff --git a/app/EXO-iOS/EXO-iOS/Models/Conversation.swift b/app/EXO-iOS/EXO-iOS/Models/Conversation.swift new file mode 100644 index 00000000..5cadbf97 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Models/Conversation.swift @@ -0,0 +1,34 @@ +import Foundation + +struct Conversation: Identifiable, Codable, Equatable { + let id: UUID + var title: String + var messages: [StoredMessage] + var modelId: String? + let createdAt: Date + + init( + id: UUID = UUID(), title: String = "New Chat", messages: [StoredMessage] = [], + modelId: String? = nil, createdAt: Date = Date() + ) { + self.id = id + self.title = title + self.messages = messages + self.modelId = modelId + self.createdAt = createdAt + } +} + +struct StoredMessage: Identifiable, Codable, Equatable { + let id: UUID + let role: String + var content: String + let timestamp: Date + + init(id: UUID = UUID(), role: String, content: String, timestamp: Date = Date()) { + self.id = id + self.role = role + self.content = content + self.timestamp = timestamp + } +} diff --git a/app/EXO-iOS/EXO-iOS/Services/ChatService.swift b/app/EXO-iOS/EXO-iOS/Services/ChatService.swift new file mode 100644 index 00000000..0490f822 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Services/ChatService.swift @@ -0,0 +1,225 @@ +import Foundation + +@Observable +@MainActor +final class ChatService { + var conversations: [Conversation] = [] + var activeConversationId: UUID? + private(set) var isGenerating: Bool = false + private var currentGenerationTask: Task? + + private let clusterService: ClusterService + private let localInferenceService: LocalInferenceService + + var canSendMessage: Bool { + clusterService.isConnected || localInferenceService.isAvailable + } + + var activeConversation: Conversation? { + guard let id = activeConversationId else { return nil } + return conversations.first { $0.id == id } + } + + var activeMessages: [ChatMessage] { + guard let conversation = activeConversation else { return [] } + return conversation.messages.map { stored in + ChatMessage( + id: stored.id, + role: ChatMessage.Role(rawValue: stored.role) ?? .user, + content: stored.content, + timestamp: stored.timestamp + ) + } + } + + init(clusterService: ClusterService, localInferenceService: LocalInferenceService) { + self.clusterService = clusterService + self.localInferenceService = localInferenceService + loadConversations() + } + + // MARK: - Conversation Management + + func createConversation(modelId: String? = nil) { + let conversation = Conversation( + modelId: modelId ?? clusterService.availableModels.first?.id) + conversations.insert(conversation, at: 0) + activeConversationId = conversation.id + saveConversations() + } + + func deleteConversation(id: UUID) { + conversations.removeAll { $0.id == id } + if activeConversationId == id { + activeConversationId = conversations.first?.id + } + saveConversations() + } + + func setActiveConversation(id: UUID) { + activeConversationId = id + } + + func setModelForActiveConversation(_ modelId: String) { + guard let index = conversations.firstIndex(where: { $0.id == activeConversationId }) else { + return + } + conversations[index].modelId = modelId + saveConversations() + } + + // MARK: - Messaging + + func sendMessage(_ text: String) { + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + + if activeConversation == nil { + createConversation() + } + + guard let index = conversations.firstIndex(where: { $0.id == activeConversationId }) else { + return + } + + let userMessage = StoredMessage(role: "user", content: text) + conversations[index].messages.append(userMessage) + + if conversations[index].title == "New Chat" { + let preview = String(text.prefix(40)) + conversations[index].title = preview + (text.count > 40 ? "..." : "") + } + + let modelId: String + if clusterService.isConnected { + guard let clusterId = conversations[index].modelId ?? clusterService.availableModels.first?.id + else { + let errorMessage = StoredMessage( + role: "assistant", content: "No model selected. Please select a model first.") + conversations[index].messages.append(errorMessage) + saveConversations() + return + } + modelId = clusterId + } else if localInferenceService.isAvailable { + modelId = localInferenceService.defaultModelId + } else { + let errorMessage = StoredMessage( + role: "assistant", + content: "Not connected to a cluster and local model is not available.") + conversations[index].messages.append(errorMessage) + saveConversations() + return + } + + conversations[index].modelId = modelId + + let assistantMessageId = UUID() + let assistantMessage = StoredMessage( + id: assistantMessageId, role: "assistant", content: "", timestamp: Date()) + conversations[index].messages.append(assistantMessage) + + let messagesForAPI = conversations[index].messages.dropLast().map { stored in + ChatCompletionMessageParam(role: stored.role, content: stored.content) + } + + let request = ChatCompletionRequest( + model: modelId, + messages: Array(messagesForAPI), + stream: true, + maxTokens: 4096, + temperature: nil + ) + + let conversationId = conversations[index].id + + isGenerating = true + currentGenerationTask = Task { [weak self] in + guard let self else { return } + await self.performStreaming( + request: request, conversationId: conversationId, + assistantMessageId: assistantMessageId) + } + + saveConversations() + } + + func cancelGeneration() { + currentGenerationTask?.cancel() + currentGenerationTask = nil + localInferenceService.cancelGeneration() + isGenerating = false + } + + // MARK: - Streaming + + private func performStreaming( + request: ChatCompletionRequest, conversationId: UUID, assistantMessageId: UUID + ) async { + defer { + isGenerating = false + currentGenerationTask = nil + saveConversations() + } + + do { + let stream = + clusterService.isConnected + ? clusterService.streamChatCompletion(request: request) + : localInferenceService.streamChatCompletion(request: request) + for try await chunk in stream { + guard !Task.isCancelled else { return } + guard let content = chunk.choices.first?.delta.content, !content.isEmpty else { + continue + } + + if let convIndex = conversations.firstIndex(where: { $0.id == conversationId }), + let msgIndex = conversations[convIndex].messages.firstIndex(where: { + $0.id == assistantMessageId + }) + { + conversations[convIndex].messages[msgIndex].content += content + } + } + } catch { + if !Task.isCancelled { + if let convIndex = conversations.firstIndex(where: { $0.id == conversationId }), + let msgIndex = conversations[convIndex].messages.firstIndex(where: { + $0.id == assistantMessageId + }) + { + if conversations[convIndex].messages[msgIndex].content.isEmpty { + conversations[convIndex].messages[msgIndex].content = + "Error: \(error.localizedDescription)" + } + } + } + } + } + + // MARK: - Persistence + + private static var storageURL: URL { + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + .first! + return documents.appendingPathComponent("exo_conversations.json") + } + + private func saveConversations() { + do { + let data = try JSONEncoder().encode(conversations) + try data.write(to: Self.storageURL, options: .atomic) + } catch { + // Save failed silently + } + } + + private func loadConversations() { + do { + let data = try Data(contentsOf: Self.storageURL) + conversations = try JSONDecoder().decode([Conversation].self, from: data) + activeConversationId = conversations.first?.id + } catch { + conversations = [] + } + } +} diff --git a/app/EXO-iOS/EXO-iOS/Services/ClusterService.swift b/app/EXO-iOS/EXO-iOS/Services/ClusterService.swift new file mode 100644 index 00000000..74dca6a3 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Services/ClusterService.swift @@ -0,0 +1,198 @@ +import Foundation + +enum ConnectionState: Equatable { + case disconnected + case connecting + case connected(ConnectionInfo) +} + +struct ModelOption: Identifiable, Equatable { + let id: String + let displayName: String +} + +@Observable +@MainActor +final class ClusterService { + private(set) var connectionState: ConnectionState = .disconnected + private(set) var availableModels: [ModelOption] = [] + private(set) var lastError: String? + + private let session: URLSession + private let decoder: JSONDecoder + private var pollingTask: Task? + + private static let connectionInfoKey = "exo_last_connection_info" + + var isConnected: Bool { + if case .connected = connectionState { return true } + return false + } + + var currentConnection: ConnectionInfo? { + if case .connected(let info) = connectionState { return info } + return nil + } + + init(session: URLSession = .shared) { + self.session = session + let decoder = JSONDecoder() + self.decoder = decoder + } + + // MARK: - Connection + + func connect(to info: ConnectionInfo) async { + connectionState = .connecting + lastError = nil + + do { + let url = info.baseURL.appendingPathComponent("node_id") + var request = URLRequest(url: url) + request.timeoutInterval = 5 + request.cachePolicy = .reloadIgnoringLocalCacheData + let (_, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) + else { + throw URLError(.badServerResponse) + } + + connectionState = .connected(info) + persistConnection(info) + startPolling() + await fetchModels(baseURL: info.baseURL) + } catch { + connectionState = .disconnected + lastError = "Could not connect to \(info.host):\(info.port)" + } + } + + func connectToDiscoveredCluster(_ cluster: DiscoveredCluster, using discoveryService: DiscoveryService) async { + guard case .disconnected = connectionState else { return } + connectionState = .connecting + lastError = nil + + guard let info = await discoveryService.resolve(cluster) else { + connectionState = .disconnected + lastError = "Could not resolve \(cluster.name)" + return + } + connectionState = .disconnected // reset so connect() can proceed + await connect(to: info) + } + + func disconnect() { + stopPolling() + connectionState = .disconnected + availableModels = [] + lastError = nil + } + + func attemptAutoReconnect() async { + guard case .disconnected = connectionState, + let info = loadPersistedConnection() + else { return } + await connect(to: info) + } + + // MARK: - Polling + + private func startPolling(interval: TimeInterval = 2.0) { + stopPolling() + pollingTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(interval)) + guard let self, !Task.isCancelled else { return } + guard let connection = self.currentConnection else { return } + await self.fetchModels(baseURL: connection.baseURL) + } + } + } + + private func stopPolling() { + pollingTask?.cancel() + pollingTask = nil + } + + // MARK: - API + + private func fetchModels(baseURL: URL) async { + do { + let url = baseURL.appendingPathComponent("models") + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) + else { return } + + let list = try decoder.decode(ModelListResponse.self, from: data) + availableModels = list.data.map { + ModelOption(id: $0.id, displayName: $0.name ?? $0.id) + } + } catch { + // Models fetch failed silently β€” will retry on next poll + } + } + + func streamChatCompletion(request body: ChatCompletionRequest) -> AsyncThrowingStream< + ChatCompletionChunk, Error + > { + AsyncThrowingStream { continuation in + let task = Task { [weak self] in + guard let self, let connection = self.currentConnection else { + continuation.finish(throwing: URLError(.notConnectedToInternet)) + return + } + + do { + let url = connection.baseURL.appendingPathComponent("v1/chat/completions") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + + let (bytes, response) = try await self.session.bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) + else { + continuation.finish(throwing: URLError(.badServerResponse)) + return + } + + let parser = SSEStreamParser( + bytes: bytes, decoder: self.decoder) + for try await chunk in parser { + continuation.yield(chunk) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + // MARK: - Persistence + + private func persistConnection(_ info: ConnectionInfo) { + if let data = try? JSONEncoder().encode(info) { + UserDefaults.standard.set(data, forKey: Self.connectionInfoKey) + } + } + + private func loadPersistedConnection() -> ConnectionInfo? { + guard let data = UserDefaults.standard.data(forKey: Self.connectionInfoKey) else { + return nil + } + return try? JSONDecoder().decode(ConnectionInfo.self, from: data) + } +} diff --git a/app/EXO-iOS/EXO-iOS/Services/DiscoveryService.swift b/app/EXO-iOS/EXO-iOS/Services/DiscoveryService.swift new file mode 100644 index 00000000..c6876e68 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Services/DiscoveryService.swift @@ -0,0 +1,118 @@ +import Foundation +import Network +import os + +struct DiscoveredCluster: Identifiable, Equatable { + let id: String + let name: String + let endpoint: NWEndpoint + + static func == (lhs: DiscoveredCluster, rhs: DiscoveredCluster) -> Bool { + lhs.id == rhs.id && lhs.name == rhs.name + } +} + +@Observable +@MainActor +final class DiscoveryService { + private(set) var discoveredClusters: [DiscoveredCluster] = [] + private(set) var isSearching = false + + private var browser: NWBrowser? + + func startBrowsing() { + guard browser == nil else { return } + + let browser = NWBrowser(for: .bonjour(type: "_exo._tcp", domain: nil), using: .tcp) + + browser.stateUpdateHandler = { [weak self] state in + guard let service = self else { return } + Task { @MainActor in + switch state { + case .ready: + service.isSearching = true + case .failed, .cancelled: + service.isSearching = false + default: + break + } + } + } + + browser.browseResultsChangedHandler = { [weak self] results, _ in + guard let service = self else { return } + Task { @MainActor in + service.discoveredClusters = results.compactMap { result in + guard case .service(let name, _, _, _) = result.endpoint else { + return nil + } + return DiscoveredCluster( + id: name, + name: name, + endpoint: result.endpoint + ) + } + } + } + + browser.start(queue: .main) + self.browser = browser + } + + func stopBrowsing() { + browser?.cancel() + browser = nil + isSearching = false + discoveredClusters = [] + } + + /// Resolve a discovered Bonjour endpoint to an IP address and port, then return a ConnectionInfo. + func resolve(_ cluster: DiscoveredCluster) async -> ConnectionInfo? { + await withCheckedContinuation { continuation in + let didResume = OSAllocatedUnfairLock(initialState: false) + let connection = NWConnection(to: cluster.endpoint, using: .tcp) + connection.stateUpdateHandler = { state in + guard didResume.withLock({ guard !$0 else { return false }; $0 = true; return true }) + else { return } + switch state { + case .ready: + if let innerEndpoint = connection.currentPath?.remoteEndpoint, + case .hostPort(let host, let port) = innerEndpoint + { + var hostString: String + switch host { + case .ipv4(let addr): + hostString = "\(addr)" + case .ipv6(let addr): + hostString = "\(addr)" + case .name(let name, _): + hostString = name + @unknown default: + hostString = "\(host)" + } + // Strip interface scope suffix (e.g. "%en0") + if let pct = hostString.firstIndex(of: "%") { + hostString = String(hostString[..? + + let defaultModelId = "mlx-community/Qwen3-0.6B-4bit" + + private static let modelDownloadedKey = "exo_local_model_downloaded" + + var isReady: Bool { + modelState == .ready + } + + var isAvailable: Bool { + modelState == .ready || modelState == .generating + } + + init() { + if UserDefaults.standard.bool(forKey: Self.modelDownloadedKey) { + modelState = .downloaded + } + } + + // MARK: - Model Lifecycle + + func prepareModel() async { + guard modelState == .notDownloaded || modelState == .downloaded else { return } + + let wasDownloaded = modelState == .downloaded + + if !wasDownloaded { + modelState = .downloading(progress: 0) + } else { + modelState = .loading + } + + do { + let container = try await loadModelContainer( + id: defaultModelId + ) { [weak self] progress in + guard let self else { return } + Task { @MainActor in + if case .downloading = self.modelState { + self.modelState = .downloading(progress: progress.fractionCompleted) + } + } + } + + self.modelContainer = container + UserDefaults.standard.set(true, forKey: Self.modelDownloadedKey) + modelState = .ready + } catch { + modelState = .error(error.localizedDescription) + } + } + + func unloadModel() { + cancelGeneration() + modelContainer = nil + modelState = .downloaded + } + + // MARK: - Generation + + func streamChatCompletion(request: ChatCompletionRequest) -> AsyncThrowingStream< + ChatCompletionChunk, Error + > { + AsyncThrowingStream { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish(throwing: LocalInferenceError.serviceUnavailable) + return + } + + guard let container = self.modelContainer else { + continuation.finish(throwing: LocalInferenceError.modelNotLoaded) + return + } + + await MainActor.run { + self.modelState = .generating + } + + defer { + Task { @MainActor [weak self] in + if self?.modelState == .generating { + self?.modelState = .ready + } + } + } + + let chunkId = "local-\(UUID().uuidString)" + + do { + // Build Chat.Message array from the request + var chatMessages: [Chat.Message] = [] + for msg in request.messages { + switch msg.role { + case "system": + chatMessages.append(.system(msg.content)) + case "assistant": + chatMessages.append(.assistant(msg.content)) + default: + chatMessages.append(.user(msg.content)) + } + } + + // Use ChatSession for streaming generation + let session = ChatSession( + container, + history: chatMessages, + generateParameters: GenerateParameters( + maxTokens: request.maxTokens ?? 4096, + temperature: Float(request.temperature ?? 0.7) + ) + ) + + // Stream with an empty prompt since history already contains the conversation + let stream = session.streamResponse(to: "") + for try await text in stream { + if Task.isCancelled { break } + + let chunk = ChatCompletionChunk( + id: chunkId, + model: request.model, + choices: [ + StreamingChoice( + index: 0, + delta: Delta(role: nil, content: text), + finishReason: nil + ) + ], + usage: nil + ) + continuation.yield(chunk) + } + + // Send final chunk with finish reason + let finalChunk = ChatCompletionChunk( + id: chunkId, + model: request.model, + choices: [ + StreamingChoice( + index: 0, + delta: Delta(role: nil, content: nil), + finishReason: "stop" + ) + ], + usage: nil + ) + continuation.yield(finalChunk) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + self.generationTask = task + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + func cancelGeneration() { + generationTask?.cancel() + generationTask = nil + if modelState == .generating { + modelState = .ready + } + } +} + +enum LocalInferenceError: LocalizedError { + case serviceUnavailable + case modelNotLoaded + + var errorDescription: String? { + switch self { + case .serviceUnavailable: "Local inference service is unavailable" + case .modelNotLoaded: "Local model is not loaded" + } + } +} diff --git a/app/EXO-iOS/EXO-iOS/Services/SSEStreamParser.swift b/app/EXO-iOS/EXO-iOS/Services/SSEStreamParser.swift new file mode 100644 index 00000000..b64ea418 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Services/SSEStreamParser.swift @@ -0,0 +1,50 @@ +import Foundation + +struct SSEStreamParser: AsyncSequence { + typealias Element = T + + let bytes: URLSession.AsyncBytes + let decoder: JSONDecoder + + init(bytes: URLSession.AsyncBytes, decoder: JSONDecoder = JSONDecoder()) { + self.bytes = bytes + self.decoder = decoder + } + + func makeAsyncIterator() -> AsyncIterator { + AsyncIterator(lines: bytes.lines, decoder: decoder) + } + + struct AsyncIterator: AsyncIteratorProtocol { + var lines: AsyncLineSequence.AsyncIterator + let decoder: JSONDecoder + + init(lines: AsyncLineSequence, decoder: JSONDecoder) { + self.lines = lines.makeAsyncIterator() + self.decoder = decoder + } + + mutating func next() async throws -> T? { + while let line = try await lines.next() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + guard trimmed.hasPrefix("data: ") else { continue } + + let payload = String(trimmed.dropFirst(6)) + + if payload == "[DONE]" { + return nil + } + + guard let data = payload.data(using: .utf8) else { continue } + + do { + return try decoder.decode(T.self, from: data) + } catch { + continue + } + } + return nil + } + } +} diff --git a/app/EXO-iOS/EXO-iOS/Views/Chat/ChatView.swift b/app/EXO-iOS/EXO-iOS/Views/Chat/ChatView.swift new file mode 100644 index 00000000..7aef6172 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Views/Chat/ChatView.swift @@ -0,0 +1,171 @@ +import SwiftUI + +struct ChatView: View { + @Environment(ClusterService.self) private var clusterService + @Environment(ChatService.self) private var chatService + @Environment(LocalInferenceService.self) private var localInferenceService + @State private var inputText = "" + @State private var showModelSelector = false + + var body: some View { + VStack(spacing: 0) { + modelBar + + Divider() + + messageList + + Divider() + + inputBar + } + .sheet(isPresented: $showModelSelector) { + ModelSelectorView( + models: clusterService.availableModels, + selectedModelId: chatService.activeConversation?.modelId + ) { modelId in + chatService.setModelForActiveConversation(modelId) + } + } + } + + // MARK: - Model Bar + + private var useLocalModel: Bool { + !clusterService.isConnected && localInferenceService.isAvailable + } + + private var modelBar: some View { + Button { + if !useLocalModel { + showModelSelector = true + } + } label: { + HStack { + Image(systemName: useLocalModel ? "iphone" : "cpu") + .foregroundStyle(useLocalModel ? .blue : .secondary) + + if useLocalModel { + Text(localInferenceService.defaultModelId) + .font(.subheadline) + .lineLimit(1) + } else if let modelId = chatService.activeConversation?.modelId { + Text(modelId) + .font(.subheadline) + .lineLimit(1) + } else { + Text("Select Model") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + if useLocalModel { + Text("On-Device") + .font(.caption2) + .foregroundStyle(.blue) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.blue.opacity(0.1)) + .clipShape(Capsule()) + } else { + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal) + .padding(.vertical, 10) + .background(Color(.secondarySystemBackground)) + } + .tint(.primary) + .disabled(useLocalModel) + } + + // MARK: - Messages + + private var messageList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 12) { + if chatService.activeMessages.isEmpty { + emptyState + } else { + ForEach(chatService.activeMessages) { message in + MessageBubbleView(message: message) + .id(message.id) + } + } + } + .padding() + } + .onChange(of: chatService.activeMessages.last?.content) { + if let lastId = chatService.activeMessages.last?.id { + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(lastId, anchor: .bottom) + } + } + } + } + } + + private var emptyState: some View { + VStack(spacing: 12) { + Spacer(minLength: 80) + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 48)) + .foregroundStyle(.tertiary) + Text("Start a conversation") + .font(.headline) + .foregroundStyle(.secondary) + Text("Send a message to begin chatting with the model.") + .font(.subheadline) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + Spacer(minLength: 80) + } + .padding() + } + + // MARK: - Input + + private var inputBar: some View { + HStack(alignment: .bottom, spacing: 8) { + TextField("Message...", text: $inputText, axis: .vertical) + .lineLimit(1...6) + .textFieldStyle(.plain) + .padding(10) + .background(Color(.tertiarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 20)) + + if chatService.isGenerating { + Button { + chatService.cancelGeneration() + } label: { + Image(systemName: "stop.circle.fill") + .font(.title2) + .foregroundStyle(.red) + } + } else { + Button { + let text = inputText + inputText = "" + chatService.sendMessage(text) + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.title2) + .foregroundStyle(canSend ? Color.accentColor : Color.gray) + } + .disabled(!canSend) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } + + private var canSend: Bool { + !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && (clusterService.isConnected || localInferenceService.isAvailable) + } +} diff --git a/app/EXO-iOS/EXO-iOS/Views/Chat/MessageBubbleView.swift b/app/EXO-iOS/EXO-iOS/Views/Chat/MessageBubbleView.swift new file mode 100644 index 00000000..9f8d1c21 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Views/Chat/MessageBubbleView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct MessageBubbleView: View { + let message: ChatMessage + + var body: some View { + HStack { + if message.role == .user { Spacer(minLength: 48) } + + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 4) { + Text(message.content + (message.isStreaming ? " \u{258C}" : "")) + .textSelection(.enabled) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(bubbleBackground) + .foregroundStyle(message.role == .user ? .white : .primary) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + if message.role == .assistant { Spacer(minLength: 48) } + } + } + + private var bubbleBackground: Color { + message.role == .user ? .accentColor : Color(.secondarySystemBackground) + } +} diff --git a/app/EXO-iOS/EXO-iOS/Views/Chat/ModelSelectorView.swift b/app/EXO-iOS/EXO-iOS/Views/Chat/ModelSelectorView.swift new file mode 100644 index 00000000..1ddf7b0d --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Views/Chat/ModelSelectorView.swift @@ -0,0 +1,66 @@ +import SwiftUI + +struct ModelSelectorView: View { + let models: [ModelOption] + let selectedModelId: String? + let onSelect: (String) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + if models.isEmpty { + emptyContent + } else { + modelsList + } + } + .navigationTitle("Select Model") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + } + + private var emptyContent: some View { + ContentUnavailableView( + "No Models Available", + systemImage: "cpu", + description: Text("Connect to an EXO cluster to see available models.") + ) + } + + private var modelsList: some View { + ForEach(models) { model in + Button { + onSelect(model.id) + dismiss() + } label: { + modelRow(model) + } + .tint(.primary) + } + } + + private func modelRow(_ model: ModelOption) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(model.displayName) + .fontWeight(.medium) + Text(model.id) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if model.id == selectedModelId { + Image(systemName: "checkmark") + .foregroundStyle(Color.accentColor) + } + } + } +} diff --git a/app/EXO-iOS/EXO-iOS/Views/Components/ConnectionStatusBadge.swift b/app/EXO-iOS/EXO-iOS/Views/Components/ConnectionStatusBadge.swift new file mode 100644 index 00000000..3cfa9997 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Views/Components/ConnectionStatusBadge.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct ConnectionStatusBadge: View { + let connectionState: ConnectionState + var localModelState: LocalModelState = .notDownloaded + + private var isLocalReady: Bool { + if case .disconnected = connectionState { + return localModelState == .ready || localModelState == .generating + } + return false + } + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(dotColor) + .frame(width: 8, height: 8) + + Text(label) + .font(.caption) + .fontWeight(.medium) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(backgroundColor) + .clipShape(Capsule()) + } + + private var dotColor: Color { + if isLocalReady { + return .blue + } + switch connectionState { + case .connected: return .green + case .connecting: return .orange + case .disconnected: return .gray + } + } + + private var label: String { + if isLocalReady { + return "Local" + } + switch connectionState { + case .connected: return "Connected" + case .connecting: return "Connecting..." + case .disconnected: return "Disconnected" + } + } + + private var backgroundColor: Color { + if isLocalReady { + return .blue.opacity(0.15) + } + switch connectionState { + case .connected: return .green.opacity(0.15) + case .connecting: return .orange.opacity(0.15) + case .disconnected: return .gray.opacity(0.15) + } + } +} diff --git a/app/EXO-iOS/EXO-iOS/Views/RootView.swift b/app/EXO-iOS/EXO-iOS/Views/RootView.swift new file mode 100644 index 00000000..caea41c5 --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Views/RootView.swift @@ -0,0 +1,117 @@ +import SwiftUI + +struct RootView: View { + @Environment(ClusterService.self) private var clusterService + @Environment(DiscoveryService.self) private var discoveryService + @Environment(ChatService.self) private var chatService + @Environment(LocalInferenceService.self) private var localInferenceService + @State private var showSettings = false + @State private var showConversations = false + + var body: some View { + NavigationStack { + ChatView() + .navigationTitle("EXO") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + conversationMenuButton + } + + ToolbarItem(placement: .principal) { + ConnectionStatusBadge( + connectionState: clusterService.connectionState, + localModelState: localInferenceService.modelState + ) + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + showSettings = true + } label: { + Image(systemName: "gear") + } + } + } + } + .sheet(isPresented: $showSettings) { + SettingsView() + .environment(discoveryService) + } + .sheet(isPresented: $showConversations) { + conversationList + } + } + + // MARK: - Conversations + + private var conversationMenuButton: some View { + HStack(spacing: 12) { + Button { + showConversations = true + } label: { + Image(systemName: "sidebar.left") + } + + Button { + chatService.createConversation() + } label: { + Image(systemName: "square.and.pencil") + } + } + } + + private var conversationList: some View { + NavigationStack { + List { + if chatService.conversations.isEmpty { + Text("No conversations yet") + .foregroundStyle(.secondary) + } else { + ForEach(chatService.conversations) { conversation in + Button { + chatService.setActiveConversation(id: conversation.id) + showConversations = false + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(conversation.title) + .fontWeight( + conversation.id == chatService.activeConversationId + ? .semibold : .regular + ) + .lineLimit(1) + + if let modelId = conversation.modelId { + Text(modelId) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .tint(.primary) + } + .onDelete { indexSet in + for index in indexSet { + chatService.deleteConversation(id: chatService.conversations[index].id) + } + } + } + } + .navigationTitle("Conversations") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showConversations = false } + } + ToolbarItem(placement: .topBarLeading) { + Button { + chatService.createConversation() + } label: { + Image(systemName: "plus") + } + } + } + } + } +} diff --git a/app/EXO-iOS/EXO-iOS/Views/Settings/SettingsView.swift b/app/EXO-iOS/EXO-iOS/Views/Settings/SettingsView.swift new file mode 100644 index 00000000..c28cce5a --- /dev/null +++ b/app/EXO-iOS/EXO-iOS/Views/Settings/SettingsView.swift @@ -0,0 +1,197 @@ +import SwiftUI + +struct SettingsView: View { + @Environment(ClusterService.self) private var clusterService + @Environment(DiscoveryService.self) private var discoveryService + @Environment(LocalInferenceService.self) private var localInferenceService + @Environment(\.dismiss) private var dismiss + @State private var host: String = "" + @State private var port: String = "52415" + + var body: some View { + NavigationStack { + Form { + localModelSection + nearbyClustersSection + connectionSection + statusSection + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } + + private var localModelSection: some View { + Section { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(localInferenceService.defaultModelId) + .font(.subheadline) + .fontWeight(.medium) + + Text(localModelStatusText) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + localModelActionButton + } + + if case .downloading(let progress) = localInferenceService.modelState { + ProgressView(value: progress) + .tint(.blue) + } + } header: { + Text("Local Model") + } footer: { + Text("When disconnected from a cluster, messages are processed on-device using this model.") + } + } + + private var localModelStatusText: String { + switch localInferenceService.modelState { + case .notDownloaded: "Not downloaded" + case .downloading(let progress): "Downloading \(Int(progress * 100))%..." + case .downloaded: "Downloaded β€” not loaded" + case .loading: "Loading into memory..." + case .ready: "Ready" + case .generating: "Generating..." + case .error(let message): "Error: \(message)" + } + } + + @ViewBuilder + private var localModelActionButton: some View { + switch localInferenceService.modelState { + case .notDownloaded: + Button("Download") { + Task { await localInferenceService.prepareModel() } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + case .downloading: + ProgressView() + .controlSize(.small) + case .downloaded: + Button("Load") { + Task { await localInferenceService.prepareModel() } + } + .buttonStyle(.bordered) + .controlSize(.small) + case .loading: + ProgressView() + .controlSize(.small) + case .ready, .generating: + Button("Unload") { + localInferenceService.unloadModel() + } + .buttonStyle(.bordered) + .controlSize(.small) + case .error: + Button("Retry") { + Task { await localInferenceService.prepareModel() } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .tint(.red) + } + } + + private var nearbyClustersSection: some View { + Section { + if discoveryService.discoveredClusters.isEmpty { + if discoveryService.isSearching { + HStack { + ProgressView() + .padding(.trailing, 8) + Text("Searching for clusters...") + .foregroundStyle(.secondary) + } + } else { + Text("No clusters found") + .foregroundStyle(.secondary) + } + } else { + ForEach(discoveryService.discoveredClusters) { cluster in + HStack { + VStack(alignment: .leading) { + Text(cluster.name) + .font(.body) + } + Spacer() + Button("Connect") { + Task { + await clusterService.connectToDiscoveredCluster( + cluster, using: discoveryService + ) + if clusterService.isConnected { + dismiss() + } + } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + } + } header: { + Text("Nearby Clusters") + } + } + + private var connectionSection: some View { + Section("Manual Connection") { + TextField("IP Address (e.g. 192.168.1.42)", text: $host) + .keyboardType(.decimalPad) + .textContentType(.URL) + .autocorrectionDisabled() + + TextField("Port", text: $port) + .keyboardType(.numberPad) + + Button(clusterService.isConnected ? "Reconnect" : "Connect") { + Task { + let portNum = Int(port) ?? ConnectionInfo.defaultPort + let info = ConnectionInfo(host: host, port: portNum, nodeId: nil) + await clusterService.connect(to: info) + if clusterService.isConnected { + dismiss() + } + } + } + .disabled(host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + private var statusSection: some View { + Section("Status") { + if let connection = clusterService.currentConnection { + LabeledContent("Host", value: connection.host) + LabeledContent("Port", value: "\(connection.port)") + if let nodeId = connection.nodeId { + LabeledContent("Node ID", value: String(nodeId.prefix(12)) + "...") + } + LabeledContent("Models", value: "\(clusterService.availableModels.count)") + + Button("Disconnect", role: .destructive) { + clusterService.disconnect() + } + } else { + if let error = clusterService.lastError { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } else { + Text("Not connected") + .foregroundStyle(.secondary) + } + } + } + } +} diff --git a/app/EXO-iOS/EXO-iOSTests/EXO_iOSTests.swift b/app/EXO-iOS/EXO-iOSTests/EXO_iOSTests.swift new file mode 100644 index 00000000..b50e01a8 --- /dev/null +++ b/app/EXO-iOS/EXO-iOSTests/EXO_iOSTests.swift @@ -0,0 +1,18 @@ +// +// EXO_iOSTests.swift +// EXO-iOSTests +// +// Created by Sami Khan on 2026-02-17. +// + +import Testing + +@testable import EXO_iOS + +struct EXO_iOSTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/app/EXO-iOS/EXO-iOSUITests/EXO_iOSUITests.swift b/app/EXO-iOS/EXO-iOSUITests/EXO_iOSUITests.swift new file mode 100644 index 00000000..d34bae75 --- /dev/null +++ b/app/EXO-iOS/EXO-iOSUITests/EXO_iOSUITests.swift @@ -0,0 +1,41 @@ +// +// EXO_iOSUITests.swift +// EXO-iOSUITests +// +// Created by Sami Khan on 2026-02-17. +// + +import XCTest + +final class EXO_iOSUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/app/EXO-iOS/EXO-iOSUITests/EXO_iOSUITestsLaunchTests.swift b/app/EXO-iOS/EXO-iOSUITests/EXO_iOSUITestsLaunchTests.swift new file mode 100644 index 00000000..44b88d4d --- /dev/null +++ b/app/EXO-iOS/EXO-iOSUITests/EXO_iOSUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// EXO_iOSUITestsLaunchTests.swift +// EXO-iOSUITests +// +// Created by Sami Khan on 2026-02-17. +// + +import XCTest + +final class EXO_iOSUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/src/exo/master/api.py b/src/exo/master/api.py index b8476334..ba8262aa 100644 --- a/src/exo/master/api.py +++ b/src/exo/master/api.py @@ -1367,6 +1367,7 @@ class API: async def run(self): shutdown_ev = anyio.Event() + bonjour_cleanup = self._register_bonjour_service() try: async with create_task_group() as tg: self._tg = tg @@ -1382,10 +1383,38 @@ class API: with anyio.CancelScope(shield=True): shutdown_ev.set() finally: + bonjour_cleanup() self._event_log.close() self.command_sender.close() self.global_event_receiver.close() + def _register_bonjour_service(self) -> Callable[[], None]: + """Register a Bonjour service via the system mDNSResponder. Returns a cleanup function.""" + import subprocess + import sys + + if sys.platform != "darwin": + logger.info("Bonjour service registration is only supported on macOS") + return lambda: None + + service_name = f"EXO Cluster ({self.node_id[:8]})" + try: + proc = subprocess.Popen( + ["dns-sd", "-R", service_name, "_exo._tcp", "local", str(self.port), f"node_id={self.node_id}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + logger.info(f"Registered Bonjour service _exo._tcp on port {self.port} (pid {proc.pid})") + + def cleanup() -> None: + proc.terminate() + proc.wait() + + return cleanup + except Exception as e: + logger.warning(f"Failed to register Bonjour service: {e}") + return lambda: None + async def run_api(self, ev: anyio.Event): cfg = Config() cfg.bind = [f"0.0.0.0:{self.port}"]