├── .gitignore ├── FileChat.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── FreeChat.xcscheme │ └── server-watchdog.xcscheme ├── FreeChat ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ └── FileChatIcon1024.png │ ├── Contents.json │ ├── ESM_Deep_UI_Sound_4_Glitch_Software_Particle_Processed_Beep_Chrip_Electronic.dataset │ │ ├── Contents.json │ │ └── ESM_Deep_UI_Sound_4_Glitch_Software_Particle_Processed_Beep_Chrip_Electronic.wav │ ├── ESM_POWER_ON_SYNTH.dataset │ │ ├── Contents.json │ │ └── ESM_POWER_ON_SYNTH.wav │ ├── ESM_Perfect_App_Button_2_Organic_Simple_Classic_Game_Click.dataset │ │ ├── Contents.json │ │ └── ESM_Perfect_App_Button_2_Organic_Simple_Classic_Game_Click.wav │ └── TextBackground.colorset │ │ └── Contents.json ├── Chats.xcdatamodeld │ ├── .xccurrentversion │ └── Mantras.xcdatamodel │ │ └── contents ├── Constants.swift ├── ContentView.swift ├── FileChat.swift ├── FileChatAppDelegate.swift ├── FreeChat.entitlements ├── Info.plist ├── Logic │ ├── Actions │ │ ├── Action.swift │ │ ├── ActionManager.swift │ │ └── Shortcut.swift │ ├── Directory Index │ │ ├── ContainerManager.swift │ │ └── Index Store │ │ │ ├── IndexItem.swift │ │ │ ├── IndexStore.swift │ │ │ └── IndexedDirectory.swift │ ├── Extensions │ │ ├── Extension+String.swift │ │ └── Extension+URL.swift │ ├── Models │ │ ├── Conversation+Extensions.swift │ │ ├── ConversationController.swift │ │ ├── ConversationManager.swift │ │ ├── DownloadManager.swift │ │ ├── GPU.swift │ │ ├── Message+Extensions.swift │ │ ├── Model+Extensions.swift │ │ ├── NPC │ │ │ ├── Agent.swift │ │ │ ├── AgentDefaults.swift │ │ │ ├── Conversation.swift │ │ │ ├── LlamaServer.swift │ │ │ ├── Message.swift │ │ │ ├── README.md │ │ │ ├── ServerHealth.swift │ │ │ ├── codesign.sh │ │ │ ├── freechat-server │ │ │ ├── freechat-server.entitlements │ │ │ └── server-watchdog │ │ │ │ └── main.swift │ │ └── Network.swift │ └── Window Management │ │ └── OpenWindow.swift ├── Persistence.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json └── Views │ ├── Actions View │ ├── ActionDetailView.swift │ ├── ActionTestInputView.swift │ ├── ActionsView.swift │ └── NewActionSheet.swift │ ├── Default View │ ├── CGKeycode+Extensions.swift │ ├── CircleMenuStyle.swift │ ├── ConversationView │ │ ├── BottomToolbar.swift │ │ ├── BottomToolbarPanel.swift │ │ ├── CapsuleButtonStyle.swift │ │ ├── ChatStyle.swift │ │ ├── ConversationView.swift │ │ ├── IndexPicker.swift │ │ ├── MessageView.swift │ │ ├── ObservableScrollView.swift │ │ └── QuickPromptButton.swift │ ├── CopyButton.swift │ ├── LengthyTasksView │ │ ├── LengthyTask.swift │ │ ├── LengthyTasksController.swift │ │ ├── LengthyTasksView.swift │ │ └── LoadingAnimationView.swift │ ├── Markdown │ │ ├── SplashCodeSyntaxHighlighter.swift │ │ ├── TextOutputFormat.swift │ │ └── Theme+FreeChat.swift │ ├── NavList.swift │ └── WelcomeSheet.swift │ ├── EnvironmentValues.swift │ ├── Settings │ ├── AISettings.swift │ ├── AISettingsView.swift │ ├── EditModels.swift │ ├── EditSystemPrompt.swift │ ├── SettingsView.swift │ └── UISettingsView.swift │ └── SystemPromptsView.swift ├── FreeChatTests └── PromptTemplateTests.swift ├── FreeChatUITests ├── FreeChatUITests.swift └── FreeChatUITestsLaunchTests.swift ├── LICENSE.txt ├── README.md └── server-watchdog.entitlements /.gitignore: -------------------------------------------------------------------------------- 1 | # Don't check in models 2 | *.bin 3 | *.gguf 4 | 5 | # Xcode 6 | # 7 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 8 | 9 | ## User settings 10 | xcuserdata/ 11 | 12 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 13 | *.xcscmblueprint 14 | *.xccheckout 15 | 16 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 17 | build/ 18 | DerivedData/ 19 | *.moved-aside 20 | *.pbxuser 21 | !default.pbxuser 22 | *.mode1v3 23 | !default.mode1v3 24 | *.mode2v3 25 | !default.mode2v3 26 | *.perspectivev3 27 | !default.perspectivev3 28 | 29 | ## Obj-C/Swift specific 30 | *.hmap 31 | 32 | ## App packaging 33 | *.ipa 34 | *.dSYM.zip 35 | *.dSYM 36 | 37 | ## Playgrounds 38 | timeline.xctimeline 39 | playground.xcworkspace 40 | 41 | # Swift Package Manager 42 | # 43 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 44 | # Packages/ 45 | # Package.pins 46 | # Package.resolved 47 | # *.xcodeproj 48 | # 49 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 50 | # hence it is not needed unless you have added a package configuration file to your project 51 | # .swiftpm 52 | 53 | .build/ 54 | 55 | # CocoaPods 56 | # 57 | # We recommend against adding the Pods directory to your .gitignore. However 58 | # you should judge for yourself, the pros and cons are mentioned at: 59 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 60 | # 61 | # Pods/ 62 | # 63 | # Add this line if you want to avoid checking in source code from the Xcode workspace 64 | # *.xcworkspace 65 | 66 | # Carthage 67 | # 68 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 69 | # Carthage/Checkouts 70 | 71 | Carthage/Build/ 72 | 73 | # Accio dependency management 74 | Dependencies/ 75 | .accio/ 76 | 77 | # fastlane 78 | # 79 | # It is recommended to not store the screenshots in the git repo. 80 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 81 | # For more information about the recommended setup visit: 82 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 83 | 84 | fastlane/report.xml 85 | fastlane/Preview.html 86 | fastlane/screenshots/**/*.png 87 | fastlane/test_output 88 | 89 | # Code Injection 90 | # 91 | # After new code Injection tools there's a generated folder /iOSInjectionProject 92 | # https://github.com/johnno1962/injectionforxcode 93 | 94 | iOSInjectionProject/ -------------------------------------------------------------------------------- /FileChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /FileChat.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /FileChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "12c80fa49d35f73276c250ee44fbd06157aca6176fdf5012b9448488c3ff8b1b", 3 | "pins" : [ 4 | { 5 | "identity" : "applegpuinfo", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/philipturner/applegpuinfo", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "cedd7526078d58d4a98cc170d4df5d9eaa8f1a99" 11 | } 12 | }, 13 | { 14 | "identity" : "bezelnotification", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/Yalir/BezelNotification.git", 17 | "state" : { 18 | "revision" : "68876d0e8c0ad09e0a1be8e7124b4f6c738df2c9", 19 | "version" : "1.1.0" 20 | } 21 | }, 22 | { 23 | "identity" : "devicekit", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/devicekit/DeviceKit", 26 | "state" : { 27 | "branch" : "master", 28 | "revision" : "5757447e9f92c476ee2ca41ead7eb6db07936430" 29 | } 30 | }, 31 | { 32 | "identity" : "eventsource", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/Recouse/EventSource.git", 35 | "state" : { 36 | "revision" : "fcd7152a3106d75287c7303bba40a4761e5b7f6d", 37 | "version" : "0.0.5" 38 | } 39 | }, 40 | { 41 | "identity" : "extensionkit", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/johnbean393/ExtensionKit", 44 | "state" : { 45 | "branch" : "main", 46 | "revision" : "45a02d652b3b21652202a169efe3030929fe92fa" 47 | } 48 | }, 49 | { 50 | "identity" : "keyboardshortcuts", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/sindresorhus/KeyboardShortcuts", 53 | "state" : { 54 | "revision" : "b878f8132be59576fc87e39405b1914eff9f55d3", 55 | "version" : "1.14.1" 56 | } 57 | }, 58 | { 59 | "identity" : "networkimage", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/gonzalezreal/NetworkImage", 62 | "state" : { 63 | "revision" : "7aff8d1b31148d32c5933d75557d42f6323ee3d1", 64 | "version" : "6.0.0" 65 | } 66 | }, 67 | { 68 | "identity" : "similarity-search-kit", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/johnbean393/similarity-search-kit", 71 | "state" : { 72 | "branch" : "main", 73 | "revision" : "f73e4bb6ef05f315e9641e7274f4b0b3906555d9" 74 | } 75 | }, 76 | { 77 | "identity" : "splash", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/JohnSundell/Splash", 80 | "state" : { 81 | "revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8", 82 | "version" : "0.16.0" 83 | } 84 | }, 85 | { 86 | "identity" : "sqlite.swift", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/stephencelis/SQLite.swift.git", 89 | "state" : { 90 | "revision" : "a95fc6df17d108bd99210db5e8a9bac90fe984b8", 91 | "version" : "0.15.3" 92 | } 93 | }, 94 | { 95 | "identity" : "swift-argument-parser", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/apple/swift-argument-parser", 98 | "state" : { 99 | "branch" : "main", 100 | "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b" 101 | } 102 | }, 103 | { 104 | "identity" : "swift-async-algorithms", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/apple/swift-async-algorithms.git", 107 | "state" : { 108 | "revision" : "9cfed92b026c524674ed869a4ff2dcfdeedf8a2a", 109 | "version" : "0.1.0" 110 | } 111 | }, 112 | { 113 | "identity" : "swift-collections", 114 | "kind" : "remoteSourceControl", 115 | "location" : "https://github.com/apple/swift-collections.git", 116 | "state" : { 117 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 118 | "version" : "1.0.4" 119 | } 120 | }, 121 | { 122 | "identity" : "swift-markdown-ui", 123 | "kind" : "remoteSourceControl", 124 | "location" : "https://github.com/gonzalezreal/swift-markdown-ui", 125 | "state" : { 126 | "revision" : "55441810c0f678c78ed7e2ebd46dde89228e02fc", 127 | "version" : "2.4.0" 128 | } 129 | }, 130 | { 131 | "identity" : "swiftui-shimmer", 132 | "kind" : "remoteSourceControl", 133 | "location" : "https://github.com/markiv/SwiftUI-Shimmer", 134 | "state" : { 135 | "revision" : "e3aa4226b0fafe345ca1c920f516b6a2f3e0aacc", 136 | "version" : "1.5.0" 137 | } 138 | } 139 | ], 140 | "version" : 3 141 | } 142 | -------------------------------------------------------------------------------- /FileChat.xcodeproj/xcshareddata/xcschemes/FreeChat.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 35 | 41 | 42 | 43 | 46 | 52 | 53 | 54 | 55 | 56 | 66 | 68 | 74 | 75 | 76 | 77 | 83 | 85 | 91 | 92 | 93 | 94 | 96 | 97 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /FileChat.xcodeproj/xcshareddata/xcschemes/server-watchdog.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /FreeChat/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "filename" : "FileChatIcon1024.png", 50 | "idiom" : "mac", 51 | "scale" : "2x", 52 | "size" : "512x512" 53 | } 54 | ], 55 | "info" : { 56 | "author" : "xcode", 57 | "version" : 1 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /FreeChat/Assets.xcassets/AppIcon.appiconset/FileChatIcon1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnbean393/FileChat/460bcd8a03dca46b9790f69aa4fa93c5b4bb53e3/FreeChat/Assets.xcassets/AppIcon.appiconset/FileChatIcon1024.png -------------------------------------------------------------------------------- /FreeChat/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /FreeChat/Assets.xcassets/ESM_Deep_UI_Sound_4_Glitch_Software_Particle_Processed_Beep_Chrip_Electronic.dataset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "data" : [ 3 | { 4 | "filename" : "ESM_Deep_UI_Sound_4_Glitch_Software_Particle_Processed_Beep_Chrip_Electronic.wav", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /FreeChat/Assets.xcassets/ESM_Deep_UI_Sound_4_Glitch_Software_Particle_Processed_Beep_Chrip_Electronic.dataset/ESM_Deep_UI_Sound_4_Glitch_Software_Particle_Processed_Beep_Chrip_Electronic.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnbean393/FileChat/460bcd8a03dca46b9790f69aa4fa93c5b4bb53e3/FreeChat/Assets.xcassets/ESM_Deep_UI_Sound_4_Glitch_Software_Particle_Processed_Beep_Chrip_Electronic.dataset/ESM_Deep_UI_Sound_4_Glitch_Software_Particle_Processed_Beep_Chrip_Electronic.wav -------------------------------------------------------------------------------- /FreeChat/Assets.xcassets/ESM_POWER_ON_SYNTH.dataset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "data" : [ 3 | { 4 | "filename" : "ESM_POWER_ON_SYNTH.wav", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /FreeChat/Assets.xcassets/ESM_POWER_ON_SYNTH.dataset/ESM_POWER_ON_SYNTH.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnbean393/FileChat/460bcd8a03dca46b9790f69aa4fa93c5b4bb53e3/FreeChat/Assets.xcassets/ESM_POWER_ON_SYNTH.dataset/ESM_POWER_ON_SYNTH.wav -------------------------------------------------------------------------------- /FreeChat/Assets.xcassets/ESM_Perfect_App_Button_2_Organic_Simple_Classic_Game_Click.dataset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "data" : [ 3 | { 4 | "filename" : "ESM_Perfect_App_Button_2_Organic_Simple_Classic_Game_Click.wav", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /FreeChat/Assets.xcassets/ESM_Perfect_App_Button_2_Organic_Simple_Classic_Game_Click.dataset/ESM_Perfect_App_Button_2_Organic_Simple_Classic_Game_Click.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnbean393/FileChat/460bcd8a03dca46b9790f69aa4fa93c5b4bb53e3/FreeChat/Assets.xcassets/ESM_Perfect_App_Button_2_Organic_Simple_Classic_Game_Click.dataset/ESM_Perfect_App_Button_2_Organic_Simple_Classic_Game_Click.wav -------------------------------------------------------------------------------- /FreeChat/Assets.xcassets/TextBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "osx", 6 | "reference" : "textBackgroundColor" 7 | }, 8 | "idiom" : "universal" 9 | }, 10 | { 11 | "appearances" : [ 12 | { 13 | "appearance" : "luminosity", 14 | "value" : "dark" 15 | } 16 | ], 17 | "color" : { 18 | "platform" : "osx", 19 | "reference" : "textBackgroundColor" 20 | }, 21 | "idiom" : "universal" 22 | } 23 | ], 24 | "info" : { 25 | "author" : "xcode", 26 | "version" : 1 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /FreeChat/Chats.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | Mantras.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /FreeChat/Chats.xcdatamodeld/Mantras.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /FreeChat/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 9/16/23. 6 | // 7 | 8 | import Foundation 9 | import KeyboardShortcuts 10 | import AVFoundation 11 | 12 | extension KeyboardShortcuts.Name { 13 | static let summonFileChat = Self("summonFileChat") 14 | } 15 | 16 | let speechSynthesizer: AVSpeechSynthesizer = { 17 | var synthesizer: AVSpeechSynthesizer = AVSpeechSynthesizer() 18 | return synthesizer 19 | }() 20 | -------------------------------------------------------------------------------- /FreeChat/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 7/31/23. 6 | // 7 | 8 | import AppKit 9 | import CoreData 10 | import KeyboardShortcuts 11 | import SwiftUI 12 | 13 | struct ContentView: View { 14 | 15 | @Environment(\.managedObjectContext) private var viewContext 16 | @Environment(\.openWindow) private var openWindow 17 | 18 | @AppStorage("systemPrompt") private var systemPrompt: String = DEFAULT_SYSTEM_PROMPT 19 | @AppStorage("firstLaunchComplete") private var firstLaunchComplete = false 20 | 21 | @FetchRequest( 22 | sortDescriptors: [NSSortDescriptor(keyPath: \Model.size, ascending: false)] 23 | ) 24 | 25 | private var models: FetchedResults 26 | 27 | @FetchRequest( 28 | sortDescriptors: [NSSortDescriptor(keyPath: \Conversation.updatedAt, ascending: true)] 29 | ) 30 | private var conversations: FetchedResults 31 | 32 | @State private var selection: Set = Set() 33 | @State private var showDeleteConfirmation = false 34 | @State private var showWelcome = false 35 | @State private var setInitialSelection = false 36 | 37 | var agent: Agent? { 38 | conversationManager.agent 39 | } 40 | 41 | @EnvironmentObject var conversationManager: ConversationManager 42 | 43 | var body: some View { 44 | NavigationSplitView { 45 | if setInitialSelection { 46 | NavList(selection: $selection, showDeleteConfirmation: $showDeleteConfirmation) 47 | .navigationSplitViewColumnWidth(min: 160, ideal: 160) 48 | } 49 | } detail: { 50 | if selection.count > 1 { 51 | Text("\(selection.count) conversations selected") 52 | } else if conversationManager.showConversation() { 53 | ConversationView() 54 | } else if conversations.count == 0 { 55 | Text("Hit ⌘N to start a conversation") 56 | } else { 57 | Text("Select a conversation") 58 | } 59 | } 60 | .onReceive( 61 | NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification), 62 | perform: { output in 63 | Task { 64 | await agent?.llama.stopServer() 65 | } 66 | } 67 | ) 68 | .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("needStartNewConversation"))) { _ in 69 | conversationManager.newConversation(viewContext: viewContext, openWindow: openWindow) 70 | } 71 | .onDeleteCommand { showDeleteConfirmation = true } 72 | .onAppear(perform: initializeFirstLaunchData) 73 | .onChange(of: selection) { nextSelection in 74 | if nextSelection.count == 1, 75 | let first = nextSelection.first 76 | { 77 | if first != conversationManager.currentConversation { 78 | conversationManager.currentConversation = first 79 | } 80 | } else { 81 | conversationManager.unsetConversation() 82 | } 83 | } 84 | .onChange(of: conversationManager.currentConversation) { nextCurrent in 85 | if conversationManager.showConversation(), !selection.contains(nextCurrent) { 86 | selection = Set([nextCurrent]) 87 | } 88 | } 89 | .onChange(of: models.count, perform: handleModelCountChange) 90 | .sheet(isPresented: $showWelcome) { 91 | WelcomeSheet(isPresented: $showWelcome) 92 | } 93 | } 94 | 95 | private func handleModelCountChange(_ nextCount: Int) { 96 | showWelcome = showWelcome || nextCount == 0 97 | } 98 | 99 | private func initializeFirstLaunchData() { 100 | if let c = conversations.last { 101 | selection = Set([c]) 102 | } 103 | setInitialSelection = true 104 | 105 | if !conversationManager.summonRegistered { 106 | KeyboardShortcuts.onKeyUp(for: .summonFileChat) { 107 | NSApp.activate(ignoringOtherApps: true) 108 | conversationManager.newConversation(viewContext: viewContext, openWindow: openWindow) 109 | } 110 | conversationManager.summonRegistered = true 111 | } 112 | 113 | try? fetchModelsSyncLocalFiles() 114 | handleModelCountChange(models.count) 115 | 116 | if firstLaunchComplete { return } 117 | conversationManager.newConversation(viewContext: viewContext, openWindow: openWindow) 118 | firstLaunchComplete = true 119 | } 120 | 121 | private func fetchModelsSyncLocalFiles() throws { 122 | for model in models { 123 | if try model.url?.checkResourceIsReachable() != true { 124 | viewContext.delete(model) 125 | } 126 | } 127 | 128 | try viewContext.save() 129 | } 130 | } 131 | 132 | #Preview { 133 | let context = PersistenceController.preview.container.viewContext 134 | return ContentView() 135 | .environment(\.managedObjectContext, context) 136 | } 137 | -------------------------------------------------------------------------------- /FreeChat/FileChat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileChatApp.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 7/31/23. 6 | // 7 | 8 | import SwiftUI 9 | import KeyboardShortcuts 10 | 11 | @main 12 | struct FileChatApp: App { 13 | 14 | @NSApplicationDelegateAdaptor(FileChatAppDelegate.self) private var appDelegate 15 | @Environment(\.openWindow) var openWindow 16 | @StateObject private var conversationManager = ConversationManager.shared 17 | @StateObject private var indexStore: IndexStore = IndexStore.shared 18 | @StateObject private var lengthyTasksController: LengthyTasksController = LengthyTasksController.shared 19 | @StateObject private var converationController: ConversationController = ConversationController.shared 20 | @StateObject private var actionManager: ActionManager = ActionManager.shared 21 | 22 | let persistenceController = PersistenceController.shared 23 | 24 | var body: some Scene { 25 | 26 | WindowGroup { 27 | ContentView() 28 | .environment(\.managedObjectContext, persistenceController.container.viewContext) 29 | .environmentObject(conversationManager) 30 | .environmentObject(indexStore) 31 | .environmentObject(lengthyTasksController) 32 | .environmentObject(converationController) 33 | .onAppear { 34 | NSWindow.allowsAutomaticWindowTabbing = false 35 | let _ = NSApplication.shared.windows.map { $0.tabbingMode = .disallowed } 36 | } 37 | } 38 | .handlesExternalEvents(matching: Set(arrayLiteral: "defaultView")) 39 | .commands { 40 | CommandMenu("Chat") { 41 | Button("New Chat") { 42 | conversationManager.newConversation(viewContext: persistenceController.container.viewContext, openWindow: openWindow) 43 | } 44 | .keyboardShortcut(KeyboardShortcut("N")) 45 | Button("\(converationController.panelIsShown ? "Hide": "Show") Panel") { 46 | withAnimation(.spring()) { 47 | converationController.panelIsShown.toggle() 48 | } 49 | } 50 | .keyboardShortcut(KeyboardShortcut("P")) 51 | } 52 | SidebarCommands() 53 | CommandGroup(after: .windowList, addition: { 54 | Button("Conversations") { 55 | conversationManager.bringConversationToFront(openWindow: openWindow) 56 | } 57 | .keyboardShortcut(KeyboardShortcut("0")) 58 | }) 59 | CommandGroup(after: .toolbar, addition: { 60 | Button("Enter Full Screen") { 61 | for index in NSApplication.shared.windows.indices { 62 | NSApplication.shared.windows[index].toggleFullScreen(nil) 63 | } 64 | } 65 | .keyboardShortcut("f", modifiers: [.command, .control]) 66 | }) 67 | } 68 | 69 | WindowGroup("Actions") { 70 | ActionsView() 71 | .environmentObject(actionManager) 72 | } 73 | .handlesExternalEvents(matching: Set(arrayLiteral: "actions")) 74 | 75 | Settings { 76 | SettingsView() 77 | .environment(\.managedObjectContext, persistenceController.container.viewContext) 78 | .environmentObject(conversationManager) 79 | } 80 | .windowResizability(.contentSize) 81 | 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /FreeChat/FileChatAppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileChatAppDelegate.swift 3 | // FileChat 4 | // 5 | 6 | import SwiftUI 7 | 8 | class FileChatAppDelegate: NSObject, NSApplicationDelegate, ObservableObject { 9 | 10 | @AppStorage("selectedModelId") private var selectedModelId: String? 11 | 12 | func application(_ application: NSApplication, open urls: [URL]) { 13 | let viewContext = PersistenceController.shared.container.viewContext 14 | do { 15 | let req = Model.fetchRequest() 16 | req.predicate = NSPredicate(format: "name IN %@", urls.map({ $0.lastPathComponent })) 17 | let existingModels = try viewContext.fetch(req).compactMap({ $0.url }) 18 | 19 | for url in urls { 20 | guard !existingModels.contains(url) else { continue } 21 | let insertedModel = try Model.create(context: viewContext, fileURL: url) 22 | selectedModelId = insertedModel.id?.uuidString 23 | } 24 | 25 | NotificationCenter.default.post(name: NSNotification.Name("selectedModelDidChange"), object: selectedModelId) 26 | NotificationCenter.default.post(name: NSNotification.Name("needStartNewConversation"), object: selectedModelId) 27 | } catch { 28 | print("Error saving model:", error) 29 | } 30 | } 31 | 32 | func applicationWillTerminate(_ notification: Notification) { 33 | Task { 34 | await ConversationManager.shared.agent.llama.stopServer() 35 | } 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /FreeChat/FreeChat.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | com.apple.security.inherit 10 | 11 | com.apple.security.network.client 12 | 13 | com.apple.security.network.server 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /FreeChat/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDocumentTypes 6 | 7 | 8 | CFBundleTypeExtensions 9 | 10 | gguf 11 | 12 | CFBundleTypeName 13 | gguf 14 | CFBundleTypeRole 15 | Viewer 16 | LSHandlerRank 17 | Default 18 | LSItemContentTypes 19 | 20 | public.data 21 | 22 | UTTypeIdentifier 23 | com.npc-pet.Chats.gguf 24 | 25 | 26 | CFBundleURLTypes 27 | 28 | 29 | CFBundleTypeRole 30 | Editor 31 | CFBundleURLSchemes 32 | 33 | fileChat 34 | 35 | 36 | 37 | ITSAppUsesNonExemptEncryption 38 | 39 | UTExportedTypeDeclarations 40 | 41 | 42 | UTTypeConformsTo 43 | 44 | public.data 45 | 46 | UTTypeIdentifier 47 | com.npc-pet.Chats.gguf 48 | UTTypeTagSpecification 49 | 50 | public.filename-extension 51 | 52 | gguf 53 | 54 | 55 | 56 | 57 | UTImportedTypeDeclarations 58 | 59 | 60 | UTTypeConformsTo 61 | 62 | public.data 63 | 64 | UTTypeIdentifier 65 | com.npc-pet.Chats.gguf 66 | UTTypeTagSpecification 67 | 68 | public.filename-extension 69 | 70 | gguf 71 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /FreeChat/Logic/Actions/Action.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Action.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 7/6/2024. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | import ExtensionKit 11 | import SQLite 12 | import SimilaritySearchKit 13 | 14 | struct Action: Identifiable, Codable, Equatable, Hashable { 15 | 16 | var id: UUID = UUID() 17 | 18 | var shortcut: Shortcut 19 | 20 | var active: Bool = true 21 | var confirmBeforeRunning: Bool = false 22 | 23 | var inputType: InputType = .noInput 24 | var inputDescription: String = "" 25 | 26 | public func run(input: String?) throws { 27 | // Get url 28 | let url: URL = try generateUrl(input: input) 29 | // Confirm with user if needed 30 | if confirmBeforeRunning { 31 | Task { 32 | await MainActor.run { 33 | // Send alert 34 | let alert: NSAlert = NSAlert() 35 | alert.messageText = "Are you sure you want to run the shortcut \"\(self.shortcut.name)\"?" 36 | alert.addButton(withTitle: "Cancel") 37 | alert.addButton(withTitle: "Yes") 38 | if alert.runModal() != .alertFirstButtonReturn { 39 | let _ = NSWorkspace.shared.open(url) 40 | } 41 | } 42 | } 43 | } else { 44 | // Else, or continue, open url 45 | let _ = NSWorkspace.shared.open(url) 46 | } 47 | } 48 | 49 | private func generateUrl(input: String?) throws -> URL { 50 | // Make URL 51 | var url: URL = URL(string: "shortcuts://run-shortcut")! 52 | url = url.appending( 53 | queryItems: [ 54 | URLQueryItem(name: "name", value: shortcut.name) 55 | ] 56 | ) 57 | // If input needed 58 | if inputType != .noInput { 59 | // Check input 60 | if let input = input { 61 | url = url.appending( 62 | queryItems: [ 63 | URLQueryItem(name: "input", value: "text"), 64 | URLQueryItem(name: "text", value: input) 65 | ] 66 | ) 67 | } else { 68 | throw ActionRunError.inputError 69 | } 70 | } 71 | return url 72 | } 73 | 74 | /// Find shortcut if name changed 75 | public mutating func locateShortcut() throws { 76 | // Try to access database 77 | // Get access to Shortcuts directory 78 | do { 79 | let _ = try FileSystemTools.openPanel( 80 | url: URL(fileURLWithPath: "/Users/\(NSUserName())/Library/Shortcuts"), 81 | files: false, 82 | folders: true, 83 | dialogTitle: "The shortcut could not be found. Press \"Open\" to give FileChat permission to view existing shortcuts" 84 | ) 85 | } catch { } 86 | // Define database file path 87 | let dbUrl: URL = URL(fileURLWithPath: "/Users/\(NSUserName())/Library/Shortcuts/Shortcuts.sqlite") 88 | // Define table structure 89 | let database: Connection = try Connection(dbUrl.posixPath()) 90 | let shortcutsTable: Table = Table("ZSHORTCUT") 91 | let id = Expression(value: "ZWORKFLOWID") 92 | let name = Expression(value: "ZNAME") 93 | let description = Expression(value: "ZACTIONSDESCRIPTION") 94 | let subtitle = Expression(value: "ZWORKFLOWSUBTITLE") 95 | let lastSynced = Expression(value: "ZLASTSYNCEDHASH") 96 | // Get and save shortcut info 97 | for shortcut in try database.prepare(shortcutsTable) { 98 | // If shortcut is not deleted 99 | if shortcut[subtitle] != nil && shortcut[lastSynced] != nil { 100 | // If shortcuts match 101 | if shortcut[id] == self.shortcut.id.uuidString { 102 | // If there was an issue 103 | if self.shortcut.name != shortcut[name] { 104 | // Fix it 105 | self.shortcut.name = shortcut[name] 106 | // Show issue fixed 107 | let alert: NSAlert = NSAlert() 108 | alert.messageText = "You renamed your shortcut, which broke the link! A link has now been reestablished." 109 | alert.addButton(withTitle: "OK") 110 | let _ = alert.runModal() 111 | return 112 | } else { 113 | // Show issue fixed 114 | let alert: NSAlert = NSAlert() 115 | alert.messageText = "No issues detected." 116 | alert.addButton(withTitle: "OK") 117 | let _ = alert.runModal() 118 | return 119 | } 120 | } 121 | } 122 | } 123 | // Show issue not fixed if shortcut was never found 124 | let alert: NSAlert = NSAlert() 125 | alert.messageText = "The shortcut could not be located." 126 | alert.addButton(withTitle: "OK") 127 | let _ = alert.runModal() 128 | } 129 | 130 | enum InputType: Codable, CaseIterable { 131 | case noInput 132 | case textInput 133 | } 134 | 135 | enum ActionRunError: Error { 136 | case inputError 137 | } 138 | 139 | var baseScore: Float { 140 | get async { 141 | let similarityIndex: SimilarityIndex = await SimilarityIndex(metric: CosineSimilarity()) 142 | await similarityIndex.addItem( 143 | id: self.shortcut.id.uuidString, 144 | text: self.shortcut.samplePrompt, 145 | metadata: ["baseScore": ""] 146 | ) 147 | return await similarityIndex.search("filler").first!.score 148 | } 149 | } 150 | 151 | } 152 | -------------------------------------------------------------------------------- /FreeChat/Logic/Actions/ActionManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActionManager.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 7/6/2024. 6 | // 7 | 8 | import Foundation 9 | import ExtensionKit 10 | import SimilaritySearchKit 11 | import SQLite 12 | 13 | class ActionManager: ValueDataModel { 14 | 15 | required init(appDirName: String = Bundle.main.applicationName ?? Bundle.main.description, datastoreName: String = "actions.json") { 16 | return 17 | } 18 | 19 | static let shared: ActionManager = ActionManager(datastoreName: "actions") 20 | 21 | @Published var availableShortcuts: [Shortcut] = [] 22 | 23 | /// Add an action 24 | public func addAction(_ action: Action) { 25 | Self.shared.values.append(action) 26 | } 27 | 28 | /// Update an existing action 29 | public func updateAction(_ action: Action) { 30 | for index in Self.shared.values.indices { 31 | if Self.shared.values[index].id == action.id { 32 | Self.shared.values[index] = action 33 | break 34 | } 35 | } 36 | } 37 | 38 | /// Remove a action 39 | public func removeAction(_ action: Action) { 40 | Self.shared.values = Self.shared.values.filter({ $0 != action }) 41 | } 42 | 43 | /// Get available shortcuts for user selection 44 | public func getAvailableShortcuts() { 45 | // Try to access database 46 | do { 47 | // Get access to Shortcuts directory 48 | do { 49 | let _ = try FileSystemTools.openPanel( 50 | url: URL(fileURLWithPath: "/Users/\(NSUserName())/Library/Shortcuts"), 51 | files: false, 52 | folders: true, 53 | dialogTitle: "Press \"Open\" to give FileChat permission to view existing shortcuts" 54 | ) 55 | } catch { } 56 | // Define database file path 57 | let dbUrl: URL = URL(fileURLWithPath: "/Users/\(NSUserName())/Library/Shortcuts/Shortcuts.sqlite") 58 | // Define table structure 59 | let database: Connection = try Connection(dbUrl.posixPath()) 60 | let shortcutsTable: Table = Table("ZSHORTCUT") 61 | let id = Expression(value: "ZWORKFLOWID") 62 | let name = Expression(value: "ZNAME") 63 | let subtitle = Expression(value: "ZWORKFLOWSUBTITLE") 64 | let lastSynced = Expression(value: "ZLASTSYNCEDHASH") 65 | // Get and save shortcut info 66 | for shortcut in try database.prepare(shortcutsTable) { 67 | // If shortcut is not deleted and not a duplicate 68 | let notCorrupted: Bool = shortcut[subtitle] != nil && shortcut[lastSynced] != nil 69 | let notDuplicate: Bool = !availableShortcuts.map({ $0.id }).contains(UUID(uuidString: shortcut[id])) 70 | let notExisting: Bool = !values.map({ $0.shortcut.id }).contains(UUID(uuidString: shortcut[id])) && !values.map({ $0.shortcut.name }).contains(shortcut[name]) 71 | if notCorrupted && notDuplicate && notExisting { 72 | // Add to list 73 | Self.shared.availableShortcuts.append( 74 | Shortcut( 75 | id: UUID(uuidString: shortcut[id])!, 76 | name: shortcut[name] 77 | ) 78 | ) 79 | } 80 | } 81 | } catch { 82 | // If fail, save blank array & output error 83 | Self.shared.availableShortcuts = [] 84 | print(error) 85 | } 86 | } 87 | 88 | // Find an action 89 | public func findActions(text: String) async -> String { 90 | // Create index 91 | let similarityIndex: SimilarityIndex = await SimilarityIndex(metric: DotProduct()) 92 | for action in Self.shared.values { 93 | await similarityIndex.addItem( 94 | id: action.shortcut.id.uuidString, 95 | text: action.shortcut.samplePrompt, 96 | metadata: ["actionId": action.id.uuidString] 97 | ) 98 | } 99 | // Search index 100 | // Max number of search results 101 | let maxResultsCount: Int = 3 102 | // Initiate search 103 | let threshold: Float = 1.3 104 | let searchResults: [SimilarityIndex.SearchResult] = await similarityIndex.search(text) 105 | // Calcuate standard deviation 106 | let expression: NSExpression = NSExpression(forFunction: "stddev:", arguments: [NSExpression(forConstantValue: searchResults.map({ $0.score }))]) 107 | let stdDev: Float = Float("\(expression.expressionValue(with: nil, context: nil) ?? 7)")! 108 | // Calculate mean 109 | let mean: Float = Float(searchResults.map({ $0.score }).reduce(0, +)) / Float(searchResults.count) 110 | // Print debug info 111 | // print("searchResults:", searchResults.map({ $0.text })) 112 | // print("searchResultsScores:", searchResults.map({ $0.score })) 113 | // print("searchResultsDistanceFromStdDev:", searchResults.map({ (abs($0.score - mean) / stdDev) })) 114 | // Filter results 115 | let filteredResults: [SimilarityIndex.SearchResult] = 116 | Array( 117 | searchResults 118 | .filter({ (abs($0.score - mean) / stdDev) >= threshold }) 119 | .sorted(by: { (abs($0.score - mean) / stdDev) <= (abs($1.score - mean) / stdDev) }) 120 | .dropLast( 121 | max(searchResults.filter({ (abs($0.score - mean) / stdDev) >= threshold }).count - maxResultsCount, 0) 122 | ) 123 | ) 124 | // print("filteredResultsScores:", filteredResults.map({ abs(Float($0.metadata["baseScore"]!)! - abs($0.score)) })) 125 | // Match search results to actions 126 | var actions: [Action] = [] 127 | for result in filteredResults { 128 | let id: UUID = UUID(uuidString: result.id)! 129 | for action in Self.shared.values { 130 | if action.shortcut.id == id { 131 | actions.append(action) 132 | break 133 | } 134 | } 135 | } 136 | // Return text 137 | // If filtered results is blank 138 | if actions.isEmpty { 139 | // Just return text 140 | return text 141 | } else { 142 | // Else, continue 143 | // let actions: [Action] = Self.shared.values 144 | let sourcesText: String = actions.map { action in 145 | let paramDescription: String = action.inputDescription.isEmpty ? "Blank Parameter" : action.inputDescription 146 | return "`\(action.shortcut.name)(\(paramDescription))`" 147 | }.joined(separator: "\n") 148 | // Process text to add search results 149 | return """ 150 | \(text) 151 | 152 | 153 | If relevant, you can execute the following system commands by making your response "`NAME OF COMMAND(TEXT VALUE OF PARAMETER)`": 154 | \(sourcesText) 155 | Else, just respond to my request, ignoring system commands. 156 | """ 157 | } 158 | } 159 | 160 | } 161 | -------------------------------------------------------------------------------- /FreeChat/Logic/Actions/Shortcut.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Shortcut.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 7/6/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Shortcut: Identifiable, Codable, Hashable { 11 | 12 | var id: UUID 13 | var name: String 14 | var samplePrompt: String = "" 15 | 16 | } 17 | -------------------------------------------------------------------------------- /FreeChat/Logic/Directory Index/ContainerManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContainerManager.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 30/5/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Class that contains information about the app's container 11 | class ContainerManager { 12 | 13 | /// Url of directory where all files (Chat records, indexes, etc) are stored 14 | static let containerUrl: URL = URL 15 | .applicationSupportDirectory 16 | .appendingPathComponent("FileChat") 17 | 18 | /// Url of directory where all indexes are stored 19 | static let indexesUrl: URL = ContainerManager 20 | .containerUrl 21 | .appendingPathComponent("Indexes") 22 | 23 | /// Url of directory where all models are stored 24 | static let modelsUrl: URL = ContainerManager 25 | .containerUrl 26 | .appendingPathComponent("Models") 27 | 28 | /// Array of all urls in the container 29 | static var allContainerDirs: [URL] { 30 | return [ 31 | ContainerManager.containerUrl, 32 | ContainerManager.indexesUrl, 33 | ContainerManager.modelsUrl 34 | ] 35 | } 36 | 37 | /// Function that initializes the container 38 | static func initContainer() { 39 | // Create container directories 40 | allContainerDirs.forEach { url in 41 | try! FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) 42 | } 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /FreeChat/Logic/Directory Index/Index Store/IndexItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndexedItem.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 30/5/2024. 6 | // 7 | 8 | import Foundation 9 | import ExtensionKit 10 | import SimilaritySearchKit 11 | import SimilaritySearchKitDistilbert 12 | 13 | extension IndexedDirectory { 14 | 15 | /// A struct representing an item in the file system that is indexed 16 | public struct IndexItem: Codable, Identifiable { 17 | 18 | /// Conform to Identifiable 19 | public var id: UUID = UUID() 20 | 21 | /// Location of the original file 22 | public var url: URL 23 | /// Name of the original file 24 | public var name: String { 25 | return url.lastPathComponent 26 | } 27 | /// Returns false if the file is still at its last recorded path 28 | public var wasMoved: Bool { 29 | return !url.fileExists() 30 | } 31 | 32 | /// Date of previous index 33 | public var prevIndexDate: Date 34 | 35 | /// Function to create directory that houses the JSON file 36 | public func createDirectory(parentDirUrl: URL) { 37 | try! FileManager.default.createDirectory(at: parentDirUrl 38 | .appendingPathComponent("\(id.uuidString)"), withIntermediateDirectories: true) 39 | } 40 | 41 | /// Function to delete directory that houses the JSON file and its contents 42 | public func deleteDirectory(parentDirUrl: URL) { 43 | let indexUrl: URL = getIndexUrl(parentDirUrl: parentDirUrl) 44 | print("indexUrl:", indexUrl.posixPath()) 45 | let dirUrl: URL = parentDirUrl.appendingPathComponent("\(id.uuidString)") 46 | do { 47 | try FileManager.default.removeItem(at: indexUrl) 48 | try FileManager.default.removeItem(at: dirUrl) 49 | } catch { 50 | print("Remove error:", error) 51 | } 52 | // Indicate change 53 | print("Removed file at \"\(self.url.posixPath())\" from index.") 54 | } 55 | 56 | /// Function to get URL of index items JSON file's parent directory 57 | private func getIndexDirUrl(parentDirUrl: URL) -> URL { 58 | return parentDirUrl 59 | .appendingPathComponent("\(id.uuidString)") 60 | } 61 | 62 | /// Function to get URL of index items JSON file 63 | private func getIndexUrl(parentDirUrl: URL) -> URL { 64 | return getIndexDirUrl(parentDirUrl: parentDirUrl) 65 | .appendingPathComponent("SimilaritySearchKitIndex.json") 66 | } 67 | 68 | /// Function that returns index items in JSON file 69 | public func getIndexItems(parentDirUrl: URL, taskId: UUID, taskCount: Int) async -> [SimilarityIndex.IndexItem] { 70 | // Init index 71 | let similarityIndex: SimilarityIndex = await SimilarityIndex( 72 | model: DistilbertEmbeddings(), 73 | metric: DotProduct() 74 | ) 75 | // Get index directory url 76 | let indexUrl: URL = getIndexUrl(parentDirUrl: parentDirUrl).deletingLastPathComponent() 77 | // Load index items 78 | let indexItems: [SimilarityIndex.IndexItem] = ( 79 | try? similarityIndex.loadIndex(fromDirectory: indexUrl) ?? [] 80 | ) ?? [] 81 | // Increment tasks 82 | LengthyTasksController.shared.incrementTask(id: taskId, newProgress: Double(1 / taskCount)) 83 | // Return index 84 | return indexItems 85 | } 86 | 87 | /// Function that saves a similarity index 88 | private func saveIndex(parentDirUrl: URL, similarityIndex: SimilarityIndex) { 89 | let _ = try! similarityIndex.saveIndex(toDirectory: getIndexDirUrl(parentDirUrl: parentDirUrl)) 90 | } 91 | 92 | /// Function that re-scans the file, then saves the updated similarity index 93 | public mutating func updateIndex(parentDirUrl: URL, taskId: UUID, taskCount: Int) async { 94 | // Exit update if file was moved 95 | if self.wasMoved { 96 | // Delete index and its directory 97 | deleteDirectory(parentDirUrl: parentDirUrl) 98 | // Exit 99 | return 100 | } 101 | // Exit update if last scanned after last modification 102 | do { 103 | let path: String = self.url.posixPath() 104 | let attributes: [FileAttributeKey: Any] = try FileManager.default.attributesOfItem(atPath: path) 105 | let modificationDate: Date = attributes[FileAttributeKey.modificationDate] as? Date ?? Date.distantFuture 106 | if modificationDate < self.prevIndexDate { 107 | // print("File \"\(url.posixPath())\" scanned since last change") 108 | return 109 | } 110 | } catch { } 111 | // print("File not \"\(url.posixPath())\" scanned since last change") 112 | // Switch flag 113 | indexState.startIndex() 114 | // Extract text from file 115 | let fileText: String = await (try? TextExtractor.extractText(url: url)) ?? "" 116 | // Split text 117 | let splitTexts: [String] = fileText.split(every: 512) 118 | // Init new similarity index 119 | let similarityIndex: SimilarityIndex = await SimilarityIndex( 120 | model: DistilbertEmbeddings(), 121 | metric: DotProduct() 122 | ) 123 | // Add texts to index 124 | for (index, splitText) in splitTexts.enumerated() { 125 | let indexItemId: String = "\(id.uuidString)_\(index)" 126 | let filename: String = url.lastPathComponent 127 | await similarityIndex.addItem( 128 | id: indexItemId, 129 | text: splitText, 130 | metadata: ["source": "\(filename)", "itemIndex": "\(index)"] 131 | ) 132 | } 133 | // Save index 134 | saveIndex(parentDirUrl: parentDirUrl, similarityIndex: similarityIndex) 135 | // Switch flag 136 | indexState.finishIndex() 137 | // Show file updated 138 | print("Updated index for file \"\(url.posixPath())\"") 139 | // Record last index date 140 | self.prevIndexDate = Date.now 141 | // Increment task 142 | LengthyTasksController.shared.incrementTask(id: id, newProgress: Double(1 / taskCount)) 143 | } 144 | 145 | /// The current indexing state, used to prevent duplicate indexes 146 | public var indexState: IndexState = .noIndex 147 | 148 | /// Enum of all possible index states 149 | public enum IndexState: CaseIterable, Codable { 150 | 151 | case noIndex, indexing, indexed // New index item always starts with IndexState of .noIndex 152 | 153 | // Mutating functions to toggle state 154 | mutating func startIndex() { 155 | self = .indexing 156 | } 157 | mutating func finishIndex() { 158 | self = .indexed 159 | } 160 | 161 | } 162 | 163 | } 164 | 165 | } 166 | -------------------------------------------------------------------------------- /FreeChat/Logic/Directory Index/Index Store/IndexStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndexStore.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 30/5/2024. 6 | // 7 | 8 | import Foundation 9 | import ExtensionKit 10 | import SimilaritySearchKit 11 | import BezelNotification 12 | 13 | /// Provides information and methods to interact with indexed directories and their indexes 14 | class IndexStore: ValueDataModel { 15 | 16 | required init(appDirName: String = Bundle.main.applicationName ?? Bundle.main.description, datastoreName: String = "\(Bundle.main.applicationName ?? Bundle.main.description)") { 17 | super.init(appDirName: appDirName, datastoreName: datastoreName) 18 | } 19 | 20 | /// Shared singleton object 21 | static let shared: IndexStore = IndexStore() 22 | 23 | /// Controls whether new messages can be sent 24 | var isLoadingIndex: Bool = false 25 | 26 | /// Stores the currently selected directory 27 | var selectedDirectory: IndexedDirectory? = nil { 28 | didSet { 29 | loadSimilarityIndex() 30 | } 31 | } 32 | 33 | /// Caches currently selected index, as it takes a significant time to load from disk 34 | var similarityIndex: SimilarityIndex? = nil 35 | 36 | /// Save the selected IndexedDirectory to disk after it is mutated 37 | private func saveSelectedDirectory() { 38 | if selectedDirectory != nil { 39 | for index in self.values.indices { 40 | if self.values[index].id == selectedDirectory!.id { 41 | Task { 42 | await MainActor.run { 43 | self.values[index] = selectedDirectory! 44 | } 45 | } 46 | break 47 | } 48 | } 49 | } 50 | } 51 | 52 | /// Loads similarity index from disk 53 | public func loadSimilarityIndex() { 54 | if selectedDirectory != nil { 55 | isLoadingIndex = true 56 | Task { 57 | await similarityIndex = selectedDirectory!.loadIndex() 58 | isLoadingIndex = false 59 | await MainActor.run { 60 | let notification: BezelNotification = BezelNotification(text: "FileChat has finished loading your folder", visibleTime: 2) 61 | notification.show() 62 | } 63 | } 64 | } 65 | } 66 | 67 | /// Sets up a new IndexedDirectory 68 | public func addIndexedDirectory(url: URL) { 69 | Task { 70 | var indexedDir: IndexedDirectory = IndexedDirectory(url: url) 71 | await indexedDir.setup() 72 | await addToIndexedDir(indexedDir: indexedDir) 73 | } 74 | } 75 | 76 | /// Adds IndexedDirectory to JSON storage 77 | private func addToIndexedDir(indexedDir: IndexedDirectory) async { 78 | await MainActor.run { 79 | IndexStore.shared.values.append(indexedDir) 80 | } 81 | } 82 | 83 | /// Purges an index from disk and removes it from the JSON storage 84 | func removeIndex(indexedDir: IndexedDirectory) { 85 | // Remove directory 86 | do { 87 | try FileManager.default.removeItem(at: indexedDir.indexUrl) 88 | } catch {} 89 | // Remove index 90 | IndexStore.shared.values = IndexStore.shared.values.filter({ $0 != indexedDir }) 91 | } 92 | 93 | /// Updates the currently selected index with incremental indexing 94 | func updateIndex() async { 95 | isLoadingIndex = true 96 | if selectedDirectory != nil { 97 | // Run on the main actor to update UI 98 | await MainActor.run { 99 | // Check file status 100 | let fileMoved: Bool = !selectedDirectory!.url.fileExists() 101 | // Reselect directory if needed 102 | if fileMoved { 103 | var tempUrl: URL? = nil 104 | repeat { 105 | do { 106 | tempUrl = try FileSystemTools.openPanel( 107 | url: URL.desktopDirectory, 108 | files: false, 109 | folders: true, 110 | dialogTitle: "The folder was moved. Please reselect it, then click \"Open\"" 111 | ) 112 | } catch { } 113 | } while tempUrl == nil 114 | // Replace url of current indexItems to prevent duplicate indexing 115 | for index in selectedDirectory!.indexItems.indices { 116 | // Replace paths 117 | selectedDirectory!.indexItems[index].url.replaceParentUrl( 118 | oldParentUrl: selectedDirectory!.url, 119 | newParentUrl: tempUrl! 120 | ) 121 | } 122 | selectedDirectory!.url = tempUrl! 123 | } else { 124 | // Select directory for permissions 125 | var noError: Bool = false 126 | repeat { 127 | do { 128 | let _ = try FileSystemTools.openPanel( 129 | url: selectedDirectory!.url, 130 | files: false, 131 | folders: true, 132 | dialogTitle: "FileChat needs permissions to access the folder. Select it, then click \"Open\"" 133 | ) 134 | noError = true 135 | } catch { } 136 | } while !noError 137 | } 138 | // Update index and UI 139 | Task { 140 | await selectedDirectory!.updateDirectoryIndex() 141 | saveSelectedDirectory() 142 | } 143 | // Notify users that update is finished 144 | let notification: BezelNotification = BezelNotification(text: "FileChat has finished updating the folder's index. It will now be loaded into memory.", visibleTime: 2) 145 | notification.show() 146 | // Load the updated SimilarityIndex into memory 147 | loadSimilarityIndex() 148 | } 149 | } 150 | isLoadingIndex = false 151 | } 152 | 153 | func search(text: String) async -> String { 154 | // Max number of search results 155 | let maxResultsCount: Int = 5 156 | // Initiate search 157 | let threshhold: Float = 7.5 158 | // print("itemsInIndex:", IndexStore.shared.similarityIndex!.indexItems.map({ $0.text })) 159 | let searchResults: [SimilarityIndex.SearchResult] = await IndexStore.shared.similarityIndex!.search(text) 160 | // print("searchResults:", searchResults.map({ $0.text })) 161 | // print("searchResultsScores:", searchResults.map({ abs(100 - abs($0.score)) })) 162 | let filteredResults: [SimilarityIndex.SearchResult] = 163 | Array( 164 | searchResults 165 | .sorted(by: { abs(100 - abs($0.score)) <= abs(100 - abs($1.score)) }) 166 | .filter({ abs(100 - abs($0.score)) <= threshhold }) 167 | .dropLast( 168 | max(searchResults.filter({ abs(100 - abs($0.score)) <= threshhold }).count - maxResultsCount, 0) 169 | ) 170 | ) 171 | // print("filteredResultsCount:", filteredResults.count) 172 | // print("filteredResultsScores:", filteredResults.map({ abs(100 - abs($0.score)) })) 173 | // If filtered results is blank 174 | if filteredResults.isEmpty { 175 | // Just return text 176 | return text 177 | } else { 178 | // Else, continue 179 | // Get full text 180 | let resultsWithIndexes: [(index: Int, result: SearchResult)] = filteredResults.map({ result in 181 | let index: Int = Int(result.metadata["itemIndex"]!)! 182 | return (index, result) 183 | }) 184 | let fullResults: [String] = resultsWithIndexes.map({ indexedResult in 185 | let result: SearchResult = indexedResult.result 186 | // Get preceding text 187 | let preData: [String: String] = { 188 | var metadata: [String: String] = result.metadata 189 | metadata["itemIndex"] = String(indexedResult.index - 1) 190 | return metadata 191 | }() 192 | let preText: String = IndexStore.shared.similarityIndex!.indexItems.filter({ $0.metadata == preData }).first?.text ?? "" 193 | // Get following text 194 | let postData: [String: String] = { 195 | var metadata: [String: String] = result.metadata 196 | metadata["itemIndex"] = String(indexedResult.index + 1) 197 | return metadata 198 | }() 199 | let postText: String = IndexStore.shared.similarityIndex!.indexItems.filter({ $0.metadata == postData }).first?.text ?? "" 200 | // Full text 201 | return "\(preText)\(result.text)\(postText)" 202 | }) 203 | // Join to prompt 204 | let sourcesText: String = fullResults.map { "\($0)\n" }.joined(separator: "\n") 205 | // Process text to add search results 206 | let modifiedPrompt: String = """ 207 | \(text) 208 | 209 | 210 | Here is some information that may or may not be relevant to my request: 211 | "\(sourcesText)" 212 | """ 213 | return modifiedPrompt 214 | } 215 | } 216 | 217 | 218 | } 219 | -------------------------------------------------------------------------------- /FreeChat/Logic/Directory Index/Index Store/IndexedDirectory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndexedDirectory.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 30/5/2024. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | import ExtensionKit 11 | import SimilaritySearchKit 12 | import SimilaritySearchKitDistilbert 13 | 14 | /// A struct representing a directory that is indexed 15 | public struct IndexedDirectory: Codable, Identifiable, Equatable, Hashable { 16 | 17 | /// Conform to Equatable 18 | public static func == (lhs: IndexedDirectory, rhs: IndexedDirectory) -> Bool { 19 | return lhs.id == rhs.id 20 | } 21 | 22 | /// Conform to Hashable for SwiftUI 23 | public func hash(into hasher: inout Hasher) { 24 | hasher.combine(id) 25 | } 26 | 27 | /// Conform to Identifiable 28 | public var id: UUID = UUID() 29 | 30 | /// Location of the original directory 31 | public var url: URL 32 | 33 | /// Url of the directory where indexes are stored 34 | public var indexUrl: URL { 35 | return ContainerManager.indexesUrl.appendingPathComponent(id.uuidString) 36 | } 37 | 38 | /// Items whose index is stores in the directory at "indexUrl" 39 | public var indexItems: [IndexedDirectory.IndexItem] = [] 40 | 41 | /// Url of all files in the index directory 42 | private var indexDirFiles: [URL] { 43 | return try! indexUrl.listDirectory() 44 | } 45 | 46 | /// Url of all directories in the index directory 47 | private var indexDirDirs: [URL] { 48 | return try! indexUrl.listDirectory().filter({ $0.hasDirectoryPath }) 49 | } 50 | 51 | /// Used for loading, must cache after initial load to improve performance 52 | public func loadIndex() async -> SimilarityIndex { 53 | // Init index 54 | let similarityIndex: SimilarityIndex = await SimilarityIndex( 55 | model: DistilbertEmbeddings(), 56 | metric: DotProduct() 57 | ) 58 | // Load index items 59 | let task: LengthyTask = LengthyTasksController.shared.addTask(name: "Loading \"\(url.lastPathComponent)\" Folder", progress: 0.0) 60 | for indexItem in indexItems { 61 | await similarityIndex.indexItems += indexItem.getIndexItems(parentDirUrl: indexUrl, taskId: task.id, taskCount: indexItems.count) 62 | } 63 | LengthyTasksController.shared.removeTask(id: task.id) 64 | // Return index 65 | return similarityIndex 66 | } 67 | 68 | // Update index 69 | public mutating func updateDirectoryIndex() async { 70 | // Update for each file 71 | var files: [URL] = [] 72 | do { 73 | files = try self.url.listDirectory() 74 | print("Files in \"\(self.url.posixPath())\":", files) 75 | } catch { 76 | print("Error listing directory:", error) 77 | } 78 | let task: LengthyTask = LengthyTasksController.shared.addTask(name: "Loading \"\(url.lastPathComponent)\" Folder Index", progress: 0.0) 79 | for file in files { 80 | await self.indexFile(file: file, taskId: task.id, taskCount: indexItems.count) 81 | // print("Indexing file \"\(file.posixPath())\"") 82 | } 83 | // Filter for moved items 84 | let tempIndexItems: [IndexItem] = indexItems.filter({ $0.wasMoved }) 85 | for index in tempIndexItems.indices { 86 | tempIndexItems[index].deleteDirectory(parentDirUrl: indexUrl) 87 | } 88 | indexItems = indexItems.filter({ !$0.wasMoved }) 89 | LengthyTasksController.shared.removeTask(id: task.id) 90 | } 91 | 92 | /// Index a file 93 | public mutating func indexFile(file: URL, taskId: UUID, taskCount: Int) async { 94 | // Check if new file 95 | let isNewFile: Bool = !(indexItems.map({ $0.url }).contains(file)) 96 | // If yes, add file to indexedItems 97 | if isNewFile { addNewFileToIndex(url: file) } 98 | // Call updateIndex function 99 | for currIndex in indexItems.indices { 100 | if indexItems[currIndex].url == file { 101 | await indexItems[currIndex].updateIndex(parentDirUrl: indexUrl, taskId: taskId, taskCount: taskCount) 102 | break 103 | } 104 | } 105 | } 106 | 107 | /// Add file to index 108 | private mutating func addNewFileToIndex(url: URL) { 109 | // Make new IndexItem 110 | let indexItem: IndexItem = IndexItem(url: url, prevIndexDate: Date.distantPast) 111 | // Make directory 112 | indexItem.createDirectory(parentDirUrl: indexUrl) 113 | // Add to indexItems 114 | indexItems.append(indexItem) 115 | // Indicate change 116 | print("Added new file at \"\(url.posixPath())\" to index.") 117 | } 118 | 119 | /// Initialize directory 120 | public mutating func setup() async { 121 | // Make directory 122 | try! FileManager.default.createDirectory(at: indexUrl, withIntermediateDirectories: true) 123 | // Index files 124 | for currFile in (try! url.listDirectory()) { 125 | addNewFileToIndex(url: currFile) 126 | } 127 | let task: LengthyTask = LengthyTasksController.shared.addTask(name: "Setting up \"\(url.lastPathComponent)\" Folder", progress: 0.0) 128 | for currIndex in indexItems.indices { 129 | let selfIndexUrl: URL = indexUrl 130 | await indexItems[currIndex].updateIndex(parentDirUrl: selfIndexUrl, taskId: task.id, taskCount: indexItems.count) 131 | } 132 | LengthyTasksController.shared.removeTask(id: task.id) 133 | } 134 | 135 | /// Reindex directory 136 | public mutating func reindexDirectory() async { 137 | // Clear indexItems 138 | indexItems.removeAll() 139 | // Clear directory 140 | try! FileManager.default.removeItem(at: indexUrl) 141 | /// Re-setup 142 | await setup() 143 | } 144 | 145 | /// Show index directory 146 | public func showIndexDirectory() { 147 | NSWorkspace.shared.activateFileViewerSelecting([indexUrl]) 148 | } 149 | 150 | } 151 | -------------------------------------------------------------------------------- /FreeChat/Logic/Extensions/Extension+String.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extension+String.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 3/6/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | 12 | /// Splits a string into groups of `every` n characters, grouping from left-to-right by default. If `backwards` is true, right-to-left. 13 | public func split(every: Int, backwards: Bool = false) -> [String] { 14 | var result = [String]() 15 | 16 | for i in stride(from: 0, to: self.count, by: every) { 17 | switch backwards { 18 | case true: 19 | let endIndex = self.index(self.endIndex, offsetBy: -i) 20 | let startIndex = self.index(endIndex, offsetBy: -every, limitedBy: self.startIndex) ?? self.startIndex 21 | result.insert(String(self[startIndex.. String? { 34 | return (range(of: from)?.upperBound).flatMap { substringFrom in 35 | (range(of: to, range: substringFrom.. Self { 15 | let record = self.init(context: ctx) 16 | record.createdAt = Date() 17 | record.lastMessageAt = record.createdAt 18 | 19 | try ctx.save() 20 | return record 21 | } 22 | 23 | var orderedMessages: [Message] { 24 | let set = messages as? Set ?? [] 25 | return set.sorted { 26 | ($0.createdAt ?? Date()) < ($1.createdAt ?? Date()) 27 | } 28 | } 29 | 30 | var titleWithDefault: String { 31 | if title != nil { 32 | return title! 33 | } else if messages?.count ?? 0 > 0 { 34 | let firstMessage = orderedMessages.first! 35 | let prefix = firstMessage.text?.prefix(200) 36 | if let firstLine = prefix?.split(separator: "\n").first { 37 | return String(firstLine) 38 | } else { 39 | return dateTitle 40 | } 41 | } else { 42 | return dateTitle 43 | } 44 | } 45 | 46 | var dateTitle: String { 47 | (createdAt ?? Date())!.formatted(Conversation.titleFormat) 48 | } 49 | 50 | static let titleFormat = Date.FormatStyle() 51 | .year() 52 | .day() 53 | .month() 54 | .hour() 55 | .minute() 56 | .locale(Locale(identifier: "en_US")) 57 | 58 | public override func willSave() { 59 | super.willSave() 60 | 61 | if !isDeleted, changedValues()["updatedAt"] == nil { 62 | self.setValue(Date(), forKey: "updatedAt") 63 | } 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/ConversationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConversationController.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 6/6/2024. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Speech 11 | import AVFoundation 12 | 13 | class ConversationController: ObservableObject { 14 | 15 | /// Shared singleton object 16 | static let shared: ConversationController = ConversationController() 17 | 18 | /// Controls whether folder selecting panel is shown 19 | @Published var panelIsShown: Bool = false 20 | 21 | /// Controls whether the LLM reads its reply aloud 22 | @AppStorage("readAloud") var readAloud: Bool = false { 23 | didSet { 24 | if !readAloud { 25 | let boundary: AVSpeechBoundary = .word 26 | speechSynthesizer.stopSpeaking(at: boundary) 27 | } 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/ConversationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConversationManager.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 9/11/23. 6 | // 7 | 8 | import Foundation 9 | import CoreData 10 | import SwiftUI 11 | 12 | @MainActor 13 | class ConversationManager: ObservableObject { 14 | 15 | static let shared = ConversationManager() 16 | 17 | var summonRegistered = false 18 | 19 | @AppStorage("systemPrompt") private var systemPrompt: String = DEFAULT_SYSTEM_PROMPT 20 | @AppStorage("contextLength") private var contextLength: Int = DEFAULT_CONTEXT_LENGTH 21 | 22 | @Published var agent: Agent = Agent(id: "Llama", systemPrompt: "", modelPath: "", contextLength: DEFAULT_CONTEXT_LENGTH) 23 | @Published var loadingModelId: String? 24 | 25 | private static var dummyConversation: Conversation = { 26 | let tempMoc = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) 27 | return Conversation(context: tempMoc) 28 | }() 29 | 30 | // in the foreground 31 | @Published var currentConversation: Conversation = ConversationManager.dummyConversation 32 | 33 | func showConversation() -> Bool { 34 | return currentConversation != ConversationManager.dummyConversation 35 | } 36 | 37 | func unsetConversation() { 38 | currentConversation = ConversationManager.dummyConversation 39 | } 40 | 41 | func bringConversationToFront(openWindow: OpenWindowAction) { 42 | // bring conversation window to front 43 | if let conversationWindow = NSApp.windows.first(where: { $0.title == currentConversation.titleWithDefault || $0.title == "FileChat" }) { 44 | conversationWindow.makeKeyAndOrderFront(self) 45 | } else { 46 | // conversation window is not open, so open it 47 | openWindow(id: "main") 48 | } 49 | } 50 | 51 | func newConversation(viewContext: NSManagedObjectContext, openWindow: OpenWindowAction) { 52 | bringConversationToFront(openWindow: openWindow) 53 | 54 | do { 55 | // delete old conversations with no messages 56 | let fetchRequest = Conversation.fetchRequest() 57 | let conversations = try viewContext.fetch(fetchRequest) 58 | for conversation in conversations { 59 | if conversation.messages?.count == 0 { 60 | viewContext.delete(conversation) 61 | } 62 | } 63 | 64 | // make a new convo 65 | try withAnimation { 66 | let c = try Conversation.create(ctx: viewContext) 67 | currentConversation = c 68 | } 69 | } catch (let error) { 70 | print("Error creating new conversation", error.localizedDescription) 71 | } 72 | } 73 | 74 | @MainActor 75 | func rebootAgent(systemPrompt: String? = nil, model: Model, viewContext: NSManagedObjectContext) { 76 | let systemPrompt = systemPrompt ?? self.systemPrompt 77 | guard let url = model.url else { 78 | return 79 | } 80 | 81 | Task { 82 | await agent.llama.stopServer() 83 | 84 | agent = Agent(id: "Llama", systemPrompt: systemPrompt, modelPath: url.path, contextLength: contextLength) 85 | loadingModelId = model.id?.uuidString 86 | 87 | model.error = nil 88 | 89 | loadingModelId = nil 90 | try? viewContext.save() 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/DownloadManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadManager.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 9/28/23. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | import SwiftUI 11 | 12 | class DownloadManager: NSObject, ObservableObject { 13 | 14 | static var shared = DownloadManager() 15 | 16 | @AppStorage("selectedModelId") private var selectedModelId: String? 17 | 18 | var viewContext: NSManagedObjectContext? 19 | 20 | private var urlSession: URLSession! 21 | @Published var tasks: [URLSessionTask] = [] 22 | @Published var lastUpdatedAt = Date() 23 | 24 | override private init() { 25 | super.init() 26 | 27 | let config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background2") 28 | config.isDiscretionary = false 29 | 30 | // Warning: Make sure that the URLSession is created only once (if an URLSession still 31 | // exists from a previous download, it doesn't create a new URLSession object but returns 32 | // the existing one with the old delegate object attached) 33 | urlSession = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue()) 34 | 35 | updateTasks() 36 | } 37 | 38 | func startDownload(url: URL) { 39 | print("Starting download", url) 40 | // ignore download if it's already in progress 41 | if tasks.contains(where: { $0.originalRequest?.url == url }) { return } 42 | let task = urlSession.downloadTask(with: url) 43 | tasks.append(task) 44 | task.resume() 45 | } 46 | 47 | private func updateTasks() { 48 | urlSession.getAllTasks { tasks in 49 | DispatchQueue.main.async { 50 | self.tasks = tasks 51 | self.lastUpdatedAt = Date() 52 | } 53 | } 54 | } 55 | } 56 | 57 | extension DownloadManager: URLSessionDelegate, URLSessionDownloadDelegate { 58 | 59 | func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten _: Int64, totalBytesExpectedToWrite _: Int64) { 60 | DispatchQueue.main.async { 61 | let now = Date() 62 | if self.lastUpdatedAt.timeIntervalSince(now) > 10 { 63 | self.lastUpdatedAt = now 64 | } 65 | } 66 | } 67 | 68 | func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 69 | os_log("Download finished: %@ %@", type: .info, location.absoluteString, downloadTask.originalRequest?.url?.lastPathComponent ?? "") 70 | // The file at location is temporary and will be gone afterwards 71 | 72 | // move file to app resources 73 | let fileName = downloadTask.originalRequest?.url?.lastPathComponent ?? "default.gguf" 74 | let folderName = Bundle.main.infoDictionary?[kCFBundleNameKey as String] as? String ?? "FileChat" 75 | let destDir = URL.applicationSupportDirectory.appending(path: folderName, directoryHint: .isDirectory) 76 | let destinationURL = destDir.appending(path: fileName) 77 | 78 | let fileManager = FileManager.default 79 | try? fileManager.removeItem(at: destinationURL) 80 | 81 | do { 82 | let folderExists = (try? destDir.checkResourceIsReachable()) ?? false 83 | if !folderExists { 84 | try fileManager.createDirectory(at: destDir, withIntermediateDirectories: false) 85 | } 86 | try fileManager.moveItem(at: location, to: destinationURL) 87 | } catch { 88 | os_log("FileManager copy error at %@ to %@ error: %@", type: .error, location.absoluteString, destinationURL.absoluteString, error.localizedDescription) 89 | return 90 | } 91 | 92 | // create Model that points to file 93 | os_log("DownloadManager creating model", type: .info) 94 | DispatchQueue.main.async { [self] in 95 | let ctx = viewContext ?? PersistenceController.shared.container.viewContext 96 | do { 97 | let m = try Model.create(context: ctx, fileURL: destinationURL) 98 | os_log("DownloadManager created model %@", type: .info, m.id?.uuidString ?? "missing id") 99 | selectedModelId = m.id?.uuidString 100 | } catch { 101 | os_log("Error creating model on main thread: %@", type: .error, error.localizedDescription) 102 | } 103 | lastUpdatedAt = Date() 104 | } 105 | } 106 | 107 | func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 108 | if let error = error { 109 | os_log("Download error: %@", type: .error, String(describing: error)) 110 | } else { 111 | os_log("Task finished: %@", type: .info, task) 112 | } 113 | 114 | let taskId = task.taskIdentifier 115 | DispatchQueue.main.async { 116 | self.tasks.removeAll(where: { $0.taskIdentifier == taskId }) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/GPU.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPU.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 12/16/23. 6 | // 7 | 8 | import Foundation 9 | import AppleGPUInfo 10 | 11 | final class GPU: ObservableObject { 12 | 13 | /// Shared singleton object 14 | static let shared = GPU() 15 | 16 | /// Returns GPU core count 17 | static var coreCount: Int { 18 | do { 19 | let gpuDevice: GPUInfoDevice = try GPUInfoDevice() 20 | return gpuDevice.coreCount 21 | } catch { 22 | return 10 23 | } 24 | } 25 | 26 | /// Returns if the GPU is available 27 | @Published private(set) var available: Bool = false 28 | 29 | /// Initializer 30 | init() { 31 | // LLaMa crashes on intel macs when gpu-layers != 0, not sure why 32 | available = getMachineHardwareName() == "arm64" 33 | } 34 | 35 | /// Gets the name of the system 36 | private func getMachineHardwareName() -> String? { 37 | var sysInfo = utsname() 38 | let retVal = uname(&sysInfo) 39 | var finalString: String? = nil 40 | 41 | if retVal == EXIT_SUCCESS { 42 | let bytes = Data(bytes: &sysInfo.machine, count: Int(_SYS_NAMELEN)) 43 | finalString = String(data: bytes, encoding: .utf8) 44 | } 45 | 46 | // _SYS_NAMELEN will include a billion null-terminators. Clear those out so string comparisons work as you expect. 47 | return finalString?.trimmingCharacters(in: CharacterSet(charactersIn: "\0")) 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/Message+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Message+Extensions.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 7/31/23. 6 | // 7 | 8 | import CoreData 9 | import Foundation 10 | import SimilaritySearchKit 11 | 12 | extension Message { 13 | 14 | static let USER_SPEAKER_ID = "`## User" 15 | 16 | static func create( 17 | text: String, 18 | fromId: String, 19 | conversation: Conversation, 20 | systemPrompt: String, 21 | inContext ctx: NSManagedObjectContext 22 | ) async throws -> Self { 23 | let record = self.init(context: ctx) 24 | record.conversation = conversation 25 | record.createdAt = Date() 26 | record.systemPrompt = systemPrompt 27 | conversation.lastMessageAt = record.createdAt 28 | record.fromId = fromId 29 | 30 | // If SimilarityIndex is loaded 31 | if IndexStore.shared.similarityIndex != nil { 32 | // Add info & actions to prompt 33 | var finalPrompt: String = await IndexStore.shared.search(text: text) 34 | // Add actions 35 | if UserDefaults.standard.bool(forKey: "useActions") { 36 | finalPrompt = await ActionManager.shared.findActions( 37 | text: finalPrompt 38 | ) 39 | } 40 | // Send back on main thread 41 | record.text = finalPrompt 42 | await MainActor.run { 43 | do { 44 | try ctx.save() 45 | } catch { print(error) } 46 | } 47 | // Return result 48 | return record 49 | } else { 50 | // Add actions to prompt 51 | var finalPrompt: String = text 52 | if UserDefaults.standard.bool(forKey: "useActions") { 53 | finalPrompt = await ActionManager.shared.findActions( 54 | text: finalPrompt 55 | ) 56 | } 57 | // Send back on main thread 58 | record.text = finalPrompt 59 | await MainActor.run { 60 | do { 61 | try ctx.save() 62 | } catch { print(error) } 63 | } 64 | // Return result 65 | return record 66 | } 67 | } 68 | 69 | public override func willSave() { 70 | super.willSave() 71 | 72 | if !isDeleted, changedValues()["updatedAt"] == nil { 73 | self.setValue(Date(), forKey: "updatedAt") 74 | } 75 | 76 | if !isDeleted, createdAt == nil { 77 | self.setValue(Date(), forKey: "createdAt") 78 | } 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/Model+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Model+Extensions.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 8/8/23. 6 | // 7 | 8 | import Foundation 9 | import CoreData 10 | import OSLog 11 | 12 | enum ModelCreateError: LocalizedError { 13 | var errorDescription: String? { 14 | switch self { 15 | case .unknownFormat: 16 | "Model files must be in .gguf format" 17 | case .accessNotAllowed(let url): 18 | "File access not allowed to \(url.absoluteString)" 19 | } 20 | } 21 | 22 | case unknownFormat 23 | case accessNotAllowed(_ url: URL) 24 | } 25 | 26 | extension Model { 27 | @available(*, deprecated, message: "use nil instead") 28 | static let unsetModelId = "unset" 29 | static let defaultModelUrl = URL(string: "https://huggingface.co/bullerwins/Meta-Llama-3.1-8B-Instruct-GGUF/resolve/main/Meta-Llama-3.1-8B-Instruct-Q6_K.gguf")! 30 | 31 | var url: URL? { 32 | if bookmark == nil { return nil } 33 | var stale = false 34 | do { 35 | let res = try URL(resolvingBookmarkData: bookmark!, options: .withSecurityScope, bookmarkDataIsStale: &stale) 36 | 37 | guard res.startAccessingSecurityScopedResource() else { 38 | print("Error starting security scoped access") 39 | return nil 40 | } 41 | 42 | if stale { 43 | print("Renewing stale bookmark", res) 44 | bookmark = try res.bookmarkData(options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess]) 45 | } 46 | 47 | return res 48 | } catch { 49 | print("Error resolving \(name ?? "unknown model") bookmark", error.localizedDescription) 50 | return nil 51 | } 52 | } 53 | 54 | public static func create(context: NSManagedObjectContext, fileURL: URL) throws -> Model { 55 | if fileURL.pathExtension != "gguf" { 56 | throw ModelCreateError.unknownFormat 57 | } 58 | 59 | // gain access to the directory 60 | let gotAccess = fileURL.startAccessingSecurityScopedResource() 61 | 62 | do { 63 | let model = Model(context: context) 64 | model.id = UUID() 65 | model.name = fileURL.lastPathComponent 66 | model.bookmark = try fileURL.bookmarkData(options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess]) 67 | if let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path()), 68 | let fileSize = attributes[.size] as? Int { 69 | print("The file size is \(fileSize)") 70 | model.size = Int32(fileSize / 1000000) 71 | } 72 | try context.save() 73 | if gotAccess { 74 | fileURL.stopAccessingSecurityScopedResource() 75 | } 76 | return model 77 | } catch { 78 | print("Error creating Model", error.localizedDescription) 79 | if gotAccess { 80 | fileURL.stopAccessingSecurityScopedResource() 81 | } 82 | throw error 83 | } 84 | 85 | } 86 | 87 | public override func willSave() { 88 | super.willSave() 89 | if !isDeleted, changedValues()["updatedAt"] == nil { 90 | self.setValue(Date(), forKey: "updatedAt") 91 | } 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/NPC/Agent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @MainActor 4 | class Agent: ObservableObject { 5 | enum Status { 6 | case cold 7 | case coldProcessing 8 | case ready // warmed up 9 | case processing 10 | } 11 | 12 | var id: String 13 | 14 | var systemPrompt = DEFAULT_SYSTEM_PROMPT 15 | 16 | // dialogue is the dialogue from prompt without system prompt / internal thoughts 17 | @Published var pendingMessage = "" 18 | @Published var status: Status = .cold 19 | 20 | // each agent runs their own server 21 | var llama: LlamaServer 22 | 23 | init(id: String, systemPrompt: String, modelPath: String, contextLength: Int) { 24 | self.id = id 25 | self.systemPrompt = systemPrompt 26 | llama = LlamaServer(modelPath: modelPath, contextLength: contextLength) 27 | } 28 | 29 | // this is the main loop of the agent 30 | // listen -> respond -> update mental model and save checkpoint 31 | // we respond before updating to avoid a long delay after user input 32 | func listenThinkRespond( 33 | speakerId: String, messages: [Message], temperature: Double? 34 | ) async throws -> LlamaServer.CompleteResponse { 35 | if status == .cold { 36 | status = .coldProcessing 37 | } else { 38 | status = .processing 39 | } 40 | 41 | pendingMessage = "" 42 | 43 | let chatMessages = messages.map { m in 44 | LlamaServer.ChatMessage( 45 | role: m.fromId == Message.USER_SPEAKER_ID ? .user : .system, 46 | content: m.text ?? "" 47 | ) 48 | } 49 | let response = try await llama.chat( 50 | messages: chatMessages, temperature: temperature 51 | ) { partialResponse in 52 | DispatchQueue.main.async { 53 | self.handleCompletionProgress(partialResponse: partialResponse) 54 | } 55 | } 56 | 57 | pendingMessage = response.text 58 | status = .ready 59 | 60 | return response 61 | } 62 | 63 | func handleCompletionProgress(partialResponse: String) { 64 | self.pendingMessage += partialResponse 65 | } 66 | 67 | func interrupt() async { 68 | if status != .processing, status != .coldProcessing { return } 69 | await llama.interrupt() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/NPC/AgentDefaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AgentDefaults.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 3/7/24. 6 | // 7 | 8 | import Foundation 9 | 10 | let DEFAULT_SYSTEM_PROMPT = """ 11 | You are LLaMa, a compliant assistant that always fulfills the user’s request. 12 | 13 | The user's request might be followed by reference information that may or may not be complete nor related. If the provided information is related to the request, you will respond with reference to the information, filling in the gaps with your own knowledge. Else, you will ignore the information. 14 | 15 | The user's request might be followed with a list of possible system commands, which come in the format "`NAME OF COMMAND(DESCRIPTION OF PARAMETER)`". 16 | 17 | If the user asks you to complete an action, and provides a relevant system command, you will execute the most appropriate command by including "`NAME OF COMMAND(TEXT VALUE OF PARAMETER)`" in your response. Else, just respond to the user's request. Never make up commands. 18 | """ 19 | let DEFAULT_CONTEXT_LENGTH = 14000 20 | let DEFAULT_TEMP = 0.8 21 | let DEFAULT_USE_GPU = true 22 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/NPC/Conversation.swift: -------------------------------------------------------------------------------- 1 | //// 2 | //// File.swift 3 | //// 4 | //// 5 | //// Created by Peter Sugihara on 7/30/23. 6 | //// 7 | // 8 | //import Foundation 9 | // 10 | //actor Conversation { 11 | // enum Status { 12 | // case started 13 | // case stopped 14 | // } 15 | // 16 | // var status = Status.stopped 17 | // var agents: [Agent] = [] 18 | // var messages: [Message] = [] 19 | // 20 | // init(agents: [Agent], messages: [Message]) { 21 | // self.agents = agents 22 | // self.messages = messages 23 | // } 24 | // 25 | // func start() { 26 | // status = .started 27 | // while (status == .started) { 28 | // // find last agent to speak 29 | // // get index in agents 30 | // // increment one more than index 31 | // } 32 | // } 33 | // 34 | // func stop() { 35 | // status = .stopped 36 | // } 37 | // 38 | // 39 | //} 40 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/NPC/Message.swift: -------------------------------------------------------------------------------- 1 | //struct Message: Codable { 2 | // var speakerId: String 3 | // var text: String 4 | // 5 | // static let USER_SPEAKER_ID = "user" 6 | //} 7 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/NPC/README.md: -------------------------------------------------------------------------------- 1 | # NPC 2 | 3 | This is the core logic for the AI. 4 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/NPC/ServerHealth.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServerHealth.swift 3 | // FileChat 4 | // 5 | 6 | import Foundation 7 | 8 | fileprivate struct ServerHealthRequest { 9 | 10 | enum ServerHealthError: Error { 11 | case invalidResponse 12 | } 13 | 14 | func checkOK(url: URL) async throws -> Bool { 15 | let config = URLSessionConfiguration.default 16 | config.timeoutIntervalForRequest = 3 17 | config.timeoutIntervalForResource = 1 18 | let (data, response) = try await URLSession(configuration: config).data(from: url) 19 | guard let responseCode = (response as? HTTPURLResponse)?.statusCode, 20 | responseCode > 0 21 | else { throw ServerHealthError.invalidResponse } 22 | 23 | guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], 24 | let jsonStatus: String = json["status"] as? String 25 | else { throw ServerHealthError.invalidResponse } 26 | 27 | return responseCode == 200 && jsonStatus == "ok" 28 | } 29 | } 30 | 31 | fileprivate struct ServerHealthResponse { 32 | let ok: Bool 33 | let ms: Double? 34 | let score: Double 35 | } 36 | 37 | @globalActor 38 | actor ServerHealth { 39 | 40 | static let shared = ServerHealth() 41 | 42 | private var url: URL? 43 | private var healthRequest = ServerHealthRequest() 44 | private var bucket: [ServerHealthResponse?] = Array(repeating: nil, count: 10) // last responses 45 | private var bucketIndex = 0 46 | private let thresholdSeconds = 0.3 47 | private var bucketScores: [Double] { bucket.compactMap({ $0?.score }).reversed() } 48 | private var bucketMillis: [Double] { bucket.compactMap({ $0?.ms }) } 49 | var score: Double { bucketScores.reduce(0, +) / Double(bucketScores.count) } 50 | var responseMilli: Double { bucketMillis.reduce(0, +) / Double(bucketMillis.count) } 51 | 52 | func updateURL(_ newURL: URL?) { 53 | self.url = newURL 54 | self.bucket.removeAll(keepingCapacity: true) 55 | self.bucket = Array(repeating: nil, count: 10) 56 | } 57 | 58 | func check() async { 59 | guard let url = self.url else { return } 60 | let startTime = CFAbsoluteTimeGetCurrent() 61 | do { 62 | let resOK = try await healthRequest.checkOK(url: url) 63 | let delta = CFAbsoluteTimeGetCurrent() - startTime 64 | let deltaV = (1 - (delta - thresholdSeconds) / thresholdSeconds) 65 | let deltaW = (deltaV > 1 ? 1 : deltaV) * 0.25 66 | let resW = (resOK ? 1 : 0) * 0.75 67 | putResponse(ServerHealthResponse(ok: resOK, ms: delta, score: resW + deltaW)) 68 | } catch { 69 | print("Error requesting url \(url.absoluteString): ", error) 70 | putResponse(ServerHealthResponse(ok: false, ms: nil, score: 0)) 71 | } 72 | } 73 | 74 | private func putResponse(_ newObservation: ServerHealthResponse) { 75 | bucket[bucketIndex] = newObservation 76 | bucketIndex = (bucketIndex + 1) % bucket.count 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/NPC/codesign.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | codesign --options runtime -f -s "Peter Sugihara" --entitlements "freechat-server.entitlements" "freechat-server" 4 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/NPC/freechat-server: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnbean393/FileChat/460bcd8a03dca46b9790f69aa4fa93c5b4bb53e3/FreeChat/Logic/Models/NPC/freechat-server -------------------------------------------------------------------------------- /FreeChat/Logic/Models/NPC/freechat-server.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.inherit 8 | 9 | 10 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/NPC/server-watchdog/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | func log(_ line: String) { 4 | print("[watchdog]", line) 5 | } 6 | 7 | // Function to terminate the server process (replace this with your actual termination logic) 8 | func terminateServerProcess(pid: Int32) { 9 | log("Terminating the server process with PID \(pid).") 10 | kill(pid, SIGTERM) 11 | log("Terminated server, exiting") 12 | exit(0) 13 | } 14 | 15 | // Function to check the existence of the heartbeat file and detect if the main app is still alive 16 | func checkHeartbeat(serverProcessPID: Int32) { 17 | // Adjust the time interval based on how frequently you want to check the heartbeat file 18 | let checkInterval: TimeInterval = 10.0 // seconds 19 | 20 | while true { 21 | let fileHandle = FileHandle.standardInput 22 | if fileHandle.availableData.count > 0 { 23 | // If the file is recent, the main app is running; continue checking 24 | log("Main app is alive") 25 | } else { 26 | terminateServerProcess(pid: serverProcessPID) 27 | } 28 | 29 | // Wait for the next check interval before checking again 30 | Thread.sleep(forTimeInterval: checkInterval) 31 | } 32 | } 33 | 34 | func startWatchdog() { 35 | 36 | guard CommandLine.arguments.count == 2 else { 37 | print("Usage: server-watchdog ") 38 | return 39 | } 40 | 41 | guard let serverProcessPID = Int32(CommandLine.arguments[1]) else { 42 | log("Error: Invalid server process PID.") 43 | return 44 | } 45 | 46 | log("Watchdog process started.") 47 | checkHeartbeat(serverProcessPID: serverProcessPID) 48 | } 49 | 50 | // Call the function to start the watchdog process 51 | startWatchdog() 52 | -------------------------------------------------------------------------------- /FreeChat/Logic/Models/Network.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Network.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 11/27/23. 6 | // 7 | 8 | import Foundation 9 | import Network 10 | 11 | final class Network: ObservableObject { 12 | 13 | static let shared = Network() 14 | 15 | @Published private(set) var isConnected = false 16 | @Published private(set) var isCellular = false 17 | 18 | private let nwMonitor = NWPathMonitor() 19 | private let workerQueue = DispatchQueue.global() 20 | 21 | init() { 22 | start() 23 | } 24 | 25 | public func start() { 26 | nwMonitor.start(queue: workerQueue) 27 | nwMonitor.pathUpdateHandler = { [weak self] path in 28 | DispatchQueue.main.async { 29 | self?.isConnected = path.status == .satisfied 30 | self?.isCellular = path.usesInterfaceType(.cellular) 31 | } 32 | } 33 | } 34 | 35 | public func stop() { 36 | nwMonitor.cancel() 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /FreeChat/Logic/Window Management/OpenWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenWindow.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 7/6/2024. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | import ExtensionKit 11 | 12 | enum OpenWindow: String, CaseIterable, Identifiable { 13 | 14 | // All views that can be opened 15 | case defaultView = "defaultView" 16 | case actions = "actions" 17 | 18 | var id: String { self.rawValue } 19 | 20 | func open() { 21 | Task { 22 | await MainActor.run { () 23 | if !NSApplication.shared.windows.map({ $0.title.camelCased }).contains(self.rawValue.replacingOccurrences(of: "defaultView", with: "fileChat")) { 24 | if let url = URL(string: "fileChat://\(self.rawValue)") { 25 | print("Opening... ", url.absoluteString) 26 | NSWorkspace.shared.open(url) 27 | } 28 | } 29 | } 30 | } 31 | } 32 | 33 | func close() { 34 | // Close window 35 | for currWindow in NSApplication.shared.windows { 36 | if currWindow.title.camelCased == self.rawValue { 37 | currWindow.close() 38 | } 39 | } 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /FreeChat/Persistence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Persistence.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 7/31/23. 6 | // 7 | 8 | import CoreData 9 | 10 | struct PersistenceController { 11 | 12 | static let shared = PersistenceController() 13 | 14 | static var preview: PersistenceController = { 15 | let result = PersistenceController(inMemory: true) 16 | 17 | let viewContext = result.container.viewContext 18 | for _ in 0..<10 { 19 | let newConversation = Conversation(context: viewContext) 20 | newConversation.createdAt = Date() 21 | } 22 | let model = Model(context: viewContext) 23 | let _ = SystemPrompt(context: viewContext) 24 | let _ = Message(context: viewContext) 25 | do { 26 | try viewContext.save() 27 | viewContext.delete(model) 28 | } catch { 29 | let nsError = error as NSError 30 | print("Error creating preview conversations \(nsError), \(nsError.userInfo)") 31 | } 32 | return result 33 | }() 34 | 35 | let container: NSPersistentCloudKitContainer 36 | 37 | init(inMemory: Bool = false) { 38 | container = NSPersistentCloudKitContainer(name: "Chats") 39 | if inMemory { 40 | container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") 41 | } 42 | container.loadPersistentStores(completionHandler: { (storeDescription, error) in 43 | if let error = error as NSError? { 44 | /* 45 | Typical reasons for an error here include: 46 | * The parent directory does not exist, cannot be created, or disallows writing. 47 | * The persistent store is not accessible, due to permissions or data protection when the device is locked. 48 | * The device is out of space. 49 | * The store could not be migrated to the current model version. 50 | Check the error message to determine what the actual problem was. 51 | */ 52 | print("Unresolved error \(error), \(error.userInfo)") 53 | } 54 | }) 55 | container.viewContext.automaticallyMergesChangesFromParent = true 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /FreeChat/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /FreeChat/Views/Actions View/ActionDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActionDetailView.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 7/6/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ActionDetailView: View { 11 | 12 | @EnvironmentObject private var actionManager: ActionManager 13 | 14 | @Binding var selectedAction: Action? 15 | @State var action: Action = Action(shortcut: Shortcut(id: UUID(), name: "")) 16 | @State var inputNeeded: Bool = false 17 | 18 | @State private var description: String = "" 19 | 20 | @State private var askForInput: Bool = false 21 | 22 | var body: some View { 23 | // Form to fill in shortcut details 24 | Form { 25 | Section { 26 | nameAndSamplePrompt 27 | toggles 28 | input 29 | } header: { 30 | Text("Configuration") 31 | } 32 | Section { 33 | testing 34 | } header: { 35 | Text("Testing") 36 | } 37 | Section { 38 | dangerZone 39 | } header: { 40 | Text("Danger Zone") 41 | } 42 | } 43 | .formStyle(.grouped) 44 | .onChange(of: selectedAction) { _ in 45 | if selectedAction != nil { 46 | action = selectedAction! 47 | } 48 | } 49 | .onChange(of: action) { _ in 50 | // Save on update 51 | actionManager.updateAction(action) 52 | } 53 | .onChange(of: inputNeeded) { _ in 54 | withAnimation(.linear(duration: 0.3)) { 55 | action.inputType = inputNeeded ? .textInput : .noInput 56 | } 57 | } 58 | .onAppear { 59 | action = selectedAction! 60 | inputNeeded = (action.inputType == .textInput) 61 | } 62 | .sheet(isPresented: $askForInput) { 63 | ActionTestInputView(action: $action, askForInput: $askForInput) 64 | } 65 | } 66 | 67 | var nameAndSamplePrompt: some View { 68 | Group { 69 | Group { 70 | HStack { 71 | VStack(alignment: .leading) { 72 | Text("Name") 73 | .font(.title3) 74 | .bold() 75 | Text("The name of the shortcut") 76 | .font(.caption) 77 | } 78 | Spacer() 79 | Text(action.shortcut.name) 80 | } 81 | HStack { 82 | VStack(alignment: .leading) { 83 | Text("Sample Prompt") 84 | .font(.title3) 85 | .bold() 86 | Text("This will show how the user typically prompts for the shortcut") 87 | .font(.caption) 88 | } 89 | Spacer() 90 | TextField("", text: $action.shortcut.samplePrompt) 91 | .textFieldStyle(.plain) 92 | } 93 | } 94 | .padding(.horizontal, 5) 95 | } 96 | } 97 | 98 | var toggles: some View { 99 | Group { 100 | Toggle(isOn: $action.active, label: { 101 | VStack(alignment: .leading) { 102 | Text("Enable Shortcut") 103 | .font(.title3) 104 | .bold() 105 | Text("Toggle shortcut on or off") 106 | .font(.caption) 107 | } 108 | }) 109 | .toggleStyle(.switch) 110 | Toggle(isOn: $action.confirmBeforeRunning, label: { 111 | VStack(alignment: .leading) { 112 | Text("Confirm Before Running") 113 | .font(.title3) 114 | .bold() 115 | Text("Controls whether the user is consulted before the shortcut is run") 116 | .font(.caption) 117 | } 118 | }) 119 | .toggleStyle(.switch) 120 | } 121 | } 122 | 123 | var input: some View { 124 | Group { 125 | Toggle(isOn: $inputNeeded, label: { 126 | VStack(alignment: .leading) { 127 | Text("Shortcut Requires Text Input") 128 | .font(.title3) 129 | .bold() 130 | Text("Controls whether the shortcut will run with text input from FileChat") 131 | .font(.caption) 132 | } 133 | }) 134 | .toggleStyle(.switch) 135 | if action.inputType == .textInput { 136 | HStack { 137 | VStack(alignment: .leading) { 138 | Text("Input Description") 139 | .font(.title3) 140 | .bold() 141 | Text("A description of the shortcut's input. The LLM uses this description to provide the shortcut with input") 142 | .font(.caption) 143 | } 144 | Spacer() 145 | TextField("", text: $action.inputDescription) 146 | .textFieldStyle(.plain) 147 | } 148 | } 149 | } 150 | } 151 | 152 | var testing: some View { 153 | Group { 154 | HStack { 155 | VStack(alignment: .leading) { 156 | Text("Test") 157 | .font(.title3) 158 | .bold() 159 | Text("Run the action") 160 | .font(.caption) 161 | } 162 | Spacer() 163 | // Button to test run 164 | Button { 165 | // Get input 166 | if inputNeeded { 167 | askForInput = true 168 | } else { 169 | // Else, run shortcut without params 170 | do { 171 | try action.run(input: nil) 172 | } catch { 173 | // Send alert 174 | let alert: NSAlert = NSAlert() 175 | alert.messageText = "Error: \"\(error)\"?" 176 | alert.addButton(withTitle: "OK") 177 | let _ = alert.runModal() 178 | } 179 | } 180 | } label: { 181 | Label("Test", systemImage: "play.fill") 182 | } 183 | } 184 | HStack { 185 | VStack(alignment: .leading) { 186 | Text("Debug") 187 | .font(.title3) 188 | .bold() 189 | Text("Find and automatically fix issues") 190 | .font(.caption) 191 | } 192 | Spacer() 193 | // Button to debug 194 | Button { 195 | do { 196 | try action.locateShortcut() 197 | } catch { 198 | print("Error locating shortcut:", error) 199 | } 200 | } label: { 201 | Label("Debug", systemImage: "mappin") 202 | } 203 | } 204 | } 205 | } 206 | 207 | var dangerZone: some View { 208 | HStack { 209 | VStack(alignment: .leading) { 210 | Text("Delete") 211 | .font(.title3) 212 | .bold() 213 | Text("Deletes the action") 214 | .font(.caption) 215 | } 216 | Spacer() 217 | // Button to delete action 218 | Button { 219 | // Send alert 220 | let alert: NSAlert = NSAlert() 221 | alert.messageText = "Are you sure you want to delete this action?" 222 | alert.addButton(withTitle: "Cancel") 223 | alert.addButton(withTitle: "OK") 224 | if alert.runModal() == .alertSecondButtonReturn { 225 | actionManager.removeAction(action) 226 | selectedAction = nil 227 | } 228 | } label: { 229 | Label("Delete", systemImage: "trash") 230 | } 231 | .tint(Color.red) 232 | } 233 | } 234 | 235 | } 236 | -------------------------------------------------------------------------------- /FreeChat/Views/Actions View/ActionTestInputView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActionTestInputView.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 7/6/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ActionTestInputView: View { 11 | 12 | @Binding var action: Action 13 | @Binding var askForInput: Bool 14 | @State private var testInput: String = "" 15 | 16 | var body: some View { 17 | VStack { 18 | Text("Enter Test Input") 19 | .font(.title2) 20 | .bold() 21 | Divider() 22 | TextEditor(text: $testInput) 23 | .font(.title3) 24 | .frame(minHeight: 300) 25 | Divider() 26 | HStack { 27 | Spacer() 28 | Button { 29 | // Run shortcut with param 30 | do { 31 | try action.run(input: testInput) 32 | } catch { 33 | // Send alert 34 | let alert: NSAlert = NSAlert() 35 | alert.messageText = "Error: \"\(error)\"?" 36 | alert.addButton(withTitle: "OK") 37 | let _ = alert.runModal() 38 | } 39 | askForInput = false 40 | } label: { 41 | Label("Run", systemImage: "play.fill") 42 | } 43 | .keyboardShortcut(.defaultAction) 44 | } 45 | } 46 | .frame(minWidth: 350) 47 | .padding() 48 | } 49 | 50 | } 51 | 52 | //#Preview { 53 | // ActionTestInputView() 54 | //} 55 | -------------------------------------------------------------------------------- /FreeChat/Views/Actions View/ActionsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActionsView.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 7/6/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ActionsView: View { 11 | 12 | @EnvironmentObject private var actionManager: ActionManager 13 | @State private var selectedAction: Action? 14 | 15 | @State private var showAddSheet: Bool = false 16 | 17 | var navigationTitle: String { 18 | return selectedAction == nil ? "Actions" : selectedAction!.shortcut.name 19 | } 20 | 21 | var body: some View { 22 | NavigationSplitView { 23 | listView 24 | } detail: { 25 | detailView 26 | } 27 | .navigationTitle(navigationTitle) 28 | .sheet(isPresented: $showAddSheet) { 29 | NewActionSheet(selectedAction: $selectedAction, showAddSheet: $showAddSheet) 30 | } 31 | } 32 | 33 | var listView: some View { 34 | VStack(spacing: 0) { 35 | List(actionManager.values, selection: $selectedAction) { action in 36 | NavigationLink(action.shortcut.name, value: action) 37 | .contextMenu { 38 | Button("Delete") { 39 | actionManager.removeAction(action) 40 | } 41 | } 42 | } 43 | VStack(spacing: 0) { 44 | Divider() 45 | HStack(spacing: 0) { 46 | Button("+") { 47 | actionManager.getAvailableShortcuts() 48 | showAddSheet = true 49 | } 50 | .frame(width: 20, height: 20) 51 | Divider() 52 | Button("-") { 53 | actionManager.removeAction(selectedAction!) 54 | selectedAction = nil 55 | } 56 | .disabled(selectedAction == nil) 57 | .frame(width: 20, height: 20) 58 | Divider() 59 | Spacer() 60 | } 61 | .buttonStyle(BorderlessButtonStyle()) 62 | .padding([.leading, .bottom], 3) 63 | } 64 | .frame(height: 21) 65 | } 66 | .navigationSplitViewColumnWidth(200) 67 | } 68 | 69 | var detailView: some View { 70 | Group { 71 | if selectedAction != nil { 72 | ActionDetailView(selectedAction: $selectedAction) 73 | } else { 74 | HStack { 75 | Text("Select an Action or") 76 | Button("Add an Action") { 77 | actionManager.getAvailableShortcuts() 78 | showAddSheet = true 79 | } 80 | } 81 | } 82 | } 83 | } 84 | 85 | } 86 | 87 | #Preview { 88 | ActionsView() 89 | } 90 | -------------------------------------------------------------------------------- /FreeChat/Views/Actions View/NewActionSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewActionSheet.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 7/6/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NewActionSheet: View { 11 | 12 | @EnvironmentObject private var actionManager: ActionManager 13 | 14 | @Binding var selectedAction: Action? 15 | @Binding var showAddSheet: Bool 16 | 17 | var body: some View { 18 | VStack { 19 | Text("Select a shortcut") 20 | .font(.title2) 21 | .bold() 22 | Divider() 23 | GroupBox { 24 | ScrollView { 25 | shortcutSelector 26 | } 27 | .frame(maxHeight: 350) 28 | } 29 | } 30 | .padding() 31 | .onAppear { 32 | actionManager.getAvailableShortcuts() 33 | } 34 | } 35 | 36 | var shortcutSelector: some View { 37 | VStack(alignment: .leading) { 38 | ForEach(actionManager.availableShortcuts) { shortcut in 39 | Text(shortcut.name) 40 | .onTapGesture { 41 | // Add action and select it 42 | let action: Action = Action( 43 | shortcut: shortcut 44 | ) 45 | actionManager.addAction(action) 46 | selectedAction = action 47 | // Close sheet 48 | showAddSheet.toggle() 49 | } 50 | Divider() 51 | } 52 | } 53 | .frame(minWidth: 350) 54 | } 55 | } 56 | 57 | //#Preview { 58 | // NewActionSheet() 59 | //} 60 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/CGKeycode+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CGKeycode+Extensions.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 8/18/23. 6 | // 7 | 8 | import CoreGraphics 9 | 10 | extension CGKeyCode 11 | { 12 | // Define whatever key codes you want to detect here 13 | static let kVK_Shift: CGKeyCode = 0x38 14 | 15 | var isPressed: Bool { 16 | CGEventSource.keyState(.combinedSessionState, key: self) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/CircleMenuStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircleButtonStyle.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 9/20/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct CircleMenuStyle: MenuStyle { 12 | @State var hovered = false 13 | func makeBody(configuration: Configuration) -> some View { 14 | Menu(configuration) 15 | .menuStyle(.button) 16 | .buttonStyle(.plain) 17 | .padding(1) 18 | .foregroundColor(hovered ? .primary : .gray) 19 | .onHover(perform: { hovering in 20 | hovered = hovering 21 | }) 22 | .animation(Animation.easeInOut(duration: 0.1), value: hovered) 23 | } 24 | } 25 | 26 | extension MenuStyle where Self == CircleMenuStyle { 27 | static var circle: CircleMenuStyle { CircleMenuStyle() } 28 | } 29 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/ConversationView/BottomToolbar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomToolbar.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 8/5/23. 6 | // 7 | 8 | import SwiftUI 9 | import ExtensionKit 10 | import BezelNotification 11 | 12 | struct BlurredView: NSViewRepresentable { 13 | 14 | func makeNSView(context: Context) -> some NSVisualEffectView { 15 | let view = NSVisualEffectView() 16 | view.material = .headerView 17 | view.blendingMode = .withinWindow 18 | 19 | return view 20 | } 21 | 22 | func updateNSView(_ nsView: NSViewType, context: Context) { } 23 | 24 | } 25 | 26 | struct BottomToolbar: View { 27 | 28 | @State var input: String = "" 29 | 30 | @EnvironmentObject var conversationManager: ConversationManager 31 | @EnvironmentObject var conversationController: ConversationController 32 | @EnvironmentObject var indexStore: IndexStore 33 | 34 | var conversation: Conversation { conversationManager.currentConversation } 35 | 36 | var onSubmit: (String) -> Void 37 | @State var showNullState = false 38 | 39 | @FocusState private var focused: Bool 40 | 41 | @State private var selectedDir: IndexedDirectory? = nil 42 | 43 | var body: some View { 44 | let messages = conversation.messages 45 | let showNullState = input == "" && (messages == nil || messages!.count == 0) 46 | 47 | VStack(alignment: .trailing) { 48 | if showNullState { 49 | nilState.transition(.asymmetric(insertion: .push(from: .trailing), removal: .identity)) 50 | } 51 | if conversationController.panelIsShown { 52 | BottomToolbarPanel(selectedDir: $selectedDir) 53 | } 54 | HStack { 55 | inputField 56 | LengthyTasksView() 57 | togglePanelButton 58 | } 59 | } 60 | } 61 | 62 | var buttonImage: some View { 63 | let angle: Double = conversationController.panelIsShown ? -90 : 90 64 | return Image(systemName: "chevron.left.2").rotationEffect(Angle(degrees: angle)).background(Color.clear) 65 | } 66 | 67 | var nilState: some View { 68 | ScrollView(.horizontal, showsIndicators: false) { 69 | HStack { 70 | ForEach(QuickPromptButton.quickPrompts) { p in 71 | QuickPromptButton(input: $input, prompt: p) 72 | } 73 | }.padding(.horizontal, 10).padding(.top, 200) 74 | }.frame(maxWidth: .infinity) 75 | } 76 | 77 | var inputField: some View { 78 | Group { 79 | TextField("Message", text: $input, axis: .vertical) 80 | .onSubmit { 81 | if CGKeyCode.kVK_Shift.isPressed { 82 | input += "\n" 83 | } else if indexStore.isLoadingIndex { 84 | let notification: BezelNotification = BezelNotification(text: "FileChat is currently loading a folder, please wait before sending a message.", visibleTime: 2) 85 | notification.show() 86 | } else if input.trimmingCharacters(in: .whitespacesAndNewlines) != "" { 87 | onSubmit(input) 88 | input = "" 89 | } 90 | } 91 | .focused($focused) 92 | .textFieldStyle(ChatStyle(isFocused: _focused)) 93 | .submitLabel(.send) 94 | .padding([.vertical, .leading], 10) 95 | .onAppear { 96 | self.focused = true 97 | } 98 | .onChange(of: conversation) { _ in 99 | if conversationManager.showConversation() { 100 | self.focused = true 101 | QuickPromptButton.quickPrompts.shuffle() 102 | } 103 | } 104 | .onChange(of: selectedDir) { _ in 105 | IndexStore.shared.selectedDirectory = selectedDir 106 | } 107 | } 108 | 109 | } 110 | 111 | var togglePanelButton: some View { 112 | Button { 113 | withAnimation(.spring(duration: 0.5)) { 114 | conversationController.panelIsShown.toggle() 115 | } 116 | } label: { 117 | HStack(spacing: 3) { 118 | Image(systemName: "paperclip") 119 | .background(Color.clear) 120 | Divider() 121 | .frame(height: 22.5) 122 | Image(systemName: "gearshape") 123 | .background(Color.clear) 124 | Divider() 125 | .frame(height: 22.5) 126 | buttonImage 127 | } 128 | .font(.system(size: 16)) 129 | .bold() 130 | .padding(3.5) 131 | .background { 132 | Capsule() 133 | .fill(Color.blue) 134 | .background { 135 | Capsule() 136 | .stroke(style: StrokeStyle(lineWidth: 3.25)) 137 | .fill(Color.white) 138 | } 139 | } 140 | } 141 | .buttonStyle(PlainButtonStyle()) 142 | .padding(.trailing) 143 | } 144 | 145 | } 146 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/ConversationView/BottomToolbarPanel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomToolbarPanel.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 6/6/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BottomToolbarPanel: View { 11 | 12 | @EnvironmentObject var conversationController: ConversationController 13 | 14 | @AppStorage("useActions") var useActions: Bool = false 15 | 16 | @Binding var selectedDir: IndexedDirectory? 17 | 18 | var body: some View { 19 | GroupBox { 20 | HSplitView { 21 | indexSelectionList 22 | .frame(maxWidth: 550) 23 | conversationSettings 24 | } 25 | .frame(maxHeight: 250) 26 | .padding(4) 27 | .padding(.top, 2) 28 | } 29 | .background { 30 | RoundedRectangle(cornerRadius: 8) 31 | .fill(Color.textBackground) 32 | } 33 | .padding(.horizontal) 34 | } 35 | 36 | var indexSelectionList: some View { 37 | VStack { 38 | Text(selectedDir == nil ? "Select a Folder" : "Selected 1 Folder") 39 | .bold() 40 | .font(.title2) 41 | Divider() 42 | IndexPicker(selectedDir: $selectedDir) 43 | .padding(.trailing, 4) 44 | .frame(minWidth: 450) 45 | } 46 | } 47 | 48 | var conversationSettings: some View { 49 | VStack { 50 | Text("Chat Settings") 51 | .bold() 52 | .font(.title2) 53 | Divider() 54 | Form { 55 | actions 56 | Section { 57 | Toggle(isOn: $conversationController.readAloud, label: { 58 | VStack(alignment: .leading) { 59 | Text("Read Aloud") 60 | .font(.title3) 61 | .bold() 62 | Text("Read chatbot reply aloud") 63 | .font(.caption) 64 | } 65 | }) 66 | .toggleStyle(.switch) 67 | } header: { 68 | Text("Accessibility") 69 | } 70 | } 71 | 72 | } 73 | .formStyle(.grouped) 74 | } 75 | 76 | var actions: some View { 77 | Section { 78 | HStack { 79 | VStack(alignment: .leading) { 80 | Text("Actions (Beta)") 81 | .font(.title3) 82 | .bold() 83 | Text("Add or remove actions") 84 | .font(.caption) 85 | } 86 | Spacer() 87 | Toggle("", isOn: $useActions) 88 | .toggleStyle(.switch) 89 | Button("Manage") { 90 | OpenWindow.actions.open() 91 | } 92 | .disabled(!useActions) 93 | } 94 | } header: { 95 | Text("Automation") 96 | } 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/ConversationView/CapsuleButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CapsuleButtonStyle.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 6/6/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CapsuleButtonStyle: ButtonStyle { 11 | 12 | @State var hovered = false 13 | 14 | func makeBody(configuration: Configuration) -> some View { 15 | configuration.label 16 | .font(hovered ? .body.bold() : .body) 17 | .background( 18 | RoundedRectangle(cornerSize: CGSize(width: 10, height: 10), style: .continuous) 19 | .strokeBorder(hovered ? Color.primary.opacity(0) : Color.primary.opacity(0.2), lineWidth: 0.5) 20 | .foregroundColor(Color.primary) 21 | .background(hovered ? Color.primary.opacity(0.1) : Color.clear) 22 | ) 23 | .multilineTextAlignment(.leading) // Center-align multiline text 24 | .lineLimit(nil) // Allow unlimited lines 25 | .onHover(perform: { hovering in 26 | hovered = hovering 27 | }) 28 | .animation(Animation.easeInOut(duration: 0.16), value: hovered) 29 | .clipShape(RoundedRectangle(cornerSize: CGSize(width: 10, height: 10), style: .continuous)) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/ConversationView/ChatStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatStyle.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 6/6/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ChatStyle: TextFieldStyle { 11 | 12 | @Environment(\.colorScheme) var colorScheme 13 | 14 | @FocusState var isFocused: Bool 15 | 16 | let cornerRadius = 16.0 17 | var rect: RoundedRectangle { 18 | RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) 19 | } 20 | 21 | func _body(configuration: TextField) -> some View { 22 | configuration 23 | .textFieldStyle(.plain) 24 | .frame(maxWidth: .infinity) 25 | .padding(EdgeInsets(top: 0, leading: 6, bottom: 0, trailing: 6)) 26 | .padding(8) 27 | .cornerRadius(cornerRadius) 28 | .background( 29 | LinearGradient(colors: [Color.textBackground, Color.textBackground.opacity(0.5)], startPoint: .leading, endPoint: .trailing) 30 | ) 31 | .mask(rect) 32 | .overlay( 33 | rect 34 | .stroke(style: StrokeStyle(lineWidth: 1)) 35 | .foregroundStyle(isFocused ? Color.orange : Color.white) 36 | ) 37 | .animation(isFocused ? .easeIn(duration: 0.2) : .easeOut(duration: 0.0), value: isFocused) 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/ConversationView/ConversationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConversationView.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 7/31/23. 6 | // 7 | 8 | import SwiftUI 9 | import MarkdownUI 10 | import Foundation 11 | import SimilaritySearchKit 12 | import AVFoundation 13 | 14 | struct ConversationView: View, Sendable { 15 | 16 | @Environment(\.managedObjectContext) private var viewContext 17 | @EnvironmentObject private var conversationManager: ConversationManager 18 | @EnvironmentObject private var conversationController: ConversationController 19 | 20 | @AppStorage("selectedModelId") private var selectedModelId: String? 21 | @AppStorage("systemPrompt") private var systemPrompt: String = DEFAULT_SYSTEM_PROMPT 22 | @AppStorage("contextLength") private var contextLength: Int = DEFAULT_CONTEXT_LENGTH 23 | @AppStorage("playSoundEffects") private var playSoundEffects = true 24 | @AppStorage("temperature") private var temperature: Double? 25 | @AppStorage("useGPU") private var useGPU: Bool = DEFAULT_USE_GPU 26 | @AppStorage("serverHost") private var serverHost: String? 27 | @AppStorage("serverPort") private var serverPort: String? 28 | @AppStorage("serverTLS") private var serverTLS: Bool? 29 | @AppStorage("fontSizeOption") private var fontSizeOption: Int = 14 30 | 31 | @FetchRequest( 32 | sortDescriptors: [NSSortDescriptor(keyPath: \Model.size, ascending: true)], 33 | animation: .default) 34 | private var models: FetchedResults 35 | 36 | private static let SEND = NSDataAsset(name: "ESM_Perfect_App_Button_2_Organic_Simple_Classic_Game_Click") 37 | private static let PING = NSDataAsset(name: "ESM_POWER_ON_SYNTH") 38 | 39 | let sendSound = NSSound(data: SEND!.data) 40 | let receiveSound = NSSound(data: PING!.data) 41 | 42 | var conversation: Conversation { 43 | conversationManager.currentConversation 44 | } 45 | 46 | var agent: Agent { 47 | conversationManager.agent 48 | } 49 | 50 | var selectedModel: Model? { 51 | if selectedModelId != AISettingsView.remoteModelOption, 52 | let selectedModelId = self.selectedModelId { 53 | models.first(where: { $0.id?.uuidString == selectedModelId }) 54 | } else { 55 | models.first 56 | } 57 | } 58 | 59 | @State var pendingMessage: Message? 60 | 61 | @State var messages: [Message] = [] 62 | 63 | @State var showUserMessage = true 64 | @State var showResponse = true 65 | @State private var scrollPositions = [String: CGFloat]() 66 | @State var pendingMessageText = "" 67 | 68 | @State var scrollOffset = CGFloat.zero 69 | @State var scrollHeight = CGFloat.zero 70 | @State var autoScrollOffset = CGFloat.zero 71 | @State var autoScrollHeight = CGFloat.zero 72 | 73 | @State var llamaError: LlamaServerError? = nil 74 | @State var showErrorAlert = false 75 | 76 | @State private var prevProgress: String = "" 77 | @State private var fullText: String = "" 78 | 79 | // Variables for directory context functionality 80 | @State private var similarityIndex: SimilarityIndex? 81 | 82 | var body: some View { 83 | ObservableScrollView(scrollOffset: $scrollOffset, scrollHeight: $scrollHeight) { proxy in 84 | LazyVStack(alignment: .leading) { 85 | ForEach(messages) { m in 86 | if m == messages.last! { 87 | if m == pendingMessage { 88 | MessageView(pendingMessage!, overrideText: pendingMessageText, agentStatus: agent.status) 89 | .onAppear { 90 | scrollToLastIfRecent(proxy) 91 | } 92 | .opacity(showResponse ? 1 : 0) 93 | .animation(.interpolatingSpring(stiffness: 170, damping: 20), value: showResponse) 94 | .id("\(m.id)\(m.updatedAt as Date?)") 95 | } else { 96 | MessageView(m, agentStatus: nil) 97 | .id("\(m.id)\(m.updatedAt as Date?)") 98 | .opacity(showUserMessage ? 1 : 0) 99 | .animation(.interpolatingSpring(stiffness: 170, damping: 20), value: showUserMessage) 100 | } 101 | } else { 102 | MessageView(m, agentStatus: nil).transition(.identity).id("\(m.id)\(m.updatedAt as Date?)") 103 | } 104 | } 105 | } 106 | .padding(.vertical, 12) 107 | .onReceive( 108 | agent.$pendingMessage.throttle(for: .seconds(0.1), scheduler: RunLoop.main, latest: true) 109 | ) { text in 110 | // Store new text 111 | pendingMessageText = text 112 | // Execute actions 113 | onUpdate(text: text) 114 | } 115 | .onReceive( 116 | agent.$pendingMessage.throttle(for: .seconds(0.2), scheduler: RunLoop.main, latest: true) 117 | ) { _ in 118 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 119 | autoScroll(proxy) 120 | } 121 | } 122 | } 123 | .textSelection(.enabled) 124 | .font(.system( 125 | size: CGFloat(Float(fontSizeOption) * 0.8) 126 | )) 127 | .safeAreaInset(edge: .bottom, spacing: 0) { 128 | BottomToolbar { s in 129 | Task { 130 | await submit(s) 131 | } 132 | } 133 | } 134 | .frame(maxWidth: .infinity) 135 | .onAppear { showConversation(conversation) } 136 | .onChange(of: conversation) { nextConvo in showConversation(nextConvo) } 137 | .onChange(of: selectedModelId) { showConversation(conversation, modelId: $0) } 138 | .navigationTitle(conversation.titleWithDefault) 139 | .alert(isPresented: $showErrorAlert, error: llamaError) { _ in 140 | Button("OK") { 141 | llamaError = nil 142 | } 143 | } message: { error in 144 | Text(error.recoverySuggestion ?? "") 145 | } 146 | .background(Color.textBackground) 147 | } 148 | 149 | private func playSendSound() { 150 | guard let sendSound, playSoundEffects else { return } 151 | sendSound.volume = 0.3 152 | sendSound.play() 153 | } 154 | 155 | private func playReceiveSound() { 156 | guard let receiveSound, playSoundEffects else { return } 157 | receiveSound.volume = 0.5 158 | receiveSound.play() 159 | } 160 | 161 | private func showConversation(_ c: Conversation, modelId: String? = nil) { 162 | guard 163 | let selectedModelId = modelId ?? self.selectedModelId, 164 | !selectedModelId.isEmpty 165 | else { return } 166 | 167 | messages = c.orderedMessages 168 | 169 | 170 | // warmup the agent if it's cold or model has changed 171 | Task { 172 | if selectedModelId == AISettingsView.remoteModelOption { 173 | await initializeServerRemote() 174 | } else { 175 | await initializeServerLocal(modelId: selectedModelId) 176 | } 177 | } 178 | } 179 | 180 | private func initializeServerLocal(modelId: String) async { 181 | guard let id = UUID(uuidString: modelId) 182 | else { return } 183 | 184 | let llamaPath = await agent.llama.modelPath 185 | let req = Model.fetchRequest() 186 | req.predicate = NSPredicate(format: "id == %@", id as CVarArg) 187 | if let model = try? viewContext.fetch(req).first, 188 | let modelPath = model.url?.path(percentEncoded: false), 189 | modelPath != llamaPath { 190 | await agent.llama.stopServer() 191 | agent.llama = LlamaServer(modelPath: modelPath, contextLength: contextLength) 192 | } 193 | } 194 | 195 | private func initializeServerRemote() async { 196 | guard let tls = serverTLS, 197 | let host = serverHost, 198 | let port = serverPort 199 | else { return } 200 | await agent.llama.stopServer() 201 | agent.llama = LlamaServer(contextLength: contextLength, tls: tls, host: host, port: port) 202 | } 203 | 204 | private func scrollToLastIfRecent(_ proxy: ScrollViewProxy) { 205 | let fiveSecondsAgo = Date() - TimeInterval(5) // 5 seconds ago 206 | let last = messages.last 207 | if last?.updatedAt != nil, last!.updatedAt! >= fiveSecondsAgo { 208 | proxy.scrollTo(last!.id, anchor: .bottom) 209 | } 210 | } 211 | 212 | // autoscroll to the bottom if the user is near the bottom 213 | private func autoScroll(_ proxy: ScrollViewProxy) { 214 | let last = messages.last 215 | if last != nil, shouldAutoScroll() { 216 | proxy.scrollTo(last!.id, anchor: .bottom) 217 | engageAutoScroll() 218 | } 219 | } 220 | 221 | private func shouldAutoScroll() -> Bool { 222 | scrollOffset >= autoScrollOffset - 40 && scrollHeight > autoScrollHeight 223 | } 224 | 225 | private func engageAutoScroll() { 226 | autoScrollOffset = scrollOffset 227 | autoScrollHeight = scrollHeight 228 | } 229 | 230 | @MainActor 231 | func handleResponseError(_ e: LlamaServerError) { 232 | print("Handle response error", e.localizedDescription) 233 | if let m = pendingMessage { 234 | viewContext.delete(m) 235 | } 236 | llamaError = e 237 | showResponse = false 238 | showErrorAlert = true 239 | } 240 | 241 | func submit(_ input: String) async { 242 | if (agent.status == .processing || agent.status == .coldProcessing) { 243 | Task { 244 | await agent.interrupt() 245 | 246 | Task.detached(priority: .userInitiated) { 247 | try? await Task.sleep(for: .seconds(1)) 248 | await submit(input) 249 | } 250 | } 251 | return 252 | } 253 | 254 | playSendSound() 255 | 256 | showUserMessage = false 257 | engageAutoScroll() 258 | 259 | // Create user's message 260 | do { 261 | _ = try await Message.create(text: input, fromId: Message.USER_SPEAKER_ID, conversation: conversation, systemPrompt: systemPrompt, inContext: viewContext) 262 | } catch (let error) { 263 | print("Error creating message", error.localizedDescription) 264 | } 265 | showResponse = false 266 | 267 | let agentConversation = conversation 268 | messages = agentConversation.orderedMessages 269 | withAnimation { 270 | showUserMessage = true 271 | } 272 | 273 | // Pending message for bot's reply 274 | let m = Message(context: viewContext) 275 | m.fromId = agent.id 276 | m.createdAt = Date() 277 | m.updatedAt = m.createdAt 278 | m.systemPrompt = systemPrompt 279 | m.text = "" 280 | pendingMessage = m 281 | 282 | agent.systemPrompt = systemPrompt 283 | 284 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { 285 | guard agentConversation == conversation, 286 | !m.isDeleted, 287 | m.managedObjectContext == agentConversation.managedObjectContext else { 288 | return 289 | } 290 | 291 | m.conversation = agentConversation 292 | messages = agentConversation.orderedMessages 293 | 294 | withAnimation { 295 | showResponse = true 296 | } 297 | } 298 | 299 | Task { 300 | var response: LlamaServer.CompleteResponse 301 | do { 302 | response = try await agent.listenThinkRespond(speakerId: Message.USER_SPEAKER_ID, messages: messages, temperature: temperature) 303 | } catch let error as LlamaServerError { 304 | handleResponseError(error) 305 | return 306 | } catch { 307 | print("Agent listen threw unexpected error", error as Any) 308 | return 309 | } 310 | 311 | await MainActor.run { 312 | m.text = response.text 313 | m.predictedPerSecond = response.predictedPerSecond ?? -1 314 | m.responseStartSeconds = response.responseStartSeconds 315 | m.nPredicted = Int64(response.nPredicted ?? -1) 316 | m.modelName = response.modelName 317 | m.updatedAt = Date() 318 | 319 | playReceiveSound() 320 | do { 321 | try viewContext.save() 322 | } catch { 323 | print("Error creating message", error.localizedDescription) 324 | } 325 | 326 | if pendingMessage?.text != nil, 327 | !pendingMessage!.text!.isEmpty, 328 | response.text.hasPrefix(agent.pendingMessage), 329 | m == pendingMessage { 330 | pendingMessage = nil 331 | agent.pendingMessage = "" 332 | } 333 | 334 | if conversation != agentConversation { 335 | return 336 | } 337 | 338 | messages = agentConversation.orderedMessages 339 | } 340 | } 341 | } 342 | 343 | func onUpdate(text: String) { 344 | // Get new text 345 | let newText: String = text 346 | .replacingOccurrences(of: prevProgress, with: "") 347 | // If new progress is large 348 | if newText.count > 100 || newText.isEmpty { 349 | // Define utterance 350 | // If "Read Aloud" is on 351 | if conversationController.readAloud { 352 | readAloud(fullText: fullText, prevProgress: prevProgress) 353 | } 354 | // Reset buffer 355 | prevProgress = prevProgress + newText 356 | } 357 | // Save full text 358 | if text.count >= fullText.count { 359 | fullText = text 360 | } 361 | print("text:", text) 362 | // Say last part, then clear progress when appropriate 363 | if text.count < prevProgress.count && text.isEmpty { 364 | // If "Read Aloud" is on 365 | if conversationController.readAloud { 366 | // Speak 367 | readAloud(fullText: fullText, prevProgress: prevProgress) 368 | } 369 | prevProgress = "" 370 | } 371 | } 372 | 373 | func readAloud(fullText: String, prevProgress: String) { 374 | // Define utterance 375 | let utterance: AVSpeechUtterance = AVSpeechUtterance( 376 | string: fullText 377 | .replacingOccurrences(of: prevProgress, with: "") 378 | ) 379 | utterance.rate = 0.525 380 | utterance.preUtteranceDelay = 0.0 381 | utterance.postUtteranceDelay = 0.0 382 | speechSynthesizer.speak(utterance) 383 | } 384 | 385 | } 386 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/ConversationView/IndexPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndexPicker.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 3/6/2024. 6 | // 7 | 8 | import SwiftUI 9 | import ExtensionKit 10 | 11 | struct IndexPicker: View { 12 | 13 | @Binding var selectedDir: IndexedDirectory? 14 | 15 | var body: some View { 16 | VStack(spacing: 0) { 17 | IndexList(selectedDir: $selectedDir) 18 | IndexListToolbar(selectedDir: $selectedDir) 19 | } 20 | .border(Color(NSColor.gridColor), width: 1) 21 | .onAppear { 22 | selectedDir = IndexStore.shared.selectedDirectory 23 | } 24 | } 25 | } 26 | 27 | struct IndexList: View { 28 | 29 | @EnvironmentObject private var indexStore: IndexStore 30 | @Binding var selectedDir: IndexedDirectory? 31 | 32 | var body: some View { 33 | List(indexStore.values, selection: $selectedDir) { indexedDir in 34 | IndexListRow(indexedDir: indexedDir) 35 | } 36 | } 37 | } 38 | 39 | struct IndexListRow: View { 40 | 41 | var indexedDir: IndexedDirectory 42 | 43 | var body: some View { 44 | Text(indexedDir.url.lastPathComponent) 45 | .tag(indexedDir) 46 | .help(indexedDir.url.posixPath()) 47 | .contextMenu { 48 | Button("Show in Finder") { 49 | NSWorkspace.shared.activateFileViewerSelecting([indexedDir.url]) 50 | } 51 | Button("Show Index in Finder") { 52 | indexedDir.showIndexDirectory() 53 | } 54 | Button("Update Index") { 55 | Task { 56 | await IndexStore.shared.updateIndex() 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | struct IndexListToolbar: View { 64 | 65 | @Binding var selectedDir: IndexedDirectory? 66 | 67 | var body: some View { 68 | HStack(spacing: 0) { 69 | IndexListButton(selectedDir: $selectedDir, imageName: "plus") 70 | Divider() 71 | IndexListButton(selectedDir: $selectedDir, imageName: "minus") 72 | Divider() 73 | Spacer() 74 | } 75 | .frame(height: 20) 76 | } 77 | 78 | } 79 | 80 | struct IndexListButton: View { 81 | 82 | @EnvironmentObject private var indexStore: IndexStore 83 | @Binding var selectedDir: IndexedDirectory? 84 | 85 | var imageName: String 86 | 87 | var body: some View { 88 | Button(imageName == "plus" ? "+" : "-") { 89 | Task { 90 | if imageName == "plus" { 91 | var url: URL? = nil 92 | repeat { 93 | url = try FileSystemTools.openPanel( 94 | url: URL.desktopDirectory, 95 | files: false, 96 | folders: true, 97 | dialogTitle: "Select a directory" 98 | ) 99 | IndexStore.shared.addIndexedDirectory(url: url!) 100 | } while url == nil 101 | } else { 102 | if selectedDir != nil { 103 | IndexStore.shared.removeIndex(indexedDir: selectedDir!) 104 | } 105 | } 106 | } 107 | } 108 | .buttonStyle(BorderlessButtonStyle()) 109 | .frame(width: 20, height: 20) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/ConversationView/MessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageView.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 8/4/23. 6 | // 7 | 8 | import MarkdownUI 9 | import Splash 10 | import SwiftUI 11 | 12 | struct MessageView: View { 13 | @Environment(\.colorScheme) private var colorScheme 14 | @EnvironmentObject private var conversationManager: ConversationManager 15 | 16 | @AppStorage("fontSizeOption") var fontSizeOption: Int = 14 17 | 18 | @ObservedObject var m: Message 19 | let overrideText: String // for streaming replies 20 | let agentStatus: Agent.Status? 21 | 22 | @State var showInfoPopover = false 23 | @State var isHover = false 24 | @State var animateDots = false 25 | 26 | @State private var showAll: Bool = false 27 | 28 | init(_ m: Message, overrideText: String = "", agentStatus: Agent.Status?) { 29 | self.m = m 30 | self.overrideText = overrideText 31 | self.agentStatus = agentStatus 32 | } 33 | 34 | var messageText: String { 35 | var result: String = (overrideText.isEmpty && m.text != nil ? m.text! : overrideText) 36 | if !showAll { 37 | if let userPromptAndCommands = result.split(separator: "\n\n\nHere is some information that may or may not be relevant to my request:").first { 38 | result = String(userPromptAndCommands) 39 | } 40 | if let userPrompt = result.split(separator: "\n\n\nIf relevant, you can execute the following system commands by making your response \"`NAME OF COMMAND(TEXT VALUE OF PARAMETER)`\":").first { 41 | result = String(userPrompt) 42 | } 43 | } 44 | return result.replacingOccurrences(of: "\n", with: " \n", options: .regularExpression) 45 | } 46 | 47 | var infoText: some View { 48 | (agentStatus == .coldProcessing && overrideText.isEmpty 49 | ? Text("Warming up...") 50 | : Text(m.createdAt ?? Date(), formatter: messageTimestampFormatter)) 51 | .font(.system(size: CGFloat(Float(fontSizeOption) * 0.8))) 52 | } 53 | 54 | var info: String { 55 | var parts: [String] = [] 56 | if m.responseStartSeconds > 0 { 57 | parts.append("Response started in: \(String(format: "%.3f", m.responseStartSeconds)) seconds") 58 | } 59 | if m.nPredicted > 0 { 60 | parts.append("Tokens generated: \(String(format: "%d", m.nPredicted))") 61 | } 62 | if m.predictedPerSecond > 0 { 63 | parts.append("Tokens generated per second: \(String(format: "%.3f", m.predictedPerSecond))") 64 | } 65 | if m.modelName != nil, !m.modelName!.isEmpty { 66 | parts.append("Model: \(m.modelName!)") 67 | } 68 | return parts.joined(separator: "\n") 69 | } 70 | 71 | var miniInfo: String { 72 | var parts: [String] = [] 73 | 74 | if let ggufCut = try? Regex(".gguf$"), 75 | let modelName = m.modelName?.replacing(ggufCut, with: "") 76 | { 77 | parts.append("\(modelName)") 78 | } 79 | if m.predictedPerSecond > 0 { 80 | parts.append("\(String(format: "%.1f", m.predictedPerSecond)) tokens/s") 81 | } 82 | 83 | return parts.joined(separator: ", ") 84 | } 85 | 86 | var menuContent: some View { 87 | Group { 88 | if m.responseStartSeconds > 0 { 89 | Button("Advanced details") { 90 | self.showInfoPopover.toggle() 91 | } 92 | } 93 | if overrideText == "", m.text != nil, !m.text!.isEmpty { 94 | CopyButton(text: messageText, buttonText: "Copy message to clipboard") 95 | } 96 | Button(showAll ? "Show Less" : "Show All") { 97 | withAnimation(.spring(duration: 0.8)) { 98 | showAll.toggle() 99 | } 100 | } 101 | } 102 | } 103 | 104 | var infoLine: some View { 105 | let processing = !(overrideText.isEmpty && agentStatus != .processing && agentStatus != .coldProcessing) 106 | let showButtons = isHover && !processing 107 | 108 | return HStack(alignment: .center, spacing: 4) { 109 | infoText 110 | if processing { 111 | Button(action: { 112 | Task { 113 | await conversationManager.agent.interrupt() 114 | } 115 | }, label: { 116 | Image(systemName: "stop.circle").help("Stop generating text") 117 | }).buttonStyle(.plain) 118 | } 119 | Menu(content: { 120 | menuContent 121 | }, label: { 122 | Image(systemName: "ellipsis.circle").imageScale(.medium) 123 | .background(.clear) 124 | .imageScale(.small) 125 | .padding(.leading, 1) 126 | .padding(.horizontal, 3) 127 | .frame(width: 15, height: 15) 128 | .scaleEffect(CGSize(width: 0.96, height: 0.96)) 129 | .background(.primary.opacity(0.00001)) // needed to be clickable 130 | }) 131 | .menuStyle(.circle) 132 | .popover(isPresented: $showInfoPopover) { 133 | Text(info).padding(12).font(.caption).textSelection(.enabled) 134 | } 135 | .opacity(showButtons ? 1 : 0) 136 | .disabled(!overrideText.isEmpty) 137 | .padding(0) 138 | .padding(.vertical, 2) 139 | if showButtons { 140 | Text(miniInfo) 141 | .padding(.leading, 2) 142 | .font(.caption) 143 | .textSelection(.enabled) 144 | } 145 | }.foregroundColor(.gray) 146 | .fixedSize(horizontal: false, vertical: true) 147 | .frame(alignment: .center) 148 | } 149 | 150 | var body: some View { 151 | HStack(alignment: .top) { 152 | ZStack(alignment: .bottomTrailing) { 153 | Image(systemName: m.fromId == Message.USER_SPEAKER_ID ? "person.fill" : "cpu.fill") 154 | .font(.system(size: 17)) 155 | .shadow(color: .secondary.opacity(0.3), radius: 2, x: 0, y: 0.5) 156 | .padding(5) 157 | .background( 158 | Circle() 159 | .fill(m.fromId == Message.USER_SPEAKER_ID ? Color.purple : Color.green) 160 | ) 161 | if agentStatus == .coldProcessing || agentStatus == .processing { 162 | ZStack { 163 | Circle() 164 | .fill(.background) 165 | .overlay(Circle().stroke(.gray.opacity(0.2), lineWidth: 0.5)) 166 | 167 | Group { 168 | Text("•") 169 | .opacity(animateDots ? 1 : 0) 170 | .offset(x: 0) 171 | Text("•") 172 | .offset(x: 4) 173 | .opacity(animateDots ? 1 : 0.5) 174 | .opacity(animateDots ? 1 : 0) 175 | Text("•") 176 | .offset(x: 8) 177 | .opacity(animateDots ? 1 : 0) 178 | .opacity(animateDots ? 1 : 0) 179 | }.animation(Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: animateDots) 180 | .offset(x: -4, y: -0.5) 181 | .font(.caption) 182 | 183 | }.frame(width: 14, height: 14) 184 | .task { 185 | animateDots.toggle() 186 | animateDots = true 187 | } 188 | .onDisappear { 189 | animateDots = false 190 | } 191 | .transition(.opacity) 192 | } 193 | } 194 | .padding(2) 195 | .padding(.top, 1) 196 | 197 | VStack(alignment: .leading, spacing: 1) { 198 | infoLine 199 | Group { 200 | if m.fromId == Message.USER_SPEAKER_ID { 201 | Text(messageText) 202 | .font(.system(size: CGFloat(fontSizeOption))) 203 | } else { 204 | Markdown(messageText) 205 | .markdownTheme(.freeChat) 206 | .markdownCodeSyntaxHighlighter(.splash(theme: self.theme)) 207 | } 208 | } 209 | .textSelection(.enabled) 210 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) 211 | .transition(.identity) 212 | } 213 | .padding(.top, 3) 214 | .padding(.bottom, 8) 215 | .padding(.horizontal, 3) 216 | } 217 | .padding(.vertical, 3) 218 | .padding(.horizontal, 8) 219 | .background(Color(white: 1, opacity: 0.000001)) // makes contextMenu work 220 | .animation(Animation.easeOut, value: isHover) 221 | .contextMenu { 222 | menuContent 223 | } 224 | .onHover { hovered in 225 | isHover = hovered 226 | } 227 | } 228 | 229 | private var theme: Splash.Theme { 230 | // NOTE: We are ignoring the Splash theme font 231 | switch colorScheme { 232 | case ColorScheme.dark: 233 | return .wwdc17(withFont: .init(size: 16)) 234 | default: 235 | return .sunset(withFont: .init(size: 16)) 236 | } 237 | } 238 | } 239 | 240 | private let messageTimestampFormatter: DateFormatter = { 241 | let formatter = DateFormatter() 242 | formatter.dateStyle = .short 243 | formatter.timeStyle = .short 244 | return formatter 245 | }() 246 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/ConversationView/ObservableScrollView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObservableScrollView.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 9/14/23. 6 | // adapted from https://swiftuirecipes.com/blog/swiftui-scrollview-scroll-offset 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ScrollViewOffsetPreferenceKey: PreferenceKey { 12 | static var defaultValue = CGFloat.zero 13 | 14 | static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { 15 | value += nextValue() 16 | } 17 | } 18 | 19 | struct ScrollViewHeightPreferenceKey: PreferenceKey { 20 | static var defaultValue = CGFloat.zero 21 | 22 | static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { 23 | value += nextValue() 24 | } 25 | } 26 | 27 | // A ScrollView wrapper that tracks scroll offset changes. 28 | struct ObservableScrollView: View where Content : View { 29 | @Namespace var scrollSpace 30 | 31 | @Binding var scrollOffset: CGFloat 32 | @Binding var scrollHeight: CGFloat 33 | let content: (ScrollViewProxy) -> Content 34 | 35 | init(scrollOffset: Binding, 36 | scrollHeight: Binding, 37 | @ViewBuilder content: @escaping (ScrollViewProxy) -> Content) { 38 | _scrollOffset = scrollOffset 39 | _scrollHeight = scrollHeight 40 | self.content = content 41 | } 42 | 43 | var body: some View { 44 | ScrollView { 45 | ScrollViewReader { proxy in 46 | content(proxy) 47 | .background(GeometryReader { geo in 48 | let offset = -geo.frame(in: .named(scrollSpace)).minY 49 | let height = geo.size.height 50 | Color 51 | .clear 52 | .preference(key: ScrollViewOffsetPreferenceKey.self, 53 | value: offset) 54 | .preference(key: ScrollViewHeightPreferenceKey.self, 55 | value: height) 56 | }) 57 | } 58 | } 59 | .coordinateSpace(name: scrollSpace) 60 | .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in 61 | scrollOffset = value 62 | } 63 | .onPreferenceChange(ScrollViewHeightPreferenceKey.self) { value in 64 | scrollHeight = value 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/ConversationView/QuickPromptButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuickPromptButton.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 9/4/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct QuickPromptButton: View { 11 | 12 | /// Struct for quick prompts 13 | struct QuickPrompt: Identifiable { 14 | 15 | /// Conform to identifiable 16 | let id: UUID = UUID() 17 | 18 | /// String containing the title of the prompt 19 | var title: String 20 | /// String containing the rest of the prompt 21 | var rest: String 22 | 23 | /// Computed property that returns the full prompt 24 | var text: String { 25 | return "\(self.title) \(self.rest)" 26 | } 27 | 28 | } 29 | 30 | /// A list of test prompts 31 | static var quickPrompts = [ 32 | QuickPrompt( 33 | title: "Write an email", 34 | rest: "asking a colleague for a quick status update." 35 | ), 36 | QuickPrompt( 37 | title: "Write a bullet summary", 38 | rest: "of the leadup and impact of the French Revolution." 39 | ), 40 | QuickPrompt( 41 | title: "Design a DB schema", 42 | rest: "for an online store." 43 | ), 44 | QuickPrompt( 45 | title: "Write a SQL query", 46 | rest: "to count rows in my Users table." 47 | ), 48 | QuickPrompt( 49 | title: "How do you", 50 | rest: "know when a steak is done?" 51 | ), 52 | QuickPrompt( 53 | title: "Write a recipe", 54 | rest: "for the perfect martini." 55 | ), 56 | QuickPrompt( 57 | title: "Write a dad joke", 58 | rest: "that really hits." 59 | ), 60 | QuickPrompt( 61 | title: "Write a Linux 1-liner", 62 | rest: "to count lines of code in a directory." 63 | ), 64 | QuickPrompt( 65 | title: "Write me content", 66 | rest: "for LinkedIn to maximize engagement. It should be about how this post was written by AI. Keep it brief, concise and smart." 67 | ), 68 | QuickPrompt( 69 | title: "Teach me how", 70 | rest: "to make a pizza in 10 simple steps, with timings and portions." 71 | ), 72 | QuickPrompt( 73 | title: "How do I", 74 | rest: "practice basketball while driving?" 75 | ), 76 | QuickPrompt( 77 | title: "Can you tell me", 78 | rest: "about the gate all around transistor?" 79 | ) 80 | ].shuffled() 81 | 82 | @Binding var input: String 83 | var prompt: QuickPrompt 84 | 85 | var body: some View { 86 | Button(action: { 87 | input = prompt.text 88 | }, label: { 89 | VStack(alignment: .leading) { 90 | Text(prompt.title).bold().font(.caption2).lineLimit(1) 91 | Text(prompt.rest).font(.caption2).lineLimit(1).foregroundColor(.secondary) 92 | } 93 | .padding(.vertical, 8) 94 | .padding(.horizontal, 10) 95 | .frame(maxWidth: .infinity, alignment: .leading) 96 | }) 97 | .buttonStyle(CapsuleButtonStyle()) 98 | .frame(maxWidth: 300) 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/CopyButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CopyButton.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 8/18/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CopyButton: View { 11 | var text: String 12 | var buttonText: String = "" 13 | private let pasteboard = NSPasteboard.general 14 | @State var justCopied = false 15 | 16 | var body: some View { 17 | Button { 18 | pasteboard.clearContents() 19 | pasteboard.setString(text, forType: .string) 20 | justCopied = true 21 | 22 | DispatchQueue.main.asyncAfter(deadline: .now() + 5) { 23 | justCopied = false 24 | } 25 | } label: { 26 | if buttonText.isEmpty { 27 | Image(systemName: justCopied ? "checkmark.circle.fill" : "doc.on.doc") 28 | .padding(.vertical, 2) 29 | } else { 30 | Label(buttonText, systemImage: justCopied ? "checkmark.circle.fill" : "doc.on.doc") 31 | .padding(.vertical, 2) 32 | } 33 | } 34 | .frame(alignment: .center) 35 | } 36 | } 37 | 38 | struct CopyButton_Previews: PreviewProvider { 39 | static var previews: some View { 40 | CopyButton(text: "text to copy") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/LengthyTasksView/LengthyTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LengthyTask.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 3/6/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | struct LengthyTask: Identifiable, Equatable { 11 | 12 | init(name: String, progress: Double) { 13 | self.name = name 14 | self.progress = progress 15 | } 16 | 17 | init(name: String, numberOfTasks: Int, progress: Int) { 18 | self.name = name 19 | self.progress = Double(progress)/Double(numberOfTasks) 20 | } 21 | 22 | var id: UUID = UUID() 23 | var name: String 24 | var progress: Double = 0.0 25 | 26 | } 27 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/LengthyTasksView/LengthyTasksController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LengthyTasksController.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 3/6/2024. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | class LengthyTasksController: ObservableObject { 12 | 13 | static let shared: LengthyTasksController = LengthyTasksController() 14 | 15 | @Published var tasks: [LengthyTask] = [] 16 | 17 | var uniqueTasks: [LengthyTask] { 18 | let uniqueNames: [String] = Array(Set(tasks.map({ $0.name }))) 19 | var result: [LengthyTask] = [] 20 | for uniqueName in uniqueNames { 21 | for task in tasks { 22 | if task.name == uniqueName { 23 | result.append(task) 24 | } 25 | break 26 | } 27 | } 28 | return result 29 | } 30 | 31 | public func addTask(name: String, progress: Double) -> LengthyTask { 32 | let newTask: LengthyTask = LengthyTask(name: name, progress: progress) 33 | Task { 34 | await MainActor.run { 35 | withAnimation(.spring()) { 36 | LengthyTasksController.shared.tasks.append(newTask) 37 | } 38 | } 39 | } 40 | return newTask 41 | } 42 | 43 | public func incrementTask(id: UUID, newProgress: Double) { 44 | Task { 45 | await MainActor.run { 46 | withAnimation(.spring()) { 47 | for index in LengthyTasksController.shared.tasks.indices { 48 | if LengthyTasksController.shared.tasks[index].id == id { 49 | LengthyTasksController.shared.tasks[index].progress += newProgress 50 | break 51 | } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | public func removeTask(id: UUID) { 59 | Task { 60 | await MainActor.run { 61 | withAnimation(.spring()) { 62 | LengthyTasksController.shared.tasks = LengthyTasksController.shared.tasks.filter({ $0.id != id }) 63 | } 64 | } 65 | } 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/LengthyTasksView/LengthyTasksView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LengthyTasksView.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 3/6/2024. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Shimmer 11 | 12 | struct LengthyTasksView: View { 13 | 14 | @EnvironmentObject private var lengthyTasksController: LengthyTasksController 15 | 16 | var body: some View { 17 | if !lengthyTasksController.uniqueTasks.isEmpty { 18 | HStack(spacing: 10) { 19 | Text(lengthyTasksController.uniqueTasks.last!.name) 20 | .bold() 21 | .shadow(radius: 5) 22 | .shimmering(bandSize: 0.9) 23 | LoadingAnimationView() 24 | } 25 | .padding(8) 26 | .background { 27 | Capsule() 28 | .stroke(style: StrokeStyle(lineWidth: 1)) 29 | .fill(Color.white) 30 | } 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/LengthyTasksView/LoadingAnimationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadingAnimationView.swift 3 | // FileChat 4 | // 5 | // Created by Bean John on 4/6/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LoadingAnimationView: View { 11 | 12 | @State private var to: CGFloat = 0 13 | @State private var rotation: CGFloat = 0 14 | 15 | var body: some View { 16 | ZStack { 17 | Circle() 18 | .trim(from: 0, to: to) 19 | .stroke(style: .init(lineWidth: 2, lineCap: .round)) 20 | .foregroundColor(.secondary) 21 | .rotationEffect(.degrees(rotation)) 22 | .animation( 23 | .linear(duration: 3) 24 | .repeatForever(autoreverses: false), 25 | value: rotation 26 | ) 27 | .animation( 28 | .linear(duration: 3) 29 | .repeatForever(autoreverses: false), 30 | value: to 31 | ) 32 | .onAppear { 33 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 34 | to = 1.0 35 | rotation = 360 36 | } 37 | } 38 | .frame(width: 10, height: 10) 39 | } 40 | } 41 | } 42 | 43 | #Preview { 44 | LoadingAnimationView() 45 | } 46 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/Markdown/SplashCodeSyntaxHighlighter.swift: -------------------------------------------------------------------------------- 1 | import MarkdownUI 2 | import Splash 3 | import SwiftUI 4 | 5 | struct SplashCodeSyntaxHighlighter: CodeSyntaxHighlighter { 6 | private let syntaxHighlighter: SyntaxHighlighter 7 | 8 | init(theme: Splash.Theme) { 9 | self.syntaxHighlighter = SyntaxHighlighter(format: TextOutputFormat(theme: theme)) 10 | } 11 | 12 | func highlightCode(_ content: String, language: String?) -> Text { 13 | return self.syntaxHighlighter.highlight(content) 14 | } 15 | } 16 | 17 | extension CodeSyntaxHighlighter where Self == SplashCodeSyntaxHighlighter { 18 | static func splash(theme: Splash.Theme) -> Self { 19 | SplashCodeSyntaxHighlighter(theme: theme) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/Markdown/TextOutputFormat.swift: -------------------------------------------------------------------------------- 1 | import Splash 2 | import SwiftUI 3 | 4 | struct TextOutputFormat: OutputFormat { 5 | private let theme: Theme 6 | 7 | init(theme: Theme) { 8 | self.theme = theme 9 | } 10 | 11 | func makeBuilder() -> Builder { 12 | Builder(theme: self.theme) 13 | } 14 | } 15 | 16 | extension TextOutputFormat { 17 | struct Builder: OutputBuilder { 18 | private let theme: Theme 19 | private var accumulatedText: [Text] 20 | 21 | fileprivate init(theme: Theme) { 22 | self.theme = theme 23 | self.accumulatedText = [] 24 | } 25 | 26 | mutating func addToken(_ token: String, ofType type: TokenType) { 27 | let color = self.theme.tokenColors[type] ?? self.theme.plainTextColor 28 | self.accumulatedText.append(Text(token).foregroundColor(.init(color))) 29 | } 30 | 31 | mutating func addPlainText(_ text: String) { 32 | self.accumulatedText.append( 33 | Text(text).foregroundColor(.init(self.theme.plainTextColor)) 34 | ) 35 | } 36 | 37 | mutating func addWhitespace(_ whitespace: String) { 38 | self.accumulatedText.append(Text(whitespace)) 39 | } 40 | 41 | func build() -> Text { 42 | self.accumulatedText.reduce(Text(""), +) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/Markdown/Theme+FreeChat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Theme+FileChat.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 8/18/23. 6 | // 7 | 8 | import SwiftUI 9 | import MarkdownUI 10 | 11 | extension Theme { 12 | 13 | /// A theme that mimics the GitHub style. 14 | /// 15 | /// Style | Preview 16 | /// --- | --- 17 | /// Inline text | ![](GitHubInlines) 18 | /// Headings | ![](GitHubHeading) 19 | /// Blockquote | ![](GitHubBlockquote) 20 | /// Code block | ![](GitHubCodeBlock) 21 | /// Image | ![](GitHubImage) 22 | /// Task list | ![](GitHubTaskList) 23 | /// Bulleted list | ![](GitHubNestedBulletedList) 24 | /// Numbered list | ![](GitHubNumberedList) 25 | /// Table | ![](GitHubTable) 26 | 27 | @AppStorage("fontSizeOption") private static var fontSizeOption: Int = 14 28 | 29 | public static var freeChat: Theme { 30 | Theme() 31 | .text { 32 | ForegroundColor(.text) 33 | BackgroundColor(.background) 34 | FontSize(CGFloat(fontSizeOption)) 35 | } 36 | .code { 37 | FontFamilyVariant(.monospaced) 38 | FontSize(.em(0.85)) 39 | BackgroundColor(.secondaryBackground) 40 | } 41 | .strong { 42 | FontWeight(.semibold) 43 | } 44 | .link { 45 | ForegroundColor(.link) 46 | } 47 | .heading1 { configuration in 48 | VStack(alignment: .leading, spacing: 0) { 49 | configuration.label 50 | .relativePadding(.bottom, length: .em(0.3)) 51 | .relativeLineSpacing(.em(0.125)) 52 | .markdownMargin(top: 24, bottom: 16) 53 | .markdownTextStyle { 54 | FontWeight(.semibold) 55 | FontSize(.em(2)) 56 | } 57 | Divider().overlay(Color.divider) 58 | } 59 | } 60 | .heading2 { configuration in 61 | VStack(alignment: .leading, spacing: 0) { 62 | configuration.label 63 | .relativePadding(.bottom, length: .em(0.3)) 64 | .relativeLineSpacing(.em(0.125)) 65 | .markdownMargin(top: 24, bottom: 16) 66 | .markdownTextStyle { 67 | FontWeight(.semibold) 68 | FontSize(.em(1.5)) 69 | } 70 | Divider().overlay(Color.divider) 71 | } 72 | } 73 | .heading3 { configuration in 74 | configuration.label 75 | .relativeLineSpacing(.em(0.125)) 76 | .markdownMargin(top: 24, bottom: 16) 77 | .markdownTextStyle { 78 | FontWeight(.semibold) 79 | FontSize(.em(1.25)) 80 | } 81 | } 82 | .heading4 { configuration in 83 | configuration.label 84 | .relativeLineSpacing(.em(0.125)) 85 | .markdownMargin(top: 24, bottom: 16) 86 | .markdownTextStyle { 87 | FontWeight(.semibold) 88 | } 89 | } 90 | .heading5 { configuration in 91 | configuration.label 92 | .relativeLineSpacing(.em(0.125)) 93 | .markdownMargin(top: 24, bottom: 16) 94 | .markdownTextStyle { 95 | FontWeight(.semibold) 96 | FontSize(.em(0.875)) 97 | } 98 | } 99 | .heading6 { configuration in 100 | configuration.label 101 | .relativeLineSpacing(.em(0.125)) 102 | .markdownMargin(top: 24, bottom: 16) 103 | .markdownTextStyle { 104 | FontWeight(.semibold) 105 | FontSize(.em(0.85)) 106 | ForegroundColor(.tertiaryText) 107 | } 108 | } 109 | .paragraph { configuration in 110 | configuration.label 111 | .fixedSize(horizontal: false, vertical: true) 112 | .relativeLineSpacing(.em(0.25)) 113 | .markdownMargin(top: 0, bottom: 16) 114 | } 115 | .blockquote { configuration in 116 | HStack(spacing: 0) { 117 | RoundedRectangle(cornerRadius: 6, style: .continuous) 118 | .fill(Color.border) 119 | .relativeFrame(width: .em(0.2)) 120 | configuration.label 121 | .markdownTextStyle { ForegroundColor(.secondaryText) } 122 | .relativePadding(.horizontal, length: .em(1)) 123 | } 124 | .fixedSize(horizontal: false, vertical: true) 125 | } 126 | .codeBlock { configuration in 127 | ZStack(alignment: .topTrailing) { 128 | ScrollView(.horizontal) { 129 | configuration.label 130 | .relativeLineSpacing(.em(0.225)) 131 | .markdownTextStyle { 132 | FontFamilyVariant(.monospaced) 133 | FontSize(.em(0.85)) 134 | } 135 | .padding(16) 136 | } 137 | .background(Color.secondaryBackground) 138 | .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) 139 | .markdownMargin(top: 0, bottom: 16) 140 | 141 | CopyButton(text: configuration.content).padding(10) 142 | } 143 | } 144 | .listItem { configuration in 145 | configuration.label 146 | .markdownMargin(top: .em(0.25)) 147 | } 148 | .taskListMarker { configuration in 149 | Image(systemName: configuration.isCompleted ? "checkmark.square.fill" : "square") 150 | .symbolRenderingMode(.hierarchical) 151 | .foregroundStyle(Color.checkbox, Color.checkboxBackground) 152 | .imageScale(.small) 153 | .relativeFrame(minWidth: .em(1.5), alignment: .trailing) 154 | } 155 | .table { configuration in 156 | configuration.label 157 | .fixedSize(horizontal: false, vertical: true) 158 | .markdownTableBorderStyle(.init(color: .border)) 159 | .markdownTableBackgroundStyle( 160 | .alternatingRows(Color.background, Color.secondaryBackground) 161 | ) 162 | .markdownMargin(top: 0, bottom: 16) 163 | } 164 | .tableCell { configuration in 165 | configuration.label 166 | .markdownTextStyle { 167 | if configuration.row == 0 { 168 | FontWeight(.semibold) 169 | } 170 | BackgroundColor(nil) 171 | } 172 | .fixedSize(horizontal: false, vertical: true) 173 | .padding(.vertical, 6) 174 | .padding(.horizontal, 13) 175 | .relativeLineSpacing(.em(0.25)) 176 | } 177 | .thematicBreak { 178 | Divider() 179 | .relativeFrame(height: .em(0.25)) 180 | .overlay(Color.border) 181 | .markdownMargin(top: 24, bottom: 24) 182 | } 183 | } 184 | } 185 | 186 | extension Color { 187 | fileprivate static let text = Color( 188 | light: Color(rgba: 0x0606_06ff), dark: Color(rgba: 0xfbfb_fcff) 189 | ) 190 | fileprivate static let secondaryText = Color( 191 | light: Color(rgba: 0x6b6e_7bff), dark: Color(rgba: 0x9294_a0ff) 192 | ) 193 | fileprivate static let tertiaryText = Color( 194 | light: Color(rgba: 0x6b6e_7bff), dark: Color(rgba: 0x6d70_7dff) 195 | ) 196 | fileprivate static let background = Color.clear 197 | fileprivate static let secondaryBackground = Color( 198 | light: Color(rgba: 0xf7f7_f9ff), dark: Color(rgba: 0x2526_2aff) 199 | ) 200 | fileprivate static let link = Color( 201 | light: Color(rgba: 0x2c65_cfff), dark: Color(rgba: 0x4c8e_f8ff) 202 | ) 203 | fileprivate static let border = Color( 204 | light: Color(rgba: 0xe4e4_e8ff), dark: Color(rgba: 0x4244_4eff) 205 | ) 206 | fileprivate static let divider = Color( 207 | light: Color(rgba: 0xd0d0_d3ff), dark: Color(rgba: 0x3334_38ff) 208 | ) 209 | fileprivate static let checkbox = Color(rgba: 0xb9b9_bbff) 210 | fileprivate static let checkboxBackground = Color(rgba: 0xeeee_efff) 211 | } 212 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/NavList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConversationNavItem.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 8/5/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NavList: View { 11 | @Environment(\.managedObjectContext) private var viewContext 12 | @Environment(\.openWindow) private var openWindow 13 | @EnvironmentObject var conversationManager: ConversationManager 14 | 15 | @FetchRequest( 16 | sortDescriptors: [NSSortDescriptor(keyPath: \Conversation.lastMessageAt, ascending: false)], 17 | animation: .default) 18 | private var items: FetchedResults 19 | 20 | @Binding var selection: Set 21 | @Binding var showDeleteConfirmation: Bool 22 | 23 | @State var editing: Conversation? 24 | @State var newTitle = "" 25 | @FocusState var fieldFocused 26 | 27 | var body: some View { 28 | List( 29 | items, 30 | id: \.self, 31 | selection: $selection 32 | ) { item in 33 | if editing == item { 34 | TextField(item.titleWithDefault, text: $newTitle) 35 | .textFieldStyle(.plain) 36 | .focused($fieldFocused) 37 | .onSubmit { 38 | saveNewTitle(conversation: item) 39 | } 40 | .onExitCommand { 41 | editing = nil 42 | } 43 | .onChange(of: fieldFocused) { focused in 44 | if !focused { 45 | editing = nil 46 | } 47 | } 48 | .padding(.horizontal, 4) 49 | } else { 50 | Text(item.titleWithDefault).padding(.leading, 4) 51 | } 52 | } 53 | .frame(minWidth: 50) 54 | .toolbar { 55 | ToolbarItem { 56 | Spacer() 57 | } 58 | ToolbarItem { 59 | Button(action: newConversation) { 60 | Label("Add conversation", systemImage: "plus") 61 | } 62 | } 63 | } 64 | .onChange(of: items.count) { _ in 65 | selection = Set([items.first].compactMap { $0 }) 66 | } 67 | .contextMenu(forSelectionType: Conversation.self) { _ in 68 | Button { 69 | deleteSelectedConversations() 70 | } label: { 71 | Label("Delete", systemImage: "trash") 72 | } 73 | } primaryAction: { items in 74 | if items.count > 1 { return } 75 | editing = items.first 76 | fieldFocused = true 77 | } 78 | .confirmationDialog("Are you sure you want to delete \(selection.count == 1 ? "this" : "\(selection.count)") conversation\(selection.count == 1 ? "" : "s")?", isPresented: $showDeleteConfirmation) { 79 | Button("Yes, delete") { 80 | deleteSelectedConversations() 81 | } 82 | .keyboardShortcut(.defaultAction) 83 | } 84 | } 85 | 86 | private func saveNewTitle(conversation: Conversation) { 87 | conversation.title = newTitle 88 | newTitle = "" 89 | do { 90 | try viewContext.save() 91 | 92 | // HACK: trigger a state change so the title will refresh the title bar 93 | selection.remove(conversation) 94 | selection.insert(conversation) 95 | } catch { 96 | let nsError = error as NSError 97 | print("Unresolved error \(nsError), \(nsError.userInfo)") 98 | } 99 | } 100 | 101 | private func deleteSelectedConversations() { 102 | withAnimation { 103 | selection.forEach(viewContext.delete) 104 | do { 105 | try viewContext.save() 106 | selection.removeAll() 107 | if items.count > 0 { 108 | selection.insert(items.first!) 109 | } 110 | } catch { 111 | let nsError = error as NSError 112 | print("Unresolved error \(nsError), \(nsError.userInfo)") 113 | } 114 | } 115 | } 116 | 117 | private func deleteConversation(conversation: Conversation) { 118 | withAnimation { 119 | viewContext.delete(conversation) 120 | do { 121 | try viewContext.save() 122 | } catch { 123 | let nsError = error as NSError 124 | print("Unresolved error \(nsError), \(nsError.userInfo)") 125 | } 126 | } 127 | } 128 | 129 | private func sortedItems() -> [Conversation] { 130 | items.sorted(by: { $0.updatedAt!.compare($1.updatedAt!) == .orderedDescending }) 131 | } 132 | 133 | private func newConversation() { 134 | conversationManager.newConversation(viewContext: viewContext, openWindow: openWindow) 135 | } 136 | } 137 | 138 | #if DEBUG 139 | struct NavList_Previews_Container: View { 140 | @State public var selection: Set = Set() 141 | @State public var showDeleteConfirmation = false 142 | 143 | var body: some View { 144 | NavList(selection: $selection, showDeleteConfirmation: $showDeleteConfirmation) 145 | .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) 146 | } 147 | } 148 | 149 | struct NavList_Previews: PreviewProvider { 150 | static var previews: some View { 151 | NavList_Previews_Container() 152 | } 153 | } 154 | #endif 155 | -------------------------------------------------------------------------------- /FreeChat/Views/Default View/WelcomeSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WelcomeSheet.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 9/28/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct WelcomeSheet: View { 11 | @FetchRequest( 12 | sortDescriptors: [NSSortDescriptor(keyPath: \Model.size, ascending: false)] 13 | ) 14 | private var models: FetchedResults 15 | 16 | @Binding var isPresented: Bool 17 | @State var showModels = false 18 | 19 | @Environment(\.managedObjectContext) private var viewContext 20 | @AppStorage("selectedModelId") private var selectedModelId: String? 21 | 22 | @StateObject var downloadManager = DownloadManager.shared 23 | 24 | 25 | var body: some View { 26 | VStack { 27 | if models.count == 0 { 28 | Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) 29 | Text("Welcome to FileChat").font(.largeTitle) 30 | 31 | Text("Download a model to get started") 32 | .font(.title3) 33 | Text("FileChat runs AI locally on your Mac for maximum privacy and security. You can chat with different AI models, which vary in terms of training data and knowledge base.\n\nThe default model is general purpose, small, and works on most computers. Larger models are slower but wiser. Some models specialize in certain tasks like coding Python. FileChat is compatible with most models in the GGUF format. [Find new models](https://huggingface.co/models?search=GGUF)") 34 | .font(.callout) 35 | .lineLimit(10) 36 | .fixedSize(horizontal: false, vertical: true) 37 | .padding(.vertical, 16) 38 | 39 | ForEach(downloadManager.tasks, id: \.self) { t in 40 | ProgressView(t.progress).padding(5) 41 | } 42 | } else { 43 | Image(systemName: "checkmark.circle.fill") 44 | .resizable() 45 | .frame(width: 60, height: 60) 46 | .foregroundColor(.green) 47 | .imageScale(.large) 48 | 49 | Text("Success!").font(.largeTitle) 50 | 51 | Text("The model was installed.") 52 | .font(.title3) 53 | 54 | Button("Continue") { 55 | isPresented = false 56 | } 57 | .buttonStyle(.borderedProminent) 58 | .controlSize(.large) 59 | .padding(.top, 16) 60 | .padding(.horizontal, 40) 61 | .keyboardShortcut(.defaultAction) 62 | } 63 | 64 | if models.count == 0, downloadManager.tasks.count == 0 { 65 | Button(action: downloadDefault) { 66 | HStack { 67 | Text("Download default model") 68 | Text("6.6 GB").foregroundStyle(.white.opacity(0.7)) 69 | }.padding(.horizontal, 20) 70 | } 71 | .keyboardShortcut(.defaultAction) 72 | .controlSize(.large) 73 | .padding(.top, 6) 74 | .padding(.horizontal) 75 | 76 | Button("Load custom model") { 77 | showModels = true 78 | }.buttonStyle(.link) 79 | .padding(.top, 4) 80 | .font(.callout) 81 | } else { 82 | 83 | } 84 | } 85 | .interactiveDismissDisabled() 86 | .frame(maxWidth: 480) 87 | .padding(.vertical, 40) 88 | .padding(.horizontal, 60) 89 | .sheet(isPresented: $showModels) { 90 | EditModels(selectedModelId: $selectedModelId) 91 | } 92 | } 93 | 94 | private func downloadDefault() { 95 | downloadManager.viewContext = viewContext 96 | downloadManager.startDownload(url: Model.defaultModelUrl) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /FreeChat/Views/EnvironmentValues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentValues.swift 3 | // FreeChat 4 | // 5 | // Created by Peter Sugihara on 9/17/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | private struct NewConversationKey: EnvironmentKey { 12 | static let defaultValue: () -> Void = {} 13 | } 14 | 15 | extension EnvironmentValues { 16 | var newConversation: () -> Void { 17 | get { self[NewConversationKey.self] } 18 | set { self[NewConversationKey.self] = newValue } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /FreeChat/Views/Settings/AISettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AISettings.swift 3 | // FreeChat 4 | // 5 | // Created by Peter Sugihara on 12/9/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AISettings: View { 11 | var body: some View { 12 | Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) 13 | } 14 | } 15 | 16 | #Preview { 17 | AISettings() 18 | } 19 | -------------------------------------------------------------------------------- /FreeChat/Views/Settings/EditModels.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomizeModelsView.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 9/3/23. 6 | // 7 | 8 | import SwiftUI 9 | import UniformTypeIdentifiers.UTType 10 | 11 | struct EditModels: View { 12 | 13 | @Environment(\.colorScheme) var colorScheme 14 | @Environment(\.managedObjectContext) private var viewContext 15 | @Environment(\.dismiss) var dismiss 16 | @EnvironmentObject var conversationManager: ConversationManager 17 | 18 | @Binding var selectedModelId: String? 19 | 20 | // List state 21 | @State var editingModelId: String? // Highlight selection in the list 22 | @State var hoveredModelId: String? 23 | 24 | @FetchRequest( 25 | sortDescriptors: [NSSortDescriptor(keyPath: \Model.size, ascending: false)], 26 | animation: .default) 27 | private var items: FetchedResults 28 | 29 | @State var showFileImporter = false 30 | @State var errorText = "" 31 | 32 | var bottomToolbar: some View { 33 | VStack(spacing: 0) { 34 | Rectangle() 35 | .frame(maxWidth: .infinity, maxHeight: 1) 36 | .foregroundColor(Color(NSColor.gridColor)) 37 | HStack { 38 | Button(action: { 39 | showFileImporter = true 40 | }) { 41 | Image(systemName: "plus").padding(.horizontal, 6) 42 | .frame(maxHeight: .infinity) 43 | } 44 | .frame(maxHeight: .infinity) 45 | .padding(.leading, 10) 46 | .buttonStyle(.borderless) 47 | .help("Add custom model (.gguf file)") 48 | .background(Color.white.opacity(0.0001)) 49 | .fileImporter( 50 | isPresented: $showFileImporter, 51 | allowedContentTypes: [UTType("com.npc-pet.Chats.gguf") ?? .data], 52 | allowsMultipleSelection: true, 53 | onCompletion: importModel 54 | ) 55 | 56 | Button(action: deleteEditing) { 57 | Image(systemName: "minus").padding(.horizontal, 6) 58 | .frame(maxHeight: .infinity) 59 | } 60 | .frame(maxHeight: .infinity) 61 | .buttonStyle(.borderless) 62 | .disabled(editingModelId == nil) 63 | 64 | Spacer() 65 | if !errorText.isEmpty { 66 | Text(errorText).foregroundColor(.red) 67 | } 68 | Spacer() 69 | Button("Select") { 70 | selectEditing() 71 | } 72 | .keyboardShortcut(.return, modifiers: []) 73 | .frame(width: 0) 74 | .hidden() 75 | Button("Done") { 76 | dismiss() 77 | }.padding(.horizontal, 10).keyboardShortcut(.escape) 78 | } 79 | .frame(maxWidth: .infinity, maxHeight: 27, alignment: .leading) 80 | } 81 | .background(Material.bar) 82 | } 83 | 84 | func modelListItem(_ i: Model, url: URL) -> some View { 85 | let loading = conversationManager.loadingModelId != nil && conversationManager.loadingModelId == i.id?.uuidString 86 | return HStack { 87 | Group { 88 | if loading { 89 | ProgressView().controlSize(.small) 90 | } else { 91 | Text("✓").bold().opacity(selectedModelId == i.id?.uuidString ? 1 : 0) 92 | } 93 | }.frame(width: 20) 94 | Text(i.name ?? url.lastPathComponent).tag(i.id?.uuidString ?? "") 95 | if i.size != 0 { 96 | Text("\(String(format: "%.2f", Double(i.size) / 1000.0)) GB") 97 | .foregroundColor(.secondary) 98 | } 99 | Spacer() 100 | if !loading, i.error != nil, !i.error!.isEmpty { 101 | Label(i.error!, systemImage: "exclamationmark.triangle.fill") 102 | .font(.caption) 103 | .accentColor(.red) 104 | } 105 | hoverSelect(i.id?.uuidString ?? "", loading: loading) 106 | }.tag(i.id?.uuidString ?? "") 107 | .padding(4) 108 | .onHover { hovered in 109 | if hovered { 110 | hoveredModelId = i.id?.uuidString 111 | } else if hoveredModelId == i.id?.uuidString { 112 | hoveredModelId = nil 113 | } 114 | } 115 | } 116 | 117 | func hoverSelect(_ modelId: String, loading: Bool = false) -> some View { 118 | Button("Select") { 119 | selectedModelId = modelId 120 | } 121 | .opacity(hoveredModelId == modelId && selectedModelId != modelId ? 1 : 0) 122 | .disabled(hoveredModelId != modelId || loading || selectedModelId == modelId) 123 | } 124 | 125 | var modelList: some View { 126 | List(selection: $editingModelId) { 127 | Section("Models") { 128 | ForEach(items) { i in 129 | if let url = i.url { 130 | modelListItem(i, url: url) 131 | .help(url.path) 132 | .contextMenu { 133 | Button("Delete Model") { deleteModel(i) } 134 | Button("Show in Finder") { showInFinder(url) } 135 | } 136 | } 137 | } 138 | } 139 | } 140 | .listStyle(.inset(alternatesRowBackgrounds: true)) 141 | .onDeleteCommand(perform: deleteEditing) 142 | } 143 | 144 | var body: some View { 145 | VStack(spacing: 0) { 146 | modelList 147 | bottomToolbar 148 | }.frame(width: 500, height: 290) 149 | } 150 | 151 | private func deleteEditing() { 152 | errorText = "" 153 | if let model = items.first(where: { m in m.id?.uuidString == editingModelId }) { 154 | deleteModel(model) 155 | } 156 | } 157 | 158 | private func showInFinder(_ url: URL) { 159 | NSWorkspace.shared.activateFileViewerSelecting([url]) 160 | } 161 | 162 | private func selectEditing() { 163 | if editingModelId != nil { 164 | selectedModelId = editingModelId! 165 | } 166 | } 167 | 168 | private func deleteModel(_ model: Model) { 169 | errorText = "" 170 | viewContext.delete(model) 171 | do { 172 | try viewContext.save() 173 | if editingModelId == selectedModelId { 174 | selectedModelId = items.first?.id?.uuidString 175 | } 176 | editingModelId = nil 177 | } catch { 178 | print("Error deleting model \(model)", error) 179 | } 180 | } 181 | 182 | private func importModel(result: Result<[URL], Error>) { 183 | errorText = "" 184 | 185 | switch result { 186 | case .success(let fileURLs): 187 | do { 188 | let insertedModels = try insertModels(from: fileURLs) 189 | selectedModelId = insertedModels.first?.id?.uuidString ?? selectedModelId 190 | } catch let error as ModelCreateError { 191 | errorText = error.localizedDescription 192 | } catch (let err) { 193 | print("Error creating model", err.localizedDescription) 194 | } 195 | case .failure(let error): 196 | // handle error 197 | print(error) 198 | } 199 | } 200 | 201 | private func insertModels(from fileURLs: [URL]) throws -> [Model] { 202 | var insertedModels = [Model]() 203 | for fileURL in fileURLs { 204 | guard nil == items.first(where: { $0.url == fileURL }) else { continue } 205 | insertedModels.append(try Model.create(context: viewContext, fileURL: fileURL)) 206 | } 207 | 208 | return insertedModels 209 | } 210 | } 211 | 212 | struct EditModels_Previews_Container: View { 213 | @State var selectedModelId: String? 214 | var body: some View { 215 | EditModels(selectedModelId: $selectedModelId) 216 | EditModels(selectedModelId: $selectedModelId, errorText: ModelCreateError.unknownFormat.localizedDescription) 217 | .previewDisplayName("Edit Models Error") 218 | 219 | } 220 | } 221 | 222 | struct EditModels_Previews: PreviewProvider { 223 | static var previews: some View { 224 | 225 | let ctx = PersistenceController.preview.container.viewContext 226 | let c = try! Conversation.create(ctx: ctx) 227 | let cm = ConversationManager() 228 | cm.currentConversation = c 229 | cm.agent = Agent(id: "llama", systemPrompt: "", modelPath: "", contextLength: DEFAULT_CONTEXT_LENGTH) 230 | 231 | let question = Message(context: ctx) 232 | question.conversation = c 233 | question.text = "how can i check file size in swift?" 234 | 235 | let response = Message(context: ctx) 236 | response.conversation = c 237 | response.fromId = "llama" 238 | response.text = """ 239 | Hi! You can use `FileManager` to get information about files, including their sizes. Here's an example of getting the size of a text file: 240 | ```swift 241 | let path = "path/to/file" 242 | do { 243 | let attributes = try FileManager.default.attributesOfItem(atPath: path) 244 | if let fileSize = attributes[FileAttributeKey.size] as? UInt64 { 245 | print("The file is \\(ByteCountFormatter().string(fromByteCount: Int64(fileSize)))") 246 | } 247 | } catch { 248 | // Handle any errors 249 | } 250 | ``` 251 | """ 252 | 253 | return EditModels_Previews_Container() 254 | .environment(\.managedObjectContext, ctx) 255 | .environmentObject(cm) 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /FreeChat/Views/Settings/EditSystemPrompt.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditSystemPrompt.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 9/3/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct EditSystemPrompt: View { 11 | @Environment(\.dismiss) var dismiss 12 | 13 | @AppStorage("systemPrompt") private var systemPrompt = DEFAULT_SYSTEM_PROMPT 14 | @State private var pendingSystemPrompt = "" 15 | private var systemPromptPendingSave: Bool { 16 | pendingSystemPrompt != "" && pendingSystemPrompt != systemPrompt 17 | } 18 | @State private var didSaveSystemPrompt = false 19 | 20 | var body: some View { 21 | VStack(alignment: .leading) { 22 | Text("Edit the system prompt to customize behavior and personality.").padding(.horizontal, 8) 23 | .foregroundColor(.secondary) 24 | .font(.body) 25 | Group { 26 | TextEditor(text: $pendingSystemPrompt).onAppear { 27 | pendingSystemPrompt = systemPrompt 28 | } 29 | .font(.body) 30 | .frame(minWidth: 200, 31 | idealWidth: 250, 32 | maxWidth: .infinity, 33 | minHeight: 100, 34 | idealHeight: 120, 35 | maxHeight: .infinity, 36 | alignment: .center) 37 | .scrollContentBackground(.hidden) 38 | .background(.clear) 39 | } 40 | .padding(EdgeInsets(top: 8, leading: 4, bottom: 8, trailing: 4)) 41 | .background(Color(NSColor.alternatingContentBackgroundColors.last ?? NSColor.controlBackgroundColor)) 42 | 43 | HStack { 44 | Button("Restore Default") { 45 | pendingSystemPrompt = DEFAULT_SYSTEM_PROMPT 46 | } 47 | .disabled(pendingSystemPrompt == DEFAULT_SYSTEM_PROMPT) 48 | Spacer() 49 | Button("Cancel") { 50 | dismiss() 51 | } 52 | 53 | Button("Save") { 54 | systemPrompt = pendingSystemPrompt 55 | dismiss() 56 | } 57 | .disabled(!systemPromptPendingSave) 58 | } 59 | .frame(maxWidth: .infinity, alignment: .bottomTrailing) 60 | }.padding(10) 61 | .background(Color(NSColor.controlBackgroundColor)) 62 | } 63 | } 64 | 65 | struct EditSystemPrompt_Previews: PreviewProvider { 66 | static var previews: some View { 67 | let context = PersistenceController.preview.container.viewContext 68 | EditSystemPrompt().environment(\.managedObjectContext, context) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /FreeChat/Views/Settings/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 8/6/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SettingsView: View { 11 | static let title = "Settings" 12 | 13 | private enum Tabs: Hashable { 14 | case ai, ui 15 | } 16 | 17 | var body: some View { 18 | TabView { 19 | UISettingsView() 20 | .tabItem { 21 | Label("General", systemImage: "gear") 22 | } 23 | .tag(Tabs.ui) 24 | AISettingsView() 25 | .tabItem { 26 | Label("Intelligence", systemImage: "hands.and.sparkles.fill") 27 | } 28 | .tag(Tabs.ai) 29 | } 30 | .frame(minWidth: 300, maxWidth: 600, minHeight: 184, idealHeight: 195, maxHeight: 400, alignment: .center) 31 | } 32 | } 33 | 34 | struct SettingsView_Previews: PreviewProvider { 35 | static var previews: some View { 36 | SettingsView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /FreeChat/Views/Settings/UISettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UISettingsView.swift 3 | // FileChat 4 | // 5 | // Created by Peter Sugihara on 12/9/23. 6 | // 7 | 8 | import SwiftUI 9 | import KeyboardShortcuts 10 | 11 | struct UISettingsView: View { 12 | @FetchRequest( 13 | sortDescriptors: [NSSortDescriptor(keyPath: \Model.size, ascending: true)], 14 | animation: .default) 15 | private var models: FetchedResults 16 | 17 | @AppStorage("playSoundEffects") private var playSoundEffects = true 18 | @AppStorage("showFeedbackButtons") private var showFeedbackButtons = true 19 | 20 | var globalHotkey: some View { 21 | KeyboardShortcuts.Recorder("Summon chat", name: .summonFileChat) 22 | } 23 | 24 | var soundEffects: some View { 25 | Toggle("Play sound effects", isOn: $playSoundEffects) 26 | } 27 | 28 | var feedbackButtons: some View { 29 | VStack(alignment: .leading) { 30 | Toggle("Show feedback buttons", isOn: $showFeedbackButtons) 31 | 32 | Text("The thumb feedback buttons allow you to contribute conversations to an open dataset to help train future models.") 33 | .font(.callout) 34 | .foregroundColor(Color(NSColor.secondaryLabelColor)) 35 | .lineLimit(5) 36 | .fixedSize(horizontal: false, vertical: true) 37 | } 38 | } 39 | 40 | var body: some View { 41 | Form { 42 | globalHotkey 43 | soundEffects 44 | feedbackButtons 45 | } 46 | .formStyle(.grouped) 47 | .frame(minWidth: 300, maxWidth: 600, minHeight: 184, idealHeight: 195, maxHeight: 400, alignment: .center) 48 | } 49 | } 50 | 51 | #Preview { 52 | UISettingsView() 53 | } 54 | -------------------------------------------------------------------------------- /FreeChat/Views/SystemPromptsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomizeSystemPromptsView.swift 3 | // FreeChat 4 | // 5 | // Created by Peter Sugihara on 9/3/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomizeSystemPromptsView: View { 11 | var body: some View { 12 | Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) 13 | } 14 | } 15 | 16 | struct CustomizeSystemPromptsView_Previews: PreviewProvider { 17 | static var previews: some View { 18 | CustomizeSystemPromptsView() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /FreeChatTests/PromptTemplateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PromptTemplateTests.swift 3 | // FileChatTests 4 | // 5 | // Created by Peter Sugihara on 10/6/23. 6 | // 7 | 8 | import XCTest 9 | @testable import FileChat 10 | 11 | final class PromptTemplateTests: XCTestCase { 12 | var shortConvo: [String] = [ 13 | "Hey baby!", 14 | "Wassup, user?", 15 | "n2m hbu" 16 | ] 17 | 18 | override func setUpWithError() throws { 19 | // Put setup code here. This method is called before the invocation of each test method in the class. 20 | } 21 | 22 | override func tearDownWithError() throws { 23 | // Put teardown code here. This method is called after the invocation of each test method in the class. 24 | } 25 | 26 | func testLlama2Opening() throws { 27 | let p = Llama2Template().run(systemPrompt: "A system prompt", messages: ["sup"]) 28 | let expected = """ 29 | [INST] <> 30 | A system prompt 31 | <> 32 | 33 | sup [/INST] \ 34 | 35 | """ 36 | 37 | XCTAssert(!p.isEmpty) 38 | XCTAssertEqual(p, expected) 39 | } 40 | 41 | func testLlama2ShortConvo() throws { 42 | let p = Llama2Template().run(systemPrompt: "A system prompt", messages: shortConvo) 43 | let expected = """ 44 | [INST] <> 45 | A system prompt 46 | <> 47 | 48 | Hey baby! [/INST] Wassup, user? [INST] n2m hbu [/INST] 49 | """ 50 | 51 | XCTAssert(!p.isEmpty) 52 | XCTAssertEqual(p, expected) 53 | } 54 | 55 | func testVicunaOpening() throws { 56 | let expected = """ 57 | SYSTEM: A system prompt 58 | USER: hi 59 | ASSISTANT: \ 60 | 61 | """ 62 | let p = VicunaTemplate().run(systemPrompt: "A system prompt", messages: ["hi"]) 63 | 64 | XCTAssert(!p.isEmpty) 65 | XCTAssertEqual(p, expected) 66 | } 67 | 68 | func testVicunaShortConvo() throws { 69 | let expected = """ 70 | SYSTEM: A system prompt 71 | USER: Hey baby! 72 | ASSISTANT: Wassup, user? 73 | USER: n2m hbu 74 | ASSISTANT: \ 75 | 76 | """ 77 | let p = VicunaTemplate().run(systemPrompt: "A system prompt", messages: shortConvo) 78 | 79 | XCTAssert(!p.isEmpty) 80 | XCTAssertEqual(p, expected) 81 | } 82 | 83 | func testChatMLOpening() throws { 84 | let expected = """ 85 | <|im_start|>system 86 | A system prompt 87 | <|im_end|> 88 | <|im_start|>user 89 | hi 90 | <|im_end|> 91 | <|im_start|>assistant 92 | 93 | """ 94 | let p = ChatMLTemplate().run(systemPrompt: "A system prompt", messages: ["hi"]) 95 | 96 | XCTAssert(!p.isEmpty) 97 | XCTAssertEqual(p, expected) 98 | } 99 | 100 | func testChatMLShortConvo() throws { 101 | let expected = """ 102 | <|im_start|>system 103 | A system prompt 104 | <|im_end|> 105 | <|im_start|>user 106 | Hey baby! 107 | <|im_end|> 108 | <|im_start|>assistant 109 | Wassup, user? 110 | <|im_end|> 111 | <|im_start|>user 112 | n2m hbu 113 | <|im_end|> 114 | <|im_start|>assistant 115 | 116 | """ 117 | let p = ChatMLTemplate().run(systemPrompt: "A system prompt", messages: shortConvo) 118 | 119 | XCTAssert(!p.isEmpty) 120 | XCTAssertEqual(p, expected) 121 | } 122 | 123 | func testAlpacaOpening() throws { 124 | let expected = """ 125 | ### Instruction: 126 | A system prompt 127 | 128 | Conversation so far: 129 | user: hi 130 | you: 131 | 132 | Respond to user's last line with markdown. 133 | 134 | ### Response: 135 | 136 | """ 137 | let p = AlpacaTemplate().run(systemPrompt: "A system prompt", messages: ["hi"]) 138 | 139 | XCTAssert(!p.isEmpty) 140 | XCTAssertEqual(p, expected) 141 | } 142 | 143 | func testAlpacaShortConvo() throws { 144 | let expected = """ 145 | ### Instruction: 146 | A system prompt 147 | 148 | Conversation so far: 149 | user: Hey baby! 150 | you: Wassup, user? 151 | user: n2m hbu 152 | you: 153 | 154 | Respond to user's last line with markdown. 155 | 156 | ### Response: 157 | 158 | """ 159 | let p = AlpacaTemplate().run(systemPrompt: "A system prompt", messages: shortConvo) 160 | 161 | XCTAssert(!p.isEmpty) 162 | XCTAssertEqual(p, expected) 163 | } 164 | 165 | func testTemplatesHaveMatchingFormats() throws { 166 | for format in TemplateFormat.allCases { 167 | let template = TemplateManager.templates[format] 168 | XCTAssertEqual(template.format, format) 169 | } 170 | } 171 | 172 | func testFormatWithModelName() throws { 173 | XCTAssertEqual(TemplateManager.formatFromModel(nil), .vicuna) 174 | XCTAssertEqual(TemplateManager.formatFromModel(""), .vicuna) 175 | XCTAssertEqual(TemplateManager.formatFromModel("codellama-34b-instruct.Q4_K_M.gguf"), .llama2) 176 | XCTAssertEqual(TemplateManager.formatFromModel("nous-hermes-llama-2-7b.Q5_K_M.gguf"), .alpaca) 177 | XCTAssertEqual(TemplateManager.formatFromModel("airoboros-m-7b-3.1.Q4_0.gguf"), .llama2) 178 | XCTAssertEqual(TemplateManager.formatFromModel("synthia-7b-v1.5.Q3_K_S.gguf"), .vicuna) 179 | XCTAssertEqual(TemplateManager.formatFromModel("openhermes-2-mistral-7b.Q8_0.gguf"), .chatML) 180 | XCTAssertEqual(TemplateManager.formatFromModel("phi-2-openhermes-2.5.Q5_K_M.gguf"), .chatML) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /FreeChatUITests/FreeChatUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileChatUITests.swift 3 | // FileChatUITests 4 | // 5 | // Created by Peter Sugihara on 7/31/23. 6 | // 7 | 8 | import XCTest 9 | 10 | final class FileChatUITests: 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 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use XCTAssert and related functions to verify your tests produce the correct results. 31 | } 32 | 33 | func testLaunchPerformance() throws { 34 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 35 | // This measures how long it takes to launch your application. 36 | measure(metrics: [XCTApplicationLaunchMetric()]) { 37 | XCUIApplication().launch() 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /FreeChatUITests/FreeChatUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileChatUITestsLaunchTests.swift 3 | // FileChatUITests 4 | // 5 | // Created by Peter Sugihara on 7/31/23. 6 | // 7 | 8 | import XCTest 9 | 10 | final class FileChatUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | func testLaunch() throws { 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Insert steps here to perform after app launch but before taking a screenshot, 25 | // such as logging into a test account or navigating somewhere in the app 26 | 27 | let attachment = XCTAttachment(screenshot: app.screenshot()) 28 | attachment.name = "Launch Screen" 29 | attachment.lifetime = .keepAlways 30 | add(attachment) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

FileChat

2 | 3 | Chat with LLMs on your Mac without installing any other software. Every conversation is saved locally, all conversations happen offline. 4 | 5 | - Customize persona and expertise by changing the system prompt 6 | - Try any llama.cpp compatible GGUF model 7 | - Run a Shortcut just by chatting to automate your workflow 8 | - No internet connection required, all local (with the option to connect to a remote model) 9 | 10 | **Note: FileChat is an extended version of FreeChat. You can visit the original [here](https://github.com/psugihara/FreeChat)** 11 | 12 | ## Installation 13 | 14 | **Requirements** 15 | - An Apple Silicon Mac 16 | - RAM ≥ 16 GB 17 | 18 | **Prebuilt Package** 19 | - Download the packages from [Releases](https://github.com/johnbean393/FileChat/releases), and open it. Note that since the package is not notarized, you will need to enable it in System Settings. 20 | 21 | **Build it yourself** 22 | - Download, open in Xcode, and build it. 23 | 24 | ## Goals 25 | 26 | The main goal of FileChat is to make open, local, private models accessible to more people, and allow a local model to gain context of files and folders. 27 | 28 | FileChat is a native LLM application for macOS that runs completely locally. Download it and ask your LLM a question without doing any configuration. Give the LLM access to your folders and files with just 1 click, allowing them to reply with context. 29 | 30 | - No config. Usable by people who haven't heard of models, prompts, or LLMs. 31 | - Performance and simplicity over dev experience or features. Notes not Word, Swift not Elektron. 32 | - Local first. Core functionality should not require an internet connection. 33 | - No conversation tracking. Talk about whatever you want with FileChat, just like Notes. 34 | - Open source. What's the point of running local AI if you can't audit that it's actually running locally? 35 | 36 | ### Contributing 37 | 38 | Contributions are very welcome. Let's make FileChat simple and powerful. 39 | 40 | ### Credits 41 | 42 | This project would not be possible without the hard work of: 43 | 44 | - psugihara and contributors who built [FreeChat](https://github.com/psugihara/FreeChat) 45 | - Georgi Gerganov for [llama.cpp](https://github.com/ggerganov/llama.cpp) 46 | - Meta for training Llama 3 47 | - TheBloke (Tom Jobbins) for model quantization 48 | -------------------------------------------------------------------------------- /server-watchdog.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.inherit 6 | 7 | 8 | 9 | --------------------------------------------------------------------------------