first commit
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 582 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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") ?? ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||