chore: basic setup of v2 AI (#12864)
Co-authored-by: Hwang <hwangdev97@gmail.com>
This commit is contained in:
1 parent
5a87d3d9f6
commit
eb5a2ffe05
199 files changed
+3478
-8311
No files matched your search
-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
|
||||
}
|
||||
+1
-27
@@ -5,19 +5,7 @@ import AffineGraphQL
|
||||
import Apollo
|
||||
import Foundation
|
||||
|
||||
public enum Intelligents {
|
||||
public private(set) static var qlClient: ApolloClient = createQlClient()
|
||||
|
||||
public static func setUpstreamEndpoint(_ upstream: String) {
|
||||
guard let url = URL(string: upstream) else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
print("[*] setting up upstream endpoint to \(url.absoluteString)")
|
||||
Constant.affineUpstreamURL = url
|
||||
qlClient = createQlClient()
|
||||
}
|
||||
}
|
||||
public enum Intelligents {}
|
||||
|
||||
private extension Intelligents {
|
||||
private final class URLSessionCookieClient: URLSessionClient {
|
||||
@@ -29,18 +17,4 @@ private extension Intelligents {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func createQlClient() -> ApolloClient {
|
||||
let store = ApolloStore(cache: InMemoryNormalizedCache())
|
||||
let provider = DefaultInterceptorProvider(
|
||||
client: URLSessionCookieClient(),
|
||||
shouldInvalidateClientOnDeinit: true,
|
||||
store: store
|
||||
)
|
||||
let transport = RequestChainNetworkTransport(
|
||||
interceptorProvider: provider,
|
||||
endpointURL: Constant.affineUpstreamURL.appendingPathComponent("graphql")
|
||||
)
|
||||
return .init(networkTransport: transport, store: store)
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
//
|
||||
// BlurTransition.swift
|
||||
// BlurTransition
|
||||
//
|
||||
// Created by 秋星桥 on 6/16/23.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIViewController {
|
||||
func presentWithFullScreenBlurTransition(_ viewController: UIViewController) {
|
||||
viewController.modalPresentationStyle = .custom
|
||||
viewController.transitioningDelegate = BlurTransitioningDelegate.shared
|
||||
present(viewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
class BlurTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
|
||||
static let shared = BlurTransitioningDelegate()
|
||||
|
||||
func animationController(
|
||||
forPresented _: UIViewController,
|
||||
presenting _: UIViewController,
|
||||
source _: UIViewController
|
||||
) -> UIViewControllerAnimatedTransitioning? {
|
||||
BlurTransitionAnimator(presenting: true)
|
||||
}
|
||||
|
||||
func animationController(
|
||||
forDismissed _: UIViewController
|
||||
) -> UIViewControllerAnimatedTransitioning? {
|
||||
BlurTransitionAnimator(presenting: false)
|
||||
}
|
||||
}
|
||||
|
||||
class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
private let presenting: Bool
|
||||
|
||||
private let snapshotViewTag = "snapshotView".hashValue
|
||||
private let blurViewTag = "blurView".hashValue
|
||||
|
||||
init(presenting: Bool) {
|
||||
self.presenting = presenting
|
||||
super.init()
|
||||
}
|
||||
|
||||
func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
0.5
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
if presenting {
|
||||
animatePresentation(using: transitionContext)
|
||||
} else {
|
||||
animateDismissal(using: transitionContext)
|
||||
}
|
||||
}
|
||||
|
||||
private func animatePresentation(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let toViewController = transitionContext.viewController(forKey: .to),
|
||||
let fromViewController = transitionContext.viewController(forKey: .from)
|
||||
else {
|
||||
transitionContext.completeTransition(false)
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
||||
let toView = toViewController.view!
|
||||
let fromView = fromViewController.view!
|
||||
|
||||
let containerView = transitionContext.containerView
|
||||
|
||||
guard let fromViewSnapshot = fromView.snapshotView(afterScreenUpdates: false) else {
|
||||
transitionContext.completeTransition(false)
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
fromViewSnapshot.frame = fromView.frame
|
||||
fromViewSnapshot.tag = snapshotViewTag
|
||||
containerView.addSubview(fromViewSnapshot)
|
||||
fromView.isHidden = true
|
||||
|
||||
let blurEffectView = UIVisualEffectView()
|
||||
blurEffectView.frame = containerView.bounds
|
||||
blurEffectView.tag = blurViewTag
|
||||
containerView.addSubview(blurEffectView)
|
||||
|
||||
toView.frame = containerView.bounds
|
||||
toView.alpha = 0
|
||||
toView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
|
||||
containerView.addSubview(toView)
|
||||
|
||||
toView.layoutIfNeeded()
|
||||
|
||||
performWithAnimation(animations: {
|
||||
blurEffectView.effect = UIBlurEffect(style: .systemMaterial)
|
||||
fromViewSnapshot.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
||||
toView.alpha = 1
|
||||
toView.transform = .identity
|
||||
fromView.layoutIfNeeded()
|
||||
toView.layoutIfNeeded()
|
||||
}) { _ in
|
||||
let success = !transitionContext.transitionWasCancelled
|
||||
if !success {
|
||||
assertionFailure()
|
||||
fromView.isHidden = false
|
||||
fromViewSnapshot.removeFromSuperview()
|
||||
blurEffectView.removeFromSuperview()
|
||||
toView.removeFromSuperview()
|
||||
}
|
||||
transitionContext.completeTransition(success)
|
||||
}
|
||||
}
|
||||
|
||||
private func animateDismissal(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let fromViewController = transitionContext.viewController(forKey: .from),
|
||||
let toViewController = transitionContext.viewController(forKey: .to)
|
||||
else {
|
||||
transitionContext.completeTransition(false)
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
||||
let fromView = fromViewController.view!
|
||||
let toView = toViewController.view!
|
||||
let containerView = transitionContext.containerView
|
||||
|
||||
guard let fromViewSnapshot = containerView.viewWithTag(snapshotViewTag),
|
||||
let blurEffectView = containerView.viewWithTag(blurViewTag) as? UIVisualEffectView
|
||||
else {
|
||||
toView.isHidden = false
|
||||
assertionFailure()
|
||||
transitionContext.completeTransition(true)
|
||||
return
|
||||
}
|
||||
|
||||
performWithAnimation(animations: {
|
||||
fromViewSnapshot.transform = .identity
|
||||
blurEffectView.effect = nil
|
||||
fromView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
|
||||
fromView.alpha = 0
|
||||
}) { _ in
|
||||
let success = !transitionContext.transitionWasCancelled
|
||||
if success {
|
||||
toView.isHidden = false
|
||||
fromViewSnapshot.removeFromSuperview()
|
||||
blurEffectView.removeFromSuperview()
|
||||
fromView.layoutIfNeeded()
|
||||
toView.layoutIfNeeded()
|
||||
} else {
|
||||
assertionFailure()
|
||||
fromView.transform = .identity
|
||||
fromView.alpha = 1
|
||||
}
|
||||
transitionContext.completeTransition(success)
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// IntelligentsController.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/17/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class IntelligentsController: UINavigationController {
|
||||
public init() {
|
||||
super.init(rootViewController: MainViewController())
|
||||
modalPresentationStyle = .custom
|
||||
transitioningDelegate = BlurTransitioningDelegate.shared
|
||||
setNavigationBarHidden(true, animated: false)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// MainViewController+Header.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/19/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension MainViewController: MainHeaderViewDelegate {
|
||||
func mainHeaderViewDidTapClose() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
func mainHeaderViewDidTapDropdown() {
|
||||
print(#function)
|
||||
}
|
||||
|
||||
func mainHeaderViewDidTapMenu() {
|
||||
print(#function)
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
//
|
||||
// MainViewController+Input.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/19/25.
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
extension MainViewController: InputBoxDelegate {
|
||||
func inputBoxDidSelectTakePhoto(_: InputBox) {
|
||||
let imagePickerController = UIImagePickerController()
|
||||
imagePickerController.delegate = self
|
||||
imagePickerController.sourceType = .camera
|
||||
imagePickerController.allowsEditing = false
|
||||
present(imagePickerController, animated: true)
|
||||
}
|
||||
|
||||
func inputBoxDidSelectPhotoLibrary(_: InputBox) {
|
||||
var configuration = PHPickerConfiguration()
|
||||
configuration.filter = .images
|
||||
configuration.selectionLimit = 0 // 0 means no limit
|
||||
|
||||
let picker = PHPickerViewController(configuration: configuration)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
func inputBoxDidSelectAttachFiles(_: InputBox) {
|
||||
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [
|
||||
.pdf, .plainText, .commaSeparatedText, .data,
|
||||
])
|
||||
documentPicker.delegate = self
|
||||
documentPicker.allowsMultipleSelection = false
|
||||
present(documentPicker, animated: true)
|
||||
}
|
||||
|
||||
func inputBoxDidSelectEmbedDocs(_ inputBox: InputBox) {
|
||||
print(#function, inputBox)
|
||||
}
|
||||
|
||||
func inputBoxDidSelectAttachment(_ inputBox: InputBox) {
|
||||
print(#function, inputBox)
|
||||
}
|
||||
|
||||
func inputBoxDidSend(_ inputBox: InputBox) {
|
||||
print(#function, inputBox, inputBox.viewModel)
|
||||
}
|
||||
|
||||
func inputBoxTextDidChange(_ text: String) {
|
||||
print(#function, text)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIImagePickerControllerDelegate
|
||||
|
||||
extension MainViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
defer { picker.dismiss(animated: true) }
|
||||
|
||||
guard let image = info[.originalImage] as? UIImage else { return }
|
||||
inputBox.addImageAttachment(image)
|
||||
}
|
||||
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
picker.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PHPickerViewControllerDelegate
|
||||
|
||||
extension MainViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
defer { picker.dismiss(animated: true) }
|
||||
|
||||
for result in results {
|
||||
if result.itemProvider.canLoadObject(ofClass: UIImage.self) {
|
||||
result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
|
||||
guard let image = object as? UIImage, error == nil else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self?.inputBox.addImageAttachment(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIDocumentPickerDelegate
|
||||
|
||||
extension MainViewController: UIDocumentPickerDelegate {
|
||||
func documentPicker(_: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
for url in urls {
|
||||
// Start accessing security-scoped resource
|
||||
guard url.startAccessingSecurityScopedResource() else { continue }
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
|
||||
// Copy file to temporary directory
|
||||
let context = IntelligentContext.shared
|
||||
context.prepareTemporaryDirectory()
|
||||
|
||||
let tempURL = context.temporaryDirectory.appendingPathComponent(url.lastPathComponent)
|
||||
|
||||
do {
|
||||
// Remove existing file if it exists
|
||||
if FileManager.default.fileExists(atPath: tempURL.path) {
|
||||
try FileManager.default.removeItem(at: tempURL)
|
||||
}
|
||||
|
||||
// Copy file to temporary directory
|
||||
try FileManager.default.copyItem(at: url, to: tempURL)
|
||||
|
||||
// Add file attachment using the temporary URL
|
||||
inputBox.addFileAttachment(tempURL)
|
||||
} catch {
|
||||
print("Failed to copy file: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import Combine
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
class MainViewController: UIViewController {
|
||||
// MARK: - UI Components
|
||||
|
||||
lazy var headerView = MainHeaderView().then {
|
||||
$0.delegate = self
|
||||
}
|
||||
|
||||
lazy var inputBox = InputBox().then {
|
||||
$0.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let intelligentContext = IntelligentContext.shared
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .affineLayerBackgroundPrimary
|
||||
|
||||
let inputBox = InputBox().then {
|
||||
$0.delegate = self
|
||||
}
|
||||
self.inputBox = inputBox
|
||||
|
||||
view.addSubview(headerView)
|
||||
view.addSubview(inputBox)
|
||||
|
||||
headerView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
}
|
||||
|
||||
inputBox.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
navigationController!.setNavigationBarHidden(true, animated: animated)
|
||||
DispatchQueue.main.async {
|
||||
self.inputBox.textView.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
navigationController!.setNavigationBarHidden(false, animated: animated)
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// ParticleView+Removal.swift
|
||||
// UIEffectKit
|
||||
//
|
||||
// Created by 秋星桥 on 6/13/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public extension UIView {
|
||||
func removeFromSuperviewWithExplodeEffect() {
|
||||
guard let superview else { return }
|
||||
guard let window else {
|
||||
removeFromSuperview()
|
||||
return
|
||||
}
|
||||
guard MTLCreateSystemDefaultDevice() != nil else {
|
||||
removeFromSuperview()
|
||||
return
|
||||
}
|
||||
|
||||
let image = createViewSnapshot()
|
||||
guard let cgImage = image.cgImage else {
|
||||
removeFromSuperview()
|
||||
return
|
||||
}
|
||||
|
||||
let frameInWindow = superview.convert(frame, to: window)
|
||||
let particleView = ParticleView(frame: frameInWindow)
|
||||
|
||||
window.addSubview(particleView)
|
||||
particleView.layer.zPosition = 1000
|
||||
particleView.frame = frameInWindow
|
||||
particleView.setNeedsLayout()
|
||||
particleView.layoutIfNeeded()
|
||||
|
||||
particleView.beginWith(cgImage, targetFrame: frameInWindow, onComplete: {
|
||||
particleView.removeFromSuperview()
|
||||
}, onFirstFrameRendered: { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self?.removeFromSuperview()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
//
|
||||
// ParticleView+Renderer.swift
|
||||
// UIEffectKit
|
||||
//
|
||||
// Created by 秋星桥 on 6/13/25.
|
||||
//
|
||||
|
||||
import MetalKit
|
||||
|
||||
extension ParticleView {
|
||||
class Renderer: NSObject, MTKViewDelegate {
|
||||
private struct Particle {
|
||||
var position: simd_float2
|
||||
var velocity: simd_float2
|
||||
var life: simd_float1
|
||||
var duration: simd_float1
|
||||
}
|
||||
|
||||
private struct Vertex {
|
||||
var position: simd_float4
|
||||
var uv: simd_float2
|
||||
var opacity: simd_float1
|
||||
}
|
||||
|
||||
private var isPrepared = false
|
||||
private var renderPipeline: MTLRenderPipelineState!
|
||||
private var computePipeline: MTLComputePipelineState!
|
||||
private var vertexBuffer: MTLBuffer!
|
||||
private var particleBuffer: MTLBuffer!
|
||||
private var particleCount: Int = 0
|
||||
private var texture: MTLTexture!
|
||||
private var targetFrameSize: simd_float2 = .zero
|
||||
private var stepSize: Float = 0
|
||||
private var commandQueue: MTLCommandQueue!
|
||||
private var maxLife: Float = 0
|
||||
private var onComplete: (() -> Void)?
|
||||
private var onFirstFrameRendered: (() -> Void)?
|
||||
private var hasRenderedFirstFrame = false
|
||||
private var device: MTLDevice!
|
||||
|
||||
func prepareResources(
|
||||
with device: MTLDevice,
|
||||
image: CGImage,
|
||||
targetFrame: CGRect,
|
||||
onComplete: @escaping () -> Void,
|
||||
onFirstFrameRendered: @escaping () -> Void
|
||||
) {
|
||||
guard !isPrepared else { return }
|
||||
|
||||
self.device = device
|
||||
self.onComplete = onComplete
|
||||
self.onFirstFrameRendered = onFirstFrameRendered
|
||||
let integralTargetFrame = targetFrame.integral
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
setupPipelineStates(with: device)
|
||||
setupVertexBuffer(with: device)
|
||||
setupParticleSystem(targetFrame: integralTargetFrame, device: device)
|
||||
setupTexture(from: image, device: device)
|
||||
finalizeSetup(targetFrame: integralTargetFrame, device: device)
|
||||
|
||||
DispatchQueue.main.async { self.isPrepared = true }
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPipelineStates(with device: MTLDevice) {
|
||||
let library = try! device.makeDefaultLibrary(bundle: .module)
|
||||
|
||||
let particleVertexFunction = library.makeFunction(name: "PTS_ParticleVertex")!
|
||||
let particleFragmentFunction = library.makeFunction(name: "PTS_ParticleFragment")!
|
||||
let updateParticlesFunction = library.makeFunction(name: "PTS_UpdateParticles")!
|
||||
|
||||
let renderPipelineDescriptor = createRenderPipelineDescriptor(
|
||||
vertexFunction: particleVertexFunction,
|
||||
fragmentFunction: particleFragmentFunction
|
||||
)
|
||||
|
||||
do {
|
||||
renderPipeline = try device.makeRenderPipelineState(descriptor: renderPipelineDescriptor)
|
||||
computePipeline = try device.makeComputePipelineState(function: updateParticlesFunction)
|
||||
} catch {
|
||||
fatalError("failed to create pipeline states: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func createRenderPipelineDescriptor(
|
||||
vertexFunction: MTLFunction,
|
||||
fragmentFunction: MTLFunction
|
||||
) -> MTLRenderPipelineDescriptor {
|
||||
let descriptor = MTLRenderPipelineDescriptor()
|
||||
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
|
||||
descriptor.colorAttachments[0].isBlendingEnabled = true
|
||||
descriptor.colorAttachments[0].rgbBlendOperation = .add
|
||||
descriptor.colorAttachments[0].alphaBlendOperation = .add
|
||||
descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha
|
||||
descriptor.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha
|
||||
descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
|
||||
descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
|
||||
descriptor.vertexFunction = vertexFunction
|
||||
descriptor.fragmentFunction = fragmentFunction
|
||||
return descriptor
|
||||
}
|
||||
|
||||
func mtkView(_: MTKView, drawableSizeWillChange _: CGSize) {
|
||||
// No-op since view is not subject to resize
|
||||
}
|
||||
|
||||
func draw(in view: MTKView) {
|
||||
guard isPrepared else { return }
|
||||
|
||||
updateParticles()
|
||||
|
||||
if checkAllParticlesDead() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onComplete?()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
renderParticles(in: view)
|
||||
|
||||
if !hasRenderedFirstFrame {
|
||||
hasRenderedFirstFrame = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onFirstFrameRendered?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateParticles() {
|
||||
let maxThreadsPerThreadgroup = computePipeline.maxTotalThreadsPerThreadgroup
|
||||
let threadgroupSize = min(maxThreadsPerThreadgroup, 2048)
|
||||
let threadgroupCount = (particleCount + threadgroupSize - 1) / threadgroupSize
|
||||
|
||||
let computeCommandBuffer = commandQueue.makeCommandBuffer()!
|
||||
|
||||
let computeCommandEncoder = computeCommandBuffer.makeComputeCommandEncoder()!
|
||||
computeCommandEncoder.setComputePipelineState(computePipeline)
|
||||
computeCommandEncoder.setBuffer(particleBuffer, offset: 0, index: 0)
|
||||
computeCommandEncoder.dispatchThreadgroups(
|
||||
.init(width: threadgroupCount, height: 1, depth: 1),
|
||||
threadsPerThreadgroup: .init(width: threadgroupSize, height: 1, depth: 1)
|
||||
)
|
||||
computeCommandEncoder.endEncoding()
|
||||
|
||||
computeCommandBuffer.commit()
|
||||
}
|
||||
|
||||
private func checkAllParticlesDead() -> Bool {
|
||||
let particleData = particleBuffer
|
||||
.contents()
|
||||
.bindMemory(to: Particle.self, capacity: particleCount)
|
||||
|
||||
for i in 0 ..< particleCount {
|
||||
if particleData[i].life >= 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func renderParticles(in view: MTKView) {
|
||||
let viewCGSize = view.bounds.size
|
||||
var viewSize = simd_float2(Float(viewCGSize.width), Float(viewCGSize.height))
|
||||
|
||||
let renderCommandBuffer = commandQueue.makeCommandBuffer()!
|
||||
|
||||
guard let renderPassDescriptor = view.currentRenderPassDescriptor else { return }
|
||||
renderPassDescriptor.colorAttachments[0].loadAction = .clear
|
||||
renderPassDescriptor.colorAttachments[0].clearColor = .init(red: 0, green: 0, blue: 0, alpha: 0)
|
||||
|
||||
let renderCommandEncoder = renderCommandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)!
|
||||
renderCommandEncoder.setRenderPipelineState(renderPipeline)
|
||||
renderCommandEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
|
||||
|
||||
withUnsafeBytes(of: &viewSize) { pointer in
|
||||
renderCommandEncoder.setVertexBytes(
|
||||
pointer.baseAddress!,
|
||||
length: MemoryLayout<simd_float2>.size,
|
||||
index: 1
|
||||
)
|
||||
}
|
||||
renderCommandEncoder.setVertexBuffer(particleBuffer, offset: 0, index: 2)
|
||||
withUnsafeBytes(of: &targetFrameSize) { pointer in
|
||||
renderCommandEncoder.setVertexBytes(
|
||||
pointer.baseAddress!,
|
||||
length: MemoryLayout<simd_float2>.size,
|
||||
index: 3
|
||||
)
|
||||
}
|
||||
withUnsafeBytes(of: &stepSize) { pointer in
|
||||
renderCommandEncoder.setVertexBytes(
|
||||
pointer.baseAddress!,
|
||||
length: MemoryLayout<Float>.size,
|
||||
index: 4
|
||||
)
|
||||
}
|
||||
renderCommandEncoder.setFragmentTexture(texture, index: 0)
|
||||
|
||||
setupSampler(renderCommandEncoder: renderCommandEncoder)
|
||||
|
||||
renderCommandEncoder.drawPrimitives(
|
||||
type: .triangleStrip,
|
||||
vertexStart: 0,
|
||||
vertexCount: 4,
|
||||
instanceCount: particleCount
|
||||
)
|
||||
renderCommandEncoder.endEncoding()
|
||||
|
||||
renderCommandBuffer.present(view.currentDrawable!)
|
||||
renderCommandBuffer.commit()
|
||||
}
|
||||
|
||||
private func setupSampler(renderCommandEncoder: MTLRenderCommandEncoder) {
|
||||
let samplerDescriptor = MTLSamplerDescriptor()
|
||||
samplerDescriptor.minFilter = .linear
|
||||
samplerDescriptor.magFilter = .linear
|
||||
samplerDescriptor.mipFilter = .notMipmapped
|
||||
samplerDescriptor.sAddressMode = .clampToEdge
|
||||
samplerDescriptor.tAddressMode = .clampToEdge
|
||||
let samplerState = device.makeSamplerState(descriptor: samplerDescriptor)
|
||||
renderCommandEncoder.setFragmentSamplerState(samplerState, index: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ParticleView.Renderer {
|
||||
private func setupVertexBuffer(with device: MTLDevice) {
|
||||
let vertices: [Vertex] = [
|
||||
.init(position: .init(0, 0, 0, 1), uv: .init(0, 0), opacity: .zero),
|
||||
.init(position: .init(1, 0, 0, 1), uv: .init(1, 0), opacity: .zero),
|
||||
.init(position: .init(0, 1, 0, 1), uv: .init(0, 1), opacity: .zero),
|
||||
.init(position: .init(1, 1, 0, 1), uv: .init(1, 1), opacity: .zero),
|
||||
]
|
||||
let vertexBuffer = vertices.withUnsafeBytes { pointer in
|
||||
device.makeBuffer(
|
||||
bytes: pointer.baseAddress!,
|
||||
length: MemoryLayout<Vertex>.stride * vertices.count,
|
||||
options: .storageModeShared
|
||||
)
|
||||
}
|
||||
self.vertexBuffer = vertexBuffer!
|
||||
}
|
||||
|
||||
private func setupParticleSystem(targetFrame: CGRect, device: MTLDevice) {
|
||||
var particles = [Particle]()
|
||||
let targetFrameHeight = Float(targetFrame.height)
|
||||
let targetFrameWidth = Float(targetFrame.width)
|
||||
let particleStep = 1
|
||||
|
||||
let estimatedParticleCount = 1
|
||||
* Int(targetFrameWidth / Float(particleStep))
|
||||
* Int(targetFrameHeight / Float(particleStep))
|
||||
let pixelMultiplier = 1
|
||||
particles.reserveCapacity(estimatedParticleCount * pixelMultiplier)
|
||||
|
||||
for y in stride(from: 0, to: Int(targetFrameHeight), by: particleStep) {
|
||||
for x in stride(from: 0, to: Int(targetFrameWidth), by: particleStep) {
|
||||
let particle = createParticle(x: x, y: y, step: particleStep)
|
||||
for _ in 0 ..< pixelMultiplier {
|
||||
particles.append(particle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
particleCount = particles.count
|
||||
let particleBuffer = particles.withUnsafeBytes { pointer in
|
||||
device.makeBuffer(
|
||||
bytes: pointer.baseAddress!,
|
||||
length: MemoryLayout<Particle>.stride * particles.count,
|
||||
options: .storageModeShared
|
||||
)
|
||||
}
|
||||
self.particleBuffer = particleBuffer!
|
||||
stepSize = Float(particleStep)
|
||||
}
|
||||
|
||||
private func createParticle(x: Int, y: Int, step: Int) -> Particle {
|
||||
let particleDuration: Float = .random(in: 20 ... 60)
|
||||
let initialX = Float(x) + Float(step) / 2.0
|
||||
let initialY = Float(y) + Float(step) / 2.0
|
||||
return .init(
|
||||
position: .init(initialX, initialY),
|
||||
velocity: .init(
|
||||
cos(Float.random(in: 0 ... (2 * Float.pi))) * Float.random(in: 1 ... 4),
|
||||
sin(Float.random(in: 0 ... (2 * Float.pi))) * Float.random(in: 1 ... 4) - 2.5
|
||||
),
|
||||
life: simd_float1(particleDuration),
|
||||
duration: simd_float1(particleDuration)
|
||||
)
|
||||
}
|
||||
|
||||
private func setupTexture(from image: CGImage, device: MTLDevice) {
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: bitmapInfo.rawValue
|
||||
) else { return }
|
||||
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
|
||||
|
||||
guard let convertedImage = context.makeImage() else { return }
|
||||
|
||||
let textureLoader = MTKTextureLoader(device: device)
|
||||
let textureLoaderOptions: [MTKTextureLoader.Option: Any] = [
|
||||
.textureStorageMode: MTLStorageMode.private.rawValue,
|
||||
.SRGB: false,
|
||||
]
|
||||
guard let texture = try? textureLoader.newTexture(
|
||||
cgImage: convertedImage,
|
||||
options: textureLoaderOptions
|
||||
) else { return }
|
||||
|
||||
self.texture = texture
|
||||
}
|
||||
|
||||
private func finalizeSetup(targetFrame: CGRect, device: MTLDevice) {
|
||||
let targetFrameWidth = Float(targetFrame.width)
|
||||
let targetFrameHeight = Float(targetFrame.height)
|
||||
targetFrameSize = .init(targetFrameWidth, targetFrameHeight)
|
||||
commandQueue = device.makeCommandQueue()!
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// ParticleView.swift
|
||||
// TrollNFC
|
||||
//
|
||||
// Created by 砍砍 on 6/8/25.
|
||||
//
|
||||
|
||||
import MetalKit
|
||||
import simd
|
||||
|
||||
import UIKit
|
||||
|
||||
class ParticleView: UIView {
|
||||
private var device: MTLDevice!
|
||||
private var metalView: MTKView!
|
||||
private var renderer = Renderer()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupMetalDevice()
|
||||
setupMetalView()
|
||||
setupViewProperties()
|
||||
}
|
||||
|
||||
private func setupMetalDevice() {
|
||||
guard let device = Self.createSystemDefaultDevice() else {
|
||||
fatalError("failed to create Metal device")
|
||||
}
|
||||
self.device = device
|
||||
}
|
||||
|
||||
private func setupMetalView() {
|
||||
metalView = MTKView(frame: .zero, device: device)
|
||||
configureMetalView()
|
||||
addSubview(metalView)
|
||||
}
|
||||
|
||||
private func configureMetalView() {
|
||||
metalView.layer.isOpaque = false
|
||||
metalView.backgroundColor = UIColor.clear
|
||||
metalView.delegate = renderer
|
||||
}
|
||||
|
||||
private func setupViewProperties() {
|
||||
clipsToBounds = false
|
||||
metalView.clipsToBounds = false
|
||||
}
|
||||
|
||||
private static func createSystemDefaultDevice() -> MTLDevice? {
|
||||
MTLCreateSystemDefaultDevice()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func beginWith(
|
||||
_ image: CGImage,
|
||||
targetFrame: CGRect,
|
||||
onComplete: @escaping () -> Void,
|
||||
onFirstFrameRendered: @escaping () -> Void
|
||||
) {
|
||||
renderer.prepareResources(
|
||||
with: device,
|
||||
image: image,
|
||||
targetFrame: targetFrame,
|
||||
onComplete: onComplete,
|
||||
onFirstFrameRendered: onFirstFrameRendered
|
||||
)
|
||||
metalView.draw()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let expandedBounds = bounds.insetBy(dx: -bounds.width, dy: -bounds.height)
|
||||
metalView.frame = expandedBounds
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// UIView+createViewSnapshot.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/19/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public extension UIView {
|
||||
func createViewSnapshot() -> UIImage {
|
||||
let renderer = UIGraphicsImageRenderer(bounds: bounds)
|
||||
return renderer.image { context in
|
||||
// clear the background
|
||||
context.cgContext.setFillColor(UIColor.clear.cgColor)
|
||||
context.cgContext.fill(bounds)
|
||||
|
||||
// MUST USE DRAW HIERARCHY TO RENDER VISUAL EFFECT VIEW
|
||||
self.drawHierarchy(in: bounds, afterScreenUpdates: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
-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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
//
|
||||
// AccentColor.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/19/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIColor {
|
||||
static let accent: UIColor = .init(red: 30 / 255, green: 150 / 255, blue: 235 / 255, alpha: 1.0)
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Animation.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/19/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
func performWithAnimation(
|
||||
animations: @escaping () -> Void,
|
||||
completion: @escaping (Bool) -> Void = { _ in }
|
||||
) {
|
||||
UIView.animate(
|
||||
withDuration: 0.5,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 0.8,
|
||||
initialSpringVelocity: 0.8,
|
||||
options: [.beginFromCurrentState, .allowAnimatedContent, .curveEaseInOut],
|
||||
animations: animations,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
//
|
||||
// UIHostingView.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/12/13.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class UIHostingView<Content: View>: UIView {
|
||||
private let hostingViewController: UIHostingController<Content>
|
||||
|
||||
var rootView: Content {
|
||||
get { hostingViewController.rootView }
|
||||
set { hostingViewController.rootView = newValue }
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
hostingViewController.view.intrinsicContentSize
|
||||
}
|
||||
|
||||
init(rootView: Content) {
|
||||
hostingViewController = UIHostingController(rootView: rootView)
|
||||
hostingViewController.edgesForExtendedLayout = []
|
||||
hostingViewController.extendedLayoutIncludesOpaqueBars = false
|
||||
super.init(frame: .zero)
|
||||
|
||||
hostingViewController.view?.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(hostingViewController.view)
|
||||
if let view = hostingViewController.view {
|
||||
view.removeFromSuperview()
|
||||
view.backgroundColor = .clear
|
||||
view.isOpaque = false
|
||||
addSubview(view)
|
||||
let constraints = [
|
||||
view.topAnchor.constraint(equalTo: topAnchor),
|
||||
view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
view.leftAnchor.constraint(equalTo: leftAnchor),
|
||||
view.rightAnchor.constraint(equalTo: rightAnchor),
|
||||
]
|
||||
NSLayoutConstraint.activate(constraints)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
||||
hostingViewController.sizeThatFits(in: size)
|
||||
}
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
import Combine
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
class InputBox: UIView {
|
||||
weak var delegate: InputBoxDelegate?
|
||||
|
||||
public let viewModel = InputBoxViewModel()
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
|
||||
lazy var containerView = UIView().then {
|
||||
$0.backgroundColor = UIColor.affineLayerBackgroundPrimary
|
||||
$0.layer.cornerRadius = 16
|
||||
$0.layer.cornerCurve = .continuous
|
||||
$0.layer.borderWidth = 1
|
||||
$0.layer.shadowColor = UIColor.black.cgColor
|
||||
$0.layer.shadowOffset = CGSize(width: 0, height: 0)
|
||||
$0.layer.shadowRadius = 12
|
||||
$0.layer.shadowOpacity = 0.075
|
||||
$0.clipsToBounds = false
|
||||
}
|
||||
|
||||
lazy var textView = UITextView().then {
|
||||
$0.backgroundColor = .clear
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
$0.textColor = UIColor.affineTextPrimary
|
||||
$0.isScrollEnabled = false
|
||||
$0.textContainer.lineFragmentPadding = 0
|
||||
$0.textContainerInset = .zero
|
||||
$0.delegate = self
|
||||
$0.text = ""
|
||||
}
|
||||
|
||||
lazy var placeholderLabel = UILabel().then {
|
||||
$0.text = "Write your message..."
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
$0.textColor = UIColor.affineTextPlaceholder
|
||||
$0.isHidden = true
|
||||
}
|
||||
|
||||
lazy var functionBar = InputBoxFunctionBar().then {
|
||||
$0.delegate = self
|
||||
}
|
||||
|
||||
lazy var imageBar = InputBoxImageBar().then {
|
||||
$0.imageBarDelegate = self
|
||||
}
|
||||
|
||||
lazy var mainStackView = UIStackView().then {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 16
|
||||
$0.alignment = .fill
|
||||
$0.clipsToBounds = true
|
||||
$0.addArrangedSubview(imageBar)
|
||||
$0.addArrangedSubview(textView)
|
||||
$0.addArrangedSubview(functionBar)
|
||||
}
|
||||
|
||||
var textViewHeightConstraint: Constraint?
|
||||
let minTextViewHeight: CGFloat = 22
|
||||
let maxTextViewHeight: CGFloat = 100
|
||||
|
||||
var text: String {
|
||||
get { textView.text ?? "" }
|
||||
set {
|
||||
textView.text = newValue
|
||||
updatePlaceholderVisibility()
|
||||
updateTextViewHeight()
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect = .zero) {
|
||||
super.init(frame: frame)
|
||||
|
||||
backgroundColor = .clear
|
||||
addSubview(containerView)
|
||||
containerView.addSubview(mainStackView)
|
||||
containerView.addSubview(placeholderLabel)
|
||||
imageBar.isHidden = true
|
||||
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
}
|
||||
|
||||
mainStackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
}
|
||||
|
||||
imageBar.snp.makeConstraints { make in
|
||||
make.left.right.equalToSuperview()
|
||||
}
|
||||
|
||||
textView.snp.makeConstraints { make in
|
||||
textViewHeightConstraint = make.height.equalTo(minTextViewHeight).constraint
|
||||
}
|
||||
|
||||
placeholderLabel.snp.makeConstraints { make in
|
||||
make.left.right.equalTo(textView)
|
||||
make.top.equalTo(textView)
|
||||
}
|
||||
|
||||
setupBindings()
|
||||
updatePlaceholderVisibility()
|
||||
updateColors()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
|
||||
updateColors()
|
||||
}
|
||||
}
|
||||
|
||||
func setupBindings() {
|
||||
// 绑定 ViewModel 到 UI
|
||||
viewModel.$inputText
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] text in
|
||||
if self?.textView.text != text {
|
||||
self?.textView.text = text
|
||||
self?.updatePlaceholderVisibility()
|
||||
self?.updateTextViewHeight()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$isToolEnabled
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] enabled in
|
||||
self?.functionBar.updateToolState(isEnabled: enabled)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$isNetworkEnabled
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] enabled in
|
||||
self?.functionBar.updateNetworkState(isEnabled: enabled)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$isDeepThinkingEnabled
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] enabled in
|
||||
self?.functionBar.updateDeepThinkingState(isEnabled: enabled)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$canSend
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] canSend in
|
||||
self?.functionBar.updateSendState(canSend: canSend)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$hasAttachments
|
||||
.dropFirst() // for view setup
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] hasAttachments in
|
||||
performWithAnimation {
|
||||
self?.updateImageBarVisibility(hasAttachments)
|
||||
self?.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$attachments
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] attachments in
|
||||
self?.updateImageBarContent(attachments)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func updateTextViewHeight() {
|
||||
let size = textView.sizeThatFits(CGSize(width: textView.frame.width, height: CGFloat.greatestFiniteMagnitude))
|
||||
let newHeight = max(minTextViewHeight, min(maxTextViewHeight, size.height))
|
||||
|
||||
let height = textView.frame.height
|
||||
guard height != newHeight else { return }
|
||||
|
||||
textViewHeightConstraint?.update(offset: newHeight)
|
||||
textView.isScrollEnabled = size.height > maxTextViewHeight
|
||||
|
||||
if height == 0 || superview == nil || window == nil || isHidden { return }
|
||||
|
||||
performWithAnimation {
|
||||
self.layoutIfNeeded()
|
||||
self.superview?.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func updatePlaceholderVisibility() {
|
||||
placeholderLabel.isHidden = !textView.text.isEmpty
|
||||
}
|
||||
|
||||
func updateImageBarVisibility(_ hasAttachments: Bool) {
|
||||
imageBar.isHidden = !hasAttachments
|
||||
}
|
||||
|
||||
func updateImageBarContent(_ attachments: [InputAttachment]) {
|
||||
imageBar.updateImageBarContent(attachments)
|
||||
}
|
||||
|
||||
func updateColors() {
|
||||
containerView.layer.borderColor = UIColor.affineLayerBorder.cgColor
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
public func addImageAttachment(_ image: UIImage) {
|
||||
guard let imageData = image.jpegData(compressionQuality: 0.8) else { return }
|
||||
|
||||
let attachment = InputAttachment(
|
||||
type: .image,
|
||||
data: imageData,
|
||||
name: "image.jpg",
|
||||
size: Int64(imageData.count)
|
||||
)
|
||||
|
||||
performWithAnimation { [self] in
|
||||
viewModel.addAttachment(attachment)
|
||||
layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
public func addFileAttachment(_ url: URL) {
|
||||
guard let fileData = try? Data(contentsOf: url) else { return }
|
||||
|
||||
let attachment = InputAttachment(
|
||||
type: .file,
|
||||
data: fileData,
|
||||
name: url.lastPathComponent,
|
||||
size: Int64(fileData.count)
|
||||
)
|
||||
|
||||
performWithAnimation { [self] in
|
||||
viewModel.addAttachment(attachment)
|
||||
layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
public var inputBoxData: InputBoxData {
|
||||
viewModel.prepareSendData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - InputBoxFunctionBarDelegate
|
||||
|
||||
extension InputBox: InputBoxFunctionBarDelegate {
|
||||
func functionBarDidTapTakePhoto(_: InputBoxFunctionBar) {
|
||||
delegate?.inputBoxDidSelectTakePhoto(self)
|
||||
}
|
||||
|
||||
func functionBarDidTapPhotoLibrary(_: InputBoxFunctionBar) {
|
||||
delegate?.inputBoxDidSelectPhotoLibrary(self)
|
||||
}
|
||||
|
||||
func functionBarDidTapAttachFiles(_: InputBoxFunctionBar) {
|
||||
delegate?.inputBoxDidSelectAttachFiles(self)
|
||||
}
|
||||
|
||||
func functionBarDidTapEmbedDocs(_: InputBoxFunctionBar) {
|
||||
delegate?.inputBoxDidSelectEmbedDocs(self)
|
||||
}
|
||||
|
||||
func functionBarDidTapTool(_: InputBoxFunctionBar) {
|
||||
viewModel.toggleTool()
|
||||
}
|
||||
|
||||
func functionBarDidTapNetwork(_: InputBoxFunctionBar) {
|
||||
viewModel.toggleNetwork()
|
||||
}
|
||||
|
||||
func functionBarDidTapDeepThinking(_: InputBoxFunctionBar) {
|
||||
viewModel.toggleDeepThinking()
|
||||
}
|
||||
|
||||
func functionBarDidTapSend(_: InputBoxFunctionBar) {
|
||||
delegate?.inputBoxDidSend(self)
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "255",
|
||||
"green" : "255",
|
||||
"red" : "255"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "32",
|
||||
"green" : "32",
|
||||
"red" : "32"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// InputBoxDelegate.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/18/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol InputBoxDelegate: AnyObject {
|
||||
func inputBoxDidSelectTakePhoto(_ inputBox: InputBox)
|
||||
func inputBoxDidSelectPhotoLibrary(_ inputBox: InputBox)
|
||||
func inputBoxDidSelectAttachFiles(_ inputBox: InputBox)
|
||||
func inputBoxDidSelectEmbedDocs(_ inputBox: InputBox)
|
||||
func inputBoxDidSend(_ inputBox: InputBox)
|
||||
func inputBoxTextDidChange(_ text: String)
|
||||
}
|
||||
|
||||
extension InputBox: InputBoxImageBarDelegate {
|
||||
func inputBoxImageBar(_: InputBoxImageBar, didRemoveImageWithId id: InputAttachment.ID) {
|
||||
performWithAnimation { [self] in
|
||||
viewModel.removeAttachment(withId: id)
|
||||
layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension InputBox: UITextViewDelegate {
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
viewModel.updateText(textView.text ?? "")
|
||||
delegate?.inputBoxTextDidChange(textView.text ?? "")
|
||||
updatePlaceholderVisibility()
|
||||
updateTextViewHeight()
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
protocol InputBoxFunctionBarDelegate: AnyObject {
|
||||
func functionBarDidTapTakePhoto(_ functionBar: InputBoxFunctionBar)
|
||||
func functionBarDidTapPhotoLibrary(_ functionBar: InputBoxFunctionBar)
|
||||
func functionBarDidTapAttachFiles(_ functionBar: InputBoxFunctionBar)
|
||||
func functionBarDidTapEmbedDocs(_ functionBar: InputBoxFunctionBar)
|
||||
func functionBarDidTapTool(_ functionBar: InputBoxFunctionBar)
|
||||
func functionBarDidTapNetwork(_ functionBar: InputBoxFunctionBar)
|
||||
func functionBarDidTapDeepThinking(_ functionBar: InputBoxFunctionBar)
|
||||
func functionBarDidTapSend(_ functionBar: InputBoxFunctionBar)
|
||||
}
|
||||
|
||||
private let unselectedColor: UIColor = UIColor.affineIconPrimary
|
||||
private let selectedColor: UIColor = UIColor.affineIconActivated
|
||||
|
||||
class InputBoxFunctionBar: UIView {
|
||||
weak var delegate: InputBoxFunctionBarDelegate?
|
||||
|
||||
lazy var attachmentButton = UIButton(type: .system).then {
|
||||
$0.setImage(UIImage.affinePlus, for: .normal)
|
||||
$0.tintColor = unselectedColor
|
||||
$0.layer.borderWidth = 1
|
||||
$0.layer.cornerRadius = 4
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.showsMenuAsPrimaryAction = true
|
||||
$0.menu = createAttachmentMenu()
|
||||
}
|
||||
|
||||
lazy var toolButton = UIButton(type: .system).then {
|
||||
$0.setImage(UIImage.affineTools, for: .normal)
|
||||
$0.tintColor = unselectedColor
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(toolButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
lazy var networkButton = UIButton(type: .system).then {
|
||||
$0.setImage(UIImage.affineWeb, for: .normal)
|
||||
$0.tintColor = unselectedColor
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(networkButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
lazy var deepThinkingButton = UIButton(type: .system).then {
|
||||
$0.setImage(UIImage.affineThink, for: .normal)
|
||||
$0.tintColor = unselectedColor
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(deepThinkingButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
lazy var sendButton = UIButton(type: .system).then {
|
||||
$0.setImage(UIImage.affineArrowUpBig, for: .normal)
|
||||
$0.tintColor = UIColor.affineTextPureWhite
|
||||
$0.backgroundColor = UIColor.affineButtonPrimary
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(sendButtonTapped), for: .touchUpInside)
|
||||
$0.clipsToBounds = true
|
||||
}
|
||||
|
||||
lazy var leftButtonsStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 16
|
||||
$0.alignment = .center
|
||||
$0.addArrangedSubview(attachmentButton)
|
||||
}
|
||||
|
||||
lazy var rightButtonsStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 16
|
||||
$0.alignment = .center
|
||||
$0.addArrangedSubview(toolButton)
|
||||
$0.addArrangedSubview(networkButton)
|
||||
$0.addArrangedSubview(deepThinkingButton)
|
||||
$0.addArrangedSubview(sendButton)
|
||||
}
|
||||
|
||||
lazy var stackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 12
|
||||
$0.alignment = .center
|
||||
$0.addArrangedSubview(leftButtonsStackView)
|
||||
$0.addArrangedSubview(UIView()) // spacer
|
||||
$0.addArrangedSubview(rightButtonsStackView)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(stackView)
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
for button in [attachmentButton, toolButton, networkButton, deepThinkingButton, sendButton] {
|
||||
button.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(32)
|
||||
}
|
||||
}
|
||||
updateColors()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
sendButton.layer.cornerRadius = sendButton.bounds.height / 2
|
||||
for button in [toolButton, networkButton, deepThinkingButton] {
|
||||
button.layer.cornerRadius = button.bounds.height / 2
|
||||
}
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
|
||||
updateColors()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
func updateToolState(isEnabled: Bool) {
|
||||
toolButton.tintColor = isEnabled ? selectedColor : unselectedColor
|
||||
}
|
||||
|
||||
func updateNetworkState(isEnabled: Bool) {
|
||||
networkButton.tintColor = isEnabled ? selectedColor : unselectedColor
|
||||
}
|
||||
|
||||
func updateDeepThinkingState(isEnabled: Bool) {
|
||||
deepThinkingButton.tintColor = isEnabled ? selectedColor : unselectedColor
|
||||
}
|
||||
|
||||
func updateSendState(canSend: Bool) {
|
||||
sendButton.isEnabled = canSend
|
||||
sendButton.alpha = canSend ? 1.0 : 0.5
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func updateColors() {
|
||||
attachmentButton.layer.borderColor = UIColor.affineLayerBorder.cgColor
|
||||
}
|
||||
|
||||
private func createAttachmentMenu() -> UIMenu {
|
||||
let takePhotoAction = UIAction(
|
||||
title: "Take Photo or Video",
|
||||
image: UIImage.affineCamera
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
delegate?.functionBarDidTapTakePhoto(self)
|
||||
}
|
||||
|
||||
let photoLibraryAction = UIAction(
|
||||
title: "Photo Library",
|
||||
image: UIImage.affineImage
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
delegate?.functionBarDidTapPhotoLibrary(self)
|
||||
}
|
||||
|
||||
let attachFilesAction = UIAction(
|
||||
title: "Attach Files (pdf, txt, csv)",
|
||||
image: UIImage.affineUpload
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
delegate?.functionBarDidTapAttachFiles(self)
|
||||
}
|
||||
|
||||
let embedDocsAction = UIAction(
|
||||
title: "Embed AFFINE Docs",
|
||||
image: UIImage.affinePage
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
delegate?.functionBarDidTapEmbedDocs(self)
|
||||
}
|
||||
|
||||
return UIMenu(
|
||||
options: [.displayInline],
|
||||
children: [takePhotoAction, photoLibraryAction, attachFilesAction, embedDocsAction].reversed()
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func toolButtonTapped() {
|
||||
delegate?.functionBarDidTapTool(self)
|
||||
}
|
||||
|
||||
@objc private func networkButtonTapped() {
|
||||
delegate?.functionBarDidTapNetwork(self)
|
||||
}
|
||||
|
||||
@objc private func deepThinkingButtonTapped() {
|
||||
delegate?.functionBarDidTapDeepThinking(self)
|
||||
}
|
||||
|
||||
@objc private func sendButtonTapped() {
|
||||
delegate?.functionBarDidTapSend(self)
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// InputBoxImageBar.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/18/25.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
protocol InputBoxImageBarDelegate: AnyObject {
|
||||
func inputBoxImageBar(_ imageBar: InputBoxImageBar, didRemoveImageWithId id: UUID)
|
||||
}
|
||||
|
||||
private class AttachmentViewModel {
|
||||
let attachment: InputAttachment
|
||||
let imageCell: InputBoxImageBar.ImageCell
|
||||
|
||||
init(attachment: InputAttachment, imageCell: InputBoxImageBar.ImageCell) {
|
||||
self.attachment = attachment
|
||||
self.imageCell = imageCell
|
||||
}
|
||||
}
|
||||
|
||||
class InputBoxImageBar: UIScrollView {
|
||||
weak var imageBarDelegate: InputBoxImageBarDelegate?
|
||||
|
||||
private var attachmentViewModels: [AttachmentViewModel] = []
|
||||
private let cellSpacing: CGFloat = 8
|
||||
private let constantHeight: CGFloat = 80
|
||||
|
||||
override init(frame: CGRect = .zero) {
|
||||
super.init(frame: frame)
|
||||
showsHorizontalScrollIndicator = false
|
||||
showsVerticalScrollIndicator = false
|
||||
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(constantHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func updateImageBarContent(_ attachments: [InputAttachment]) {
|
||||
let currentIds = Set(attachmentViewModels.map(\.attachment.id))
|
||||
let imageAttachments = attachments.filter { $0.type == .image }
|
||||
let newIds = Set(imageAttachments.map(\.id))
|
||||
|
||||
// 移除不再存在的附件
|
||||
let idsToRemove = currentIds.subtracting(newIds)
|
||||
for id in idsToRemove {
|
||||
if let index = attachmentViewModels.firstIndex(where: { $0.attachment.id == id }) {
|
||||
let viewModel = attachmentViewModels.remove(at: index)
|
||||
viewModel.imageCell.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
// 添加新的附件
|
||||
let idsToAdd = newIds.subtracting(currentIds)
|
||||
var initialXOffset = attachmentViewModels.reduce(0) { $0 + $1.imageCell.frame.width + cellSpacing }
|
||||
for attachment in imageAttachments {
|
||||
if idsToAdd.contains(attachment.id),
|
||||
let data = attachment.data,
|
||||
let image = UIImage(data: data)
|
||||
{
|
||||
let imageCell = ImageCell(
|
||||
// for animation to work
|
||||
frame: .init(x: initialXOffset, y: 0, width: constantHeight, height: constantHeight),
|
||||
image: image,
|
||||
attachmentId: attachment.id
|
||||
)
|
||||
initialXOffset += constantHeight + cellSpacing
|
||||
imageCell.onRemove = { [weak self] cell in
|
||||
self?.removeImageCell(cell)
|
||||
}
|
||||
imageCell.alpha = 0
|
||||
DispatchQueue.main.async {
|
||||
performWithAnimation { imageCell.alpha = 1 }
|
||||
}
|
||||
|
||||
let viewModel = AttachmentViewModel(attachment: attachment, imageCell: imageCell)
|
||||
attachmentViewModels.append(viewModel)
|
||||
addSubview(imageCell)
|
||||
}
|
||||
}
|
||||
|
||||
layoutImageCells()
|
||||
}
|
||||
|
||||
func removeImageCell(_ cell: ImageCell) {
|
||||
if let index = attachmentViewModels.firstIndex(where: { $0.imageCell === cell }) {
|
||||
let viewModel = attachmentViewModels.remove(at: index)
|
||||
viewModel.imageCell.removeFromSuperviewWithExplodeEffect()
|
||||
imageBarDelegate?.inputBoxImageBar(self, didRemoveImageWithId: cell.attachmentId)
|
||||
layoutImageCells()
|
||||
}
|
||||
}
|
||||
|
||||
func clear() {
|
||||
for viewModel in attachmentViewModels {
|
||||
viewModel.imageCell.removeFromSuperview()
|
||||
}
|
||||
attachmentViewModels.removeAll()
|
||||
contentSize = .zero
|
||||
}
|
||||
|
||||
private func layoutImageCells() {
|
||||
var xOffset: CGFloat = 0
|
||||
|
||||
for viewModel in attachmentViewModels {
|
||||
viewModel.imageCell.frame = CGRect(x: xOffset, y: 0, width: constantHeight, height: constantHeight)
|
||||
xOffset += constantHeight + cellSpacing
|
||||
}
|
||||
|
||||
// Update content size
|
||||
let totalWidth = max(0, xOffset - cellSpacing)
|
||||
contentSize = CGSize(width: totalWidth, height: constantHeight)
|
||||
}
|
||||
}
|
||||
|
||||
extension InputBoxImageBar {
|
||||
class ImageCell: UIView {
|
||||
let attachmentId: UUID
|
||||
var onRemove: ((ImageCell) -> Void)?
|
||||
|
||||
private lazy var imageView = UIImageView(frame: bounds).then {
|
||||
$0.contentMode = .scaleAspectFill
|
||||
$0.clipsToBounds = true
|
||||
$0.layer.cornerRadius = 12
|
||||
$0.layer.cornerCurve = .continuous
|
||||
$0.backgroundColor = .systemGray6
|
||||
}
|
||||
|
||||
private lazy var removeButton = DeleteButtonView(frame: removeButtonFrame).then {
|
||||
$0.onTapped = { [weak self] in
|
||||
self?.removeButtonTapped()
|
||||
}
|
||||
}
|
||||
|
||||
init(frame: CGRect, image: UIImage, attachmentId: UUID) {
|
||||
self.attachmentId = attachmentId
|
||||
super.init(frame: frame)
|
||||
addSubview(imageView)
|
||||
addSubview(removeButton)
|
||||
imageView.image = image
|
||||
}
|
||||
|
||||
var removeButtonFrame: CGRect {
|
||||
let buttonSize: CGFloat = 18
|
||||
let buttonInset: CGFloat = 6
|
||||
return CGRect(
|
||||
x: bounds.width - buttonSize - buttonInset,
|
||||
y: buttonInset,
|
||||
width: buttonSize,
|
||||
height: buttonSize
|
||||
)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
imageView.frame = bounds
|
||||
|
||||
removeButton.frame = removeButtonFrame
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@objc private func removeButtonTapped() {
|
||||
onRemove?(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
//
|
||||
// InputBoxViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by AI Assistant on 6/17/25.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
// MARK: - Data Models
|
||||
|
||||
public struct InputAttachment: Identifiable, Equatable, Hashable, Codable {
|
||||
public var id: UUID = .init()
|
||||
public var type: AttachmentType
|
||||
public var data: Data?
|
||||
public var url: URL?
|
||||
public var name: String
|
||||
public var size: Int64
|
||||
|
||||
public enum AttachmentType: String, Equatable, Hashable, Codable {
|
||||
case image
|
||||
case document
|
||||
case file
|
||||
}
|
||||
|
||||
public init(
|
||||
type: AttachmentType,
|
||||
data: Data? = nil,
|
||||
url: URL? = nil,
|
||||
name: String,
|
||||
size: Int64 = 0
|
||||
) {
|
||||
self.type = type
|
||||
self.data = data
|
||||
self.url = url
|
||||
self.name = name
|
||||
self.size = size
|
||||
}
|
||||
}
|
||||
|
||||
public struct InputBoxData {
|
||||
public var text: String
|
||||
public var attachments: [InputAttachment]
|
||||
public var isToolEnabled: Bool
|
||||
public var isNetworkEnabled: Bool
|
||||
public var isDeepThinkingEnabled: Bool
|
||||
|
||||
public init(
|
||||
text: String,
|
||||
attachments: [InputAttachment],
|
||||
isToolEnabled: Bool,
|
||||
isNetworkEnabled: Bool,
|
||||
isDeepThinkingEnabled: Bool
|
||||
) {
|
||||
self.text = text
|
||||
self.attachments = attachments
|
||||
self.isToolEnabled = isToolEnabled
|
||||
self.isNetworkEnabled = isNetworkEnabled
|
||||
self.isDeepThinkingEnabled = isDeepThinkingEnabled
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View Model
|
||||
|
||||
public class InputBoxViewModel: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published public var inputText: String = ""
|
||||
@Published public var isToolEnabled: Bool = false
|
||||
@Published public var isNetworkEnabled: Bool = false
|
||||
@Published public var isDeepThinkingEnabled: Bool = false
|
||||
@Published public var hasAttachments: Bool = false
|
||||
@Published public var attachments: [InputAttachment] = []
|
||||
@Published public var canSend: Bool = false
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init() {
|
||||
setupBindings()
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func setupBindings() {
|
||||
// 监听文本变化,自动更新发送按钮状态
|
||||
$inputText
|
||||
.map { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
.assign(to: \.canSend, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
// 监听附件变化
|
||||
$attachments
|
||||
.map { !$0.isEmpty }
|
||||
.assign(to: \.hasAttachments, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Text Management
|
||||
|
||||
public extension InputBoxViewModel {
|
||||
func updateText(_ text: String) {
|
||||
inputText = text
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Feature Toggles
|
||||
|
||||
public extension InputBoxViewModel {
|
||||
func toggleTool() {
|
||||
isToolEnabled.toggle()
|
||||
}
|
||||
|
||||
func toggleNetwork() {
|
||||
isNetworkEnabled.toggle()
|
||||
}
|
||||
|
||||
func toggleDeepThinking() {
|
||||
isDeepThinkingEnabled.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Attachment Management
|
||||
|
||||
public extension InputBoxViewModel {
|
||||
func addAttachment(_ attachment: InputAttachment) {
|
||||
attachments.append(attachment)
|
||||
}
|
||||
|
||||
func removeAttachment(withId id: UUID) {
|
||||
attachments.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
func clearAttachments() {
|
||||
attachments.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Send Management
|
||||
|
||||
public extension InputBoxViewModel {
|
||||
func prepareSendData() -> InputBoxData {
|
||||
InputBoxData(
|
||||
text: inputText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
attachments: attachments,
|
||||
isToolEnabled: isToolEnabled,
|
||||
isNetworkEnabled: isNetworkEnabled,
|
||||
isDeepThinkingEnabled: isDeepThinkingEnabled
|
||||
)
|
||||
}
|
||||
|
||||
func resetInput() {
|
||||
inputText = ""
|
||||
attachments.removeAll()
|
||||
isToolEnabled = false
|
||||
isNetworkEnabled = false
|
||||
isDeepThinkingEnabled = false
|
||||
}
|
||||
}
|
||||
+11
-20
@@ -5,6 +5,7 @@
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
public extension UIViewController {
|
||||
@@ -16,15 +17,15 @@ public extension UIViewController {
|
||||
let button = IntelligentsButton()
|
||||
view.addSubview(button)
|
||||
view.bringSubviewToFront(button)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
button.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20),
|
||||
button.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20 - Constant.affineTabbarHeight),
|
||||
button.widthAnchor.constraint(equalToConstant: 50),
|
||||
button.heightAnchor.constraint(equalToConstant: 50),
|
||||
].forEach { $0.isActive = true }
|
||||
button.snp.makeConstraints { make in
|
||||
make.trailing.equalTo(view.safeAreaLayoutGuide).offset(-20)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-20 - 44)
|
||||
make.width.height.equalTo(50)
|
||||
}
|
||||
button.transform = .init(scaleX: 0, y: 0)
|
||||
view.layoutIfNeeded()
|
||||
if view.frame != .zero {
|
||||
view.layoutIfNeeded()
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
@@ -47,12 +48,7 @@ public extension UIViewController {
|
||||
button.stopProgress()
|
||||
view.layoutIfNeeded()
|
||||
|
||||
UIView.animate(
|
||||
withDuration: 0.5,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 1.0,
|
||||
initialSpringVelocity: 0.8
|
||||
) {
|
||||
performWithAnimation {
|
||||
button.alpha = 1
|
||||
button.transform = .identity
|
||||
button.setNeedsLayout()
|
||||
@@ -77,12 +73,7 @@ public extension UIViewController {
|
||||
button.stopProgress()
|
||||
button.setNeedsLayout()
|
||||
view.layoutIfNeeded()
|
||||
UIView.animate(
|
||||
withDuration: 0.5,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 1.0,
|
||||
initialSpringVelocity: 0.8
|
||||
) {
|
||||
performWithAnimation {
|
||||
button.alpha = 0
|
||||
button.transform = .init(scaleX: 0, y: 0)
|
||||
button.setNeedsLayout()
|
||||
File renamed without changes.
+56
-42
@@ -5,13 +5,26 @@
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import SwifterSwift
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
// floating button to open intelligent panel
|
||||
public class IntelligentsButton: UIView {
|
||||
let image = UIImageView()
|
||||
let background = UIView()
|
||||
let activityIndicator = UIActivityIndicatorView()
|
||||
lazy var image = UIImageView().then {
|
||||
$0.image = .init(named: "spark", in: .module, with: .none)
|
||||
$0.contentMode = .scaleAspectFit
|
||||
}
|
||||
|
||||
lazy var background = UIView().then {
|
||||
$0.backgroundColor = .init(
|
||||
light: .systemBackground,
|
||||
dark: .darkGray.withAlphaComponent(0.25)
|
||||
)
|
||||
}
|
||||
|
||||
lazy var activityIndicator = UIActivityIndicatorView()
|
||||
|
||||
public weak var delegate: (any IntelligentsButtonDelegate)? = nil {
|
||||
didSet { assert(Thread.isMainThread) }
|
||||
@@ -19,45 +32,10 @@ public class IntelligentsButton: UIView {
|
||||
|
||||
public init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
background.backgroundColor = .white
|
||||
addSubview(background)
|
||||
background.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
background.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
background.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
background.topAnchor.constraint(equalTo: topAnchor),
|
||||
background.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
image.image = .init(named: "spark", in: .module, with: .none)
|
||||
image.contentMode = .scaleAspectFit
|
||||
image.tintColor = .accent
|
||||
addSubview(image)
|
||||
let imageInsetValue: CGFloat = 12
|
||||
image.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
image.leadingAnchor.constraint(equalTo: leadingAnchor, constant: imageInsetValue),
|
||||
image.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -imageInsetValue),
|
||||
image.topAnchor.constraint(equalTo: topAnchor, constant: imageInsetValue),
|
||||
image.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -imageInsetValue),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(activityIndicator)
|
||||
[
|
||||
activityIndicator.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
activityIndicator.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
clipsToBounds = true
|
||||
layer.borderWidth = 2
|
||||
layer.borderColor = UIColor.gray.withAlphaComponent(0.1).cgColor
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
|
||||
addGestureRecognizer(tap)
|
||||
isUserInteractionEnabled = true
|
||||
|
||||
setupViews()
|
||||
setupConstraints()
|
||||
setupGesture()
|
||||
setupAppearance()
|
||||
stopProgress()
|
||||
}
|
||||
|
||||
@@ -97,3 +75,39 @@ public class IntelligentsButton: UIView {
|
||||
image.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Setup Methods
|
||||
|
||||
private extension IntelligentsButton {
|
||||
func setupViews() {
|
||||
addSubview(background)
|
||||
addSubview(image)
|
||||
addSubview(activityIndicator)
|
||||
}
|
||||
|
||||
func setupConstraints() {
|
||||
background.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
image.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(12)
|
||||
}
|
||||
|
||||
activityIndicator.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
func setupGesture() {
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
|
||||
addGestureRecognizer(tap)
|
||||
isUserInteractionEnabled = true
|
||||
}
|
||||
|
||||
func setupAppearance() {
|
||||
clipsToBounds = true
|
||||
layer.borderWidth = 2
|
||||
layer.borderColor = UIColor.gray.withAlphaComponent(0.1).cgColor
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
protocol MainHeaderViewDelegate: AnyObject {
|
||||
func mainHeaderViewDidTapClose()
|
||||
func mainHeaderViewDidTapDropdown()
|
||||
func mainHeaderViewDidTapMenu()
|
||||
}
|
||||
|
||||
class MainHeaderView: UIView {
|
||||
weak var delegate: MainHeaderViewDelegate?
|
||||
|
||||
private lazy var closeButton = UIButton(type: .system).then {
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.setImage(UIImage.affineClose, for: .normal)
|
||||
$0.tintColor = UIColor.affineIconPrimary
|
||||
$0.backgroundColor = UIColor.affineLayerBackgroundSecondary
|
||||
$0.layer.cornerRadius = 8
|
||||
$0.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside)
|
||||
$0.setContentHuggingPriority(.required, for: .horizontal)
|
||||
}
|
||||
|
||||
private lazy var titleLabel = UILabel().then {
|
||||
$0.text = "AFFiNE AI"
|
||||
$0.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
$0.textColor = UIColor.affineTextPrimary
|
||||
$0.textAlignment = .center
|
||||
}
|
||||
|
||||
private lazy var dropdownButton = UIButton(type: .system).then {
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.setImage(UIImage.affineArrowDown, for: .normal)
|
||||
$0.tintColor = UIColor.affineIconPrimary
|
||||
$0.addTarget(self, action: #selector(dropdownButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private lazy var centerStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 8
|
||||
$0.alignment = .center
|
||||
$0.addArrangedSubview(titleLabel)
|
||||
$0.addArrangedSubview(dropdownButton)
|
||||
}
|
||||
|
||||
private lazy var menuButton = UIButton(type: .system).then {
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.setImage(UIImage.affineMore, for: .normal)
|
||||
$0.tintColor = UIColor.affineIconPrimary
|
||||
$0.backgroundColor = UIColor.affineLayerBackgroundSecondary
|
||||
$0.layer.cornerRadius = 8
|
||||
$0.addTarget(self, action: #selector(menuButtonTapped), for: .touchUpInside)
|
||||
$0.setContentHuggingPriority(.required, for: .horizontal)
|
||||
}
|
||||
|
||||
private lazy var leftSpacerView = UIView()
|
||||
|
||||
private lazy var rightSpacerView = UIView()
|
||||
|
||||
private lazy var mainStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.alignment = .center
|
||||
$0.distribution = .fill
|
||||
$0.spacing = 16
|
||||
$0.addArrangedSubview(closeButton)
|
||||
$0.addArrangedSubview(leftSpacerView)
|
||||
$0.addArrangedSubview(centerStackView)
|
||||
$0.addArrangedSubview(rightSpacerView)
|
||||
$0.addArrangedSubview(menuButton)
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
backgroundColor = UIColor.affineLayerBackgroundPrimary
|
||||
addSubview(mainStackView)
|
||||
|
||||
mainStackView.snp.makeConstraints { make in
|
||||
make.left.right.equalToSuperview().inset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
|
||||
closeButton.snp.makeConstraints { make in
|
||||
make.size.equalTo(titleLabel.font.pointSize + 16)
|
||||
}
|
||||
|
||||
menuButton.snp.makeConstraints { make in
|
||||
make.size.equalTo(titleLabel.font.pointSize + 16)
|
||||
}
|
||||
|
||||
dropdownButton.snp.makeConstraints { make in
|
||||
make.size.equalTo(titleLabel.font.pointSize + 16)
|
||||
}
|
||||
|
||||
// ensure center stack to be center
|
||||
leftSpacerView.snp.makeConstraints { make in
|
||||
make.width.equalTo(rightSpacerView)
|
||||
}
|
||||
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(52)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@objc private func closeButtonTapped() {
|
||||
delegate?.mainHeaderViewDidTapClose()
|
||||
}
|
||||
|
||||
@objc private func dropdownButtonTapped() {
|
||||
delegate?.mainHeaderViewDidTapDropdown()
|
||||
}
|
||||
|
||||
@objc private func menuButtonTapped() {
|
||||
delegate?.mainHeaderViewDidTapMenu()
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
+56
@@ -0,0 +1,56 @@
|
||||
import UIKit
|
||||
|
||||
class DeleteButtonView: UIView {
|
||||
let imageView = UIImageView(image: .init(systemName: "xmark")).then {
|
||||
$0.tintColor = .white
|
||||
$0.contentMode = .scaleAspectFit
|
||||
}
|
||||
|
||||
let blur = UIVisualEffectView(
|
||||
effect: UIBlurEffect(style: .systemUltraThinMaterialDark)
|
||||
).then {
|
||||
$0.clipsToBounds = true
|
||||
}
|
||||
|
||||
var onTapped: () -> Void = {}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
isUserInteractionEnabled = true
|
||||
|
||||
addSubview(blur)
|
||||
addSubview(imageView)
|
||||
|
||||
blur.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(2)
|
||||
}
|
||||
|
||||
let gesture = UITapGestureRecognizer(target: self, action: #selector(tapped))
|
||||
addGestureRecognizer(gesture)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
||||
if bounds.width < 50 || bounds.height < 50 {
|
||||
return bounds.insetBy(dx: -20, dy: -20).contains(point)
|
||||
}
|
||||
return super.point(inside: point, with: event)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
blur.layer.cornerRadius = min(bounds.width, bounds.height) / 2
|
||||
}
|
||||
|
||||
@objc func tapped() {
|
||||
onTapped()
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// IntelligentContext.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/17/25.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
public class IntelligentContext {
|
||||
// shared across the app, we expect our app to have a single context and webview
|
||||
public static let shared = IntelligentContext()
|
||||
|
||||
public var webView: WKWebView!
|
||||
|
||||
public lazy var temporaryDirectory: URL = {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
return tempDir.appendingPathComponent("IntelligentContext")
|
||||
}()
|
||||
|
||||
private init() {}
|
||||
|
||||
public func preparePresent(_ completion: @escaping () -> Void) {
|
||||
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
||||
prepareTemporaryDirectory()
|
||||
// TODO: used to gathering information, populate content from webview, etc.
|
||||
DispatchQueue.main.async {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func prepareTemporaryDirectory() {
|
||||
if FileManager.default.fileExists(atPath: temporaryDirectory.path) {
|
||||
try? FileManager.default.removeItem(at: temporaryDirectory)
|
||||
}
|
||||
try? FileManager.default.createDirectory(
|
||||
at: temporaryDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import UIKit
|
||||
|
||||
extension UIColor {
|
||||
/// Primary icon color
|
||||
static var affineIconPrimary: UIColor {
|
||||
UIColor(named: "affine.icon.primary", in: .module, compatibleWith: nil) ?? .black
|
||||
}
|
||||
|
||||
/// Primary background layer color
|
||||
static var affineLayerBackgroundPrimary: UIColor {
|
||||
UIColor(named: "affine.layer.background.primary", in: .module, compatibleWith: nil) ?? .white
|
||||
}
|
||||
|
||||
/// Secondary background layer color
|
||||
static var affineLayerBackgroundSecondary: UIColor {
|
||||
UIColor(named: "affine.layer.background.secondary", in: .module, compatibleWith: nil) ?? .systemGray6
|
||||
}
|
||||
|
||||
/// Border layer color
|
||||
static var affineLayerBorder: UIColor {
|
||||
UIColor(named: "affine.layer.border", in: .module, compatibleWith: nil) ?? .gray
|
||||
}
|
||||
|
||||
/// Pure white layer color
|
||||
static var affineLayerPureWhite: UIColor {
|
||||
UIColor(named: "affine.layer.pureWhite", in: .module, compatibleWith: nil) ?? .white
|
||||
}
|
||||
|
||||
/// Primary button color
|
||||
static var affineButtonPrimary: UIColor {
|
||||
UIColor(named: "affine.button.primary", in: .module, compatibleWith: nil) ?? .blue
|
||||
}
|
||||
|
||||
/// Activated icon color
|
||||
static var affineIconActivated: UIColor {
|
||||
UIColor(named: "affine.icon.activated", in: .module, compatibleWith: nil) ?? .blue
|
||||
}
|
||||
|
||||
/// Text emphasis color
|
||||
static var affineTextEmphasis: UIColor {
|
||||
UIColor(named: "affine.text.emphasis", in: .module, compatibleWith: nil) ?? .blue
|
||||
}
|
||||
|
||||
/// Text link color
|
||||
static var affineTextLink: UIColor {
|
||||
UIColor(named: "affine.text.link", in: .module, compatibleWith: nil) ?? .blue
|
||||
}
|
||||
|
||||
/// List dot and number color
|
||||
static var affineTextListDotAndNumber: UIColor {
|
||||
UIColor(named: "affine.text.listDotAndNumber", in: .module, compatibleWith: nil) ?? .blue
|
||||
}
|
||||
|
||||
/// Placeholder text color
|
||||
static var affineTextPlaceholder: UIColor {
|
||||
UIColor(named: "affine.text.placeholder", in: .module, compatibleWith: nil) ?? .gray
|
||||
}
|
||||
|
||||
/// Primary text color
|
||||
static var affineTextPrimary: UIColor {
|
||||
UIColor(named: "affine.text.primary", in: .module, compatibleWith: nil) ?? .black
|
||||
}
|
||||
|
||||
/// Pure white text color
|
||||
static var affineTextPureWhite: UIColor {
|
||||
UIColor(named: "affine.text.pureWhite", in: .module, compatibleWith: nil) ?? .white
|
||||
}
|
||||
|
||||
/// Secondary text color
|
||||
static var affineTextSecondary: UIColor {
|
||||
UIColor(named: "affine.text.secondary", in: .module, compatibleWith: nil) ?? .gray
|
||||
}
|
||||
|
||||
/// Tertiary text color
|
||||
static var affineTextTertiary: UIColor {
|
||||
UIColor(named: "affine.text.tertiary", in: .module, compatibleWith: nil) ?? .gray
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import UIKit
|
||||
|
||||
extension UIImage {
|
||||
/// Check circle icon
|
||||
static var affineCheckCircle: UIImage {
|
||||
UIImage(named: "CheckCircle", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// More options icon
|
||||
static var affineMore: UIImage {
|
||||
UIImage(named: "More", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Arrow down icon
|
||||
static var affineArrowDown: UIImage {
|
||||
UIImage(named: "ArrowDown", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Tools icon
|
||||
static var affineTools: UIImage {
|
||||
UIImage(named: "Tools", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Box icon
|
||||
static var affineBox: UIImage {
|
||||
UIImage(named: "Box", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Think icon
|
||||
static var affineThink: UIImage {
|
||||
UIImage(named: "Think", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Web icon
|
||||
static var affineWeb: UIImage {
|
||||
UIImage(named: "Web", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Calendar icon
|
||||
static var affineCalendar: UIImage {
|
||||
UIImage(named: "Calendar", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Camera icon
|
||||
static var affineCamera: UIImage {
|
||||
UIImage(named: "Camera", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Close icon
|
||||
static var affineClose: UIImage {
|
||||
UIImage(named: "Close", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Big arrow up icon
|
||||
static var affineArrowUpBig: UIImage {
|
||||
UIImage(named: "ArrowUpBig", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Broom icon
|
||||
static var affineBroom: UIImage {
|
||||
UIImage(named: "Broom", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Bubble icon
|
||||
static var affineBubble: UIImage {
|
||||
UIImage(named: "Bubble", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Image icon
|
||||
static var affineImage: UIImage {
|
||||
UIImage(named: "Image", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Page icon
|
||||
static var affinePage: UIImage {
|
||||
UIImage(named: "Page", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Plus icon
|
||||
static var affinePlus: UIImage {
|
||||
UIImage(named: "Plus", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Settings icon
|
||||
static var affineSettings: UIImage {
|
||||
UIImage(named: "Settings", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
|
||||
/// Upload icon
|
||||
static var affineUpload: UIImage {
|
||||
UIImage(named: "Upload", in: .module, compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xEB",
|
||||
"green" : "0x96",
|
||||
"red" : "0x1E"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xEB",
|
||||
"green" : "0x96",
|
||||
"red" : "0x1E"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xEB",
|
||||
"green" : "0x96",
|
||||
"red" : "0x1E"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xEB",
|
||||
"green" : "0x96",
|
||||
"red" : "0x1E"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x7A",
|
||||
"green" : "0x7A",
|
||||
"red" : "0x7A"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xF3",
|
||||
"green" : "0xF3",
|
||||
"red" : "0xF3"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFF"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x14",
|
||||
"green" : "0x14",
|
||||
"red" : "0x14"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xF5",
|
||||
"green" : "0xF5",
|
||||
"red" : "0xF5"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x25",
|
||||
"green" : "0x25",
|
||||
"red" : "0x25"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xE6",
|
||||
"green" : "0xE6",
|
||||
"red" : "0xE6"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x41",
|
||||
"green" : "0x41",
|
||||
"red" : "0x41"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFF"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "255",
|
||||
"green" : "255",
|
||||
"red" : "255"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xEB",
|
||||
"green" : "0x96",
|
||||
"red" : "0x1E"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xEB",
|
||||
"green" : "0x96",
|
||||
"red" : "0x1E"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x9F",
|
||||
"green" : "0x5F",
|
||||
"red" : "0x03"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x9F",
|
||||
"green" : "0x5F",
|
||||
"red" : "0x03"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xCB",
|
||||
"green" : "0x77",
|
||||
"red" : "0x00"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xCB",
|
||||
"green" : "0x77",
|
||||
"red" : "0x00"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x92",
|
||||
"green" : "0x92",
|
||||
"red" : "0x92"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x7A",
|
||||
"green" : "0x7A",
|
||||
"red" : "0x7A"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x14",
|
||||
"green" : "0x14",
|
||||
"red" : "0x14"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xE6",
|
||||
"green" : "0xE6",
|
||||
"red" : "0xE6"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFF"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFF"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x7A",
|
||||
"green" : "0x7A",
|
||||
"red" : "0x7A"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x92",
|
||||
"green" : "0x92",
|
||||
"red" : "0x92"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x92",
|
||||
"green" : "0x92",
|
||||
"red" : "0x92"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x7A",
|
||||
"green" : "0x7A",
|
||||
"red" : "0x7A"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "More Options Icon.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ArrowUpBig.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Left icon-5.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Broom.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Bubble.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Left icon-1.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user