├── .gitignore ├── Info.plist ├── LICENSE ├── README.md ├── xllama.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcuserdata │ │ └── dunbin.xcuserdatad │ │ └── UserInterfaceState.xcuserstate └── xcuserdata │ └── dunbin.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── xllama ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 256 1.png │ │ ├── 256.png │ │ ├── 32 1.png │ │ ├── 32.png │ │ ├── 512 1.png │ │ ├── 512.png │ │ ├── 64.png │ │ ├── Contents.json │ │ └── logo.png │ └── Contents.json ├── ContentView.swift ├── Info.plist ├── Models │ └── ChatMessage.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Services │ ├── ChatService.swift │ ├── NetworkService.swift │ ├── OllamaService.swift │ └── chat_view.html ├── Views │ ├── ChatHistoryView.swift │ ├── ChatView.swift │ └── SettingsView.swift ├── xllama.entitlements └── xllamaApp.swift ├── xllamaTests └── xllamaTests.swift └── xllamaUITests ├── xllamaUITests.swift └── xllamaUITestsLaunchTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Obj-C/Swift specific 9 | *.hmap 10 | 11 | ## App packaging 12 | *.ipa 13 | *.dSYM.zip 14 | *.dSYM 15 | 16 | ## Playgrounds 17 | timeline.xctimeline 18 | playground.xcworkspace 19 | 20 | # Swift Package Manager 21 | # 22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 23 | # Packages/ 24 | # Package.pins 25 | # Package.resolved 26 | # *.xcodeproj 27 | # 28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 29 | # hence it is not needed unless you have added a package configuration file to your project 30 | # .swiftpm 31 | 32 | .build/ 33 | 34 | # CocoaPods 35 | # 36 | # We recommend against adding the Pods directory to your .gitignore. However 37 | # you should judge for yourself, the pros and cons are mentioned at: 38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 39 | # 40 | # Pods/ 41 | # 42 | # Add this line if you want to avoid checking in source code from the Xcode workspace 43 | # *.xcworkspace 44 | 45 | # Carthage 46 | # 47 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 48 | # Carthage/Checkouts 49 | 50 | Carthage/Build/ 51 | 52 | # fastlane 53 | # 54 | # It is recommended to not store the screenshots in the git repo. 55 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 56 | # For more information about the recommended setup visit: 57 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 58 | 59 | fastlane/report.xml 60 | fastlane/Preview.html 61 | fastlane/screenshots/**/*.png 62 | fastlane/test_output 63 | -------------------------------------------------------------------------------- /Info.plist: -------------------------------------------------------------------------------- 1 | NSAppTransportSecurity 2 | 3 | NSAllowsArbitraryLoads 4 | 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 DunBin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XDOllama 2 | 3 | ![image](https://github.com/user-attachments/assets/18bfc8c7-8941-43a4-9afa-5a45f2139a04) 4 | 5 | 适用于MacOS上快速调用Ollama\Dify\Xinference的AI模型界面。/Interface for quickly invoking Ollama\Dify\Xinference AI models on MacOS. 6 | 7 | ## 支持MacOS快速调用AI模型 8 | 9 | 最近国补购买了新款MacMini,如此心爱之物,为了发挥它的作用,特地开发了本桌面应用。 10 | 11 | ## 解决问题 12 | 13 | 目前主流AI启动框架包括Ollama、Dify、Xinference,都是非常优秀的后台应用。 14 | 15 | 但他们对于广大小白新手并不友好,在使用上未能达到随取随用,因此萌生开发这款软件的想法。 16 | 17 | ## 安装 18 | - 仅支持MacOS平台。 19 | - 下载DMG文件,双击打开后,将XDOllama.app拖入应用程序文件夹即可完成安装。 20 | 21 | ## 功能 22 | - 调用本地或在线Ollama模型 23 | - 调用本地或在线Xinference模型 24 | - 调用本地或在线Dify应用 25 | 26 | ![image](https://github.com/user-attachments/assets/19ff7b4d-1080-4c12-90ff-4ed90d5e8b62) 27 | 28 | ![image](https://github.com/user-attachments/assets/86bd3bad-6c44-4987-a9aa-38a9a9e17c93) 29 | 30 | ## 补充 31 | 由于只是文科生,第一次开发MacOS桌面端,技术欠佳,希望有兴趣的朋友一起加入优化完成。 32 | -------------------------------------------------------------------------------- /xllama.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | AF0A1A322CEF72B300B4F2DA /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = AFDE039F2CEF05FC00E01EFA /* MarkdownUI */; }; 11 | AFFB5DF62CEF2EE500042098 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = AFFB5DF52CEF2EE500042098 /* MarkdownUI */; }; 12 | /* End PBXBuildFile section */ 13 | 14 | /* Begin PBXContainerItemProxy section */ 15 | AF2D3E012CEEE25F00CB431B /* PBXContainerItemProxy */ = { 16 | isa = PBXContainerItemProxy; 17 | containerPortal = AF2D3DE72CEEE25D00CB431B /* Project object */; 18 | proxyType = 1; 19 | remoteGlobalIDString = AF2D3DEE2CEEE25D00CB431B; 20 | remoteInfo = xllama; 21 | }; 22 | AF2D3E0B2CEEE25F00CB431B /* PBXContainerItemProxy */ = { 23 | isa = PBXContainerItemProxy; 24 | containerPortal = AF2D3DE72CEEE25D00CB431B /* Project object */; 25 | proxyType = 1; 26 | remoteGlobalIDString = AF2D3DEE2CEEE25D00CB431B; 27 | remoteInfo = xllama; 28 | }; 29 | /* End PBXContainerItemProxy section */ 30 | 31 | /* Begin PBXFileReference section */ 32 | AF2D3DEF2CEEE25D00CB431B /* xllama.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = xllama.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33 | AF2D3E002CEEE25F00CB431B /* xllamaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = xllamaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 34 | AF2D3E0A2CEEE25F00CB431B /* xllamaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = xllamaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 35 | /* End PBXFileReference section */ 36 | 37 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 38 | AF2D3DF12CEEE25D00CB431B /* xllama */ = { 39 | isa = PBXFileSystemSynchronizedRootGroup; 40 | path = xllama; 41 | sourceTree = ""; 42 | }; 43 | AF2D3E032CEEE25F00CB431B /* xllamaTests */ = { 44 | isa = PBXFileSystemSynchronizedRootGroup; 45 | path = xllamaTests; 46 | sourceTree = ""; 47 | }; 48 | AF2D3E0D2CEEE25F00CB431B /* xllamaUITests */ = { 49 | isa = PBXFileSystemSynchronizedRootGroup; 50 | path = xllamaUITests; 51 | sourceTree = ""; 52 | }; 53 | /* End PBXFileSystemSynchronizedRootGroup section */ 54 | 55 | /* Begin PBXFrameworksBuildPhase section */ 56 | AF2D3DEC2CEEE25D00CB431B /* Frameworks */ = { 57 | isa = PBXFrameworksBuildPhase; 58 | buildActionMask = 2147483647; 59 | files = ( 60 | AF0A1A322CEF72B300B4F2DA /* MarkdownUI in Frameworks */, 61 | AFFB5DF62CEF2EE500042098 /* MarkdownUI in Frameworks */, 62 | ); 63 | runOnlyForDeploymentPostprocessing = 0; 64 | }; 65 | AF2D3DFD2CEEE25F00CB431B /* Frameworks */ = { 66 | isa = PBXFrameworksBuildPhase; 67 | buildActionMask = 2147483647; 68 | files = ( 69 | ); 70 | runOnlyForDeploymentPostprocessing = 0; 71 | }; 72 | AF2D3E072CEEE25F00CB431B /* Frameworks */ = { 73 | isa = PBXFrameworksBuildPhase; 74 | buildActionMask = 2147483647; 75 | files = ( 76 | ); 77 | runOnlyForDeploymentPostprocessing = 0; 78 | }; 79 | /* End PBXFrameworksBuildPhase section */ 80 | 81 | /* Begin PBXGroup section */ 82 | AF2D3DE62CEEE25D00CB431B = { 83 | isa = PBXGroup; 84 | children = ( 85 | AF2D3DF12CEEE25D00CB431B /* xllama */, 86 | AF2D3E032CEEE25F00CB431B /* xllamaTests */, 87 | AF2D3E0D2CEEE25F00CB431B /* xllamaUITests */, 88 | AF2D3DF02CEEE25D00CB431B /* Products */, 89 | ); 90 | sourceTree = ""; 91 | }; 92 | AF2D3DF02CEEE25D00CB431B /* Products */ = { 93 | isa = PBXGroup; 94 | children = ( 95 | AF2D3DEF2CEEE25D00CB431B /* xllama.app */, 96 | AF2D3E002CEEE25F00CB431B /* xllamaTests.xctest */, 97 | AF2D3E0A2CEEE25F00CB431B /* xllamaUITests.xctest */, 98 | ); 99 | name = Products; 100 | sourceTree = ""; 101 | }; 102 | /* End PBXGroup section */ 103 | 104 | /* Begin PBXNativeTarget section */ 105 | AF2D3DEE2CEEE25D00CB431B /* xllama */ = { 106 | isa = PBXNativeTarget; 107 | buildConfigurationList = AF2D3E142CEEE25F00CB431B /* Build configuration list for PBXNativeTarget "xllama" */; 108 | buildPhases = ( 109 | AF2D3DEB2CEEE25D00CB431B /* Sources */, 110 | AF2D3DEC2CEEE25D00CB431B /* Frameworks */, 111 | AF2D3DED2CEEE25D00CB431B /* Resources */, 112 | ); 113 | buildRules = ( 114 | ); 115 | dependencies = ( 116 | ); 117 | fileSystemSynchronizedGroups = ( 118 | AF2D3DF12CEEE25D00CB431B /* xllama */, 119 | ); 120 | name = xllama; 121 | packageProductDependencies = ( 122 | AFDE039F2CEF05FC00E01EFA /* MarkdownUI */, 123 | AFFB5DF52CEF2EE500042098 /* MarkdownUI */, 124 | ); 125 | productName = xllama; 126 | productReference = AF2D3DEF2CEEE25D00CB431B /* xllama.app */; 127 | productType = "com.apple.product-type.application"; 128 | }; 129 | AF2D3DFF2CEEE25F00CB431B /* xllamaTests */ = { 130 | isa = PBXNativeTarget; 131 | buildConfigurationList = AF2D3E172CEEE25F00CB431B /* Build configuration list for PBXNativeTarget "xllamaTests" */; 132 | buildPhases = ( 133 | AF2D3DFC2CEEE25F00CB431B /* Sources */, 134 | AF2D3DFD2CEEE25F00CB431B /* Frameworks */, 135 | AF2D3DFE2CEEE25F00CB431B /* Resources */, 136 | ); 137 | buildRules = ( 138 | ); 139 | dependencies = ( 140 | AF2D3E022CEEE25F00CB431B /* PBXTargetDependency */, 141 | ); 142 | fileSystemSynchronizedGroups = ( 143 | AF2D3E032CEEE25F00CB431B /* xllamaTests */, 144 | ); 145 | name = xllamaTests; 146 | packageProductDependencies = ( 147 | ); 148 | productName = xllamaTests; 149 | productReference = AF2D3E002CEEE25F00CB431B /* xllamaTests.xctest */; 150 | productType = "com.apple.product-type.bundle.unit-test"; 151 | }; 152 | AF2D3E092CEEE25F00CB431B /* xllamaUITests */ = { 153 | isa = PBXNativeTarget; 154 | buildConfigurationList = AF2D3E1A2CEEE25F00CB431B /* Build configuration list for PBXNativeTarget "xllamaUITests" */; 155 | buildPhases = ( 156 | AF2D3E062CEEE25F00CB431B /* Sources */, 157 | AF2D3E072CEEE25F00CB431B /* Frameworks */, 158 | AF2D3E082CEEE25F00CB431B /* Resources */, 159 | ); 160 | buildRules = ( 161 | ); 162 | dependencies = ( 163 | AF2D3E0C2CEEE25F00CB431B /* PBXTargetDependency */, 164 | ); 165 | fileSystemSynchronizedGroups = ( 166 | AF2D3E0D2CEEE25F00CB431B /* xllamaUITests */, 167 | ); 168 | name = xllamaUITests; 169 | packageProductDependencies = ( 170 | ); 171 | productName = xllamaUITests; 172 | productReference = AF2D3E0A2CEEE25F00CB431B /* xllamaUITests.xctest */; 173 | productType = "com.apple.product-type.bundle.ui-testing"; 174 | }; 175 | /* End PBXNativeTarget section */ 176 | 177 | /* Begin PBXProject section */ 178 | AF2D3DE72CEEE25D00CB431B /* Project object */ = { 179 | isa = PBXProject; 180 | attributes = { 181 | BuildIndependentTargetsInParallel = 1; 182 | LastSwiftUpdateCheck = 1600; 183 | LastUpgradeCheck = 1600; 184 | TargetAttributes = { 185 | AF2D3DEE2CEEE25D00CB431B = { 186 | CreatedOnToolsVersion = 16.0; 187 | }; 188 | AF2D3DFF2CEEE25F00CB431B = { 189 | CreatedOnToolsVersion = 16.0; 190 | TestTargetID = AF2D3DEE2CEEE25D00CB431B; 191 | }; 192 | AF2D3E092CEEE25F00CB431B = { 193 | CreatedOnToolsVersion = 16.0; 194 | TestTargetID = AF2D3DEE2CEEE25D00CB431B; 195 | }; 196 | }; 197 | }; 198 | buildConfigurationList = AF2D3DEA2CEEE25D00CB431B /* Build configuration list for PBXProject "xllama" */; 199 | developmentRegion = en; 200 | hasScannedForEncodings = 0; 201 | knownRegions = ( 202 | en, 203 | Base, 204 | ); 205 | mainGroup = AF2D3DE62CEEE25D00CB431B; 206 | minimizedProjectReferenceProxies = 1; 207 | packageReferences = ( 208 | AFFB5DF42CEF2EE500042098 /* XCRemoteSwiftPackageReference "MarkdownUI" */, 209 | ); 210 | preferredProjectObjectVersion = 77; 211 | productRefGroup = AF2D3DF02CEEE25D00CB431B /* Products */; 212 | projectDirPath = ""; 213 | projectRoot = ""; 214 | targets = ( 215 | AF2D3DEE2CEEE25D00CB431B /* xllama */, 216 | AF2D3DFF2CEEE25F00CB431B /* xllamaTests */, 217 | AF2D3E092CEEE25F00CB431B /* xllamaUITests */, 218 | ); 219 | }; 220 | /* End PBXProject section */ 221 | 222 | /* Begin PBXResourcesBuildPhase section */ 223 | AF2D3DED2CEEE25D00CB431B /* Resources */ = { 224 | isa = PBXResourcesBuildPhase; 225 | buildActionMask = 2147483647; 226 | files = ( 227 | ); 228 | runOnlyForDeploymentPostprocessing = 0; 229 | }; 230 | AF2D3DFE2CEEE25F00CB431B /* Resources */ = { 231 | isa = PBXResourcesBuildPhase; 232 | buildActionMask = 2147483647; 233 | files = ( 234 | ); 235 | runOnlyForDeploymentPostprocessing = 0; 236 | }; 237 | AF2D3E082CEEE25F00CB431B /* Resources */ = { 238 | isa = PBXResourcesBuildPhase; 239 | buildActionMask = 2147483647; 240 | files = ( 241 | ); 242 | runOnlyForDeploymentPostprocessing = 0; 243 | }; 244 | /* End PBXResourcesBuildPhase section */ 245 | 246 | /* Begin PBXSourcesBuildPhase section */ 247 | AF2D3DEB2CEEE25D00CB431B /* Sources */ = { 248 | isa = PBXSourcesBuildPhase; 249 | buildActionMask = 2147483647; 250 | files = ( 251 | ); 252 | runOnlyForDeploymentPostprocessing = 0; 253 | }; 254 | AF2D3DFC2CEEE25F00CB431B /* Sources */ = { 255 | isa = PBXSourcesBuildPhase; 256 | buildActionMask = 2147483647; 257 | files = ( 258 | ); 259 | runOnlyForDeploymentPostprocessing = 0; 260 | }; 261 | AF2D3E062CEEE25F00CB431B /* Sources */ = { 262 | isa = PBXSourcesBuildPhase; 263 | buildActionMask = 2147483647; 264 | files = ( 265 | ); 266 | runOnlyForDeploymentPostprocessing = 0; 267 | }; 268 | /* End PBXSourcesBuildPhase section */ 269 | 270 | /* Begin PBXTargetDependency section */ 271 | AF2D3E022CEEE25F00CB431B /* PBXTargetDependency */ = { 272 | isa = PBXTargetDependency; 273 | target = AF2D3DEE2CEEE25D00CB431B /* xllama */; 274 | targetProxy = AF2D3E012CEEE25F00CB431B /* PBXContainerItemProxy */; 275 | }; 276 | AF2D3E0C2CEEE25F00CB431B /* PBXTargetDependency */ = { 277 | isa = PBXTargetDependency; 278 | target = AF2D3DEE2CEEE25D00CB431B /* xllama */; 279 | targetProxy = AF2D3E0B2CEEE25F00CB431B /* PBXContainerItemProxy */; 280 | }; 281 | /* End PBXTargetDependency section */ 282 | 283 | /* Begin XCBuildConfiguration section */ 284 | AF2D3E122CEEE25F00CB431B /* Debug */ = { 285 | isa = XCBuildConfiguration; 286 | buildSettings = { 287 | ALWAYS_SEARCH_USER_PATHS = NO; 288 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 289 | CLANG_ANALYZER_NONNULL = YES; 290 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 291 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 292 | CLANG_ENABLE_MODULES = YES; 293 | CLANG_ENABLE_OBJC_ARC = YES; 294 | CLANG_ENABLE_OBJC_WEAK = YES; 295 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 296 | CLANG_WARN_BOOL_CONVERSION = YES; 297 | CLANG_WARN_COMMA = YES; 298 | CLANG_WARN_CONSTANT_CONVERSION = YES; 299 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 300 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 301 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 302 | CLANG_WARN_EMPTY_BODY = YES; 303 | CLANG_WARN_ENUM_CONVERSION = YES; 304 | CLANG_WARN_INFINITE_RECURSION = YES; 305 | CLANG_WARN_INT_CONVERSION = YES; 306 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 307 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 308 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 309 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 310 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 311 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 312 | CLANG_WARN_STRICT_PROTOTYPES = YES; 313 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 314 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 315 | CLANG_WARN_UNREACHABLE_CODE = YES; 316 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 317 | COPY_PHASE_STRIP = NO; 318 | DEBUG_INFORMATION_FORMAT = dwarf; 319 | ENABLE_STRICT_OBJC_MSGSEND = YES; 320 | ENABLE_TESTABILITY = YES; 321 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 322 | GCC_C_LANGUAGE_STANDARD = gnu17; 323 | GCC_DYNAMIC_NO_PIC = NO; 324 | GCC_NO_COMMON_BLOCKS = YES; 325 | GCC_OPTIMIZATION_LEVEL = 0; 326 | GCC_PREPROCESSOR_DEFINITIONS = ( 327 | "DEBUG=1", 328 | "$(inherited)", 329 | ); 330 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 331 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 332 | GCC_WARN_UNDECLARED_SELECTOR = YES; 333 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 334 | GCC_WARN_UNUSED_FUNCTION = YES; 335 | GCC_WARN_UNUSED_VARIABLE = YES; 336 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 337 | MACOSX_DEPLOYMENT_TARGET = 15.0; 338 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 339 | MTL_FAST_MATH = YES; 340 | ONLY_ACTIVE_ARCH = YES; 341 | SDKROOT = macosx; 342 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 343 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 344 | }; 345 | name = Debug; 346 | }; 347 | AF2D3E132CEEE25F00CB431B /* Release */ = { 348 | isa = XCBuildConfiguration; 349 | buildSettings = { 350 | ALWAYS_SEARCH_USER_PATHS = NO; 351 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 352 | CLANG_ANALYZER_NONNULL = YES; 353 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 354 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 355 | CLANG_ENABLE_MODULES = YES; 356 | CLANG_ENABLE_OBJC_ARC = YES; 357 | CLANG_ENABLE_OBJC_WEAK = YES; 358 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 359 | CLANG_WARN_BOOL_CONVERSION = YES; 360 | CLANG_WARN_COMMA = YES; 361 | CLANG_WARN_CONSTANT_CONVERSION = YES; 362 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 363 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 364 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 365 | CLANG_WARN_EMPTY_BODY = YES; 366 | CLANG_WARN_ENUM_CONVERSION = YES; 367 | CLANG_WARN_INFINITE_RECURSION = YES; 368 | CLANG_WARN_INT_CONVERSION = YES; 369 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 370 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 371 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 372 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 373 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 374 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 375 | CLANG_WARN_STRICT_PROTOTYPES = YES; 376 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 377 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 378 | CLANG_WARN_UNREACHABLE_CODE = YES; 379 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 380 | COPY_PHASE_STRIP = NO; 381 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 382 | ENABLE_NS_ASSERTIONS = NO; 383 | ENABLE_STRICT_OBJC_MSGSEND = YES; 384 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 385 | GCC_C_LANGUAGE_STANDARD = gnu17; 386 | GCC_NO_COMMON_BLOCKS = YES; 387 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 388 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 389 | GCC_WARN_UNDECLARED_SELECTOR = YES; 390 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 391 | GCC_WARN_UNUSED_FUNCTION = YES; 392 | GCC_WARN_UNUSED_VARIABLE = YES; 393 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 394 | MACOSX_DEPLOYMENT_TARGET = 15.0; 395 | MTL_ENABLE_DEBUG_INFO = NO; 396 | MTL_FAST_MATH = YES; 397 | SDKROOT = macosx; 398 | SWIFT_COMPILATION_MODE = wholemodule; 399 | }; 400 | name = Release; 401 | }; 402 | AF2D3E152CEEE25F00CB431B /* Debug */ = { 403 | isa = XCBuildConfiguration; 404 | buildSettings = { 405 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 406 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 407 | CODE_SIGN_ENTITLEMENTS = xllama/xllama.entitlements; 408 | CODE_SIGN_STYLE = Automatic; 409 | COMBINE_HIDPI_IMAGES = YES; 410 | CURRENT_PROJECT_VERSION = 1; 411 | DEVELOPMENT_ASSET_PATHS = "\"xllama/Preview Content\""; 412 | DEVELOPMENT_TEAM = 6G8S75DG33; 413 | ENABLE_HARDENED_RUNTIME = YES; 414 | ENABLE_PREVIEWS = YES; 415 | GENERATE_INFOPLIST_FILE = YES; 416 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 417 | LD_RUNPATH_SEARCH_PATHS = ( 418 | "$(inherited)", 419 | "@executable_path/../Frameworks", 420 | ); 421 | MARKETING_VERSION = 1.0; 422 | PRODUCT_BUNDLE_IDENTIFIER = zhixingwrite.xllama; 423 | PRODUCT_NAME = "$(TARGET_NAME)"; 424 | SWIFT_EMIT_LOC_STRINGS = YES; 425 | SWIFT_VERSION = 5.0; 426 | }; 427 | name = Debug; 428 | }; 429 | AF2D3E162CEEE25F00CB431B /* Release */ = { 430 | isa = XCBuildConfiguration; 431 | buildSettings = { 432 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 433 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 434 | CODE_SIGN_ENTITLEMENTS = xllama/xllama.entitlements; 435 | CODE_SIGN_STYLE = Automatic; 436 | COMBINE_HIDPI_IMAGES = YES; 437 | CURRENT_PROJECT_VERSION = 1; 438 | DEVELOPMENT_ASSET_PATHS = "\"xllama/Preview Content\""; 439 | DEVELOPMENT_TEAM = 6G8S75DG33; 440 | ENABLE_HARDENED_RUNTIME = YES; 441 | ENABLE_PREVIEWS = YES; 442 | GENERATE_INFOPLIST_FILE = YES; 443 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 444 | LD_RUNPATH_SEARCH_PATHS = ( 445 | "$(inherited)", 446 | "@executable_path/../Frameworks", 447 | ); 448 | MARKETING_VERSION = 1.0; 449 | PRODUCT_BUNDLE_IDENTIFIER = zhixingwrite.xllama; 450 | PRODUCT_NAME = "$(TARGET_NAME)"; 451 | SWIFT_EMIT_LOC_STRINGS = YES; 452 | SWIFT_VERSION = 5.0; 453 | }; 454 | name = Release; 455 | }; 456 | AF2D3E182CEEE25F00CB431B /* Debug */ = { 457 | isa = XCBuildConfiguration; 458 | buildSettings = { 459 | BUNDLE_LOADER = "$(TEST_HOST)"; 460 | CODE_SIGN_STYLE = Automatic; 461 | CURRENT_PROJECT_VERSION = 1; 462 | DEVELOPMENT_TEAM = 6G8S75DG33; 463 | GENERATE_INFOPLIST_FILE = YES; 464 | MACOSX_DEPLOYMENT_TARGET = 15.0; 465 | MARKETING_VERSION = 1.0; 466 | PRODUCT_BUNDLE_IDENTIFIER = zhixingwrite.xllamaTests; 467 | PRODUCT_NAME = "$(TARGET_NAME)"; 468 | SWIFT_EMIT_LOC_STRINGS = NO; 469 | SWIFT_VERSION = 5.0; 470 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/xllama.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/xllama"; 471 | }; 472 | name = Debug; 473 | }; 474 | AF2D3E192CEEE25F00CB431B /* Release */ = { 475 | isa = XCBuildConfiguration; 476 | buildSettings = { 477 | BUNDLE_LOADER = "$(TEST_HOST)"; 478 | CODE_SIGN_STYLE = Automatic; 479 | CURRENT_PROJECT_VERSION = 1; 480 | DEVELOPMENT_TEAM = 6G8S75DG33; 481 | GENERATE_INFOPLIST_FILE = YES; 482 | MACOSX_DEPLOYMENT_TARGET = 15.0; 483 | MARKETING_VERSION = 1.0; 484 | PRODUCT_BUNDLE_IDENTIFIER = zhixingwrite.xllamaTests; 485 | PRODUCT_NAME = "$(TARGET_NAME)"; 486 | SWIFT_EMIT_LOC_STRINGS = NO; 487 | SWIFT_VERSION = 5.0; 488 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/xllama.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/xllama"; 489 | }; 490 | name = Release; 491 | }; 492 | AF2D3E1B2CEEE25F00CB431B /* Debug */ = { 493 | isa = XCBuildConfiguration; 494 | buildSettings = { 495 | CODE_SIGN_STYLE = Automatic; 496 | CURRENT_PROJECT_VERSION = 1; 497 | DEVELOPMENT_TEAM = 6G8S75DG33; 498 | GENERATE_INFOPLIST_FILE = YES; 499 | MARKETING_VERSION = 1.0; 500 | PRODUCT_BUNDLE_IDENTIFIER = zhixingwrite.xllamaUITests; 501 | PRODUCT_NAME = "$(TARGET_NAME)"; 502 | SWIFT_EMIT_LOC_STRINGS = NO; 503 | SWIFT_VERSION = 5.0; 504 | TEST_TARGET_NAME = xllama; 505 | }; 506 | name = Debug; 507 | }; 508 | AF2D3E1C2CEEE25F00CB431B /* Release */ = { 509 | isa = XCBuildConfiguration; 510 | buildSettings = { 511 | CODE_SIGN_STYLE = Automatic; 512 | CURRENT_PROJECT_VERSION = 1; 513 | DEVELOPMENT_TEAM = 6G8S75DG33; 514 | GENERATE_INFOPLIST_FILE = YES; 515 | MARKETING_VERSION = 1.0; 516 | PRODUCT_BUNDLE_IDENTIFIER = zhixingwrite.xllamaUITests; 517 | PRODUCT_NAME = "$(TARGET_NAME)"; 518 | SWIFT_EMIT_LOC_STRINGS = NO; 519 | SWIFT_VERSION = 5.0; 520 | TEST_TARGET_NAME = xllama; 521 | }; 522 | name = Release; 523 | }; 524 | /* End XCBuildConfiguration section */ 525 | 526 | /* Begin XCConfigurationList section */ 527 | AF2D3DEA2CEEE25D00CB431B /* Build configuration list for PBXProject "xllama" */ = { 528 | isa = XCConfigurationList; 529 | buildConfigurations = ( 530 | AF2D3E122CEEE25F00CB431B /* Debug */, 531 | AF2D3E132CEEE25F00CB431B /* Release */, 532 | ); 533 | defaultConfigurationIsVisible = 0; 534 | defaultConfigurationName = Release; 535 | }; 536 | AF2D3E142CEEE25F00CB431B /* Build configuration list for PBXNativeTarget "xllama" */ = { 537 | isa = XCConfigurationList; 538 | buildConfigurations = ( 539 | AF2D3E152CEEE25F00CB431B /* Debug */, 540 | AF2D3E162CEEE25F00CB431B /* Release */, 541 | ); 542 | defaultConfigurationIsVisible = 0; 543 | defaultConfigurationName = Release; 544 | }; 545 | AF2D3E172CEEE25F00CB431B /* Build configuration list for PBXNativeTarget "xllamaTests" */ = { 546 | isa = XCConfigurationList; 547 | buildConfigurations = ( 548 | AF2D3E182CEEE25F00CB431B /* Debug */, 549 | AF2D3E192CEEE25F00CB431B /* Release */, 550 | ); 551 | defaultConfigurationIsVisible = 0; 552 | defaultConfigurationName = Release; 553 | }; 554 | AF2D3E1A2CEEE25F00CB431B /* Build configuration list for PBXNativeTarget "xllamaUITests" */ = { 555 | isa = XCConfigurationList; 556 | buildConfigurations = ( 557 | AF2D3E1B2CEEE25F00CB431B /* Debug */, 558 | AF2D3E1C2CEEE25F00CB431B /* Release */, 559 | ); 560 | defaultConfigurationIsVisible = 0; 561 | defaultConfigurationName = Release; 562 | }; 563 | /* End XCConfigurationList section */ 564 | 565 | /* Begin XCRemoteSwiftPackageReference section */ 566 | AFFB5DF42CEF2EE500042098 /* XCRemoteSwiftPackageReference "MarkdownUI" */ = { 567 | isa = XCRemoteSwiftPackageReference; 568 | repositoryURL = "https://github.com/gonzalezreal/MarkdownUI"; 569 | requirement = { 570 | kind = upToNextMajorVersion; 571 | minimumVersion = 2.4.1; 572 | }; 573 | }; 574 | /* End XCRemoteSwiftPackageReference section */ 575 | 576 | /* Begin XCSwiftPackageProductDependency section */ 577 | AFDE039F2CEF05FC00E01EFA /* MarkdownUI */ = { 578 | isa = XCSwiftPackageProductDependency; 579 | productName = MarkdownUI; 580 | }; 581 | AFFB5DF52CEF2EE500042098 /* MarkdownUI */ = { 582 | isa = XCSwiftPackageProductDependency; 583 | package = AFFB5DF42CEF2EE500042098 /* XCRemoteSwiftPackageReference "MarkdownUI" */; 584 | productName = MarkdownUI; 585 | }; 586 | /* End XCSwiftPackageProductDependency section */ 587 | }; 588 | rootObject = AF2D3DE72CEEE25D00CB431B /* Project object */; 589 | } 590 | -------------------------------------------------------------------------------- /xllama.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /xllama.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "427e2f78970f33b38feca26c7986bebb5f56cf55a388a29b6bade958aaa6607e", 3 | "pins" : [ 4 | { 5 | "identity" : "markdownui", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/gonzalezreal/MarkdownUI", 8 | "state" : { 9 | "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", 10 | "version" : "2.4.1" 11 | } 12 | }, 13 | { 14 | "identity" : "networkimage", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/gonzalezreal/NetworkImage", 17 | "state" : { 18 | "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", 19 | "version" : "6.0.1" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-cmark", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/swiftlang/swift-cmark", 26 | "state" : { 27 | "revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53", 28 | "version" : "0.5.0" 29 | } 30 | } 31 | ], 32 | "version" : 3 33 | } 34 | -------------------------------------------------------------------------------- /xllama.xcodeproj/project.xcworkspace/xcuserdata/dunbin.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dunbin/XDOllama/fb4c68bd4af959623d8e3dc46720107206f531cc/xllama.xcodeproj/project.xcworkspace/xcuserdata/dunbin.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /xllama.xcodeproj/xcuserdata/dunbin.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | xllama.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | xllama.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 0 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /xllama/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /xllama/Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dunbin/XDOllama/fb4c68bd4af959623d8e3dc46720107206f531cc/xllama/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /xllama/Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dunbin/XDOllama/fb4c68bd4af959623d8e3dc46720107206f531cc/xllama/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /xllama/Assets.xcassets/AppIcon.appiconset/256 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dunbin/XDOllama/fb4c68bd4af959623d8e3dc46720107206f531cc/xllama/Assets.xcassets/AppIcon.appiconset/256 1.png -------------------------------------------------------------------------------- /xllama/Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dunbin/XDOllama/fb4c68bd4af959623d8e3dc46720107206f531cc/xllama/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /xllama/Assets.xcassets/AppIcon.appiconset/32 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dunbin/XDOllama/fb4c68bd4af959623d8e3dc46720107206f531cc/xllama/Assets.xcassets/AppIcon.appiconset/32 1.png -------------------------------------------------------------------------------- /xllama/Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dunbin/XDOllama/fb4c68bd4af959623d8e3dc46720107206f531cc/xllama/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /xllama/Assets.xcassets/AppIcon.appiconset/512 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dunbin/XDOllama/fb4c68bd4af959623d8e3dc46720107206f531cc/xllama/Assets.xcassets/AppIcon.appiconset/512 1.png -------------------------------------------------------------------------------- /xllama/Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dunbin/XDOllama/fb4c68bd4af959623d8e3dc46720107206f531cc/xllama/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /xllama/Assets.xcassets/AppIcon.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dunbin/XDOllama/fb4c68bd4af959623d8e3dc46720107206f531cc/xllama/Assets.xcassets/AppIcon.appiconset/64.png -------------------------------------------------------------------------------- /xllama/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "32.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "32 1.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "64.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "256.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "256 1.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "512.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "512 1.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "idiom" : "mac", 59 | "scale" : "2x", 60 | "size" : "512x512" 61 | } 62 | ], 63 | "info" : { 64 | "author" : "xcode", 65 | "version" : 1 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /xllama/Assets.xcassets/AppIcon.appiconset/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dunbin/XDOllama/fb4c68bd4af959623d8e3dc46720107206f531cc/xllama/Assets.xcassets/AppIcon.appiconset/logo.png -------------------------------------------------------------------------------- /xllama/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /xllama/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // xllama 4 | // 5 | // Created by dunbin on 2024/11/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | @State private var showSettings = false 12 | @StateObject private var chatHistoryManager = ChatHistoryManager.shared 13 | @StateObject private var chatService = ChatService.shared 14 | 15 | var body: some View { 16 | NavigationSplitView { 17 | ChatHistoryView() 18 | } detail: { 19 | HStack(spacing: 0) { 20 | ChatView() 21 | } 22 | } 23 | .toolbar { 24 | ToolbarItem(placement: .automatic) { 25 | HStack(spacing: 12) { 26 | Button(action: { chatHistoryManager.createNewConversation() }) { 27 | Image(systemName: "plus") 28 | } 29 | .disabled(shouldDisableNewChat) 30 | 31 | Button(action: { showSettings.toggle() }) { 32 | Image(systemName: "gear") 33 | } 34 | } 35 | } 36 | } 37 | .sheet(isPresented: $showSettings) { 38 | SettingsView() 39 | } 40 | } 41 | 42 | private var shouldDisableNewChat: Bool { 43 | guard let currentId = chatHistoryManager.currentConversationId, 44 | let currentConversation = chatHistoryManager.conversations.first(where: { $0.id == currentId }) 45 | else { return false } 46 | 47 | return currentConversation.messages.isEmpty && chatService.isLoading 48 | } 49 | } 50 | 51 | #Preview { 52 | ContentView() 53 | } 54 | -------------------------------------------------------------------------------- /xllama/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | LSApplicationCategoryType 7 | public.app-category.developer-tools 8 | 9 | 10 | CFBundleName 11 | XDOllama 12 | CFBundleDisplayName 13 | XDOllama 14 | CFBundleIdentifier 15 | $(PRODUCT_BUNDLE_IDENTIFIER) 16 | CFBundleVersion 17 | $(CURRENT_PROJECT_VERSION) 18 | CFBundleShortVersionString 19 | $(MARKETING_VERSION) 20 | 21 | -------------------------------------------------------------------------------- /xllama/Models/ChatMessage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct ChatMessage: Identifiable, Codable, Equatable { 4 | var id: UUID 5 | var content: String 6 | let isUser: Bool 7 | let timestamp: Date 8 | 9 | init(content: String, isUser: Bool) { 10 | self.id = UUID() 11 | self.content = content 12 | self.isUser = isUser 13 | self.timestamp = Date() 14 | } 15 | 16 | static func == (lhs: ChatMessage, rhs: ChatMessage) -> Bool { 17 | return lhs.id == rhs.id && 18 | lhs.content == rhs.content && 19 | lhs.isUser == rhs.isUser 20 | } 21 | } -------------------------------------------------------------------------------- /xllama/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /xllama/Services/ChatService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @MainActor 4 | class ChatService: ObservableObject { 5 | static let shared = ChatService() 6 | 7 | @Published var messages: [ChatMessage] = [] 8 | @Published var isLoading = false 9 | @Published var streamResponse: String = "" 10 | 11 | private let ollamaService = OllamaService.shared 12 | private let xinferenceService = XinferenceService.shared 13 | 14 | private weak var chatHistoryManager: ChatHistoryManager? 15 | 16 | private var isCancelled = false 17 | 18 | private init() {} 19 | 20 | func setHistoryManager(_ manager: ChatHistoryManager) { 21 | self.chatHistoryManager = manager 22 | } 23 | 24 | func clearMessages() { 25 | messages.removeAll() 26 | } 27 | 28 | func loadMessages(_ messages: [ChatMessage]) { 29 | self.messages = messages 30 | } 31 | 32 | func sendMessage(_ content: String) async { 33 | await chatHistoryManager?.sendMessage(content) 34 | 35 | let userMessage = ChatMessage(content: content, isUser: true) 36 | await MainActor.run { 37 | messages.append(userMessage) 38 | isLoading = true 39 | streamResponse = "" 40 | messages.append(ChatMessage(content: "", isUser: false)) 41 | } 42 | 43 | do { 44 | try await generateStreamResponse(content) 45 | await MainActor.run { 46 | isLoading = false 47 | chatHistoryManager?.updateCurrentConversation(messages: messages) 48 | } 49 | } catch { 50 | print("Error generating response:", error) 51 | await MainActor.run { 52 | messages.removeLast() 53 | isLoading = false 54 | } 55 | } 56 | } 57 | 58 | func regenerateResponse() async { 59 | guard let lastUserMessage = messages.last(where: { $0.isUser }), 60 | messages.count >= 2 else { return } 61 | 62 | await MainActor.run { 63 | if !messages.last!.isUser { 64 | messages.removeLast() 65 | } 66 | isLoading = true 67 | streamResponse = "" 68 | messages.append(ChatMessage(content: "", isUser: false)) 69 | } 70 | 71 | do { 72 | try await generateStreamResponse(lastUserMessage.content) 73 | await MainActor.run { 74 | isLoading = false 75 | chatHistoryManager?.updateCurrentConversation(messages: messages) 76 | } 77 | } catch { 78 | print("Error regenerating response:", error) 79 | await MainActor.run { 80 | messages.removeLast() 81 | isLoading = false 82 | } 83 | } 84 | } 85 | 86 | func sendXinferenceMessage(_ content: String) async { 87 | await chatHistoryManager?.sendMessage(content) 88 | 89 | let userMessage = ChatMessage(content: content, isUser: true) 90 | await MainActor.run { 91 | messages.append(userMessage) 92 | isLoading = true 93 | streamResponse = "" 94 | messages.append(ChatMessage(content: "", isUser: false)) 95 | } 96 | 97 | do { 98 | try await generateXinferenceResponse(content) 99 | await MainActor.run { 100 | isLoading = false 101 | chatHistoryManager?.updateCurrentConversation(messages: messages) 102 | } 103 | } catch { 104 | print("Error generating response:", error) 105 | await MainActor.run { 106 | messages.removeLast() 107 | isLoading = false 108 | } 109 | } 110 | } 111 | 112 | func sendDifyMessage(_ content: String) async { 113 | await chatHistoryManager?.sendMessage(content) 114 | 115 | let userMessage = ChatMessage(content: content, isUser: true) 116 | await MainActor.run { 117 | messages.append(userMessage) 118 | isLoading = true 119 | streamResponse = "" 120 | messages.append(ChatMessage(content: "", isUser: false)) 121 | } 122 | 123 | do { 124 | try await generateDifyResponse(content) 125 | await MainActor.run { 126 | isLoading = false 127 | chatHistoryManager?.updateCurrentConversation(messages: messages) 128 | } 129 | } catch { 130 | print("Error generating Dify response:", error) 131 | await MainActor.run { 132 | messages.removeLast() 133 | isLoading = false 134 | } 135 | } 136 | } 137 | 138 | func cancelGeneration() { 139 | isCancelled = true 140 | } 141 | 142 | private func generateStreamResponse(_ prompt: String) async throws { 143 | isCancelled = false 144 | var accumulatedResponse = "" 145 | 146 | guard let url = URL(string: "\(ollamaService.baseURL)/api/chat") else { 147 | throw URLError(.badURL) 148 | } 149 | 150 | let parameters: [String: Any] = [ 151 | "model": ollamaService.selectedModel, 152 | "messages": ollamaService.buildConversationContext(messages + [ChatMessage(content: prompt, isUser: true)]), 153 | "stream": true 154 | ] 155 | 156 | var request = URLRequest(url: url) 157 | request.httpMethod = "POST" 158 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 159 | request.httpBody = try JSONSerialization.data(withJSONObject: parameters) 160 | 161 | let (bytes, _) = try await URLSession.shared.bytes(for: request) 162 | 163 | struct StreamResponse: Codable { 164 | let message: Message 165 | let done: Bool 166 | 167 | struct Message: Codable { 168 | let role: String 169 | let content: String 170 | } 171 | } 172 | 173 | for try await line in bytes.lines { 174 | if isCancelled { 175 | await MainActor.run { 176 | isLoading = false 177 | } 178 | break 179 | } 180 | 181 | guard !line.isEmpty else { continue } 182 | 183 | if let data = line.data(using: .utf8), 184 | let response = try? JSONDecoder().decode(StreamResponse.self, from: data) { 185 | accumulatedResponse += response.message.content 186 | 187 | await MainActor.run { 188 | self.streamResponse = accumulatedResponse 189 | if var lastMessage = messages.last, !lastMessage.isUser { 190 | lastMessage.content = accumulatedResponse 191 | messages[messages.count - 1] = lastMessage 192 | } 193 | objectWillChange.send() 194 | } 195 | 196 | if response.done { 197 | break 198 | } 199 | } 200 | } 201 | } 202 | 203 | private func generateXinferenceResponse(_ prompt: String) async throws { 204 | isCancelled = false 205 | var accumulatedResponse = "" 206 | 207 | guard let url = URL(string: "\(xinferenceService.baseURL)/v1/chat/completions") else { 208 | throw URLError(.badURL) 209 | } 210 | 211 | let parameters: [String: Any] = [ 212 | "model": xinferenceService.selectedModel, 213 | "messages": [ 214 | ["role": "user", "content": prompt] 215 | ], 216 | "stream": true 217 | ] 218 | 219 | var request = URLRequest(url: url) 220 | request.httpMethod = "POST" 221 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 222 | request.httpBody = try JSONSerialization.data(withJSONObject: parameters) 223 | 224 | let (bytes, _) = try await URLSession.shared.bytes(for: request) 225 | 226 | struct StreamResponse: Codable { 227 | let choices: [Choice] 228 | 229 | struct Choice: Codable { 230 | let delta: Delta 231 | 232 | struct Delta: Codable { 233 | let content: String? 234 | } 235 | } 236 | } 237 | 238 | for try await line in bytes.lines { 239 | if isCancelled { 240 | await MainActor.run { 241 | isLoading = false 242 | } 243 | break 244 | } 245 | 246 | guard !line.isEmpty, line != "data: [DONE]" else { continue } 247 | 248 | if let dataRange = line.range(of: "data: ") { 249 | let jsonString = String(line[dataRange.upperBound...]) 250 | if let data = jsonString.data(using: .utf8), 251 | let response = try? JSONDecoder().decode(StreamResponse.self, from: data) { 252 | if let content = response.choices.first?.delta.content { 253 | accumulatedResponse += content 254 | 255 | await MainActor.run { 256 | self.streamResponse = accumulatedResponse 257 | if var lastMessage = messages.last, !lastMessage.isUser { 258 | lastMessage.content = accumulatedResponse 259 | messages[messages.count - 1] = lastMessage 260 | objectWillChange.send() 261 | } 262 | } 263 | } 264 | } 265 | } 266 | } 267 | } 268 | 269 | private func generateDifyResponse(_ prompt: String) async throws { 270 | isCancelled = false 271 | var accumulatedResponse = "" 272 | 273 | let difyService = DifyService.shared 274 | 275 | guard !difyService.apiKey.isEmpty, 276 | let url = URL(string: "\(difyService.baseURL)/chat-messages") else { 277 | throw URLError(.badURL) 278 | } 279 | 280 | let parameters: [String: Any] = [ 281 | "inputs": [:], 282 | "query": prompt, 283 | "response_mode": "streaming", 284 | "conversation_id": "", 285 | "user": "xllama_user" 286 | ] 287 | 288 | var request = URLRequest(url: url) 289 | request.httpMethod = "POST" 290 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 291 | request.setValue("Bearer \(difyService.apiKey)", forHTTPHeaderField: "Authorization") 292 | request.httpBody = try JSONSerialization.data(withJSONObject: parameters) 293 | 294 | let (bytes, _) = try await URLSession.shared.bytes(for: request) 295 | 296 | struct DifyStreamResponse: Codable { 297 | let event: String? 298 | let message: Message? 299 | let answer: String? 300 | 301 | struct Message: Codable { 302 | let content: String? 303 | } 304 | } 305 | 306 | for try await line in bytes.lines { 307 | if isCancelled { 308 | await MainActor.run { 309 | isLoading = false 310 | } 311 | break 312 | } 313 | 314 | guard !line.isEmpty, line.hasPrefix("data: ") else { continue } 315 | 316 | let jsonString = String(line.dropFirst(6)) 317 | if let data = jsonString.data(using: .utf8), 318 | let response = try? JSONDecoder().decode(DifyStreamResponse.self, from: data) { 319 | 320 | let content = response.message?.content ?? response.answer ?? "" 321 | 322 | if !content.isEmpty { 323 | accumulatedResponse += content 324 | 325 | await MainActor.run { 326 | self.streamResponse = accumulatedResponse 327 | if var lastMessage = messages.last, !lastMessage.isUser { 328 | lastMessage.content = accumulatedResponse 329 | messages[messages.count - 1] = lastMessage 330 | objectWillChange.send() 331 | } 332 | } 333 | } 334 | 335 | if response.event == "message_end" || response.event == "[DONE]" { 336 | break 337 | } 338 | } 339 | } 340 | } 341 | 342 | private func handleStreamResponse(_ response: String) { 343 | DispatchQueue.main.async { 344 | self.streamResponse = response 345 | // 更新最后一条消息的内容 346 | if let lastIndex = self.messages.lastIndex(where: { !$0.isUser }) { 347 | self.messages[lastIndex].content = response 348 | } 349 | } 350 | } 351 | } -------------------------------------------------------------------------------- /xllama/Services/NetworkService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Network 3 | 4 | @MainActor 5 | class NetworkService: ObservableObject { 6 | static let shared = NetworkService() 7 | 8 | @Published var isRunning = false { 9 | didSet { 10 | // 当状态改变时保存到 UserDefaults 11 | UserDefaults.standard.set(isRunning, forKey: "network_server_status") 12 | } 13 | } 14 | 15 | @Published var serverURL: String = "" 16 | @Published var serverPort: UInt16 { 17 | didSet { 18 | UserDefaults.standard.set(serverPort, forKey: "network_server_port") 19 | // 如果服务正在运行,重启服务以应用新端口 20 | if isRunning { 21 | stopServer() 22 | startServer() 23 | } 24 | } 25 | } 26 | 27 | private var httpServer: HttpServer? 28 | 29 | init() { 30 | // 从 UserDefaults 读取上次的运行状态 31 | self.isRunning = UserDefaults.standard.bool(forKey: "network_server_status") 32 | 33 | // 从 UserDefaults 读取保存的端口,如果没有则使用默认值 8383 34 | self.serverPort = UInt16(UserDefaults.standard.integer(forKey: "network_server_port")) 35 | if self.serverPort == 0 { 36 | self.serverPort = 8383 37 | } 38 | 39 | // 如果之前是运行状态或设置了自动启动,则启动服务器 40 | if self.isRunning || UserDefaults.standard.bool(forKey: "network_server_autostart") { 41 | startServer() 42 | } 43 | } 44 | 45 | func startServer() { 46 | guard !isRunning else { return } 47 | 48 | httpServer = HttpServer() 49 | 50 | // 配置路由 51 | configureRoutes() 52 | 53 | do { 54 | guard let server = httpServer else { 55 | print("Server initialization failed") 56 | return 57 | } 58 | try server.start(port: serverPort) 59 | isRunning = true 60 | 61 | // 获取本机IP地址 62 | if let ipAddress = getLocalIPAddress() { 63 | serverURL = "http://\(ipAddress):\(serverPort)" 64 | } 65 | } catch { 66 | print("Server start error: \(error)") 67 | isRunning = false 68 | serverURL = "" 69 | } 70 | } 71 | 72 | func stopServer() { 73 | httpServer?.stop() 74 | isRunning = false 75 | serverURL = "" 76 | // 清除自动启动状态 77 | UserDefaults.standard.set(false, forKey: "network_server_autostart") 78 | } 79 | 80 | private func configureRoutes() { 81 | // 添加根路径处理 82 | httpServer?.get("/") { _, responseHandler in 83 | let html = """ 84 | 85 | 86 | 87 | XDOLLama Chat 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 714 | 715 | 716 |
717 |

XDOLLama Chat

718 |
719 | 724 | 730 |
731 |
732 | 733 |
734 | 735 |
736 |
737 | 738 | 739 |
740 |
741 | 742 | 743 | 770 | 771 | 1018 | 1019 | 1020 | """ 1021 | 1022 | let responseData = html.data(using: .utf8)! 1023 | responseHandler(.html(responseData)) 1024 | } 1025 | 1026 | // 添加 OPTIONS 请求支持 1027 | httpServer?.routes["OPTIONS /chat"] = { _, responseHandler in 1028 | var response = "HTTP/1.1 200 OK\r\n" 1029 | response += "Access-Control-Allow-Origin: *\r\n" 1030 | response += "Access-Control-Allow-Methods: POST, OPTIONS\r\n" 1031 | response += "Access-Control-Allow-Headers: Content-Type\r\n" 1032 | response += "Content-Length: 0\r\n" 1033 | response += "\r\n" 1034 | 1035 | let responseData = response.data(using: .utf8)! 1036 | responseHandler(.ok(responseData)) 1037 | } 1038 | 1039 | // 处理聊天请求 1040 | httpServer?.post("/chat") { request, responseHandler in 1041 | Task { 1042 | do { 1043 | guard let data = request.body, 1044 | let chatRequest = try? JSONDecoder().decode(ChatRequest.self, from: data) else { 1045 | print("Failed to decode chat request") 1046 | responseHandler(.badRequest) 1047 | return 1048 | } 1049 | 1050 | print("Received chat request: \(chatRequest)") 1051 | 1052 | var response: String = "" 1053 | do { 1054 | switch chatRequest.modelType { 1055 | case .ollama: 1056 | response = try await self.forwardToOllama(chatRequest.message, model: chatRequest.model) 1057 | case .xinference: 1058 | response = try await self.forwardToXinference(chatRequest.message, model: chatRequest.model) 1059 | case .dify: 1060 | response = try await self.forwardToDify(chatRequest.message, model: chatRequest.model) 1061 | } 1062 | 1063 | print("Got response: \(response)") 1064 | 1065 | let responseData = ChatResponse(response: response) 1066 | let jsonData = try JSONEncoder().encode(responseData) 1067 | responseHandler(.ok(jsonData)) 1068 | } catch { 1069 | print("Error forwarding request: \(error)") 1070 | // 返回具体的错误信息 1071 | let errorResponse = ChatResponse(response: "Error: \(error.localizedDescription)") 1072 | if let errorData = try? JSONEncoder().encode(errorResponse) { 1073 | responseHandler(.ok(errorData)) 1074 | } else { 1075 | responseHandler(.internalServerError) 1076 | } 1077 | } 1078 | } catch { 1079 | print("Error handling request: \(error)") 1080 | responseHandler(.internalServerError) 1081 | } 1082 | } 1083 | } 1084 | 1085 | // 获取可用模型列表 1086 | httpServer?.get("/models") { _, responseHandler in 1087 | Task { 1088 | do { 1089 | let models = NetworkModelsResponse( 1090 | ollama: try await OllamaService.shared.fetchModels(), 1091 | xinference: XinferenceService.shared.models, 1092 | dify: DifyService.shared.models 1093 | ) 1094 | let jsonData = try JSONEncoder().encode(models) 1095 | responseHandler(.ok(jsonData)) 1096 | } catch { 1097 | responseHandler(.internalServerError) 1098 | } 1099 | } 1100 | } 1101 | } 1102 | 1103 | private func getLocalIPAddress() -> String? { 1104 | var address: String? 1105 | var ifaddr: UnsafeMutablePointer? 1106 | 1107 | guard getifaddrs(&ifaddr) == 0 else { 1108 | return nil 1109 | } 1110 | defer { freeifaddrs(ifaddr) } 1111 | 1112 | var ptr = ifaddr 1113 | while ptr != nil { 1114 | defer { ptr = ptr?.pointee.ifa_next } 1115 | 1116 | let interface = ptr?.pointee 1117 | let addrFamily = interface?.ifa_addr.pointee.sa_family 1118 | 1119 | if addrFamily == UInt8(AF_INET) { 1120 | let name = String(cString: (interface?.ifa_name)!) 1121 | if name == "en0" || name == "en1" || name == "en2" || name == "en3" || name == "en4" || name == "en5" { 1122 | var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) 1123 | getnameinfo(interface?.ifa_addr, 1124 | socklen_t((interface?.ifa_addr.pointee.sa_len)!), 1125 | &hostname, 1126 | socklen_t(hostname.count), 1127 | nil, 1128 | 0, 1129 | NI_NUMERICHOST) 1130 | address = String(cString: hostname) 1131 | } 1132 | } 1133 | } 1134 | return address 1135 | } 1136 | 1137 | private func forwardToOllama(_ message: String, model: String) async throws -> String { 1138 | guard let url = URL(string: "\(OllamaService.shared.baseURL)/api/chat") else { 1139 | throw URLError(.badURL) 1140 | } 1141 | 1142 | let parameters: [String: Any] = [ 1143 | "model": model, 1144 | "messages": [ 1145 | ["role": "user", "content": message] 1146 | ], 1147 | "stream": false 1148 | ] 1149 | 1150 | var request = URLRequest(url: url) 1151 | request.httpMethod = "POST" 1152 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 1153 | request.httpBody = try JSONSerialization.data(withJSONObject: parameters) 1154 | 1155 | print("Sending request to Ollama: \(parameters)") 1156 | 1157 | let (data, _) = try await URLSession.shared.data(for: request) 1158 | 1159 | if let responseString = String(data: data, encoding: .utf8) { 1160 | print("Ollama response: \(responseString)") 1161 | } 1162 | 1163 | if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], 1164 | let message = json["message"] as? [String: Any], 1165 | let content = message["content"] as? String { 1166 | return content 1167 | } else { 1168 | throw URLError(.cannotParseResponse) 1169 | } 1170 | } 1171 | 1172 | private func forwardToXinference(_ message: String, model: String) async throws -> String { 1173 | guard let url = URL(string: "\(XinferenceService.shared.baseURL)/v1/chat/completions") else { 1174 | throw URLError(.badURL) 1175 | } 1176 | 1177 | let parameters: [String: Any] = [ 1178 | "model": model, 1179 | "messages": [ 1180 | ["role": "user", "content": message] 1181 | ], 1182 | "stream": false 1183 | ] 1184 | 1185 | var request = URLRequest(url: url) 1186 | request.httpMethod = "POST" 1187 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 1188 | request.httpBody = try JSONSerialization.data(withJSONObject: parameters) 1189 | 1190 | let (data, _) = try await URLSession.shared.data(for: request) 1191 | 1192 | struct XinferenceResponse: Codable { 1193 | let choices: [Choice] 1194 | 1195 | struct Choice: Codable { 1196 | let message: Message 1197 | 1198 | struct Message: Codable { 1199 | let content: String 1200 | } 1201 | } 1202 | } 1203 | 1204 | let response = try JSONDecoder().decode(XinferenceResponse.self, from: data) 1205 | return response.choices.first?.message.content ?? "" 1206 | } 1207 | 1208 | private func forwardToDify(_ message: String, model: String) async throws -> String { 1209 | guard let url = URL(string: "\(DifyService.shared.baseURL)/chat-messages"), 1210 | !DifyService.shared.apiKey.isEmpty else { 1211 | throw URLError(.badURL) 1212 | } 1213 | 1214 | let parameters: [String: Any] = [ 1215 | "inputs": [:], 1216 | "query": message, 1217 | "response_mode": "blocking", 1218 | "conversation_id": "", 1219 | "user": "xllama_user", 1220 | "model": model 1221 | ] 1222 | 1223 | var request = URLRequest(url: url) 1224 | request.httpMethod = "POST" 1225 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 1226 | request.setValue("Bearer \(DifyService.shared.apiKey)", forHTTPHeaderField: "Authorization") 1227 | request.httpBody = try JSONSerialization.data(withJSONObject: parameters) 1228 | 1229 | let (data, _) = try await URLSession.shared.data(for: request) 1230 | 1231 | struct DifyResponse: Codable { 1232 | let answer: String 1233 | } 1234 | 1235 | let response = try JSONDecoder().decode(DifyResponse.self, from: data) 1236 | return response.answer 1237 | } 1238 | } 1239 | 1240 | // 请求和响应模型 1241 | struct ChatRequest: Codable { 1242 | enum ModelType: String, Codable { 1243 | case ollama 1244 | case xinference 1245 | case dify 1246 | } 1247 | 1248 | let modelType: ModelType 1249 | let model: String 1250 | let message: String 1251 | } 1252 | 1253 | struct ChatResponse: Codable { 1254 | let response: String 1255 | } 1256 | 1257 | // 修改 ModelsResponse 为 NetworkModelsResponse 1258 | struct NetworkModelsResponse: Codable { 1259 | let ollama: [OllamaModel] 1260 | let xinference: [XinferenceModel] 1261 | let dify: [DifyModel] 1262 | } 1263 | 1264 | // 简单的 HTTP 服务器实现 1265 | class HttpServer { 1266 | private var listener: NWListener? 1267 | var routes: [String: (HttpRequest, @escaping (HttpResponse) -> Void) -> Void] = [:] 1268 | 1269 | func start(port: UInt16) throws { 1270 | let parameters = NWParameters.tcp 1271 | let nwPort = NWEndpoint.Port(rawValue: port)! 1272 | listener = try NWListener(using: parameters, on: nwPort) 1273 | 1274 | listener?.stateUpdateHandler = { [weak self] state in 1275 | switch state { 1276 | case .ready: 1277 | print("Server ready on port \(port)") 1278 | case .failed(let error): 1279 | print("Server failed with error: \(error)") 1280 | self?.stop() 1281 | default: 1282 | break 1283 | } 1284 | } 1285 | 1286 | listener?.newConnectionHandler = { [weak self] connection in 1287 | self?.handleConnection(connection) 1288 | } 1289 | 1290 | listener?.start(queue: .main) 1291 | } 1292 | 1293 | func stop() { 1294 | listener?.cancel() 1295 | } 1296 | 1297 | func post(_ path: String, handler: @escaping (HttpRequest, @escaping (HttpResponse) -> Void) -> Void) { 1298 | routes["POST " + path] = handler 1299 | } 1300 | 1301 | func get(_ path: String, handler: @escaping (HttpRequest, @escaping (HttpResponse) -> Void) -> Void) { 1302 | routes["GET " + path] = handler 1303 | } 1304 | 1305 | private func handleConnection(_ connection: NWConnection) { 1306 | connection.stateUpdateHandler = { [weak self] state in 1307 | switch state { 1308 | case .ready: 1309 | print("Connection ready") 1310 | self?.receiveRequest(connection) 1311 | case .failed(let error): 1312 | print("Connection failed: \(error)") 1313 | connection.cancel() 1314 | case .cancelled: 1315 | print("Connection cancelled") 1316 | default: 1317 | break 1318 | } 1319 | } 1320 | 1321 | connection.start(queue: .main) 1322 | } 1323 | 1324 | private func receiveRequest(_ connection: NWConnection) { 1325 | connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, error in 1326 | if let error = error { 1327 | print("Receive error: \(error)") 1328 | connection.cancel() 1329 | return 1330 | } 1331 | 1332 | guard let data = content, !data.isEmpty else { 1333 | if isComplete { 1334 | connection.cancel() 1335 | } 1336 | return 1337 | } 1338 | 1339 | // 解析 HTTP 请求 1340 | if let requestString = String(data: data, encoding: .utf8) { 1341 | let components = requestString.components(separatedBy: "\r\n\r\n") 1342 | let headerPart = components[0] 1343 | let bodyPart = components.count > 1 ? components[1] : nil 1344 | 1345 | let headerLines = headerPart.components(separatedBy: "\r\n") 1346 | if let requestLine = headerLines.first { 1347 | let parts = requestLine.components(separatedBy: " ") 1348 | if parts.count >= 2 { 1349 | let method = parts[0] 1350 | let path = parts[1] 1351 | 1352 | // 解析请头 1353 | var headers: [String: String] = [:] 1354 | for line in headerLines.dropFirst() { 1355 | let headerParts = line.split(separator: ":", maxSplits: 1).map(String.init) 1356 | if headerParts.count == 2 { 1357 | headers[headerParts[0].trimmingCharacters(in: .whitespaces)] = 1358 | headerParts[1].trimmingCharacters(in: .whitespaces) 1359 | } 1360 | } 1361 | 1362 | // 构建请求对 1363 | let request = HttpRequest( 1364 | method: method, 1365 | path: path, 1366 | headers: headers, 1367 | body: bodyPart?.data(using: .utf8) 1368 | ) 1369 | 1370 | // 查找并执行对应的由处理器 1371 | let routeKey = "\(method) \(path)" 1372 | if let handler = self?.routes[routeKey] { 1373 | handler(request) { response in 1374 | // 构建 HTTP 响应 1375 | var responseString = "HTTP/1.1 \(response.statusCode)\r\n" 1376 | responseString += "Content-Type: \(response.contentType)\r\n" 1377 | 1378 | switch response { 1379 | case .ok(let data), .html(let data): 1380 | responseString += "Content-Length: \(data.count)\r\n\r\n" 1381 | let responseData = responseString.data(using: .utf8)! + data 1382 | 1383 | connection.send(content: responseData, completion: .idempotent) 1384 | case .badRequest, .notFound, .internalServerError: 1385 | responseString += "Content-Length: 0\r\n\r\n" 1386 | let responseData = responseString.data(using: .utf8)! 1387 | 1388 | connection.send(content: responseData, completion: .idempotent) 1389 | } 1390 | } 1391 | } else { 1392 | // 处理 404 Not Found 1393 | let notFoundResponse = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n" 1394 | let responseData = notFoundResponse.data(using: .utf8)! 1395 | connection.send(content: responseData, completion: .idempotent) 1396 | } 1397 | } 1398 | } 1399 | } 1400 | 1401 | // 继续接收数据 1402 | if !isComplete { 1403 | self?.receiveRequest(connection) 1404 | } else { 1405 | connection.cancel() 1406 | } 1407 | } 1408 | } 1409 | } 1410 | 1411 | // HTTP 请求和响应型 1412 | struct HttpRequest { 1413 | let method: String 1414 | let path: String 1415 | let headers: [String: String] 1416 | let body: Data? 1417 | } 1418 | 1419 | enum HttpResponse { 1420 | case ok(Data) 1421 | case html(Data) 1422 | case badRequest 1423 | case notFound 1424 | case internalServerError 1425 | 1426 | var statusCode: Int { 1427 | switch self { 1428 | case .ok, .html: return 200 1429 | case .badRequest: return 400 1430 | case .notFound: return 404 1431 | case .internalServerError: return 500 1432 | } 1433 | } 1434 | 1435 | var contentType: String { 1436 | switch self { 1437 | case .html: return "text/html; charset=utf-8" 1438 | default: return "application/json" 1439 | } 1440 | } 1441 | } -------------------------------------------------------------------------------- /xllama/Services/OllamaService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // 修改数据模型以匹配 Ollama API 的实际返回格式 4 | struct OllamaResponse: Codable { 5 | let models: [OllamaModel] 6 | } 7 | 8 | struct OllamaModel: Codable, Identifiable { 9 | let name: String 10 | 11 | // 这些字段在新版本可能不存在,设为可选 12 | let size: Int64? 13 | let digest: String? 14 | let modified_at: String? 15 | 16 | var id: String { name } 17 | 18 | // 添加自定义解码以处理不同的 JSON 格式 19 | enum CodingKeys: String, CodingKey { 20 | case name 21 | case size 22 | case digest 23 | case modified_at 24 | } 25 | 26 | init(from decoder: Decoder) throws { 27 | let container = try decoder.container(keyedBy: CodingKeys.self) 28 | name = try container.decode(String.self, forKey: .name) 29 | size = try container.decodeIfPresent(Int64.self, forKey: .size) 30 | digest = try container.decodeIfPresent(String.self, forKey: .digest) 31 | modified_at = try container.decodeIfPresent(String.self, forKey: .modified_at) 32 | } 33 | } 34 | 35 | @MainActor 36 | class OllamaService: ObservableObject { 37 | static let shared = OllamaService() 38 | 39 | @Published var baseURL: String { 40 | didSet { 41 | UserDefaults.standard.set(baseURL, forKey: "ollama_base_url") 42 | } 43 | } 44 | 45 | @Published var selectedModel: String { 46 | didSet { 47 | UserDefaults.standard.set(selectedModel, forKey: "ollama_selected_model") 48 | } 49 | } 50 | 51 | @Published var maxConversationTurns: Int { 52 | didSet { 53 | UserDefaults.standard.set(maxConversationTurns, forKey: "ollama_max_turns") 54 | } 55 | } 56 | 57 | init() { 58 | self.baseURL = UserDefaults.standard.string(forKey: "ollama_base_url") ?? "http://localhost:11434" 59 | self.selectedModel = UserDefaults.standard.string(forKey: "ollama_selected_model") ?? "" 60 | self.maxConversationTurns = UserDefaults.standard.integer(forKey: "ollama_max_turns") != 0 61 | ? UserDefaults.standard.integer(forKey: "ollama_max_turns") 62 | : 5 // 默认5轮对话 63 | } 64 | 65 | // 构建历史对话消息 66 | func buildConversationContext(_ messages: [ChatMessage]) -> [[String: String]] { 67 | let maxTurns = maxConversationTurns 68 | let recentMessages = messages.count > maxTurns * 2 69 | ? Array(messages.suffix(maxTurns * 2)) 70 | : messages 71 | 72 | return recentMessages.map { message in 73 | [ 74 | "role": message.isUser ? "user" : "assistant", 75 | "content": message.content 76 | ] 77 | } 78 | } 79 | 80 | func fetchModels() async throws -> [OllamaModel] { 81 | guard let url = URL(string: "\(baseURL)/api/tags") else { 82 | throw URLError(.badURL) 83 | } 84 | 85 | var request = URLRequest(url: url) 86 | request.httpMethod = "GET" 87 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 88 | 89 | do { 90 | let (data, response) = try await URLSession.shared.data(for: request) 91 | 92 | guard let httpResponse = response as? HTTPURLResponse else { 93 | throw URLError(.badServerResponse) 94 | } 95 | 96 | if httpResponse.statusCode != 200 { 97 | throw URLError(.badServerResponse) 98 | } 99 | 100 | // 打印返回的 JSON 数据,用于调试 101 | if let jsonString = String(data: data, encoding: .utf8) { 102 | print("Received JSON:", jsonString) 103 | } 104 | 105 | // 尝试解析返回的 JSON 数据 106 | let decoder = JSONDecoder() 107 | if let response = try? decoder.decode(OllamaResponse.self, from: data) { 108 | return response.models 109 | } else { 110 | // 如果上面的格式不匹配,尝试直接析为模型数组 111 | return try decoder.decode([OllamaModel].self, from: data) 112 | } 113 | 114 | } catch { 115 | print("Error fetching models:", error) 116 | throw error 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /xllama/Services/chat_view.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | XDOLLama Chat 5 | 6 | 7 | 542 | 543 | 544 |
545 |

XDOLLama Chat

546 | 552 |
553 | 554 |
555 | 556 |
557 |
558 | 559 | 560 |
561 |
562 | 563 | 564 | 591 | 592 | 807 | 808 | -------------------------------------------------------------------------------- /xllama/Views/ChatHistoryView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // 添加 VisualEffectView 4 | struct VisualEffectView: NSViewRepresentable { 5 | func makeNSView(context: Context) -> NSVisualEffectView { 6 | let view = NSVisualEffectView() 7 | view.blendingMode = .behindWindow 8 | view.state = .active 9 | view.material = .sidebar 10 | return view 11 | } 12 | 13 | func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} 14 | } 15 | 16 | struct ChatHistoryView: View { 17 | @StateObject private var chatHistoryManager = ChatHistoryManager.shared 18 | @State private var showDeleteConfirmation: Bool = false 19 | @State private var conversationToDelete: UUID? 20 | 21 | var body: some View { 22 | ZStack { 23 | // 背景模糊效果 24 | VisualEffectView() 25 | 26 | ScrollView { 27 | LazyVStack(spacing: 8) { 28 | ForEach(chatHistoryManager.conversations) { conversation in 29 | ConversationCard( 30 | conversation: conversation, 31 | isSelected: chatHistoryManager.currentConversationId == conversation.id, 32 | onTap: { 33 | chatHistoryManager.switchToConversation(conversation.id) 34 | }, 35 | onDelete: { 36 | conversationToDelete = conversation.id 37 | showDeleteConfirmation = true 38 | } 39 | ) 40 | } 41 | } 42 | .padding(.vertical, 8) 43 | .padding(.horizontal, 6) 44 | } 45 | } 46 | .frame(minWidth: 250) 47 | .background(Color.clear) 48 | .confirmationDialog("确认删除对话?", 49 | isPresented: $showDeleteConfirmation, 50 | titleVisibility: .visible) { 51 | Button("删除", role: .destructive) { 52 | if let id = conversationToDelete { 53 | chatHistoryManager.deleteConversation(id) 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | struct ConversationCard: View { 61 | let conversation: ChatHistoryManager.Conversation 62 | let isSelected: Bool 63 | let onTap: () -> Void 64 | let onDelete: () -> Void 65 | @State private var isHovered = false 66 | 67 | var body: some View { 68 | Button(action: onTap) { 69 | HStack(spacing: 8) { 70 | // 对话图标 71 | Image(systemName: "message") 72 | .font(.system(size: 14)) 73 | .foregroundColor(isSelected ? .white : .secondary) 74 | .frame(width: 16) 75 | 76 | // 对话标题 77 | Text(conversation.title) 78 | .lineLimit(1) 79 | .truncationMode(.tail) 80 | .font(.system(size: 13)) 81 | .foregroundColor(isSelected ? .white : .primary) 82 | 83 | Spacer(minLength: 0) 84 | 85 | HStack(spacing: 4) { 86 | // 删除按钮 - 固定位置 87 | if isHovered { 88 | Button(action: onDelete) { 89 | Image(systemName: "trash") 90 | .font(.system(size: 12)) 91 | .foregroundColor(isSelected ? .white.opacity(0.7) : .secondary) 92 | } 93 | .buttonStyle(PlainButtonStyle()) 94 | .transition(.opacity) 95 | .frame(width: 20) 96 | } 97 | 98 | // 消息数量指示 99 | if !conversation.messages.isEmpty { 100 | Text("\(conversation.messages.count)") 101 | .font(.system(size: 12)) 102 | .padding(.horizontal, 6) 103 | .padding(.vertical, 2) 104 | .background( 105 | Capsule() 106 | .fill(isSelected ? Color.white.opacity(0.2) : Color.secondary.opacity(0.2)) 107 | ) 108 | .foregroundColor(isSelected ? .white : .secondary) 109 | } 110 | } 111 | } 112 | .padding(.horizontal, 12) 113 | .padding(.vertical, 8) 114 | .frame(maxWidth: .infinity, alignment: .leading) 115 | .contentShape(Rectangle()) 116 | .background( 117 | RoundedRectangle(cornerRadius: 8) 118 | .fill(isSelected ? Color.accentColor : Color(.controlBackgroundColor)) 119 | ) 120 | .overlay( 121 | RoundedRectangle(cornerRadius: 8) 122 | .stroke(Color.secondary.opacity(0.2), lineWidth: isSelected ? 0 : 1) 123 | ) 124 | } 125 | .buttonStyle(PlainButtonStyle()) 126 | .onHover { hovering in 127 | withAnimation(.easeInOut(duration: 0.2)) { 128 | isHovered = hovering 129 | } 130 | } 131 | } 132 | } 133 | 134 | @MainActor 135 | class ChatHistoryManager: ObservableObject { 136 | static let shared = ChatHistoryManager() 137 | 138 | struct Conversation: Identifiable, Codable { 139 | let id: UUID 140 | var title: String 141 | var messages: [ChatMessage] 142 | var timestamp: Date 143 | var needsTitleUpdate: Bool 144 | 145 | init(title: String, messages: [ChatMessage], id: UUID = UUID()) { 146 | self.id = id 147 | self.title = title 148 | self.messages = messages 149 | self.timestamp = Date() 150 | self.needsTitleUpdate = false 151 | } 152 | 153 | enum CodingKeys: String, CodingKey { 154 | case id, title, messages, timestamp, needsTitleUpdate 155 | } 156 | } 157 | 158 | @Published var conversations: [Conversation] = [] 159 | @Published var currentConversationId: UUID? 160 | @Published var currentMessages: [ChatMessage] = [] 161 | 162 | private let ollamaService = OllamaService.shared 163 | private let conversationArchiveKey = "savedConversations" 164 | 165 | // 添加一个标志,表示是否允许自动创建对话 166 | @Published var canAutoCreateConversation = false 167 | 168 | init() { 169 | ChatService.shared.setHistoryManager(self) 170 | loadSavedConversations() 171 | 172 | // 如果没有对话,创建新对话 173 | if conversations.isEmpty { 174 | createNewConversation(autoCreated: true) 175 | } 176 | } 177 | 178 | // 保存对话到本地 179 | private func saveConversations() { 180 | do { 181 | let encodedData = try JSONEncoder().encode(conversations) 182 | UserDefaults.standard.set(encodedData, forKey: conversationArchiveKey) 183 | } catch { 184 | print("Error saving conversations: \(error)") 185 | } 186 | } 187 | 188 | // 加载本地保存的对话 189 | private func loadSavedConversations() { 190 | guard let savedData = UserDefaults.standard.data(forKey: conversationArchiveKey) else { 191 | return 192 | } 193 | 194 | do { 195 | conversations = try JSONDecoder().decode([Conversation].self, from: savedData) 196 | } catch { 197 | print("Error loading conversations: \(error)") 198 | } 199 | } 200 | 201 | // 删除对话 202 | func deleteConversation(_ id: UUID) { 203 | conversations.removeAll { $0.id == id } 204 | 205 | // 如果删除的是当前对话,切换到第一个对话或创建新对话 206 | if currentConversationId == id { 207 | if let firstConversation = conversations.first { 208 | switchToConversation(firstConversation.id) 209 | } else { 210 | createNewConversation() 211 | } 212 | } 213 | 214 | saveConversations() 215 | } 216 | 217 | // 重写现有方法以支持持久化 218 | func createNewConversation(autoCreated: Bool = false) { 219 | let newConversation = Conversation(title: "新对话", messages: []) 220 | conversations.insert(newConversation, at: 0) 221 | currentConversationId = newConversation.id 222 | currentMessages = [] 223 | ChatService.shared.clearMessages() 224 | 225 | // 只有手动创建或第一次初始化时才保存 226 | if !autoCreated { 227 | canAutoCreateConversation = true 228 | } 229 | 230 | saveConversations() 231 | } 232 | 233 | func updateCurrentConversation(messages: [ChatMessage]) { 234 | guard let currentId = currentConversationId, 235 | let index = conversations.firstIndex(where: { $0.id == currentId }) 236 | else { return } 237 | 238 | conversations[index].messages = messages 239 | 240 | if conversations[index].title == "新对话" && messages.count >= 4 { 241 | conversations[index].needsTitleUpdate = true 242 | Task { 243 | await generateTitle(for: index) 244 | } 245 | } 246 | 247 | saveConversations() 248 | } 249 | 250 | func switchToConversation(_ id: UUID) { 251 | if id == currentConversationId { 252 | return 253 | } 254 | 255 | if let currentIndex = conversations.firstIndex(where: { $0.id == currentConversationId }), 256 | conversations[currentIndex].needsTitleUpdate { 257 | Task { 258 | await generateTitle(for: currentIndex) 259 | } 260 | } 261 | 262 | if let targetIndex = conversations.firstIndex(where: { $0.id == id }) { 263 | currentConversationId = id 264 | currentMessages = conversations[targetIndex].messages 265 | ChatService.shared.loadMessages(conversations[targetIndex].messages) 266 | } 267 | } 268 | 269 | private func generateTitle(for conversationIndex: Int) async { 270 | let messages = conversations[conversationIndex].messages 271 | guard messages.count >= 4 else { return } 272 | 273 | let prompt = """ 274 | 请根据以下对话生成一个简短的标题(不超过15个字): 275 | 276 | 用户:\(messages[0].content) 277 | AI:\(messages[1].content) 278 | 用户:\(messages[2].content) 279 | AI:\(messages[3].content) 280 | 281 | 只需要返回标题,不要其他任何内容。 282 | """ 283 | 284 | do { 285 | guard let url = URL(string: "\(ollamaService.baseURL)/api/generate") else { 286 | return 287 | } 288 | 289 | let parameters: [String: Any] = [ 290 | "model": ollamaService.selectedModel, 291 | "prompt": prompt, 292 | "stream": false 293 | ] 294 | 295 | var request = URLRequest(url: url) 296 | request.httpMethod = "POST" 297 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 298 | request.httpBody = try JSONSerialization.data(withJSONObject: parameters) 299 | 300 | struct GenerateResponse: Codable { 301 | let response: String 302 | } 303 | 304 | let (data, _) = try await URLSession.shared.data(for: request) 305 | let response = try JSONDecoder().decode(GenerateResponse.self, from: data) 306 | 307 | let title = response.response.trimmingCharacters(in: .whitespacesAndNewlines) 308 | 309 | await MainActor.run { 310 | if !title.isEmpty { 311 | conversations[conversationIndex].title = title 312 | conversations[conversationIndex].needsTitleUpdate = false 313 | } 314 | } 315 | 316 | } catch { 317 | print("Error generating title:", error) 318 | } 319 | } 320 | 321 | func sendMessage(_ content: String) async { 322 | // 如果是第一条消息且未自动创建对话 323 | if !canAutoCreateConversation && currentMessages.isEmpty { 324 | createNewConversation() 325 | } 326 | } 327 | } -------------------------------------------------------------------------------- /xllama/Views/ChatView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import MarkdownUI 3 | 4 | struct ChatView: View { 5 | @StateObject private var chatService = ChatService.shared 6 | @StateObject private var ollamaService = OllamaService.shared 7 | @StateObject private var xinferenceService = XinferenceService.shared 8 | @StateObject private var chatHistoryManager = ChatHistoryManager.shared 9 | @StateObject private var difyService = DifyService.shared 10 | @State private var messageText = "" 11 | @State private var ollamaModels: [OllamaModel] = [] 12 | @State private var selectedModelType: ModelType = .ollama 13 | @FocusState private var isInputFocused: Bool 14 | @State private var shouldScrollToBottom = false 15 | 16 | enum ModelType: String { 17 | case ollama = "Ollama" 18 | case xinference = "Xinference" 19 | case dify = "Dify" 20 | } 21 | 22 | var body: some View { 23 | VStack(spacing: 0) { 24 | // 模型选择器 25 | HStack { 26 | Picker("服务", selection: $selectedModelType) { 27 | Text(ModelType.ollama.rawValue).tag(ModelType.ollama) 28 | Text(ModelType.xinference.rawValue).tag(ModelType.xinference) 29 | Text(ModelType.dify.rawValue).tag(ModelType.dify) 30 | } 31 | .frame(width: 120) 32 | 33 | Picker("模型", selection: Binding( 34 | get: { 35 | switch selectedModelType { 36 | case .ollama: return ollamaService.selectedModel 37 | case .xinference: return xinferenceService.selectedModel 38 | case .dify: return difyService.selectedModel 39 | } 40 | }, 41 | set: { newValue in 42 | Task { @MainActor in 43 | switch selectedModelType { 44 | case .ollama: ollamaService.selectedModel = newValue 45 | case .xinference: xinferenceService.selectedModel = newValue 46 | case .dify: difyService.selectedModel = newValue 47 | } 48 | } 49 | } 50 | )) { 51 | Text("选择模型").tag("") 52 | if selectedModelType == .ollama { 53 | ForEach(ollamaModels) { model in 54 | Text(model.name).tag(model.name) 55 | } 56 | } else if selectedModelType == .xinference { 57 | ForEach(xinferenceService.models.filter { $0.isAvailable }) { model in 58 | Text(model.name).tag(model.name) 59 | } 60 | } else { 61 | ForEach(DifyService.shared.models) { model in 62 | Text(model.name).tag(model.name) 63 | } 64 | } 65 | } 66 | .frame(width: 200) 67 | .disabled(chatService.isLoading) 68 | 69 | Spacer() 70 | } 71 | .padding(.horizontal) 72 | .padding(.vertical, 8) 73 | 74 | // 聊天消息列表 75 | ScrollView { 76 | ScrollViewReader { proxy in 77 | LazyVStack(spacing: 20) { 78 | ForEach(chatService.messages) { message in 79 | MessageBubble(message: message) 80 | .id(message.id) 81 | .transition(.opacity) 82 | } 83 | 84 | if chatService.isLoading { 85 | LoadingDots() 86 | .padding() 87 | .id("loading") 88 | } 89 | 90 | // 底部锚点 91 | Color.clear 92 | .frame(height: 1) 93 | .id("bottom") 94 | } 95 | .padding(.horizontal, 50) 96 | .padding(.vertical, 20) 97 | .onChange(of: chatService.messages) { _, _ in 98 | shouldScrollToBottom = true 99 | scrollToBottom(proxy: proxy) 100 | } 101 | .onChange(of: chatService.streamResponse) { _, _ in 102 | shouldScrollToBottom = true 103 | scrollToBottom(proxy: proxy) 104 | } 105 | .onChange(of: messageText) { _, _ in 106 | shouldScrollToBottom = true 107 | scrollToBottom(proxy: proxy) 108 | } 109 | .onAppear { 110 | shouldScrollToBottom = true 111 | scrollToBottom(proxy: proxy) 112 | } 113 | } 114 | } 115 | .onChange(of: shouldScrollToBottom) { _, newValue in 116 | if newValue { 117 | DispatchQueue.main.async { 118 | withAnimation { 119 | shouldScrollToBottom = false 120 | } 121 | } 122 | } 123 | } 124 | 125 | // 消息输入框 126 | HStack(spacing: 12) { 127 | TextEditor(text: $messageText) 128 | .font(.body) 129 | .padding(.horizontal, 8) 130 | .padding(.vertical, 8) 131 | .frame(height: min(100, max(36, messageText.isEmpty ? 36 : messageText.height(withConstrainedWidth: 1000) + 16))) 132 | .background(Color(.textBackgroundColor)) 133 | .cornerRadius(8) 134 | .disabled(chatService.isLoading) 135 | .focused($isInputFocused) 136 | .background( 137 | GeometryReader { geometry in 138 | Color.clear 139 | .onAppear { 140 | NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in 141 | if event.keyCode == 36 { // 回车键的键码 142 | if !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !chatService.isLoading { 143 | sendMessage() 144 | return nil 145 | } 146 | } 147 | return event 148 | } 149 | } 150 | } 151 | ) 152 | 153 | Button(action: { 154 | if chatService.isLoading { 155 | chatService.cancelGeneration() 156 | } else { 157 | sendMessage() 158 | } 159 | }) { 160 | Circle() 161 | .fill(chatService.isLoading ? Color.black : (messageText.isEmpty ? Color.gray.opacity(0.3) : Color.black)) 162 | .frame(width: 32, height: 32) 163 | .overlay( 164 | Image(systemName: chatService.isLoading ? "stop.fill" : "arrow.up") 165 | .font(.system(size: 14, weight: .medium)) 166 | .foregroundColor(.white) 167 | ) 168 | } 169 | .disabled(messageText.isEmpty && !chatService.isLoading) 170 | .buttonStyle(PlainButtonStyle()) 171 | } 172 | .padding(12) 173 | .background(Color(.windowBackgroundColor)) 174 | .overlay( 175 | Rectangle() 176 | .frame(height: 1) 177 | .foregroundColor(Color.gray.opacity(0.2)), 178 | alignment: .top 179 | ) 180 | } 181 | .onAppear { 182 | isInputFocused = true 183 | Task { 184 | do { 185 | // 获取 Ollama 模型列表 186 | ollamaModels = try await ollamaService.fetchModels() 187 | // 获取 Xinference 模型列表 188 | try await xinferenceService.fetchModels() 189 | // 获取 Dify 模型列表 190 | try await DifyService.shared.fetchModels() 191 | } catch { 192 | print("Error fetching models:", error) 193 | } 194 | } 195 | } 196 | .onChange(of: chatService.isLoading) { _, newValue in 197 | if !newValue { 198 | isInputFocused = true 199 | } 200 | } 201 | .onSubmit { 202 | if !messageText.isEmpty && !chatService.isLoading { 203 | sendMessage() 204 | } 205 | } 206 | .submitLabel(.send) 207 | } 208 | 209 | private func sendMessage() { 210 | guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } 211 | let content = messageText.trimmingCharacters(in: .whitespacesAndNewlines) 212 | messageText = "" 213 | 214 | Task { @MainActor in 215 | switch selectedModelType { 216 | case .ollama: 217 | await chatService.sendMessage(content) 218 | case .xinference: 219 | await chatService.sendXinferenceMessage(content) 220 | case .dify: 221 | await chatService.sendDifyMessage(content) 222 | } 223 | } 224 | } 225 | 226 | // 修改滚动辅助函数 227 | private func scrollToBottom(proxy: ScrollViewProxy) { 228 | // 使用 asyncAfter 确保在内容更新后滚动 229 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 230 | withAnimation(.easeOut(duration: 0.2)) { 231 | proxy.scrollTo("bottom", anchor: .bottom) 232 | } 233 | } 234 | } 235 | } 236 | 237 | struct LoadingDots: View { 238 | @State private var dotOpacity1: Double = 0.3 239 | @State private var dotOpacity2: Double = 0.3 240 | @State private var dotOpacity3: Double = 0.3 241 | 242 | var body: some View { 243 | HStack(spacing: 4) { 244 | Circle() 245 | .frame(width: 6, height: 6) 246 | .opacity(dotOpacity1) 247 | Circle() 248 | .frame(width: 6, height: 6) 249 | .opacity(dotOpacity2) 250 | Circle() 251 | .frame(width: 6, height: 6) 252 | .opacity(dotOpacity3) 253 | } 254 | .foregroundColor(.gray) 255 | .onAppear { 256 | let animation = Animation.easeInOut(duration: 0.4).repeatForever() 257 | withAnimation(animation.delay(0.0)) { 258 | dotOpacity1 = 1 259 | } 260 | withAnimation(animation.delay(0.2)) { 261 | dotOpacity2 = 1 262 | } 263 | withAnimation(animation.delay(0.4)) { 264 | dotOpacity3 = 1 265 | } 266 | } 267 | } 268 | } 269 | 270 | // 在 CodeBlockView 前添加代码高亮相关的结构体 271 | struct CodeHighlighter { 272 | struct Token { 273 | let text: String 274 | let type: TokenType 275 | } 276 | 277 | enum TokenType { 278 | case keyword 279 | case string 280 | case number 281 | case comment 282 | case `operator` 283 | case identifier 284 | case type 285 | case plain 286 | 287 | var color: Color { 288 | switch self { 289 | case .keyword: return .blue 290 | case .string: return .green 291 | case .number: return .orange 292 | case .comment: return .gray 293 | case .operator: return .purple 294 | case .identifier: return .primary 295 | case .type: return Color(red: 0.8, green: 0.2, blue: 0.2) 296 | case .plain: return .primary 297 | } 298 | } 299 | } 300 | 301 | static func highlightCode(_ code: String, language: String) -> AttributedString { 302 | var attributedString = AttributedString(code) 303 | 304 | // 定义语法规则 305 | let patterns: [(pattern: String, type: TokenType)] = [ 306 | // 关键字 307 | ("\\b(func|let|var|if|else|guard|return|while|for|in|switch|case|break|continue|struct|class|enum|import|try|catch|throws|async|await)\\b", .keyword), 308 | // 字符串 309 | ("\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*\"", .string), 310 | // 数字 311 | ("\\b\\d+\\.?\\d*\\b", .number), 312 | // 注释 313 | ("//.*?$|/\\*.*?\\*/", .comment), 314 | // 运算符 315 | ("[=+\\-*/<>!&|^~?:%]", .operator), 316 | // 类型 317 | ("\\b(String|Int|Double|Bool|Array|Dictionary|Set|Any|Void)\\b", .type) 318 | ] 319 | 320 | for (pattern, type) in patterns { 321 | guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { continue } 322 | let nsRange = NSRange(code.startIndex..., in: code) 323 | let matches = regex.matches(in: code, range: nsRange) 324 | 325 | for match in matches.reversed() { 326 | guard let range = Range(match.range, in: code), 327 | let attributedRange = Range(range, in: attributedString) else { continue } 328 | attributedString[attributedRange].foregroundColor = type.color 329 | } 330 | } 331 | 332 | return attributedString 333 | } 334 | } 335 | 336 | // 添加一个用于缓存代码块的类 337 | class CodeBlockCache: ObservableObject { 338 | static let shared = CodeBlockCache() 339 | private var cache: [String: AttributedString] = [:] 340 | 341 | func getHighlightedCode(_ code: String, language: String) -> AttributedString { 342 | let key = "\(code)_\(language)" 343 | if let cached = cache[key] { 344 | return cached 345 | } 346 | let highlighted = CodeHighlighter.highlightCode(code, language: language) 347 | cache[key] = highlighted 348 | return highlighted 349 | } 350 | } 351 | 352 | // 修改 CodeBlockView 353 | struct CodeBlockView: View { 354 | let content: String 355 | @State private var showCopyButton = false 356 | @State private var showCopiedFeedback = false 357 | @State private var isLoaded = false 358 | 359 | private func extractLanguageAndCode() -> (language: String, code: String) { 360 | let lines = content.split(separator: "\n", omittingEmptySubsequences: false) 361 | if content.hasPrefix("```") { 362 | let firstLine = String(lines[0]).trimmingCharacters(in: .whitespaces) 363 | let language = firstLine.dropFirst(3).trimmingCharacters(in: .whitespaces) 364 | let codeLines = Array(lines.dropFirst().dropLast()) 365 | let code = codeLines.joined(separator: "\n") 366 | return (language.isEmpty ? "plaintext" : language, code) 367 | } 368 | return ("plaintext", content) 369 | } 370 | 371 | var body: some View { 372 | let extracted = extractLanguageAndCode() 373 | 374 | VStack(spacing: 0) { 375 | // 标题栏 376 | HStack { 377 | Text(extracted.language) 378 | .font(.system(size: 12)) 379 | .foregroundColor(.secondary) 380 | Spacer() 381 | Button(action: { 382 | NSPasteboard.general.clearContents() 383 | NSPasteboard.general.setString(extracted.code, forType: .string) 384 | withAnimation { 385 | showCopiedFeedback = true 386 | } 387 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { 388 | withAnimation { 389 | showCopiedFeedback = false 390 | } 391 | } 392 | }) { 393 | Image(systemName: showCopiedFeedback ? "checkmark" : "doc.on.doc") 394 | .font(.system(size: 12)) 395 | .foregroundColor(.secondary) 396 | } 397 | .buttonStyle(PlainButtonStyle()) 398 | } 399 | .padding(.horizontal, 12) 400 | .padding(.vertical, 8) 401 | .background(Color(.controlBackgroundColor)) 402 | 403 | // 代码内容 404 | Group { 405 | if isLoaded { 406 | Text(CodeBlockCache.shared.getHighlightedCode(extracted.code, language: extracted.language)) 407 | .textSelection(.enabled) 408 | .font(.system(.body, design: .monospaced)) 409 | } else { 410 | Text(extracted.code) 411 | .textSelection(.enabled) 412 | .font(.system(.body, design: .monospaced)) 413 | .onAppear { 414 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 415 | isLoaded = true 416 | } 417 | } 418 | } 419 | } 420 | .padding(12) 421 | .frame(maxWidth: .infinity, alignment: .leading) 422 | .background(Color(.textBackgroundColor)) 423 | } 424 | .cornerRadius(8) 425 | .overlay( 426 | RoundedRectangle(cornerRadius: 8) 427 | .stroke(Color.secondary.opacity(0.2), lineWidth: 1) 428 | ) 429 | } 430 | } 431 | 432 | // 修改 MessageBubble 中 AI 回复部分的样式 433 | struct MessageBubble: View { 434 | let message: ChatMessage 435 | @State private var showCopyButton = false 436 | @State private var showCopiedFeedback = false 437 | @StateObject private var chatService = ChatService.shared 438 | @State private var processedContent: (normalText: String, codeBlocks: [String])? 439 | 440 | // 添加对消息内容的监听 441 | private var messageContent: String { 442 | // 如果是最后一条 AI 消息且正在加载,使用实时内容 443 | if !message.isUser && message.id == chatService.messages.last?.id && chatService.isLoading { 444 | return chatService.streamResponse 445 | } 446 | return message.content 447 | } 448 | 449 | private var processedContentValue: (normalText: String, codeBlocks: [String]) { 450 | if let cached = processedContent, !chatService.isLoading { 451 | return cached 452 | } 453 | let result = processContent(messageContent) 454 | if !chatService.isLoading { 455 | DispatchQueue.main.async { 456 | processedContent = result 457 | } 458 | } 459 | return result 460 | } 461 | 462 | private func processContent(_ content: String) -> (normalText: String, codeBlocks: [String]) { 463 | var normalText = content 464 | var codeBlocks: [String] = [] 465 | 466 | // 匹配代码块 467 | let pattern = "```[\\s\\S]*?```" 468 | guard let regex = try? NSRegularExpression(pattern: pattern) else { 469 | return (content, []) 470 | } 471 | 472 | let matches = regex.matches(in: content, range: NSRange(content.startIndex..., in: content)) 473 | 474 | // 确保匹配结果有效 475 | for match in matches.reversed() { 476 | if let range = Range(match.range, in: content) { 477 | let codeBlock = String(content[range]) 478 | codeBlocks.insert(codeBlock, at: 0) 479 | 480 | // 从原文中移除代码块,替换为占位符 481 | if let textRange = Range(match.range, in: normalText) { 482 | normalText.replaceSubrange(textRange, with: "{{CODE_BLOCK}}") 483 | } 484 | } 485 | } 486 | 487 | // 确保代码块数量和占位符数量匹配 488 | let placeholderCount = normalText.components(separatedBy: "{{CODE_BLOCK}}").count - 1 489 | if codeBlocks.count > placeholderCount { 490 | codeBlocks = Array(codeBlocks.prefix(placeholderCount)) 491 | } 492 | 493 | return (normalText, codeBlocks) 494 | } 495 | 496 | var body: some View { 497 | HStack { 498 | if message.isUser { 499 | Spacer() 500 | } 501 | 502 | VStack(alignment: message.isUser ? .trailing : .leading) { 503 | if message.isUser { 504 | Text(message.content) 505 | .font(.body) 506 | .padding() 507 | .background(Color(.controlBackgroundColor)) 508 | .foregroundColor(.primary) 509 | .cornerRadius(8) 510 | .textSelection(.enabled) 511 | } else { 512 | let processed = processedContentValue 513 | let textParts = processed.normalText.components(separatedBy: "{{CODE_BLOCK}}") 514 | 515 | VStack(alignment: .leading, spacing: 8) { 516 | ForEach(Array(zip(textParts.indices, textParts)), id: \.0) { index, text in 517 | // 显示文本部分 518 | if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { 519 | Markdown(text) 520 | .markdownTheme(.gitHub.text { 521 | ForegroundColor(.primary) 522 | BackgroundColor(.clear) 523 | FontSize(14) 524 | }) 525 | .textSelection(.enabled) 526 | .padding(.horizontal, 8) 527 | } 528 | 529 | // 安全地显示代码块 530 | if index < processed.codeBlocks.count { 531 | CodeBlockView(content: processed.codeBlocks[index]) 532 | .id("\(message.id)-code-\(index)") 533 | } 534 | } 535 | } 536 | .padding(.vertical, 8) 537 | .frame(maxWidth: .infinity, alignment: .leading) 538 | .contentShape(Rectangle()) 539 | .onHover { hovering in 540 | showCopyButton = hovering 541 | } 542 | } 543 | 544 | if !message.isUser { 545 | HStack(spacing: 12) { 546 | // 重新生成按钮 547 | Button(action: { 548 | Task { 549 | await chatService.regenerateResponse() 550 | } 551 | }) { 552 | Image(systemName: "arrow.clockwise") 553 | .font(.system(size: 12)) 554 | .foregroundColor(.secondary) 555 | } 556 | .buttonStyle(PlainButtonStyle()) 557 | .opacity(showCopyButton ? 1 : 0) 558 | .frame(width: 24, height: 24) 559 | 560 | // 复制按钮 561 | Button(action: { 562 | if !message.content.isEmpty { 563 | NSPasteboard.general.clearContents() 564 | NSPasteboard.general.setString(message.content, forType: .string) 565 | 566 | withAnimation { 567 | showCopiedFeedback = true 568 | } 569 | 570 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { 571 | withAnimation { 572 | showCopiedFeedback = false 573 | } 574 | } 575 | } 576 | }) { 577 | Image(systemName: showCopiedFeedback ? "checkmark" : "doc.on.doc") 578 | .font(.system(size: 12)) 579 | .foregroundColor(.secondary) 580 | } 581 | .buttonStyle(PlainButtonStyle()) 582 | .opacity(showCopyButton ? 1 : 0) 583 | .frame(width: 24, height: 24) 584 | } 585 | .padding(.top, -8) 586 | } 587 | } 588 | 589 | if !message.isUser { 590 | Spacer() 591 | } 592 | } 593 | .textSelection(.enabled) 594 | .id("\(message.id)-\(messageContent.hashValue)") // 添加动态ID以触发重新渲染 595 | } 596 | } 597 | 598 | // 添加 String 扩展来计算文本高度 599 | extension String { 600 | func height(withConstrainedWidth width: CGFloat) -> CGFloat { 601 | let size = CGSize(width: width, height: .greatestFiniteMagnitude) 602 | let attributes = [NSAttributedString.Key.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] 603 | let boundingBox = self.boundingRect(with: size, 604 | options: [.usesFontLeading, .usesLineFragmentOrigin], 605 | attributes: attributes, 606 | context: nil) 607 | return ceil(boundingBox.height) 608 | } 609 | } 610 | -------------------------------------------------------------------------------- /xllama/Views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // 在文件顶部添加 DifyService 4 | @MainActor 5 | class DifyService: ObservableObject { 6 | static let shared = DifyService() 7 | 8 | @Published var baseURL: String { 9 | didSet { 10 | UserDefaults.standard.set(baseURL, forKey: "dify_base_url") 11 | } 12 | } 13 | 14 | @Published var apiKey: String { 15 | didSet { 16 | UserDefaults.standard.set(apiKey, forKey: "dify_api_key") 17 | } 18 | } 19 | 20 | @Published var selectedModel: String { 21 | didSet { 22 | UserDefaults.standard.set(selectedModel, forKey: "dify_selected_model") 23 | } 24 | } 25 | 26 | @Published var models: [DifyModel] = [] { 27 | didSet { 28 | if let encoded = try? JSONEncoder().encode(models) { 29 | UserDefaults.standard.set(encoded, forKey: "dify_saved_models") 30 | } 31 | } 32 | } 33 | 34 | init() { 35 | self.baseURL = UserDefaults.standard.string(forKey: "dify_base_url") ?? "https://api.dify.ai/v1" 36 | self.apiKey = UserDefaults.standard.string(forKey: "dify_api_key") ?? "" 37 | self.selectedModel = UserDefaults.standard.string(forKey: "dify_selected_model") ?? "" 38 | 39 | if let savedModels = UserDefaults.standard.data(forKey: "dify_saved_models"), 40 | let decodedModels = try? JSONDecoder().decode([DifyModel].self, from: savedModels) { 41 | self.models = decodedModels 42 | } 43 | } 44 | 45 | func addModel(name: String) { 46 | let model = DifyModel(name: name) 47 | if !models.contains(where: { $0.name == name }) { 48 | models.append(model) 49 | } 50 | } 51 | 52 | func removeModel(_ model: DifyModel) { 53 | models.removeAll { $0.id == model.id } 54 | } 55 | 56 | func fetchModels() async throws { 57 | guard !apiKey.isEmpty, let url = URL(string: "\(baseURL)/model-providers") else { 58 | throw URLError(.badURL) 59 | } 60 | 61 | var request = URLRequest(url: url) 62 | request.httpMethod = "GET" 63 | request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") 64 | 65 | let (data, _) = try await URLSession.shared.data(for: request) 66 | let response = try JSONDecoder().decode(DifyModelsResponse.self, from: data) 67 | 68 | await MainActor.run { 69 | // 更新模型列表 70 | self.models = response.data.map { DifyModel(name: $0.name) } 71 | } 72 | } 73 | } 74 | 75 | struct DifyModel: Identifiable, Codable { 76 | var id: UUID 77 | let name: String 78 | var isAvailable: Bool 79 | 80 | init(name: String) { 81 | self.id = UUID() 82 | self.name = name 83 | self.isAvailable = true 84 | } 85 | } 86 | 87 | struct DifyModelsResponse: Codable { 88 | let data: [DifyModelInfo] 89 | } 90 | 91 | struct DifyModelInfo: Codable { 92 | let name: String 93 | } 94 | 95 | struct SettingsView: View { 96 | @Environment(\.dismiss) private var dismiss 97 | @State private var showOllamaSettings = false 98 | @State private var showXinferenceSettings = false 99 | @State private var showDifySettings = false 100 | @State private var showNetworkSettings = false 101 | 102 | var body: some View { 103 | VStack { 104 | HStack { 105 | Text("设置") 106 | .font(.headline) 107 | Spacer() 108 | Button("完成") { 109 | dismiss() 110 | } 111 | } 112 | .padding() 113 | 114 | Spacer() 115 | 116 | VStack(spacing: 16) { 117 | Button(action: { showOllamaSettings.toggle() }) { 118 | ZStack { 119 | RoundedRectangle(cornerRadius: 8) 120 | .fill(Color.black) 121 | .frame(height: 44) 122 | 123 | Text("Ollama 设置") 124 | .foregroundColor(.white) 125 | } 126 | } 127 | .buttonStyle(.plain) 128 | 129 | Button(action: { showXinferenceSettings.toggle() }) { 130 | ZStack { 131 | RoundedRectangle(cornerRadius: 8) 132 | .fill(Color.black) 133 | .frame(height: 44) 134 | 135 | Text("Xinference 设置") 136 | .foregroundColor(.white) 137 | } 138 | } 139 | .buttonStyle(.plain) 140 | 141 | Button(action: { showDifySettings.toggle() }) { 142 | ZStack { 143 | RoundedRectangle(cornerRadius: 8) 144 | .fill(Color.black) 145 | .frame(height: 44) 146 | 147 | Text("Dify 设置") 148 | .foregroundColor(.white) 149 | } 150 | } 151 | .buttonStyle(.plain) 152 | 153 | Button(action: { showNetworkSettings.toggle() }) { 154 | ZStack { 155 | RoundedRectangle(cornerRadius: 8) 156 | .fill(Color.black) 157 | .frame(height: 44) 158 | 159 | Text("网络设置") 160 | .foregroundColor(.white) 161 | } 162 | } 163 | .buttonStyle(.plain) 164 | } 165 | .padding(.horizontal, 40) 166 | 167 | Spacer() 168 | 169 | // 添加作者信息和 GitHub 链接 170 | VStack(spacing: 8) { 171 | VStack(spacing: 4) { 172 | Text("作者:耳听西湖") 173 | .font(.system(size: 12)) 174 | .foregroundColor(.gray) 175 | Text("B站:耳听西湖") 176 | .font(.system(size: 12)) 177 | .foregroundColor(.gray) 178 | Text("github项目地址:") 179 | .font(.system(size: 12)) 180 | .foregroundColor(.gray) 181 | } 182 | 183 | Button(action: { 184 | if let url = URL(string: "https://github.com/dunbin/XDOllama") { 185 | NSWorkspace.shared.open(url) 186 | } 187 | }) { 188 | Image(systemName: "link.circle.fill") 189 | .font(.system(size: 20)) 190 | .foregroundColor(.gray) 191 | } 192 | } 193 | .padding(.bottom, 24) 194 | .padding(.top, 16) 195 | } 196 | .frame(width: 400, height: 400) 197 | .background(Color(.windowBackgroundColor)) 198 | .sheet(isPresented: $showOllamaSettings) { 199 | OllamaSettingsView() 200 | } 201 | .sheet(isPresented: $showXinferenceSettings) { 202 | XinferenceSettingsView() 203 | } 204 | .sheet(isPresented: $showDifySettings) { 205 | DifySettingsView() 206 | } 207 | .sheet(isPresented: $showNetworkSettings) { 208 | NetworkSettingsView() 209 | } 210 | } 211 | } 212 | 213 | struct OllamaSettingsView: View { 214 | @Environment(\.dismiss) private var dismiss 215 | @StateObject private var ollamaService = OllamaService.shared 216 | @State private var models: [OllamaModel] = [] 217 | @State private var isLoading = false 218 | @State private var errorMessage: String? 219 | 220 | var body: some View { 221 | VStack { 222 | HStack { 223 | Text("Ollama 设置") 224 | .font(.headline) 225 | Spacer() 226 | Button("完成") { 227 | dismiss() 228 | } 229 | } 230 | .padding() 231 | 232 | Form { 233 | Section(header: Text("Ollama 配置").font(.headline)) { 234 | TextField("Ollama 地址", text: $ollamaService.baseURL) 235 | .textFieldStyle(RoundedBorderTextFieldStyle()) 236 | .padding(.vertical, 5) 237 | 238 | if !models.isEmpty { 239 | Picker("默认模型", selection: $ollamaService.selectedModel) { 240 | Text("未选择").tag("") 241 | ForEach(models) { model in 242 | Text(model.name).tag(model.name) 243 | } 244 | } 245 | .padding(.vertical, 5) 246 | } 247 | 248 | Stepper("历史对话轮次: \(ollamaService.maxConversationTurns)", 249 | value: $ollamaService.maxConversationTurns, 250 | in: 1...20) 251 | .padding(.vertical, 5) 252 | 253 | Button(action: refreshModels) { 254 | if isLoading { 255 | ProgressView() 256 | .scaleEffect(0.8) 257 | } else { 258 | Text("刷新模型列表") 259 | } 260 | } 261 | .buttonStyle(.bordered) 262 | .padding(.vertical, 5) 263 | .disabled(isLoading) 264 | } 265 | 266 | if let error = errorMessage { 267 | Text(error) 268 | .foregroundColor(.red) 269 | .font(.caption) 270 | } 271 | } 272 | .padding() 273 | 274 | Spacer() 275 | } 276 | .frame(width: 400, height: 400) 277 | .background(Color(.windowBackgroundColor)) 278 | .onAppear { 279 | refreshModels() 280 | } 281 | } 282 | 283 | private func refreshModels() { 284 | isLoading = true 285 | errorMessage = nil 286 | 287 | Task { 288 | do { 289 | models = try await ollamaService.fetchModels() 290 | } catch { 291 | errorMessage = "获取模型列表失败: \(error.localizedDescription)" 292 | } 293 | isLoading = false 294 | } 295 | } 296 | } 297 | 298 | struct XinferenceSettingsView: View { 299 | @Environment(\.dismiss) private var dismiss 300 | @StateObject private var xinferenceService = XinferenceService.shared 301 | @State private var isLoading = false 302 | @State private var errorMessage: String? 303 | @State private var newModelName = "" 304 | 305 | var body: some View { 306 | VStack { 307 | HStack { 308 | Text("Xinference 设置") 309 | .font(.headline) 310 | Spacer() 311 | Button("完成") { 312 | dismiss() 313 | } 314 | } 315 | .padding() 316 | 317 | Form { 318 | Section(header: Text("Xinference 配置").font(.headline)) { 319 | TextField("服务地址", text: $xinferenceService.baseURL) 320 | .textFieldStyle(RoundedBorderTextFieldStyle()) 321 | .padding(.vertical, 5) 322 | 323 | if !xinferenceService.models.isEmpty { 324 | Picker("默认模型", selection: $xinferenceService.selectedModel) { 325 | Text("未选择").tag("") 326 | ForEach(xinferenceService.models) { model in 327 | Text(model.name).tag(model.name) 328 | } 329 | } 330 | .padding(.vertical, 5) 331 | } 332 | 333 | HStack { 334 | TextField("添加模型名称", text: $newModelName) 335 | .textFieldStyle(RoundedBorderTextFieldStyle()) 336 | 337 | Button(action: { 338 | if !newModelName.isEmpty { 339 | xinferenceService.addModel(name: newModelName) 340 | newModelName = "" 341 | } 342 | }) { 343 | Image(systemName: "plus.circle.fill") 344 | } 345 | .disabled(newModelName.isEmpty) 346 | } 347 | .padding(.vertical, 5) 348 | 349 | // 已保存的模型列表 350 | ForEach(xinferenceService.models) { model in 351 | HStack { 352 | Text(model.name) 353 | Spacer() 354 | Button(action: { 355 | xinferenceService.removeModel(model) 356 | }) { 357 | Image(systemName: "trash") 358 | .foregroundColor(.red) 359 | } 360 | } 361 | } 362 | 363 | Button(action: refreshModels) { 364 | if isLoading { 365 | ProgressView() 366 | .scaleEffect(0.8) 367 | } else { 368 | Text("刷新模型列表") 369 | } 370 | } 371 | .buttonStyle(.bordered) 372 | .padding(.vertical, 5) 373 | .disabled(isLoading) 374 | } 375 | 376 | if let error = errorMessage { 377 | Text(error) 378 | .foregroundColor(.red) 379 | .font(.caption) 380 | } 381 | } 382 | .padding() 383 | 384 | Spacer() 385 | } 386 | .frame(width: 400, height: 400) 387 | .background(Color(.windowBackgroundColor)) 388 | .onAppear { 389 | refreshModels() 390 | } 391 | } 392 | 393 | private func refreshModels() { 394 | isLoading = true 395 | errorMessage = nil 396 | 397 | Task { 398 | do { 399 | try await xinferenceService.fetchModels() 400 | } catch { 401 | errorMessage = "获取模型列表失败: \(error.localizedDescription)" 402 | } 403 | isLoading = false 404 | } 405 | } 406 | } 407 | 408 | struct DifySettingsView: View { 409 | @Environment(\.dismiss) private var dismiss 410 | @StateObject private var difyService = DifyService.shared 411 | @State private var isLoading = false 412 | @State private var errorMessage: String? 413 | @State private var newModelName = "" 414 | 415 | var body: some View { 416 | VStack { 417 | HStack { 418 | Text("Dify 设置") 419 | .font(.headline) 420 | Spacer() 421 | Button("完成") { 422 | dismiss() 423 | } 424 | } 425 | .padding() 426 | 427 | Form { 428 | Section(header: Text("Dify 配置").font(.headline)) { 429 | TextField("服务地址", text: $difyService.baseURL) 430 | .textFieldStyle(RoundedBorderTextFieldStyle()) 431 | .padding(.vertical, 5) 432 | 433 | SecureField("API Key", text: $difyService.apiKey) 434 | .textFieldStyle(RoundedBorderTextFieldStyle()) 435 | .padding(.vertical, 5) 436 | 437 | if !difyService.models.isEmpty { 438 | Picker("默认模型", selection: $difyService.selectedModel) { 439 | Text("未选择").tag("") 440 | ForEach(difyService.models) { model in 441 | Text(model.name).tag(model.name) 442 | } 443 | } 444 | .padding(.vertical, 5) 445 | } 446 | 447 | HStack { 448 | TextField("添加模型名称", text: $newModelName) 449 | .textFieldStyle(RoundedBorderTextFieldStyle()) 450 | 451 | Button(action: { 452 | if !newModelName.isEmpty { 453 | difyService.addModel(name: newModelName) 454 | newModelName = "" 455 | } 456 | }) { 457 | Image(systemName: "plus.circle.fill") 458 | } 459 | .disabled(newModelName.isEmpty) 460 | } 461 | .padding(.vertical, 5) 462 | 463 | // 已保存的模型列表 464 | ForEach(difyService.models) { model in 465 | HStack { 466 | Text(model.name) 467 | Spacer() 468 | Button(action: { 469 | difyService.removeModel(model) 470 | }) { 471 | Image(systemName: "trash") 472 | .foregroundColor(.red) 473 | } 474 | } 475 | } 476 | 477 | Button(action: refreshModels) { 478 | if isLoading { 479 | ProgressView() 480 | .scaleEffect(0.8) 481 | } else { 482 | Text("刷新模型列表") 483 | } 484 | } 485 | .buttonStyle(.bordered) 486 | .padding(.vertical, 5) 487 | .disabled(isLoading) 488 | } 489 | 490 | if let error = errorMessage { 491 | Text(error) 492 | .foregroundColor(.red) 493 | .font(.caption) 494 | } 495 | } 496 | .padding() 497 | 498 | Spacer() 499 | } 500 | .frame(width: 400, height: 400) 501 | .background(Color(.windowBackgroundColor)) 502 | .onAppear { 503 | refreshModels() 504 | } 505 | } 506 | 507 | private func refreshModels() { 508 | isLoading = true 509 | errorMessage = nil 510 | 511 | Task { 512 | do { 513 | try await difyService.fetchModels() 514 | } catch { 515 | errorMessage = "获取模型列表失败: \(error.localizedDescription)" 516 | } 517 | isLoading = false 518 | } 519 | } 520 | } 521 | 522 | // 添加 XinferenceService 523 | @MainActor 524 | class XinferenceService: ObservableObject { 525 | static let shared = XinferenceService() 526 | 527 | @Published var baseURL: String { 528 | didSet { 529 | UserDefaults.standard.set(baseURL, forKey: "xinference_base_url") 530 | } 531 | } 532 | 533 | @Published var selectedModel: String { 534 | didSet { 535 | UserDefaults.standard.set(selectedModel, forKey: "xinference_selected_model") 536 | } 537 | } 538 | 539 | @Published var models: [XinferenceModel] = [] { 540 | didSet { 541 | if let encoded = try? JSONEncoder().encode(models) { 542 | UserDefaults.standard.set(encoded, forKey: "xinference_saved_models") 543 | } 544 | } 545 | } 546 | 547 | init() { 548 | self.baseURL = UserDefaults.standard.string(forKey: "xinference_base_url") ?? "http://127.0.0.1:9997" 549 | self.selectedModel = UserDefaults.standard.string(forKey: "xinference_selected_model") ?? "" 550 | 551 | if let savedModels = UserDefaults.standard.data(forKey: "xinference_saved_models"), 552 | let decodedModels = try? JSONDecoder().decode([XinferenceModel].self, from: savedModels) { 553 | self.models = decodedModels 554 | } 555 | } 556 | 557 | func addModel(name: String) { 558 | let model = XinferenceModel(name: name) 559 | if !models.contains(where: { $0.name == name }) { 560 | models.append(model) 561 | } 562 | } 563 | 564 | func removeModel(_ model: XinferenceModel) { 565 | models.removeAll { $0.id == model.id } 566 | } 567 | 568 | func fetchModels() async throws { 569 | // 这里实现从 Xinference 服务器获取模型列表的逻辑 570 | // 使用 /v1/models 接口 571 | guard let url = URL(string: "\(baseURL)/v1/models") else { 572 | throw URLError(.badURL) 573 | } 574 | 575 | let (data, _) = try await URLSession.shared.data(from: url) 576 | let response = try JSONDecoder().decode(ModelsResponse.self, from: data) 577 | 578 | await MainActor.run { 579 | // 更新已保存的模型状态 580 | self.updateModelsStatus(with: response.data) 581 | } 582 | } 583 | 584 | private func updateModelsStatus(with availableModels: [ModelInfo]) { 585 | let availableModelNames = Set(availableModels.map { $0.id }) 586 | for i in 0.. 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.network.server 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /xllama/xllamaApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // xllamaApp.swift 3 | // xllama 4 | // 5 | // Created by dunbin on 2024/11/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct XLlamaApp: App { 12 | @StateObject private var networkService = NetworkService.shared 13 | 14 | var body: some Scene { 15 | WindowGroup { 16 | ContentView() 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /xllamaTests/xllamaTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // xllamaTests.swift 3 | // xllamaTests 4 | // 5 | // Created by dunbin on 2024/11/21. 6 | // 7 | 8 | import Testing 9 | @testable import xllama 10 | 11 | struct xllamaTests { 12 | 13 | @Test func example() async throws { 14 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /xllamaUITests/xllamaUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // xllamaUITests.swift 3 | // xllamaUITests 4 | // 5 | // Created by dunbin on 2024/11/21. 6 | // 7 | 8 | import XCTest 9 | 10 | final class xllamaUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // 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. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | @MainActor 26 | func testExample() throws { 27 | // UI tests must launch the application that they test. 28 | let app = XCUIApplication() 29 | app.launch() 30 | 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | @MainActor 35 | func testLaunchPerformance() throws { 36 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 37 | // This measures how long it takes to launch your application. 38 | measure(metrics: [XCTApplicationLaunchMetric()]) { 39 | XCUIApplication().launch() 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /xllamaUITests/xllamaUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // xllamaUITestsLaunchTests.swift 3 | // xllamaUITests 4 | // 5 | // Created by dunbin on 2024/11/21. 6 | // 7 | 8 | import XCTest 9 | 10 | final class xllamaUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | @MainActor 21 | func testLaunch() throws { 22 | let app = XCUIApplication() 23 | app.launch() 24 | 25 | // Insert steps here to perform after app launch but before taking a screenshot, 26 | // such as logging into a test account or navigating somewhere in the app 27 | 28 | let attachment = XCTAttachment(screenshot: app.screenshot()) 29 | attachment.name = "Launch Screen" 30 | attachment.lifetime = .keepAlways 31 | add(attachment) 32 | } 33 | } 34 | --------------------------------------------------------------------------------