Created
December 22, 2022 18:58
-
-
Save ericlewis/05fb9fee1ac51fd911af505d73c9b714 to your computer and use it in GitHub Desktop.
A way of using ChatGPT with SwiftUI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
import WebKit | |
// MARK: Observable Object | |
public class Chat: NSObject, ObservableObject { | |
internal var webView: WKWebView | |
private var decoder: JSONDecoder = { | |
let decoder = JSONDecoder() | |
decoder.keyDecodingStrategy = .convertFromSnakeCase | |
return decoder | |
}() | |
@Published | |
public var isReady = false | |
@Published | |
public var isLoading = false | |
@Published | |
public var response: String? | |
@Published | |
internal var showAuthentication: Bool = false | |
static var defaultConfiguration: WKWebViewConfiguration = { | |
let webConfiguration = WKWebViewConfiguration() | |
webConfiguration.applicationNameForUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15" | |
webConfiguration.userContentController.addUserScript(WKUserScript( | |
source: js, | |
injectionTime: .atDocumentEnd, | |
forMainFrameOnly: false | |
)) | |
return webConfiguration | |
}() | |
override init() { | |
let config = Self.defaultConfiguration | |
self.webView = .init(frame: .zero, configuration: config) | |
super.init() | |
self.webView.navigationDelegate = self | |
config.userContentController.add(self, name: "handler") | |
} | |
@MainActor | |
public func ask(prompt: String) async throws { | |
isLoading = true | |
let message = prompt.data(using: .utf8)?.base64EncodedString() ?? "" | |
try await self.webView.evaluateJavaScript("sendMessage('\(message)');") | |
} | |
@MainActor | |
public func reset() async throws { | |
try await webView.evaluateJavaScript("reset()"); | |
} | |
internal func load() { | |
guard webView.url != URL(string: "https://chat.openai.com/chat") else { | |
return | |
} | |
webView.load( | |
URLRequest(url: URL(string: "https://chat.openai.com/chat")!) | |
) | |
} | |
} | |
// MARK: WKScriptMessageHandler | |
extension Chat: WKScriptMessageHandler { | |
@MainActor | |
public func userContentController( | |
_ userContentController: WKUserContentController, | |
didReceive message: WKScriptMessage | |
) { | |
guard let dict = message.body as? [String: String], | |
let resource = dict["resource"], | |
let json = dict["json"] | |
else { | |
return | |
} | |
if (resource.hasSuffix("/api/auth/session")) { | |
guard let jsonData = json.data(using: .utf8), | |
let _ = try? decoder.decode(AuthResponse.self, from: jsonData) | |
else { | |
return | |
} | |
isReady = true | |
} else if (resource.hasSuffix("api/conversation")) { | |
defer { isLoading = false } | |
guard let out = json | |
.components(separatedBy: "\n") | |
.filter({ !$0.isEmpty }) | |
.dropLast() | |
.last? | |
.replacingOccurrences(of: "data: ", with: ""), | |
let data = out.data(using: .utf8) | |
else { | |
return | |
} | |
let res = try? decoder.decode( | |
MessageResponse.self, | |
from: data | |
) | |
response = res?.message.content.parts.joined() | |
} else { | |
#if DEBUG | |
dump(dict) | |
#endif | |
} | |
} | |
} | |
// MARK: WKNavigationDelegate | |
extension Chat: WKNavigationDelegate { | |
@MainActor | |
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { | |
if (!showAuthentication && navigationAction.request.url?.path == "/auth/login") { | |
showAuthentication = true | |
} else if (showAuthentication && navigationAction.request.url?.path == "/api/auth/callback/auth0") { | |
showAuthentication = false | |
} | |
return .allow | |
} | |
} | |
// MARK: Models | |
struct AuthResponse: Decodable { | |
let accessToken: String | |
} | |
struct MessageResponse: Decodable { | |
let message: Message | |
let conversationId: UUID? | |
} | |
struct MessageContent: Decodable { | |
let parts: [String] | |
} | |
struct Message: Decodable { | |
let id: UUID? | |
let content: MessageContent | |
} | |
struct Response: Identifiable { | |
var id: String { | |
question + answer | |
} | |
let question: String | |
let answer: String | |
} | |
// MARK: JavaScript | |
let js = #""" | |
const { fetch: originalFetch } = window; | |
var authorization = null; | |
var parentId = crypto.randomUUID(); | |
var conversationId = null; | |
window.fetch = async (...args) => { | |
const [resource, config] = args; | |
if (config.headers && config.headers.Authorization) { | |
authorization = config.headers.Authorization; | |
} | |
const response = await originalFetch(resource, config); | |
if ( | |
window.webkit && | |
window.webkit.messageHandlers && | |
window.webkit.messageHandlers.handler | |
) { | |
const json = await response.clone().text(); | |
window.webkit.messageHandlers.handler.postMessage({ | |
resource, | |
json, | |
}); | |
} | |
return response; | |
}; | |
const sendMessage = async (message) => { | |
const decodedMessage = window.atob(message); | |
const payload = { | |
action: "next", | |
messages: [ | |
{ | |
id: crypto.randomUUID(), | |
role: "user", | |
content: { | |
content_type: "text", | |
parts: [decodedMessage], | |
}, | |
}, | |
], | |
parent_message_id: parentId, | |
model: "text-davinci-002-render", | |
}; | |
if (conversationId) { | |
payload.conversation_id = conversationId; | |
} | |
const response = await fetch( | |
"https://chat.openai.com/backend-api/conversation", | |
{ | |
method: "POST", | |
body: JSON.stringify(payload), | |
headers: { | |
authorization, | |
accept: "text/event-stream", | |
"X-OpenAI-Assistant-App-Id": "", | |
"Content-Type": "application/json", | |
}, | |
} | |
).then((response) => response.text()); | |
const messages = response.split("\n").filter((s) => s.length); | |
const result = JSON.parse( | |
messages[messages.length - 2].substring("data: ".length) | |
); | |
conversationId = result.conversation_id; | |
parentId = result.message.id; | |
}; | |
const reset = () => { | |
conversationId = null; | |
parentId = crypto.randomUUID(); | |
}; | |
"""# | |
// MARK: Environment | |
extension EnvironmentValues { | |
struct ChatKey: EnvironmentKey { | |
static let defaultValue = Chat() | |
} | |
var chatStore: Chat { | |
get { self[ChatKey.self] } | |
} | |
} | |
// MARK: Views | |
#if os(iOS) | |
public struct ChatWebView: View, UIViewRepresentable { | |
public let webView: WKWebView | |
public init(_ viewStore: Chat) { self.webView = viewStore.webView } | |
public func makeUIView(context: UIViewRepresentableContext<ChatWebView>) -> WKWebView { webView } | |
public func updateUIView(_ uiView: WKWebView, context: UIViewRepresentableContext<ChatWebView>) {} | |
} | |
#elseif os(macOS) | |
public struct ChatWebView: View, NSViewRepresentable { | |
public let webView: WKWebView | |
public init(_ viewStore: ViewStore) { self.webView = viewStore.webView } | |
public func makeNSView(context: NSViewRepresentableContext<ChatWebView>) -> WKWebView { webView } | |
public func updateNSView(_ uiView: WKWebView, context: NSViewRepresentableContext<ChatWebView>) {} | |
} | |
#endif | |
struct ChatViewWrapper<Content: View>: View { | |
@EnvironmentObject | |
private var chat: Chat | |
var content: () -> Content | |
var body: some View { | |
content() | |
.sheet(isPresented: $chat.showAuthentication) { | |
ChatWebView(chat) | |
.interactiveDismissDisabled() | |
.ignoresSafeArea(.all, edges: .bottom) | |
} | |
.background { | |
ChatWebView(chat) | |
.frame(width: .zero, height: .zero) | |
.onAppear(perform: chat.load) | |
} | |
} | |
} | |
// MARK: Modifiers | |
struct ChatModifier: ViewModifier { | |
@Environment(\.chatStore) | |
private var chat | |
func body(content: Content) -> some View { | |
ChatViewWrapper { | |
content | |
} | |
.environmentObject(chat) | |
} | |
} | |
extension View { | |
public func injectChat() -> some View { | |
self.modifier(ChatModifier()) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment