Skip to content

Instantly share code, notes, and snippets.

@ericlewis
Created December 22, 2022 18:58
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ericlewis/05fb9fee1ac51fd911af505d73c9b714 to your computer and use it in GitHub Desktop.
Save ericlewis/05fb9fee1ac51fd911af505d73c9b714 to your computer and use it in GitHub Desktop.
A way of using ChatGPT with SwiftUI
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