feat(ios): implement chat function (#9482)

Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
Lakr
2025-01-02 17:35:07 +08:00
committed by GitHub
parent 72e343c379
commit 094cb1d71c
220 changed files with 7695 additions and 164 deletions
@@ -17,12 +17,18 @@ let package = Package(
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"),
.package(url: "https://github.com/Lakr233/SpringInterpolation", from: "1.3.0"),
.package(url: "https://github.com/Lakr233/MSDisplayLink", from: "1.1.1"),
.package(path: "../AffineGraphQL"),
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.0.0"),
.package(url: "https://github.com/LaunchDarkly/swift-eventsource.git", from: "3.3.0"),
],
targets: [
.target(name: "Intelligents", dependencies: [
"AffineGraphQL",
"SpringInterpolation",
"MSDisplayLink",
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
.product(name: "Apollo", package: "apollo-ios"),
.product(name: "LDSwiftEventSource", package: "swift-eventsource"),
]),
]
)
@@ -10,4 +10,6 @@ import UIKit
enum Constant {
static let affineTabbarHeight: CGFloat = 44
static let affineTintColor: UIColor = .init(red: 30 / 255, green: 150 / 255, blue: 235 / 255, alpha: 1.0)
static var affineUpstreamURL = URL(string: "https://app.affine.pro/")!
}
@@ -0,0 +1,54 @@
//
// Prompt.swift
// Intelligents
//
// Created by on 2024/12/26.
//
import Foundation
enum Prompt: String {
#if DEBUG
case debug_chat_gpt4 = "debug:chat:gpt4"
case debug_action_dalle3 = "debug:action:dalle3"
case debug_action_fal_sd15 = "debug:action:fal-sd15"
case debug_action_fal_upscaler = "debug:action:fal-upscaler"
case debug_action_fal_remove_bg = "debug:action:fal-remove-bg"
case debug_action_fal_face_to_sticker = "debug:action:fal-face-to-sticker"
#endif
case general_Chat_With_AFFiNE_AI = "Chat With AFFiNE AI"
case general_Summary = "Summary"
case general_Generate_a_caption = "Generate a caption"
case general_Summary_the_webpage = "Summary the webpage"
case general_Explain_this = "Explain this"
case general_Explain_this_image = "Explain this image"
case general_Explain_this_code = "Explain this code"
case general_Translate_to = "Translate to"
case general_Write_an_article_about_this = "Write an article about this"
case general_Write_a_twitter_about_this = "Write a twitter about this"
case general_Write_a_poem_about_this = "Write a poem about this"
case general_Write_a_blog_post_about_this = "Write a blog post about this"
case general_Write_outline = "Write outline"
case general_Change_tone_to = "Change tone to"
case general_Brainstorm_ideas_about_this = "Brainstorm ideas about this"
case general_Expand_mind_map = "Expand mind map"
case general_Improve_writing_for_it = "Improve writing for it"
case general_Improve_grammar_for_it = "Improve grammar for it"
case general_Fix_spelling_for_it = "Fix spelling for it"
case general_Find_action_items_from_it = "Find action items from it"
case general_Check_code_error = "Check code error"
case general_Create_headings = "Create headings"
case general_Make_it_real = "Make it real"
case general_Make_it_real_with_text = "Make it real with text"
case general_Make_it_longer = "Make it longer"
case general_Make_it_shorter = "Make it shorter"
case general_Continue_writing = "Continue writing"
case workflow_presentation = "workflow:presentation"
case workflow_brainstorm = "workflow:brainstorm"
case workflow_image_sketch = "workflow:image-sketch"
case workflow_image_clay = "workflow:image-clay"
case workflow_image_anime = "workflow:image-anime"
case workflow_image_pixel = "workflow:image-pixel"
}
@@ -0,0 +1,37 @@
//
// Ext+EventHandler.swift
// Intelligents
//
// Created by on 2024/12/26.
//
import Foundation
import LDSwiftEventSource
class BlockEventHandler: EventHandler {
var onOpenedBlock: (() -> Void)?
var onClosedBlock: (() -> Void)?
var onMessageBlock: ((String, LDSwiftEventSource.MessageEvent) -> Void)?
var onCommentBlock: ((String) -> Void)?
var onErrorBlock: ((Error) -> Void)?
public func onOpened() {
onOpenedBlock?()
}
public func onClosed() {
onClosedBlock?()
}
public func onMessage(eventType: String, messageEvent: LDSwiftEventSource.MessageEvent) {
onMessageBlock?(eventType, messageEvent)
}
public func onComment(comment: String) {
onCommentBlock?(comment)
}
public func onError(error: any Error) {
onErrorBlock?(error)
}
}
@@ -9,10 +9,19 @@ import UIKit
extension UIColor {
static var accent: UIColor {
guard let color = UIColor(named: "accent", in: .module, compatibleWith: nil) else {
assertionFailure()
return .systemBlue
}
return color
Constant.affineTintColor
}
convenience init(light: UIColor, dark: UIColor) {
self.init(dynamicProvider: { traitCollection in
switch traitCollection.userInterfaceStyle {
case .light:
light
case .dark:
dark
default:
light
}
})
}
}
@@ -35,4 +35,16 @@ public extension UIViewController {
@objc func dismissKeyboard() {
view.endEditing(true)
}
func presentError(_ error: Error, onDismiss: @escaping () -> Void = {}) {
let alert = UIAlertController(
title: "Error".localized(),
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK".localized(), style: .default) { _ in
onDismiss()
})
present(alert, animated: true)
}
}
@@ -1,4 +1,46 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book
enum Intelligents {}
import AffineGraphQL
import Apollo
import Foundation
public enum Intelligents {
public private(set) static var qlClient: ApolloClient = createQlClient()
public static func setUpstreamEndpoint(_ upstream: String) {
guard let url = URL(string: upstream) else {
assertionFailure()
return
}
print("[*] setting up upstream endpoint to \(url.absoluteString)")
Constant.affineUpstreamURL = url
qlClient = createQlClient()
}
}
private extension Intelligents {
private final class URLSessionCookieClient: URLSessionClient {
init() {
super.init()
session.configuration.httpCookieStorage = .init()
HTTPCookieStorage.shared.cookies?.forEach { cookie in
self.session.configuration.httpCookieStorage?.setCookie(cookie)
}
}
}
static func createQlClient() -> ApolloClient {
let store = ApolloStore(cache: InMemoryNormalizedCache())
let provider = DefaultInterceptorProvider(
client: URLSessionCookieClient(),
shouldInvalidateClientOnDeinit: true,
store: store
)
let transport = RequestChainNetworkTransport(
interceptorProvider: provider,
endpointURL: Constant.affineUpstreamURL.appendingPathComponent("graphql")
)
return .init(networkTransport: transport, store: store)
}
}
@@ -32,7 +32,7 @@ public class IntelligentsButton: UIView {
image.image = .init(named: "spark", in: .module, with: .none)
image.contentMode = .scaleAspectFit
image.tintColor = Constant.affineTintColor
image.tintColor = .accent
addSubview(image)
let imageInsetValue: CGFloat = 12
image.translatesAutoresizingMaskIntoConstraints = false
@@ -106,7 +106,7 @@ extension ChatTableView {
extension ChatTableView.ChatCell {
class ViewModel {
let participant: Participant
let markdownDocument: String
var markdownDocument: String
init(participant: Participant, markdownDocument: String) {
self.participant = participant
@@ -26,13 +26,21 @@ extension ChatTableView: UIScrollViewDelegate, DisplayLinkDelegate {
private func tikVsync(deltaTime: TimeInterval) {
guard scrollToBottomEnabled else { return }
guard scrollToBottomAllowed else { return }
// read from contentSize if not needed to scroll
guard tableView.contentSize.height > tableView.bounds.height else {
resetAnimationContext(to: tableView.contentOffset.y)
return
}
guard abs(bottomLocationY - tableView.contentOffset.y) > 1 else {
return
}
scrollAnimationContext.setTarget(bottomLocationY)
scrollAnimationContext.update(withDeltaTime: deltaTime)
tableView.contentOffset.y = scrollAnimationContext.value
}
@inline(__always)
private func resetAnimationContext(to offset: CGFloat) {
func resetAnimationContext(to offset: CGFloat) {
scrollAnimationContext.context = .init(
currentPos: offset,
currentVel: 0,
@@ -73,45 +73,14 @@ class ChatTableView: UIView {
}
scrollAnimationController.delegatingObject(self)
putMockData()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
}
extension ChatTableView {
func putMockData() {
DispatchQueue.main.async {
let json: [String: Any] = ["query": """
{
currentUser {
email
name
}
}
""", "variables": [:]]
let jsonData = try? JSONSerialization.data(withJSONObject: json)
let url = URL(string: "https://affine.fail/graphql")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.allHTTPHeaderFields = [
"content-type": "application/json",
]
request.httpBody = jsonData
URLSession.shared.dataTask(with: request) { v1, _, _ in
guard let data = v1 else { return }
let json = try? JSONSerialization.jsonObject(with: data, options: .allowFragments)
print(json)
}.resume()
self.tableView.reloadData()
self.scrollToBottom()
}
func reloadData() {
tableView.reloadData()
}
}
@@ -127,6 +127,7 @@ class InputEditView: UIView, UITextViewDelegate {
}
attachmentsEditor.rebuildViews()
parentViewController?.view.layoutIfNeeded()
updatePlaceholderVisibility()
}
}
}
@@ -12,7 +12,7 @@ class PlainTextEditView: UITextView, UITextViewDelegate {
super.init(frame: .zero, textContainer: nil)
delegate = self
tintColor = Constant.affineTintColor
tintColor = .accent
linkTextAttributes = [:]
showsVerticalScrollIndicator = false
@@ -0,0 +1,30 @@
//
// IntelligentsChatController+Cell.swift
// Intelligents
//
// Created by on 2024/12/26.
//
import Foundation
extension IntelligentsChatController {
func insertIntoTableView(viewModel: ChatTableView.DataElement) {
assert(Thread.isMainThread)
tableView.dataSource.append(viewModel)
tableView.reloadData()
}
func insertIntoTableView(withChatModel chatModel: ChatTableView.ChatCell.ViewModel) {
insertIntoTableView(viewModel: .init(
type: .chat,
object: chatModel
))
}
func insertIntoTableView(withError error: Error) {
insertIntoTableView(withChatModel: .init(
participant: .system,
markdownDocument: error.localizedDescription
))
}
}
@@ -0,0 +1,212 @@
//
// IntelligentsChatController+Chat.swift
// Intelligents
//
// Created by on 2024/12/26.
//
import AffineGraphQL
import LDSwiftEventSource
import UIKit
extension IntelligentsChatController {
@objc func chat_onLoad() {
beginProgress()
chat_createSession { session in
self.sessionID = session ?? ""
self.endProgress()
} onFailure: { error in
self.presentError(error) {
if let nav = self.navigationController {
nav.popViewController(animated: true)
} else {
self.dismiss(animated: true)
}
}
}
}
@objc func chat_onSend() {
beginProgress()
let viewModel = inputBox.editor.viewModel.duplicate()
inputBox.editor.viewModel.reset()
inputBox.editor.updateValues()
DispatchQueue.global().async {
self.chat_onSendExecute(viewModel: viewModel)
self.endProgress()
}
}
}
private extension IntelligentsChatController {
func dispatchToMain(_ block: @escaping () -> Void) {
if Thread.isMainThread {
block()
} else {
DispatchQueue.main.async(execute: block)
}
}
func beginProgress() {
dispatchToMain { [self] in
inputBox.isUserInteractionEnabled = false
progressView.isHidden = false
progressView.alpha = 0
progressView.startAnimating()
UIView.animate(withDuration: 0.25) {
self.inputBox.editor.alpha = 0
self.progressView.alpha = 1
}
}
}
func endProgress() {
dispatchToMain { [self] in
UIView.animate(withDuration: 0.3) {
self.inputBox.editor.alpha = 1
self.progressView.alpha = 0
} completion: { _ in
self.inputBox.isUserInteractionEnabled = true
self.progressView.stopAnimating()
}
}
}
}
private extension IntelligentsChatController {
func chat_onError(_ error: Error) {
// TODO: IMPL add error cell
print("[*] chat error", error)
}
func chat_createSession(
onSuccess: @escaping (String?) -> Void,
onFailure: @escaping (Error) -> Void
) {
Intelligents.qlClient.perform(
mutation: CreateCopilotSessionMutation(options: .init(
docId: "", // TODO: put the real data
promptName: Prompt.general_Chat_With_AFFiNE_AI.rawValue,
workspaceId: "" // TODO: put the real data
)),
queue: .global()
) { result in
switch result {
case let .success(value):
if let session = value.data?.createCopilotSession {
self.dispatchToMain { onSuccess(session) }
} else {
self.dispatchToMain {
onFailure(
NSError(
domain: "Intelligents",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "No session created"]
)
)
}
}
case let .failure(error):
self.dispatchToMain { onFailure(error) }
}
}
}
func chat_onSendExecute(viewModel: InputEditView.ViewModel) {
let text = viewModel.text
// let images = viewModel.attachments
dispatchToMain {
self.insertIntoTableView(withChatModel: .init(
participant: .user,
markdownDocument: text
))
}
let sem = DispatchSemaphore(value: 0)
let sessionID = sessionID
Intelligents.qlClient.perform(
mutation: CreateCopilotMessageMutation(options: .init(
content: .init(stringLiteral: text),
sessionId: sessionID
)),
queue: .global()
) { result in
defer { sem.signal() }
switch result {
case let .success(value):
if let messageID = value.data?.createCopilotMessage {
print("[*] messageID", messageID)
self.chat_processWithMessageID(sessionID: sessionID, messageID: messageID)
} else {
self.chat_onError(NSError(
domain: "Intelligents",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "No message created"]
))
}
case let .failure(error):
self.chat_onError(error)
}
}
sem.wait()
}
func chat_processWithMessageID(sessionID: String, messageID: String) {
let url = Constant.affineUpstreamURL
.appendingPathComponent("api")
.appendingPathComponent("copilot")
.appendingPathComponent("chat")
.appendingPathComponent(sessionID)
.appendingPathComponent("stream")
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
comps?.queryItems = [URLQueryItem(name: "messageId", value: messageID)]
guard let url = comps?.url else {
assertionFailure()
chat_onError(NSError(
domain: "Intelligents",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "No message created"]
))
return
}
let chatModel = ChatTableView.ChatCell.ViewModel(
participant: .assistant,
markdownDocument: ""
)
dispatchToMain { self.insertIntoTableView(withChatModel: chatModel) }
let sem = DispatchSemaphore(value: 0)
let eventHandler = BlockEventHandler()
eventHandler.onOpenedBlock = {
print("[*] chat opened")
}
eventHandler.onClosedBlock = {
sem.signal()
self.chatTask?.stop()
self.chatTask = nil
}
eventHandler.onErrorBlock = { error in
self.chat_onError(error)
}
eventHandler.onMessageBlock = { _, message in
self.chat_onEvent(message.data, chatModel: chatModel)
}
let eventSource = EventSource(config: .init(handler: eventHandler, url: url))
chatTask = eventSource
eventSource.start()
sem.wait()
}
func chat_onEvent(_ data: String, chatModel: ChatTableView.ChatCell.ViewModel) {
dispatchToMain { [self] in
chatModel.markdownDocument += data
tableView.reloadData()
}
}
}
@@ -55,7 +55,7 @@ private extension IntelligentsChatController.Header {
UIImage(systemName: "chevron.left"),
for: .normal
)
backButton.tintColor = Constant.affineTintColor
backButton.tintColor = .accent
backButton.addTarget(self, action: #selector(navigateActionBack), for: .touchUpInside)
dropMenu.setImage(
@@ -103,6 +103,6 @@ private extension IntelligentsChatController.Header {
.init(systemName: "ellipsis.circle"),
for: .normal
)
moreMenu.tintColor = Constant.affineTintColor
moreMenu.tintColor = .accent
}
}
@@ -20,7 +20,10 @@ extension IntelligentsChatController {
editor.textEditor.font = UIFont.systemFont(ofSize: UIFont.labelFontSize)
editor.placeholderText = "Summarize this article for me...".localized()
backgroundView.backgroundColor = .systemBackground
backgroundView.backgroundColor = .init(
light: .init(white: 1, alpha: 1),
dark: .init(white: 0.15, alpha: 1)
)
backgroundView.layer.cornerRadius = 16
backgroundView.layer.shadowColor = UIColor.black.withAlphaComponent(0.25).cgColor
backgroundView.layer.shadowOffset = .init(width: 0, height: 0)
@@ -5,6 +5,7 @@
// Created by on 2024/11/18.
//
import LDSwiftEventSource
import UIKit
public class IntelligentsChatController: UIViewController {
@@ -16,6 +17,12 @@ public class IntelligentsChatController: UIViewController {
var inputBoxKeyboardAdapterHeightConstraint = NSLayoutConstraint()
var sessionID: String = "" {
didSet { print("[*] new sessionID: \(sessionID)") }
}
var chatTask: EventSource?
override public var title: String? {
set {
super.title = newValue
@@ -53,6 +60,8 @@ public class IntelligentsChatController: UIViewController {
deinit {
NotificationCenter.default.removeObserver(self)
chatTask?.stop()
chatTask = nil
}
override public func viewDidLoad() {
@@ -61,11 +70,24 @@ public class IntelligentsChatController: UIViewController {
view.backgroundColor = .secondarySystemBackground
hideKeyboardWhenTappedAround()
view.addSubview(header)
view.addSubview(tableView)
view.addSubview(inputBoxKeyboardAdapter)
view.addSubview(inputBox)
view.addSubview(progressView)
setupLayout()
chat_onLoad()
}
override public func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
chatTask?.stop()
chatTask = nil
}
func setupLayout() {
view.addSubview(header)
header.translatesAutoresizingMaskIntoConstraints = false
[
header.topAnchor.constraint(equalTo: view.topAnchor),
@@ -74,7 +96,6 @@ public class IntelligentsChatController: UIViewController {
header.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44),
].forEach { $0.isActive = true }
view.addSubview(inputBoxKeyboardAdapter)
inputBoxKeyboardAdapter.translatesAutoresizingMaskIntoConstraints = false
[
inputBoxKeyboardAdapter.leadingAnchor.constraint(equalTo: view.leadingAnchor),
@@ -85,7 +106,6 @@ public class IntelligentsChatController: UIViewController {
inputBoxKeyboardAdapterHeightConstraint.isActive = true
inputBoxKeyboardAdapter.backgroundColor = inputBox.backgroundView.backgroundColor
view.addSubview(inputBox)
inputBox.translatesAutoresizingMaskIntoConstraints = false
[
inputBox.leadingAnchor.constraint(equalTo: view.leadingAnchor),
@@ -93,7 +113,6 @@ public class IntelligentsChatController: UIViewController {
inputBox.bottomAnchor.constraint(equalTo: inputBoxKeyboardAdapter.topAnchor),
].forEach { $0.isActive = true }
view.addSubview(tableView)
tableView.translatesAutoresizingMaskIntoConstraints = false
[
tableView.topAnchor.constraint(equalTo: header.bottomAnchor),
@@ -102,7 +121,12 @@ public class IntelligentsChatController: UIViewController {
tableView.bottomAnchor.constraint(equalTo: inputBox.topAnchor, constant: 16),
].forEach { $0.isActive = true }
view.addSubview(progressView)
inputBox.editor.controlBanner.sendButton.addTarget(
self,
action: #selector(chat_onSend),
for: .touchUpInside
)
progressView.hidesWhenStopped = true
progressView.stopAnimating()
progressView.translatesAutoresizingMaskIntoConstraints = false
@@ -111,44 +135,11 @@ public class IntelligentsChatController: UIViewController {
progressView.centerYAnchor.constraint(equalTo: inputBox.centerYAnchor),
].forEach { $0.isActive = true }
progressView.style = .large
view.bringSubviewToFront(inputBox)
inputBox.editor.controlBanner.sendButton.addTarget(
self,
action: #selector(send),
for: .touchUpInside
)
}
@objc func send() {
assert(Thread.isMainThread)
inputBox.isUserInteractionEnabled = false
progressView.startAnimating()
progressView.isHidden = false
progressView.alpha = 0
UIView.animate(withDuration: 0.3) {
self.inputBox.editor.alpha = 0
self.progressView.alpha = 1
} completion: { _ in
let viewModel = self.inputBox.editor.viewModel.duplicate()
self.inputBox.editor.viewModel.reset()
DispatchQueue.global().async {
self.sendSyncEx(viewModel: viewModel)
DispatchQueue.main.async {
UIView.animate(withDuration: 0.3) {
self.inputBox.editor.alpha = 1
self.progressView.alpha = 0
} completion: { _ in
self.inputBox.isUserInteractionEnabled = true
self.progressView.stopAnimating()
}
}
}
}
}
private func sendSyncEx(viewModel: InputEditView.ViewModel) {
let text = viewModel.text
let images = viewModel.attachments
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
tableView.scrollToBottomEnabled = true
tableView.scrollToBottomAllowed = true
}
}
@@ -38,7 +38,7 @@ extension IntelligentsFocusApertureView {
headerLabel.textAlignment = .left
headerIcon.image = .init(named: "spark", in: .module, with: nil)
headerIcon.contentMode = .scaleAspectFit
headerIcon.tintColor = Constant.affineTintColor
headerIcon.tintColor = .accent
headerGroup.addSubview(headerLabel)
headerGroup.addSubview(headerIcon)
[
@@ -16,13 +16,20 @@ class UIHostingView<Content: View>: UIView {
set { hostingViewController.rootView = newValue }
}
override var intrinsicContentSize: CGSize {
hostingViewController.view.intrinsicContentSize
}
init(rootView: Content) {
hostingViewController = UIHostingController(rootView: rootView)
hostingViewController.edgesForExtendedLayout = []
hostingViewController.extendedLayoutIncludesOpaqueBars = false
super.init(frame: .zero)
hostingViewController.view?.translatesAutoresizingMaskIntoConstraints = false
addSubview(hostingViewController.view)
if let view = hostingViewController.view {
view.removeFromSuperview()
view.backgroundColor = .clear
view.isOpaque = false
addSubview(view)
@@ -1,20 +0,0 @@
{
"colors": [
{
"color": {
"color-space": "srgb",
"components": {
"alpha": "1.000",
"blue": "228",
"green": "148",
"red": "72"
}
},
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -6,26 +6,12 @@
*/
/* No comment provided by engineer. */
"Chat with AI" = "Chat with AI";
/* No comment provided by engineer. */
"AFFiNE AI" = "AFFiNE AI";
/* No comment provided by engineer. */
"Translate" = "Translate";
/* No comment provided by engineer. */
"Summary" = "Summary";
/* No comment provided by engineer. */
"Summarize this article for me..." = "Summarize this article for me...";
/* No comment provided by engineer. */
"System" = "System";
/* No comment provided by engineer. */
"AFFiNE AI" = "AFFiNE AI";
/* No comment provided by engineer. */
"You" = "You";
"Error" = "Error";
@@ -6,26 +6,13 @@
*/
/* No comment provided by engineer. */
"Chat with AI" = "与 AI 聊天";
/* No comment provided by engineer. */
"AFFiNE AI" = "AFFiNE 人工智能";
/* No comment provided by engineer. */
"Translate" = "翻译";
/* No comment provided by engineer. */
"Summary" = "总结";
/* No comment provided by engineer. */
"Summarize this article for me..." = "请为我总结这份文档...";
/* No comment provided by engineer. */
"System" = "系统";
/* No comment provided by engineer. */
"AFFiNE AI" = "AFFiNE AI";
/* No comment provided by engineer. */
"You" = "你";
"Error" = "错误";
"OK" = "确定";