mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-23 04:51:49 +08:00
Clean Up
This commit is contained in:
@@ -7,26 +7,20 @@ let package = Package(
|
||||
name: "Intelligents",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v15),
|
||||
.macCatalyst(.v15),
|
||||
.iOS(.v17),
|
||||
],
|
||||
products: [
|
||||
.library(name: "Intelligents", targets: ["Intelligents"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../AffineGraphQL"),
|
||||
.package(path: "../MarkdownView"),
|
||||
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.22.0"),
|
||||
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.18.0"),
|
||||
.package(url: "https://github.com/LaunchDarkly/swift-eventsource.git", from: "3.3.0"),
|
||||
.package(url: "https://github.com/apple/swift-collections", from: "1.2.0"),
|
||||
.package(url: "https://github.com/Lakr233/ChidoriMenu", from: "3.0.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "Intelligents", dependencies: [
|
||||
"AffineGraphQL",
|
||||
"ChidoriMenu",
|
||||
"MarkdownView",
|
||||
"ChidoriMenu",
|
||||
.product(name: "Apollo", package: "apollo-ios"),
|
||||
.product(name: "LDSwiftEventSource", package: "swift-eventsource"),
|
||||
.product(name: "OrderedCollections", package: "swift-collections"),
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
//
|
||||
// Constant.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
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/")!
|
||||
}
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
//
|
||||
// UnableTo.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 4/1/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
private let domain = "Intelligents"
|
||||
|
||||
enum UnableTo {
|
||||
static let identifyDocumentOrWorkspace =
|
||||
NSError(
|
||||
domain: domain,
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Unable to identify the document or workspace"]
|
||||
)
|
||||
|
||||
static let createSession = NSError(
|
||||
domain: domain,
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Unable to create a session"]
|
||||
)
|
||||
|
||||
static let createMessage = NSError(
|
||||
domain: domain,
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Unable to create a message"]
|
||||
)
|
||||
|
||||
static let compressImage = NSError(
|
||||
domain: domain,
|
||||
code: -1,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to compress image data",
|
||||
]
|
||||
)
|
||||
|
||||
static let clearHistory = NSError(
|
||||
domain: domain,
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Unable to clear history"]
|
||||
)
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
//
|
||||
// Chat.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Chat: Codable {
|
||||
enum ParticipantType: String, Codable, Equatable {
|
||||
case user
|
||||
case bot
|
||||
}
|
||||
|
||||
var participant: ParticipantType
|
||||
|
||||
typealias MarkdownDocument = String
|
||||
var content: MarkdownDocument
|
||||
var date: Date
|
||||
|
||||
init(participant: ParticipantType, content: MarkdownDocument, date: Date = .init()) {
|
||||
self.participant = participant
|
||||
self.content = content
|
||||
self.date = date
|
||||
}
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
//
|
||||
// Prompt.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/12/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum Prompt: String {
|
||||
#if DEBUG
|
||||
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
@@ -1,37 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
//
|
||||
// Ext+String.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
func localized() -> String {
|
||||
let ans = NSLocalizedString(self, bundle: Bundle.module, comment: "")
|
||||
guard !ans.isEmpty else {
|
||||
assertionFailure()
|
||||
return self
|
||||
}
|
||||
return ans
|
||||
}
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
//
|
||||
// Ext+UIColor.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/12/13.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIColor {
|
||||
static var accent: UIColor {
|
||||
Constant.affineTintColor
|
||||
}
|
||||
|
||||
convenience init(light: UIColor, dark: UIColor) {
|
||||
self.init(dynamicProvider: { traitCollection in
|
||||
switch traitCollection.userInterfaceStyle {
|
||||
case .light:
|
||||
light
|
||||
case .dark:
|
||||
dark
|
||||
default:
|
||||
light
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
//
|
||||
// Ext+UIFont.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/21.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIFont {
|
||||
static func preferredFont(for style: TextStyle, weight: Weight, italic: Bool = false) -> UIFont {
|
||||
// Get the style's default pointSize
|
||||
let traits = UITraitCollection(preferredContentSizeCategory: .large)
|
||||
let desc = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style, compatibleWith: traits)
|
||||
|
||||
// Get the font at the default size and preferred weight
|
||||
var font = UIFont.systemFont(ofSize: desc.pointSize, weight: weight)
|
||||
if italic == true {
|
||||
font = font.with([.traitItalic])
|
||||
}
|
||||
|
||||
// Setup the font to be auto-scalable
|
||||
let metrics = UIFontMetrics(forTextStyle: style)
|
||||
return metrics.scaledFont(for: font)
|
||||
}
|
||||
|
||||
private func with(_ traits: UIFontDescriptor.SymbolicTraits...) -> UIFont {
|
||||
guard let descriptor = fontDescriptor.withSymbolicTraits(UIFontDescriptor.SymbolicTraits(traits).union(fontDescriptor.symbolicTraits)) else {
|
||||
return self
|
||||
}
|
||||
return UIFont(descriptor: descriptor, size: 0)
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
//
|
||||
// Ext+UIView.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIView {
|
||||
var parentViewController: UIViewController? {
|
||||
var responder: UIResponder? = self
|
||||
while responder != nil {
|
||||
if let responder = responder as? UIViewController {
|
||||
return responder
|
||||
}
|
||||
responder = responder?.next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeEveryAutoResizingMasks() {
|
||||
var views: [UIView] = [self]
|
||||
while let view = views.first {
|
||||
views.removeFirst()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.subviews.forEach { views.append($0) }
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func debugFrame() {
|
||||
layer.borderWidth = 1
|
||||
layer.borderColor = [
|
||||
UIColor.red,
|
||||
.green,
|
||||
.blue,
|
||||
.yellow,
|
||||
.cyan,
|
||||
.magenta,
|
||||
.orange,
|
||||
].map(\.cgColor).randomElement()
|
||||
subviews.forEach { $0.debugFrame() }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
//
|
||||
// Ext+UIViewController.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public extension UIViewController {
|
||||
func presentIntoCurrentContext(withTargetController targetController: UIViewController, animated: Bool = true) {
|
||||
if let nav = self as? UINavigationController {
|
||||
nav.pushViewController(targetController, animated: animated)
|
||||
} else if let nav = navigationController {
|
||||
nav.pushViewController(targetController, animated: animated)
|
||||
} else {
|
||||
present(targetController, animated: animated, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func dismissInContext() {
|
||||
if let nav = navigationController {
|
||||
nav.popViewController(animated: true)
|
||||
} else {
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func hideKeyboardWhenTappedAround() {
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(UIViewController.dismissKeyboard))
|
||||
tap.cancelsTouchesInView = false
|
||||
view.addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
@objc func dismissKeyboard() {
|
||||
view.endEditing(true)
|
||||
}
|
||||
|
||||
func presentError(_ error: Error, onDismiss: @escaping () -> Void = {}) {
|
||||
DispatchQueue.main.async { [self] in
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
//
|
||||
// Ext+print.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public func print(_ items: Any..., separator: String = " ", terminator: String = "\n") {
|
||||
#if DEBUG
|
||||
Swift.print(items, separator: separator, terminator: terminator)
|
||||
#endif
|
||||
}
|
||||
-163
@@ -1,163 +0,0 @@
|
||||
//
|
||||
// AttachmentBannerView.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
private let attachmentSize: CGFloat = 100
|
||||
private let attachmentSpacing: CGFloat = 16
|
||||
|
||||
class AttachmentBannerView: UIScrollView {
|
||||
var readAttachments: (() -> ([UIImage]))?
|
||||
var onAttachmentsDelete: ((Int) -> Void)?
|
||||
var attachments: [UIImage] {
|
||||
get { readAttachments?() ?? [] }
|
||||
set { assertionFailure() }
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
if attachments.isEmpty { return .zero }
|
||||
return .init(
|
||||
width: (attachmentSize + attachmentSize) * CGFloat(attachments.count)
|
||||
- attachmentSpacing,
|
||||
height: attachmentSize
|
||||
)
|
||||
}
|
||||
|
||||
let stackView = UIStackView()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
showsHorizontalScrollIndicator = false
|
||||
showsVerticalScrollIndicator = false
|
||||
|
||||
stackView.axis = .horizontal
|
||||
stackView.spacing = attachmentSpacing
|
||||
stackView.alignment = .center
|
||||
stackView.distribution = .fill
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(stackView)
|
||||
[
|
||||
stackView.topAnchor.constraint(equalTo: topAnchor),
|
||||
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
rebuildViews()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
var reusableViews = [AttachmentPreviewView]()
|
||||
|
||||
func rebuildViews() {
|
||||
let attachments = attachments
|
||||
|
||||
if reusableViews.count > attachments.count {
|
||||
for index in attachments.count ..< reusableViews.count {
|
||||
reusableViews[index].removeFromSuperview()
|
||||
}
|
||||
reusableViews.removeLast(reusableViews.count - attachments.count)
|
||||
}
|
||||
if reusableViews.count < attachments.count {
|
||||
for _ in reusableViews.count ..< attachments.count {
|
||||
let view = AttachmentPreviewView()
|
||||
view.alpha = 0
|
||||
reusableViews.append(view)
|
||||
}
|
||||
}
|
||||
|
||||
assert(reusableViews.count == attachments.count)
|
||||
|
||||
for (index, attachment) in attachments.enumerated() {
|
||||
let view = reusableViews[index]
|
||||
view.imageView.image = attachment
|
||||
stackView.addArrangedSubview(view)
|
||||
view.deleteButtonAction = { [weak self] in
|
||||
self?.onAttachmentsDelete?(index)
|
||||
}
|
||||
}
|
||||
|
||||
invalidateIntrinsicContentSize()
|
||||
contentSize = intrinsicContentSize
|
||||
UIView.performWithoutAnimation {
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
for view in self.reusableViews {
|
||||
view.alpha = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AttachmentBannerView {
|
||||
class AttachmentPreviewView: UIView {
|
||||
let imageView = UIImageView()
|
||||
let deleteButton = UIButton()
|
||||
|
||||
var deleteButtonAction: (() -> Void)?
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
.init(width: attachmentSize, height: attachmentSize)
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
addSubview(imageView)
|
||||
addSubview(deleteButton)
|
||||
|
||||
layer.cornerRadius = 8
|
||||
clipsToBounds = true
|
||||
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
imageView.topAnchor.constraint(equalTo: topAnchor),
|
||||
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
deleteButton.setImage(.init(named: "close", in: .module, with: nil), for: .normal)
|
||||
deleteButton.imageView?.contentMode = .scaleAspectFit
|
||||
deleteButton.tintColor = .white
|
||||
deleteButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
deleteButton.topAnchor.constraint(equalTo: topAnchor, constant: 4),
|
||||
deleteButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
|
||||
deleteButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
deleteButton.heightAnchor.constraint(equalToConstant: 32),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside)
|
||||
|
||||
[
|
||||
widthAnchor.constraint(equalToConstant: attachmentSize),
|
||||
heightAnchor.constraint(equalToConstant: attachmentSize),
|
||||
].forEach { $0.isActive = true }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@objc func deleteButtonTapped() {
|
||||
deleteButtonAction?()
|
||||
deleteButtonAction = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// InputEditView+Camera.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/12/6.
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import UIKit
|
||||
|
||||
extension InputEditView: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
@objc func takePhoto() {
|
||||
AVCaptureDevice.requestAccess(for: .video) { _ in
|
||||
DispatchQueue.main.async {
|
||||
let ctrl = UIImagePickerController()
|
||||
ctrl.allowsEditing = false
|
||||
ctrl.sourceType = .camera
|
||||
ctrl.mediaTypes = [UTType.movie.identifier, UTType.image.identifier]
|
||||
ctrl.cameraCaptureMode = .photo
|
||||
ctrl.delegate = self
|
||||
self.parentViewController?.present(ctrl, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processJPEGImageData(_ image: UIImage) throws -> Data? {
|
||||
guard let data = image.jpegData(compressionQuality: 0.75) else {
|
||||
throw UnableTo.compressImage
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
picker.dismiss(animated: true) {
|
||||
var itemUrl: URL?
|
||||
|
||||
if itemUrl == nil,
|
||||
let image = info[.editedImage] as? UIImage ?? info[.originalImage] as? UIImage
|
||||
{
|
||||
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("Camera")
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let tempFile = tempDir
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("jpeg")
|
||||
try? self.processJPEGImageData(image)?.write(to: tempFile)
|
||||
itemUrl = tempFile
|
||||
}
|
||||
if itemUrl == nil,
|
||||
let url = info[.mediaURL] as? URL
|
||||
{
|
||||
itemUrl = url
|
||||
}
|
||||
|
||||
guard let url = itemUrl, FileManager.default.fileExists(atPath: url.path) else {
|
||||
return
|
||||
}
|
||||
guard let image = UIImage(contentsOfFile: url.path) else { return }
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
self.viewModel.attachments.append(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
//
|
||||
// InputEditView+Photo.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/12/6.
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import UIKit
|
||||
|
||||
extension InputEditView: PHPickerViewControllerDelegate {
|
||||
@objc func selectPhoto() {
|
||||
var config = PHPickerConfiguration(photoLibrary: .shared())
|
||||
config.filter = .images
|
||||
config.selectionLimit = 9
|
||||
let picker = PHPickerViewController(configuration: config)
|
||||
picker.modalPresentationStyle = .formSheet
|
||||
picker.delegate = self
|
||||
parentViewController?.present(picker, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
loadPNG(from: results)
|
||||
}
|
||||
|
||||
private func loadPNG(from results: [PHPickerResult]) {
|
||||
for result in results {
|
||||
result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
|
||||
if let image = image as? UIImage {
|
||||
DispatchQueue.main.async {
|
||||
self?.viewModel.attachments.append(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
//
|
||||
// InputEditView+ViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
extension InputEditView {
|
||||
class ViewModel: ObservableObject {
|
||||
var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
@Published var text: String = ""
|
||||
@Published var attachments: [UIImage] = []
|
||||
|
||||
init() {}
|
||||
|
||||
deinit {
|
||||
cancellables.forEach { $0.cancel() }
|
||||
cancellables.removeAll()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
text = ""
|
||||
attachments = []
|
||||
}
|
||||
|
||||
func duplicate() -> ViewModel {
|
||||
let ans = ViewModel()
|
||||
ans.text = text
|
||||
ans.attachments = attachments
|
||||
return ans
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension InputEditView.ViewModel: Hashable, Equatable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(text)
|
||||
hasher.combine(attachments)
|
||||
}
|
||||
|
||||
static func == (lhs: InputEditView.ViewModel, rhs: InputEditView.ViewModel) -> Bool {
|
||||
lhs.hashValue == rhs.hashValue
|
||||
}
|
||||
}
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
//
|
||||
// InputEditView.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
class InputEditView: UIView {
|
||||
let mainStack = UIStackView()
|
||||
let attachmentsEditor = AttachmentBannerView()
|
||||
let textEditor = PlainTextEditView()
|
||||
let placeholderLabel = UILabel()
|
||||
let controlBanner = TextEditControlBanner()
|
||||
|
||||
let viewModel = ViewModel()
|
||||
var placeholderText: String = "" {
|
||||
didSet {
|
||||
placeholderLabel.text = placeholderText
|
||||
}
|
||||
}
|
||||
|
||||
var submitAction: (() -> Void) = {}
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
addSubview(mainStack)
|
||||
mainStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
mainStack.axis = .vertical
|
||||
mainStack.spacing = 16
|
||||
mainStack.alignment = .fill
|
||||
mainStack.distribution = .equalSpacing
|
||||
[
|
||||
mainStack.topAnchor.constraint(equalTo: topAnchor),
|
||||
mainStack.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
mainStack.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
mainStack.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
textEditor.heightAnchor.constraint(greaterThanOrEqualToConstant: 64).isActive = true
|
||||
|
||||
[
|
||||
attachmentsEditor, textEditor, controlBanner,
|
||||
].forEach {
|
||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||
mainStack.addArrangedSubview($0)
|
||||
[
|
||||
$0.leadingAnchor.constraint(equalTo: mainStack.leadingAnchor),
|
||||
$0.trailingAnchor.constraint(equalTo: mainStack.trailingAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
}
|
||||
|
||||
attachmentsEditor.readAttachments = { [weak self] in
|
||||
self?.viewModel.attachments ?? []
|
||||
}
|
||||
attachmentsEditor.onAttachmentsDelete = { [weak self] index in
|
||||
self?.viewModel.attachments.remove(at: index)
|
||||
}
|
||||
|
||||
controlBanner.cameraButton.addTarget(
|
||||
self,
|
||||
action: #selector(takePhoto),
|
||||
for: .touchUpInside
|
||||
)
|
||||
controlBanner.photoButton.addTarget(
|
||||
self,
|
||||
action: #selector(selectPhoto),
|
||||
for: .touchUpInside
|
||||
)
|
||||
|
||||
textEditor.returnKeyType = .send
|
||||
textEditor.addSubview(placeholderLabel)
|
||||
placeholderLabel.textColor = .label.withAlphaComponent(0.25)
|
||||
placeholderLabel.font = textEditor.font
|
||||
placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
placeholderLabel.leadingAnchor.constraint(equalTo: textEditor.leadingAnchor, constant: 2),
|
||||
placeholderLabel.trailingAnchor.constraint(equalTo: textEditor.trailingAnchor, constant: -2),
|
||||
placeholderLabel.topAnchor.constraint(equalTo: textEditor.topAnchor, constant: 0),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
viewModel.objectWillChange
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.updateValues()
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
|
||||
updateValues()
|
||||
|
||||
textEditor.textDidChange = { [weak self] text in
|
||||
self?.viewModel.text = text
|
||||
self?.updatePlaceholderVisibility()
|
||||
}
|
||||
|
||||
textEditor.textDidReturn = { [weak self] in
|
||||
self?.submitAction()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func updatePlaceholderVisibility() {
|
||||
let visible = viewModel.text.isEmpty && !textEditor.isFirstResponder
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.placeholderLabel.alpha = visible ? 1 : 0
|
||||
}
|
||||
}
|
||||
|
||||
func updateValues() {
|
||||
UIView.animate(
|
||||
withDuration: 0.5,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 1.0,
|
||||
initialSpringVelocity: 0.8
|
||||
) { [self] in
|
||||
if textEditor.text != viewModel.text {
|
||||
textEditor.text = viewModel.text
|
||||
}
|
||||
attachmentsEditor.rebuildViews()
|
||||
parentViewController?.view.layoutIfNeeded()
|
||||
updatePlaceholderVisibility()
|
||||
}
|
||||
}
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
//
|
||||
// PlainTextEditView.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class PlainTextEditView: UITextView, UITextViewDelegate {
|
||||
var textDidChange: ((String) -> Void) = { _ in }
|
||||
var textDidReturn: (() -> Void) = {}
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero, textContainer: nil)
|
||||
|
||||
delegate = self
|
||||
tintColor = .accent
|
||||
|
||||
linkTextAttributes = [:]
|
||||
showsVerticalScrollIndicator = false
|
||||
showsHorizontalScrollIndicator = false
|
||||
textContainer.lineFragmentPadding = .zero
|
||||
textAlignment = .natural
|
||||
backgroundColor = .clear
|
||||
textContainerInset = .zero
|
||||
textContainer.lineBreakMode = .byTruncatingTail
|
||||
isScrollEnabled = false
|
||||
clipsToBounds = false
|
||||
|
||||
isEditable = true
|
||||
isSelectable = true
|
||||
isScrollEnabled = false
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
textDidChange(textView.text)
|
||||
}
|
||||
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
textDidChange(textView.text)
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ textView: UITextView) {
|
||||
textDidChange(textView.text)
|
||||
}
|
||||
|
||||
func textView(_: UITextView, editMenuForTextIn _: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||
.init(children: suggestedActions + [
|
||||
UIAction(title: "Insert Newline") { [weak self] _ in
|
||||
self?.insertText("\n")
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
func textView(_: UITextView, shouldChangeTextIn _: NSRange, replacementText text: String) -> Bool {
|
||||
if text == "\n" {
|
||||
textDidReturn()
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
//
|
||||
// TextEditControlBanner.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class TextEditControlBanner: UIStackView {
|
||||
static let height: CGFloat = 32
|
||||
|
||||
let cameraButton = UIButton()
|
||||
let photoButton = UIButton()
|
||||
|
||||
let spacer = UIView()
|
||||
|
||||
let sendButton = UIButton()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
axis = .horizontal
|
||||
spacing = 16
|
||||
alignment = .center
|
||||
distribution = .fill
|
||||
|
||||
[
|
||||
heightAnchor.constraint(equalToConstant: Self.height),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
[
|
||||
cameraButton, photoButton,
|
||||
sendButton,
|
||||
].forEach {
|
||||
$0.widthAnchor.constraint(equalToConstant: Self.height).isActive = true
|
||||
$0.heightAnchor.constraint(equalToConstant: Self.height).isActive = true
|
||||
}
|
||||
|
||||
[
|
||||
cameraButton, photoButton,
|
||||
spacer,
|
||||
sendButton,
|
||||
].forEach {
|
||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||
addArrangedSubview($0)
|
||||
}
|
||||
|
||||
cameraButton.setImage(.init(systemName: "camera"), for: .normal)
|
||||
cameraButton.tintColor = .label
|
||||
photoButton.setImage(.init(systemName: "photo"), for: .normal)
|
||||
photoButton.tintColor = .label
|
||||
|
||||
sendButton.setImage(.init(systemName: "paperplane.fill"), for: .normal)
|
||||
sendButton.tintColor = .label
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
-372
@@ -1,372 +0,0 @@
|
||||
//
|
||||
// IntelligentsChatController+Chat.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/12/26.
|
||||
//
|
||||
|
||||
import AffineGraphQL
|
||||
import LDSwiftEventSource
|
||||
import MarkdownParser
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsChatController {
|
||||
@objc func chat_onLoad() {
|
||||
beginProgress()
|
||||
chat_createSession { session in
|
||||
self.sessionID = session ?? ""
|
||||
self.chat_retrieveHistories {
|
||||
self.dispatchToMain {
|
||||
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()
|
||||
viewModel.text = viewModel.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
inputBox.editor.viewModel.reset()
|
||||
inputBox.editor.updateValues()
|
||||
DispatchQueue.global().async {
|
||||
self.chat_onSendExecute(viewModel: viewModel)
|
||||
self.endProgress()
|
||||
}
|
||||
}
|
||||
|
||||
func chat_clearHistory() {
|
||||
beginProgress()
|
||||
Intelligents.qlClient.perform(mutation: CleanupCopilotSessionMutation(input: .init(
|
||||
docId: metadata[.documentID] ?? "",
|
||||
sessionIds: [sessionID],
|
||||
workspaceId: metadata[.workspaceID] ?? ""
|
||||
))) { result in
|
||||
self.dispatchToMain {
|
||||
self.endProgress()
|
||||
if case let .success(value) = result,
|
||||
let sessions = value.data?.cleanupCopilotSession,
|
||||
sessions.contains(self.sessionID)
|
||||
{
|
||||
self.simpleChatContents.removeAll()
|
||||
return
|
||||
}
|
||||
self.presentError(UnableTo.clearHistory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func chat_retrieveHistories(_ completion: @escaping () -> Void) {
|
||||
Intelligents.qlClient.fetch(query: GetCopilotHistoriesQuery(
|
||||
workspaceId: metadata[.workspaceID] ?? "",
|
||||
docId: .init(stringLiteral: metadata[.documentID] ?? ""),
|
||||
options: .some(.init(
|
||||
action: false,
|
||||
fork: false,
|
||||
limit: .init(nilLiteral: ()),
|
||||
messageOrder: .some(.case(.asc)),
|
||||
sessionId: .init(stringLiteral: sessionID),
|
||||
sessionOrder: .some(.case(.desc)),
|
||||
skip: .init(nilLiteral: ()),
|
||||
withPrompt: .init(booleanLiteral: false)
|
||||
))
|
||||
)) { [weak self] result in
|
||||
if let self,
|
||||
case let .success(value) = result,
|
||||
let object = value.data,
|
||||
let currentUser = object.__data._data["currentUser"] as? DataDict,
|
||||
let copilot = currentUser._data["copilot"] as? DataDict,
|
||||
let histories = copilot._data["histories"] as? [DataDict],
|
||||
let mostRecent = histories.first,
|
||||
let messages = mostRecent._data["messages"] as? [DataDict],
|
||||
!messages.isEmpty
|
||||
{
|
||||
print("[*] retrieved \(messages.count) messages")
|
||||
tableView.scrollToBottomOnNextUpdate = true
|
||||
tableView.alpha = 0
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.8) {
|
||||
self.tableView.alpha = 1
|
||||
}
|
||||
}
|
||||
for message in messages {
|
||||
guard let role = message._data["role"] as? String,
|
||||
let content = message._data["content"] as? String
|
||||
// TODO: ATTACHMENTS
|
||||
else { continue }
|
||||
switch role {
|
||||
case "assistant":
|
||||
simpleChatContents.updateValue(
|
||||
.assistant(document: content),
|
||||
forKey: UUID()
|
||||
)
|
||||
case "user":
|
||||
simpleChatContents.updateValue(
|
||||
.user(document: content),
|
||||
forKey: UUID()
|
||||
)
|
||||
default:
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension IntelligentsChatController {
|
||||
func dispatchToMain(_ block: @escaping () -> Void) {
|
||||
if Thread.isMainThread {
|
||||
block()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: block)
|
||||
}
|
||||
}
|
||||
|
||||
func beginProgress() {
|
||||
dispatchToMain { [self] in
|
||||
header.isUserInteractionEnabled = false
|
||||
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
|
||||
self.header.isUserInteractionEnabled = true
|
||||
} completion: { _ in
|
||||
self.inputBox.isUserInteractionEnabled = true
|
||||
self.progressView.stopAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension IntelligentsChatController {
|
||||
func chat_onError(_ error: Error) {
|
||||
print("[*] chat error", error)
|
||||
dispatchToMain {
|
||||
let key = UUID()
|
||||
let content = ChatContent.error(text: error.localizedDescription)
|
||||
self.simpleChatContents.updateValue(content, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func chat_createSession(
|
||||
forceCreateNewSession: Bool = false,
|
||||
onSuccess: @escaping (String?) -> Void,
|
||||
onFailure: @escaping (Error) -> Void
|
||||
) {
|
||||
if !forceCreateNewSession,
|
||||
let doc = metadata[.documentID],
|
||||
!doc.isEmpty
|
||||
{
|
||||
Intelligents.qlClient.fetch(query: GetCopilotSessionsQuery(
|
||||
workspaceId: .init(stringLiteral: metadata[.workspaceID] ?? ""),
|
||||
docId: .init(stringLiteral: doc),
|
||||
options: .some(QueryChatSessionsInput(InputDict([
|
||||
"action": false,
|
||||
])))
|
||||
)) { result in
|
||||
switch result {
|
||||
case let .success(value):
|
||||
if let result = value.data,
|
||||
let currentUser = result.__data._data["currentUser"] as? DataDict,
|
||||
let copilot = currentUser._data["copilot"] as? DataDict,
|
||||
let sessions = copilot._data["sessions"] as? [DataDict],
|
||||
let mostRecent = sessions.last,
|
||||
let sessionID = mostRecent._data["id"] as? String
|
||||
{
|
||||
print("[*] using existing session", sessionID)
|
||||
self.dispatchToMain { onSuccess(sessionID) }
|
||||
return
|
||||
}
|
||||
self.chat_createSession(
|
||||
forceCreateNewSession: true,
|
||||
onSuccess: onSuccess,
|
||||
onFailure: onFailure
|
||||
)
|
||||
case let .failure(error):
|
||||
self.dispatchToMain { onFailure(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Intelligents.qlClient.perform(
|
||||
mutation: CreateCopilotSessionMutation(options: .init(
|
||||
docId: metadata[.documentID] ?? "",
|
||||
promptName: Prompt.general_Chat_With_AFFiNE_AI.rawValue,
|
||||
workspaceId: metadata[.workspaceID] ?? ""
|
||||
)),
|
||||
queue: .global()
|
||||
) { result in
|
||||
switch result {
|
||||
case let .success(value):
|
||||
if let session = value.data?.createCopilotSession {
|
||||
self.dispatchToMain { onSuccess(session) }
|
||||
} else {
|
||||
self.dispatchToMain {
|
||||
onFailure(UnableTo.createSession)
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
self.dispatchToMain { onFailure(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func chat_onSendExecute(viewModel: InputEditView.ViewModel) {
|
||||
let text = viewModel.text
|
||||
// let images = viewModel.attachments
|
||||
|
||||
let assistantContentID = UUID()
|
||||
dispatchToMain {
|
||||
let content = ChatContent.user(document: text)
|
||||
self.simpleChatContents.updateValue(content, forKey: .init())
|
||||
self.simpleChatContents.updateValue(
|
||||
.assistant(document: "..."),
|
||||
forKey: assistantContentID
|
||||
)
|
||||
self.tableView.scrollToBottomOnNextUpdate = true
|
||||
}
|
||||
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
let sessionID = sessionID
|
||||
Intelligents.qlClient.perform(
|
||||
mutation: CreateCopilotMessageMutation(options: .init(
|
||||
content: .init(stringLiteral: text),
|
||||
params: .some(.dictionary([
|
||||
"docs": [
|
||||
"docId": metadata[.documentID] ?? "",
|
||||
"docContent": metadata[.content] ?? "",
|
||||
],
|
||||
])),
|
||||
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,
|
||||
cellID: assistantContentID
|
||||
)
|
||||
} else {
|
||||
self.chat_onError(UnableTo.createMessage)
|
||||
}
|
||||
case let .failure(error):
|
||||
self.chat_onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
sem.wait()
|
||||
}
|
||||
|
||||
func chat_processWithMessageID(sessionID: String, messageID: String, cellID: UUID) {
|
||||
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(UnableTo.createMessage)
|
||||
return
|
||||
}
|
||||
|
||||
dispatchToMain {
|
||||
self.simpleChatContents.updateValue(
|
||||
.assistant(document: "..."),
|
||||
forKey: cellID
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
var document = ""
|
||||
eventHandler.onMessageBlock = { _, message in
|
||||
self.dispatchToMain {
|
||||
document += message.data
|
||||
let content = ChatContent.assistant(document: document)
|
||||
self.simpleChatContents.updateValue(content, forKey: cellID)
|
||||
}
|
||||
}
|
||||
let eventSource = EventSource(config: .init(handler: eventHandler, url: url))
|
||||
chatTask = eventSource
|
||||
eventSource.start()
|
||||
|
||||
sem.wait()
|
||||
}
|
||||
}
|
||||
|
||||
extension IntelligentsChatController {
|
||||
func updateContentToPublisher() {
|
||||
assert(Thread.isMainThread)
|
||||
let copy = simpleChatContents
|
||||
let input: [MessageListView.Element] = copy.map { key, value in
|
||||
switch value {
|
||||
case let .assistant(document):
|
||||
let nodes = MarkdownParser().feed(document)
|
||||
return .init(
|
||||
id: key,
|
||||
cell: .assistant,
|
||||
viewModel: MessageListView.AssistantCell.ViewModel(blocks: nodes),
|
||||
object: nil
|
||||
)
|
||||
case let .user(document):
|
||||
return .init(
|
||||
id: key,
|
||||
cell: .user,
|
||||
viewModel: MessageListView.UserCell.ViewModel(text: document),
|
||||
object: nil
|
||||
)
|
||||
case let .error(text):
|
||||
return .init(
|
||||
id: key,
|
||||
cell: .hint,
|
||||
viewModel: MessageListView.HintCell.ViewModel(hint: text),
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
publisher.send(input)
|
||||
}
|
||||
}
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
//
|
||||
// IntelligentsChatController+Header.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsChatController {
|
||||
class Header: UIView {
|
||||
static let height: CGFloat = 44
|
||||
|
||||
let contentView = UIView()
|
||||
let titleLabel = UILabel()
|
||||
let dropMenu = UIButton()
|
||||
let backButton = UIButton()
|
||||
let rightBarItemsStack = UIStackView()
|
||||
let moreMenu = UIButton()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override var isUserInteractionEnabled: Bool {
|
||||
didSet { updateAvailabilityStyles() }
|
||||
}
|
||||
|
||||
func updateAvailabilityStyles() {
|
||||
if isUserInteractionEnabled {
|
||||
backButton.isEnabled = true
|
||||
dropMenu.isEnabled = true
|
||||
moreMenu.isEnabled = true
|
||||
} else {
|
||||
backButton.isEnabled = false
|
||||
dropMenu.isEnabled = false
|
||||
moreMenu.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
@objc func navigateActionBack() {
|
||||
parentViewController?.dismissInContext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension IntelligentsChatController.Header {
|
||||
func setupLayout() {
|
||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(contentView)
|
||||
[
|
||||
contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
contentView.heightAnchor.constraint(equalToConstant: Self.height),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
titleLabel.textColor = .label
|
||||
titleLabel.font = .systemFont(
|
||||
ofSize: UIFont.labelFontSize,
|
||||
weight: .semibold
|
||||
)
|
||||
|
||||
backButton.setImage(
|
||||
UIImage(systemName: "chevron.left"),
|
||||
for: .normal
|
||||
)
|
||||
backButton.tintColor = .accent
|
||||
backButton.addTarget(self, action: #selector(navigateActionBack), for: .touchUpInside)
|
||||
|
||||
dropMenu.setImage(
|
||||
.init(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
dropMenu.tintColor = .gray.withAlphaComponent(0.5)
|
||||
|
||||
contentView.addSubview(titleLabel)
|
||||
contentView.addSubview(backButton)
|
||||
contentView.addSubview(dropMenu)
|
||||
contentView.addSubview(rightBarItemsStack)
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
backButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
dropMenu.translatesAutoresizingMaskIntoConstraints = false
|
||||
rightBarItemsStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
rightBarItemsStack.axis = .horizontal
|
||||
rightBarItemsStack.spacing = 10
|
||||
rightBarItemsStack.alignment = .center
|
||||
rightBarItemsStack.distribution = .equalSpacing
|
||||
|
||||
[
|
||||
backButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
backButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
|
||||
backButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
backButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
|
||||
rightBarItemsStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
rightBarItemsStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
|
||||
rightBarItemsStack.heightAnchor.constraint(equalToConstant: 44),
|
||||
|
||||
titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
||||
titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: backButton.trailingAnchor, constant: 10),
|
||||
|
||||
dropMenu.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
dropMenu.widthAnchor.constraint(equalToConstant: 44),
|
||||
dropMenu.heightAnchor.constraint(equalToConstant: 44),
|
||||
titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: dropMenu.leadingAnchor, constant: -10),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
rightBarItemsStack.addArrangedSubview(moreMenu)
|
||||
moreMenu.setImage(
|
||||
.init(systemName: "ellipsis.circle"),
|
||||
for: .normal
|
||||
)
|
||||
moreMenu.tintColor = .accent
|
||||
}
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
//
|
||||
// IntelligentsChatController+InputBox.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsChatController {
|
||||
class InputBox: UIView {
|
||||
let backgroundView = UIView()
|
||||
let editor = InputEditView()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
setupLayout()
|
||||
|
||||
editor.textEditor.font = UIFont.systemFont(ofSize: UIFont.labelFontSize)
|
||||
editor.placeholderText = "Summarize this article for me...".localized()
|
||||
|
||||
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)
|
||||
backgroundView.layer.shadowRadius = 8
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension IntelligentsChatController.InputBox {
|
||||
func setupLayout() {
|
||||
addSubview(backgroundView)
|
||||
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
addSubview(editor)
|
||||
editor.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let inset: CGFloat = 16
|
||||
|
||||
[
|
||||
editor.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset),
|
||||
editor.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset),
|
||||
editor.topAnchor.constraint(equalTo: topAnchor, constant: inset),
|
||||
editor.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -inset),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
[
|
||||
backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0),
|
||||
backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0),
|
||||
backgroundView.topAnchor.constraint(equalTo: topAnchor, constant: 0),
|
||||
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 128),
|
||||
].forEach { $0.isActive = true }
|
||||
}
|
||||
}
|
||||
-150
@@ -1,150 +0,0 @@
|
||||
//
|
||||
// IntelligentsChatController.swift
|
||||
//
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import LDSwiftEventSource
|
||||
import OrderedCollections
|
||||
import UIKit
|
||||
|
||||
public class IntelligentsChatController: UIViewController {
|
||||
let header = Header()
|
||||
let inputBox = InputBox()
|
||||
let progressView = UIActivityIndicatorView()
|
||||
|
||||
let publisher = PassthroughSubject<MessageListView.ElementPublisher.Output, Never>()
|
||||
lazy var tableView = MessageListView(dataPublisher: publisher.eraseToAnyPublisher())
|
||||
|
||||
var inputBoxKeyboardAdapterHeightConstraint = NSLayoutConstraint()
|
||||
|
||||
enum ChatContent {
|
||||
case user(document: String)
|
||||
case assistant(document: String)
|
||||
case error(text: String)
|
||||
}
|
||||
|
||||
var simpleChatContents: OrderedDictionary<UUID, ChatContent> = [:] {
|
||||
didSet { updateContentToPublisher() }
|
||||
}
|
||||
|
||||
var sessionID: String = ""
|
||||
|
||||
public enum MetadataKey: String {
|
||||
case documentID
|
||||
case workspaceID
|
||||
case content
|
||||
}
|
||||
|
||||
public var metadata: [MetadataKey: String] = [:]
|
||||
|
||||
var chatTask: EventSource?
|
||||
|
||||
override public var title: String? {
|
||||
set {
|
||||
super.title = newValue
|
||||
header.titleLabel.text = newValue
|
||||
}
|
||||
get {
|
||||
super.title
|
||||
}
|
||||
}
|
||||
|
||||
public init() {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
title = "Chat with AI".localized()
|
||||
|
||||
overrideUserInterfaceStyle = .dark
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
deinit {
|
||||
chatTask?.stop()
|
||||
chatTask = nil
|
||||
}
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
assert(navigationController != nil)
|
||||
view.backgroundColor = .secondarySystemBackground
|
||||
|
||||
hideKeyboardWhenTappedAround()
|
||||
|
||||
view.addSubview(header)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(inputBox)
|
||||
view.addSubview(progressView)
|
||||
setupLayout()
|
||||
|
||||
header.moreMenu.showsMenuAsPrimaryAction = true
|
||||
header.moreMenu.menu = .init(children: [
|
||||
UIAction(title: "Clear History".localized(), image: UIImage(systemName: "eraser")) { [weak self] _ in
|
||||
self?.chat_clearHistory()
|
||||
},
|
||||
])
|
||||
|
||||
// TODO: IMPL
|
||||
header.dropMenu.isHidden = true
|
||||
inputBox.editor.controlBanner.cameraButton.isHidden = true
|
||||
inputBox.editor.controlBanner.photoButton.isHidden = true
|
||||
|
||||
updateContentToPublisher()
|
||||
chat_onLoad()
|
||||
}
|
||||
|
||||
override public func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
chatTask?.stop()
|
||||
chatTask = nil
|
||||
}
|
||||
|
||||
func setupLayout() {
|
||||
header.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
header.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
header.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
header.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
header.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
inputBox.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
inputBox.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
inputBox.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
inputBox.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
tableView.topAnchor.constraint(equalTo: header.bottomAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: inputBox.topAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
inputBox.editor.controlBanner.sendButton.addTarget(
|
||||
self,
|
||||
action: #selector(chat_onSend),
|
||||
for: .touchUpInside
|
||||
)
|
||||
inputBox.editor.submitAction = { [weak self] in
|
||||
guard let self else { return }
|
||||
chat_onSend()
|
||||
}
|
||||
|
||||
progressView.hidesWhenStopped = true
|
||||
progressView.stopAnimating()
|
||||
progressView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
progressView.centerXAnchor.constraint(equalTo: inputBox.centerXAnchor),
|
||||
progressView.centerYAnchor.constraint(equalTo: inputBox.centerYAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
progressView.style = .large
|
||||
}
|
||||
}
|
||||
-150
@@ -1,150 +0,0 @@
|
||||
//
|
||||
// MessageListView+AssistantCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import MarkdownParser
|
||||
import MarkdownView
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class AssistantCell: BaseCell {
|
||||
let avatarView = UIImageView()
|
||||
let usernameView = UILabel()
|
||||
let markdownView = MarkdownView()
|
||||
|
||||
override func initializeContent() {
|
||||
super.initializeContent()
|
||||
|
||||
avatarView.contentMode = .scaleAspectFit
|
||||
avatarView.image = UIImage(named: "spark", in: .module, with: nil)
|
||||
usernameView.text = "AFFiNE AI"
|
||||
usernameView.font = .preferredFont(forTextStyle: .body).bold
|
||||
usernameView.textColor = .label
|
||||
|
||||
containerView.addSubview(avatarView)
|
||||
containerView.addSubview(usernameView)
|
||||
containerView.addSubview(markdownView)
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
markdownView.prepareForReuse()
|
||||
}
|
||||
|
||||
override func updateContent(
|
||||
object: any MessageListView.Element.ViewModel,
|
||||
originalObject _: Element.UserObject?
|
||||
) {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
_ = object
|
||||
}
|
||||
|
||||
override func layoutContent(cache: any MessageListView.TableLayoutEngine.LayoutCache) {
|
||||
super.layoutContent(cache: cache)
|
||||
guard let cache = cache as? LayoutCache else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
avatarView.frame = cache.avatarRect
|
||||
usernameView.frame = cache.usernameRect
|
||||
markdownView.frame = cache.markdownFrame
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
markdownView.updateContentViews(cache.manifests)
|
||||
}
|
||||
}
|
||||
|
||||
override class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any MessageListView.Element.ViewModel
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return LayoutCache()
|
||||
}
|
||||
let cache = LayoutCache()
|
||||
cache.width = containerWidth
|
||||
|
||||
let inset: CGFloat = 8
|
||||
let bubbleInset = UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset)
|
||||
|
||||
let avatarRect = CGRect(x: bubbleInset.left, y: bubbleInset.top, width: 24, height: 24)
|
||||
let usernameRect = CGRect(
|
||||
x: avatarRect.maxX + bubbleInset.right,
|
||||
y: bubbleInset.top,
|
||||
width: containerWidth - avatarRect.maxX - bubbleInset.right,
|
||||
height: 24
|
||||
)
|
||||
|
||||
let textWidth = containerWidth - bubbleInset.left - bubbleInset.right
|
||||
|
||||
var height: CGFloat = 0
|
||||
let manifests = object.blocks.map {
|
||||
let ret = $0.manifest(theme: object.theme)
|
||||
ret.setLayoutTheme(.default)
|
||||
ret.setLayoutWidth(textWidth)
|
||||
ret.layoutIfNeeded()
|
||||
height += ret.size.height + Theme.default.spacings.final
|
||||
return ret
|
||||
}
|
||||
if height > 0 { height -= Theme.default.spacings.final }
|
||||
let textRect = CGRect(
|
||||
x: bubbleInset.left,
|
||||
y: usernameRect.maxY + bubbleInset.bottom,
|
||||
width: textWidth,
|
||||
height: height
|
||||
)
|
||||
cache.markdownFrame = textRect
|
||||
cache.avatarRect = avatarRect
|
||||
cache.usernameRect = usernameRect
|
||||
cache.manifests = manifests
|
||||
cache.height = textRect.maxY + bubbleInset.bottom
|
||||
|
||||
return cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.AssistantCell {
|
||||
class ViewModel: MessageListView.Element.ViewModel {
|
||||
var theme: Theme
|
||||
var blocks: [BlockNode]
|
||||
|
||||
enum GroupLocation {
|
||||
case begin
|
||||
case center
|
||||
case end
|
||||
}
|
||||
|
||||
var groupLocation: GroupLocation = .center
|
||||
|
||||
init(theme: Theme = .default, blocks: [BlockNode]) {
|
||||
self.theme = theme
|
||||
self.blocks = blocks
|
||||
}
|
||||
|
||||
func contentIdentifier(hasher: inout Hasher) {
|
||||
hasher.combine(blocks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.AssistantCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
|
||||
var avatarRect: CGRect = .zero
|
||||
var usernameRect: CGRect = .zero
|
||||
|
||||
var markdownFrame: CGRect = .zero
|
||||
var manifests: [AnyBlockManifest] = []
|
||||
}
|
||||
}
|
||||
-143
@@ -1,143 +0,0 @@
|
||||
//
|
||||
// MessageListView+BaseCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class BaseCell: UITableViewCell, MessageListView.TableLayoutEngine.LayoutableCell {
|
||||
var associatedObject: Element? = nil
|
||||
var cancellable: Set<AnyCancellable> = []
|
||||
let containerView: UIView = .init()
|
||||
|
||||
var layoutEngine: MessageListView.TableLayoutEngine? = nil
|
||||
|
||||
func layoutCache() -> MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let associatedObject, let engine = layoutEngine else {
|
||||
return MessageListView.TableLayoutEngine.ZeroLayoutCache()
|
||||
}
|
||||
let cache = engine.requestLayoutCacheFromCell(
|
||||
forElement: associatedObject,
|
||||
atWidth: bounds.width
|
||||
)
|
||||
return cache
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
commitInit()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
commitInit()
|
||||
}
|
||||
|
||||
private func commitInit() {
|
||||
selectionStyle = .none
|
||||
separatorInset = .zero
|
||||
contentView.addSubview(containerView)
|
||||
contentView.clipsToBounds = false
|
||||
clipsToBounds = false
|
||||
initializeContent()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard let cache = layoutCache() as? LayoutCache else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
containerView.frame = cache.containerRect
|
||||
layoutContent(cache: cache.containerLayoutCache)
|
||||
}
|
||||
|
||||
func registerViewModel(element: Element) {
|
||||
removeViewModelObject()
|
||||
associatedObject = element
|
||||
updateContent(object: element.viewModel, originalObject: element.object)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func removeViewModelObject() {
|
||||
associatedObject = nil
|
||||
cancellable.forEach { $0.cancel() }
|
||||
cancellable.removeAll()
|
||||
}
|
||||
|
||||
func initializeContent() {}
|
||||
func updateContent(object: any Element.ViewModel, originalObject: Element.UserObject?) {
|
||||
_ = object
|
||||
_ = originalObject
|
||||
}
|
||||
|
||||
func layoutContent(cache: MessageListView.TableLayoutEngine.LayoutCache) {
|
||||
_ = cache
|
||||
}
|
||||
|
||||
class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any Element.ViewModel
|
||||
) -> MessageListView.TableLayoutEngine.LayoutCache {
|
||||
_ = containerWidth
|
||||
_ = object
|
||||
assertionFailure("must override")
|
||||
return MessageListView.TableLayoutEngine.ZeroLayoutCache()
|
||||
}
|
||||
|
||||
class func containerInset() -> UIEdgeInsets {
|
||||
let inset: CGFloat = 16
|
||||
let containerInset = UIEdgeInsets(top: inset / 2, left: inset, bottom: inset / 2, right: inset)
|
||||
return containerInset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.BaseCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat
|
||||
var height: CGFloat
|
||||
var containerRect: CGRect
|
||||
var containerLayoutCache: any MessageListView.TableLayoutEngine.LayoutCache
|
||||
|
||||
init(
|
||||
width: CGFloat,
|
||||
height: CGFloat,
|
||||
containerRect: CGRect,
|
||||
containerLayoutCache: any MessageListView.TableLayoutEngine.LayoutCache
|
||||
) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.containerRect = containerRect
|
||||
self.containerLayoutCache = containerLayoutCache
|
||||
}
|
||||
}
|
||||
|
||||
class func resolveLayout(
|
||||
dataElement element: MessageListView.Element,
|
||||
contentWidth width: CGFloat
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
let object = element.viewModel
|
||||
let containerInset = MessageListView.BaseCell.containerInset()
|
||||
let containerWidth = width - containerInset.left - containerInset.right
|
||||
let containerCache = Self.layoutInsideContainer(containerWidth: containerWidth, object: object)
|
||||
let cellHeight = containerCache.height + containerInset.top + containerInset.bottom
|
||||
let containerRect = CGRect(
|
||||
x: containerInset.left,
|
||||
y: containerInset.top,
|
||||
width: containerWidth,
|
||||
height: containerCache.height
|
||||
)
|
||||
let cache = LayoutCache(
|
||||
width: width,
|
||||
height: cellHeight,
|
||||
containerRect: containerRect,
|
||||
containerLayoutCache: containerCache
|
||||
)
|
||||
return cache
|
||||
}
|
||||
}
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
//
|
||||
// MessageListView+HintCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class HintCell: BaseCell {
|
||||
let label = UILabel()
|
||||
|
||||
override func initializeContent() {
|
||||
super.initializeContent()
|
||||
label.font = .preferredFont(forTextStyle: .footnote)
|
||||
label.alpha = 0.5
|
||||
label.numberOfLines = 0
|
||||
containerView.addSubview(label)
|
||||
}
|
||||
|
||||
override func updateContent(
|
||||
object: any MessageListView.Element.ViewModel,
|
||||
originalObject: Element.UserObject?
|
||||
) {
|
||||
super.updateContent(object: object, originalObject: originalObject)
|
||||
guard let object = object as? ViewModel else { return }
|
||||
label.attributedText = object.hint
|
||||
}
|
||||
|
||||
override func layoutContent(cache: any MessageListView.TableLayoutEngine.LayoutCache) {
|
||||
super.layoutContent(cache: cache)
|
||||
guard let cache = cache as? LayoutCache else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
label.frame = cache.labelFrame
|
||||
}
|
||||
|
||||
override class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any MessageListView.Element.ViewModel
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return LayoutCache()
|
||||
}
|
||||
let cache = LayoutCache()
|
||||
cache.width = containerWidth
|
||||
cache.height = object.hint.measureHeight(usingWidth: containerWidth)
|
||||
cache.labelFrame = .init(x: 0, y: 0, width: containerWidth, height: cache.height)
|
||||
return cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.HintCell {
|
||||
class ViewModel: MessageListView.Element.ViewModel {
|
||||
var hint: NSAttributedString = .init()
|
||||
|
||||
init(hint: NSAttributedString) {
|
||||
self.hint = hint
|
||||
}
|
||||
|
||||
convenience init(hint: String) {
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.alignment = .center
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: UIFont.preferredFont(forTextStyle: .footnote),
|
||||
.originalFont: UIFont.preferredFont(forTextStyle: .footnote),
|
||||
.foregroundColor: UIColor.label,
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
let text = NSMutableAttributedString(string: hint, attributes: attributes)
|
||||
self.init(hint: text)
|
||||
}
|
||||
|
||||
func contentIdentifier(hasher: inout Hasher) {
|
||||
hasher.combine(hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.HintCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
|
||||
var labelFrame: CGRect = .zero
|
||||
}
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
//
|
||||
// MessageListView+SpacerCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/12.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class SpacerCell: BaseCell {
|
||||
override class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any MessageListView.Element.ViewModel
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return LayoutCache()
|
||||
}
|
||||
let cache = LayoutCache()
|
||||
cache.width = containerWidth
|
||||
cache.height = object.height
|
||||
return cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.SpacerCell {
|
||||
class ViewModel: MessageListView.Element.ViewModel {
|
||||
var height: CGFloat
|
||||
init(height: CGFloat) {
|
||||
self.height = height
|
||||
}
|
||||
|
||||
func contentIdentifier(hasher: inout Hasher) {
|
||||
hasher.combine(height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.SpacerCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
}
|
||||
}
|
||||
-167
@@ -1,167 +0,0 @@
|
||||
//
|
||||
// MessageListView+UserCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class UserCell: BaseCell {
|
||||
let avatarView = UIImageView()
|
||||
let usernameView = UILabel()
|
||||
let bubbleView = UIView()
|
||||
let textView = UITextView()
|
||||
|
||||
override func initializeContent() {
|
||||
super.initializeContent()
|
||||
|
||||
textView.isSelectable = true
|
||||
textView.isScrollEnabled = true
|
||||
textView.isEditable = false
|
||||
textView.showsVerticalScrollIndicator = false
|
||||
textView.showsHorizontalScrollIndicator = false
|
||||
textView.textColor = .label
|
||||
textView.textContainer.lineFragmentPadding = .zero
|
||||
textView.textAlignment = .natural
|
||||
textView.backgroundColor = .clear
|
||||
textView.textContainerInset = .zero
|
||||
textView.textContainer.lineBreakMode = .byTruncatingTail
|
||||
|
||||
avatarView.contentMode = .scaleAspectFit
|
||||
avatarView.image = UIImage(systemName: "person.fill")
|
||||
usernameView.text = "You"
|
||||
usernameView.font = .preferredFont(forTextStyle: .body).bold
|
||||
usernameView.textColor = .label
|
||||
|
||||
bubbleView.layer.cornerRadius = 8
|
||||
bubbleView.backgroundColor = .gray.withAlphaComponent(0.1)
|
||||
|
||||
containerView.addSubview(bubbleView)
|
||||
containerView.addSubview(avatarView)
|
||||
containerView.addSubview(usernameView)
|
||||
containerView.addSubview(textView)
|
||||
}
|
||||
|
||||
override func updateContent(
|
||||
object: any MessageListView.Element.ViewModel,
|
||||
originalObject: Element.UserObject?
|
||||
) {
|
||||
super.updateContent(object: object, originalObject: originalObject)
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
textView.attributedText = object.text
|
||||
}
|
||||
|
||||
override func layoutContent(cache: any MessageListView.TableLayoutEngine.LayoutCache) {
|
||||
super.layoutContent(cache: cache)
|
||||
guard let cache = cache as? LayoutCache else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
bubbleView.frame = cache.bubbleFrame
|
||||
avatarView.frame = cache.avatarFrame
|
||||
usernameView.frame = cache.usernameFrame
|
||||
textView.frame = cache.labelFrame
|
||||
}
|
||||
|
||||
override class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any MessageListView.Element.ViewModel
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return LayoutCache()
|
||||
}
|
||||
let cache = LayoutCache()
|
||||
cache.width = containerWidth
|
||||
|
||||
let inset: CGFloat = 8
|
||||
let bubbleInset = UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset)
|
||||
|
||||
let avatarRect = CGRect(
|
||||
x: bubbleInset.left,
|
||||
y: bubbleInset.top,
|
||||
width: 24,
|
||||
height: 24
|
||||
)
|
||||
let usernameFrame = CGRect(
|
||||
x: avatarRect.maxX + inset,
|
||||
y: bubbleInset.top,
|
||||
width: containerWidth - avatarRect.maxX - bubbleInset.right,
|
||||
height: 24
|
||||
)
|
||||
|
||||
let textWidth = min(
|
||||
object.text.measureWidth(),
|
||||
containerWidth - inset * 2
|
||||
)
|
||||
let textHeight = object.text.measureHeight(usingWidth: textWidth)
|
||||
let textRect = CGRect(
|
||||
x: bubbleInset.left,
|
||||
y: avatarRect.maxY + bubbleInset.top,
|
||||
width: textWidth,
|
||||
height: textHeight
|
||||
)
|
||||
let bubbleRect = CGRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: containerWidth,
|
||||
height: textRect.maxY + bubbleInset.bottom
|
||||
)
|
||||
cache.bubbleFrame = bubbleRect
|
||||
cache.avatarFrame = avatarRect
|
||||
cache.usernameFrame = usernameFrame
|
||||
cache.labelFrame = textRect
|
||||
cache.height = bubbleRect.maxY
|
||||
return cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.UserCell {
|
||||
class ViewModel: MessageListView.Element.ViewModel {
|
||||
var text: NSAttributedString = .init()
|
||||
|
||||
init(text: NSAttributedString) {
|
||||
self.text = text
|
||||
}
|
||||
|
||||
convenience init(text: String) {
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.alignment = .natural
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: UIFont.preferredFont(forTextStyle: .body),
|
||||
.originalFont: UIFont.preferredFont(forTextStyle: .body),
|
||||
.foregroundColor: UIColor.label,
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
var text = text
|
||||
while text.contains("\n\n\n") {
|
||||
text = text.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
||||
}
|
||||
print(text)
|
||||
self.init(text: NSMutableAttributedString(string: text, attributes: attributes))
|
||||
}
|
||||
|
||||
func contentIdentifier(hasher: inout Hasher) {
|
||||
hasher.combine(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.UserCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
|
||||
var bubbleFrame: CGRect = .zero
|
||||
var labelFrame: CGRect = .zero
|
||||
var avatarFrame: CGRect = .zero
|
||||
var usernameFrame: CGRect = .zero
|
||||
}
|
||||
}
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
//
|
||||
// MessageListView+DataElement.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
struct Element: Identifiable {
|
||||
let id: AnyHashable // equals to message id if applicable
|
||||
|
||||
enum Cell: String, CaseIterable {
|
||||
case base
|
||||
case hint
|
||||
case user
|
||||
case assistant
|
||||
case spacer
|
||||
}
|
||||
|
||||
let cell: Cell
|
||||
let viewModel: any ViewModel
|
||||
|
||||
typealias UserObject = any(Identifiable & Hashable)
|
||||
let object: UserObject?
|
||||
|
||||
init(id: AnyHashable, cell: Cell, viewModel: any ViewModel, object: UserObject?) {
|
||||
assert(cell != .base)
|
||||
self.id = id
|
||||
self.cell = cell
|
||||
self.viewModel = viewModel
|
||||
self.object = object
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.Element.Cell {
|
||||
var cellClass: MessageListView.BaseCell.Type {
|
||||
switch self {
|
||||
case .base:
|
||||
MessageListView.BaseCell.self
|
||||
case .hint:
|
||||
MessageListView.HintCell.self
|
||||
case .user:
|
||||
MessageListView.UserCell.self
|
||||
case .assistant:
|
||||
MessageListView.AssistantCell.self
|
||||
case .spacer:
|
||||
MessageListView.SpacerCell.self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.Element {
|
||||
protocol ViewModel {
|
||||
func contentIdentifier(hasher: inout Hasher)
|
||||
}
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
//
|
||||
// MessageListView+Delegate.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/6.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension MessageListView: UITableViewDelegate, UITableViewDataSource {
|
||||
func item(forIndexPath indexPath: IndexPath) -> Element? {
|
||||
guard indexPath.row < elements.count else {
|
||||
return nil
|
||||
}
|
||||
guard indexPath.row >= 0 else {
|
||||
return nil
|
||||
}
|
||||
return elements.values[indexPath.row]
|
||||
}
|
||||
|
||||
func numberOfSections(in _: UITableView) -> Int {
|
||||
1
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
||||
elements.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard let item = item(forIndexPath: indexPath) else {
|
||||
assertionFailure()
|
||||
return UITableViewCell()
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: item.cell.rawValue, for: indexPath)
|
||||
if let cell = cell as? BaseCell {
|
||||
cell.layoutEngine = layoutEngine
|
||||
cell.registerViewModel(element: item)
|
||||
}
|
||||
cell.backgroundColor = .clear
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
guard let item = item(forIndexPath: indexPath) else {
|
||||
return 0
|
||||
}
|
||||
if let height = layoutEngine.height(forElement: item) {
|
||||
heightKeeper[item.id] = height
|
||||
return height
|
||||
}
|
||||
let ret = layoutEngine.resolveLayoutNow(item).height
|
||||
heightKeeper[item.id] = ret
|
||||
return ret
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
guard let item = item(forIndexPath: indexPath) else {
|
||||
return 0
|
||||
}
|
||||
if let height = layoutEngine.height(forElement: item) {
|
||||
return height
|
||||
}
|
||||
if let height = heightKeeper[item.id] {
|
||||
return height
|
||||
}
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
}
|
||||
-143
@@ -1,143 +0,0 @@
|
||||
//
|
||||
// MessageListView+LayoutEngine.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension MessageListView {
|
||||
class TableLayoutEngine {
|
||||
private let lock = NSLock()
|
||||
struct LayoutCacheBox {
|
||||
var cache: LayoutCache
|
||||
var contentIdentifier: Int
|
||||
}
|
||||
|
||||
private var layoutCache: [Element.ID: LayoutCacheBox] = [:]
|
||||
private(set) var contentWidth: CGFloat = .zero
|
||||
var layoutSession: UUID = .init()
|
||||
|
||||
func setContentWidth(_ width: CGFloat) {
|
||||
accessLayoutCache { _ in
|
||||
contentWidth = width
|
||||
layoutSession = .init()
|
||||
}
|
||||
}
|
||||
|
||||
func createSession() -> UUID {
|
||||
let session = UUID()
|
||||
layoutSession = session
|
||||
return session
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func accessLayoutCache<T>(_ block: (inout [Element.ID: LayoutCacheBox]) -> T) -> T {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return block(&layoutCache)
|
||||
}
|
||||
|
||||
func contentIdentifier(forElement dataElement: Element) -> Int? {
|
||||
accessLayoutCache { pool in
|
||||
guard let box = pool[dataElement.id] else { return nil }
|
||||
return box.contentIdentifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func viewCallingUpdateLayoutEngineWidth() {
|
||||
guard layoutEngine.contentWidth != tableView.bounds.width else { return }
|
||||
layoutEngine.setContentWidth(tableView.bounds.width)
|
||||
reconfigure(enforceReload: false)
|
||||
NSObject.cancelPreviousPerformRequests(
|
||||
withTarget: self,
|
||||
selector: #selector(resolveAllLayoutInBackground),
|
||||
object: nil
|
||||
)
|
||||
perform(#selector(resolveAllLayoutInBackground), with: nil, afterDelay: 0.1)
|
||||
}
|
||||
|
||||
@objc private func resolveAllLayoutInBackground() {
|
||||
let items = Array(elements.values)
|
||||
let date = Date()
|
||||
DispatchQueue.global().async {
|
||||
let session = self.layoutEngine.createSession()
|
||||
for element in items {
|
||||
guard self.layoutEngine.layoutSession == session else { continue }
|
||||
self.layoutEngine.resolveLayoutNow(element)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.reconfigure(enforceReload: true)
|
||||
print("[*] layout engine updated \(items.count) items in \(Date().timeIntervalSince(date)) seconds")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.TableLayoutEngine {
|
||||
protocol LayoutableCell: AnyObject {
|
||||
static func resolveLayout(
|
||||
dataElement: MessageListView.Element,
|
||||
contentWidth: CGFloat
|
||||
) -> LayoutCache
|
||||
}
|
||||
|
||||
protocol LayoutCache: AnyObject {
|
||||
var width: CGFloat { get }
|
||||
var height: CGFloat { get }
|
||||
}
|
||||
|
||||
class ZeroLayoutCache: LayoutCache {
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.TableLayoutEngine {
|
||||
@discardableResult
|
||||
func resolveLayoutNow(_ element: MessageListView.Element) -> LayoutCache {
|
||||
var hasher = Hasher()
|
||||
element.viewModel.contentIdentifier(hasher: &hasher)
|
||||
let contentIdentifier = hasher.finalize()
|
||||
|
||||
if let cacheBox = accessLayoutCache({ $0[element.id] }) {
|
||||
if cacheBox.cache.width == contentWidth,
|
||||
cacheBox.contentIdentifier == contentIdentifier
|
||||
{ return cacheBox.cache }
|
||||
}
|
||||
|
||||
let target = element.cell.cellClass.self
|
||||
let cache = target.resolveLayout(dataElement: element, contentWidth: contentWidth)
|
||||
let cacheBox = LayoutCacheBox(cache: cache, contentIdentifier: contentIdentifier)
|
||||
accessLayoutCache { $0[element.id] = cacheBox }
|
||||
return cache
|
||||
}
|
||||
|
||||
func requestLayoutCacheFromCell(
|
||||
forElement dataElement: MessageListView.Element,
|
||||
atWidth width: CGFloat
|
||||
) -> LayoutCache {
|
||||
let cache = accessLayoutCache { pool -> LayoutCache? in
|
||||
guard let box = pool[dataElement.id] else { return nil }
|
||||
guard box.contentIdentifier == dataElement.object?.hashValue else { return nil }
|
||||
guard box.cache.width == contentWidth else { return nil }
|
||||
guard box.cache.width == width else { return nil }
|
||||
return box.cache
|
||||
}
|
||||
if let cache { return cache }
|
||||
return resolveLayoutNow(dataElement)
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.TableLayoutEngine {
|
||||
func height(forElement dataElement: MessageListView.Element) -> CGFloat? {
|
||||
accessLayoutCache { pool in
|
||||
guard let box = pool[dataElement.id] else { return nil }
|
||||
guard box.contentIdentifier == dataElement.object?.hashValue else { return nil }
|
||||
guard box.cache.width == contentWidth else { return nil }
|
||||
return box.cache.height
|
||||
}
|
||||
}
|
||||
}
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
//
|
||||
// MessageListView+Update.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import OrderedCollections
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
func setupPublishers(dataPublisher: AnyPublisher<[Element], Never>) {
|
||||
// process input from data source where we transform those to view model
|
||||
let publisher = dataPublisher
|
||||
.map { input -> [Element] in input + [Element(
|
||||
id: "spacer",
|
||||
cell: .spacer,
|
||||
viewModel: MessageListView.SpacerCell.ViewModel(height: 32),
|
||||
object: nil
|
||||
)] }
|
||||
.map { output in
|
||||
OrderedDictionary<Element.ID, Element>(
|
||||
uniqueKeysWithValues: output.map { ($0.id, $0) }
|
||||
)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
// after so, limit the refresh rate so we can handle them better
|
||||
let updateQueue = DispatchQueue(label: "affine.message-list-update-queue", qos: .userInteractive)
|
||||
let inQueuePublisher = publisher
|
||||
.throttle(for: .seconds(1 / 5), scheduler: updateQueue, latest: true)
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
// finally before sending to display, call layout engine to process those items
|
||||
inQueuePublisher
|
||||
.sink { [weak self] output in self?.prepare(forNewElements: output) }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func prepare(forNewElements elements: Elements) {
|
||||
print("[*] received \(elements.count) for update at \(Date())")
|
||||
elementUpdateProcessLock.lock()
|
||||
distributedPendingUpdateElements = elements
|
||||
elementUpdateProcessLock.unlock()
|
||||
performSelector(onMainThread: #selector(elementsUpdateExecute), with: nil, waitUntilDone: false)
|
||||
}
|
||||
|
||||
private func pickupElementsPair() -> (oldValue: Elements, newValue: Elements)? {
|
||||
#if DEBUG // just make sure assert is not called in release mode
|
||||
assert(!elementUpdateProcessLock.try(), "should not call this method without lock")
|
||||
#endif
|
||||
guard let distributedPendingUpdateElements else { return nil }
|
||||
|
||||
let oldValue = elements
|
||||
elements = distributedPendingUpdateElements
|
||||
self.distributedPendingUpdateElements = nil
|
||||
print("[*] pikup is sending \(elements.count) for update at \(Date())")
|
||||
return (oldValue, distributedPendingUpdateElements)
|
||||
}
|
||||
|
||||
@objc private func elementsUpdateExecute() {
|
||||
assert(Thread.isMainThread)
|
||||
elementUpdateProcessLock.lock()
|
||||
defer { elementUpdateProcessLock.unlock() }
|
||||
let pickup = pickupElementsPair()
|
||||
guard let (oldValue, newValue) = pickup else { return }
|
||||
guard window != nil else { return }
|
||||
for value in heightKeeper.keys where !newValue.keys.contains(value) {
|
||||
heightKeeper.removeValue(forKey: value)
|
||||
}
|
||||
|
||||
let shouldRealodTableView = newValue.count != oldValue.count
|
||||
let contentOffset = tableView.contentOffset
|
||||
UIView.performWithoutAnimation {
|
||||
self.reconfigure(enforceReload: shouldRealodTableView)
|
||||
self.tableView.layoutIfNeeded()
|
||||
}
|
||||
tableView.contentOffset = contentOffset
|
||||
|
||||
if scrollToBottomOnNextUpdate {
|
||||
scrollToBottomOnNextUpdate = false
|
||||
scrollToBottom(useTableViewAnimation: false)
|
||||
}
|
||||
}
|
||||
|
||||
func reconfigure(enforceReload: Bool) {
|
||||
if enforceReload || tableView(tableView, numberOfRowsInSection: 0) != elements.count {
|
||||
tableView.reloadData()
|
||||
return
|
||||
}
|
||||
var requiresReload = [IndexPath]()
|
||||
for indexPath in tableView.indexPathsForVisibleRows ?? [] {
|
||||
guard let item = item(forIndexPath: indexPath) else { continue }
|
||||
guard let cell = tableView.cellForRow(at: indexPath) as? BaseCell else { continue }
|
||||
guard type(of: cell) == item.cell.cellClass else {
|
||||
requiresReload.append(indexPath)
|
||||
continue
|
||||
}
|
||||
layoutEngine.resolveLayoutNow(item)
|
||||
cell.registerViewModel(element: item)
|
||||
}
|
||||
tableView.beginUpdates()
|
||||
tableView.reloadRows(at: requiresReload, with: .none)
|
||||
tableView.endUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView {
|
||||
func scrollToBottom(useTableViewAnimation: Bool = false) {
|
||||
guard elements.count > 0 else { return }
|
||||
guard tableView.contentSize.height > tableView.frame.height else { return }
|
||||
let targetIndexPath = IndexPath(row: elements.count - 1, section: 0)
|
||||
let cellRect = tableView.rectForRow(at: targetIndexPath)
|
||||
if tableView.contentOffset.y + tableView.frame.height >= cellRect.origin.y + cellRect.height { return }
|
||||
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.8) {
|
||||
self.tableView.scrollToRow(
|
||||
at: targetIndexPath,
|
||||
at: .bottom,
|
||||
animated: useTableViewAnimation
|
||||
)
|
||||
self.tableView.layoutIfNeeded()
|
||||
}
|
||||
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(finishAutomaticScroll), object: nil)
|
||||
isAutomaticScrollAnimating = true
|
||||
perform(#selector(finishAutomaticScroll), with: nil, afterDelay: 0.5)
|
||||
}
|
||||
|
||||
// func scrollLastCellToTop(useTableViewAnimation: Bool = false) {
|
||||
// guard elements.count > 1 else { return }
|
||||
// guard tableView.contentSize.height > tableView.frame.height else { return }
|
||||
// UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.8) {
|
||||
// self.tableView.scrollToRow(
|
||||
// at: IndexPath(row: self.elements.count - 1, section: 0),
|
||||
// at: .top,
|
||||
// animated: useTableViewAnimation
|
||||
// )
|
||||
// }
|
||||
// NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(finishAutomaticScroll), object: nil)
|
||||
// isAutomaticScrollAnimating = true
|
||||
// perform(#selector(finishAutomaticScroll), with: nil, afterDelay: 0.5)
|
||||
// }
|
||||
|
||||
@objc private func finishAutomaticScroll() {
|
||||
isAutomaticScrollAnimating = false
|
||||
}
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
//
|
||||
// MessageListView.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import OrderedCollections
|
||||
import UIKit
|
||||
|
||||
class MessageListView: UIView {
|
||||
typealias ElementPublisher = AnyPublisher<[Element], Never>
|
||||
typealias Elements = OrderedDictionary<Element.ID, Element>
|
||||
var elements: Elements = .init()
|
||||
|
||||
var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
let tableView: UITableView = .init(frame: .zero, style: .plain)
|
||||
|
||||
let layoutEngine = TableLayoutEngine()
|
||||
var heightKeeper: [Element.ID: CGFloat] = [:]
|
||||
let elementUpdateProcessLock = NSLock()
|
||||
var distributedPendingUpdateElements: Elements? = nil
|
||||
var isAutomaticScrollAnimating: Bool = false
|
||||
var scrollToBottomOnNextUpdate = false
|
||||
|
||||
let footerView = UIView(frame: .init(x: 0, y: 0, width: 0, height: 200))
|
||||
|
||||
init(dataPublisher: AnyPublisher<[Element], Never>) {
|
||||
super.init(frame: .zero)
|
||||
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
tableView.allowsSelection = false
|
||||
tableView.allowsMultipleSelection = false
|
||||
tableView.allowsFocus = false
|
||||
tableView.selectionFollowsFocus = true
|
||||
tableView.separatorColor = .clear
|
||||
tableView.backgroundColor = .clear
|
||||
for cellIdentifier in Element.Cell.allCases {
|
||||
tableView.register(cellIdentifier.cellClass, forCellReuseIdentifier: cellIdentifier.rawValue)
|
||||
}
|
||||
addSubview(tableView)
|
||||
|
||||
tableView.tableFooterView = footerView
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
tableView.topAnchor.constraint(equalTo: topAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
|
||||
setupPublishers(dataPublisher: dataPublisher)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancellables.forEach { $0.cancel() }
|
||||
cancellables.removeAll()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
viewCallingUpdateLayoutEngineWidth()
|
||||
}
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
//
|
||||
// EphemeralAction.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension IntelligentsEphemeralActionController {
|
||||
enum EphemeralAction {
|
||||
public enum Language: String, CaseIterable {
|
||||
case langEnglish = "English"
|
||||
case langSpanish = "Spanish"
|
||||
case langGerman = "German"
|
||||
case langFrench = "French"
|
||||
case langItalian = "Italian"
|
||||
case langSimplifiedChinese = "Simplified Chinese"
|
||||
case langTraditionalChinese = "Traditional Chinese"
|
||||
case langJapanese = "Japanese"
|
||||
case langRussian = "Russian"
|
||||
case langKorean = "Korean"
|
||||
}
|
||||
|
||||
case translate(to: Language)
|
||||
case summarize
|
||||
}
|
||||
}
|
||||
|
||||
extension IntelligentsEphemeralActionController.EphemeralAction {
|
||||
var title: String {
|
||||
switch self {
|
||||
case let .translate(to):
|
||||
String(format: NSLocalizedString("Translate to %@", comment: ""), to.rawValue)
|
||||
case .summarize:
|
||||
NSLocalizedString("Summarize", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
var prompt: Prompt {
|
||||
switch self {
|
||||
case .translate:
|
||||
.general_Translate_to
|
||||
case .summarize:
|
||||
.general_Summary
|
||||
}
|
||||
}
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
//
|
||||
// ImageRotatedPreview.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/8.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class RotatedImagePreview: UIView {
|
||||
let imageView = UIImageView()
|
||||
let rotationDegree: CGFloat = 5
|
||||
|
||||
public init() {
|
||||
super.init(frame: .zero)
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.layer.cornerRadius = 16
|
||||
imageView.clipsToBounds = true
|
||||
addSubview(imageView)
|
||||
|
||||
clipsToBounds = false
|
||||
|
||||
heightAnchor.constraint(equalToConstant: 300).isActive = true
|
||||
imageView.transform = CGAffineTransform(rotationAngle: rotationDegree * CGFloat.pi / 180)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
public required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
public func configure(previewImage: UIImage) {
|
||||
imageView.image = previewImage
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override public func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
guard let image = imageView.image else {
|
||||
imageView.frame = .zero
|
||||
return
|
||||
}
|
||||
|
||||
let viewHeight = bounds.height // limiter
|
||||
guard bounds.height > 0 else { return }
|
||||
|
||||
// fit in side
|
||||
let imageAspectRatio = image.size.width / image.size.height
|
||||
let imageHeight = viewHeight
|
||||
let imageWidth = imageHeight * imageAspectRatio
|
||||
|
||||
imageView.frame = CGRect(
|
||||
x: (bounds.width - imageWidth) / 2,
|
||||
y: (bounds.height - imageHeight) / 2,
|
||||
width: imageWidth,
|
||||
height: imageHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
-143
@@ -1,143 +0,0 @@
|
||||
//
|
||||
// IntelligentsEphemeralActionController+API.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/15.
|
||||
//
|
||||
|
||||
import AffineGraphQL
|
||||
import Foundation
|
||||
import LDSwiftEventSource
|
||||
|
||||
extension IntelligentsEphemeralActionController {
|
||||
func beginAction() {
|
||||
print("[*] begin ephemeral action for did \(documentID) wid \(workspaceID)")
|
||||
chatTask?.stop()
|
||||
chatTask = nil
|
||||
copilotDocumentStorage = ""
|
||||
sessionID = ""
|
||||
messageID = ""
|
||||
chat_createSession(
|
||||
documentIdentifier: documentID,
|
||||
workspaceIdentifier: workspaceID
|
||||
) { session in
|
||||
self.sessionID = session
|
||||
self.beginThisRound()
|
||||
} onFailure: { error in
|
||||
self.presentError(error) {
|
||||
self.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func chat_createSession(
|
||||
documentIdentifier: String,
|
||||
workspaceIdentifier: String,
|
||||
onSuccess: @escaping (String) -> Void,
|
||||
onFailure: @escaping (Error) -> Void
|
||||
) {
|
||||
if documentIdentifier.isEmpty || workspaceIdentifier.isEmpty {
|
||||
onFailure(UnableTo.identifyDocumentOrWorkspace)
|
||||
}
|
||||
Intelligents.qlClient.perform(
|
||||
mutation: CreateCopilotSessionMutation(options: .init(
|
||||
docId: documentIdentifier,
|
||||
promptName: action.prompt.rawValue,
|
||||
workspaceId: workspaceIdentifier
|
||||
)),
|
||||
queue: .global()
|
||||
) { result in
|
||||
switch result {
|
||||
case let .success(value):
|
||||
if let session = value.data?.createCopilotSession, !session.isEmpty {
|
||||
DispatchQueue.main.async { onSuccess(session) }
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
onFailure(UnableTo.createSession)
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
DispatchQueue.main.async { onFailure(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func beginThisRound() {
|
||||
let parms: [String: AnyHashable] = switch action {
|
||||
case let .translate(lang):
|
||||
["language": lang.rawValue]
|
||||
case .summarize:
|
||||
[:]
|
||||
}
|
||||
let json = try! CustomJSON(_jsonValue: parms)
|
||||
Intelligents.qlClient.perform(
|
||||
mutation: CreateCopilotMessageMutation(options: .init(
|
||||
content: .init(stringLiteral: "\(documentContent)"),
|
||||
params: .some(json),
|
||||
sessionId: sessionID
|
||||
)),
|
||||
queue: .global()
|
||||
) { result in
|
||||
switch result {
|
||||
case let .success(value):
|
||||
if let messageID = value.data?.createCopilotMessage {
|
||||
self.messageID = messageID
|
||||
self.chat_processWithMessageID(sessionID: self.sessionID, messageID: messageID)
|
||||
} else {
|
||||
self.presentError(UnableTo.createMessage) {
|
||||
self.close()
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
self.presentError(error) {
|
||||
self.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
presentError(UnableTo.createMessage)
|
||||
return
|
||||
}
|
||||
|
||||
let eventHandler = BlockEventHandler()
|
||||
eventHandler.onOpenedBlock = {
|
||||
print("[*] chat opened")
|
||||
}
|
||||
eventHandler.onErrorBlock = { error in
|
||||
self.presentError(error) { self.close() }
|
||||
}
|
||||
eventHandler.onMessageBlock = { _, message in
|
||||
self.chat_onEvent(message.data)
|
||||
}
|
||||
eventHandler.onClosedBlock = {
|
||||
self.chatTask?.stop()
|
||||
self.chatTask = nil
|
||||
}
|
||||
let eventSource = EventSource(config: .init(handler: eventHandler, url: url))
|
||||
eventSource.start()
|
||||
chatTask = eventSource
|
||||
}
|
||||
|
||||
func chat_onEvent(_ data: String) {
|
||||
if Thread.isMainThread {
|
||||
copilotDocumentStorage += data
|
||||
} else {
|
||||
DispatchQueue.main.asyncAndWait {
|
||||
self.copilotDocumentStorage += data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
//
|
||||
// IntelligentsEphemeralActionController+ActionBar.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/15.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsEphemeralActionController {
|
||||
class ActionBar: UIView {
|
||||
let retryButton = DarkActionButton()
|
||||
let continueToChat = DarkActionButton()
|
||||
let createNewDoc = DarkActionButton()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
defer { removeEveryAutoResizingMasks() }
|
||||
|
||||
let contentSpacing: CGFloat = 16
|
||||
let buttonGroupHeight: CGFloat = 55
|
||||
|
||||
let firstButtonSectionGroup = UIView()
|
||||
addSubview(firstButtonSectionGroup)
|
||||
[
|
||||
firstButtonSectionGroup.topAnchor.constraint(equalTo: topAnchor, constant: contentSpacing),
|
||||
firstButtonSectionGroup.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
firstButtonSectionGroup.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
firstButtonSectionGroup.heightAnchor.constraint(equalToConstant: buttonGroupHeight),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
retryButton.title = NSLocalizedString("Retry", comment: "")
|
||||
retryButton.iconSystemName = "arrow.clockwise"
|
||||
continueToChat.title = NSLocalizedString("Continue to Chat", comment: "")
|
||||
continueToChat.iconSystemName = "paperplane"
|
||||
firstButtonSectionGroup.addSubview(retryButton)
|
||||
firstButtonSectionGroup.addSubview(continueToChat)
|
||||
[
|
||||
retryButton.topAnchor.constraint(equalTo: firstButtonSectionGroup.topAnchor),
|
||||
retryButton.leadingAnchor.constraint(equalTo: firstButtonSectionGroup.leadingAnchor),
|
||||
retryButton.bottomAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor),
|
||||
|
||||
continueToChat.topAnchor.constraint(equalTo: firstButtonSectionGroup.topAnchor),
|
||||
continueToChat.trailingAnchor.constraint(equalTo: firstButtonSectionGroup.trailingAnchor),
|
||||
continueToChat.bottomAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor),
|
||||
|
||||
retryButton.widthAnchor.constraint(equalTo: continueToChat.widthAnchor),
|
||||
retryButton.trailingAnchor.constraint(equalTo: continueToChat.leadingAnchor, constant: -contentSpacing),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
let secondButtonSectionGroup = UIView()
|
||||
addSubview(secondButtonSectionGroup)
|
||||
[
|
||||
secondButtonSectionGroup.topAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor, constant: contentSpacing),
|
||||
secondButtonSectionGroup.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
secondButtonSectionGroup.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
secondButtonSectionGroup.heightAnchor.constraint(equalToConstant: buttonGroupHeight),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
secondButtonSectionGroup.addSubview(createNewDoc)
|
||||
createNewDoc.title = NSLocalizedString("Create New Doc", comment: "")
|
||||
createNewDoc.iconSystemName = "doc.badge.plus"
|
||||
[
|
||||
createNewDoc.topAnchor.constraint(equalTo: secondButtonSectionGroup.topAnchor),
|
||||
createNewDoc.leadingAnchor.constraint(equalTo: secondButtonSectionGroup.leadingAnchor),
|
||||
createNewDoc.bottomAnchor.constraint(equalTo: secondButtonSectionGroup.bottomAnchor),
|
||||
createNewDoc.trailingAnchor.constraint(equalTo: secondButtonSectionGroup.trailingAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
[
|
||||
secondButtonSectionGroup.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
//
|
||||
// IntelligentsEphemeralActionController+Header.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/8.
|
||||
//
|
||||
|
||||
//
|
||||
// IntelligentsChatController+Header.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsEphemeralActionController {
|
||||
class Header: UIView {
|
||||
static let height: CGFloat = 44
|
||||
|
||||
let contentView = UIView()
|
||||
let titleLabel = UILabel()
|
||||
let dropMenu = UIButton()
|
||||
let backButton = UIButton()
|
||||
let rightBarItemsStack = UIStackView()
|
||||
let moreMenu = UIButton()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@objc func navigateActionBack() {
|
||||
parentViewController?.dismissInContext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension IntelligentsEphemeralActionController.Header {
|
||||
func setupLayout() {
|
||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(contentView)
|
||||
[
|
||||
contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
contentView.heightAnchor.constraint(equalToConstant: Self.height),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
titleLabel.textColor = .label
|
||||
titleLabel.font = .systemFont(
|
||||
ofSize: UIFont.labelFontSize,
|
||||
weight: .semibold
|
||||
)
|
||||
|
||||
backButton.setImage(
|
||||
UIImage(systemName: "chevron.left"),
|
||||
for: .normal
|
||||
)
|
||||
backButton.tintColor = .accent
|
||||
backButton.addTarget(self, action: #selector(navigateActionBack), for: .touchUpInside)
|
||||
|
||||
dropMenu.setImage(
|
||||
.init(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
dropMenu.tintColor = .gray.withAlphaComponent(0.5)
|
||||
|
||||
contentView.addSubview(titleLabel)
|
||||
contentView.addSubview(backButton)
|
||||
contentView.addSubview(dropMenu)
|
||||
contentView.addSubview(rightBarItemsStack)
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
backButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
dropMenu.translatesAutoresizingMaskIntoConstraints = false
|
||||
rightBarItemsStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
rightBarItemsStack.axis = .horizontal
|
||||
rightBarItemsStack.spacing = 10
|
||||
rightBarItemsStack.alignment = .center
|
||||
rightBarItemsStack.distribution = .equalSpacing
|
||||
|
||||
[
|
||||
backButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
backButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
|
||||
backButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
backButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
|
||||
rightBarItemsStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
rightBarItemsStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
|
||||
rightBarItemsStack.heightAnchor.constraint(equalToConstant: 44),
|
||||
|
||||
titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
||||
titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: backButton.trailingAnchor, constant: 10),
|
||||
|
||||
dropMenu.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
dropMenu.widthAnchor.constraint(equalToConstant: 44),
|
||||
dropMenu.heightAnchor.constraint(equalToConstant: 44),
|
||||
titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: dropMenu.leadingAnchor, constant: -10),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
rightBarItemsStack.addArrangedSubview(moreMenu)
|
||||
moreMenu.setImage(
|
||||
.init(systemName: "ellipsis.circle"),
|
||||
for: .normal
|
||||
)
|
||||
moreMenu.tintColor = .accent
|
||||
}
|
||||
}
|
||||
-297
@@ -1,297 +0,0 @@
|
||||
//
|
||||
// IntelligentsEphemeralActionController.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/8.
|
||||
//
|
||||
|
||||
import LDSwiftEventSource
|
||||
import MarkdownParser
|
||||
import MarkdownView
|
||||
import UIKit
|
||||
|
||||
public class IntelligentsEphemeralActionController: UIViewController {
|
||||
let action: EphemeralAction
|
||||
let scrollView = UIScrollView()
|
||||
let stackView = UIStackView()
|
||||
|
||||
let header = Header()
|
||||
let preview = RotatedImagePreview()
|
||||
|
||||
let markdownView = MarkdownView()
|
||||
let indicator = UIActivityIndicatorView(style: .large)
|
||||
var responseContainer: UIView = .init()
|
||||
var responseHeightAnchor: NSLayoutConstraint?
|
||||
|
||||
let actionBar = ActionBar()
|
||||
|
||||
public var documentID: String = ""
|
||||
public var workspaceID: String = ""
|
||||
public var documentContent: String = ""
|
||||
public internal(set) var sessionID: String = "" {
|
||||
didSet { print(#fileID, #function, sessionID) }
|
||||
}
|
||||
|
||||
public internal(set) var messageID: String = "" {
|
||||
didSet { print(#fileID, #function, messageID) }
|
||||
}
|
||||
|
||||
var chatTask: EventSource?
|
||||
var copilotDocumentStorage: String = "" {
|
||||
didSet {
|
||||
updateDocumentPresentationView()
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
public init(action: EphemeralAction) {
|
||||
self.action = action
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
title = action.title
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
overrideUserInterfaceStyle = .dark
|
||||
hideKeyboardWhenTappedAround()
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
header.titleLabel.text = title
|
||||
header.dropMenu.isHidden = true
|
||||
header.moreMenu.isHidden = true
|
||||
view.addSubview(header)
|
||||
header.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
header.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
header.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
header.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
header.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
view.addSubview(actionBar)
|
||||
actionBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
actionBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8),
|
||||
actionBar.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
|
||||
actionBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
scrollView.clipsToBounds = true
|
||||
scrollView.alwaysBounceVertical = true
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(scrollView)
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
scrollView.topAnchor.constraint(equalTo: header.bottomAnchor),
|
||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: actionBar.topAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
let contentView = UIView()
|
||||
scrollView.addSubview(contentView)
|
||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
|
||||
contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
|
||||
contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
|
||||
contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
|
||||
contentView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.heightAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
contentView.addSubview(stackView)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = 16
|
||||
stackView.alignment = .fill
|
||||
stackView.distribution = .fill
|
||||
contentView.addSubview(stackView)
|
||||
|
||||
let stackViewInset: CGFloat = 8
|
||||
[
|
||||
stackView.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: stackViewInset),
|
||||
stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: stackViewInset),
|
||||
stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -stackViewInset),
|
||||
stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -stackViewInset),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
setupContentViews()
|
||||
|
||||
actionBar.retryButton.action = { [weak self] in
|
||||
self?.beginAction()
|
||||
}
|
||||
actionBar.continueToChat.action = { [weak self] in
|
||||
guard let self else { return }
|
||||
continueToChat()
|
||||
}
|
||||
}
|
||||
|
||||
func setupContentViews() {
|
||||
defer { stackView.addArrangedSubview(UIView()) }
|
||||
|
||||
preview.layer.cornerRadius = 16
|
||||
preview.clipsToBounds = true
|
||||
preview.contentMode = .scaleAspectFill
|
||||
preview.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addArrangedSubview(preview)
|
||||
|
||||
let headerGroup = UIView()
|
||||
headerGroup.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addArrangedSubview(headerGroup)
|
||||
|
||||
let headerLabel = UILabel()
|
||||
let headerIcon = UIImageView()
|
||||
|
||||
headerLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
headerLabel.text = NSLocalizedString("AFFiNE AI", comment: "")
|
||||
headerLabel.font = .preferredFont(for: .title3, weight: .bold)
|
||||
headerLabel.textColor = .white
|
||||
headerLabel.textAlignment = .left
|
||||
headerIcon.translatesAutoresizingMaskIntoConstraints = false
|
||||
headerIcon.image = .init(named: "spark", in: .module, with: nil)
|
||||
headerIcon.contentMode = .scaleAspectFit
|
||||
headerIcon.tintColor = .accent
|
||||
headerGroup.addSubview(headerLabel)
|
||||
headerGroup.addSubview(headerIcon)
|
||||
[
|
||||
headerIcon.leadingAnchor.constraint(equalTo: headerGroup.leadingAnchor),
|
||||
headerIcon.centerYAnchor.constraint(equalTo: headerGroup.centerYAnchor),
|
||||
headerIcon.widthAnchor.constraint(equalToConstant: 32),
|
||||
|
||||
headerLabel.leadingAnchor.constraint(equalTo: headerIcon.trailingAnchor, constant: 16),
|
||||
headerLabel.topAnchor.constraint(equalTo: headerGroup.topAnchor),
|
||||
headerLabel.bottomAnchor.constraint(equalTo: headerGroup.bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
responseContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
responseContainer.setContentHuggingPriority(.required, for: .vertical)
|
||||
responseContainer.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
responseContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: 350).isActive = true
|
||||
stackView.addArrangedSubview(responseContainer)
|
||||
|
||||
responseContainer.addSubview(markdownView)
|
||||
|
||||
markdownView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
markdownView.topAnchor.constraint(equalTo: responseContainer.topAnchor),
|
||||
markdownView.leadingAnchor.constraint(equalTo: responseContainer.leadingAnchor),
|
||||
markdownView.trailingAnchor.constraint(equalTo: responseContainer.trailingAnchor),
|
||||
markdownView.bottomAnchor.constraint(equalTo: responseContainer.bottomAnchor),
|
||||
].forEach {
|
||||
$0.isActive = true
|
||||
}
|
||||
|
||||
indicator.startAnimating()
|
||||
indicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
responseContainer.addSubview(indicator)
|
||||
[
|
||||
indicator.centerXAnchor.constraint(equalTo: responseContainer.centerXAnchor),
|
||||
indicator.centerYAnchor.constraint(equalTo: responseContainer.centerYAnchor),
|
||||
indicator.heightAnchor.constraint(equalToConstant: 200),
|
||||
].forEach {
|
||||
$0.isActive = true
|
||||
}
|
||||
|
||||
updateDocumentPresentationView()
|
||||
}
|
||||
|
||||
public func configure(previewImage: UIImage) {
|
||||
preview.configure(previewImage: previewImage)
|
||||
}
|
||||
|
||||
private var isFirstAppear: Bool = true
|
||||
override public func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
guard isFirstAppear else { return }
|
||||
isFirstAppear = false
|
||||
onFirstAppear()
|
||||
}
|
||||
|
||||
func onFirstAppear() {
|
||||
beginAction()
|
||||
}
|
||||
|
||||
func close() {
|
||||
if let navigationController {
|
||||
navigationController.popViewController(animated: true)
|
||||
} else {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var previousLayoutWidth: CGFloat = 0
|
||||
|
||||
override public func viewWillLayoutSubviews() {
|
||||
super.viewWillLayoutSubviews()
|
||||
|
||||
if previousLayoutWidth != view.bounds.width {
|
||||
previousLayoutWidth = view.bounds.width
|
||||
updateDocumentPresentationView()
|
||||
}
|
||||
}
|
||||
|
||||
func updateDocumentPresentationView() {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
responseHeightAnchor?.isActive = false
|
||||
responseHeightAnchor = nil
|
||||
|
||||
if copilotDocumentStorage.isEmpty {
|
||||
indicator.isHidden = false
|
||||
indicator.startAnimating()
|
||||
responseHeightAnchor = responseContainer.heightAnchor.constraint(equalToConstant: 200)
|
||||
responseHeightAnchor?.isActive = true
|
||||
markdownView.updateContentViews([])
|
||||
return
|
||||
}
|
||||
|
||||
indicator.isHidden = true
|
||||
indicator.stopAnimating()
|
||||
|
||||
let document = MarkdownParser().feed(copilotDocumentStorage)
|
||||
var height: CGFloat = 0
|
||||
let manifests = document.map {
|
||||
let ret = $0.manifest(theme: .default)
|
||||
ret.setLayoutWidth(responseContainer.bounds.width)
|
||||
ret.layoutIfNeeded()
|
||||
height += ret.size.height
|
||||
height += Theme.default.spacings.final
|
||||
return ret
|
||||
}
|
||||
markdownView.updateContentViews(manifests)
|
||||
if height > 0 { height -= Theme.default.spacings.final }
|
||||
responseHeightAnchor = responseContainer.heightAnchor.constraint(equalToConstant: height)
|
||||
responseHeightAnchor?.isActive = true
|
||||
}
|
||||
|
||||
func scrollToBottom() {
|
||||
guard !copilotDocumentStorage.isEmpty else { return }
|
||||
let bottomOffset = CGPoint(
|
||||
x: 0,
|
||||
y: max(0, scrollView.contentSize.height - scrollView.bounds.size.height)
|
||||
)
|
||||
UIView.animate(
|
||||
withDuration: 0.5,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 1.0,
|
||||
initialSpringVelocity: 0.8
|
||||
) { self.scrollView.setContentOffset(bottomOffset, animated: false) }
|
||||
}
|
||||
}
|
||||
|
||||
extension IntelligentsEphemeralActionController {
|
||||
func continueToChat() {
|
||||
let chatController = IntelligentsChatController()
|
||||
chatController.metadata[.documentID] = documentID
|
||||
chatController.metadata[.workspaceID] = workspaceID
|
||||
chatController.metadata[.content] = documentContent
|
||||
navigationController?.pushViewController(chatController, animated: true)
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
//
|
||||
// IntelligentsFocusApertureView+Capture.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/21.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsFocusApertureView {
|
||||
func captureImageBuffer(_ targetContentView: UIView) {
|
||||
let contentSize = targetContentView.frame.size
|
||||
|
||||
let renderer = UIGraphicsImageRenderer(size: contentSize)
|
||||
let image = renderer.image { _ in
|
||||
let drawRect = CGRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: contentSize.width,
|
||||
height: contentSize.height
|
||||
)
|
||||
|
||||
targetContentView.drawHierarchy(
|
||||
in: drawRect,
|
||||
afterScreenUpdates: true
|
||||
)
|
||||
}
|
||||
capturedImage = image
|
||||
}
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
//
|
||||
// IntelligentsFocusApertureView+Delegate.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum IntelligentsFocusApertureViewActionType: String {
|
||||
case translateTo
|
||||
case summary
|
||||
case chatWithAI
|
||||
case dismiss
|
||||
}
|
||||
|
||||
public protocol IntelligentsFocusApertureViewDelegate: AnyObject {
|
||||
func focusApertureRequestAction(
|
||||
from: IntelligentsFocusApertureView,
|
||||
actionType: IntelligentsFocusApertureViewActionType
|
||||
)
|
||||
}
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
//
|
||||
// IntelligentsFocusApertureView+Layout.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/21.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsFocusApertureView {
|
||||
func prepareFrameLayout() {
|
||||
guard let viewController = targetViewController,
|
||||
let view = viewController.view
|
||||
else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
let safeLayout = viewController.view.safeAreaLayoutGuide
|
||||
|
||||
frameConstraints = [
|
||||
// use safe area to layout content views
|
||||
leadingAnchor.constraint(equalTo: safeLayout.leadingAnchor),
|
||||
trailingAnchor.constraint(equalTo: safeLayout.trailingAnchor),
|
||||
topAnchor.constraint(equalTo: safeLayout.topAnchor),
|
||||
bottomAnchor.constraint(equalTo: safeLayout.bottomAnchor),
|
||||
// cover all safe area so use constraints over view
|
||||
backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
backgroundView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
]
|
||||
}
|
||||
|
||||
func prepareContentLayouts() {
|
||||
guard let targetView else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
||||
contentBeginConstraints = [
|
||||
snapshotImageView.leftAnchor.constraint(equalTo: targetView.leftAnchor),
|
||||
snapshotImageView.rightAnchor.constraint(equalTo: targetView.rightAnchor),
|
||||
snapshotImageView.topAnchor.constraint(equalTo: targetView.topAnchor),
|
||||
snapshotImageView.bottomAnchor.constraint(equalTo: targetView.bottomAnchor),
|
||||
|
||||
controlButtonsPanel.leftAnchor.constraint(equalTo: leftAnchor),
|
||||
controlButtonsPanel.rightAnchor.constraint(equalTo: rightAnchor),
|
||||
controlButtonsPanel.topAnchor.constraint(equalTo: bottomAnchor),
|
||||
]
|
||||
|
||||
let sharedInset: CGFloat = 32
|
||||
contentFinalConstraints = [
|
||||
snapshotImageView.leftAnchor.constraint(equalTo: leftAnchor, constant: sharedInset),
|
||||
snapshotImageView.rightAnchor.constraint(equalTo: rightAnchor, constant: -sharedInset),
|
||||
snapshotImageView.topAnchor.constraint(equalTo: topAnchor),
|
||||
snapshotImageView.bottomAnchor.constraint(equalTo: controlButtonsPanel.topAnchor, constant: -sharedInset / 2),
|
||||
|
||||
controlButtonsPanel.leftAnchor.constraint(equalTo: leftAnchor, constant: sharedInset),
|
||||
controlButtonsPanel.rightAnchor.constraint(equalTo: rightAnchor, constant: -sharedInset),
|
||||
controlButtonsPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
]
|
||||
}
|
||||
|
||||
enum LayoutType {
|
||||
case begin
|
||||
case complete
|
||||
}
|
||||
|
||||
func activateLayoutForAnimation(_ type: LayoutType) {
|
||||
NSLayoutConstraint.activate(frameConstraints)
|
||||
switch type {
|
||||
case .begin:
|
||||
NSLayoutConstraint.deactivate(contentFinalConstraints)
|
||||
NSLayoutConstraint.activate(contentBeginConstraints)
|
||||
|
||||
snapshotImageView.layer.cornerRadius = 0
|
||||
case .complete:
|
||||
NSLayoutConstraint.deactivate(contentBeginConstraints)
|
||||
NSLayoutConstraint.activate(contentFinalConstraints)
|
||||
|
||||
snapshotImageView.layer.cornerRadius = 32
|
||||
}
|
||||
let effectiveView = superview ?? self
|
||||
effectiveView.setNeedsUpdateConstraints()
|
||||
effectiveView.setNeedsLayout()
|
||||
updateConstraints()
|
||||
layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
//
|
||||
// IntelligentsFocusApertureView+Panel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/21.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsFocusApertureView {
|
||||
class ControlButtonsPanel: UIView {
|
||||
let headerLabel = UILabel()
|
||||
let headerIcon = UIImageView()
|
||||
|
||||
let translateButton = DarkActionButton()
|
||||
let summaryButton = DarkActionButton()
|
||||
let chatWithAIButton = DarkActionButton()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
defer { removeEveryAutoResizingMasks() }
|
||||
|
||||
let contentSpacing: CGFloat = 16
|
||||
let buttonGroupHeight: CGFloat = 55
|
||||
|
||||
let headerGroup = UIView()
|
||||
addSubview(headerGroup)
|
||||
[
|
||||
headerGroup.topAnchor.constraint(equalTo: topAnchor),
|
||||
headerGroup.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
headerGroup.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
headerLabel.text = NSLocalizedString("AFFiNE AI", comment: "") // TODO: FREE TRAIL???
|
||||
// title 3 with bold
|
||||
headerLabel.font = .preferredFont(for: .title3, weight: .bold)
|
||||
headerLabel.textColor = .white
|
||||
headerLabel.textAlignment = .left
|
||||
headerIcon.image = .init(named: "spark", in: .module, with: nil)
|
||||
headerIcon.contentMode = .scaleAspectFit
|
||||
headerIcon.tintColor = .accent
|
||||
headerGroup.addSubview(headerLabel)
|
||||
headerGroup.addSubview(headerIcon)
|
||||
[
|
||||
headerLabel.topAnchor.constraint(equalTo: headerGroup.topAnchor),
|
||||
headerLabel.leadingAnchor.constraint(equalTo: headerGroup.leadingAnchor),
|
||||
headerLabel.bottomAnchor.constraint(equalTo: headerGroup.bottomAnchor),
|
||||
|
||||
headerIcon.topAnchor.constraint(equalTo: headerGroup.topAnchor),
|
||||
headerIcon.trailingAnchor.constraint(equalTo: headerGroup.trailingAnchor),
|
||||
headerIcon.bottomAnchor.constraint(equalTo: headerGroup.bottomAnchor),
|
||||
|
||||
headerIcon.widthAnchor.constraint(equalToConstant: 32),
|
||||
headerIcon.trailingAnchor.constraint(equalTo: headerGroup.trailingAnchor),
|
||||
headerIcon.leadingAnchor.constraint(equalTo: headerLabel.trailingAnchor, constant: contentSpacing),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
let firstButtonSectionGroup = UIView()
|
||||
addSubview(firstButtonSectionGroup)
|
||||
[
|
||||
firstButtonSectionGroup.topAnchor.constraint(equalTo: headerGroup.bottomAnchor, constant: contentSpacing),
|
||||
firstButtonSectionGroup.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
firstButtonSectionGroup.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
firstButtonSectionGroup.heightAnchor.constraint(equalToConstant: buttonGroupHeight),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
translateButton.title = NSLocalizedString("Translate", comment: "")
|
||||
translateButton.iconSystemName = "textformat"
|
||||
summaryButton.title = NSLocalizedString("Summary", comment: "")
|
||||
summaryButton.iconSystemName = "doc.text"
|
||||
firstButtonSectionGroup.addSubview(translateButton)
|
||||
firstButtonSectionGroup.addSubview(summaryButton)
|
||||
[
|
||||
translateButton.topAnchor.constraint(equalTo: firstButtonSectionGroup.topAnchor),
|
||||
translateButton.leadingAnchor.constraint(equalTo: firstButtonSectionGroup.leadingAnchor),
|
||||
translateButton.bottomAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor),
|
||||
|
||||
summaryButton.topAnchor.constraint(equalTo: firstButtonSectionGroup.topAnchor),
|
||||
summaryButton.trailingAnchor.constraint(equalTo: firstButtonSectionGroup.trailingAnchor),
|
||||
summaryButton.bottomAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor),
|
||||
|
||||
translateButton.widthAnchor.constraint(equalTo: summaryButton.widthAnchor),
|
||||
translateButton.trailingAnchor.constraint(equalTo: summaryButton.leadingAnchor, constant: -contentSpacing),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
let secondButtonSectionGroup = UIView()
|
||||
addSubview(secondButtonSectionGroup)
|
||||
[
|
||||
secondButtonSectionGroup.topAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor, constant: contentSpacing),
|
||||
secondButtonSectionGroup.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
secondButtonSectionGroup.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
secondButtonSectionGroup.heightAnchor.constraint(equalToConstant: buttonGroupHeight),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
secondButtonSectionGroup.addSubview(chatWithAIButton)
|
||||
chatWithAIButton.title = NSLocalizedString("Chat with AI", comment: "")
|
||||
chatWithAIButton.iconSystemName = "paperplane"
|
||||
[
|
||||
chatWithAIButton.topAnchor.constraint(equalTo: secondButtonSectionGroup.topAnchor),
|
||||
chatWithAIButton.leadingAnchor.constraint(equalTo: secondButtonSectionGroup.leadingAnchor),
|
||||
chatWithAIButton.bottomAnchor.constraint(equalTo: secondButtonSectionGroup.bottomAnchor),
|
||||
chatWithAIButton.trailingAnchor.constraint(equalTo: secondButtonSectionGroup.trailingAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
[
|
||||
secondButtonSectionGroup.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
//
|
||||
// IntelligentsFocusApertureView.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/21.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class IntelligentsFocusApertureView: UIView {
|
||||
public let backgroundView = UIView()
|
||||
public let snapshotImageView = UIImageView()
|
||||
let controlButtonsPanel = ControlButtonsPanel()
|
||||
|
||||
public var animationDuration: TimeInterval = 0.75
|
||||
|
||||
public internal(set) weak var targetView: UIView?
|
||||
public internal(set) weak var targetViewController: UIViewController?
|
||||
public internal(set) weak var capturedImage: UIImage? {
|
||||
get { snapshotImageView.image }
|
||||
set { snapshotImageView.image = newValue }
|
||||
}
|
||||
|
||||
var frameConstraints: [NSLayoutConstraint] = []
|
||||
var contentBeginConstraints: [NSLayoutConstraint] = []
|
||||
var contentFinalConstraints: [NSLayoutConstraint] = []
|
||||
|
||||
public weak var delegate: (any IntelligentsFocusApertureViewDelegate)?
|
||||
|
||||
public init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
backgroundView.backgroundColor = .black
|
||||
backgroundView.isUserInteractionEnabled = true
|
||||
let tap = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(dismissFocus)
|
||||
)
|
||||
tap.cancelsTouchesInView = true
|
||||
backgroundView.addGestureRecognizer(tap)
|
||||
|
||||
snapshotImageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
snapshotImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
snapshotImageView.layer.contentsGravity = .top
|
||||
snapshotImageView.layer.masksToBounds = true
|
||||
snapshotImageView.contentMode = .scaleAspectFill
|
||||
snapshotImageView.isUserInteractionEnabled = true
|
||||
snapshotImageView.addGestureRecognizer(UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(dismissFocus)
|
||||
))
|
||||
|
||||
addSubview(backgroundView)
|
||||
addSubview(controlButtonsPanel)
|
||||
addSubview(snapshotImageView)
|
||||
bringSubviewToFront(snapshotImageView)
|
||||
|
||||
controlButtonsPanel.translateButton.action = { [weak self] in
|
||||
guard let self else { return }
|
||||
delegate?.focusApertureRequestAction(from: self, actionType: .translateTo)
|
||||
}
|
||||
controlButtonsPanel.summaryButton.action = { [weak self] in
|
||||
guard let self else { return }
|
||||
delegate?.focusApertureRequestAction(from: self, actionType: .summary)
|
||||
}
|
||||
controlButtonsPanel.chatWithAIButton.action = { [weak self] in
|
||||
guard let self else { return }
|
||||
delegate?.focusApertureRequestAction(from: self, actionType: .chatWithAI)
|
||||
}
|
||||
removeEveryAutoResizingMasks()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
public func prepareAnimationWith(
|
||||
capturingTargetContentView targetContentView: UIView,
|
||||
coveringRootViewController viewController: UIViewController
|
||||
) {
|
||||
captureImageBuffer(targetContentView)
|
||||
|
||||
targetView = targetContentView
|
||||
targetViewController = viewController
|
||||
|
||||
viewController.view.addSubview(self)
|
||||
|
||||
prepareFrameLayout()
|
||||
prepareContentLayouts()
|
||||
activateLayoutForAnimation(.begin)
|
||||
}
|
||||
|
||||
public func executeAnimationKickIn(_ completion: @escaping () -> Void = {}) {
|
||||
activateLayoutForAnimation(.begin)
|
||||
isUserInteractionEnabled = false
|
||||
UIView.animate(
|
||||
withDuration: animationDuration,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 1.0,
|
||||
initialSpringVelocity: 0.8
|
||||
) {
|
||||
self.activateLayoutForAnimation(.complete)
|
||||
} completion: { _ in
|
||||
self.isUserInteractionEnabled = true
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
public func executeAnimationDismiss(_ completion: @escaping () -> Void = {}) {
|
||||
activateLayoutForAnimation(.complete)
|
||||
isUserInteractionEnabled = false
|
||||
UIView.animate(
|
||||
withDuration: animationDuration,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 1.0,
|
||||
initialSpringVelocity: 0.8
|
||||
) {
|
||||
self.activateLayoutForAnimation(.begin)
|
||||
} completion: { _ in
|
||||
self.isUserInteractionEnabled = true
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func dismissFocus() {
|
||||
isUserInteractionEnabled = false
|
||||
executeAnimationDismiss {
|
||||
self.removeFromSuperview()
|
||||
self.delegate?.focusApertureRequestAction(from: self, actionType: .dismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
Intelligents
|
||||
|
||||
Created by 秋星桥 on 2024/11/18.
|
||||
|
||||
*/
|
||||
|
||||
"Chat with AI" = "Chat with AI";
|
||||
"AFFiNE AI" = "AFFiNE AI";
|
||||
"Translate" = "Translate";
|
||||
"Summary" = "Summary";
|
||||
"Summarize this article for me..." = "Summarize this article for me...";
|
||||
"System" = "System";
|
||||
"AFFiNE AI" = "AFFiNE AI";
|
||||
"You" = "You";
|
||||
"Error" = "Error";
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
Intelligents
|
||||
|
||||
Created by 秋星桥 on 2024/11/18.
|
||||
|
||||
*/
|
||||
|
||||
"Chat with AI" = "与 AI 聊天";
|
||||
"AFFiNE AI" = "AFFiNE 人工智能";
|
||||
"Translate" = "翻译";
|
||||
"Summary" = "总结";
|
||||
"Summarize this article for me..." = "请为我总结这份文档...";
|
||||
"System" = "系统";
|
||||
"AFFiNE AI" = "AFFiNE AI";
|
||||
"You" = "你";
|
||||
"Error" = "错误";
|
||||
"OK" = "确定";
|
||||
Reference in New Issue
Block a user