first commit

This commit is contained in:
Sacha Servan-Schreiber
2026-03-25 14:28:54 -07:00
commit 28f450c03f
52 changed files with 9219 additions and 0 deletions
+692
View File
@@ -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 = "<group>";
};
99304C8E2F743BEE00E4DDD0 /* SwiftChatTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = SwiftChatTests;
sourceTree = "<group>";
};
99304C982F743BEE00E4DDD0 /* SwiftChatUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = SwiftChatUITests;
sourceTree = "<group>";
};
/* 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 = "<group>";
};
99304C7F2F743BED00E4DDD0 /* Products */ = {
isa = PBXGroup;
children = (
99304C7E2F743BED00E4DDD0 /* SwiftChat.app */,
99304C8B2F743BEE00E4DDD0 /* SwiftChatTests.xctest */,
99304C952F743BEE00E4DDD0 /* SwiftChatUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -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
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>SwiftChat.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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
}
}
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+108
View File
@@ -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)
}
}
+77
View File
@@ -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<String> = ["pdf", "txt", "md", "csv", "html"]
static let supportedImageExtensions: Set<String> = ["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"
}
}
+49
View File
@@ -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") ?? ""
}
}
+63
View File
@@ -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
}
}
+46
View File
@@ -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.")
}
}
}
+36
View File
@@ -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
)
}
}
+103
View File
@@ -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
}
}
+108
View File
@@ -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
}
}
+375
View File
@@ -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)
}
}
}
@@ -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)"
}
}
}
@@ -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[..<fenceRange.lowerBound])
if !beforeFence.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let chunkId = "paragraph_\(beforeFence.hashValue)_\(Date().timeIntervalSince1970)"
completedChunks.append(ContentChunk(
id: chunkId,
type: .paragraph,
content: beforeFence,
isComplete: true
))
}
let afterFence = String(workingBuffer[fenceRange.upperBound...])
let langLine = afterFence.components(separatedBy: .newlines).first ?? ""
codeBlockLanguage = langLine.trimmingCharacters(in: .whitespacesAndNewlines)
if codeBlockLanguage?.isEmpty == true {
codeBlockLanguage = nil
}
isInCodeBlock = true
workingBuffer = String(workingBuffer[fenceRange.lowerBound...])
return true
}
}
let lines = workingBuffer.components(separatedBy: .newlines)
if lines.count >= 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
}
}
@@ -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
}
}
@@ -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[..<range.lowerBound])
if !paragraph.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let chunkId = "thinking_\(completedChunks.count)"
completedChunks.append(ThinkingChunk(
id: chunkId,
content: paragraph,
isComplete: true
))
}
workingBuffer = String(workingBuffer[range.upperBound...])
}
}
func finalize() {
if !workingBuffer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let chunkId = "thinking_\(completedChunks.count)"
completedChunks.append(ThinkingChunk(
id: chunkId,
content: workingBuffer,
isComplete: true
))
}
workingBuffer = ""
}
func reset() {
completedChunks.removeAll()
workingBuffer = ""
}
}
+18
View File
@@ -0,0 +1,18 @@
//
// SwiftChatApp.swift
// SwiftChat
//
// Created on 03/25/26.
// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved.
//
import SwiftUI
@main
struct SwiftChatApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
+106
View File
@@ -0,0 +1,106 @@
//
// ChatQueryBuilder.swift
// SwiftChat
//
// Created on 03/25/26.
// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved.
//
import Foundation
import OpenAI
/// Helper for building CreateModelResponseQuery for the Responses API
struct ChatQueryBuilder {
private typealias Schemas = Components.Schemas
/// Build a Responses API query with system instructions, conversation history, and optional web search
static func buildQuery(
modelId: String,
systemPrompt: String,
rules: String,
conversationMessages: [Message],
maxMessages: Int,
stream: Bool = true,
webSearchEnabled: Bool = false,
isMultimodal: Bool = false
) -> 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
)
}
}
+31
View File
@@ -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()
}
}
+933
View File
@@ -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<Void, Error>?
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<ResponseStreamEvent, Error> = 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..<matchRange.lowerBound]
result += "[\(num)](#cite-\(num)~\(encodedUrl)~\(encodedTitle))"
lastEnd = matchRange.upperBound
}
result += content[lastEnd...]
return result
}
}
// MARK: - LLM Title Generation
extension ChatViewModel {
fileprivate func generateLLMTitle(from messages: [Message]) async -> 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<ResponseStreamEvent, Error> = 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
}
}
}
+132
View File
@@ -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)
}
}
}
+50
View File
@@ -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()
}
}
}
+167
View File
@@ -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)
}
}
+241
View File
@@ -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)
)
}
}
+340
View File
@@ -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`<Content: View>(_ 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)
}
}
}
+69
View File
@@ -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
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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..<Swift.min($0 + size, count)])
}
}
}
struct ZoomableImagePage: View {
let attachment: Attachment
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
@State private var decodedImage: UIImage?
var body: some View {
Group {
if let decodedImage {
Image(uiImage: decodedImage)
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale)
.gesture(
MagnifyGesture()
.onChanged { value in
scale = lastScale * value.magnification
}
.onEnded { _ in
lastScale = max(1.0, scale)
scale = lastScale
}
)
.onTapGesture(count: 2) {
withAnimation {
scale = 1.0
lastScale = 1.0
}
}
} else {
VStack(spacing: 12) {
Image(systemName: "photo")
.font(.system(size: 40))
.foregroundStyle(.white.opacity(0.5))
Text("Unable to load image")
.foregroundStyle(.white.opacity(0.5))
.font(.caption)
}
}
}
.onAppear {
let base64 = attachment.base64 ?? attachment.thumbnailBase64
guard let base64, let data = Data(base64Encoded: base64) else { return }
decodedImage = UIImage(data: data)
}
}
}
+648
View File
@@ -0,0 +1,648 @@
//
// MessageInputView.swift
// SwiftChat
//
// Created on 03/25/26.
// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved.
//
import SwiftUI
import UIKit
import PhotosUI
/// Input area for typing messages, including attachments and send button
struct MessageInputView: View {
// MARK: - Constants
fileprivate enum Layout {
static let defaultHeight: CGFloat = 72
static let minimumHeight: CGFloat = 72
static let maximumHeight: CGFloat = 180
}
@Binding var messageText: String
@ObservedObject var viewModel: SwiftChat.ChatViewModel
@Environment(\.colorScheme) var colorScheme
@State private var textHeight: CGFloat = Layout.defaultHeight
@ObservedObject private var settings = SettingsManager.shared
@StateObject private var audioService = AudioRecordingService.shared
var isKeyboardVisible: Bool = false
private var isDarkMode: Bool { colorScheme == .dark }
// Attachment picker state
@State private var showAddSheet = false
@State private var showDocumentPicker = false
@State private var showPhotoPicker = false
@State private var showCamera = false
@State private var selectedPhotoItems: [PhotosPickerItem] = []
@State private var pendingPickerAction: PickerAction?
private enum PickerAction {
case camera, photos, files
}
private var showAttachmentError: Binding<Bool> {
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
}
}
}
}
+713
View File
@@ -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<String> = []
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<String> = []
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
}
}
}
}
File diff suppressed because it is too large Load Diff
+171
View File
@@ -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)
}
}
+226
View File
@@ -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)
}
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg"><path d="M 27.9999 51.9063 C 41.0546 51.9063 51.9063 41.0781 51.9063 28 C 51.9063 14.9453 41.0312 4.0937 27.9765 4.0937 C 14.8983 4.0937 4.0937 14.9453 4.0937 28 C 4.0937 41.0781 14.9218 51.9063 27.9999 51.9063 Z M 28.0234 35.2422 C 24.0624 35.2422 20.7812 31.9844 20.7812 28 C 20.7812 24.0625 24.0624 20.8281 28.0234 20.8281 C 31.9374 20.8281 35.2421 24.0625 35.2421 28 C 35.2421 31.9844 31.9374 35.2422 28.0234 35.2422 Z"/></svg>

After

Width:  |  Height:  |  Size: 654 B

+50
View File
@@ -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"
}
}
+16
View File
@@ -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.
}
}
+41
View File
@@ -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()
}
}
}
@@ -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)
}
}