mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 09:21:24 +08:00
feat(ios): implement chat function (#9482)
Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
@@ -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"),
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
||||
+2
@@ -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/")!
|
||||
}
|
||||
+54
@@ -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"
|
||||
}
|
||||
+37
@@ -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)
|
||||
}
|
||||
}
|
||||
+14
-5
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+43
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+9
-1
@@ -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,
|
||||
+2
-33
@@ -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()
|
||||
}
|
||||
}
|
||||
+1
@@ -127,6 +127,7 @@ class InputEditView: UIView, UITextViewDelegate {
|
||||
}
|
||||
attachmentsEditor.rebuildViews()
|
||||
parentViewController?.view.layoutIfNeeded()
|
||||
updatePlaceholderVisibility()
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@ class PlainTextEditView: UITextView, UITextViewDelegate {
|
||||
super.init(frame: .zero, textContainer: nil)
|
||||
|
||||
delegate = self
|
||||
tintColor = Constant.affineTintColor
|
||||
tintColor = .accent
|
||||
|
||||
linkTextAttributes = [:]
|
||||
showsVerticalScrollIndicator = false
|
||||
+30
@@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
+212
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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)
|
||||
+33
-42
@@ -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
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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)
|
||||
[
|
||||
+7
@@ -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)
|
||||
-20
@@ -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
|
||||
}
|
||||
}
|
||||
+1
-15
@@ -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";
|
||||
|
||||
+2
-15
@@ -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" = "确定";
|
||||
|
||||
Reference in New Issue
Block a user