├── .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 | 
18 | /// Headings | 
19 | /// Blockquote | 
20 | /// Code block | 
21 | /// Image | 
22 | /// Task list | 
23 | /// Bulleted list | 
24 | /// Numbered list | 
25 | /// Table | 
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 |
--------------------------------------------------------------------------------