diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index 641e0aac..d3e8b979 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 9F7335EF29674F7100AFF0BA /* QuickLook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F7335EE29674F7100AFF0BA /* QuickLook.framework */; }; 9F7335F22967608F00AFF0BA /* AddRemoteTimelineVIew.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7335F12967608F00AFF0BA /* AddRemoteTimelineVIew.swift */; }; 9F7335F92968576500AFF0BA /* DisplaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7335F82968576500AFF0BA /* DisplaySettingsView.swift */; }; + 9FAD85832971BF7200496AB1 /* Secret.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9FAD85822971BF7200496AB1 /* Secret.plist */; }; 9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE4ACA293783B000772766 /* SettingsTab.swift */; }; 9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9FAE4ACD29379A5A00772766 /* KeychainSwift */; }; 9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBFE63C292A715500C250E9 /* IceCubesApp.swift */; }; @@ -112,6 +113,7 @@ 9F7335EE29674F7100AFF0BA /* QuickLook.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuickLook.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.2.sdk/System/Library/Frameworks/QuickLook.framework; sourceTree = DEVELOPER_DIR; }; 9F7335F12967608F00AFF0BA /* AddRemoteTimelineVIew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddRemoteTimelineVIew.swift; sourceTree = ""; }; 9F7335F82968576500AFF0BA /* DisplaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySettingsView.swift; sourceTree = ""; }; + 9FAD85822971BF7200496AB1 /* Secret.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Secret.plist; sourceTree = ""; }; 9FAE4AC8293774FF00772766 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 9FAE4ACA293783B000772766 /* SettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTab.swift; sourceTree = ""; }; 9FBFE639292A715500C250E9 /* IceCubesApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IceCubesApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -254,6 +256,7 @@ 9F398AB429360A5800A889F2 /* App */, 9FBFE642292A715600C250E9 /* IceCubesApp.entitlements */, 9F398AB529360A6100A889F2 /* Resources */, + 9FAD85822971BF7200496AB1 /* Secret.plist */, ); path = IceCubesApp; sourceTree = ""; @@ -399,6 +402,7 @@ 9F2A542C296B1177009B2D7C /* glass.caf in Resources */, 9FD34823293D06E800DB0EE9 /* Assets.xcassets in Resources */, 9F24EEB829360C330042359D /* Preview Assets.xcassets in Resources */, + 9FAD85832971BF7200496AB1 /* Secret.plist in Resources */, 9F2A542E296B1CC0009B2D7C /* glass.wav in Resources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/IceCubesApp/Secret.plist b/IceCubesApp/Secret.plist new file mode 100644 index 00000000..f39534a7 --- /dev/null +++ b/IceCubesApp/Secret.plist @@ -0,0 +1,8 @@ + + + + + OPENAI_SECRET + NICE_TRY + + diff --git a/Packages/Network/Sources/Network/OpenAIClient.swift b/Packages/Network/Sources/Network/OpenAIClient.swift new file mode 100644 index 00000000..468be874 --- /dev/null +++ b/Packages/Network/Sources/Network/OpenAIClient.swift @@ -0,0 +1,97 @@ +import Foundation + +public struct OpenAIClient { + private let endpoint: URL = URL(string: "https://api.openai.com/v1/completions")! + + private var APIKey: String { + if let path = Bundle.main.path(forResource: "Secret", ofType: "plist") { + let secret = NSDictionary(contentsOfFile: path) + return secret?["OPENAI_SECRET"] as? String ?? "" + } + return "" + } + + private var authorizationHeaderValue: String { + "Bearer \(APIKey)" + } + + private var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + return encoder + } + + private var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + } + + public struct Request: Encodable { + let model = "text-davinci-003" + let topP: Int = 1 + let frequencyPenalty: Int = 0 + let presencePenalty: Int = 0 + let prompt: String + let temperature: Double + let maxTokens: Int + + public init(prompt: String, temperature: Double, maxTokens: Int) { + self.prompt = prompt + self.temperature = temperature + self.maxTokens = maxTokens + } + } + + public enum Prompts { + case correct(input: String) + case shorten(input: String) + case emphasize(input: String) + + var request: Request { + switch self { + case let .correct(input): + return Request(prompt: "Correct this to standard English:\(input)", + temperature: 0, + maxTokens: 500) + case let .shorten(input): + return Request(prompt: "Make a summary of this paragraph:\(input)", + temperature: 0.7, + maxTokens: 100) + case let .emphasize(input): + return Request(prompt: "Make this paragraph catchy, more fun:\(input)", + temperature: 0.8, + maxTokens: 500) + } + } + } + + public struct Response: Decodable { + public struct Choice: Decodable { + public let text: String + } + + public let id: String + public let object: String + public let model: String + public let choices: [Choice] + } + + public init() { } + + public func request(_ prompt: Prompts) async throws -> Response { + do { + let jsonData = try encoder.encode(prompt.request) + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue(authorizationHeaderValue, forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = jsonData + let (result, _) = try await URLSession.shared.data(for: request) + let response = try decoder.decode(Response.self, from: result) + return response + } catch let error { + throw error + } + } +} diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorAIPrompts.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorAIPrompts.swift new file mode 100644 index 00000000..6af189eb --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorAIPrompts.swift @@ -0,0 +1,30 @@ +import Foundation +import SwiftUI +import Network + +enum StatusEditorAIPrompts: CaseIterable { + case correct, fit, emphasize + + @ViewBuilder + var label: some View { + switch self { + case .correct: + Label("Correct text", systemImage: "text.badge.checkmark") + case .fit: + Label("Shorten text", systemImage: "text.badge.minus") + case .emphasize: + Label("Emphasize text", systemImage: "text.badge.star") + } + } + + func toRequestPrompt(text: String) -> OpenAIClient.Prompts { + switch self { + case .correct: + return .correct(input: text) + case .fit: + return .shorten(input: text) + case .emphasize: + return .emphasize(input: text) + } + } +} diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift index 71dbe166..7229689d 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift @@ -20,6 +20,7 @@ public struct StatusEditorView: View { @FocusState private var isSpoilerTextFocused: Bool @State private var isDismissAlertPresented: Bool = false + @State private var isLoadingAIRequest: Bool = false public init(mode: StatusEditorViewModel.Mode) { _viewModel = StateObject(wrappedValue: .init(mode: mode)) @@ -77,6 +78,10 @@ public struct StatusEditorView: View { .navigationTitle(viewModel.mode.title) .navigationBarTitleDisplayMode(.inline) .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + AIMenu + .disabled(!viewModel.canPost) + } ToolbarItem(placement: .navigationBarTrailing) { Button { Task { @@ -176,4 +181,35 @@ public struct StatusEditorView: View { ) } } + + private var AIMenu: some View { + Menu { + ForEach(StatusEditorAIPrompts.allCases, id: \.self) { prompt in + Button { + Task { + isLoadingAIRequest = true + await viewModel.runOpenAI(prompt: prompt.toRequestPrompt(text: viewModel.statusText.string)) + isLoadingAIRequest = false + } + } label: { + prompt.label + } + } + if let backup = viewModel.backupStatustext { + Button { + viewModel.replaceTextWith(text: backup.string) + viewModel.backupStatustext = nil + } label: { + Label("Restore previous text", systemImage: "arrow.uturn.right") + } + } + } label: { + if isLoadingAIRequest { + ProgressView() + } else { + Image(systemName: "faxmachine") + } + } + + } } diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift index 0b4010e3..d90d24ba 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift @@ -27,6 +27,7 @@ public class StatusEditorViewModel: ObservableObject { checkEmbed() } } + @Published var backupStatustext: NSAttributedString? @Published var showPoll: Bool = false @Published var pollVotingFrequency = PollVotingFrequency.oneVote @@ -88,6 +89,11 @@ public class StatusEditorViewModel: ObservableObject { statusText = string selectedRange = NSRange(location: inRange.location + text.utf16.count, length: 0) } + + func replaceTextWith(text: String) { + statusText = .init(string: text) + selectedRange = .init(location: text.utf16.count, length: 0) + } private func getPollOptionsForAPI() -> [String] { pollOptions.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } @@ -298,6 +304,20 @@ public class StatusEditorViewModel: ObservableObject { } } + // MARK: - OpenAI Prompt + func runOpenAI(prompt: OpenAIClient.Prompts) async { + do { + let client = OpenAIClient() + let response = try await client.request(prompt) + if var text = response.choices.first?.text { + text.removeFirst() + text.removeFirst() + backupStatustext = statusText + replaceTextWith(text: text) + } + } catch { } + } + // MARK: - Media related function private func indexOf(container: ImageContainer) -> Int? { diff --git a/ci_scripts/ci_pre_xcodebuild.sh b/ci_scripts/ci_pre_xcodebuild.sh new file mode 100755 index 00000000..1432f6fb --- /dev/null +++ b/ci_scripts/ci_pre_xcodebuild.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +cd ../IceCubesApp/ +plutil -replace OPENAI_SECRET -string $OPENAI_SECRET Secret.plist +plutil -p Secret.plist +exit 0