commit 28f450c03f6eb7acc760fc2a1b7fd60698a5b659 Author: Sacha Servan-Schreiber <8027773+sachaservan@users.noreply.github.com> Date: Wed Mar 25 14:28:54 2026 -0700 first commit diff --git a/SwiftChat.xcodeproj/project.pbxproj b/SwiftChat.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b70a750 --- /dev/null +++ b/SwiftChat.xcodeproj/project.pbxproj @@ -0,0 +1,692 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + AA000001AAAA000100000001 /* OpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = AA000011AAAA001100000011 /* OpenAI */; }; + AA000002AAAA000200000002 /* Textual in Frameworks */ = {isa = PBXBuildFile; productRef = AA000012AAAA001200000012 /* Textual */; }; + AA000003AAAA000300000003 /* SwiftMath in Frameworks */ = {isa = PBXBuildFile; productRef = AA000013AAAA001300000013 /* SwiftMath */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 99304C8C2F743BEE00E4DDD0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 99304C762F743BED00E4DDD0 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 99304C7D2F743BED00E4DDD0; + remoteInfo = SwiftChat; + }; + 99304C962F743BEE00E4DDD0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 99304C762F743BED00E4DDD0 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 99304C7D2F743BED00E4DDD0; + remoteInfo = SwiftChat; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 99304C7E2F743BED00E4DDD0 /* SwiftChat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftChat.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 99304C8B2F743BEE00E4DDD0 /* SwiftChatTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftChatTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 99304C952F743BEE00E4DDD0 /* SwiftChatUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftChatUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 99304C802F743BED00E4DDD0 /* SwiftChat */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SwiftChat; + sourceTree = ""; + }; + 99304C8E2F743BEE00E4DDD0 /* SwiftChatTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SwiftChatTests; + sourceTree = ""; + }; + 99304C982F743BEE00E4DDD0 /* SwiftChatUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SwiftChatUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 99304C7B2F743BED00E4DDD0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AA000001AAAA000100000001 /* OpenAI in Frameworks */, + AA000002AAAA000200000002 /* Textual in Frameworks */, + AA000003AAAA000300000003 /* SwiftMath in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 99304C882F743BEE00E4DDD0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 99304C922F743BEE00E4DDD0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 99304C752F743BED00E4DDD0 = { + isa = PBXGroup; + children = ( + 99304C802F743BED00E4DDD0 /* SwiftChat */, + 99304C8E2F743BEE00E4DDD0 /* SwiftChatTests */, + 99304C982F743BEE00E4DDD0 /* SwiftChatUITests */, + 99304C7F2F743BED00E4DDD0 /* Products */, + ); + sourceTree = ""; + }; + 99304C7F2F743BED00E4DDD0 /* Products */ = { + isa = PBXGroup; + children = ( + 99304C7E2F743BED00E4DDD0 /* SwiftChat.app */, + 99304C8B2F743BEE00E4DDD0 /* SwiftChatTests.xctest */, + 99304C952F743BEE00E4DDD0 /* SwiftChatUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 99304C7D2F743BED00E4DDD0 /* SwiftChat */ = { + isa = PBXNativeTarget; + buildConfigurationList = 99304C9F2F743BEE00E4DDD0 /* Build configuration list for PBXNativeTarget "SwiftChat" */; + buildPhases = ( + 99304C7A2F743BED00E4DDD0 /* Sources */, + 99304C7B2F743BED00E4DDD0 /* Frameworks */, + 99304C7C2F743BED00E4DDD0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 99304C802F743BED00E4DDD0 /* SwiftChat */, + ); + name = SwiftChat; + packageProductDependencies = ( + AA000011AAAA001100000011 /* OpenAI */, + AA000012AAAA001200000012 /* Textual */, + AA000013AAAA001300000013 /* SwiftMath */, + ); + productName = SwiftChat; + productReference = 99304C7E2F743BED00E4DDD0 /* SwiftChat.app */; + productType = "com.apple.product-type.application"; + }; + 99304C8A2F743BEE00E4DDD0 /* SwiftChatTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 99304CA22F743BEE00E4DDD0 /* Build configuration list for PBXNativeTarget "SwiftChatTests" */; + buildPhases = ( + 99304C872F743BEE00E4DDD0 /* Sources */, + 99304C882F743BEE00E4DDD0 /* Frameworks */, + 99304C892F743BEE00E4DDD0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 99304C8D2F743BEE00E4DDD0 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 99304C8E2F743BEE00E4DDD0 /* SwiftChatTests */, + ); + name = SwiftChatTests; + packageProductDependencies = ( + ); + productName = SwiftChatTests; + productReference = 99304C8B2F743BEE00E4DDD0 /* SwiftChatTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 99304C942F743BEE00E4DDD0 /* SwiftChatUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 99304CA52F743BEE00E4DDD0 /* Build configuration list for PBXNativeTarget "SwiftChatUITests" */; + buildPhases = ( + 99304C912F743BEE00E4DDD0 /* Sources */, + 99304C922F743BEE00E4DDD0 /* Frameworks */, + 99304C932F743BEE00E4DDD0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 99304C972F743BEE00E4DDD0 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 99304C982F743BEE00E4DDD0 /* SwiftChatUITests */, + ); + name = SwiftChatUITests; + packageProductDependencies = ( + ); + productName = SwiftChatUITests; + productReference = 99304C952F743BEE00E4DDD0 /* SwiftChatUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 99304C762F743BED00E4DDD0 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2620; + TargetAttributes = { + 99304C7D2F743BED00E4DDD0 = { + CreatedOnToolsVersion = 26.2; + }; + 99304C8A2F743BEE00E4DDD0 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 99304C7D2F743BED00E4DDD0; + }; + 99304C942F743BEE00E4DDD0 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 99304C7D2F743BED00E4DDD0; + }; + }; + }; + buildConfigurationList = 99304C792F743BED00E4DDD0 /* Build configuration list for PBXProject "SwiftChat" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 99304C752F743BED00E4DDD0; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + AA000021AAAA002100000021 /* XCRemoteSwiftPackageReference "openai-swift-fork" */, + AA000022AAAA002200000022 /* XCRemoteSwiftPackageReference "textual" */, + AA000023AAAA002300000023 /* XCRemoteSwiftPackageReference "SwiftMath" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 99304C7F2F743BED00E4DDD0 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 99304C7D2F743BED00E4DDD0 /* SwiftChat */, + 99304C8A2F743BEE00E4DDD0 /* SwiftChatTests */, + 99304C942F743BEE00E4DDD0 /* SwiftChatUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 99304C7C2F743BED00E4DDD0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 99304C892F743BEE00E4DDD0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 99304C932F743BEE00E4DDD0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 99304C7A2F743BED00E4DDD0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 99304C872F743BEE00E4DDD0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 99304C912F743BEE00E4DDD0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 99304C8D2F743BEE00E4DDD0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 99304C7D2F743BED00E4DDD0 /* SwiftChat */; + targetProxy = 99304C8C2F743BEE00E4DDD0 /* PBXContainerItemProxy */; + }; + 99304C972F743BEE00E4DDD0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 99304C7D2F743BED00E4DDD0 /* SwiftChat */; + targetProxy = 99304C962F743BEE00E4DDD0 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 99304C9D2F743BEE00E4DDD0 /* 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; + DEAD_CODE_STRIPPING = YES; + 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; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 99304C9E2F743BEE00E4DDD0 /* 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; + DEAD_CODE_STRIPPING = YES; + 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; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 99304CA02F743BEE00E4DDD0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APP_ICON = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + ASSETCATALOG_OTHER_FLAGS = "--enable-icon-stack-fallback-generation=disabled"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + ENABLE_APP_SANDBOX = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Swift Chat"; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "SwiftChat needs microphone access for voice input."; + "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.3s.SwiftChat; + 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; + }; + 99304CA12F743BEE00E4DDD0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APP_ICON = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + ASSETCATALOG_OTHER_FLAGS = "--enable-icon-stack-fallback-generation=disabled"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + ENABLE_APP_SANDBOX = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Swift Chat"; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "SwiftChat needs microphone access for voice input."; + "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.3s.SwiftChat; + 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; + }; + 99304CA32F743BEE00E4DDD0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.3s.SwiftChatTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + 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,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftChat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SwiftChat"; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Debug; + }; + 99304CA42F743BEE00E4DDD0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.3s.SwiftChatTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + 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,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftChat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SwiftChat"; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Release; + }; + 99304CA62F743BEE00E4DDD0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.3s.SwiftChatUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + 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,7"; + TEST_TARGET_NAME = SwiftChat; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Debug; + }; + 99304CA72F743BEE00E4DDD0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.3s.SwiftChatUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + 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,7"; + TEST_TARGET_NAME = SwiftChat; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 99304C792F743BED00E4DDD0 /* Build configuration list for PBXProject "SwiftChat" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 99304C9D2F743BEE00E4DDD0 /* Debug */, + 99304C9E2F743BEE00E4DDD0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 99304C9F2F743BEE00E4DDD0 /* Build configuration list for PBXNativeTarget "SwiftChat" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 99304CA02F743BEE00E4DDD0 /* Debug */, + 99304CA12F743BEE00E4DDD0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 99304CA22F743BEE00E4DDD0 /* Build configuration list for PBXNativeTarget "SwiftChatTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 99304CA32F743BEE00E4DDD0 /* Debug */, + 99304CA42F743BEE00E4DDD0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 99304CA52F743BEE00E4DDD0 /* Build configuration list for PBXNativeTarget "SwiftChatUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 99304CA62F743BEE00E4DDD0 /* Debug */, + 99304CA72F743BEE00E4DDD0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + AA000021AAAA002100000021 /* XCRemoteSwiftPackageReference "openai-swift-fork" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tinfoilsh/openai-swift-fork.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.0.4; + }; + }; + AA000022AAAA002200000022 /* XCRemoteSwiftPackageReference "textual" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tinfoilsh/textual"; + requirement = { + branch = main; + kind = branch; + }; + }; + AA000023AAAA002300000023 /* XCRemoteSwiftPackageReference "SwiftMath" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/mgriebling/SwiftMath"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.7.3; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + AA000011AAAA001100000011 /* OpenAI */ = { + isa = XCSwiftPackageProductDependency; + package = AA000021AAAA002100000021 /* XCRemoteSwiftPackageReference "openai-swift-fork" */; + productName = OpenAI; + }; + AA000012AAAA001200000012 /* Textual */ = { + isa = XCSwiftPackageProductDependency; + package = AA000022AAAA002200000022 /* XCRemoteSwiftPackageReference "textual" */; + productName = Textual; + }; + AA000013AAAA001300000013 /* SwiftMath */ = { + isa = XCSwiftPackageProductDependency; + package = AA000023AAAA002300000023 /* XCRemoteSwiftPackageReference "SwiftMath" */; + productName = SwiftMath; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 99304C762F743BED00E4DDD0 /* Project object */; +} diff --git a/SwiftChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/SwiftChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/SwiftChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/SwiftChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..78eae86 --- /dev/null +++ b/SwiftChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,69 @@ +{ + "originHash" : "d64b502adaec5c1be21961b075b64183f1a9f190b4aa2a70d9ac3e97223c7ebe", + "pins" : [ + { + "identity" : "openai-swift-fork", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tinfoilsh/openai-swift-fork.git", + "state" : { + "revision" : "fb076a51a97b56c3cc2ba4c9e8bcc9e88f59d53f", + "version" : "0.0.4" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "f039fa6d6338aab5164f3d1be16281524c9a8f89", + "version" : "1.11.0" + } + }, + { + "identity" : "swiftmath", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mgriebling/SwiftMath", + "state" : { + "revision" : "fa8244ed032f4a1ade4cb0571bf87d2f1a9fd2d7", + "version" : "1.7.3" + } + }, + { + "identity" : "swiftui-math", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swiftui-math", + "state" : { + "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", + "version" : "0.1.0" + } + }, + { + "identity" : "textual", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tinfoilsh/textual", + "state" : { + "branch" : "main", + "revision" : "d255c23028345314fac085685e49447b7c498772" + } + } + ], + "version" : 3 +} diff --git a/SwiftChat.xcodeproj/project.xcworkspace/xcuserdata/Sacha.xcuserdatad/UserInterfaceState.xcuserstate b/SwiftChat.xcodeproj/project.xcworkspace/xcuserdata/Sacha.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..679cf10 Binary files /dev/null and b/SwiftChat.xcodeproj/project.xcworkspace/xcuserdata/Sacha.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/SwiftChat.xcodeproj/xcuserdata/Sacha.xcuserdatad/xcschemes/xcschememanagement.plist b/SwiftChat.xcodeproj/xcuserdata/Sacha.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..555edbf --- /dev/null +++ b/SwiftChat.xcodeproj/xcuserdata/Sacha.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + SwiftChat.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/SwiftChat/Assets.xcassets/AccentColor.colorset/Contents.json b/SwiftChat/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/SwiftChat/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftChat/Assets.xcassets/AppIcon.appiconset/Contents.json b/SwiftChat/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..054adb3 --- /dev/null +++ b/SwiftChat/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,88 @@ +{ + "images" : [ + { + "filename" : "Icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "Icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftChat/Assets.xcassets/AppIcon.appiconset/Icon-1024.png b/SwiftChat/Assets.xcassets/AppIcon.appiconset/Icon-1024.png new file mode 100644 index 0000000..1cf9c37 Binary files /dev/null and b/SwiftChat/Assets.xcassets/AppIcon.appiconset/Icon-1024.png differ diff --git a/SwiftChat/Assets.xcassets/Contents.json b/SwiftChat/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SwiftChat/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftChat/Assets.xcassets/LaunchColor.colorset/Contents.json b/SwiftChat/Assets.xcassets/LaunchColor.colorset/Contents.json new file mode 100644 index 0000000..76c3cd7 --- /dev/null +++ b/SwiftChat/Assets.xcassets/LaunchColor.colorset/Contents.json @@ -0,0 +1,47 @@ +{ + "colors" : [ + { + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.090", + "green" : "0.090", + "red" : "0.090" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftChat/Assets.xcassets/openai-icon.imageset/Contents.json b/SwiftChat/Assets.xcassets/openai-icon.imageset/Contents.json new file mode 100644 index 0000000..66cd201 --- /dev/null +++ b/SwiftChat/Assets.xcassets/openai-icon.imageset/Contents.json @@ -0,0 +1,56 @@ +{ + "images" : [ + { + "filename" : "openai.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "openai-white 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "openai@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "openai-white@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "openai@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "openai-white@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftChat/Assets.xcassets/openai-icon.imageset/openai-white 1.png b/SwiftChat/Assets.xcassets/openai-icon.imageset/openai-white 1.png new file mode 100644 index 0000000..e1c7a1f Binary files /dev/null and b/SwiftChat/Assets.xcassets/openai-icon.imageset/openai-white 1.png differ diff --git a/SwiftChat/Assets.xcassets/openai-icon.imageset/openai-white@2x.png b/SwiftChat/Assets.xcassets/openai-icon.imageset/openai-white@2x.png new file mode 100644 index 0000000..3d0e2a5 Binary files /dev/null and b/SwiftChat/Assets.xcassets/openai-icon.imageset/openai-white@2x.png differ diff --git a/SwiftChat/Assets.xcassets/openai-icon.imageset/openai-white@3x.png b/SwiftChat/Assets.xcassets/openai-icon.imageset/openai-white@3x.png new file mode 100644 index 0000000..08ac429 Binary files /dev/null and b/SwiftChat/Assets.xcassets/openai-icon.imageset/openai-white@3x.png differ diff --git a/SwiftChat/Assets.xcassets/openai-icon.imageset/openai.png b/SwiftChat/Assets.xcassets/openai-icon.imageset/openai.png new file mode 100644 index 0000000..16f0f81 Binary files /dev/null and b/SwiftChat/Assets.xcassets/openai-icon.imageset/openai.png differ diff --git a/SwiftChat/Assets.xcassets/openai-icon.imageset/openai@2x.png b/SwiftChat/Assets.xcassets/openai-icon.imageset/openai@2x.png new file mode 100644 index 0000000..59b2611 Binary files /dev/null and b/SwiftChat/Assets.xcassets/openai-icon.imageset/openai@2x.png differ diff --git a/SwiftChat/Assets.xcassets/openai-icon.imageset/openai@3x.png b/SwiftChat/Assets.xcassets/openai-icon.imageset/openai@3x.png new file mode 100644 index 0000000..7c1b763 Binary files /dev/null and b/SwiftChat/Assets.xcassets/openai-icon.imageset/openai@3x.png differ diff --git a/SwiftChat/Config/AppConfig.swift b/SwiftChat/Config/AppConfig.swift new file mode 100644 index 0000000..2b6271f --- /dev/null +++ b/SwiftChat/Config/AppConfig.swift @@ -0,0 +1,108 @@ +// +// AppConfig.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import Foundation +import Combine +import OpenAI + +/// A model available for chat +struct ModelType: Identifiable, Codable, Hashable, Equatable { + let id: String + let displayName: String + let fullName: String + let iconName: String + let isMultimodal: Bool + + var modelName: String { id } + + func hash(into hasher: inout Hasher) { hasher.combine(id) } + static func == (lhs: ModelType, rhs: ModelType) -> Bool { lhs.id == rhs.id } +} + +/// Application-wide configuration +@MainActor +class AppConfig: ObservableObject { + static let shared = AppConfig() + + // MARK: - Configuration + + /// Set your API key here or via environment + var apiKey: String = "YOUR_API_KEY" + + /// OpenAI-compatible API host (no scheme, no path) + var apiHost: String = "api.openai.com" + + /// Base path for the API + var apiBasePath: String = "/v1" + + /// System prompt sent with every conversation + var systemPrompt: String = "You are a helpful AI assistant." + + /// Additional rules appended to the system prompt + var rules: String = "" + + // MARK: - State + + @Published private(set) var isInitialized = false + @Published private(set) var initializationError: Error? + @Published var currentModel: ModelType? { + didSet { + if let model = currentModel { + UserDefaults.standard.set(model.id, forKey: "selectedModel") + } + } + } + @Published private(set) var availableModels: [ModelType] = [] + @Published private(set) var networkMonitor = NetworkMonitor() + + private init() { + setupDefaultModels() + loadLastSelectedModel() + isInitialized = true + } + + // MARK: - Models + + /// Override this to change available models + private func setupDefaultModels() { + availableModels = [ + ModelType(id: "gpt-4.1", displayName: "GPT-4.1", fullName: "GPT-4.1", iconName: "openai-icon", isMultimodal: true), + ModelType(id: "gpt-4.1-mini", displayName: "GPT-4.1 Mini", fullName: "GPT-4.1 Mini", iconName: "openai-icon", isMultimodal: true), + ModelType(id: "o4-mini", displayName: "o4-mini", fullName: "o4-mini", iconName: "openai-icon", isMultimodal: true), + ] + } + + private func loadLastSelectedModel() { + if let savedId = UserDefaults.standard.string(forKey: "selectedModel"), + let model = availableModels.first(where: { $0.id == savedId }) { + currentModel = model + } else { + currentModel = availableModels.first + } + } + + func filteredModelTypes() -> [ModelType] { + return availableModels + } + + /// The model used for generating chat titles (nil = skip title generation) + var titleModel: ModelType? { + availableModels.first(where: { $0.id == "gpt-4.1-mini" }) ?? availableModels.first + } + + // MARK: - OpenAI Client + + func makeClient() -> OpenAI { + let config = OpenAI.Configuration( + token: apiKey, + host: apiHost, + basePath: apiBasePath + ) + return OpenAI(configuration: config) + } +} diff --git a/SwiftChat/Config/Constants.swift b/SwiftChat/Config/Constants.swift new file mode 100644 index 0000000..551dbb3 --- /dev/null +++ b/SwiftChat/Config/Constants.swift @@ -0,0 +1,77 @@ +// +// Constants.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import Foundation + +/// Application-wide constants +enum Constants { + enum UI { + static let scrollToBottomButtonSize: CGFloat = 27 + static let scrollToBottomIconSize: CGFloat = 16 + static let tableMaxColumnWidth: CGFloat = 300 + static let tableFontSize: CGFloat = 16 + static let tableCellHorizontalPadding: CGFloat = 12 + static let actionButtonCornerRadius: CGFloat = 6 + } + + enum Rendering { + static let maxSyntaxHighlightCharacters = 15_000 + static let maxFullParsingCharacters = 50_000 + static let maxMarkdownSegmentCharacters = 8_000 + } + + enum StreamingBuffer { + static let initialMultiplier: CGFloat = 50.0 + static let multiplierIncrement: CGFloat = 10.0 + static let maxMultiplier: CGFloat = 200.0 + static let extensionThresholdRatio: CGFloat = 0.9 + static let maxCellHeight: CGFloat = 200_000 + } + + enum Pagination { + static let chatsPerPage = 20 + } + + enum Context { + static let defaultMaxMessages = 75 + static let maxMessagesLimit = 200 + } + + enum ThinkingSummary { + static let minContentLength = 100 + static let cooldownSeconds: TimeInterval = 3.0 + static let tailWordCount = 200 + } + + enum TitleGeneration { + static let wordThreshold = 100 + static let systemPrompt = "Generate a concise, descriptive title of minimum 2 words, maximum 5 words for the following text. NEVER output markdown." + } + + enum Attachments { + static let maxImageDimension: CGFloat = 768 + static let imageCompressionQuality: CGFloat = 0.85 + static let maxFileSizeBytes: Int64 = 20 * 1024 * 1024 + static let maxImageSizeBytes: Int64 = 10 * 1024 * 1024 + static let previewThumbnailSize: CGFloat = 60 + static let thumbnailMaxDimension: CGFloat = 300 + static let previewMaxWidth: CGFloat = 200 + static let messageThumbnailSize: CGFloat = 80 + static let messageThumbnailColumns: Int = 3 + static let supportedDocumentExtensions: Set = ["pdf", "txt", "md", "csv", "html"] + static let supportedImageExtensions: Set = ["jpg", "jpeg", "png", "gif", "webp", "heic"] + static let defaultImageMimeType = "image/jpeg" + } + + enum Audio { + static let sampleRate: Double = 44100 + static let numberOfChannels: Int = 1 + static let recordingTimeoutSeconds: TimeInterval = 120 + static let transcriptionModel = "whisper-1" + } +} diff --git a/SwiftChat/Config/SettingsManager.swift b/SwiftChat/Config/SettingsManager.swift new file mode 100644 index 0000000..9a0f2f7 --- /dev/null +++ b/SwiftChat/Config/SettingsManager.swift @@ -0,0 +1,49 @@ +// +// SettingsManager.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI +import Combine + +/// Minimal settings manager for the chat template +@MainActor +class SettingsManager: ObservableObject { + static let shared = SettingsManager() + + @Published var hapticFeedbackEnabled: Bool { + didSet { UserDefaults.standard.set(hapticFeedbackEnabled, forKey: "hapticFeedbackEnabled") } + } + + @Published var selectedLanguage: String { + didSet { UserDefaults.standard.set(selectedLanguage, forKey: "selectedLanguage") } + } + + @Published var maxMessages: Int { + didSet { UserDefaults.standard.set(maxMessages, forKey: "maxPromptMessages") } + } + + @Published var webSearchEnabled: Bool { + didSet { UserDefaults.standard.set(webSearchEnabled, forKey: "webSearchEnabled") } + } + + @Published var isUsingCustomPrompt: Bool { + didSet { UserDefaults.standard.set(isUsingCustomPrompt, forKey: "isUsingCustomPrompt") } + } + + @Published var customSystemPrompt: String { + didSet { UserDefaults.standard.set(customSystemPrompt, forKey: "customSystemPrompt") } + } + + private init() { + self.hapticFeedbackEnabled = UserDefaults.standard.object(forKey: "hapticFeedbackEnabled") as? Bool ?? true + self.selectedLanguage = UserDefaults.standard.string(forKey: "selectedLanguage") ?? "System" + self.maxMessages = UserDefaults.standard.object(forKey: "maxPromptMessages") as? Int ?? Constants.Context.defaultMaxMessages + self.webSearchEnabled = UserDefaults.standard.object(forKey: "webSearchEnabled") as? Bool ?? false + self.isUsingCustomPrompt = UserDefaults.standard.object(forKey: "isUsingCustomPrompt") as? Bool ?? false + self.customSystemPrompt = UserDefaults.standard.string(forKey: "customSystemPrompt") ?? "" + } +} diff --git a/SwiftChat/Config/Theme.swift b/SwiftChat/Config/Theme.swift new file mode 100644 index 0000000..e01d57a --- /dev/null +++ b/SwiftChat/Config/Theme.swift @@ -0,0 +1,63 @@ +// +// Theme.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI + +/// Centralized theme and design system for the app +enum Theme { + + // MARK: - Colors + enum Colors { + static let backgroundPrimary = Color.backgroundPrimary + static let chatSurfaceDark = Color.chatSurfaceDark + static let chatSurfaceLight = Color.chatSurfaceLight + static let sidebarButtonDark = Color.sidebarButtonBackgroundDark + static let sidebarButtonLight = Color.sidebarButtonBackgroundLight + static let cardSurfaceDark = Color.cardSurfaceDark + static let cardSurfaceLight = Color.cardSurfaceLight + static let chatBackgroundDark = Color.chatBackgroundDark + static let chatBackgroundLight = Color.chatBackgroundLight + static let sidebarBackgroundDark = Color.sidebarBackgroundDark + static let sidebarBackgroundLight = Color.sidebarBackgroundLight + static let settingsBackgroundDark = Color.settingsBackgroundDark + static let settingsBackgroundLight = Color.settingsBackgroundLight + } + + // MARK: - Dimensions + enum Dimensions { + // Layout + static let sidebarWidth: CGFloat = 300 + + // Common padding values used throughout the app + static let paddingExtraSmall: CGFloat = 4 + static let paddingSmall: CGFloat = 8 + static let paddingMedium: CGFloat = 12 + static let paddingLarge: CGFloat = 16 + static let paddingExtraLarge: CGFloat = 24 + + // Common corner radius values + static let cornerRadiusSmall: CGFloat = 8 + static let cornerRadiusMedium: CGFloat = 12 + static let cornerRadiusLarge: CGFloat = 16 + } + + // MARK: - Animations + enum Animations { + // Common animation durations + static let defaultDuration: Double = 0.25 + static let mediumDuration: Double = 0.3 + static let longDuration: Double = 0.6 + static let copyFeedbackDuration: Double = 1.5 + + // Spring animations used in various places + static let springResponse: Double = 0.3 + static let springDamping: Double = 0.75 + static let springResponseFast: Double = 0.2 + static let springDampingHigh: Double = 0.9 + } +} diff --git a/SwiftChat/ContentView.swift b/SwiftChat/ContentView.swift new file mode 100644 index 0000000..09be1ec --- /dev/null +++ b/SwiftChat/ContentView.swift @@ -0,0 +1,46 @@ +// +// ContentView.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI + +struct ContentView: View { + @StateObject private var viewModel = ChatViewModel() + @State private var showAPIKeyPrompt = false + @State private var apiKeyInput = "" + + private var needsAPIKey: Bool { + let key = AppConfig.shared.apiKey + return key.isEmpty || key == "YOUR_API_KEY" + } + + var body: some View { + ChatContainer() + .environmentObject(viewModel) + .onAppear { + if let saved = UserDefaults.standard.string(forKey: "apiKey"), !saved.isEmpty { + AppConfig.shared.apiKey = saved + } else if needsAPIKey { + showAPIKeyPrompt = true + } + } + .alert("API Key Required", isPresented: $showAPIKeyPrompt) { + TextField("Paste your API key", text: $apiKeyInput) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + Button("Save") { + let trimmed = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + AppConfig.shared.apiKey = trimmed + UserDefaults.standard.set(trimmed, forKey: "apiKey") + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Enter your API key to start chatting.") + } + } +} diff --git a/SwiftChat/Extensions/Color+Hex.swift b/SwiftChat/Extensions/Color+Hex.swift new file mode 100644 index 0000000..babf854 --- /dev/null +++ b/SwiftChat/Extensions/Color+Hex.swift @@ -0,0 +1,36 @@ +// +// Color+Hex.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI + +extension Color { + 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) = (1, 1, 1, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} \ No newline at end of file diff --git a/SwiftChat/Extensions/Color.swift b/SwiftChat/Extensions/Color.swift new file mode 100644 index 0000000..73d245d --- /dev/null +++ b/SwiftChat/Extensions/Color.swift @@ -0,0 +1,103 @@ +// +// Color.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI +import UIKit + +extension Color { + // Accent colors + static let accentPrimary = Color(red: 16/255, green: 185/255, blue: 129/255) // #10B981 + + // Brand colors + static let brandDark = Color(hex: "061820") + static let brandLight = Color(hex: "EEF3F3") + static let brandAccentDark = Color(hex: "004444") + static let brandAccentLight = Color(hex: "68C7AC") + + // App surface colors + static let backgroundPrimary = Color.brandDark + static let chatSurfaceDark = Color(hex: "2C2C2E") + static let chatSurfaceLight = Color(hex: "F2F2F7") + static let sidebarButtonBackgroundDark = Color(hex: "2C2C2E") + static let sidebarButtonBackgroundLight = Color.white + static let cardSurfaceDark = Color(hex: "1C1C1E") + static let cardSurfaceLight = Color.white + static let chatBackgroundDark = Color(hex: "121212") + static let chatBackgroundLight = Color.white + static let actionButtonBackgroundDark = Color.white.opacity(0.08) + static let actionButtonBackgroundLight = Color.black.opacity(0.05) + static let sidebarBackgroundDark = Color(hex: "121212") + static let sidebarBackgroundLight = Color.white + static let settingsBackgroundDark = Color(hex: "121212") + static let settingsBackgroundLight = Color(UIColor.systemGroupedBackground) + static let sendButtonBackgroundDark = Color.brandDark + static let sendButtonBackgroundLight = Color.white + static let sendButtonForegroundDark = Color.white + static let sendButtonForegroundLight = Color.black + + // Reasoning and messaging surfaces + static let thinkingBackgroundDark = chatSurfaceDark + static let thinkingBackgroundLight = chatSurfaceLight + static let userMessageBackgroundDark = chatSurfaceDark + static let userMessageBackgroundLight = chatSurfaceLight + static let userMessageForegroundDark = Color.white + static let userMessageForegroundLight = Color.black + + + // Adaptive accent color for buttons/links that works in both light and dark mode + static let adaptiveAccent = Color(UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + // White in dark mode for better visibility + return .white + } else { + // Use the green accent in light mode + return UIColor(Color.accentPrimary) + } + }) + + // Convenience helpers for common surfaces + static func chatSurface(isDarkMode: Bool) -> Color { + isDarkMode ? chatSurfaceDark : chatSurfaceLight + } + + static func sidebarButtonBackground(for colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? sidebarButtonBackgroundDark : sidebarButtonBackgroundLight + } + + static func cardSurface(for colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? cardSurfaceDark : cardSurfaceLight + } + + static func chatBackground(isDarkMode: Bool) -> Color { + isDarkMode ? chatBackgroundDark : chatBackgroundLight + } + + static func sidebarBackground(for colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? sidebarBackgroundDark : sidebarBackgroundLight + } + + static func settingsBackground(for colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? settingsBackgroundDark : settingsBackgroundLight + } + + static func thinkingBackground(isDarkMode: Bool) -> Color { + isDarkMode ? thinkingBackgroundDark : thinkingBackgroundLight + } + + static func userMessageBackground(isDarkMode: Bool) -> Color { + isDarkMode ? userMessageBackgroundDark : userMessageBackgroundLight + } + + static func userMessageForeground(isDarkMode: Bool) -> Color { + isDarkMode ? userMessageForegroundDark : userMessageForegroundLight + } + + static func actionButtonBackground(isDarkMode: Bool) -> Color { + isDarkMode ? actionButtonBackgroundDark : actionButtonBackgroundLight + } +} diff --git a/SwiftChat/Models/AttachmentModels.swift b/SwiftChat/Models/AttachmentModels.swift new file mode 100644 index 0000000..b47de74 --- /dev/null +++ b/SwiftChat/Models/AttachmentModels.swift @@ -0,0 +1,108 @@ +// +// AttachmentModels.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import Foundation +import UIKit + +enum AttachmentType: String, Codable, Equatable { + case document + case image +} + +enum AttachmentProcessingState: String, Codable, Equatable { + case pending + case processing + case completed + case failed +} + +struct Attachment: Identifiable, Equatable { + let id: String + let type: AttachmentType + let fileName: String + var mimeType: String? + var base64: String? + var thumbnailBase64: String? + var textContent: String? + var description: String? + var fileSize: Int64 + // v1 format: per-attachment AES-256 key as standard base64 (32 raw bytes → 44 chars). + // Cross-platform contract: iOS uses Data.base64EncodedString(), React uses btoa(). + // The encrypted blob (nonce || ciphertext || tag) is stored separately at /api/storage/attachment/:id. + var encryptionKey: String? + var processingState: AttachmentProcessingState + + init( + id: String = UUID().uuidString.lowercased(), + type: AttachmentType, + fileName: String, + mimeType: String? = nil, + base64: String? = nil, + thumbnailBase64: String? = nil, + textContent: String? = nil, + description: String? = nil, + fileSize: Int64 = 0, + encryptionKey: String? = nil, + processingState: AttachmentProcessingState = .pending + ) { + self.id = id + self.type = type + self.fileName = fileName + self.mimeType = mimeType + self.base64 = base64 + self.thumbnailBase64 = thumbnailBase64 + self.textContent = textContent + self.description = description + self.fileSize = fileSize + self.encryptionKey = encryptionKey + self.processingState = processingState + } +} + +// MARK: - Codable (exclude transient UI state from serialization) + +extension Attachment: Codable { + enum CodingKeys: String, CodingKey { + case id, type, fileName, mimeType, base64, thumbnailBase64 + case textContent, description, fileSize + case encryptionKey + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString.lowercased() + type = try container.decode(AttachmentType.self, forKey: .type) + fileName = try container.decode(String.self, forKey: .fileName) + mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) + base64 = try container.decodeIfPresent(String.self, forKey: .base64) + thumbnailBase64 = try container.decodeIfPresent(String.self, forKey: .thumbnailBase64) + textContent = try container.decodeIfPresent(String.self, forKey: .textContent) + description = try container.decodeIfPresent(String.self, forKey: .description) + fileSize = try container.decodeIfPresent(Int64.self, forKey: .fileSize) ?? 0 + encryptionKey = try container.decodeIfPresent(String.self, forKey: .encryptionKey) + // processingState is transient UI state — always reset to completed on decode + processingState = .completed + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(type, forKey: .type) + try container.encode(fileName, forKey: .fileName) + try container.encodeIfPresent(mimeType, forKey: .mimeType) + try container.encodeIfPresent(base64, forKey: .base64) + try container.encodeIfPresent(thumbnailBase64, forKey: .thumbnailBase64) + try container.encodeIfPresent(textContent, forKey: .textContent) + try container.encodeIfPresent(description, forKey: .description) + if fileSize > 0 { + try container.encode(fileSize, forKey: .fileSize) + } + try container.encodeIfPresent(encryptionKey, forKey: .encryptionKey) + // processingState is transient UI state — never encode + } +} diff --git a/SwiftChat/Models/ChatModels.swift b/SwiftChat/Models/ChatModels.swift new file mode 100644 index 0000000..5a5b12a --- /dev/null +++ b/SwiftChat/Models/ChatModels.swift @@ -0,0 +1,375 @@ +// +// ChatModels.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + + +import Foundation +import UIKit + +/// Represents a chat conversation +struct Chat: Identifiable, Codable { + enum TitleState: String, Codable { + case placeholder + case generated + case manual + } + + static let placeholderTitle = "Untitled" + + let id: String + var title: String + var titleState: TitleState + var messages: [Message] + var hasActiveStream: Bool = false + var createdAt: Date + var modelType: ModelType + var language: String? + + // Computed properties + var isBlankChat: Bool { + return messages.isEmpty + } + + var needsGeneratedTitle: Bool { + return titleState == .placeholder + } + + /// Generates a permanent reverse-timestamp ID locally. + /// Format: {reverseTimestamp padded to 13 digits}_{UUID} + static func generateReverseId(timestampMs: Int = Int(Date().timeIntervalSince1970 * 1000)) -> String { + let maxReverseTimestamp = 9999999999999 + let reverseTimestamp = maxReverseTimestamp - timestampMs + let unpadded = String(reverseTimestamp) + let digits = String(maxReverseTimestamp).count + let reverseTsStr = String(repeating: "0", count: max(0, digits - unpadded.count)) + unpadded + return "\(reverseTsStr)_\(UUID().uuidString.lowercased())" + } + + static func deriveTitleState(for title: String, messages: [Message]) -> TitleState { + let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + if messages.isEmpty { + return normalizedTitle.isEmpty || normalizedTitle == placeholderTitle ? .placeholder : .manual + } + if normalizedTitle.isEmpty || normalizedTitle == placeholderTitle { + return .placeholder + } + return .generated + } + + init( + id: String = Chat.generateReverseId(), + title: String = Chat.placeholderTitle, + titleState: TitleState? = nil, + messages: [Message] = [], + createdAt: Date = Date(), + modelType: ModelType, + language: String? = nil) + { + let resolvedTitleState = titleState ?? Chat.deriveTitleState(for: title, messages: messages) + + self.id = id + self.title = title + self.titleState = resolvedTitleState + self.messages = messages + self.createdAt = createdAt + self.modelType = modelType + self.language = language + } + + // MARK: - Factory Methods + + /// Creates a new chat with the current model from AppConfig + @MainActor + static func create( + id: String = Chat.generateReverseId(), + title: String = Chat.placeholderTitle, + titleState: TitleState? = nil, + messages: [Message] = [], + createdAt: Date = Date(), + modelType: ModelType? = nil, + language: String? = nil + ) -> Chat { + guard let model = modelType ?? AppConfig.shared.currentModel ?? AppConfig.shared.availableModels.first else { + fatalError("Cannot create Chat without available models. Ensure AppConfig is initialized before creating chats.") + } + return Chat( + id: id, + title: title, + titleState: titleState, + messages: messages, + createdAt: createdAt, + modelType: model, + language: language + ) + } + + // MARK: - Codable Implementation + + enum CodingKeys: String, CodingKey { + case id, title, titleState, messages, createdAt, modelType, language + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(String.self, forKey: .id) + title = try container.decode(String.self, forKey: .title) + messages = try container.decode([Message].self, forKey: .messages) + titleState = (try? container.decode(TitleState.self, forKey: .titleState)) ?? Chat.deriveTitleState(for: title, messages: messages) + // hasActiveStream is transient UI state — always reset to false on decode + hasActiveStream = false + createdAt = try container.decode(Date.self, forKey: .createdAt) + modelType = try container.decode(ModelType.self, forKey: .modelType) + language = try container.decodeIfPresent(String.self, forKey: .language) + } + + // MARK: - Haptic Feedback Methods + + /// Triggers haptic feedback when a chat operation succeeds + static func triggerSuccessFeedback() { + HapticFeedback.trigger(.success) + } +} + +/// Represents a message role +enum MessageRole: String, Codable { + case user + case assistant +} + +// MARK: - Web Search Types + +/// Represents a source from web search results +struct WebSearchSource: Codable, Equatable, Identifiable { + let id: String + let title: String + let url: String + + init(id: String = UUID().uuidString.lowercased(), title: String, url: String) { + self.id = id + self.title = title + self.url = url + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString.lowercased() + title = try container.decode(String.self, forKey: .title) + url = try container.decode(String.self, forKey: .url) + } +} + +/// Status of a web search operation +enum WebSearchStatus: String, Codable, Equatable { + case searching + case completed + case failed + case blocked +} + +/// State of web search for a message +struct WebSearchState: Codable, Equatable { + var query: String? + var status: WebSearchStatus + var sources: [WebSearchSource] + var reason: String? + + init( + query: String? = nil, + status: WebSearchStatus = .searching, + sources: [WebSearchSource] = [], + reason: String? = nil + ) { + self.query = query + self.status = status + self.sources = sources + self.reason = reason + } +} + +// MARK: - URL Fetch Types + +/// Status of a URL fetch operation +enum URLFetchStatus: String, Codable, Equatable { + case fetching + case completed + case failed + case blocked +} + +/// Tracks the state of a single URL being fetched during web search +struct URLFetchState: Codable, Equatable, Identifiable { + let id: String + let url: String + var status: URLFetchStatus + + init(id: String = UUID().uuidString.lowercased(), url: String, status: URLFetchStatus = .fetching) { + self.id = id + self.url = url + self.status = status + } +} + +/// URL citation from web search results +struct URLCitation: Codable, Equatable { + let title: String + let url: String + let start_index: Int? + let end_index: Int? +} + +/// Annotation wrapper for URL citations +struct Annotation: Codable, Equatable { + let type: String + let url_citation: URLCitation +} + +/// Represents a single message in a chat +struct Message: Identifiable, Codable, Equatable { + let id: String + let role: MessageRole + var content: String + var thoughts: String? = nil + var isThinking: Bool = false + var timestamp: Date + var isCollapsed: Bool = true + var isStreaming: Bool = false + var streamError: String? = nil + var isRequestError: Bool = false + var generationTimeSeconds: Double? = nil + var contentChunks: [ContentChunk] = [] + var thinkingChunks: [ThinkingChunk] = [] + var webSearchState: WebSearchState? = nil + var urlFetches: [URLFetchState] = [] + var attachments: [Attachment] = [] + + // Passthrough fields for cross-platform round-trip + var annotations: [Annotation]? = nil + + static let longMessageAttachmentThreshold = 1200 + var shouldDisplayAsAttachment: Bool { + role == .user && content.count >= Message.longMessageAttachmentThreshold + } + + private static let iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let iso8601FormatterNoFractional: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + init(id: String = UUID().uuidString.lowercased(), role: MessageRole, content: String, thoughts: String? = nil, isThinking: Bool = false, timestamp: Date = Date(), isCollapsed: Bool = true, generationTimeSeconds: Double? = nil, contentChunks: [ContentChunk] = [], thinkingChunks: [ThinkingChunk] = [], webSearchState: WebSearchState? = nil, attachments: [Attachment] = []) { + self.id = id + self.role = role + self.content = content + self.thoughts = thoughts + self.isThinking = isThinking + self.timestamp = timestamp + self.isCollapsed = isCollapsed + self.generationTimeSeconds = generationTimeSeconds + self.contentChunks = contentChunks + self.thinkingChunks = thinkingChunks + self.webSearchState = webSearchState + self.attachments = attachments + } + + // MARK: - Codable Implementation + + enum CodingKeys: String, CodingKey { + case id, role, content, thoughts, isThinking, timestamp, isCollapsed, isStreaming, streamError, isRequestError, generationTimeSeconds, webSearchState + case webSearch // Alternative key used by React app + case urlFetches + case attachments + case annotations + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString.lowercased() + role = try container.decode(MessageRole.self, forKey: .role) + content = try container.decode(String.self, forKey: .content) + thoughts = try container.decodeIfPresent(String.self, forKey: .thoughts) + isThinking = try container.decodeIfPresent(Bool.self, forKey: .isThinking) ?? false + + // Handle timestamp as either Date or String (ISO8601) + if let date = try? container.decode(Date.self, forKey: .timestamp) { + timestamp = date + } else if let dateString = try? container.decode(String.self, forKey: .timestamp) { + timestamp = Self.iso8601Formatter.date(from: dateString) + ?? Self.iso8601FormatterNoFractional.date(from: dateString) + ?? Date() + } else { + timestamp = Date() + } + + isCollapsed = try container.decodeIfPresent(Bool.self, forKey: .isCollapsed) ?? true + isStreaming = try container.decodeIfPresent(Bool.self, forKey: .isStreaming) ?? false + streamError = try container.decodeIfPresent(String.self, forKey: .streamError) + isRequestError = try container.decodeIfPresent(Bool.self, forKey: .isRequestError) ?? false + generationTimeSeconds = try container.decodeIfPresent(Double.self, forKey: .generationTimeSeconds) + contentChunks = [] + thinkingChunks = [] + webSearchState = try container.decodeIfPresent(WebSearchState.self, forKey: .webSearchState) + ?? container.decodeIfPresent(WebSearchState.self, forKey: .webSearch) + urlFetches = try container.decodeIfPresent([URLFetchState].self, forKey: .urlFetches) ?? [] + attachments = try container.decodeIfPresent([Attachment].self, forKey: .attachments) ?? [] + annotations = try container.decodeIfPresent([Annotation].self, forKey: .annotations) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(role.rawValue, forKey: .role) + try container.encode(content, forKey: .content) + try container.encodeIfPresent(thoughts, forKey: .thoughts) + try container.encode(isThinking, forKey: .isThinking) + try container.encode(Self.iso8601Formatter.string(from: timestamp), forKey: .timestamp) + try container.encode(isCollapsed, forKey: .isCollapsed) + try container.encode(isStreaming, forKey: .isStreaming) + try container.encodeIfPresent(streamError, forKey: .streamError) + if isRequestError { try container.encode(isRequestError, forKey: .isRequestError) } + try container.encodeIfPresent(generationTimeSeconds, forKey: .generationTimeSeconds) + try container.encodeIfPresent(webSearchState, forKey: .webSearch) + if !urlFetches.isEmpty { + try container.encode(urlFetches, forKey: .urlFetches) + } + if !attachments.isEmpty { + try container.encode(attachments, forKey: .attachments) + } + try container.encodeIfPresent(annotations, forKey: .annotations) + } +} + +// MARK: - Haptic Feedback + +/// Utility for handling haptic feedback in chat interactions +enum HapticFeedback { + enum FeedbackType { + case error + case success + } + + static func trigger(_ type: FeedbackType) { + let hapticEnabled = UserDefaults.standard.object(forKey: "hapticFeedbackEnabled") as? Bool ?? true + guard hapticEnabled else { return } + + let generator = UINotificationFeedbackGenerator() + switch type { + case .error: + generator.notificationOccurred(.error) + case .success: + generator.notificationOccurred(.success) + } + } +} + diff --git a/SwiftChat/Services/AudioRecordingService.swift b/SwiftChat/Services/AudioRecordingService.swift new file mode 100644 index 0000000..4f719a9 --- /dev/null +++ b/SwiftChat/Services/AudioRecordingService.swift @@ -0,0 +1,162 @@ +// +// AudioRecordingService.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import Foundation +import Combine +import AVFoundation +import OpenAI + +/// Service for managing audio recording and transcription +@MainActor +class AudioRecordingService: NSObject, ObservableObject { + static let shared = AudioRecordingService() + + @Published private(set) var isRecording = false + @Published private(set) var isTranscribing = false + + private var audioRecorder: AVAudioRecorder? + private var timeoutTimer: Timer? + private var recordingURL: URL? + + private override init() { + super.init() + } + + // MARK: - Permission + + func requestPermission() async -> Bool { + await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + } + + var hasPermission: Bool { + AVAudioApplication.shared.recordPermission == .granted + } + + // MARK: - Recording + + func startRecording() throws { + guard !isRecording else { return } + + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) + try session.setActive(true) + + let url = getRecordingURL() + recordingURL = url + + let settings: [String: Any] = [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: Constants.Audio.sampleRate, + AVNumberOfChannelsKey: Constants.Audio.numberOfChannels, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue + ] + + audioRecorder = try AVAudioRecorder(url: url, settings: settings) + audioRecorder?.record() + isRecording = true + + timeoutTimer = Timer.scheduledTimer(withTimeInterval: Constants.Audio.recordingTimeoutSeconds, repeats: false) { [weak self] _ in + Task { @MainActor in + self?.stopRecording() + } + } + } + + @discardableResult + func stopRecording() -> URL? { + guard isRecording else { return nil } + + timeoutTimer?.invalidate() + timeoutTimer = nil + + audioRecorder?.stop() + audioRecorder = nil + isRecording = false + + return recordingURL + } + + func cancelRecording() { + stopRecording() + cleanupRecordingFile() + } + + // MARK: - Transcription + + func transcribe(fileURL: URL, client: OpenAI) async throws -> String { + isTranscribing = true + defer { + isTranscribing = false + cleanupRecordingFile() + } + + let audioData = try await Task.detached { + try Data(contentsOf: fileURL) + }.value + + guard !audioData.isEmpty else { + throw AudioRecordingError.emptyRecording + } + + let query = AudioTranscriptionQuery( + file: audioData, + fileType: .m4a, + model: Constants.Audio.transcriptionModel, + responseFormat: .json + ) + + let result = try await client.audioTranscriptions(query: query) + let transcription = result.text.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !transcription.isEmpty else { + throw AudioRecordingError.emptyTranscription + } + + return transcription + } + + // MARK: - Private Helpers + + private func getRecordingURL() -> URL { + let tempDir = FileManager.default.temporaryDirectory + let filename = "recording_\(UUID().uuidString).m4a" + return tempDir.appendingPathComponent(filename) + } + + private func cleanupRecordingFile() { + guard let url = recordingURL else { return } + try? FileManager.default.removeItem(at: url) + recordingURL = nil + } +} + +// MARK: - Errors + +enum AudioRecordingError: LocalizedError { + case permissionDenied + case emptyRecording + case emptyTranscription + case transcriptionFailed(String) + + var errorDescription: String? { + switch self { + case .permissionDenied: + return "Microphone access denied" + case .emptyRecording: + return "Recording is empty" + case .emptyTranscription: + return "Transcription returned empty" + case .transcriptionFailed(let message): + return "Transcription failed: \(message)" + } + } +} diff --git a/SwiftChat/Services/StreamingMarkdownChunker.swift b/SwiftChat/Services/StreamingMarkdownChunker.swift new file mode 100644 index 0000000..3abf0de --- /dev/null +++ b/SwiftChat/Services/StreamingMarkdownChunker.swift @@ -0,0 +1,202 @@ +// +// StreamingMarkdownChunker.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import Foundation + +enum ContentChunkType: Codable, Equatable, Hashable { + case paragraph + case codeBlock(language: String?) + case heading + case list + case blockquote + case table + case other +} + +struct ContentChunk: Codable, Equatable, Identifiable, Hashable { + let id: String + let type: ContentChunkType + let content: String + let isComplete: Bool + + init(id: String = UUID().uuidString, type: ContentChunkType, content: String, isComplete: Bool) { + self.id = id + self.type = type + self.content = content + self.isComplete = isComplete + } +} + +class StreamingMarkdownChunker { + private var completedChunks: [ContentChunk] = [] + private var workingBuffer: String = "" + private var isInCodeBlock = false + private var isInTable = false + private var codeBlockLanguage: String? + private var codeBlockFenceCount = 0 + private var isFinalized: Bool = false + + func getAllChunks() -> [ContentChunk] { + var result = completedChunks + + if !workingBuffer.isEmpty { + let chunkType: ContentChunkType = isInTable ? .table : (isInCodeBlock ? .codeBlock(language: codeBlockLanguage) : .paragraph) + let chunkId = isInTable ? "working_table" : "working_current" + result.append(ContentChunk( + id: chunkId, + type: chunkType, + content: workingBuffer, + isComplete: false + )) + } + + return result + } + + @discardableResult + func appendToken(_ token: String) -> Bool { + workingBuffer += token + + if isInCodeBlock { + if workingBuffer.hasSuffix("```") || workingBuffer.contains("\n```\n") || workingBuffer.contains("\n```") { + if let closingRange = workingBuffer.range(of: "```", options: .backwards) { + let afterFence = String(workingBuffer[closingRange.upperBound...]) + if afterFence.trimmingCharacters(in: .whitespaces).isEmpty || afterFence.hasPrefix("\n") { + finalizeCodeBlock(preserveAfterFence: afterFence) + return true + } + } + } + return false + } + + if isInTable { + if workingBuffer.hasSuffix("\n\n") || (workingBuffer.hasSuffix("\n") && !workingBuffer.suffix(10).contains("|")) { + finalizeTable() + return true + } + return false + } + + if workingBuffer.contains("```") { + if let fenceRange = workingBuffer.range(of: "```") { + let beforeFence = String(workingBuffer[..= 2 { + let lastTwo = lines.suffix(2) + if lastTwo.allSatisfy({ $0.contains("|") }) { + if lastTwo.last?.contains("---") == true || lastTwo.last?.contains(":--") == true || lastTwo.last?.contains("--:") == true { + isInTable = true + return false + } + } + } + + if workingBuffer.hasSuffix("\n\n") && workingBuffer.count > 2 { + let content = String(workingBuffer.dropLast(2)) + if !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let chunkId = "paragraph_\(content.hashValue)_\(Date().timeIntervalSince1970)" + completedChunks.append(ContentChunk( + id: chunkId, + type: .paragraph, + content: content, + isComplete: true + )) + workingBuffer = "\n\n" + return true + } + } + + return false + } + + private func finalizeCodeBlock(preserveAfterFence: String = "") { + let chunkId = "codeblock_\(workingBuffer.hashValue)_\(Date().timeIntervalSince1970)" + completedChunks.append(ContentChunk( + id: chunkId, + type: .codeBlock(language: codeBlockLanguage), + content: workingBuffer, + isComplete: true + )) + + isInCodeBlock = false + codeBlockLanguage = nil + workingBuffer = preserveAfterFence + } + + private func finalizeTable() { + let trimmed = workingBuffer.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + let chunkId = "table_\(trimmed.hashValue)_\(Date().timeIntervalSince1970)" + completedChunks.append(ContentChunk( + id: chunkId, + type: .table, + content: trimmed, + isComplete: true + )) + } + + isInTable = false + workingBuffer = "" + } + + func finalize() { + isFinalized = true + + if isInCodeBlock { + finalizeCodeBlock() + } else if isInTable { + finalizeTable() + } else if !workingBuffer.isEmpty { + let trimmed = workingBuffer.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + let chunkId = "paragraph_\(trimmed.hashValue)_\(Date().timeIntervalSince1970)" + completedChunks.append(ContentChunk( + id: chunkId, + type: .paragraph, + content: trimmed, + isComplete: true + )) + workingBuffer = "" + } + } + } + + func reset() { + completedChunks.removeAll() + workingBuffer = "" + isInCodeBlock = false + isInTable = false + codeBlockLanguage = nil + isFinalized = false + } +} diff --git a/SwiftChat/Services/ThinkingSummaryService.swift b/SwiftChat/Services/ThinkingSummaryService.swift new file mode 100644 index 0000000..5dfc0d5 --- /dev/null +++ b/SwiftChat/Services/ThinkingSummaryService.swift @@ -0,0 +1,63 @@ +// +// ThinkingSummaryService.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import Foundation + +/// Service for generating thinking summaries during streaming. +/// Uses a simple tail-text extraction approach. +@MainActor +class ThinkingSummaryService { + static let shared = ThinkingSummaryService() + + private var currentSummary: String = "" + private var lastGenerationTime: Date? + private var summarizedContentLength: Int = 0 + + private init() {} + + /// Generate a summary of the thinking content by extracting the last sentence + func generateSummary(thoughts: String, completion: @escaping @MainActor (String) -> Void) { + let newContent = String(thoughts.dropFirst(summarizedContentLength)) + + guard newContent.count >= Constants.ThinkingSummary.minContentLength else { return } + + if let lastTime = lastGenerationTime, + Date().timeIntervalSince(lastTime) < Constants.ThinkingSummary.cooldownSeconds { + return + } + + lastGenerationTime = Date() + summarizedContentLength = thoughts.count + + // Extract last meaningful sentence as a summary + let words = thoughts.split(separator: " ") + let tailText: String + if words.count > 20 { + tailText = words.suffix(20).joined(separator: " ") + } else { + tailText = thoughts + } + + let trimmed = tailText.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + currentSummary = trimmed + completion(trimmed) + } + } + + /// Reset state for a new thinking session + func reset() { + currentSummary = "" + lastGenerationTime = nil + summarizedContentLength = 0 + } + + var summary: String { + currentSummary + } +} diff --git a/SwiftChat/Services/ThinkingTextChunker.swift b/SwiftChat/Services/ThinkingTextChunker.swift new file mode 100644 index 0000000..16e53cf --- /dev/null +++ b/SwiftChat/Services/ThinkingTextChunker.swift @@ -0,0 +1,69 @@ +// +// ThinkingTextChunker.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import Foundation + +struct ThinkingChunk: Identifiable, Equatable, Hashable { + let id: String + let content: String + let isComplete: Bool +} + +class ThinkingTextChunker { + private var completedChunks: [ThinkingChunk] = [] + private var workingBuffer: String = "" + + func getAllChunks() -> [ThinkingChunk] { + var result = completedChunks + + if !workingBuffer.isEmpty { + result.append(ThinkingChunk( + id: "thinking_working_\(completedChunks.count)", + content: workingBuffer, + isComplete: false + )) + } + + return result + } + + func appendToken(_ token: String) { + workingBuffer += token + + // Split on double-newline paragraph boundaries + while let range = workingBuffer.range(of: "\n\n") { + let paragraph = String(workingBuffer[.. CreateModelResponseQuery { + + let fullPrompt = rules.isEmpty ? systemPrompt : systemPrompt + "\n\n" + rules + + var inputItems: [InputItem] = [] + + // Add conversation history + let recentMessages = Array(conversationMessages.suffix(maxMessages)) + + for msg in recentMessages { + if msg.role == .user { + var userContent = msg.content + + // Derive document content and image data from attachments + let documentAttachments = msg.attachments.filter { $0.type == .document } + let imageAttachments = msg.attachments.filter { $0.type == .image } + + // Prepend document content as context when present + if !documentAttachments.isEmpty { + let docContent = documentAttachments + .compactMap { attachment -> String? in + guard let text = attachment.textContent, !text.isEmpty else { return nil } + return "Document title: \(attachment.fileName)\nDocument contents:\n\(text)" + } + .joined(separator: "\n\n") + if !docContent.isEmpty { + userContent = "---\nDocument content:\n\(docContent)\n---\n\n\(userContent)" + } + } + + // Use multimodal content parts when model supports it and message has images + if isMultimodal, !imageAttachments.isEmpty { + var parts: [InputContent] = [] + parts.append(.inputText(Schemas.InputTextContent(_type: .inputText, text: userContent))) + for attachment in imageAttachments { + guard let base64 = attachment.base64, + let mimeType = attachment.mimeType else { continue } + let dataUrl = "data:\(mimeType);base64,\(base64)" + parts.append(.inputImage(InputImage( + _type: .inputImage, + imageUrl: dataUrl, + detail: .auto + ))) + } + inputItems.append(.inputMessage(EasyInputMessage(role: .user, content: .inputItemContentList(parts)))) + } else if !imageAttachments.isEmpty { + // Non-multimodal model: append image descriptions as text fallback + let descriptions = imageAttachments + .compactMap { attachment -> String? in + guard let desc = attachment.description, !desc.isEmpty else { return nil } + return "Image: \(attachment.fileName)\nDescription:\n\(desc)" + } + if !descriptions.isEmpty { + let descText = descriptions.joined(separator: "\n\n") + userContent = userContent + "\n\n[Treat these descriptions as if they are the raw images.]\n" + descText + } + inputItems.append(.inputMessage(EasyInputMessage(role: .user, content: .textInput(userContent)))) + } else { + inputItems.append(.inputMessage(EasyInputMessage(role: .user, content: .textInput(userContent)))) + } + } else if !msg.content.isEmpty { + inputItems.append(.inputMessage(EasyInputMessage(role: .assistant, content: .textInput(msg.content)))) + } + } + + // Build tools array + var tools: [Tool]? = nil + if webSearchEnabled { + tools = [.webSearchTool(Schemas.WebSearchPreviewTool(_type: .webSearchPreview))] + } + + return CreateModelResponseQuery( + input: .inputItemList(inputItems), + model: modelId, + instructions: fullPrompt, + stream: stream ? true : nil, + tools: tools + ) + } +} diff --git a/SwiftChat/Utilities/NetworkMonitor.swift b/SwiftChat/Utilities/NetworkMonitor.swift new file mode 100644 index 0000000..7692ccc --- /dev/null +++ b/SwiftChat/Utilities/NetworkMonitor.swift @@ -0,0 +1,31 @@ +// +// NetworkMonitor.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import Network +import Combine +import SwiftUI + +@MainActor +class NetworkMonitor: ObservableObject { + @Published private(set) var isConnected = true + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "NetworkMonitor") + + init() { + monitor.pathUpdateHandler = { [weak self] path in + DispatchQueue.main.async { + self?.isConnected = path.status == .satisfied + } + } + monitor.start(queue: queue) + } + + deinit { + monitor.cancel() + } +} diff --git a/SwiftChat/ViewModels/ChatViewModel.swift b/SwiftChat/ViewModels/ChatViewModel.swift new file mode 100644 index 0000000..bedd52f --- /dev/null +++ b/SwiftChat/ViewModels/ChatViewModel.swift @@ -0,0 +1,933 @@ +// +// ChatViewModel.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import Foundation +import Combine +import SwiftUI +import OpenAI +@_spi(Generated) import OpenAPIRuntime + +@MainActor +class ChatViewModel: ObservableObject { + private static let citationMarkerRegex = try? NSRegularExpression(pattern: "【(\\d+)[^】]*】", options: []) + + // Published properties for UI updates + @Published var chats: [Chat] = [] + @Published var currentChat: Chat? + @Published var isLoading: Bool = false + @Published var thinkingSummary: String = "" + @Published var webSearchSummary: String = "" + @Published var scrollTargetMessageId: String? = nil + @Published var scrollTargetOffset: CGFloat = 0 + @Published var shouldFocusInput: Bool = false + @Published var isScrollInteractionActive: Bool = false + @Published var isAtBottom: Bool = true + @Published var scrollToBottomTrigger: UUID = UUID() + @Published var scrollToUserMessageTrigger: UUID = UUID() + @Published var isWebSearchEnabled: Bool = false + @Published var imageViewerImages: [Attachment] = [] + @Published var imageViewerIndex: Int = 0 + @Published var showImageViewer: Bool = false + @Published var editRequestedForMessageIndex: Int? = nil + + // Model properties + @Published var currentModel: ModelType + + // Attachment properties + @Published var pendingAttachments: [Attachment] = [] + @Published var isProcessingAttachment: Bool = false + @Published var attachmentError: String? = nil + @Published var pendingImageThumbnails: [String: String] = [:] + + var messages: [Message] { + currentChat?.messages ?? [] + } + + // Private properties + private var client: OpenAI? + private var currentTask: Task? + private var streamUpdateTimer: Timer? + private var pendingStreamUpdate: Chat? + private var networkStatusCancellable: AnyCancellable? + + init() { + guard let model = AppConfig.shared.currentModel ?? AppConfig.shared.availableModels.first else { + fatalError("ChatViewModel cannot be initialized without available models.") + } + self.currentModel = model + self.isWebSearchEnabled = SettingsManager.shared.webSearchEnabled + + // Create initial blank chat + let newChat = Chat.create(modelType: currentModel) + currentChat = newChat + chats = [newChat] + + setupClient() + setupNetworkStatusObserver() + } + + deinit { + streamUpdateTimer?.invalidate() + networkStatusCancellable?.cancel() + } + + // MARK: - Client Setup + + private func setupClient() { + client = AppConfig.shared.makeClient() + } + + func retryClientSetup() { + guard client == nil else { return } + guard !isLoading else { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.retryClientSetup() + } + return + } + setupClient() + } + + private func setupNetworkStatusObserver() { + networkStatusCancellable = AppConfig.shared.networkMonitor.$isConnected + .dropFirst() + .removeDuplicates() + .sink { [weak self] isConnected in + guard let self = self, isConnected else { return } + guard self.client == nil else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.retryClientSetup() + } + } + } + + // MARK: - Chat Management + + func createNewChat(language: String? = nil, modelType: ModelType? = nil, focusInput: Bool = true) { + if isLoading { cancelGeneration() } + + // Check if we already have a blank chat + if let existing = chats.first(where: { $0.isBlankChat }) { + selectChat(existing) + shouldFocusInput = focusInput + return + } + + let newChat = Chat.create(modelType: modelType ?? currentModel, language: language) + chats.insert(newChat, at: 0) + selectChat(newChat) + shouldFocusInput = focusInput + } + + func selectChat(_ chat: Chat) { + if isLoading { cancelGeneration() } + + if let index = chats.firstIndex(where: { $0.id == chat.id }) { + currentChat = chats[index] + } else { + currentChat = chat + chats.append(chat) + } + + if currentModel != chat.modelType { + changeModel(to: chat.modelType, shouldUpdateChat: false) + } + } + + func deleteChat(_ id: String) { + if let index = chats.firstIndex(where: { $0.id == id }) { + chats.remove(at: index) + } + + if currentChat?.id == id { + if let first = chats.first { + currentChat = first + } else { + createNewChat() + } + } + } + + func updateChatTitle(_ id: String, newTitle: String) { + guard let index = chats.firstIndex(where: { $0.id == id }) else { return } + let trimmed = newTitle.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + chats[index].title = Chat.placeholderTitle + chats[index].titleState = .placeholder + } else { + chats[index].title = trimmed + chats[index].titleState = .manual + } + if currentChat?.id == id { + currentChat = chats[index] + } + } + + // MARK: - Message Sending + + func sendMessage(text: String) { + guard !isLoading else { return } + let hasText = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasAttachments = !pendingAttachments.isEmpty + guard hasText || hasAttachments else { return } + + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + + isLoading = true + + let messageAttachments = pendingAttachments + clearPendingAttachments() + + let userMessage = Message(role: .user, content: text, attachments: messageAttachments) + addMessage(userMessage) + + generateResponse() + } + + // MARK: - Attachment Management + + func addImageAttachment(data: Data, fileName: String) { + isProcessingAttachment = true + attachmentError = nil + + let attachmentId = UUID().uuidString.lowercased() + var attachment = Attachment( + id: attachmentId, + type: .image, + fileName: fileName, + fileSize: Int64(data.count), + processingState: .processing + ) + pendingAttachments.append(attachment) + + Task { + // Simple image processing — resize and compress + guard let uiImage = UIImage(data: data) else { + attachment.processingState = .failed + if let index = pendingAttachments.firstIndex(where: { $0.id == attachmentId }) { + pendingAttachments[index] = attachment + } + attachmentError = "Failed to load image" + isProcessingAttachment = false + return + } + + let maxDim = Constants.Attachments.maxImageDimension + let scale = min(maxDim / max(uiImage.size.width, uiImage.size.height), 1.0) + let newSize = CGSize(width: uiImage.size.width * scale, height: uiImage.size.height * scale) + + UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) + uiImage.draw(in: CGRect(origin: .zero, size: newSize)) + let resized = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + guard let compressed = resized?.jpegData(compressionQuality: Constants.Attachments.imageCompressionQuality) else { + attachment.processingState = .failed + if let index = pendingAttachments.firstIndex(where: { $0.id == attachmentId }) { + pendingAttachments[index] = attachment + } + attachmentError = "Failed to compress image" + isProcessingAttachment = false + return + } + + attachment.mimeType = Constants.Attachments.defaultImageMimeType + attachment.base64 = compressed.base64EncodedString() + attachment.fileSize = Int64(compressed.count) + attachment.processingState = .completed + + let sizeKB = compressed.count / 1024 + attachment.description = "\(fileName) — \(Int(newSize.width))x\(Int(newSize.height)) JPEG, \(sizeKB) KB" + + // Generate thumbnail + let thumbMax = Constants.Attachments.thumbnailMaxDimension + let thumbScale = min(thumbMax / max(newSize.width, newSize.height), 1.0) + let thumbSize = CGSize(width: newSize.width * thumbScale, height: newSize.height * thumbScale) + UIGraphicsBeginImageContextWithOptions(thumbSize, false, 1.0) + resized?.draw(in: CGRect(origin: .zero, size: thumbSize)) + let thumb = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + attachment.thumbnailBase64 = thumb?.jpegData(compressionQuality: 0.6)?.base64EncodedString() + + if let index = pendingAttachments.firstIndex(where: { $0.id == attachmentId }) { + pendingAttachments[index] = attachment + } + if let tb = attachment.thumbnailBase64 { + pendingImageThumbnails[attachmentId] = tb + } + isProcessingAttachment = false + } + } + + func addDocumentAttachment(url: URL, fileName: String) { + isProcessingAttachment = true + attachmentError = nil + + let attachmentId = UUID().uuidString.lowercased() + var attachment = Attachment( + id: attachmentId, + type: .document, + fileName: fileName, + processingState: .processing + ) + pendingAttachments.append(attachment) + + Task { + do { + let accessing = url.startAccessingSecurityScopedResource() + defer { if accessing { url.stopAccessingSecurityScopedResource() } } + + let text = try String(contentsOf: url, encoding: .utf8) + let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0 + + attachment.textContent = text + attachment.fileSize = fileSize + attachment.processingState = .completed + + if let index = pendingAttachments.firstIndex(where: { $0.id == attachmentId }) { + pendingAttachments[index] = attachment + } + } catch { + attachment.processingState = .failed + if let index = pendingAttachments.firstIndex(where: { $0.id == attachmentId }) { + pendingAttachments[index] = attachment + } + attachmentError = error.localizedDescription + } + isProcessingAttachment = false + } + } + + func removePendingAttachment(id: String) { + pendingAttachments.removeAll { $0.id == id } + pendingImageThumbnails.removeValue(forKey: id) + if pendingAttachments.isEmpty { attachmentError = nil } + } + + func clearPendingAttachments() { + pendingAttachments.removeAll() + pendingImageThumbnails.removeAll() + attachmentError = nil + isProcessingAttachment = false + } + + // MARK: - Response Generation + + private func generateResponse() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + isLoading = true + + let assistantMessage = Message(role: .assistant, content: "", isCollapsed: true) + addMessage(assistantMessage) + + if var chat = currentChat { + chat.hasActiveStream = true + replaceChat(chat) + currentChat = chat + } + + let streamChatId = currentChat?.id + currentTask?.cancel() + + currentTask = Task { + var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid + backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "CompleteStreamingResponse") { + UIApplication.shared.endBackgroundTask(backgroundTaskId) + backgroundTaskId = .invalid + } + defer { + if backgroundTaskId != .invalid { + UIApplication.shared.endBackgroundTask(backgroundTaskId) + } + } + + var hasRetriedWithFreshKey = false + + retryLoop: do { + if client == nil { setupClient() } + + guard let client = client else { + throw NSError(domain: "ChatApp", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Client not initialized. Check your API key."]) + } + + let modelId = currentModel.modelName + let settingsManager = SettingsManager.shared + + var systemPrompt: String + if settingsManager.isUsingCustomPrompt && !settingsManager.customSystemPrompt.isEmpty { + systemPrompt = settingsManager.customSystemPrompt + } else { + systemPrompt = AppConfig.shared.systemPrompt + } + + systemPrompt = systemPrompt.replacingOccurrences(of: "{MODEL_NAME}", with: currentModel.fullName) + + let languageToUse = settingsManager.selectedLanguage != "System" ? settingsManager.selectedLanguage : "English" + systemPrompt = systemPrompt.replacingOccurrences(of: "{LANGUAGE}", with: languageToUse) + systemPrompt = systemPrompt.replacingOccurrences(of: "{USER_PREFERENCES}", with: "") + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + let currentDateTime = dateFormatter.string(from: Date()) + let timezone = TimeZone.current.abbreviation() ?? TimeZone.current.identifier + systemPrompt = systemPrompt.replacingOccurrences(of: "{CURRENT_DATETIME}", with: currentDateTime) + systemPrompt = systemPrompt.replacingOccurrences(of: "{TIMEZONE}", with: timezone) + + var processedRules = AppConfig.shared.rules + if !processedRules.isEmpty { + processedRules = processedRules.replacingOccurrences(of: "{MODEL_NAME}", with: currentModel.fullName) + processedRules = processedRules.replacingOccurrences(of: "{LANGUAGE}", with: languageToUse) + processedRules = processedRules.replacingOccurrences(of: "{USER_PREFERENCES}", with: "") + processedRules = processedRules.replacingOccurrences(of: "{CURRENT_DATETIME}", with: currentDateTime) + processedRules = processedRules.replacingOccurrences(of: "{TIMEZONE}", with: timezone) + } + + let chatQuery = ChatQueryBuilder.buildQuery( + modelId: modelId, + systemPrompt: systemPrompt, + rules: processedRules, + conversationMessages: self.messages, + maxMessages: settingsManager.maxMessages, + webSearchEnabled: self.isWebSearchEnabled, + isMultimodal: self.currentModel.isMultimodal + ) + + var collectedSources: [WebSearchSource] = [] + + let stream: AsyncThrowingStream = client.responses.createResponseStreaming(query: chatQuery) + + var thinkStartTime: Date? = nil + var thoughtsBuffer = "" + var isInThinkingMode = false + var responseContent = "" + var currentThoughts: String? = nil + var generationTimeSeconds: TimeInterval? = nil + let hapticEnabled = SettingsManager.shared.hapticFeedbackEnabled + var hapticGenerator: UIImpactFeedbackGenerator? + var lastHapticTime = Date.distantPast + let minHapticInterval: TimeInterval = 0.1 + let chunker = StreamingMarkdownChunker() + let thinkingChunker = ThinkingTextChunker() + var hapticChunkCount = 0 + var hasStartedResponse = false + var lastUIUpdateTime = Date.distantPast + let uiUpdateInterval: TimeInterval = 0.033 + + await MainActor.run { + if let chat = self.currentChat, + !chat.messages.isEmpty, + let lastIndex = chat.messages.indices.last { + responseContent = chat.messages[lastIndex].content + currentThoughts = chat.messages[lastIndex].thoughts + generationTimeSeconds = chat.messages[lastIndex].generationTimeSeconds + isInThinkingMode = chat.messages[lastIndex].isThinking + } + if hapticEnabled { + hapticGenerator = UIImpactFeedbackGenerator(style: .light) + hapticGenerator?.prepare() + } + } + + for try await event in stream { + if Task.isCancelled { break } + + // Haptic feedback + if hapticEnabled, let generator = hapticGenerator { + if hapticChunkCount < 5 { + let now = Date() + if now.timeIntervalSince(lastHapticTime) >= minHapticInterval { + generator.impactOccurred(intensity: 0.5) + lastHapticTime = now + hapticChunkCount += 1 + } + } + if !isInThinkingMode && !hasStartedResponse { + hasStartedResponse = true + hapticChunkCount = 0 + } + } + + var didMutateState = false + + switch event { + case .outputText(.delta(let textEvent)): + let content = textEvent.delta + if !content.isEmpty { + if isInThinkingMode { + if let startTime = thinkStartTime { + generationTimeSeconds = Date().timeIntervalSince(startTime) + } + isInThinkingMode = false + thinkStartTime = nil + thinkingChunker.finalize() + currentThoughts = thoughtsBuffer.isEmpty ? nil : thoughtsBuffer + Task { @MainActor [weak self] in + ThinkingSummaryService.shared.reset() + self?.thinkingSummary = "" + } + } + responseContent += content + chunker.appendToken(content) + didMutateState = true + if !hasStartedResponse { + hasStartedResponse = true + hapticChunkCount = 0 + } + } + + case .reasoning(.delta(let reasoningEvent)): + let text = (reasoningEvent.delta.value as? [String: Any])?["text"] as? String ?? "" + if !text.isEmpty { + if !isInThinkingMode { + isInThinkingMode = true + thinkStartTime = Date() + Task { @MainActor in ThinkingSummaryService.shared.reset() } + } + thoughtsBuffer += text + thinkingChunker.appendToken(text) + currentThoughts = thoughtsBuffer.isEmpty ? nil : thoughtsBuffer + didMutateState = true + let currentThoughtsForSummary = thoughtsBuffer + Task { @MainActor [weak self] in + ThinkingSummaryService.shared.generateSummary(thoughts: currentThoughtsForSummary) { summary in + self?.thinkingSummary = summary + } + } + } + + case .outputTextAnnotation(.added(let annotationEvent)): + if let dict = annotationEvent.annotation.value as? [String: Any], + let type = dict["type"] as? String, + type == "url_citation", + let url = dict["url"] as? String { + let title = dict["title"] as? String ?? url + collectedSources.append(WebSearchSource(title: title, url: url)) + didMutateState = true + } + + case .webSearchCall(.inProgress(_)), .webSearchCall(.searching(_)): + Task { @MainActor [weak self] in + guard let self = self else { return } + guard var chat = self.currentChat, + !chat.messages.isEmpty, + let lastIndex = chat.messages.indices.last else { return } + if chat.messages[lastIndex].webSearchState == nil { + chat.messages[lastIndex].webSearchState = WebSearchState(status: .searching) + } + self.webSearchSummary = "Searching the web..." + self.replaceChat(chat) + self.currentChat = chat + } + + case .webSearchCall(.completed(_)): + Task { @MainActor [weak self] in + guard let self = self else { return } + guard var chat = self.currentChat, + !chat.messages.isEmpty, + let lastIndex = chat.messages.indices.last else { return } + chat.messages[lastIndex].webSearchState?.status = .completed + self.webSearchSummary = "" + self.replaceChat(chat) + self.currentChat = chat + } + + default: + break + } + + // Throttled UI update + let now = Date() + if didMutateState && now.timeIntervalSince(lastUIUpdateTime) >= uiUpdateInterval { + lastUIUpdateTime = now + let currentChunks = chunker.getAllChunks() + let currentThinkingChunks = thinkingChunker.getAllChunks() + let capturedContent = responseContent + let capturedThoughts = currentThoughts + let capturedThinking = isInThinkingMode + let capturedGenTime = generationTimeSeconds + let capturedSources = collectedSources + + Task { @MainActor [weak self] in + guard let self = self else { return } + guard self.currentChat?.id == streamChatId else { return } + guard var chat = self.currentChat, + chat.hasActiveStream, + !chat.messages.isEmpty, + let lastIndex = chat.messages.indices.last else { return } + + let processedContent = self.processCitationMarkers(capturedContent, sources: capturedSources) + let processedChunks = self.processChunksWithCitations(currentChunks, sources: capturedSources) + + chat.messages[lastIndex].content = processedContent + chat.messages[lastIndex].thoughts = capturedThoughts + chat.messages[lastIndex].thinkingChunks = currentThinkingChunks + chat.messages[lastIndex].isThinking = capturedThinking + chat.messages[lastIndex].generationTimeSeconds = capturedGenTime + chat.messages[lastIndex].contentChunks = processedChunks + + if !capturedSources.isEmpty { + var searchState = chat.messages[lastIndex].webSearchState ?? WebSearchState(status: .searching) + searchState.sources = capturedSources + chat.messages[lastIndex].webSearchState = searchState + } + + self.replaceChat(chat) + self.currentChat = chat + } + } + } + + // Handle remaining thinking content when stream ends + if isInThinkingMode && !thoughtsBuffer.isEmpty { + currentThoughts = thoughtsBuffer.isEmpty ? nil : thoughtsBuffer + if responseContent.isEmpty { + responseContent = thoughtsBuffer + currentThoughts = nil + } + if let startTime = thinkStartTime { generationTimeSeconds = Date().timeIntervalSince(startTime) } + isInThinkingMode = false + } + + // Finalize message + await MainActor.run { + guard var chat = self.currentChat, chat.id == streamChatId else { + self.isLoading = false + return + } + chat.hasActiveStream = false + + ThinkingSummaryService.shared.reset() + self.thinkingSummary = "" + self.webSearchSummary = "" + + if !chat.messages.isEmpty, let lastIndex = chat.messages.indices.last { + chunker.finalize() + thinkingChunker.finalize() + let processedContent = self.processCitationMarkers(responseContent, sources: collectedSources) + chat.messages[lastIndex].content = processedContent + chat.messages[lastIndex].thoughts = currentThoughts + chat.messages[lastIndex].thinkingChunks = thinkingChunker.getAllChunks() + chat.messages[lastIndex].isThinking = false + chat.messages[lastIndex].generationTimeSeconds = generationTimeSeconds + let processedChunks = self.processChunksWithCitations(chunker.getAllChunks(), sources: collectedSources) + chat.messages[lastIndex].contentChunks = processedChunks + if !collectedSources.isEmpty { + var searchState = chat.messages[lastIndex].webSearchState ?? WebSearchState(status: .searching) + searchState.sources = collectedSources + chat.messages[lastIndex].webSearchState = searchState + } + } + + self.replaceChat(chat) + self.currentChat = chat + self.isLoading = false + + // Generate title if needed + if chat.needsGeneratedTitle && chat.messages.count >= 2 { + Task { + if let generated = await self.generateLLMTitle(from: chat.messages) { + if var updatedChat = self.chats.first(where: { $0.id == chat.id }) { + updatedChat.title = generated + updatedChat.titleState = .generated + self.replaceChat(updatedChat) + if self.currentChat?.id == updatedChat.id { + self.currentChat = updatedChat + } + Chat.triggerSuccessFeedback() + } + } + } + } + } + } catch { + let shouldRetry = await MainActor.run { + if !hasRetriedWithFreshKey && ChatViewModel.isAuthenticationError(error) { return true } + return false + } + + if shouldRetry { + hasRetriedWithFreshKey = true + await self.refreshClientForRetry() + if await MainActor.run(body: { self.client != nil }) { + continue retryLoop + } + } + + await MainActor.run { + self.isLoading = false + self.thinkingSummary = "" + self.webSearchSummary = "" + + if var chat = self.currentChat, chat.id == streamChatId { + chat.hasActiveStream = false + if !chat.messages.isEmpty { + let lastIndex = chat.messages.count - 1 + chat.messages[lastIndex].streamError = self.formatUserFriendlyError(error) + chat.messages[lastIndex].isRequestError = self.isRequestError(error) + } + self.replaceChat(chat) + self.currentChat = chat + } + } + } + } + } + + func cancelGeneration() { + currentTask?.cancel() + currentTask = nil + isLoading = false + thinkingSummary = "" + webSearchSummary = "" + + if var chat = currentChat { + chat.hasActiveStream = false + replaceChat(chat) + currentChat = chat + } + } + + func regenerateLastResponse() { + guard let chat = currentChat, !isLoading else { return } + guard let lastUserMessageIndex = chat.messages.lastIndex(where: { $0.role == .user }) else { return } + + var updatedChat = chat + updatedChat.messages = Array(chat.messages.prefix(lastUserMessageIndex + 1)) + replaceChat(updatedChat) + currentChat = updatedChat + + isScrollInteractionActive = false + scrollToUserMessageTrigger = UUID() + generateResponse() + } + + func editMessage(at messageIndex: Int, newContent: String) { + guard let chat = currentChat, + !isLoading, + messageIndex >= 0, + messageIndex < chat.messages.count, + chat.messages[messageIndex].role == .user else { return } + + let trimmedContent = newContent.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedContent.isEmpty else { return } + + var updatedChat = chat + updatedChat.messages = Array(chat.messages.prefix(messageIndex)) + replaceChat(updatedChat) + currentChat = updatedChat + + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + isLoading = true + + let userMessage = Message(role: .user, content: trimmedContent) + addMessage(userMessage) + generateResponse() + } + + func regenerateMessage(at messageIndex: Int) { + guard let chat = currentChat, + !isLoading, + messageIndex >= 0, + messageIndex < chat.messages.count, + chat.messages[messageIndex].role == .user else { return } + + var updatedChat = chat + updatedChat.messages = Array(chat.messages.prefix(messageIndex + 1)) + replaceChat(updatedChat) + currentChat = updatedChat + + isScrollInteractionActive = false + scrollToUserMessageTrigger = UUID() + generateResponse() + } + + // MARK: - Model Management + + func changeModel(to modelType: ModelType, shouldUpdateChat: Bool = true) { + guard modelType != currentModel else { return } + currentTask?.cancel() + currentTask = nil + isLoading = false + + self.currentModel = modelType + AppConfig.shared.currentModel = modelType + + if shouldUpdateChat, var chat = currentChat { + chat.modelType = modelType + replaceChat(chat) + currentChat = chat + } + } + + // MARK: - Thoughts Collapse + + func setThoughtsCollapsed(for messageId: String, collapsed: Bool) { + guard var chat = currentChat, + let messageIndex = chat.messages.firstIndex(where: { $0.id == messageId }) else { return } + guard chat.messages[messageIndex].isCollapsed != collapsed else { return } + chat.messages[messageIndex].isCollapsed = collapsed + replaceChat(chat) + currentChat = chat + } + + // MARK: - Private Helpers + + private func addMessage(_ message: Message) { + guard var chat = currentChat else { return } + chat.messages.append(message) + replaceChat(chat) + currentChat = chat + } + + private func replaceChat(_ updatedChat: Chat) { + if let index = chats.firstIndex(where: { $0.id == updatedChat.id }) { + chats[index] = updatedChat + } else if !updatedChat.isBlankChat { + chats.insert(updatedChat, at: min(1, chats.count)) + } + } + + private func refreshClientForRetry() async { + setupClient() + } + + private func formatUserFriendlyError(_ error: Error) -> String { + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + switch nsError.code { + case NSURLErrorNotConnectedToInternet, NSURLErrorDataNotAllowed: + return "The Internet connection appears to be offline." + case NSURLErrorNetworkConnectionLost: + return "Network connection was lost." + case NSURLErrorTimedOut: + return "Request timed out. Please try again." + default: + return "Network error. Please check your connection." + } + } + if case OpenAIError.statusError(_, let statusCode) = error { + switch statusCode { + case 401: + return "Invalid API key. Please check your key and try again." + case 429: + return "Rate limit exceeded. Please wait a moment and try again." + case 404: + return "Model not found. The selected model may not be available on your plan." + case 500...599: + return "The server encountered an error. Please try again later." + default: + return "Request failed (status \(statusCode)). Please try again." + } + } + if let apiError = error as? APIErrorResponse { + return apiError.error.message + } + return "An error occurred. Please try again." + } + + private func isRequestError(_ error: Error) -> Bool { + if case OpenAIError.statusError(_, let statusCode) = error, + (400...499).contains(statusCode), statusCode != 401 { return true } + return false + } + + static func isAuthenticationError(_ error: Error) -> Bool { + if case OpenAIError.statusError(_, let statusCode) = error, statusCode == 401 { return true } + if let apiError = error as? APIErrorResponse, apiError.error.code == "invalid_api_key" { return true } + return false + } + + private func processChunksWithCitations(_ chunks: [ContentChunk], sources: [WebSearchSource]) -> [ContentChunk] { + chunks.map { chunk in + ContentChunk(id: chunk.id, type: chunk.type, content: processCitationMarkers(chunk.content, sources: sources), isComplete: chunk.isComplete) + } + } + + private func processCitationMarkers(_ content: String, sources: [WebSearchSource]) -> String { + guard !sources.isEmpty else { return content } + guard let regex = Self.citationMarkerRegex else { return content } + + let nsContent = content as NSString + let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsContent.length)) + guard !matches.isEmpty else { return content } + + var result = "" + var lastEnd = content.startIndex + + for match in matches { + guard let matchRange = Range(match.range, in: content), + let numRange = Range(match.range(at: 1), in: content), + let num = Int(content[numRange]) else { continue } + + let index = num - 1 + guard index >= 0, index < sources.count else { continue } + let source = sources[index] + + let encodedUrl = source.url + .replacingOccurrences(of: "(", with: "%28") + .replacingOccurrences(of: ")", with: "%29") + .replacingOccurrences(of: "|", with: "%7C") + .replacingOccurrences(of: "~", with: "%7E") + let encodedTitle = (source.title + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? source.title) + .replacingOccurrences(of: "(", with: "%28") + .replacingOccurrences(of: ")", with: "%29") + .replacingOccurrences(of: "~", with: "%7E") + + result += content[lastEnd.. String? { + guard let assistantMessage = messages.first(where: { $0.role == .assistant }), + !assistantMessage.content.isEmpty else { return nil } + + // Use the title model if available, otherwise skip title generation + guard let titleModelConfig = AppConfig.shared.titleModel else { return nil } + + let words = assistantMessage.content.split(separator: " ", omittingEmptySubsequences: true) + let truncatedContent = words.prefix(Constants.TitleGeneration.wordThreshold).joined(separator: " ") + + do { + guard let client = client else { return nil } + + let query = CreateModelResponseQuery( + input: .inputItemList([ + .inputMessage(EasyInputMessage(role: .user, content: .textInput(truncatedContent))) + ]), + model: titleModelConfig.modelName, + instructions: Constants.TitleGeneration.systemPrompt, + maxOutputTokens: 50, + stream: true + ) + + var title = "" + let stream: AsyncThrowingStream = client.responses.createResponseStreaming(query: query) + for try await event in stream { + if case .outputText(.delta(let textEvent)) = event { + title += textEvent.delta + } + } + + let cleaned = title.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\"", with: "") + return cleaned.isEmpty ? nil : cleaned + } catch { + return nil + } + } +} diff --git a/SwiftChat/Views/AttachmentPreviewBar.swift b/SwiftChat/Views/AttachmentPreviewBar.swift new file mode 100644 index 0000000..89e547c --- /dev/null +++ b/SwiftChat/Views/AttachmentPreviewBar.swift @@ -0,0 +1,132 @@ +// +// AttachmentPreviewBar.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI + +struct AttachmentPreviewBar: View { + let attachments: [Attachment] + let thumbnails: [String: String] + let onRemove: (String) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(attachments) { attachment in + AttachmentPreviewChip( + attachment: attachment, + thumbnailBase64: thumbnails[attachment.id], + onRemove: { onRemove(attachment.id) } + ) + } + } + } + } +} + +private struct AttachmentPreviewChip: View { + let attachment: Attachment + let thumbnailBase64: String? + let onRemove: () -> Void + + @Environment(\.colorScheme) private var colorScheme + private var isDarkMode: Bool { colorScheme == .dark } + + var body: some View { + HStack(spacing: 8) { + thumbnailOrIcon + .frame(width: Constants.Attachments.previewThumbnailSize, + height: Constants.Attachments.previewThumbnailSize) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + VStack(alignment: .leading, spacing: 2) { + Text(attachment.fileName) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + .foregroundColor(isDarkMode ? .white : .black) + + HStack(spacing: 4) { + if attachment.processingState == .processing { + ProgressView() + .scaleEffect(0.6) + .frame(width: 12, height: 12) + Text("Processing...") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } else if attachment.processingState == .failed { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + .foregroundColor(.red) + Text("Failed") + .font(.system(size: 10)) + .foregroundColor(.red) + } else { + Text(formattedFileSize) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + } + } + .padding(6) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isDarkMode ? Color.white.opacity(0.1) : Color.black.opacity(0.05)) + ) + .frame(maxWidth: Constants.Attachments.previewMaxWidth) + .padding(.top, 6) + .padding(.trailing, 6) + .overlay(alignment: .topTrailing) { + Button(action: onRemove) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundColor(.secondary) + } + } + } + + @ViewBuilder + private var thumbnailOrIcon: some View { + if attachment.type == .image, let base64 = thumbnailBase64, + let data = Data(base64Encoded: base64), + let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(isDarkMode ? Color.white.opacity(0.08) : Color.black.opacity(0.06)) + Image(systemName: iconName) + .font(.system(size: 22)) + .foregroundColor(.secondary) + } + } + } + + private var iconName: String { + let ext = (attachment.fileName as NSString).pathExtension.lowercased() + switch ext { + case "pdf": return "doc.richtext" + case "html": return "globe" + case "csv": return "tablecells" + case "md": return "text.document" + default: return "doc.text" + } + } + + private var formattedFileSize: String { + let bytes = attachment.fileSize + if bytes < 1024 { + return "\(bytes) B" + } else if bytes < 1_048_576 { + return String(format: "%.0f KB", Double(bytes) / 1024) + } else { + return String(format: "%.1f MB", Double(bytes) / 1_048_576) + } + } +} diff --git a/SwiftChat/Views/CameraPickerView.swift b/SwiftChat/Views/CameraPickerView.swift new file mode 100644 index 0000000..f6bedaa --- /dev/null +++ b/SwiftChat/Views/CameraPickerView.swift @@ -0,0 +1,50 @@ +// +// CameraPickerView.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI +import UIKit + +/// UIViewControllerRepresentable wrapping UIImagePickerController for camera capture +struct CameraPickerView: UIViewControllerRepresentable { + let onImageCaptured: (UIImage) -> Void + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + if UIImagePickerController.isSourceTypeAvailable(.camera) { + picker.sourceType = .camera + } + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: CameraPickerView + + init(_ parent: CameraPickerView) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let image = info[.originalImage] as? UIImage { + parent.onImageCaptured(image) + } + parent.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + } +} diff --git a/SwiftChat/Views/ChatListView.swift b/SwiftChat/Views/ChatListView.swift new file mode 100644 index 0000000..cf4f599 --- /dev/null +++ b/SwiftChat/Views/ChatListView.swift @@ -0,0 +1,167 @@ +// +// ChatListView.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI + +struct ChatListView: View { + let isDarkMode: Bool + let isLoading: Bool + @ObservedObject var viewModel: SwiftChat.ChatViewModel + @ObservedObject private var settings = SettingsManager.shared + @Binding var messageText: String + + @State private var isAtBottom = true + @State private var userHasScrolled = false + @State private var isKeyboardVisible = false + @State private var keyboardHeight: CGFloat = 0 + @State private var scrollTrigger = UUID() + @State private var scrollToUserTrigger = UUID() + @State private var tableOpacity = 1.0 + + private var messages: [Message] { + viewModel.messages + } + + private var archivedMessagesStartIndex: Int { + max(0, messages.count - settings.maxMessages) + } + + var body: some View { + MessageTableView( + archivedMessagesStartIndex: archivedMessagesStartIndex, + isDarkMode: isDarkMode, + isLoading: isLoading, + viewModel: viewModel, + isAtBottom: $isAtBottom, + userHasScrolled: $userHasScrolled, + scrollTrigger: scrollTrigger, + scrollToUserTrigger: scrollToUserTrigger, + tableOpacity: $tableOpacity, + keyboardHeight: keyboardHeight + ) + .opacity(tableOpacity) + .background(Color.chatBackground(isDarkMode: isDarkMode)) + .overlay(alignment: .bottom) { + if !isAtBottom && !messages.isEmpty && !isKeyboardVisible { + Group { + if #available(iOS 26, *) { + Button(action: { + userHasScrolled = false + viewModel.isScrollInteractionActive = false + scrollTrigger = UUID() + }) { + Image(systemName: "arrow.down") + .font(.system(size: 12, weight: .semibold)) + .frame(width: Constants.UI.scrollToBottomButtonSize, height: Constants.UI.scrollToBottomButtonSize) + } + .buttonStyle(.glass) + .clipShape(Circle()) + } else { + Button(action: { + userHasScrolled = false + viewModel.isScrollInteractionActive = false + scrollTrigger = UUID() + }) { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.white) + .padding(8) + .background(Color.gray.opacity(0.8)) + .clipShape(Circle()) + } + } + } + .padding(.bottom, 16) + .transition(.opacity) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + MessageInputView( + messageText: $messageText, + viewModel: viewModel, + isKeyboardVisible: isKeyboardVisible + ) + .environmentObject(viewModel) + .if(UIDevice.current.userInterfaceIdiom == .pad) { view in + HStack { + Spacer() + view.frame(maxWidth: 600) + Spacer() + } + } + } + .onAppear { + setupKeyboardObservers() + } + .onDisappear { + removeKeyboardObservers() + viewModel.isScrollInteractionActive = false + } + .onChange(of: messages.count) { oldCount, newCount in + if newCount > oldCount { + let newMessages = messages.suffix(newCount - oldCount) + let hasUserMessage = newMessages.contains { $0.role == .user } + let wasInitialLoad = oldCount == 0 + + if hasUserMessage || wasInitialLoad { + userHasScrolled = false + viewModel.isScrollInteractionActive = false + if hasUserMessage && viewModel.isLoading { + scrollToUserTrigger = UUID() + } else { + scrollTrigger = UUID() + } + } + } + } + .onChange(of: viewModel.currentChat?.createdAt) { _, _ in + userHasScrolled = false + viewModel.isScrollInteractionActive = false + + let isNewEmptyChat = viewModel.currentChat?.isBlankChat ?? true + + if !isNewEmptyChat { + tableOpacity = 0 + scrollTrigger = UUID() + isAtBottom = true + } else { + tableOpacity = 1.0 + isAtBottom = true + } + } + .onChange(of: viewModel.scrollToBottomTrigger) { _, _ in + userHasScrolled = false + viewModel.isScrollInteractionActive = false + scrollTrigger = UUID() + } + .onChange(of: viewModel.scrollToUserMessageTrigger) { _, _ in + userHasScrolled = false + viewModel.isScrollInteractionActive = false + scrollToUserTrigger = UUID() + } + } + + private func setupKeyboardObservers() { + NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { notification in + if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + isKeyboardVisible = true + self.keyboardHeight = keyboardFrame.height + } + } + + NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in + isKeyboardVisible = false + keyboardHeight = 0 + } + } + + private func removeKeyboardObservers() { + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) + } +} diff --git a/SwiftChat/Views/ChatSidebar.swift b/SwiftChat/Views/ChatSidebar.swift new file mode 100644 index 0000000..a3b99c2 --- /dev/null +++ b/SwiftChat/Views/ChatSidebar.swift @@ -0,0 +1,241 @@ +// +// ChatSidebar.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI +import Combine + +struct ChatSidebar: View { + @Environment(\.colorScheme) var colorScheme + @Binding var isOpen: Bool + @ObservedObject var viewModel: SwiftChat.ChatViewModel + @State private var editingChatId: String? = nil + @State private var editingTitle: String = "" + @State private var deletingChatId: String? = nil + @State private var showDeleteAlert: Bool = false + + // Timer to update relative time strings + @State private var timeUpdateTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect() + @State private var currentTime = Date() + + private func relativeTimeString(from date: Date) -> String { + let now = currentTime + let difference = now.timeIntervalSince(date) + + if difference < 60 { + return "Just now" + } else if difference < 3600 { + let minutes = Int(difference / 60) + return "\(minutes)m ago" + } else if difference < 86400 { + let hours = Int(difference / 3600) + return "\(hours)h ago" + } else if difference < 604800 { + let days = Int(difference / 86400) + return "\(days)d ago" + } else if difference < 2592000 { + let weeks = Int(difference / 604800) + return "\(weeks)w ago" + } else { + let months = Int(difference / 2592000) + return "\(months)mo ago" + } + } + + var body: some View { + sidebarContent + .frame(width: 300) + .background(colorScheme == .dark ? Color.sidebarBackground(for: colorScheme) : Color.white) + .ignoresSafeArea(edges: .bottom) + .onReceive(timeUpdateTimer) { _ in + currentTime = Date() + } + .alert("Delete Chat", isPresented: $showDeleteAlert) { + Button("Cancel", role: .cancel) { + deletingChatId = nil + } + Button("Delete", role: .destructive) { + if let id = deletingChatId { + viewModel.deleteChat(id) + if viewModel.chats.isEmpty { + viewModel.createNewChat() + } + } + deletingChatId = nil + } + } + } + + private var sidebarContent: some View { + VStack(spacing: 0) { + // Chat History Header + VStack(alignment: .leading, spacing: 4) { + Text("Chat History") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 16) + .overlay( + Divider() + .background(Color.gray.opacity(0.3)), + alignment: .bottom + ) + + // Chat List + ScrollView { + LazyVStack(spacing: 12) { + ForEach(Array(viewModel.chats.enumerated()), id: \.element.id) { index, chat in + ChatListItem( + chat: chat, + isSelected: viewModel.currentChat?.id == chat.id, + isEditing: editingChatId == chat.id, + editingTitle: $editingTitle, + timeString: chat.isBlankChat ? "" : relativeTimeString(from: chat.createdAt), + onSelect: { + viewModel.selectChat(chat) + }, + onEdit: { + if editingChatId == chat.id { + viewModel.updateChatTitle(chat.id, newTitle: editingTitle) + editingChatId = nil + } else { + startEditing(chat) + } + }, + onDelete: { confirmDelete(chat) }, + showEditDelete: !chat.isBlankChat + ) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 8) + } + .applyAlwaysBounceIfAvailable() + .frame(maxHeight: .infinity) + + Divider() + .background(Color.gray.opacity(0.3)) + } + .frame(maxHeight: .infinity, alignment: .top) + } + + private func startEditing(_ chat: Chat) { + editingChatId = chat.id + editingTitle = chat.title + } + + private func confirmDelete(_ chat: Chat) { + deletingChatId = chat.id + showDeleteAlert = true + } +} + +// MARK: - Helpers + +private extension View { + @ViewBuilder + func applyAlwaysBounceIfAvailable() -> some View { + if #available(iOS 16.0, *) { + self.scrollBounceBehavior(.always) + } else { + self + } + } +} + +struct ChatListItem: View { + let chat: Chat + let isSelected: Bool + let isEditing: Bool + @Binding var editingTitle: String + let timeString: String + let onSelect: () -> Void + let onEdit: () -> Void + let onDelete: () -> Void + let showEditDelete: Bool + + var body: some View { + Button(action: onSelect) { + VStack(alignment: .leading, spacing: 2) { + HStack { + if isEditing { + TextField("Chat Title", text: $editingTitle) + .textFieldStyle(PlainTextFieldStyle()) + .foregroundColor(.primary) + .onSubmit { onEdit() } + + HStack(spacing: 12) { + Button(action: onEdit) { + Image(systemName: "checkmark") + .foregroundColor(.primary) + } + Button(action: { editingTitle = chat.title; onEdit() }) { + Image(systemName: "xmark") + .foregroundColor(.primary) + } + } + } else { + HStack(spacing: 4) { + Text(chat.title) + .foregroundColor(.primary) + .lineLimit(1) + + if chat.isBlankChat { + Circle() + .fill(Color.blue) + .frame(width: 8, height: 8) + } + + Spacer() + } + + if isSelected && showEditDelete { + HStack(spacing: 12) { + Button(action: onEdit) { + Image(systemName: "square.and.pencil") + .foregroundColor(.gray) + } + Button(action: onDelete) { + Image(systemName: "trash") + .foregroundColor(.gray) + } + } + } + } + } + + if !isEditing { + if !timeString.isEmpty { + Text(timeString) + .font(.caption) + .foregroundColor(.gray) + } else { + Text(" ") + .font(.caption) + .frame(height: 14) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? Color(UIColor.secondarySystemBackground) : Color(UIColor.secondarySystemBackground).opacity(0.3)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.gray.opacity(0.1), lineWidth: 1) + ) + } +} diff --git a/SwiftChat/Views/ChatView.swift b/SwiftChat/Views/ChatView.swift new file mode 100644 index 0000000..9c40829 --- /dev/null +++ b/SwiftChat/Views/ChatView.swift @@ -0,0 +1,340 @@ +// +// ChatView.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + + +import SwiftUI + + +// MARK: - ChatContainer + +/// The primary SwiftUI container that holds the main chat interface and sidebar navigation. +struct ChatContainer: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject private var viewModel: SwiftChat.ChatViewModel + @StateObject private var settings = SettingsManager.shared + + @State private var isSidebarOpen = UIDevice.current.userInterfaceIdiom == .pad + @State private var messageText = "" + @State private var dragOffset: CGFloat = 0 + + // Sidebar constants + private let sidebarWidth: CGFloat = 300 + + private var toolbarContentColor: Color { + colorScheme == .dark ? Color.white : Color.black + } + + var body: some View { + NavigationView { + mainContent + .background(Color.chatBackground(isDarkMode: colorScheme == .dark)) + } + .navigationViewStyle(.stack) + .environmentObject(viewModel) + .onAppear { + setupNavigationBarAppearance() + isSidebarOpen = false + dragOffset = 0 + } + .onChange(of: colorScheme) { _, _ in + setupNavigationBarAppearance() + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in + withAnimation(.easeOut(duration: 0.2)) { + dragOffset = 0 + } + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + withAnimation(.easeOut(duration: 0.2)) { + isSidebarOpen = false + dragOffset = 0 + } + } + .fullScreenCover(isPresented: $viewModel.showImageViewer) { + ImageViewerOverlay( + images: viewModel.imageViewerImages, + initialIndex: viewModel.imageViewerIndex, + onDismiss: { viewModel.showImageViewer = false } + ) + } + } + + /// Configure navigation bar appearance + private func setupNavigationBarAppearance() { + if #available(iOS 26, *) { + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.shadowColor = .clear + updateAllNavigationBars(with: appearance) + } else { + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = colorScheme == .dark ? UIColor(Color.backgroundPrimary) : .white + appearance.shadowColor = .clear + updateAllNavigationBars(with: appearance) + } + } + + private func updateAllNavigationBars(with appearance: UINavigationBarAppearance) { + let tintColor: UIColor = colorScheme == .dark ? .white : .black + UINavigationBar.appearance().standardAppearance = appearance + UINavigationBar.appearance().compactAppearance = appearance + UINavigationBar.appearance().scrollEdgeAppearance = appearance + UINavigationBar.appearance().tintColor = tintColor + + for scene in UIApplication.shared.connectedScenes { + if let windowScene = scene as? UIWindowScene { + for window in windowScene.windows { + if let navigationBar = window.rootViewController?.navigationController?.navigationBar { + navigationBar.standardAppearance = appearance + navigationBar.compactAppearance = appearance + navigationBar.scrollEdgeAppearance = appearance + navigationBar.tintColor = tintColor + } + } + } + } + } + + /// The main content layout including chat area and sidebar + private var mainContent: some View { + Group { + if UIDevice.current.userInterfaceIdiom == .pad { + GeometryReader { geometry in + HStack(spacing: 0) { + if isSidebarOpen { + ChatSidebar(isOpen: $isSidebarOpen, viewModel: viewModel) + .frame(width: sidebarWidth) + .transition(.move(edge: .leading)) + } + chatArea + .frame(width: isSidebarOpen ? geometry.size.width - sidebarWidth : geometry.size.width) + } + } + .animation(.easeInOut(duration: 0.3), value: isSidebarOpen) + } else { + ZStack { + chatArea + sidebarLayer + } + } + } + .navigationBarTitleDisplayMode(.inline) + .applyTransparentToolbarIfAvailable() + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: toggleSidebar) { + MenuToXButton(isX: isSidebarOpen) + .frame(width: 24, height: 24) + .foregroundColor(toolbarContentColor) + } + } + ToolbarItem(placement: .principal) { + Text("SwiftChat") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(toolbarContentColor) + .opacity(isSidebarOpen ? 1 : 0) + .animation(.easeInOut(duration: 0.2), value: isSidebarOpen) + } + if !(viewModel.currentChat?.isBlankChat ?? true) { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: createNewChat) { + Image(systemName: "plus") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(toolbarContentColor) + } + } + } + } + .gesture( + UIDevice.current.userInterfaceIdiom == .phone ? + DragGesture() + .onChanged { gesture in + if isSidebarOpen { + dragOffset = max(-sidebarWidth, min(0, gesture.translation.width)) + } else { + dragOffset = max(0, min(sidebarWidth, gesture.translation.width)) + } + } + .onEnded { gesture in + let threshold: CGFloat = 100 + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { + if isSidebarOpen { + if gesture.translation.width < -threshold { + isSidebarOpen = false + } + } else { + if gesture.translation.width > threshold { + isSidebarOpen = true + dismissKeyboard() + } + } + dragOffset = 0 + } + } : nil + ) + .onChange(of: isSidebarOpen) { _, isOpen in + if !isOpen { dragOffset = 0 } + } + } + + private var chatArea: some View { + ChatListView( + isDarkMode: colorScheme == .dark, + isLoading: viewModel.isLoading, + viewModel: viewModel, + messageText: $messageText + ) + .background(Color.chatBackground(isDarkMode: colorScheme == .dark)) + .ignoresSafeArea(edges: .top) + } + + private var sidebarLayer: some View { + ZStack { + Color.black + .opacity({ + let base = 0.4 + let fraction = (dragOffset / sidebarWidth * 0.4) + return isSidebarOpen ? (base + fraction) : max(0, fraction) + }()) + .ignoresSafeArea() + .allowsHitTesting(isSidebarOpen || abs(dragOffset) > 0.1) + .onTapGesture { + withAnimation { isSidebarOpen = false } + } + + HStack(spacing: 0) { + ChatSidebar(isOpen: $isSidebarOpen, viewModel: viewModel) + .frame(width: sidebarWidth) + .offset(x: isSidebarOpen ? + (0 + dragOffset) : + (-(sidebarWidth + 1) + dragOffset)) + Spacer() + } + } + .animation(.easeInOut, value: isSidebarOpen) + } + + // MARK: - Actions + + private func toggleSidebar() { + withAnimation { + isSidebarOpen.toggle() + if isSidebarOpen { dismissKeyboard() } + } + } + + private func dismissKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + + private func createNewChat() { + if !viewModel.messages.isEmpty { + let language = settings.selectedLanguage == "System" ? nil : settings.selectedLanguage + viewModel.createNewChat(language: language) + messageText = "" + } + } +} + +// MARK: - WelcomeView + +struct WelcomeView: View { + let isDarkMode: Bool + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 16) { + Text("Start a conversation") + .font(.title) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 32) + } + .padding(.top, 24) + .padding(.bottom, 4) + } +} + +// MARK: - Helper Views + +/// Animated button that transforms between a menu icon and an X +struct MenuToXButton: View { + let isX: Bool + + var body: some View { + ZStack { + Rectangle() + .frame(width: 18, height: 2) + .rotationEffect(.degrees(isX ? 45 : 0)) + .offset(y: isX ? 0 : -6) + Rectangle() + .frame(width: 18, height: 2) + .opacity(isX ? 0 : 1) + Rectangle() + .frame(width: 18, height: 2) + .rotationEffect(.degrees(isX ? -45 : 0)) + .offset(y: isX ? 0 : 6) + } + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isX) + } +} + +/// A shape for custom corner rounding +struct RoundedCorner: Shape { + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } +} + +/// Extension for applying rounded corners to views +extension View { + func corners(_ corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: 15, corners: corners)) + } + + @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { + if condition { + transform(self) + } else { + self + } + } + + @ViewBuilder + func applyTransparentToolbarIfAvailable() -> some View { + if #available(iOS 26, *) { + self.toolbarBackground(.hidden, for: .navigationBar) + } else { + self + } + } +} + +// Helper extension to convert UIView.AnimationCurve to SwiftUI Animation +extension Animation { + init(curve: UIView.AnimationCurve, duration: Double) { + switch curve { + case .easeInOut: + self = .easeInOut(duration: duration) + case .easeIn: + self = .easeIn(duration: duration) + case .easeOut: + self = .easeOut(duration: duration) + case .linear: + self = .linear(duration: duration) + @unknown default: + self = .easeInOut(duration: duration) + } + } +} diff --git a/SwiftChat/Views/DocumentPickerView.swift b/SwiftChat/Views/DocumentPickerView.swift new file mode 100644 index 0000000..b485697 --- /dev/null +++ b/SwiftChat/Views/DocumentPickerView.swift @@ -0,0 +1,69 @@ +// +// DocumentPickerView.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI +import UniformTypeIdentifiers + +struct DocumentPickerView: UIViewControllerRepresentable { + var onDocumentPicked: (URL, String) -> Void + + private static let supportedTypes: [UTType] = [ + .pdf, + .plainText, + .html, + .commaSeparatedText, + UTType(filenameExtension: "md") ?? .plainText + ] + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let picker = UIDocumentPickerViewController(forOpeningContentTypes: Self.supportedTypes) + picker.allowsMultipleSelection = false + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(onDocumentPicked: onDocumentPicked) + } + + class Coordinator: NSObject, UIDocumentPickerDelegate { + let onDocumentPicked: (URL, String) -> Void + + init(onDocumentPicked: @escaping (URL, String) -> Void) { + self.onDocumentPicked = onDocumentPicked + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let sourceURL = urls.first else { return } + + let fileName = sourceURL.lastPathComponent + + guard sourceURL.startAccessingSecurityScopedResource() else { + return + } + defer { sourceURL.stopAccessingSecurityScopedResource() } + + let tempDir = FileManager.default.temporaryDirectory + let tempURL = tempDir.appendingPathComponent(UUID().uuidString + "_" + fileName) + + do { + if FileManager.default.fileExists(atPath: tempURL.path) { + try FileManager.default.removeItem(at: tempURL) + } + try FileManager.default.copyItem(at: sourceURL, to: tempURL) + onDocumentPicked(tempURL, fileName) + } catch { + #if DEBUG + print("Failed to copy document to temp directory: \(error)") + #endif + } + } + } +} diff --git a/SwiftChat/Views/LaTeXMarkdownView.swift b/SwiftChat/Views/LaTeXMarkdownView.swift new file mode 100644 index 0000000..f1bd3ae --- /dev/null +++ b/SwiftChat/Views/LaTeXMarkdownView.swift @@ -0,0 +1,1044 @@ +// +// LaTeXMarkdownView.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI +import Textual +import SwiftMath +import UIKit + +private enum SegmentKind: Sendable { + case markdown(String) + case latex(String, isDisplay: Bool) + case table(ParsedTable) +} + + + +private struct ContentSegment: Sendable { + let id: String + let kind: SegmentKind +} + +private struct SegmentView: View { + let segment: ContentSegment + let isDarkMode: Bool + let isStreaming: Bool + + var body: some View { + switch segment.kind { + case .markdown(let text): + // Strip citation markers from text - sources shown separately at message level + // Skip during streaming to avoid catastrophic regex backtracking on incomplete citations + let strippedText = isStreaming ? text : LaTeXMarkdownView.stripCitations(from: text) + StructuredText(markdown: strippedText) + .textual.structuredTextStyle(.gitHub) + .textual.highlighterTheme(isStreaming ? .plain : .default) + .textual.textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .environment(\.colorScheme, isDarkMode ? .dark : .light) + case .latex(let latex, let isDisplay): + LaTeXView( + latex: latex, + isDisplay: isDisplay, + isDarkMode: isDarkMode + ) + case .table(let table): + MarkdownTableView( + table: table, + isDarkMode: isDarkMode + ) + } + } +} + +/// Simple cache for parsed markdown segments +private class MarkdownRenderCache: @unchecked Sendable { + static let shared = MarkdownRenderCache() + + private var cache: [String: [ContentSegment]] = [:] + private let queue = DispatchQueue(label: "com.swiftchat.markdown-cache", attributes: .concurrent) + + init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleMemoryWarning), + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func handleMemoryWarning() { + clear() + } + + func get(for key: String) -> [ContentSegment]? { + queue.sync { + cache[key] + } + } + + func set(_ segments: [ContentSegment], for key: String) { + queue.async(flags: .barrier) { + self.cache[key] = segments + } + } + + func clear() { + queue.async(flags: .barrier) { + self.cache.removeAll() + } + } +} + +/// A view that renders mixed Markdown and LaTeX content +struct LaTeXMarkdownView: View, Equatable { + let content: String + let isDarkMode: Bool + let horizontalPadding: CGFloat + let maxWidthAlignment: Alignment + let isStreaming: Bool + + @State private var segments: [ContentSegment]? = nil + + // Pre-compiled regex patterns (compiled once, reused across all renders) + private nonisolated(unsafe) static let codeBlockRegex = try? NSRegularExpression(pattern: "```[\\s\\S]*?```", options: []) + private nonisolated(unsafe) static let inlineCodeRegex = try? NSRegularExpression(pattern: "`[^`]+`", options: []) + private nonisolated(unsafe) static let displayLatexRegex = try? NSRegularExpression(pattern: "\\\\\\[(.+?)\\\\\\]", options: [.dotMatchesLineSeparators]) + private nonisolated(unsafe) static let inlineLatexRegex = try? NSRegularExpression(pattern: "\\\\\\((.+?)\\\\\\)", options: []) + static func == (lhs: LaTeXMarkdownView, rhs: LaTeXMarkdownView) -> Bool { + lhs.content == rhs.content && + lhs.isDarkMode == rhs.isDarkMode && + lhs.horizontalPadding == rhs.horizontalPadding && + lhs.maxWidthAlignment == rhs.maxWidthAlignment && + lhs.isStreaming == rhs.isStreaming + } + + init(content: String, isDarkMode: Bool, horizontalPadding: CGFloat = 0, maxWidthAlignment: Alignment = .leading, isStreaming: Bool = false) { + self.content = content + self.isDarkMode = isDarkMode + self.horizontalPadding = horizontalPadding + self.maxWidthAlignment = maxWidthAlignment + self.isStreaming = isStreaming + + // Resolve segments synchronously from cache when available so the + // first render already has the final view tree. This prevents + // UIHostingConfiguration from calculating the cell height based on + // the fallback view and then never recalculating after the async + // .task swaps in the parsed segments. + if !isStreaming { + let cacheKey = "\(content.hashValue)" + if let cached = MarkdownRenderCache.shared.get(for: cacheKey) { + _segments = State(initialValue: cached) + } + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if isStreaming { + markdownFallback(content: content) + } else if let segments = segments { + ForEach(segments, id: \.id) { segment in + SegmentView(segment: segment, isDarkMode: isDarkMode, isStreaming: false) + .id(segment.id) + } + } else { + markdownFallback(content: content) + } + } + .environment(\.colorScheme, isDarkMode ? .dark : .light) + .padding(.horizontal, horizontalPadding) + .frame(maxWidth: horizontalPadding > 0 ? .infinity : nil, alignment: maxWidthAlignment) + .transaction { transaction in + transaction.animation = nil + } + .task(id: content) { + guard !isStreaming else { return } + let cacheKey = "\(content.hashValue)" + if let cached = MarkdownRenderCache.shared.get(for: cacheKey) { + if segments == nil { + segments = cached + } + return + } + let contentToProcess = content + let parsed = Self.parseContent(contentToProcess) + MarkdownRenderCache.shared.set(parsed, for: cacheKey) + segments = parsed + } + } + + private func markdownFallback(content: String) -> some View { + let strippedText = LaTeXMarkdownView.stripCitations(from: content) + return StructuredText(markdown: strippedText) + .textual.structuredTextStyle(.gitHub) + .textual.highlighterTheme(isStreaming ? .plain : .default) + .textual.textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .environment(\.colorScheme, isDarkMode ? .dark : .light) + } + + /// Strip citation markers from markdown text. + /// Uses a linear-time scanner instead of regex to avoid catastrophic backtracking + /// on incomplete citations during streaming. + /// Matches: ` ?[N](#cite-N~...)` where parens in URLs are balanced one level deep. + static func stripCitations(from text: String) -> String { + let chars = Array(text.unicodeScalars) + let count = chars.count + var result = String.UnicodeScalarView() + result.reserveCapacity(count) + var i = 0 + let citePrefix: [UnicodeScalar] = Array("#cite-".unicodeScalars) + + while i < count { + // Check for optional leading space before `[` + var start = i + if chars[start] == " " && start + 1 < count && chars[start + 1] == "[" { + start += 1 + } + guard start < count, chars[start] == "[" else { + result.append(chars[i]) + i += 1 + continue + } + + // Match `[digits]` + var j = start + 1 + guard j < count, chars[j] >= "0", chars[j] <= "9" else { + result.append(chars[i]) + i += 1 + continue + } + let digitStart = j + while j < count, chars[j] >= "0", chars[j] <= "9" { j += 1 } + let digitEnd = j + guard j < count, chars[j] == "]" else { + result.append(chars[i]) + i += 1 + continue + } + j += 1 + + // Match `(#cite-digits~` + guard j < count, chars[j] == "(" else { + result.append(chars[i]) + i += 1 + continue + } + j += 1 + var matches = true + for c in citePrefix { + guard j < count, chars[j] == c else { matches = false; break } + j += 1 + } + guard matches else { + result.append(chars[i]) + i += 1 + continue + } + guard j < count, chars[j] >= "0", chars[j] <= "9" else { + result.append(chars[i]) + i += 1 + continue + } + while j < count, chars[j] >= "0", chars[j] <= "9" { j += 1 } + guard j < count, chars[j] == "~" else { + result.append(chars[i]) + i += 1 + continue + } + j += 1 + + // Capture URL (everything up to the next `~`) + let urlStart = j + var urlEnd = j + var foundTilde = false + while j < count { + if chars[j] == "~" { + urlEnd = j + foundTilde = true + break + } + j += 1 + } + + guard foundTilde else { + for k in i.. [ContentSegment] { + if content.count > Constants.Rendering.maxFullParsingCharacters { + return [ContentSegment(id: "md_full_\(content.hashValue)", kind: .markdown(content))] + } + + let nsContent = content as NSString + let fullRange = NSRange(location: 0, length: nsContent.length) + + var excludedRanges: [NSRange] = [] + + if let codeBlockRegex = Self.codeBlockRegex { + let matches = codeBlockRegex.matches(in: content, options: [], range: fullRange) + excludedRanges.append(contentsOf: matches.map(\.range)) + } + + if let inlineCodeRegex = Self.inlineCodeRegex { + let matches = inlineCodeRegex.matches(in: content, options: [], range: fullRange) + for match in matches where !excludedRanges.contains(where: { NSIntersectionRange($0, match.range).length > 0 }) { + excludedRanges.append(match.range) + } + } + + let tableSegments = Self.findMarkdownTables(in: content) + excludedRanges.append(contentsOf: tableSegments.map(\.range)) + + func isExcluded(_ range: NSRange) -> Bool { + excludedRanges.contains { NSIntersectionRange($0, range).length > 0 } + } + + var latexRanges: [(range: NSRange, isDisplay: Bool)] = [] + + if let displayRegex = Self.displayLatexRegex { + let matches = displayRegex.matches(in: content, options: [], range: fullRange) + for match in matches where !isExcluded(match.range) { + latexRanges.append((match.range, true)) + } + } + + if let inlineRegex = Self.inlineLatexRegex { + let matches = inlineRegex.matches(in: content, options: [], range: fullRange) + for match in matches where !isExcluded(match.range) { + let overlaps = latexRanges.contains { existing in + NSLocationInRange(match.range.location, existing.range) || + NSLocationInRange(existing.range.location, match.range) + } + if !overlaps { + latexRanges.append((match.range, false)) + } + } + } + + latexRanges.sort { $0.range.location < $1.range.location } + + var specialSegments: [SpecialSegment] = latexRanges.map { match in + SpecialSegment(range: match.range, kind: .latex(isDisplay: match.isDisplay)) + } + specialSegments.append(contentsOf: tableSegments.map { table in + SpecialSegment(range: table.range, kind: .table(table.table)) + }) + specialSegments.sort { $0.range.location < $1.range.location } + + var segments: [ContentSegment] = [] + var lastIndex = content.startIndex + + for special in specialSegments { + guard let swiftRange = Range(special.range, in: content) else { continue } + + if lastIndex < swiftRange.lowerBound { + let markdownText = String(content[lastIndex.. [ContentSegment] in + guard case .markdown(let text) = segment.kind, + text.count > Constants.Rendering.maxMarkdownSegmentCharacters else { + return [segment] + } + return splitMarkdownSegment(text, baseId: segment.id) + } + + return segments + } + + private nonisolated static func splitMarkdownSegment(_ text: String, baseId: String) -> [ContentSegment] { + let paragraphs = text.components(separatedBy: "\n\n") + var result: [ContentSegment] = [] + var current = "" + var subIndex = 0 + + for (_, paragraph) in paragraphs.enumerated() { + let candidate = current.isEmpty ? paragraph : current + "\n\n" + paragraph + if candidate.count > Constants.Rendering.maxMarkdownSegmentCharacters && !current.isEmpty { + result.append(ContentSegment( + id: "\(baseId)_split_\(subIndex)", + kind: .markdown(current) + )) + subIndex += 1 + current = paragraph + } else { + current = candidate + } + } + + if !current.isEmpty { + result.append(ContentSegment( + id: "\(baseId)_split_\(subIndex)", + kind: .markdown(current) + )) + } + + return result + } + + private nonisolated static func sanitizeLatex(_ latex: String) -> String { + let trimmed = latex.trimmingCharacters(in: .whitespacesAndNewlines) + let base = trimmed.isEmpty ? latex : trimmed + + guard base.contains("\\text{") else { return base } + + return Self.normalizeTextCommands(in: base) + } + + private nonisolated static func normalizeTextCommands(in latex: String) -> String { + var result = "" + var index = latex.startIndex + + while index < latex.endIndex { + if latex[index...].hasPrefix("\\text{") { + let contentStart = latex.index(index, offsetBy: 6) + var cursor = contentStart + var depth = 1 + + while cursor < latex.endIndex && depth > 0 { + let character = latex[cursor] + if character == "{" { + depth += 1 + } else if character == "}" { + depth -= 1 + if depth == 0 { break } + } + cursor = latex.index(after: cursor) + } + + if depth == 0 { + let content = String(latex[contentStart.. String { + guard content.contains("\\(") else { + return "\\text{\(content)}" + } + + var result = "" + var currentIndex = content.startIndex + + while let openRange = content.range(of: "\\(", range: currentIndex.. + } + + private nonisolated static func findMarkdownTables(in content: String) -> [TableMatch] { + guard content.contains("|") else { return [] } + + var lines: [LineInfo] = [] + content.enumerateSubstrings(in: content.startIndex.. ParsedTable? { + guard lines.count >= 2 else { return nil } + + let headerCells = parseTableCells(from: lines[0]) + let alignmentCells = parseAlignmentRow(from: lines[1], columnCount: headerCells.count) + guard !headerCells.isEmpty else { return nil } + + let columnCount = max(headerCells.count, alignmentCells.count) + let normalizedHeader = normalizeRow(headerCells, targetCount: columnCount) + let normalizedAlignments = alignmentCells.count == columnCount ? alignmentCells : normalizeAlignments(alignmentCells, targetCount: columnCount) + + var rows: [[String]] = [] + for line in lines.dropFirst(2) { + let cells = parseTableCells(from: line) + rows.append(normalizeRow(cells, targetCount: columnCount)) + } + + return ParsedTable(headers: normalizedHeader, alignments: normalizedAlignments, rows: rows) + } + + private nonisolated static func parseAlignmentRow(from line: String, columnCount: Int) -> [TableAlignment] { + let cells = parseTableCells(from: line) + guard !cells.isEmpty else { return Array(repeating: .leading, count: columnCount) } + + let alignments = cells.map { cell -> TableAlignment in + let trimmed = cell.trimmingCharacters(in: .whitespaces) + let leading = trimmed.hasPrefix(":") + let trailing = trimmed.hasSuffix(":") + + switch (leading, trailing) { + case (true, true): + return .center + case (false, true): + return .trailing + default: + return .leading + } + } + + return normalizeAlignments(alignments, targetCount: max(columnCount, alignments.count)) + } + + private nonisolated static func normalizeAlignments(_ alignments: [TableAlignment], targetCount: Int) -> [TableAlignment] { + if alignments.count == targetCount { + return alignments + } else if alignments.count < targetCount { + return alignments + Array(repeating: .leading, count: targetCount - alignments.count) + } else { + return Array(alignments.prefix(targetCount)) + } + } + + private nonisolated static func normalizeRow(_ cells: [String], targetCount: Int) -> [String] { + if cells.count == targetCount { + return cells + } else if cells.count < targetCount { + return cells + Array(repeating: "", count: targetCount - cells.count) + } else { + return Array(cells.prefix(targetCount)) + } + } + + private nonisolated static func parseTableCells(from line: String) -> [String] { + let placeholder = "__ESCAPED_PIPE__" + var working = line.trimmingCharacters(in: .whitespaces) + + while working.hasPrefix("|") { + working.removeFirst() + } + while working.hasSuffix("|") { + working.removeLast() + } + + working = working.replacingOccurrences(of: "\\|", with: placeholder) + + let parts = working.split(separator: "|", omittingEmptySubsequences: false).map(String.init) + return parts.map { part in + part.replacingOccurrences(of: placeholder, with: "|").trimmingCharacters(in: .whitespaces) + } + } + + private nonisolated static func isAlignmentLine(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("|") else { return false } + + let cells = parseTableCells(from: line) + guard !cells.isEmpty else { return false } + + let allowed = CharacterSet(charactersIn: "-: ") + return cells.allSatisfy { cell in + let cellTrimmed = cell.trimmingCharacters(in: .whitespaces) + guard cellTrimmed.contains("-") else { return false } + return cellTrimmed.unicodeScalars.allSatisfy { allowed.contains($0) } + } + } +} + +private struct ParsedTable: Sendable { + let headers: [String] + let alignments: [TableAlignment] + let rows: [[String]] +} + +private enum TableAlignment: Sendable { + case leading + case center + case trailing + + var viewAlignment: Alignment { + switch self { + case .leading: + return .leading + case .center: + return .center + case .trailing: + return .trailing + } + } +} + +private struct MarkdownTableView: View { + let table: ParsedTable + let isDarkMode: Bool + + @State private var columnWidths: [Int: CGFloat] = [:] + + private var borderColor: SwiftUI.Color { + isDarkMode ? SwiftUI.Color.white.opacity(0.2) : SwiftUI.Color.black.opacity(0.2) + } + + private var headerBackground: SwiftUI.Color { + SwiftUI.Color.clear + } + + private var alternatingRowBackground: SwiftUI.Color { + SwiftUI.Color.clear + } + + var body: some View { + ViewThatFits(in: .horizontal) { + tableContainer + ScrollView(.horizontal, showsIndicators: true) { + tableContainer + } + } + .padding(.vertical, 8) + .onAppear { + if columnWidths.isEmpty { + columnWidths = Self.measureColumnWidths(for: table) + } + } + } + + /// Measure column widths using text measurement instead of rendering a hidden table. + private static func measureColumnWidths(for table: ParsedTable) -> [Int: CGFloat] { + let font = UIFont.systemFont(ofSize: Constants.UI.tableFontSize) + let boldFont = UIFont.boldSystemFont(ofSize: Constants.UI.tableFontSize) + let horizontalPadding = Constants.UI.tableCellHorizontalPadding * 2 + var widths: [Int: CGFloat] = [:] + + for (index, header) in table.headers.enumerated() { + let size = (header as NSString).size(withAttributes: [.font: boldFont]) + widths[index] = min(size.width + horizontalPadding, Constants.UI.tableMaxColumnWidth) + } + + for row in table.rows { + for (index, cell) in row.enumerated() { + let size = (cell as NSString).size(withAttributes: [.font: font]) + let width = min(size.width + horizontalPadding, Constants.UI.tableMaxColumnWidth) + widths[index] = max(widths[index] ?? 0, width) + } + } + + return widths + } + + private var tableContainer: some View { + VStack(spacing: 0) { + if !table.headers.isEmpty { + MarkdownTableRowView( + cells: table.headers, + alignments: table.alignments, + isHeader: true, + isDarkMode: isDarkMode, + borderColor: borderColor, + background: headerBackground, + columnWidths: columnWidths + ) + Rectangle() + .fill(borderColor) + .frame(height: 1) + } + + ForEach(table.rows.indices, id: \.self) { index in + MarkdownTableRowView( + cells: table.rows[index], + alignments: table.alignments, + isHeader: false, + isDarkMode: isDarkMode, + borderColor: borderColor, + background: index.isMultiple(of: 2) ? alternatingRowBackground : SwiftUI.Color.clear, + columnWidths: columnWidths + ) + + if index < table.rows.count - 1 { + Rectangle() + .fill(borderColor) + .frame(height: 1) + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(borderColor, lineWidth: 1) + ) + } +} + +private struct MarkdownTableRowView: View { + let cells: [String] + let alignments: [TableAlignment] + let isHeader: Bool + let isDarkMode: Bool + let borderColor: SwiftUI.Color + let background: SwiftUI.Color + let columnWidths: [Int: CGFloat] + + var body: some View { + HStack(alignment: .top, spacing: 0) { + ForEach(cells.indices, id: \.self) { index in + MarkdownTableCell( + content: cells[index], + alignment: alignments.indices.contains(index) ? alignments[index] : .leading, + isHeader: isHeader, + isDarkMode: isDarkMode, + columnWidth: columnWidths[index], + background: background + ) + .frame(maxHeight: .infinity, alignment: .top) + + if index < cells.count - 1 { + Rectangle() + .fill(borderColor) + .frame(width: 1) + } + } + } + .frame(minHeight: 0, maxHeight: .infinity) + } +} + +private struct MarkdownTableCell: View { + let content: String + let alignment: TableAlignment + let isHeader: Bool + let isDarkMode: Bool + let columnWidth: CGFloat? + let background: SwiftUI.Color + + var body: some View { + let cellContent = LaTeXMarkdownView( + content: content.isEmpty ? " " : content, + isDarkMode: isDarkMode, + horizontalPadding: 0, + maxWidthAlignment: alignment.viewAlignment + ) + .padding(.vertical, isHeader ? 6 : 5) + .padding(.horizontal, Constants.UI.tableCellHorizontalPadding) + + if let width = columnWidth { + cellContent + .frame(width: width, alignment: alignment.viewAlignment) + .frame(maxHeight: .infinity) + .background(background) + } else { + cellContent + .frame(maxHeight: .infinity) + .background(background) + } + } +} + +/// A view that renders LaTeX equations using SwiftMath +struct LaTeXView: View { + let latex: String + let isDisplay: Bool + let isDarkMode: Bool + + private var isUnsupportedEnvironment: Bool { + latex.contains("\\begin{tabular}") || + latex.contains("\\begin{longtable}") + } + + var body: some View { + if isUnsupportedEnvironment { + UnsupportedLaTeXView(isDarkMode: isDarkMode) + } else if isDisplay { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + Spacer(minLength: 0) + MathView( + latex: latex, + displayMode: true, + isDarkMode: isDarkMode + ) + .fixedSize() + Spacer(minLength: 0) + } + .padding(.horizontal, 4) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } else { + MathView( + latex: latex, + displayMode: false, + isDarkMode: isDarkMode + ) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 2) + } + } +} + +private struct UnsupportedLaTeXView: View { + let isDarkMode: Bool + + var body: some View { + VStack(spacing: 8) { + Text("LaTeX table not supported on iOS") + .font(.subheadline) + .foregroundColor(isDarkMode ? .white.opacity(0.7) : .black.opacity(0.6)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isDarkMode ? Color.white.opacity(0.05) : Color.black.opacity(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isDarkMode ? Color.white.opacity(0.1) : Color.black.opacity(0.1), lineWidth: 1) + ) + .padding(.vertical, 8) + } +} + +/// Bridge to SwiftMath's MTMathUILabel +struct MathView: UIViewRepresentable { + let latex: String + let displayMode: Bool + let isDarkMode: Bool + + func makeUIView(context: Context) -> MTMathUILabel { + let label = MTMathUILabel() + label.latex = latex + label.labelMode = displayMode ? .display : .text + label.textColor = isDarkMode ? .white : .black.withAlphaComponent(0.8) + label.fontSize = displayMode ? 18 : 16 + label.textAlignment = displayMode ? .center : .left + label.backgroundColor = .clear + label.isUserInteractionEnabled = true + label.contentInsets = UIEdgeInsets(top: 6, left: 2, bottom: 6, right: 2) + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.setContentHuggingPriority(.required, for: .vertical) + label.sizeToFit() + return label + } + + func updateUIView(_ uiView: MTMathUILabel, context: Context) { + uiView.latex = latex + uiView.labelMode = displayMode ? .display : .text + uiView.textColor = isDarkMode ? .white : .black.withAlphaComponent(0.8) + uiView.fontSize = displayMode ? 18 : 16 + uiView.textAlignment = displayMode ? .center : .left + uiView.contentInsets = UIEdgeInsets(top: 6, left: 2, bottom: 6, right: 2) + uiView.setContentCompressionResistancePriority(.required, for: .vertical) + uiView.setContentHuggingPriority(.required, for: .vertical) + uiView.sizeToFit() + } +} + + diff --git a/SwiftChat/Views/MessageAttachmentIndicator.swift b/SwiftChat/Views/MessageAttachmentIndicator.swift new file mode 100644 index 0000000..addc96a --- /dev/null +++ b/SwiftChat/Views/MessageAttachmentIndicator.swift @@ -0,0 +1,294 @@ +// +// MessageAttachmentIndicator.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI + +struct MessageAttachmentIndicator: View { + let attachments: [Attachment] + let isDarkMode: Bool + @EnvironmentObject private var viewModel: SwiftChat.ChatViewModel + + private var imageAttachments: [Attachment] { + attachments.filter { $0.type == .image } + } + + private var documentAttachments: [Attachment] { + attachments.filter { $0.type == .document } + } + + var body: some View { + VStack(alignment: .trailing, spacing: 4) { + if !imageAttachments.isEmpty { + imageGrid + } + + ForEach(documentAttachments) { attachment in + AttachmentChip(attachment: attachment, isDarkMode: isDarkMode) + } + } + .padding(.bottom, 4) + } + + @ViewBuilder + private var imageGrid: some View { + let size = Constants.Attachments.messageThumbnailSize + let columns = Constants.Attachments.messageThumbnailColumns + let rows = imageAttachments.chunked(into: columns) + VStack(alignment: .trailing, spacing: 4) { + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + HStack(spacing: 4) { + ForEach(row) { attachment in + ImageThumbnail(attachment: attachment, size: size) + .onTapGesture { + let allImages = (viewModel.currentChat?.messages ?? []) + .flatMap { $0.attachments } + .filter { $0.type == .image && $0.base64 != nil } + if let index = allImages.firstIndex(where: { $0.id == attachment.id }) { + viewModel.imageViewerImages = allImages + viewModel.imageViewerIndex = index + viewModel.showImageViewer = true + } + } + } + } + } + } + } +} + +private struct AttachmentChip: View { + let attachment: Attachment + let isDarkMode: Bool + + var body: some View { + HStack(spacing: 6) { + Image(systemName: iconName) + .font(.system(size: 14)) + .foregroundColor(isDarkMode ? .white.opacity(0.7) : .black.opacity(0.6)) + + Text(attachment.fileName) + .font(.system(size: 12)) + .lineLimit(1) + .foregroundColor(isDarkMode ? .white.opacity(0.8) : .black.opacity(0.7)) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isDarkMode ? Color.white.opacity(0.08) : Color.black.opacity(0.06)) + ) + } + + private var iconName: String { + let ext = (attachment.fileName as NSString).pathExtension.lowercased() + switch ext { + case "pdf": return "doc.richtext" + case "html": return "globe" + case "csv": return "tablecells" + case "md": return "text.document" + default: return "doc.text" + } + } +} + +private struct ImageThumbnail: View { + let attachment: Attachment + let size: CGFloat + + var body: some View { + if let base64 = attachment.base64 ?? attachment.thumbnailBase64, + let data = Data(base64Encoded: base64), + let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray.opacity(0.3)) + .frame(width: size, height: size) + .overlay { + Image(systemName: "photo") + .foregroundColor(.gray) + } + } + } +} + +struct ImageViewerOverlay: View { + let images: [Attachment] + let initialIndex: Int + let onDismiss: () -> Void + @State private var currentIndex: Int + @State private var dragOffset: CGFloat = 0 + @State private var isDraggingVertically = false + + init(images: [Attachment], initialIndex: Int, onDismiss: @escaping () -> Void) { + self.images = images + self.initialIndex = initialIndex + self.onDismiss = onDismiss + _currentIndex = State(initialValue: initialIndex) + } + + private var backgroundOpacity: Double { + 1.0 - min(abs(dragOffset) / 300.0, 0.5) + } + + var body: some View { + ZStack { + Color.black.opacity(backgroundOpacity).ignoresSafeArea() + + TabView(selection: $currentIndex) { + ForEach(Array(images.enumerated()), id: \.element.id) { index, attachment in + ZoomableImagePage(attachment: attachment) + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: images.count > 1 ? .automatic : .never)) + .ignoresSafeArea() + .offset(y: dragOffset) + .simultaneousGesture( + DragGesture(minimumDistance: 20) + .onChanged { value in + if !isDraggingVertically { + isDraggingVertically = abs(value.translation.height) > abs(value.translation.width) + } + if isDraggingVertically { + dragOffset = value.translation.height + } + } + .onEnded { value in + if isDraggingVertically && abs(value.translation.height) > 100 { + onDismiss() + } else { + withAnimation(.interactiveSpring()) { + dragOffset = 0 + } + } + isDraggingVertically = false + } + ) + + // Close button + counter + VStack { + HStack { + if images.count > 1 { + Text("\(currentIndex + 1) / \(images.count)") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white.opacity(0.8)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: Capsule()) + } + + Spacer() + + Button { + onDismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 30)) + .foregroundStyle(.white.opacity(0.8)) + } + } + .padding() + + Spacer() + + // Left/right arrow buttons + if images.count > 1 { + HStack { + Button { + withAnimation { + currentIndex = max(0, currentIndex - 1) + } + } label: { + Image(systemName: "chevron.left.circle.fill") + .font(.system(size: 36)) + .foregroundStyle(.white.opacity(currentIndex > 0 ? 0.7 : 0.2)) + } + .disabled(currentIndex == 0) + + Spacer() + + Button { + withAnimation { + currentIndex = min(images.count - 1, currentIndex + 1) + } + } label: { + Image(systemName: "chevron.right.circle.fill") + .font(.system(size: 36)) + .foregroundStyle(.white.opacity(currentIndex < images.count - 1 ? 0.7 : 0.2)) + } + .disabled(currentIndex == images.count - 1) + } + .padding(.horizontal, 20) + .padding(.bottom, 40) + } + } + } + .persistentSystemOverlays(.hidden) + } +} + +private extension Array { + func chunked(into size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { + Array(self[$0.. { + Binding( + get: { viewModel.attachmentError != nil }, + set: { if !$0 { viewModel.attachmentError = nil } } + ) + } + + @ViewBuilder + var body: some View { + inputContent + .alert("Attachment Error", isPresented: showAttachmentError) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.attachmentError ?? "An error occurred") + } + .sheet(isPresented: $showDocumentPicker) { + DocumentPickerView { url, fileName in + viewModel.addDocumentAttachment(url: url, fileName: fileName) + } + } + .sheet(isPresented: $showPhotoPicker, onDismiss: processSelectedPhotos) { + NavigationStack { + PhotosPicker(selection: $selectedPhotoItems, matching: .images) { + Text("Select Photos") + } + .photosPickerStyle(.inline) + .navigationTitle("Select Photos") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { showPhotoPicker = false } + } + } + } + .presentationDetents([.medium, .large]) + } + .fullScreenCover(isPresented: $showCamera) { + CameraPickerView { image in + if let data = image.jpegData(compressionQuality: CGFloat(Constants.Attachments.imageCompressionQuality)) { + viewModel.addImageAttachment(data: data, fileName: "Camera Photo.jpg") + } + } + .ignoresSafeArea() + } + .sheet(isPresented: $showAddSheet, onDismiss: { + guard let action = pendingPickerAction else { return } + pendingPickerAction = nil + switch action { + case .camera: showCamera = true + case .photos: showPhotoPicker = true + case .files: showDocumentPicker = true + } + }) { + AddToSheetView( + viewModel: viewModel, + isDarkMode: isDarkMode, + onCamera: { + pendingPickerAction = .camera + showAddSheet = false + }, + onPhotos: { + pendingPickerAction = .photos + showAddSheet = false + }, + onFiles: { + pendingPickerAction = .files + showAddSheet = false + } + ) + .presentationDetents([.height(340)]) + .presentationBackground(isDarkMode ? Color(hex: "161616") : Color(UIColor.systemGroupedBackground)) + } + } + + @ViewBuilder + private var inputContent: some View { + if #available(iOS 26, *) { + VStack(spacing: 4) { + VStack(spacing: 0) { + if !viewModel.pendingAttachments.isEmpty { + AttachmentPreviewBar( + attachments: viewModel.pendingAttachments, + thumbnails: viewModel.pendingImageThumbnails, + onRemove: { id in viewModel.removePendingAttachment(id: id) } + ) + .padding(.horizontal, 12) + .padding(.top, 8) + } + + CustomTextEditor(text: $messageText, + textHeight: $textHeight, + placeholderText: viewModel.currentChat?.messages.isEmpty ?? true ? "What's on your mind?" : "Message", + shouldFocusInput: viewModel.shouldFocusInput, + isLoading: viewModel.isLoading, + onFocusHandled: { viewModel.shouldFocusInput = false }, + onSendMessage: { text in viewModel.sendMessage(text: text) }) + .frame(height: textHeight) + .padding(.horizontal) + + HStack { + attachButton + webSearchButton + Spacer() + + micButton + + Button(action: sendOrCancelMessage) { + Image(systemName: viewModel.isLoading ? "stop.fill" : "arrow.up") + .font(.system(size: 16, weight: .semibold)) + .frame(width: 24, height: 24) + .foregroundColor(isDarkMode ? Color.sendButtonForegroundDark : Color.sendButtonForegroundLight) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.circle) + .glassEffect(.regular.interactive(), in: .circle) + .clipShape(.circle) + .tint(isDarkMode ? Color.sendButtonBackgroundDark : Color.sendButtonBackgroundLight) + .padding(.trailing, 8) + } + .padding(.vertical, 8) + } + .glassEffect(.regular.interactive(), in: RoundedRectangle(cornerRadius: 26)) + } + .padding(.horizontal, 12) + .padding(.bottom, isKeyboardVisible ? 12 : 0) + } else { + VStack(spacing: 4) { + VStack(spacing: 0) { + if !viewModel.pendingAttachments.isEmpty { + AttachmentPreviewBar( + attachments: viewModel.pendingAttachments, + thumbnails: viewModel.pendingImageThumbnails, + onRemove: { id in viewModel.removePendingAttachment(id: id) } + ) + .padding(.horizontal, 12) + .padding(.top, 8) + } + + CustomTextEditor(text: $messageText, + textHeight: $textHeight, + placeholderText: viewModel.currentChat?.messages.isEmpty ?? true ? "What's on your mind?" : "Message", + shouldFocusInput: viewModel.shouldFocusInput, + isLoading: viewModel.isLoading, + onFocusHandled: { viewModel.shouldFocusInput = false }, + onSendMessage: { text in viewModel.sendMessage(text: text) }) + .frame(height: textHeight) + .padding(.horizontal) + + HStack { + attachButton + webSearchButton + Spacer() + + micButton + + Button(action: sendOrCancelMessage) { + ZStack { + Circle() + .fill(isDarkMode ? Color.sendButtonBackgroundDark : Color.sendButtonBackgroundLight) + .frame(width: 32, height: 32) + Image(systemName: viewModel.isLoading ? "stop.fill" : "arrow.up") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(isDarkMode ? Color.sendButtonForegroundDark : Color.sendButtonForegroundLight) + } + } + .padding(.trailing, 8) + } + .padding(.vertical, 8) + } + .background { + RoundedRectangle(cornerRadius: 26) + .fill(.thickMaterial) + } + } + .padding(.horizontal, 12) + .padding(.bottom, isKeyboardVisible ? 12 : 0) + } + } + + @ViewBuilder + private var attachButton: some View { + Button { + showAddSheet = true + } label: { + Image(systemName: "plus") + .font(.system(size: 20)) + .foregroundColor(.secondary) + .frame(width: 24, height: 24) + } + .disabled(viewModel.isLoading || viewModel.isProcessingAttachment) + .padding(.leading, 8) + } + + @ViewBuilder + private var webSearchButton: some View { + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.isWebSearchEnabled.toggle() + settings.webSearchEnabled = viewModel.isWebSearchEnabled + } + }) { + if viewModel.isWebSearchEnabled { + HStack(spacing: 6) { + Image(systemName: "globe") + .font(.system(size: 14, weight: .semibold)) + Text("Web Search") + .font(.system(size: 12, weight: .semibold)) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.15)) + .clipShape(Capsule()) + .foregroundColor(.blue) + } else { + Image(systemName: "globe") + .font(.system(size: 20)) + .foregroundColor(.secondary) + .frame(width: 24, height: 24) + } + } + .padding(.leading, 8) + } + + @State private var isPulsing = false + + @ViewBuilder + private var micButton: some View { + Button(action: toggleRecording) { + ZStack { + if audioService.isRecording { + Circle() + .fill(Color.red.opacity(0.2)) + .frame(width: 44, height: 44) + .scaleEffect(isPulsing ? 1.1 : 0.9) + .animation( + .easeInOut(duration: 0.8).repeatForever(autoreverses: true), + value: isPulsing + ) + } + + Group { + if audioService.isTranscribing { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .secondary)) + .scaleEffect(0.8) + } else { + Image(systemName: audioService.isRecording ? "stop.fill" : "mic.fill") + .font(.system(size: 20)) + } + } + .frame(width: 32, height: 32) + .foregroundColor(audioService.isRecording ? .red : .secondary) + } + .frame(width: 32, height: 32) + } + .onChange(of: audioService.isRecording) { _, isRecording in + isPulsing = isRecording + } + .disabled(audioService.isTranscribing || viewModel.isLoading) + .padding(.trailing, 4) + } + + private func toggleRecording() { + if audioService.isRecording { + guard let fileURL = audioService.stopRecording() else { return } + Task { + do { + let client = AppConfig.shared.makeClient() + let text = try await audioService.transcribe(fileURL: fileURL, client: client) + messageText += (messageText.isEmpty ? "" : " ") + text + } catch { + viewModel.attachmentError = error.localizedDescription + } + } + } else { + Task { + let granted = await audioService.requestPermission() + guard granted else { + viewModel.attachmentError = "Microphone access is required for voice input. Enable it in Settings." + return + } + do { + try audioService.startRecording() + } catch { + viewModel.attachmentError = "Failed to start recording: \(error.localizedDescription)" + } + } + } + } + + private func sendOrCancelMessage() { + if viewModel.isLoading { + viewModel.cancelGeneration() + } else if !messageText.isEmpty || !viewModel.pendingAttachments.isEmpty { + viewModel.sendMessage(text: messageText) + messageText = "" + textHeight = Layout.defaultHeight + } + } + + private func processSelectedPhotos() { + let items = selectedPhotoItems + selectedPhotoItems = [] + for (index, item) in items.enumerated() { + Task { + if let data = try? await item.loadTransferable(type: Data.self) { + let fileName = items.count > 1 ? "Photo \(index + 1).jpg" : "Photo.jpg" + viewModel.addImageAttachment(data: data, fileName: fileName) + } + } + } + } +} + +/// Bottom sheet presented from the "+" button with attachment options and model selector +struct AddToSheetView: View { + @ObservedObject var viewModel: SwiftChat.ChatViewModel + let isDarkMode: Bool + let onCamera: () -> Void + let onPhotos: () -> Void + let onFiles: () -> Void + @Environment(\.dismiss) private var dismiss + private var availableModels: [ModelType] { + AppConfig.shared.filteredModelTypes() + } + + var body: some View { + NavigationStack { + VStack(spacing: 20) { + // Attachment buttons + HStack(spacing: 12) { + if viewModel.currentModel.isMultimodal { + if UIImagePickerController.isSourceTypeAvailable(.camera) { + attachmentButton(icon: "camera", label: "Camera") { onCamera() } + } + attachmentButton(icon: "photo.on.rectangle", label: "Photos") { onPhotos() } + } + attachmentButton(icon: "doc.badge.arrow.up", label: "Files") { onFiles() } + } + .padding(.horizontal, 20) + + Divider() + .padding(.horizontal, 20) + + // Model selector + Text("Select a Model") + .font(.system(size: 17, weight: .semibold)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 20) + .padding(.bottom, -12) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(availableModels) { model in + ModelCard( + model: model, + isSelected: viewModel.currentModel.id == model.id, + isDarkMode: isDarkMode + ) { + viewModel.changeModel(to: model) + } + } + } + .padding(.horizontal, 20) + } + } + .padding(.top, 8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background((isDarkMode ? Color(hex: "161616") : Color(UIColor.systemGroupedBackground)).ignoresSafeArea()) + .navigationTitle("Add to Chat") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 18, weight: .medium)) + } + } + } + } + } + + @ViewBuilder + private func attachmentButton(icon: String, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 22)) + Text(label) + .font(.system(size: 12, weight: .medium)) + } + .foregroundColor(.primary) + .frame(maxWidth: .infinity) + .frame(height: 72) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.secondarySystemGroupedBackground)) + ) + } + } +} + +/// Simple model card for the model selector +struct ModelCard: View { + let model: ModelType + let isSelected: Bool + let isDarkMode: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + VStack(spacing: 8) { + Image(model.iconName) + .resizable() + .scaledToFit() + .frame(width: 36, height: 36) + + Text(model.displayName) + .font(.system(size: 13, weight: .medium)) + .multilineTextAlignment(.center) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundColor(isSelected ? .primary : .secondary) + .frame(width: 120, height: 110) + .background( + ZStack { + if #available(iOS 26, *) { + RoundedRectangle(cornerRadius: 16) + .fill(.thickMaterial) + } else { + RoundedRectangle(cornerRadius: 16) + .fill(Color.chatSurface(isDarkMode: isDarkMode)) + } + if isSelected { + RoundedRectangle(cornerRadius: 16) + .fill(Color.accentPrimary.opacity(0.15)) + } + if !isSelected { + RoundedRectangle(cornerRadius: 16) + .strokeBorder(Color.gray.opacity(0.2), lineWidth: 1) + } + if isSelected { + VStack { + HStack { + Spacer() + Circle() + .fill(Color.accentPrimary) + .frame(width: 16, height: 16) + .overlay( + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(.white) + ) + } + Spacer() + } + .padding(8) + } + } + ) + } + .buttonStyle(PlainButtonStyle()) + .scaleEffect(isSelected ? 1.02 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected) + } +} + +/// Custom UIViewRepresentable for a properly managed text editor +struct CustomTextEditor: UIViewRepresentable { + @Binding var text: String + @Binding var textHeight: CGFloat + var placeholderText: String + var shouldFocusInput: Bool + var isLoading: Bool + var onFocusHandled: () -> Void + var onSendMessage: (String) -> Void + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.delegate = context.coordinator + textView.font = UIFont.preferredFont(forTextStyle: .body) + textView.backgroundColor = .clear + textView.isScrollEnabled = true + textView.isEditable = true + textView.isSelectable = true + textView.alwaysBounceVertical = false + textView.scrollsToTop = false + textView.textContainerInset = UIEdgeInsets(top: 16, left: 2, bottom: 8, right: 5) + textView.textContainer.lineFragmentPadding = 0 + textView.tintColor = UIColor.systemBlue + + if text.isEmpty { + textView.text = placeholderText + textView.textColor = .lightGray + } else { + textView.text = text + textView.textColor = UIColor { traitCollection in + return traitCollection.userInterfaceStyle == .dark ? .white : .black + } + } + + return textView + } + + func updateUIView(_ uiView: UITextView, context: Context) { + context.coordinator.parent = self + let isCurrentlyEditing = context.coordinator.isEditing + + if shouldFocusInput && !context.coordinator.hasFocusedFromFlag { + context.coordinator.hasFocusedFromFlag = true + DispatchQueue.main.async { + if !uiView.isFirstResponder { uiView.becomeFirstResponder() } + self.onFocusHandled() + } + } else if !shouldFocusInput { + context.coordinator.hasFocusedFromFlag = false + } + + if text.isEmpty && !isCurrentlyEditing && uiView.textColor != .lightGray { + uiView.text = placeholderText + uiView.textColor = .lightGray + } else if text.isEmpty && isCurrentlyEditing { + if uiView.text.isEmpty && uiView.textColor == .lightGray { + uiView.text = "" + uiView.textColor = UIColor { tc in tc.userInterfaceStyle == .dark ? .white : .black } + } else if !uiView.text.isEmpty && uiView.textColor != .lightGray { + self.text = uiView.text + } + } else if !text.isEmpty && uiView.textColor == .lightGray { + uiView.text = text + uiView.textColor = UIColor { tc in tc.userInterfaceStyle == .dark ? .white : .black } + } else if !text.isEmpty && uiView.text != text && uiView.textColor != .lightGray { + uiView.text = text + } + + uiView.isEditable = true + + let size = uiView.sizeThatFits(CGSize(width: uiView.frame.width, height: CGFloat.greatestFiniteMagnitude)) + let newHeight = min(MessageInputView.Layout.maximumHeight, max(MessageInputView.Layout.minimumHeight, size.height)) + if textHeight != newHeight { + DispatchQueue.main.async { self.textHeight = newHeight } + } + } + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + class Coordinator: NSObject, UITextViewDelegate { + var parent: CustomTextEditor + var isEditing = false + var hasFocusedFromFlag = false + + init(_ parent: CustomTextEditor) { self.parent = parent } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + if text == "\n" { + let isMac = ProcessInfo.processInfo.isiOSAppOnMac + if isMac { + let currentText = textView.text ?? "" + let trimmedText = currentText.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedText.isEmpty && !parent.isLoading { + parent.onSendMessage(trimmedText) + textView.text = "" + parent.text = "" + parent.textHeight = MessageInputView.Layout.defaultHeight + textView.text = parent.placeholderText + textView.textColor = .lightGray + textView.resignFirstResponder() + } + return false + } + } + + let currentText = textView.text as NSString + let newText = currentText.replacingCharacters(in: range, with: text) + if newText.isEmpty && isEditing { return true } + return true + } + + func textViewDidChange(_ textView: UITextView) { + if textView.textColor != .lightGray { + parent.text = textView.text + let size = textView.sizeThatFits(CGSize(width: textView.frame.width, height: CGFloat.greatestFiniteMagnitude)) + let newHeight = min(MessageInputView.Layout.maximumHeight, max(MessageInputView.Layout.minimumHeight, size.height)) + if parent.textHeight != newHeight { parent.textHeight = newHeight } + } + } + + func textViewDidBeginEditing(_ textView: UITextView) { + isEditing = true + if textView.textColor == .lightGray { + textView.text = "" + textView.textColor = UIColor { tc in tc.userInterfaceStyle == .dark ? .white : .black } + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + isEditing = false + if textView.text.isEmpty { + textView.text = parent.placeholderText + textView.textColor = .lightGray + } + } + } +} diff --git a/SwiftChat/Views/MessageTableView.swift b/SwiftChat/Views/MessageTableView.swift new file mode 100644 index 0000000..20463db --- /dev/null +++ b/SwiftChat/Views/MessageTableView.swift @@ -0,0 +1,713 @@ +// +// MessageTableView.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI +import Combine + +struct MessageTableView: UIViewRepresentable { + let archivedMessagesStartIndex: Int + let isDarkMode: Bool + let isLoading: Bool + @ObservedObject var viewModel: SwiftChat.ChatViewModel + @Binding var isAtBottom: Bool + @Binding var userHasScrolled: Bool + let scrollTrigger: UUID + let scrollToUserTrigger: UUID + @Binding var tableOpacity: Double + let keyboardHeight: CGFloat + + private var messages: [Message] { + viewModel.messages + } + + func makeUIView(context: Context) -> UITableView { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.delegate = context.coordinator + tableView.dataSource = context.coordinator + tableView.keyboardDismissMode = .onDrag + tableView.allowsSelection = false + tableView.estimatedRowHeight = 100 + tableView.rowHeight = UITableView.automaticDimension + tableView.showsVerticalScrollIndicator = true + tableView.contentInsetAdjustmentBehavior = .automatic + tableView.clipsToBounds = false + + if #available(iOS 15.0, *) { + tableView.isPrefetchingEnabled = true + } + + context.coordinator.tableView = tableView + + return tableView + } + + func updateUIView(_ tableView: UITableView, context: Context) { + context.coordinator.parent = self + + let keyboardHeightChanged = context.coordinator.lastKeyboardHeight != keyboardHeight + if keyboardHeightChanged { + let wasAtBottom = context.coordinator.parent.isAtBottom + context.coordinator.lastKeyboardHeight = keyboardHeight + context.coordinator.isKeyboardTransitioning = true + + if wasAtBottom && keyboardHeight > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + context.coordinator.scrollToBottom(animated: true) + context.coordinator.isKeyboardTransitioning = false + } + } else { + DispatchQueue.main.async { + context.coordinator.isKeyboardTransitioning = false + } + } + } + + let currentChatId = viewModel.currentChat?.id + let previousChatId = context.coordinator.lastChatId + let chatIdChanged = previousChatId != currentChatId + + // Detect ID conversion (temp → permanent) by checking if message IDs match + // This is more reliable than checking wrappers since wrappers only exist for rendered cells + let currentMessageIds = Set(viewModel.messages.map { $0.id }) + let isIdConversion = chatIdChanged && !currentMessageIds.isEmpty && + currentMessageIds == context.coordinator.lastMessageIds + + if chatIdChanged { + context.coordinator.lastChatId = currentChatId + + if !isIdConversion { + context.coordinator.messageWrappers.removeAll() + context.coordinator.shownMessageIds.removeAll() + } + context.coordinator.lastMessageIds = currentMessageIds + } else if currentMessageIds != context.coordinator.lastMessageIds { + // Message IDs changed without chat ID changing (e.g., regenerate) + let idsWereReplaced = !currentMessageIds.isEmpty && + !context.coordinator.lastMessageIds.isEmpty && + currentMessageIds.isDisjoint(with: context.coordinator.lastMessageIds) + + if idsWereReplaced { + // All message IDs are different - clear stale wrappers + context.coordinator.messageWrappers.removeAll() + context.coordinator.shownMessageIds.removeAll() + context.coordinator.heightCache.removeAll() + } + context.coordinator.lastMessageIds = currentMessageIds + tableView.reloadData() + } + + let isDarkModeChanged = context.coordinator.lastIsDarkMode != isDarkMode + let messageCountChanged = context.coordinator.lastMessageCount != messages.count + + if isDarkModeChanged { + context.coordinator.lastIsDarkMode = isDarkMode + DispatchQueue.main.async { + for wrapper in context.coordinator.messageWrappers.values { + wrapper.isDarkMode = isDarkMode + } + } + } + + if messageCountChanged || (chatIdChanged && !isIdConversion) { + context.coordinator.lastMessageCount = messages.count + context.coordinator.heightCache.removeAll() + tableView.reloadData() + } else if isLoading && !messages.isEmpty { + // During streaming, update the last message wrapper directly + if let lastMessage = messages.last, + let wrapper = context.coordinator.messageWrappers[lastMessage.id] { + + let isArchived = messages.count - 1 < archivedMessagesStartIndex + let showArchiveSeparator = messages.count - 1 == archivedMessagesStartIndex && archivedMessagesStartIndex > 0 + + // Update multiplier synchronously to prevent race condition: + // updateUIView is called on every streaming token, and if we defer + // the update to DispatchQueue.main.async, many calls will read the + // stale multiplier and each queue a +10 increment, causing it to + // explode to thousands. + let screenHeight = UIScreen.main.bounds.height + let needsBufferExtension = wrapper.extendBufferIfNeeded(screenHeight: screenHeight) + + let coordinator = context.coordinator + DispatchQueue.main.async { + guard let currentMessage = coordinator.parent.messages.last else { return } + + wrapper.update( + message: currentMessage, + isDarkMode: coordinator.parent.isDarkMode, + isLastMessage: true, + isLoading: coordinator.parent.isLoading, + isArchived: isArchived, + showArchiveSeparator: showArchiveSeparator, + messageIndex: coordinator.parent.messages.count - 1 + ) + + if needsBufferExtension && !coordinator.isKeyboardTransitioning { + tableView.beginUpdates() + tableView.endUpdates() + } + } + } + } + + let isLoadingChanged = context.coordinator.lastIsLoading != isLoading + context.coordinator.lastIsLoading = isLoading + + if isLoadingChanged && !isLoading { + // Streaming just ended - update the last message wrapper to reflect final state (including any errors) + if let lastMessage = messages.last, + let wrapper = context.coordinator.messageWrappers[lastMessage.id] { + let isArchived = messages.count - 1 < archivedMessagesStartIndex + let showArchiveSeparator = messages.count - 1 == archivedMessagesStartIndex && archivedMessagesStartIndex > 0 + wrapper.update( + message: lastMessage, + isDarkMode: isDarkMode, + isLastMessage: true, + isLoading: false, + isArchived: isArchived, + showArchiveSeparator: showArchiveSeparator, + messageIndex: messages.count - 1 + ) + + DispatchQueue.main.async { + wrapper.resetBuffer() + } + } + + DispatchQueue.main.async { + UIView.performWithoutAnimation { + context.coordinator.isUserMessageScrollMode = false + let currentOffset = tableView.contentOffset.y + context.coordinator.updateContentInset() + tableView.layoutIfNeeded() + tableView.contentOffset.y = currentOffset + } + } + } + + if context.coordinator.lastScrollToUserTrigger != scrollToUserTrigger { + context.coordinator.lastScrollToUserTrigger = scrollToUserTrigger + context.coordinator.shouldScrollToUserMessageAfterLayout = true + context.coordinator.shouldScrollToBottomAfterLayout = false + context.coordinator.isUserMessageScrollMode = true + + DispatchQueue.main.async { + context.coordinator.scrollToUserMessage(animated: false) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + if context.coordinator.shouldScrollToUserMessageAfterLayout { + context.coordinator.scrollToUserMessage(animated: false) + context.coordinator.shouldScrollToUserMessageAfterLayout = false + + withAnimation(.easeIn(duration: 0.2)) { + self.tableOpacity = 1.0 + } + } + } + } + } + + if context.coordinator.lastScrollTrigger != scrollTrigger { + context.coordinator.lastScrollTrigger = scrollTrigger + context.coordinator.shouldScrollToBottomAfterLayout = true + context.coordinator.shouldScrollToUserMessageAfterLayout = false + context.coordinator.isUserMessageScrollMode = false + + DispatchQueue.main.async { + context.coordinator.scrollToBottom(animated: false) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + if context.coordinator.shouldScrollToBottomAfterLayout { + context.coordinator.scrollToBottom(animated: false) + context.coordinator.shouldScrollToBottomAfterLayout = false + + withAnimation(.easeIn(duration: 0.2)) { + self.tableOpacity = 1.0 + } + } + } + } + } + + DispatchQueue.main.async { + context.coordinator.checkIfAtBottom() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UITableViewDelegate, UITableViewDataSource { + var parent: MessageTableView + weak var tableView: UITableView? + var lastScrollTrigger: UUID? + var lastScrollToUserTrigger: UUID? + var lastMessageCount: Int = 0 + var lastIsLoading: Bool = false + var cellReuseIdentifierSuffix: String = "" + var lastKeyboardHeight: CGFloat = 0 + var lastIsDarkMode: Bool = false + var lastChatId: String? = nil + var lastMessageIds: Set = [] + private var isDragging = false + private var isUpdatingContentInset = false + var messageWrappers: [String: ObservableMessageWrapper] = [:] + var shouldScrollToBottomAfterLayout = false + var shouldScrollToUserMessageAfterLayout = false + /// Stays true while streaming after user sent a message, adjusting the + /// bottom inset so the user message can be scrolled to the top of the screen. + var isUserMessageScrollMode = false + var heightCache: [IndexPath: CGFloat] = [:] + var messageHeightCache: [String: CGFloat] = [:] + var shownMessageIds: Set = [] + var isKeyboardTransitioning = false + + init(_ parent: MessageTableView) { + self.parent = parent + } + + func getOrCreateWrapper(for message: Message, isDarkMode: Bool, isLastMessage: Bool, isLoading: Bool, isArchived: Bool, showArchiveSeparator: Bool, messageIndex: Int) -> ObservableMessageWrapper { + if let existing = messageWrappers[message.id] { + existing.update(message: message, isDarkMode: isDarkMode, isLastMessage: isLastMessage, isLoading: isLoading, isArchived: isArchived, showArchiveSeparator: showArchiveSeparator, messageIndex: messageIndex) + // Never re-animate existing messages + existing.shouldAnimateAppearance = false + return existing + } else { + let isFirstTimeShown = !shownMessageIds.contains(message.id) + shownMessageIds.insert(message.id) + let wrapper = ObservableMessageWrapper(message: message, isDarkMode: isDarkMode, isLastMessage: isLastMessage, isLoading: isLoading, isArchived: isArchived, showArchiveSeparator: showArchiveSeparator, shouldAnimateAppearance: isFirstTimeShown, messageIndex: messageIndex) + messageWrappers[message.id] = wrapper + return wrapper + } + } + + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + if parent.messages.isEmpty { + return 1 + } + return parent.messages.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cellIdentifier = (parent.messages.isEmpty ? "WelcomeCell" : "MessageCell") + cellReuseIdentifierSuffix + + let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .default, reuseIdentifier: cellIdentifier) + cell.selectionStyle = .none + cell.backgroundColor = .clear + + if parent.messages.isEmpty { + cell.contentConfiguration = UIHostingConfiguration { + WelcomeView(isDarkMode: parent.isDarkMode) + .padding(.vertical, 16) + .padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 100 : 0) + .frame(maxWidth: 900) + .frame(maxWidth: .infinity) + } + .minSize(width: 0, height: 0) + .margins(.all, 0) + .background(.clear) + } else { + let message = parent.messages[indexPath.row] + let isLastMessage = indexPath.row == parent.messages.count - 1 + let isArchived = indexPath.row < parent.archivedMessagesStartIndex + let showArchiveSeparator = indexPath.row == parent.archivedMessagesStartIndex && parent.archivedMessagesStartIndex > 0 + + let wrapper = getOrCreateWrapper( + for: message, + isDarkMode: parent.isDarkMode, + isLastMessage: isLastMessage, + isLoading: parent.isLoading && isLastMessage, + isArchived: isArchived, + showArchiveSeparator: showArchiveSeparator, + messageIndex: indexPath.row + ) + + // Always recreate the content configuration to ensure correct wrapper is used + cell.contentConfiguration = UIHostingConfiguration { + ObservableMessageCell(wrapper: wrapper, viewModel: parent.viewModel, coordinator: self) + } + .minSize(width: 0, height: 0) + .margins(.all, 0) + .background(.clear) + } + + return cell + } + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + if let cachedHeight = heightCache[indexPath] { + return cachedHeight + } + // Fall back to message-ID-based cache (survives reloadData) + if indexPath.row < parent.messages.count { + let messageId = parent.messages[indexPath.row].id + if let cachedHeight = messageHeightCache[messageId] { + return cachedHeight + } + } + return 100 + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + let height = cell.frame.size.height + if height > 0 { + heightCache[indexPath] = height + if indexPath.row < parent.messages.count { + messageHeightCache[parent.messages[indexPath.row].id] = height + } + } + + if shouldScrollToBottomAfterLayout { + let numberOfRows = tableView.numberOfRows(inSection: 0) + if indexPath.row == numberOfRows - 1 { + DispatchQueue.main.async { + self.scrollToBottom(animated: false) + self.shouldScrollToBottomAfterLayout = false + + withAnimation(.easeIn(duration: 0.2)) { + self.parent.tableOpacity = 1.0 + } + } + } + } + + if shouldScrollToUserMessageAfterLayout { + let numberOfRows = tableView.numberOfRows(inSection: 0) + if indexPath.row == numberOfRows - 1 { + DispatchQueue.main.async { + self.scrollToUserMessage(animated: false) + self.shouldScrollToUserMessageAfterLayout = false + + withAnimation(.easeIn(duration: 0.2)) { + self.parent.tableOpacity = 1.0 + } + } + } + } + } + + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + updateContentInset() + checkIfAtBottom() + } + + func updateContentInset() { + guard !isUpdatingContentInset else { return } + guard let tableView = tableView else { return } + isUpdatingContentInset = true + defer { isUpdatingContentInset = false } + + let targetInset: CGFloat + + if parent.isLoading, let lastMessage = parent.messages.last, + let wrapper = messageWrappers[lastMessage.id], wrapper.actualContentHeight > 0 { + + let screenHeight = UIScreen.main.bounds.height + let bufferHeight = screenHeight * wrapper.bufferMultiplier + let unusedBuffer = bufferHeight - wrapper.actualContentHeight + let streamingInset = -max(0, unusedBuffer) + + if isUserMessageScrollMode { + let userMessageInset = insetForUserMessageAtTop(tableView) + targetInset = max(streamingInset, userMessageInset) + } else { + targetInset = streamingInset + } + } else if parent.isLoading && isUserMessageScrollMode { + targetInset = insetForUserMessageAtTop(tableView) + } else { + targetInset = 0 + } + + if tableView.contentInset.bottom != targetInset { + UIView.performWithoutAnimation { + tableView.contentInset.bottom = targetInset + tableView.verticalScrollIndicatorInsets.bottom = targetInset + } + } + } + + /// Returns the minimum bottom inset that allows the user message row + /// (second-to-last) to be scrolled to the top of the visible area. + private func insetForUserMessageAtTop(_ tableView: UITableView) -> CGFloat { + let numberOfRows = tableView.numberOfRows(inSection: 0) + guard numberOfRows >= 2 else { return 0 } + let userMessageIndexPath = IndexPath(row: numberOfRows - 2, section: 0) + let userMessageY = tableView.rectForRow(at: userMessageIndexPath).origin.y + return userMessageY + tableView.bounds.height - tableView.contentSize.height + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + isDragging = true + parent.userHasScrolled = true + parent.viewModel.isScrollInteractionActive = true + shouldScrollToBottomAfterLayout = false + shouldScrollToUserMessageAfterLayout = false + + UIView.animate(withDuration: 0.3) { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + isDragging = false + if !decelerate { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.parent.viewModel.isScrollInteractionActive = false + } + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.parent.viewModel.isScrollInteractionActive = false + } + } + + func scrollToBottom(animated: Bool) { + guard let tableView = tableView else { return } + guard !parent.messages.isEmpty else { return } + + updateContentInset() + + let numberOfRows = tableView.numberOfRows(inSection: 0) + guard numberOfRows > 0 else { return } + guard numberOfRows == parent.messages.count || parent.messages.isEmpty else { return } + + let lastIndexPath = IndexPath(row: numberOfRows - 1, section: 0) + tableView.scrollToRow(at: lastIndexPath, at: .bottom, animated: animated) + } + + /// Scrolls so the user's message sits at the top of the visible area, + /// with the assistant response streaming in below it. + func scrollToUserMessage(animated: Bool) { + guard let tableView = tableView else { return } + + let numberOfRows = tableView.numberOfRows(inSection: 0) + guard numberOfRows >= 2 else { + scrollToBottom(animated: animated) + return + } + guard numberOfRows == parent.messages.count else { return } + + updateContentInset() + + let userMessageIndexPath = IndexPath(row: numberOfRows - 2, section: 0) + tableView.scrollToRow(at: userMessageIndexPath, at: .top, animated: animated) + } + + func checkIfAtBottom() { + guard let tableView = tableView else { return } + guard tableView.window != nil else { return } + + let contentHeight = tableView.contentSize.height + let bottomInset = tableView.contentInset.bottom + let viewHeight = tableView.bounds.height + let currentOffset = tableView.contentOffset.y + + let maxOffset = contentHeight - viewHeight + bottomInset + let distanceFromBottom = maxOffset - currentOffset + + let slack: CGFloat = 150 + let isVisible = distanceFromBottom <= slack + + if parent.isAtBottom != isVisible { + DispatchQueue.main.async { + self.parent.isAtBottom = isVisible + self.parent.viewModel.isAtBottom = isVisible + if isVisible { + self.parent.userHasScrolled = false + } + } + } + } + } +} + +class ObservableMessageWrapper: ObservableObject { + @Published var message: Message + @Published var isDarkMode: Bool + @Published var isLastMessage: Bool + @Published var isLoading: Bool + @Published var isArchived: Bool + @Published var showArchiveSeparator: Bool + @Published var shouldAnimateAppearance: Bool = false + @Published var messageIndex: Int + var bufferMultiplier: CGFloat = Constants.StreamingBuffer.initialMultiplier + var actualContentHeight: CGFloat = 0 + var cachedHeight: CGFloat? + var cachedHeightKey: Int? + + init(message: Message, isDarkMode: Bool, isLastMessage: Bool, isLoading: Bool, isArchived: Bool, showArchiveSeparator: Bool, shouldAnimateAppearance: Bool = true, messageIndex: Int = 0) { + self.message = message + self.isDarkMode = isDarkMode + self.isLastMessage = isLastMessage + self.isLoading = isLoading + self.isArchived = isArchived + self.showArchiveSeparator = showArchiveSeparator + self.shouldAnimateAppearance = shouldAnimateAppearance + self.messageIndex = messageIndex + } + + func update(message: Message, isDarkMode: Bool, isLastMessage: Bool, isLoading: Bool, isArchived: Bool, showArchiveSeparator: Bool, messageIndex: Int) { + let contentChanged = self.message.content != message.content || + self.message.thoughts != message.thoughts || + self.message.contentChunks != message.contentChunks || + self.message.thinkingChunks != message.thinkingChunks || + self.message.isThinking != message.isThinking || + self.message.isCollapsed != message.isCollapsed || + self.message.generationTimeSeconds != message.generationTimeSeconds || + self.message.streamError != message.streamError || + self.isDarkMode != isDarkMode + + let metadataChanged = self.isLastMessage != isLastMessage || + self.isLoading != isLoading || + self.isArchived != isArchived || + self.showArchiveSeparator != showArchiveSeparator || + self.messageIndex != messageIndex + + if !contentChanged && !metadataChanged { + return + } + + if contentChanged { + cachedHeight = nil + cachedHeightKey = nil + } + + // Defer @Published property updates to the next run loop tick. + // UIHostingConfiguration requires the objectWillChange notification + // to arrive on a separate run loop iteration to trigger a re-render. + DispatchQueue.main.async { + self.message = message + self.isDarkMode = isDarkMode + self.isLastMessage = isLastMessage + self.isLoading = isLoading + self.isArchived = isArchived + self.showArchiveSeparator = showArchiveSeparator + self.messageIndex = messageIndex + } + } + + /// Checks whether the streaming buffer needs to grow and extends it if so. + /// Returns true if the buffer was extended. + @discardableResult + func extendBufferIfNeeded(screenHeight: CGFloat) -> Bool { + let currentBufferHeight = screenHeight * bufferMultiplier + let threshold = currentBufferHeight * Constants.StreamingBuffer.extensionThresholdRatio + let needsExtension = actualContentHeight > threshold + && bufferMultiplier < Constants.StreamingBuffer.maxMultiplier + + if needsExtension { + bufferMultiplier += Constants.StreamingBuffer.multiplierIncrement + } + return needsExtension + } + + func resetBuffer() { + bufferMultiplier = Constants.StreamingBuffer.initialMultiplier + actualContentHeight = 0 + } + + func getCacheKey() -> Int { + message.content.hashValue ^ + (message.thoughts?.hashValue ?? 0) ^ + (message.contentChunks.hashValue) ^ + (message.thinkingChunks.hashValue) ^ + isDarkMode.hashValue + } +} + +struct ObservableMessageCell: View { + @ObservedObject var wrapper: ObservableMessageWrapper + @ObservedObject var viewModel: ChatViewModel + weak var coordinator: MessageTableView.Coordinator? + @State private var hasAppeared = false + + private var bufferHeight: CGFloat { + let screenHeight = UIScreen.main.bounds.height + return min( + screenHeight * wrapper.bufferMultiplier, + Constants.StreamingBuffer.maxCellHeight + ) + } + + var body: some View { + VStack(spacing: 0) { + if wrapper.showArchiveSeparator { + HStack(spacing: 8) { + Rectangle() + .frame(height: 1) + .foregroundColor(.gray) + Text("archived") + .foregroundColor(.gray) + .font(.system(size: 12)) + Rectangle() + .frame(height: 1) + .foregroundColor(.gray) + } + .padding(.vertical, 16) + .padding(.horizontal, 24) + } + + ZStack(alignment: .topLeading) { + if wrapper.isLoading && wrapper.isLastMessage { + Color.clear + .frame(height: bufferHeight) + } + + MessageView( + message: wrapper.message, + isDarkMode: wrapper.isDarkMode, + isLastMessage: wrapper.isLastMessage, + isLoading: wrapper.isLoading, + messageIndex: wrapper.messageIndex + ) + .environmentObject(viewModel) + .opacity(wrapper.isArchived ? 0.6 : 1.0) + .padding(.vertical, 8) + .padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 100 : 8) + .if(UIDevice.current.userInterfaceIdiom == .pad) { view in + view.frame(maxWidth: 900) + .frame(maxWidth: .infinity) + } + .if(wrapper.isLoading && wrapper.isLastMessage) { view in + view.background( + GeometryReader { geometry in + Color.clear + .onChange(of: geometry.size.height) { _, newHeight in + wrapper.actualContentHeight = newHeight + } + } + ) + } + } + } + .opacity(wrapper.shouldAnimateAppearance && !hasAppeared ? 0 : 1) + .onAppear { + if wrapper.shouldAnimateAppearance && !hasAppeared { + withAnimation(.easeIn(duration: 0.2)) { + hasAppeared = true + } + } else { + hasAppeared = true + } + } + } +} + diff --git a/SwiftChat/Views/MessageView.swift b/SwiftChat/Views/MessageView.swift new file mode 100644 index 0000000..eafa04e --- /dev/null +++ b/SwiftChat/Views/MessageView.swift @@ -0,0 +1,1443 @@ +// +// MessageView.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI +import Textual +import SwiftMath +import UIKit + +struct MessageView: View { + let message: Message + let isDarkMode: Bool + let isLastMessage: Bool + let isLoading: Bool + let messageIndex: Int + @EnvironmentObject var viewModel: SwiftChat.ChatViewModel + @State private var showCopyFeedback = false + @State private var cachedParsedContent: (thinkingText: String, remainderText: String, contentHash: Int)? = nil + @State private var showLongMessageSheet = false + @State private var showRawContentModal = false + @State private var isEditMode = false + @State private var editedContent = "" + @State private var showSelectableText = false + @State private var showSourcesSheet = false + @State private var showUserMessageActions = false + @State private var showThoughtsSheet = false + @State private var showURLFetchSheet = false + + var body: some View { + HStack { + if message.role == .user { + Spacer() + } + + VStack(alignment: .trailing, spacing: 4) { + // Show attachment indicators above the message bubble + if message.role == .user && !message.attachments.isEmpty { + MessageAttachmentIndicator( + attachments: message.attachments, + isDarkMode: isDarkMode + ) + } + + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 2) { + // Show the loading dots for a fresh streaming assistant response (but not if we have thoughts or are thinking) + if message.role == .assistant && + message.content.isEmpty && + message.thoughts == nil && + !message.isThinking && + isLoading && + isLastMessage { + VStack(alignment: .leading, spacing: 4) { + if !message.urlFetches.isEmpty { + URLFetchBox(urlFetches: message.urlFetches, isDarkMode: isDarkMode, onTap: { showURLFetchSheet = true }) + } + + // Show web search box if searching + if let webSearchState = message.webSearchState { + WebSearchBox( + webSearchState: webSearchState, + isDarkMode: isDarkMode, + isStreaming: true, + webSearchSummary: viewModel.webSearchSummary, + onTap: { showSourcesSheet = true } + ) + } + + // Show loading dots if no web search or search is complete + if message.webSearchState == nil || message.webSearchState?.status != .searching { + LoadingDotsView(isDarkMode: isDarkMode) + .padding(.horizontal) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // If the message is thinking or has thoughts, display them in a thinking box + else if message.isThinking || message.thoughts != nil { + VStack(alignment: .leading, spacing: 4) { + if !message.urlFetches.isEmpty { + URLFetchBox(urlFetches: message.urlFetches, isDarkMode: isDarkMode, onTap: { showURLFetchSheet = true }) + } + + // Web search box (if applicable) - shown before thoughts since search happens first + if let webSearchState = message.webSearchState { + WebSearchBox( + webSearchState: webSearchState, + isDarkMode: isDarkMode, + isStreaming: isLoading && isLastMessage, + webSearchSummary: isLastMessage ? viewModel.webSearchSummary : nil, + onTap: { showSourcesSheet = true } + ) + } + + CollapsibleThinkingBox( + thinkingText: message.thoughts ?? "", + isDarkMode: isDarkMode, + isStreaming: message.isThinking && isLoading && isLastMessage, + generationTimeSeconds: message.generationTimeSeconds, + thinkingSummary: isLastMessage && message.isThinking ? viewModel.thinkingSummary : nil, + onTap: { showThoughtsSheet = true } + ) + + if !message.content.isEmpty { + if !message.contentChunks.isEmpty { + ChunkedContentView(chunks: message.contentChunks, isDarkMode: isDarkMode, isStreaming: isLoading && isLastMessage) + .equatable() + .frame(maxWidth: .infinity, alignment: .leading) + } else { + LaTeXMarkdownView(content: message.content, isDarkMode: isDarkMode, isStreaming: isLoading && isLastMessage) + .equatable() + .frame(maxWidth: .infinity, alignment: .leading) + .transaction { transaction in + transaction.animation = nil + } + } + } + } + } + + // Legacy support: if content still has tags, parse and display + else if let parsed = getParsedMessageContent() { + VStack(alignment: .leading, spacing: 4) { + CollapsibleThinkingBox( + thinkingText: parsed.thinkingText, + isDarkMode: isDarkMode, + isStreaming: isLoading && isLastMessage, + generationTimeSeconds: message.generationTimeSeconds, + thinkingSummary: isLastMessage && !message.content.contains("") ? viewModel.thinkingSummary : nil, + onTap: { showThoughtsSheet = true } + ) + + // Remainder: text after if present + if !parsed.remainderText.isEmpty { + LaTeXMarkdownView(content: parsed.remainderText, isDarkMode: isDarkMode, isStreaming: isLoading && isLastMessage) + .equatable() + .frame(maxWidth: .infinity, alignment: .leading) + .transaction { transaction in + transaction.animation = nil + } + } + } + } + + // Display long user messages as an attachment-style preview that expands on tap + else if message.role == .user && message.shouldDisplayAsAttachment { + if isEditMode { + UserMessageEditView( + content: $editedContent, + isDarkMode: isDarkMode, + onSave: { + viewModel.editMessage(at: messageIndex, newContent: editedContent) + isEditMode = false + }, + onCancel: { + isEditMode = false + editedContent = message.content + } + ) + } else { + LongMessageAttachmentView(message: message, isDarkMode: isDarkMode) { + showLongMessageSheet = true + } + } + } + + else if !message.content.isEmpty { + if message.role == .user { + if isEditMode { + UserMessageEditView( + content: $editedContent, + isDarkMode: isDarkMode, + onSave: { + viewModel.editMessage(at: messageIndex, newContent: editedContent) + isEditMode = false + }, + onCancel: { + isEditMode = false + editedContent = message.content + } + ) + } else { + AdaptiveMarkdownText(content: message.content, isDarkMode: isDarkMode) + } + } else { + VStack(alignment: .leading, spacing: 4) { + if !message.urlFetches.isEmpty { + URLFetchBox(urlFetches: message.urlFetches, isDarkMode: isDarkMode, onTap: { showURLFetchSheet = true }) + } + + // Web search box for non-thinking assistant messages + if let webSearchState = message.webSearchState { + WebSearchBox( + webSearchState: webSearchState, + isDarkMode: isDarkMode, + isStreaming: isLoading && isLastMessage, + webSearchSummary: isLastMessage ? viewModel.webSearchSummary : nil, + onTap: { showSourcesSheet = true } + ) + } + + if !message.contentChunks.isEmpty { + ChunkedContentView(chunks: message.contentChunks, isDarkMode: isDarkMode, isStreaming: isLoading && isLastMessage) + .equatable() + .frame(maxWidth: .infinity, alignment: .leading) + } else { + LaTeXMarkdownView(content: message.content, isDarkMode: isDarkMode, isStreaming: isLoading && isLastMessage) + .equatable() + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + // Show error box with regenerate button if stream failed + if message.streamError != nil && message.role == .assistant { + ErrorMessageView( + errorMessage: message.streamError!, + isDarkMode: isDarkMode, + isRequestError: message.isRequestError, + onRegenerate: isLastMessage ? { viewModel.regenerateLastResponse() } : nil + ) + .padding(.top, message.content.isEmpty && message.thoughts == nil ? 0 : 8) + } + + // Add action buttons for assistant messages (only when not streaming) + if message.role == .assistant && + (!message.content.isEmpty || message.thoughts != nil) && + !(isLoading && isLastMessage) { + HStack(spacing: 16) { + // Sources button - only show if we have web search sources + if let webSearchState = message.webSearchState, + !webSearchState.sources.isEmpty { + SourcesButton( + sources: webSearchState.sources, + isDarkMode: isDarkMode + ) { + showSourcesSheet = true + } + } + + Button { + showRawContentModal = true + } label: { + Image(systemName: "doc.on.doc") + .font(.system(size: 16)) + .foregroundColor(isDarkMode ? .white.opacity(0.5) : .black.opacity(0.5)) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + + // Regenerate button - only on the last assistant message + if isLastMessage && !viewModel.isLoading && messageIndex > 0 { + Button { + viewModel.regenerateMessage(at: messageIndex - 1) + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 16)) + .foregroundColor(isDarkMode ? .white.opacity(0.5) : .black.opacity(0.5)) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + } + + Spacer() + } + .padding(.vertical, 8) + + // AI disclaimer - only on the last assistant message + if isLastMessage { + Text("AI can make mistakes. Verify important information.") + .font(.system(size: 11)) + .foregroundColor(isDarkMode ? .white.opacity(0.35) : .black.opacity(0.35)) + } + } + + } + .padding(.vertical, message.role == .user && message.content.isEmpty ? 0 : 8) + .padding(.horizontal, message.role == .user && !message.content.isEmpty ? 12 : 0) + .background { + if message.role == .user && !message.content.isEmpty { + if #available(iOS 26, *) { + RoundedRectangle(cornerRadius: 16) + .fill(.thickMaterial) + } else { + Color.userMessageBackground(isDarkMode: isDarkMode) + } + } + } + .cornerRadius(16) + .modifier(MessageBubbleModifier(isUserMessage: message.role == .user)) + .contextMenu { + if message.role == .user && !message.content.isEmpty { + Button { + viewModel.regenerateMessage(at: messageIndex) + } label: { + Label("Resend", systemImage: "arrow.clockwise") + } + .disabled(viewModel.isLoading) + + Button { + UIPasteboard.general.string = message.content + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + + Button { + editedContent = message.content + isEditMode = true + } label: { + Label("Edit", systemImage: "pencil") + } + } + } + .onChange(of: message.id) { _, _ in + isEditMode = false + editedContent = "" + } + .onChange(of: viewModel.editRequestedForMessageIndex) { _, newValue in + if newValue == messageIndex && message.role == .user { + editedContent = message.content + isEditMode = true + viewModel.editRequestedForMessageIndex = nil + } + } + + } + } + .padding(.horizontal, 4) + .sheet(isPresented: $showLongMessageSheet) { + LongMessageDetailView( + message: message + ) + .presentationDetents([.medium, .large]) + } + .sheet(isPresented: $showRawContentModal) { + RawContentModalView(message: message) + .presentationDetents([.medium, .large]) + .presentationBackground(isDarkMode ? Color(hex: "161616") : Color(UIColor.systemGroupedBackground)) + } + .sheet(isPresented: $showSelectableText) { + UserMessageSelectView(content: message.content) + .presentationDetents([.medium, .large]) + } + .sheet(isPresented: $showSourcesSheet) { + if let sources = message.webSearchState?.sources { + SourcesSheetView(sources: sources, isDarkMode: isDarkMode) + .presentationDetents([.medium, .large]) + } + } + .sheet(isPresented: $showThoughtsSheet) { + ThoughtsSheetView( + thinkingText: thoughtsContent ?? "", + thinkingChunks: message.thinkingChunks, + generationTimeSeconds: message.generationTimeSeconds, + isDarkMode: isDarkMode + ) + .presentationDetents([.medium, .large]) + .presentationBackground(isDarkMode ? Color(hex: "161616") : Color(UIColor.systemGroupedBackground)) + } + .sheet(isPresented: $showURLFetchSheet) { + URLFetchSheetView(urlFetches: message.urlFetches, isDarkMode: isDarkMode) + .presentationDetents([.medium, .large]) + .presentationBackground(isDarkMode ? Color(hex: "161616") : Color(UIColor.systemGroupedBackground)) + } + .environment(\.openURL, OpenURLAction { url in + if url.scheme == "cite" { + let path = url.absoluteString.dropFirst(5) + if let tildeIndex = path.firstIndex(of: "~") { + let afterFirstTilde = path[path.index(after: tildeIndex)...] + if let secondTildeIndex = afterFirstTilde.firstIndex(of: "~") { + let encodedUrl = String(afterFirstTilde[.. String { + guard text.hasPrefix("") else { return text } + + // If the text has think tags, we need to clean it up + if let endTagRange = text.range(of: "") { + // Get just the text after the closing think tag + return String(text[endTagRange.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + } + + return text.replacingOccurrences(of: "", with: "") + } + + private var thoughtsContent: String? { + if let thoughts = message.thoughts, !thoughts.isEmpty { + return thoughts + } + if message.content.hasPrefix("") { + let tagPrefix = "" + let tagSuffix = "" + let start = message.content.index(message.content.startIndex, offsetBy: tagPrefix.count) + + if let endTagRange = message.content.range(of: tagSuffix, range: start..") ? "" : message.content + } + if message.content.hasPrefix("") { + let tagSuffix = "" + if let endTagRange = message.content.range(of: tagSuffix) { + return String(message.content[endTagRange.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return message.content + } + + /// Parse message content with tags + private func getParsedMessageContent() -> (thinkingText: String, remainderText: String)? { + // Parse if content starts with + guard message.content.hasPrefix("") else { return nil } + + let tagPrefix = "" + let tagSuffix = "" + let start = message.content.index(message.content.startIndex, offsetBy: tagPrefix.count) + + if let endTagRange = message.content.range(of: tagSuffix, range: start.. as the thinking text. + let thinkingText = String(message.content[start...]) + return (thinkingText, "") + } + } +} + +private struct LongMessageAttachmentView: View { + let message: Message + let isDarkMode: Bool + let openAction: () -> Void + + private var wordCountText: String { + let words = message.content.split { $0.isWhitespace || $0.isNewline } + return "\(words.count) words" + } + + private var previewText: String { + let trimmed = message.content.trimmingCharacters(in: .whitespacesAndNewlines) + let preview = trimmed.prefix(180) + return preview + (trimmed.count > 180 ? "…" : "") + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "doc.text") + .font(.system(size: 22, weight: .medium)) + .foregroundColor(Color.userMessageForeground(isDarkMode: isDarkMode).opacity(0.85)) + + VStack(alignment: .leading, spacing: 6) { + Text("Long Message") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color.userMessageForeground(isDarkMode: isDarkMode)) + + Text(wordCountText) + .font(.system(size: 12)) + .foregroundColor(Color.userMessageForeground(isDarkMode: isDarkMode).opacity(0.6)) + + Text(previewText) + .font(.system(size: 14)) + .foregroundColor(Color.userMessageForeground(isDarkMode: isDarkMode).opacity(0.85)) + .lineLimit(3) + .multilineTextAlignment(.leading) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.userMessageForeground(isDarkMode: isDarkMode).opacity(0.5)) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture(perform: openAction) + } +} + +private struct LongMessageDetailView: View { + let message: Message + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 16) { + SelectableTextView(text: message.content) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.backgroundPrimary) + .navigationTitle("Long Message") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close") { + dismiss() + } + } + } + } + .preferredColorScheme(.dark) + } +} + +private struct SelectableTextView: UIViewRepresentable { + let text: String + @Environment(\.colorScheme) private var colorScheme + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = true + textView.backgroundColor = .clear + textView.font = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.alwaysBounceVertical = true + textView.isScrollEnabled = true + textView.showsVerticalScrollIndicator = true + textView.textContainer.lineBreakMode = .byWordWrapping + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.setContentHuggingPriority(.defaultLow, for: .horizontal) + return textView + } + + func updateUIView(_ uiView: UITextView, context: Context) { + if uiView.text != text { + uiView.text = text + } + uiView.textColor = colorScheme == .dark ? UIColor(white: 1.0, alpha: 0.92) : UIColor(white: 0.0, alpha: 0.92) + } +} + +private struct UserMessageSelectView: View { + let content: String + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + SelectableTextView(text: content) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(20) + .background(Color.backgroundPrimary) + .navigationTitle("Select Text") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close") { + dismiss() + } + } + } + } + .preferredColorScheme(.dark) + } +} + +private struct RawContentModalView: View { + let message: Message + + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + @State private var showCopyAllFeedback = false + @State private var showCopyResponseFeedback = false + @State private var showCopyThoughtsFeedback = false + + private var isDarkMode: Bool { + colorScheme == .dark + } + + private var hasThoughts: Bool { + if let thoughts = message.thoughts, !thoughts.isEmpty { + return true + } + return message.content.hasPrefix("") + } + + private var thoughtsContent: String? { + if let thoughts = message.thoughts, !thoughts.isEmpty { + return thoughts + } + if message.content.hasPrefix("") { + let tagPrefix = "" + let tagSuffix = "" + let start = message.content.index(message.content.startIndex, offsetBy: tagPrefix.count) + + if let endTagRange = message.content.range(of: tagSuffix, range: start..") ? "" : message.content + } + if message.content.hasPrefix("") { + let tagSuffix = "" + if let endTagRange = message.content.range(of: tagSuffix) { + return String(message.content[endTagRange.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return message.content + } + + private var fullRawContent: String { + if let thoughts = thoughtsContent, !thoughts.isEmpty { + if !responseContent.isEmpty { + return thoughts + "\n\n" + responseContent + } + return thoughts + } + return responseContent + } + + private var sheetBackground: Color { + isDarkMode ? Color(hex: "161616") : Color(UIColor.systemGroupedBackground) + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + SelectableTextView(text: fullRawContent) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 20) + + Divider() + + VStack(spacing: 10) { + copyButton( + action: copyAll, + icon: showCopyAllFeedback ? "checkmark" : "doc.on.doc", + label: showCopyAllFeedback ? "Copied All!" : "Copy All" + ) + + if hasThoughts && !responseContent.isEmpty { + copyButton( + action: copyResponse, + icon: showCopyResponseFeedback ? "checkmark" : "text.quote", + label: showCopyResponseFeedback ? "Copied Response!" : "Copy Response" + ) + } + + if hasThoughts { + copyButton( + action: copyThoughts, + icon: showCopyThoughtsFeedback ? "checkmark" : "brain", + label: showCopyThoughtsFeedback ? "Copied Thoughts!" : "Copy Thoughts" + ) + } + } + .padding(20) + } + .background(sheetBackground) + .navigationTitle("Raw Content") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 18, weight: .medium)) + } + } + } + } + } + + private func copyButton(action: @escaping () -> Void, icon: String, label: String) -> some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: icon) + Text(label) + } + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.brandAccentDark) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + + private func copyAll() { + UIPasteboard.general.string = fullRawContent + withAnimation { + showCopyAllFeedback = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + withAnimation { + showCopyAllFeedback = false + } + } + } + + private func copyResponse() { + UIPasteboard.general.string = responseContent + withAnimation { + showCopyResponseFeedback = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + withAnimation { + showCopyResponseFeedback = false + } + } + } + + private func copyThoughts() { + if let thoughts = thoughtsContent { + UIPasteboard.general.string = thoughts + withAnimation { + showCopyThoughtsFeedback = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + withAnimation { + showCopyThoughtsFeedback = false + } + } + } + } +} + +/// Modifier that handles message bubble sizing based on sender +struct MessageBubbleModifier: ViewModifier { + let isUserMessage: Bool + + func body(content: Content) -> some View { + Group { + if isUserMessage { + content + // User messages get adaptive width based on content with minimum width + .frame(minWidth: 60, idealWidth: nil, maxWidth: max(60, UIScreen.main.bounds.width * 0.85), alignment: .trailing) + .frame(maxWidth: .infinity, alignment: .trailing) + } else { + content + // Assistant messages get nearly full width + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} + +/// A view that renders Markdown content using the Textual library. +struct MarkdownText: View { + let content: String + let isDarkMode: Bool + let horizontalPadding: CGFloat + + init(content: String, isDarkMode: Bool, horizontalPadding: CGFloat = 0) { + self.content = content + self.isDarkMode = isDarkMode + self.horizontalPadding = horizontalPadding + } + + var body: some View { + StructuredText(markdown: content) + .textual.structuredTextStyle(.gitHub) + .textual.highlighterTheme(.default) + .textual.textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, horizontalPadding) + .environment(\.colorScheme, isDarkMode ? .dark : .light) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// A specialized markdown text view for user messages +struct AdaptiveMarkdownText: View { + let content: String + let isDarkMode: Bool + let horizontalPadding: CGFloat + + init(content: String, isDarkMode: Bool, horizontalPadding: CGFloat = 0) { + self.content = content + self.isDarkMode = isDarkMode + self.horizontalPadding = horizontalPadding + } + + var body: some View { + StructuredText(markdown: content) + .textual.structuredTextStyle(.gitHub) + .textual.highlighterTheme(.default) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, -16) + .padding(.horizontal, horizontalPadding) + .environment(\.colorScheme, isDarkMode ? .dark : .light) + } +} + +struct CollapsibleThinkingBox: View { + let thinkingText: String + let isDarkMode: Bool + let isStreaming: Bool + let generationTimeSeconds: Double? + let thinkingSummary: String? + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack { + if let seconds = generationTimeSeconds { + Text("Thought for \(String(format: "%.1f", seconds))s") + .font(.subheadline) + .foregroundColor(isDarkMode ? .white.opacity(0.7) : Color.black.opacity(0.6)) + } else if isStreaming { + if let summary = thinkingSummary, !summary.isEmpty { + Text(summary) + .font(.subheadline) + .foregroundColor(isDarkMode ? .white : Color.black.opacity(0.8)) + .lineLimit(1) + .truncationMode(.tail) + .modifier(TextPulseAnimation()) + } else { + HStack(spacing: 4) { + Text("Thinking") + .font(.system(size: 16)) + .foregroundColor(isDarkMode ? .white : Color.black.opacity(0.8)) + InlineLoadingDotsView(isDarkMode: isDarkMode) + } + .modifier(TextPulseAnimation()) + } + } else { + Text("Thinking") + .font(.system(size: 16)) + .foregroundColor(isDarkMode ? .white : Color.black.opacity(0.8)) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(isDarkMode ? .white.opacity(0.4) : .black.opacity(0.4)) + } + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(NoHighlightButtonStyle()) + } +} + +struct ThoughtsSheetView: View { + let thinkingText: String + let thinkingChunks: [ThinkingChunk] + let generationTimeSeconds: Double? + let isDarkMode: Bool + @Environment(\.dismiss) private var dismiss + + private var titleText: String { + if let seconds = generationTimeSeconds { + return "Thought for \(String(format: "%.1f", seconds))s" + } + return "Thoughts" + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if !thinkingChunks.isEmpty { + ForEach(thinkingChunks) { chunk in + ThinkingChunkView(chunk: chunk, isDarkMode: isDarkMode) + .equatable() + } + } else { + Text(thinkingText) + .font(.system(.body)) + .foregroundColor(isDarkMode ? .white.opacity(0.9) : Color.black.opacity(0.8)) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle(titleText) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } +} + +struct ThinkingChunkView: View, Equatable { + let chunk: ThinkingChunk + let isDarkMode: Bool + + static func == (lhs: ThinkingChunkView, rhs: ThinkingChunkView) -> Bool { + if lhs.chunk.isComplete && rhs.chunk.isComplete { + return lhs.chunk.id == rhs.chunk.id && lhs.isDarkMode == rhs.isDarkMode + } + return lhs.chunk.id == rhs.chunk.id && + lhs.chunk.content == rhs.chunk.content && + lhs.isDarkMode == rhs.isDarkMode + } + + var body: some View { + Text(chunk.content) + .font(.system(.body)) + .foregroundColor(isDarkMode ? .white.opacity(0.9) : Color.black.opacity(0.8)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + } +} + +struct LoadingDotsView: View { + let isDarkMode: Bool + + var body: some View { + HStack(spacing: 4) { + ForEach(0..<3) { index in + Circle() + .frame(width: 6, height: 6) + .modifier(PulsingAnimation(delay: 0.2 * Double(index))) + } + } + .foregroundColor(isDarkMode ? .white : .black) + } +} + +struct InlineLoadingDotsView: View { + let isDarkMode: Bool + + var body: some View { + HStack(spacing: 2) { + ForEach(0..<3) { index in + Circle() + .frame(width: 4, height: 4) + .modifier(PulsingAnimation(delay: 0.2 * Double(index))) + } + } + .foregroundColor(isDarkMode ? .white.opacity(0.8) : Color.black.opacity(0.7)) + } +} + +struct NoHighlightButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + } +} + +struct PulsingAnimation: ViewModifier { + let delay: Double + @State private var isPulsing = false + + func body(content: Content) -> some View { + content + .scaleEffect(isPulsing ? 1.0 : 0.6) + .opacity(isPulsing ? 1.0 : 0.3) + .animation( + Animation.easeInOut(duration: 0.6) + .repeatForever(autoreverses: true) + .delay(delay), + value: isPulsing + ) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isPulsing = true + } + } + } +} + +struct TextPulseAnimation: ViewModifier { + @State private var offset: CGFloat = -1.0 + + func body(content: Content) -> some View { + content + .overlay( + GeometryReader { geometry in + let shimmerWidth = geometry.size.width * 0.4 + LinearGradient( + colors: [ + .clear, + .white.opacity(0.35), + .clear + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: shimmerWidth) + .offset(x: offset * (geometry.size.width + shimmerWidth)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .mask(content) + ) + .onAppear { + withAnimation( + .easeInOut(duration: 2.0) + .repeatForever(autoreverses: false) + ) { + offset = 1.0 + } + } + } +} + +struct MessageActionsView: View { + @EnvironmentObject var viewModel: SwiftChat.ChatViewModel + + var body: some View { + EmptyView() // Placeholder - replace with actual content when needed + } +} + +/// A simplified code block view without syntax highlighting + +struct ChunkedContentView: View, Equatable { + let chunks: [ContentChunk] + let isDarkMode: Bool + let isStreaming: Bool + + static func == (lhs: ChunkedContentView, rhs: ChunkedContentView) -> Bool { + lhs.chunks == rhs.chunks && + lhs.isDarkMode == rhs.isDarkMode && + lhs.isStreaming == rhs.isStreaming + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(chunks) { chunk in + ChunkView(chunk: chunk, isDarkMode: isDarkMode, isStreaming: isStreaming) + } + } + } +} + +struct ChunkView: View, Equatable { + let chunk: ContentChunk + let isDarkMode: Bool + let isStreaming: Bool + + static func == (lhs: ChunkView, rhs: ChunkView) -> Bool { + if lhs.chunk.isComplete && rhs.chunk.isComplete { + return lhs.chunk.id == rhs.chunk.id && lhs.isDarkMode == rhs.isDarkMode + } + return lhs.chunk.id == rhs.chunk.id && + lhs.chunk.isComplete == rhs.chunk.isComplete && + lhs.chunk.content == rhs.chunk.content && + lhs.isDarkMode == rhs.isDarkMode && + lhs.isStreaming == rhs.isStreaming + } + + var body: some View { + if chunk.type == .table && isStreaming { + GeneratingTableView(isDarkMode: isDarkMode) + } else { + LaTeXMarkdownView( + content: chunk.content, + isDarkMode: isDarkMode, + isStreaming: chunk.isComplete ? false : isStreaming + ) + .equatable() + } + } +} + +struct GeneratingTableView: View { + let isDarkMode: Bool + + var body: some View { + HStack(spacing: 8) { + Text("Generating table") + .font(.subheadline.weight(.medium)) + .foregroundColor(isDarkMode ? .white : .black) + InlineLoadingDotsView(isDarkMode: isDarkMode) + } + .frame(height: 48) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(isDarkMode ? Color.white.opacity(0.15) : Color.black.opacity(0.15), lineWidth: 1) + ) + } +} + +struct ErrorMessageView: View { + let errorMessage: String + let isDarkMode: Bool + var isRequestError: Bool = false + var onRegenerate: (() -> Void)? = nil + + private var accentColor: Color { + isRequestError ? .red : .orange + } + + private var isConnectionError: Bool { + let msg = errorMessage.lowercased() + return msg.contains("internet connection") || msg.contains("network") || msg.contains("connection was lost") || msg.contains("unable to connect") + } + + private var headerIcon: String { + if isConnectionError { return "wifi.exclamationmark" } + return "exclamationmark.triangle" + } + + private var headerText: String { + if isConnectionError { return "Connection Lost" } + return "Something Went Wrong" + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: headerIcon) + .foregroundColor(accentColor) + .font(.system(size: 16)) + + Text(headerText) + .font(.subheadline.weight(.medium)) + .foregroundColor(isDarkMode ? .white : .black) + + Spacer() + } + + Text(errorMessage) + .font(.caption) + .foregroundColor(isDarkMode ? .white.opacity(0.7) : .black.opacity(0.7)) + .multilineTextAlignment(.leading) + + HStack(spacing: 8) { + if let onRegenerate = onRegenerate { + Button(action: onRegenerate) { + HStack(spacing: 6) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12, weight: .medium)) + Text("Try again") + .font(.subheadline.weight(.medium)) + } + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(accentColor) + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(accentColor.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(accentColor.opacity(0.3), lineWidth: 1) + ) + ) + } +} + +struct UserMessageEditView: View { + @Binding var content: String + let isDarkMode: Bool + let onSave: () -> Void + let onCancel: () -> Void + @FocusState private var isFocused: Bool + + private var textColor: Color { + isDarkMode ? .white : .black + } + + private var secondaryTextColor: Color { + isDarkMode ? .white.opacity(0.5) : .black.opacity(0.5) + } + + private var buttonBackgroundColor: Color { + isDarkMode ? .white.opacity(0.1) : .black.opacity(0.1) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Messages below will be deleted and regenerated.") + .font(.system(size: 12)) + .foregroundColor(secondaryTextColor) + + TextField("Edit message...", text: $content, axis: .vertical) + .textFieldStyle(.plain) + .font(.body) + .foregroundColor(textColor) + .lineLimit(1...4) + .focused($isFocused) + .onAppear { + isFocused = true + } + .onSubmit { + if !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + onSave() + } + } + + HStack(spacing: 8) { + Spacer() + + Button(action: onCancel) { + Text("Cancel") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(secondaryTextColor) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(buttonBackgroundColor) + .cornerRadius(6) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: { + if !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + onSave() + } + }) { + Text("Save") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.accentPrimary) + .cornerRadius(6) + } + .buttonStyle(PlainButtonStyle()) + } + } + } +} + +// MARK: - Sources Button and Sheet + +/// Button showing "Sources" with overlapping favicons +private struct SourcesButton: View { + let sources: [WebSearchSource] + let isDarkMode: Bool + let action: () -> Void + + private var uniqueDomains: [String] { + var seen = Set() + var domains: [String] = [] + for source in sources { + let domain = getDomain(from: source.url) + if !seen.contains(domain) { + seen.insert(domain) + domains.append(domain) + } + if domains.count >= 4 { break } + } + return domains + } + + private func getDomain(from urlString: String) -> String { + guard let url = URL(string: urlString), + let host = url.host else { + return urlString + } + return host.hasPrefix("www.") ? String(host.dropFirst(4)) : host + } + + private func faviconUrl(for domain: String) -> String { + "https://icons.duckduckgo.com/ip3/\(domain).ico" + } + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Text("Sources") + .font(.system(size: 13, weight: .medium)) + + // Overlapping favicons + HStack(spacing: -6) { + ForEach(Array(uniqueDomains.enumerated()), id: \.offset) { index, domain in + AsyncImage(url: URL(string: faviconUrl(for: domain))) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure, .empty: + Image(systemName: "globe") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.gray) + @unknown default: + EmptyView() + } + } + .frame(width: 18, height: 18) + .background(isDarkMode ? Color.black : Color.white) + .clipShape(Circle()) + .overlay(Circle().stroke(isDarkMode ? Color.white.opacity(0.2) : Color.black.opacity(0.1), lineWidth: 1)) + .zIndex(Double(uniqueDomains.count - index)) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isDarkMode ? Color.white.opacity(0.1) : Color.black.opacity(0.05)) + .cornerRadius(20) + } + .buttonStyle(PlainButtonStyle()) + .foregroundColor(isDarkMode ? .white : .black) + } +} + +/// Sheet view showing all sources +private struct SourcesSheetView: View { + let sources: [WebSearchSource] + let isDarkMode: Bool + @Environment(\.dismiss) private var dismiss + + private func getDomain(from urlString: String) -> String { + guard let url = URL(string: urlString), + let host = url.host else { + return urlString + } + return host.hasPrefix("www.") ? String(host.dropFirst(4)) : host + } + + private func faviconUrl(for domain: String) -> String { + "https://icons.duckduckgo.com/ip3/\(domain).ico" + } + + var body: some View { + NavigationStack { + List { + ForEach(sources) { source in + Button { + if let url = URL(string: source.url) { + UIApplication.shared.open(url) + } + } label: { + HStack(spacing: 12) { + AsyncImage(url: URL(string: faviconUrl(for: getDomain(from: source.url)))) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure, .empty: + Image(systemName: "globe") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.gray) + @unknown default: + EmptyView() + } + } + .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 4)) + + VStack(alignment: .leading, spacing: 2) { + Text(source.title) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(isDarkMode ? .white : .black) + .lineLimit(2) + + Text(getDomain(from: source.url)) + .font(.system(size: 13)) + .foregroundColor(.gray) + } + + Spacer() + + Image(systemName: "arrow.up.right") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + .padding(.vertical, 4) + } + .buttonStyle(PlainButtonStyle()) + } + } + .listStyle(.plain) + .navigationTitle("Sources") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + .preferredColorScheme(isDarkMode ? .dark : .light) + } +} diff --git a/SwiftChat/Views/URLFetchBox.swift b/SwiftChat/Views/URLFetchBox.swift new file mode 100644 index 0000000..ea1e9f6 --- /dev/null +++ b/SwiftChat/Views/URLFetchBox.swift @@ -0,0 +1,171 @@ +// +// URLFetchBox.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI + +/// Inline row showing URL fetch count; tapping opens detail sheet +struct URLFetchBox: View { + let urlFetches: [URLFetchState] + let isDarkMode: Bool + let onTap: () -> Void + + private var isFetching: Bool { + urlFetches.contains { $0.status == .fetching } + } + + private var completedCount: Int { + urlFetches.filter { $0.status == .completed }.count + } + + var body: some View { + Button(action: onTap) { + HStack { + if isFetching { + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.6) + .frame(width: 14, height: 14) + Text("Reading \(urlFetches.count) link\(urlFetches.count == 1 ? "" : "s")") + .font(.subheadline) + .foregroundColor(isDarkMode ? .white : .black.opacity(0.8)) + } + } else { + HStack(spacing: 8) { + Image(systemName: "link") + .font(.system(size: 13)) + .foregroundColor(isDarkMode ? .white.opacity(0.5) : .black.opacity(0.5)) + Text("Read \(completedCount) link\(completedCount == 1 ? "" : "s")") + .font(.subheadline) + .foregroundColor(isDarkMode ? .white.opacity(0.7) : .black.opacity(0.6)) + fetchFavicons + } + } + Spacer() + if !isFetching { + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(isDarkMode ? .white.opacity(0.4) : .black.opacity(0.4)) + } + } + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(NoHighlightButtonStyle()) + .disabled(urlFetches.isEmpty) + } + + @ViewBuilder + private var fetchFavicons: some View { + HStack(spacing: -4) { + ForEach(Array(urlFetches.filter { $0.status == .completed }.prefix(4).enumerated()), id: \.element.id) { index, fetch in + FaviconView(url: fetch.url, isDarkMode: isDarkMode) + .zIndex(Double(4 - index)) + } + if completedCount > 4 { + Text("+\(completedCount - 4)") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(isDarkMode ? .white.opacity(0.6) : .black.opacity(0.6)) + .padding(.leading, 6) + } + } + } +} + +/// Sheet view showing all URL fetches with their status +struct URLFetchSheetView: View { + let urlFetches: [URLFetchState] + let isDarkMode: Bool + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + ForEach(urlFetches) { fetch in + URLFetchSheetRow(fetch: fetch, isDarkMode: isDarkMode) + .listRowBackground(Color.clear) + } + } + .listStyle(.plain) + .navigationTitle("Links") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } +} + +/// A single row in the URL fetch sheet +private struct URLFetchSheetRow: View { + let fetch: URLFetchState + let isDarkMode: Bool + + private var displayHost: String { + guard let urlObj = URL(string: fetch.url), + let host = urlObj.host else { return fetch.url } + return host.replacingOccurrences(of: "www.", with: "") + } + + var body: some View { + Button(action: openURL) { + HStack(spacing: 10) { + FaviconView(url: fetch.url, isDarkMode: isDarkMode) + .opacity(fetch.status == .failed || fetch.status == .blocked ? 0.4 : 1.0) + + VStack(alignment: .leading, spacing: 2) { + Text(displayHost) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(isDarkMode ? .white : .black.opacity(0.85)) + .lineLimit(1) + + Text(statusLabel) + .font(.system(size: 12)) + .foregroundColor(statusColor) + } + + Spacer() + + if fetch.status == .completed { + Image(systemName: "arrow.up.right") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(isDarkMode ? .white.opacity(0.4) : .black.opacity(0.4)) + } + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .disabled(fetch.status != .completed) + } + + private var statusLabel: String { + switch fetch.status { + case .fetching: return "Reading..." + case .completed: return "Read" + case .failed: return "Failed" + case .blocked: return "Blocked" + } + } + + private var statusColor: Color { + switch fetch.status { + case .fetching: return isDarkMode ? .white.opacity(0.5) : .black.opacity(0.5) + case .completed: return isDarkMode ? .white.opacity(0.5) : .black.opacity(0.5) + case .failed: return .red.opacity(0.7) + case .blocked: return .orange.opacity(0.7) + } + } + + private func openURL() { + guard let url = URL(string: fetch.url), + url.scheme == "https" || url.scheme == "http" else { return } + UIApplication.shared.open(url) + } +} diff --git a/SwiftChat/Views/WebSearchBox.swift b/SwiftChat/Views/WebSearchBox.swift new file mode 100644 index 0000000..f6c076e --- /dev/null +++ b/SwiftChat/Views/WebSearchBox.swift @@ -0,0 +1,226 @@ +// +// WebSearchBox.swift +// SwiftChat +// +// Created on 03/25/26. +// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved. +// + +import SwiftUI + +/// Inline row showing web search status; tapping opens sources sheet +struct WebSearchBox: View { + let webSearchState: WebSearchState + let isDarkMode: Bool + let isStreaming: Bool + let webSearchSummary: String? + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack { + headerContent + Spacer() + if webSearchState.status != .searching && !webSearchState.sources.isEmpty { + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(isDarkMode ? .white.opacity(0.4) : .black.opacity(0.4)) + } + } + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(NoHighlightButtonStyle()) + .disabled(webSearchState.status == .searching || webSearchState.sources.isEmpty) + } + + @ViewBuilder + private var headerContent: some View { + switch webSearchState.status { + case .searching: + HStack(spacing: 8) { + SearchingDotsView(isDarkMode: isDarkMode) + if let summary = webSearchSummary, !summary.isEmpty { + Text(summary) + .font(.subheadline) + .foregroundColor(isDarkMode ? .white : .black.opacity(0.8)) + .lineLimit(1) + .truncationMode(.tail) + } else if let query = webSearchState.query { + Text("Searching: \(query)") + .font(.subheadline) + .foregroundColor(isDarkMode ? .white : .black.opacity(0.8)) + .lineLimit(1) + .truncationMode(.tail) + } else { + Text("Searching the web...") + .font(.subheadline) + .foregroundColor(isDarkMode ? .white : .black.opacity(0.8)) + } + } + + case .completed: + HStack(spacing: 8) { + Image(systemName: "globe") + .foregroundColor(.blue) + .font(.system(size: 14)) + + if webSearchState.sources.isEmpty { + Text("Web search completed") + .font(.subheadline) + .foregroundColor(isDarkMode ? .white.opacity(0.7) : .black.opacity(0.6)) + } else { + Text("\(webSearchState.sources.count) source\(webSearchState.sources.count == 1 ? "" : "s") found") + .font(.subheadline) + .foregroundColor(isDarkMode ? .white.opacity(0.7) : .black.opacity(0.6)) + + sourceFavicons + } + } + + case .failed: + HStack(spacing: 8) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + .font(.system(size: 14)) + Text("Search failed") + .font(.subheadline) + .foregroundColor(.red.opacity(0.8)) + } + + case .blocked: + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.system(size: 14)) + Text(webSearchState.reason ?? "Search blocked") + .font(.subheadline) + .foregroundColor(.orange.opacity(0.8)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + + @ViewBuilder + private var sourceFavicons: some View { + HStack(spacing: -4) { + ForEach(Array(webSearchState.sources.prefix(4).enumerated()), id: \.element.id) { index, source in + FaviconView(url: source.url, isDarkMode: isDarkMode) + .zIndex(Double(4 - index)) + } + if webSearchState.sources.count > 4 { + Text("+\(webSearchState.sources.count - 4)") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(isDarkMode ? .white.opacity(0.6) : .black.opacity(0.6)) + .padding(.leading, 6) + } + } + } + +} + +/// Animated dots for searching state +struct SearchingDotsView: View { + let isDarkMode: Bool + + var body: some View { + HStack(spacing: 3) { + ForEach(0..<3) { index in + Circle() + .frame(width: 5, height: 5) + .modifier(PulsingAnimation(delay: 0.15 * Double(index))) + } + } + .foregroundColor(.blue) + } +} + +/// Favicon view that displays a website icon +struct FaviconView: View { + let url: String + let isDarkMode: Bool + + private var faviconURL: URL? { + guard let urlObj = URL(string: url), + let host = urlObj.host else { return nil } + return URL(string: "https://icons.duckduckgo.com/ip3/\(host).ico") + } + + var body: some View { + AsyncImage(url: faviconURL) { phase in + switch phase { + case .empty: + placeholderIcon + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure: + placeholderIcon + @unknown default: + placeholderIcon + } + } + .frame(width: 16, height: 16) + .background(isDarkMode ? Color.white.opacity(0.1) : Color.black.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(isDarkMode ? Color.white.opacity(0.2) : Color.black.opacity(0.1), lineWidth: 0.5) + ) + } + + private var placeholderIcon: some View { + Image(systemName: "globe") + .font(.system(size: 10)) + .foregroundColor(isDarkMode ? .white.opacity(0.5) : .black.opacity(0.5)) + } +} + +/// Row view for a single source +struct SourceRowView: View { + let source: WebSearchSource + let isDarkMode: Bool + + private var displayHost: String { + guard let url = URL(string: source.url), + let host = url.host else { return source.url } + return host.replacingOccurrences(of: "www.", with: "") + } + + var body: some View { + Button(action: openURL) { + HStack(spacing: 10) { + FaviconView(url: source.url, isDarkMode: isDarkMode) + + VStack(alignment: .leading, spacing: 2) { + Text(source.title) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(isDarkMode ? .white : .black.opacity(0.85)) + .lineLimit(1) + .truncationMode(.tail) + + Text(displayHost) + .font(.system(size: 11)) + .foregroundColor(isDarkMode ? .white.opacity(0.5) : .black.opacity(0.5)) + .lineLimit(1) + } + + Spacer() + + Image(systemName: "arrow.up.right") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(isDarkMode ? .white.opacity(0.4) : .black.opacity(0.4)) + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + } + + private func openURL() { + guard let url = URL(string: source.url) else { return } + UIApplication.shared.open(url) + } +} diff --git a/SwiftChat/swift-chat.icon/Assets/smallcircle-fill-circle-fill-svgrepo-com.svg b/SwiftChat/swift-chat.icon/Assets/smallcircle-fill-circle-fill-svgrepo-com.svg new file mode 100644 index 0000000..3913c7c --- /dev/null +++ b/SwiftChat/swift-chat.icon/Assets/smallcircle-fill-circle-fill-svgrepo-com.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/SwiftChat/swift-chat.icon/icon.json b/SwiftChat/swift-chat.icon/icon.json new file mode 100644 index 0000000..4143c42 --- /dev/null +++ b/SwiftChat/swift-chat.icon/icon.json @@ -0,0 +1,50 @@ +{ + "fill" : { + "automatic-gradient" : "srgb:1.00000,1.00000,1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "blend-mode" : "darken", + "fill" : { + "automatic-gradient" : "display-p3:0.01871,0.08824,0.27462,1.00000", + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 0 + }, + "stop" : { + "x" : 0.5, + "y" : 0.7 + } + } + }, + "image-name" : "smallcircle-fill-circle-fill-svgrepo-com.svg", + "name" : "smallcircle-fill-circle-fill-svgrepo-com", + "position" : { + "scale" : 1, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/SwiftChatTests/SwiftChatTests.swift b/SwiftChatTests/SwiftChatTests.swift new file mode 100644 index 0000000..8c60a7a --- /dev/null +++ b/SwiftChatTests/SwiftChatTests.swift @@ -0,0 +1,16 @@ +// +// SwiftChatTests.swift +// SwiftChatTests +// +// Created by Sacha on 3/25/26. +// + +import Testing + +struct SwiftChatTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/SwiftChatUITests/SwiftChatUITests.swift b/SwiftChatUITests/SwiftChatUITests.swift new file mode 100644 index 0000000..f3f379e --- /dev/null +++ b/SwiftChatUITests/SwiftChatUITests.swift @@ -0,0 +1,41 @@ +// +// SwiftChatUITests.swift +// SwiftChatUITests +// +// Created by Sacha on 3/25/26. +// + +import XCTest + +final class SwiftChatUITests: 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/SwiftChatUITests/SwiftChatUITestsLaunchTests.swift b/SwiftChatUITests/SwiftChatUITestsLaunchTests.swift new file mode 100644 index 0000000..43c1fbc --- /dev/null +++ b/SwiftChatUITests/SwiftChatUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// SwiftChatUITestsLaunchTests.swift +// SwiftChatUITests +// +// Created by Sacha on 3/25/26. +// + +import XCTest + +final class SwiftChatUITestsLaunchTests: 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) + } +}