用 SwiftUI 實現 AI 聊天對話 app - iChatGPT
一、前言
關於 ChatGPT 的話題,大家都不陌生,我們直入話題,因為 ChatGPT 目前限制中國訪問服務,所以如果直接使用 ChatGPT 網頁進行對話,還是不太方便。通過 ChatGPT SessionToken 就可以不限制網路訪問,所以大家發揮想象力實現各種的聊天機器人、小程式,而原生 app 可能體驗更好!所以就有了 iChatGPT!一款用 SwiftUI 實現的開源 ChatGPT app,歡迎大家關注和提 PR。
二、iChatGPT
GitHub 開源地址:https://github.com/37iOS/iChatGPT
目前 v1.1.0,實現 ChatGPT 基本聊天功能: - 可以直接與 ChatGPT 對話,並且保留上下文; - 可以複製問題和回答內容; - 可以快捷重複提問等
支援系統: - iOS 14.0+ - iPadOS 14.0+ - macOS 11.0+
三、App 使用介紹
首先,需要點選 app 右上角圖示,新增 ChatGPT SessionToken
金鑰才能使用,否則無法請求。
獲取 SessionToken
的方法很多,比如抓網路請求,其中瀏覽器方法最簡單:
- 登入 https://chat.openai.com/chat
- 按 F12 開啟控制檯(macOS 可以用快捷鍵
command + option + I
) - 切換到 Application(應用) 選項卡,找到 Cookies (Safari 瀏覽器是
儲存空間
選項卡) - 複製
__Secure-next-auth.session-token
的值,新增到 app 後確認。
iOS 操作的介面:
macOS 操作介面:
四、App 實現介紹
使用 SwiftUI 大概幾個小時就完成所有的工作,方便跟蘋果生態實現。實現的難點就可能就是模擬 ChatGPT 請求過程。目前是根據 A-kirami/nonebot-plugin-chatgpt 專案中的 python 實現,用 Swift 重寫了一次,而 ChatGPT 登陸暫時沒有實現,大家可以提 pr。
最後封裝的網路請求類 ChatGPT.swift
```swift
class Chatbot {
let apUrl = "https://chat.openai.com/"
let sessionTokenKey = "__Secure-next-auth.session-token"
let timeout = 30
var sessionToken: String
var authorization = ""
var conversationId = ""
var parentId = ""
let id = ""
init(sessionToken: String) {
self.sessionToken = sessionToken
}
func headers() -> [String: String] {
return [
"Host": "chat.openai.com",
"Accept": "text/event-stream",
"Authorization": "Bearer \(self.authorization)",
"Content-Type": "application/json",
"User-Agent": "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",
"X-Openai-Assistant-App-Id": "",
"Connection": "close",
"Accept-Language": "en-US,en;q=0.9",
"Referer": "https://chat.openai.com/chat",
]
}
func getPayload(prompt: String) -> [String: Any] {
var body = [
"action": "next",
"messages": [
[
"id": "\(UUID().uuidString)",
"role": "user",
"content": ["content_type": "text", "parts": [prompt]],
]
],
"parent_message_id": "\(self.parentId)",
"model": "text-davinci-002-render",
] as [String: Any]
if !self.conversationId.isEmpty {
body["conversation_id"] = self.conversationId
}
return body
}
func refreshSession() async {
let cookies = "\(sessionTokenKey)=\(self.sessionToken)"
let url = self.apUrl + "api/auth/session"
let userAgent = "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"
var request = URLRequest(url: URL(string: url)!)
request.httpMethod = "GET"
request.addValue(userAgent, forHTTPHeaderField: "User-Agent")
request.addValue(cookies, forHTTPHeaderField: "Cookie")
do {
let (data, response) = try await URLSession.shared.data(for: request)
let json = try JSONSerialization.jsonObject(with: data, options: [])
if let dictionary = json as? [String: Any] {
// Use the dictionary here
if let accessToken = dictionary["accessToken"] as? String {
authorization = accessToken
}
}
guard let response = response as? HTTPURLResponse,
let cookies = HTTPCookieStorage.shared.cookies(for: response.url!) else {
// handle error
print("重新整理會話失敗: <r>HTTP:\(response)")
return
}
for cookie in cookies {
if cookie.name == sessionTokenKey {
self.sessionToken = cookie.value
UserDefaults.standard.set(cookie.value, forKey: ChatGPTSessionTokenKey)
}
}
}
catch {
print("重新整理會話失敗: <r>HTTP:\(error)")
}
}
func getChatResponse(prompt: String) async -> String {
if self.authorization.isEmpty {
await refreshSession()
}
let url = self.apUrl + "backend-api/conversation"
var request = URLRequest(url: URL(string: url)!)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers()
let dict = getPayload(prompt: prompt)
do {
let jsonData = try JSONSerialization.data(withJSONObject: dict, options: [])
request.httpBody = jsonData
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
let err = "非預期的響應內容: <r>HTTP:\(response)"
print(err)
return err
}
if response.statusCode == 429 {
return "請求過多,請放慢速度"
}
guard let text = String(data: data, encoding: .utf8) else {
return "非預期的響應內容: 內容讀取失敗~"
}
if response.statusCode != 200 {
let err = "非預期的響應內容: <r>HTTP:\(response.statusCode)</r> \(text)"
print(err)
return err
}
let lines = text.components(separatedBy: "\n")
// 倒數第四行,第6個字元後開始
let str = lines[lines.count - 5]
#if DEBUG
print(str)
#endif
let jsonString = str.suffix(from: str.index(str.startIndex, offsetBy: 6))
guard let jsondata = jsonString.data(using: .utf8) else {
return ""
}
let json = try JSONSerialization.jsonObject(with: jsondata, options: [])
guard let dictionary = json as? [String: Any],
let conversation_id = dictionary["conversation_id"] as? String,
let message = dictionary["message"] as? [String: Any],
let parent_id = message["id"] as? String,
let content = message["content"] as? [String: Any],
let texts = content["parts"] as? [String],
let parts = texts.last
else {
return "解析錯誤~"
}
self.parentId = parent_id
self.conversationId = conversation_id
return parts
}
catch {
return "異常:\(error)"
}
}
} ```
唯一可以說說的就是,ChatGPT 的 backend-api/conversation
介面返回的內容,為了實現一個連線開啟的效果,返回了一堆的資料。例如一個回答是 "我無法確定全球當前的人口數量,因為我沒有瀏覽網頁的能力。"
,返回的內容是這樣:
```js data: {"message": {"id": "xxxx", "role": "assistant", "user": null, "create_time": null, "update_time": null, "content": {"content_type": "text", "parts": ["我"]}, "end_turn": null, "weight": 1.0, "metadata": {}, "recipient": "all"}, "conversation_id": "xxxx", "error": null}
data: {"message": {"id": "xxxx", "role": "assistant", "user": null, "create_time": null, "update_time": null, "content": {"content_type": "text", "parts": ["我無"]}, "end_turn": null, "weight": 1.0, "metadata": {}, "recipient": "all"}, "conversation_id": "xxxx", "error": null}
data: {"message": {"id": "xxxx", "role": "assistant", "user": null, "create_time": null, "update_time": null, "content": {"content_type": "text", "parts": ["我無法"]}, "end_turn": null, "weight": 1.0, "metadata": {}, "recipient": "all"}, "conversation_id": "xxxx", "error": null}
中間省略xxxx行 中間省略xxxx行 中間省略xxxx行
data: {"message": {"id": "xxxx", "role": "assistant", "user": null, "create_time": null, "update_time": null, "content": {"content_type": "text", "parts": ["我無法確定全球當前的人口數量,因為我沒有瀏覽網頁的能力"]}, "end_turn": null, "weight": 1.0, "metadata": {}, "recipient": "all"}, "conversation_id": "xxxx", "error": null}
data: {"message": {"id": "xxxx", "role": "assistant", "user": null, "create_time": null, "update_time": null, "content": {"content_type": "text", "parts": ["我無法確定全球當前的人口數量,因為我沒有瀏覽網頁的能力。"]}, "end_turn": null, "weight": 1.0, "metadata": {}, "recipient": "all"}, "conversation_id": "xxxx", "error": null} ```
所以,需要按行分割,然後取倒數第四行的內容,再去掉 data:
字元才是我們想要的 json 內容。
let lines = text.components(separatedBy: "\n")
// 倒數第四行,第6個字元後開始
let str = lines[lines.count - 5]
當然,目前 ChatGPT 還是 beta 階段,所以暫時沒有開放 API,後續如果提供 API,就會更加方便!
五、ChatGPT 的一些問題
是否收費
目前 ChatGPT 是 beta 免費使用階段,未來 API 請求會收費,具體可參考 https://openai.com/blog 。
修改頭像
ChatGPT 對話的個人頭像,大家發現無法有 https://openai.com 上進行修改。因為目前使用的是 Gravatar 服務。
Gravatar,全稱 Globally Recognized Avatar
。翻譯成中文叫:全球通用頭像。
Gravatar 的概念首先是在國外的獨立 WordPress 部落格中興起的,當你到任何一個支援Gravatar的網站留言時,這個網站都會根據你所提供的Email地址為你顯示出匹配的頭像。當然,這個頭像,是需要你事先到 Gravatar 的網站註冊並上傳的,否則,在這個網站上,就只會顯示成一個預設的頭像。
簡單來說,就是頭像連結為 https://s.gravatar.com/avatar/xxx
,其中 xxx
就是你登陸郵箱的 MD5 值,只要在 Gravatar 註冊驗證了這個郵箱,你就可以更新頭像,或者任何人都可以獲取你的頭像,只要知道你的郵箱。詳細可以參考:Image Requests - Globally
有趣的對話
寫一首詩,慶祝 iChatGPT app 開源:
``` 咦,知道 iChatGPT
它酷炫極了,支援語言模型互動
它開源了,人人可用
快來下載,體驗它的強大
它可以幫助你,完成複雜任務
不論是寫文章,還是做研究
它是程式設計師的好幫手
讓工作更高效,更愉快
啦啦啦,iChatGPT
開源了,萬歲! ```
直呼牛~
六、總結
目前 iChatGPT 開源地址:https://github.com/37iOS/iChatGPT 。還有很多功能沒有實現,比如:
- 儲存對話
- 程式碼沒有高亮
- ~~顯示個人頭像~~(v1.1.0 已實現)
- ~~請求失敗重試等等~~(v1.1.0 已實現)
歡迎大家提 PR !
另外,我們近期也會更新 AppleParty,更新蘋果批量上傳內購商品功能,敬請期待~
最後,大家覺得 ChatGPT
解決了什麼痛點?有什麼期待嗎
歡迎大家評論區一起討論交流~
歡迎關注我們,瞭解更多 iOS 和 Apple 的動態~
參考引用
- App Store 新定價機制 - 2023年最全版
- 關於 App Store 蘋果商店價格的那些事(歷上最全版)
- 使用 App Store Connect API 批量建立內購商品
- 用 SwiftUI 實現 AI 聊天對話 app - iChatGPT
- WWDC22 - In App Purchase 更新總結
- WWDC22 - Apple 隱私技術探索
- WWDC22 開發者需要關注的重點內容
- 蘋果 AppStore 財年和賬單那些趣事
- 開源一款蘋果 macOS 工具 - AppleParty(蘋果派)
- 你一定不知道的 AppStore 祕密
- 揭祕蘋果應用稽核團隊(史上最全版)
- WWDC21 - App Store Server API 實踐總結
- 用 SwiftUI 實現一個開源的 App Store
- Xcode 配置多套 App 圖示的方法 --- AppStore 圖示 A/B Test 實踐
- 教你實現一個 iOS 重簽名工具
- 趣談 iOS Universal Link
- iOS15 安全漏洞分析:價值10萬美元的漏洞曝光