From ea1e7076d74cbf96d245ce0f41dfad35fbe75fd5 Mon Sep 17 00:00:00 2001 From: Lakr Date: Wed, 18 Jun 2025 18:52:44 +0800 Subject: [PATCH 1/8] chroe: input box view model --- .../ios/App/App.xcodeproj/project.pbxproj | 8 +- .../App/Packages/Intelligents/Package.swift | 2 +- .../MainViewController.swift | 35 ++-- .../Interface/View/InputBox/InputBox.swift | 66 ++++++- .../View/InputBox/InputBoxViewModel.swift | 172 ++++++++++++++++++ .../Model/IntelligentContext.swift | 3 + 6 files changed, 265 insertions(+), 21 deletions(-) create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj index 20e68e423c..44b56f612f 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ @@ -77,6 +77,8 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ C45499AB2D140B5000E21978 /* NBStore */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = NBStore; sourceTree = ""; }; @@ -324,13 +326,9 @@ ); inputFileListPaths = ( ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n"; diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift index 76dd137cf9..eb76742bed 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift @@ -27,7 +27,7 @@ let package = Package( .product(name: "Apollo", package: "apollo-ios"), .product(name: "OrderedCollections", package: "swift-collections"), ], resources: [ - .process("Interface/View/InputBox/InputBox.xcassets") + .process("Interface/View/InputBox/InputBox.xcassets"), ]), ] ) diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift index 0698ce2c02..a016245eb0 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift @@ -1,3 +1,4 @@ +import Combine import SnapKit import Then import UIKit @@ -13,6 +14,11 @@ class MainViewController: UIViewController { $0.delegate = self } + // MARK: - Properties + + private var cancellables = Set() + private let intelligentContext = IntelligentContext.shared + // MARK: - Lifecycle override func viewDidLoad() { @@ -72,27 +78,30 @@ extension MainViewController: MainHeaderViewDelegate { extension MainViewController: InputBoxDelegate { func inputBoxDidTapAddAttachment() { - + // TODO: 实现添加附件功能 + print("Add attachment tapped") } - + func inputBoxDidTapTool() { - + print("Tool toggled: \(inputBox.viewModel.isToolEnabled)") } - + func inputBoxDidTapNetwork() { - + print("Network toggled: \(inputBox.viewModel.isNetworkEnabled)") } - + func inputBoxDidTapDeepThinking() { - + print("Deep thinking toggled: \(inputBox.viewModel.isDeepThinkingEnabled)") } - - func inputBoxDidTapSend() { - + + func inputBoxDidTapSend(data: InputBoxData) { + // 处理发送逻辑 + guard !data.text.isEmpty else { return } + print("[*] send tapped with text: \(data.text)") } - + func inputBoxTextDidChange(_ text: String) { - + // 可以在这里处理文本变化的其他逻辑 + print("Text changed: \(text)") } - } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift index dbb20ae53f..050acf2c3f 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift @@ -1,3 +1,4 @@ +import Combine import SnapKit import Then import UIKit @@ -7,13 +8,18 @@ protocol InputBoxDelegate: AnyObject { func inputBoxDidTapTool() func inputBoxDidTapNetwork() func inputBoxDidTapDeepThinking() - func inputBoxDidTapSend() + func inputBoxDidTapSend(data: InputBoxData) func inputBoxTextDidChange(_ text: String) } class InputBox: UIView { weak var delegate: InputBoxDelegate? + // MARK: - ViewModel + + public let viewModel = InputBoxViewModel() + private var cancellables = Set() + private lazy var containerView = UIView().then { $0.backgroundColor = .systemBackground $0.layer.cornerRadius = 12 @@ -136,6 +142,7 @@ class InputBox: UIView { super.init(frame: .zero) setupViews() setupConstraints() + setupBindings() updatePlaceholderVisibility() } @@ -151,6 +158,56 @@ class InputBox: UIView { containerView.addSubview(placeholderLabel) } + private func setupBindings() { + // 绑定 ViewModel 到 UI + viewModel.$inputText + .sink { [weak self] text in + if self?.textView.text != text { + self?.textView.text = text + self?.updatePlaceholderVisibility() + self?.updateTextViewHeight() + } + } + .store(in: &cancellables) + + viewModel.$isToolEnabled + .sink { [weak self] enabled in + self?.toolButton.isSelected = enabled + self?.toolButton.tintColor = enabled ? .systemBlue : .secondaryLabel + } + .store(in: &cancellables) + + viewModel.$isNetworkEnabled + .sink { [weak self] enabled in + self?.webButton.isSelected = enabled + self?.webButton.tintColor = enabled ? .systemBlue : .secondaryLabel + } + .store(in: &cancellables) + + viewModel.$isDeepThinkingEnabled + .sink { [weak self] enabled in + self?.reactButton.isSelected = enabled + self?.reactButton.tintColor = enabled ? .systemBlue : .secondaryLabel + } + .store(in: &cancellables) + + viewModel.$canSend + .sink { [weak self] canSend in + self?.sendButton.isEnabled = canSend + self?.sendButton.alpha = canSend ? 1.0 : 0.5 + } + .store(in: &cancellables) + + viewModel.$isSending + .sink { [weak self] isSending in + self?.sendButton.isEnabled = !isSending + if isSending { + // TODO: 添加加载动画 + } + } + .store(in: &cancellables) + } + private func setupConstraints() { containerView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) @@ -212,19 +269,23 @@ class InputBox: UIView { } @objc private func toolButtonTapped() { + viewModel.toggleTool() delegate?.inputBoxDidTapTool() } @objc private func webButtonTapped() { + viewModel.toggleNetwork() delegate?.inputBoxDidTapNetwork() } @objc private func reactButtonTapped() { + viewModel.toggleDeepThinking() delegate?.inputBoxDidTapDeepThinking() } @objc private func sendButtonTapped() { - delegate?.inputBoxDidTapSend() + let data = viewModel.prepareSendData() + delegate?.inputBoxDidTapSend(data: data) } } @@ -234,6 +295,7 @@ extension InputBox: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { updatePlaceholderVisibility() updateTextViewHeight() + viewModel.updateText(textView.text) delegate?.inputBoxTextDidChange(textView.text) } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift new file mode 100644 index 0000000000..d59faca7ff --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift @@ -0,0 +1,172 @@ +// +// InputBoxViewModel.swift +// Intelligents +// +// Created by AI Assistant on 6/17/25. +// + +import Combine +import Foundation + +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 isSending: Bool = false + + // MARK: - Private Properties + + private var cancellables = Set() + + // 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: - Public Properties + + @Published public var canSend: Bool = false + + // MARK: - Public Methods + + public func updateText(_ text: String) { + inputText = text + } + + public func toggleTool() { + isToolEnabled.toggle() + } + + public func toggleNetwork() { + isNetworkEnabled.toggle() + } + + public func toggleDeepThinking() { + isDeepThinkingEnabled.toggle() + } + + public func addAttachment(_ attachment: InputAttachment) { + attachments.append(attachment) + } + + public func removeAttachment(at index: Int) { + guard index < attachments.count else { return } + attachments.remove(at: index) + } + + public func clearAttachments() { + attachments.removeAll() + } + + public func prepareSendData() -> InputBoxData { + InputBoxData( + text: inputText.trimmingCharacters(in: .whitespacesAndNewlines), + attachments: attachments, + isToolEnabled: isToolEnabled, + isNetworkEnabled: isNetworkEnabled, + isDeepThinkingEnabled: isDeepThinkingEnabled + ) + } + + public func resetInput() { + inputText = "" + attachments.removeAll() + isToolEnabled = false + isNetworkEnabled = false + isDeepThinkingEnabled = false + isSending = false + } + + public func setSending(_ sending: Bool) { + isSending = sending + } +} + +// MARK: - Supporting Types + +public struct InputBoxData { + public let text: String + public let attachments: [InputAttachment] + public let isToolEnabled: Bool + public let isNetworkEnabled: Bool + public let 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 + } +} + +public struct InputAttachment { + public let id: String + public let type: AttachmentType + public let data: Data? + public let url: URL? + public let name: String + public let size: Int64 + + public init( + id: String = UUID().uuidString, + type: AttachmentType, + data: Data? = nil, + url: URL? = nil, + name: String, + size: Int64 = 0 + ) { + self.id = id + self.type = type + self.data = data + self.url = url + self.name = name + self.size = size + } +} + +public enum AttachmentType { + case image + case document + case video + case audio + case other(String) + + public var displayName: String { + switch self { + case .image: "Image" + case .document: "Document" + case .video: "Video" + case .audio: "Audio" + case let .other(type): type + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/IntelligentContext.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/IntelligentContext.swift index 369c707c1b..71a7b173e0 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/IntelligentContext.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/IntelligentContext.swift @@ -5,6 +5,7 @@ // Created by 秋星桥 on 6/17/25. // +import Combine import Foundation import WebKit @@ -21,4 +22,6 @@ public class IntelligentContext { // TODO: if needed completion() } + + // MARK: - Input Processing } From c19ef055344886b6a4f548627e2f02b36d7fc4d3 Mon Sep 17 00:00:00 2001 From: Lakr Date: Wed, 18 Jun 2025 19:58:37 +0800 Subject: [PATCH 2/8] chore: input box ux --- .../BlurTransition.swift | 12 +- .../MainViewController.swift | 46 +-- .../Interface/View/InputBox/InputBox.swift | 237 ++++++---------- .../View/InputBox/InputBoxDelegate.swift | 29 ++ .../View/InputBox/InputBoxFunctionBar.swift | 155 ++++++++++ .../View/InputBox/InputBoxImageBar.swift | 152 ++++++++++ .../View/InputBox/InputBoxViewModel.swift | 265 +++++++++--------- 7 files changed, 598 insertions(+), 298 deletions(-) create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/IntelligentsController/BlurTransition.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/IntelligentsController/BlurTransition.swift index 02587fa28a..7a01044370 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/IntelligentsController/BlurTransition.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/IntelligentsController/BlurTransition.swift @@ -35,7 +35,7 @@ class BlurTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { private let presenting: Bool - private let duration: TimeInterval = 0.5 + private let duration: TimeInterval = 0.75 private let snapshotViewTag = "snapshotView".hashValue private let blurViewTag = "blurView".hashValue @@ -52,8 +52,8 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { UIView.animate( withDuration: duration, delay: 0, - usingSpringWithDamping: 0.75, - initialSpringVelocity: 0.75, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0.8, options: [.beginFromCurrentState, .allowAnimatedContent, .curveEaseInOut], animations: animations, completion: completion @@ -106,11 +106,15 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { toView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05) containerView.addSubview(toView) + toView.layoutIfNeeded() + performAnimation(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 { @@ -157,6 +161,8 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { toView.isHidden = false fromViewSnapshot.removeFromSuperview() blurEffectView.removeFromSuperview() + fromView.layoutIfNeeded() + toView.layoutIfNeeded() } else { assertionFailure() fromView.transform = .identity diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift index a016245eb0..456f7b2ee9 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift @@ -23,7 +23,9 @@ class MainViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() + view.layoutIfNeeded() setupUI() + view.layoutIfNeeded() } override func viewWillAppear(_ animated: Bool) { @@ -31,6 +33,11 @@ class MainViewController: UIViewController { navigationController!.setNavigationBarHidden(true, animated: animated) } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + inputBox.textView.becomeFirstResponder() + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) navigationController!.setNavigationBarHidden(false, animated: animated) @@ -41,6 +48,19 @@ class MainViewController: UIViewController { private func setupUI() { view.backgroundColor = .systemBackground + // 计算 InputBox 的初始 frame 以避免布局动画 + let inputBoxFrame = CGRect( + x: 0, + y: view.bounds.height - 150, // 预估高度 + width: view.bounds.width, + height: 150 + ) + let inputBox = InputBox(frame: inputBoxFrame).then { + $0.delegate = self + } + self.inputBox = inputBox + self.inputBox.layoutIfNeeded() + view.addSubview(headerView) view.addSubview(inputBox) @@ -77,31 +97,15 @@ extension MainViewController: MainHeaderViewDelegate { // MARK: - InputBoxDelegate extension MainViewController: InputBoxDelegate { - func inputBoxDidTapAddAttachment() { - // TODO: 实现添加附件功能 - print("Add attachment tapped") + func inputBoxDidSelectAttachment(_ inputBox: InputBox) { + print(#function, inputBox) } - func inputBoxDidTapTool() { - print("Tool toggled: \(inputBox.viewModel.isToolEnabled)") - } - - func inputBoxDidTapNetwork() { - print("Network toggled: \(inputBox.viewModel.isNetworkEnabled)") - } - - func inputBoxDidTapDeepThinking() { - print("Deep thinking toggled: \(inputBox.viewModel.isDeepThinkingEnabled)") - } - - func inputBoxDidTapSend(data: InputBoxData) { - // 处理发送逻辑 - guard !data.text.isEmpty else { return } - print("[*] send tapped with text: \(data.text)") + func inputBoxDidSend(_ inputBox: InputBox) { + print(#function, inputBox, inputBox.viewModel) } func inputBoxTextDidChange(_ text: String) { - // 可以在这里处理文本变化的其他逻辑 - print("Text changed: \(text)") + print(#function, text) } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift index 050acf2c3f..1092968c2e 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift @@ -3,24 +3,13 @@ import SnapKit import Then import UIKit -protocol InputBoxDelegate: AnyObject { - func inputBoxDidTapAddAttachment() - func inputBoxDidTapTool() - func inputBoxDidTapNetwork() - func inputBoxDidTapDeepThinking() - func inputBoxDidTapSend(data: InputBoxData) - func inputBoxTextDidChange(_ text: String) -} - class InputBox: UIView { weak var delegate: InputBoxDelegate? - // MARK: - ViewModel - public let viewModel = InputBoxViewModel() - private var cancellables = Set() + var cancellables = Set() - private lazy var containerView = UIView().then { + lazy var containerView = UIView().then { $0.backgroundColor = .systemBackground $0.layer.cornerRadius = 12 $0.layer.borderWidth = 0.5 @@ -32,7 +21,7 @@ class InputBox: UIView { $0.clipsToBounds = false } - private lazy var textView = UITextView().then { + lazy var textView = UITextView().then { $0.backgroundColor = .clear $0.font = .systemFont(ofSize: 16) $0.textColor = .label @@ -43,91 +32,30 @@ class InputBox: UIView { $0.text = "This is AFFiNE AI" } - private lazy var placeholderLabel = UILabel().then { + lazy var placeholderLabel = UILabel().then { $0.text = "Write your message..." $0.font = .systemFont(ofSize: 16) $0.textColor = .systemGray3 $0.isHidden = true } - private lazy var addButton = UIButton(type: .system).then { - $0.backgroundColor = .systemBackground - $0.layer.cornerRadius = 6 - $0.layer.borderWidth = 0.5 - $0.layer.borderColor = UIColor.systemGray4.cgColor - $0.setImage(UIImage(named: "inputbox.add.attachment", in: .module, with: nil), for: .normal) - $0.tintColor = .secondaryLabel - $0.imageView?.contentMode = .scaleAspectFit - $0.addTarget(self, action: #selector(addButtonTapped), for: .touchUpInside) + lazy var functionBar = InputBoxFunctionBar().then { + $0.delegate = self } - private lazy var toolButton = UIButton(type: .system).then { - $0.setImage(UIImage(named: "inputbox.tool", in: .module, with: nil), for: .normal) - $0.tintColor = .secondaryLabel - $0.imageView?.contentMode = .scaleAspectFit - $0.addTarget(self, action: #selector(toolButtonTapped), for: .touchUpInside) - } + lazy var imageBar = InputBoxImageBar(frame: bounds) - private lazy var webButton = UIButton(type: .system).then { - $0.setImage(UIImage(named: "inputbox.network", in: .module, with: nil), for: .normal) - $0.tintColor = .secondaryLabel - $0.imageView?.contentMode = .scaleAspectFit - $0.addTarget(self, action: #selector(webButtonTapped), for: .touchUpInside) - } - - private lazy var reactButton = UIButton(type: .system).then { - $0.setImage(UIImage(named: "inputbox.deep.thinking", in: .module, with: nil), for: .normal) - $0.tintColor = .secondaryLabel - $0.imageView?.contentMode = .scaleAspectFit - $0.addTarget(self, action: #selector(reactButtonTapped), for: .touchUpInside) - } - - private lazy var sendButton = UIButton(type: .system).then { - $0.backgroundColor = UIColor.systemBlue - $0.layer.cornerRadius = 19 - $0.setImage(UIImage(named: "inputbox.send", in: .module, with: nil), for: .normal) - $0.tintColor = .white - $0.imageView?.contentMode = .scaleAspectFit - $0.addTarget(self, action: #selector(sendButtonTapped), for: .touchUpInside) - } - - private lazy var leftButtonsStackView = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 16 - $0.alignment = .center - $0.addArrangedSubview(addButton) - } - - private lazy var rightButtonsStackView = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 16 - $0.alignment = .center - $0.addArrangedSubview(toolButton) - $0.addArrangedSubview(webButton) - $0.addArrangedSubview(reactButton) - $0.addArrangedSubview(sendButton) - } - - private lazy var functionsStackView = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 12 - $0.alignment = .center - $0.addArrangedSubview(leftButtonsStackView) - $0.addArrangedSubview(UIView()) // spacer - $0.addArrangedSubview(rightButtonsStackView) - } - - private lazy var mainStackView = UIStackView().then { + lazy var mainStackView = UIStackView().then { $0.axis = .vertical $0.spacing = 16 $0.alignment = .fill $0.addArrangedSubview(textView) - $0.addArrangedSubview(functionsStackView) + $0.addArrangedSubview(functionBar) } - private var textViewHeightConstraint: Constraint? - private let minTextViewHeight: CGFloat = 22 - private let maxTextViewHeight: CGFloat = 100 + var textViewHeightConstraint: Constraint? + let minTextViewHeight: CGFloat = 22 + let maxTextViewHeight: CGFloat = 100 var text: String { get { textView.text ?? "" } @@ -137,9 +65,9 @@ class InputBox: UIView { updateTextViewHeight() } } - - init() { - super.init(frame: .zero) + + override init(frame: CGRect = .zero) { + super.init(frame: frame) setupViews() setupConstraints() setupBindings() @@ -151,14 +79,16 @@ class InputBox: UIView { fatalError("init(coder:) has not been implemented") } - private func setupViews() { + func setupViews() { backgroundColor = .clear addSubview(containerView) containerView.addSubview(mainStackView) containerView.addSubview(placeholderLabel) + + imageBar.imageBarDelegate = self } - private func setupBindings() { + func setupBindings() { // 绑定 ViewModel 到 UI viewModel.$inputText .sink { [weak self] text in @@ -172,43 +102,42 @@ class InputBox: UIView { viewModel.$isToolEnabled .sink { [weak self] enabled in - self?.toolButton.isSelected = enabled - self?.toolButton.tintColor = enabled ? .systemBlue : .secondaryLabel + self?.functionBar.updateToolState(isEnabled: enabled) } .store(in: &cancellables) viewModel.$isNetworkEnabled .sink { [weak self] enabled in - self?.webButton.isSelected = enabled - self?.webButton.tintColor = enabled ? .systemBlue : .secondaryLabel + self?.functionBar.updateNetworkState(isEnabled: enabled) } .store(in: &cancellables) viewModel.$isDeepThinkingEnabled .sink { [weak self] enabled in - self?.reactButton.isSelected = enabled - self?.reactButton.tintColor = enabled ? .systemBlue : .secondaryLabel + self?.functionBar.updateDeepThinkingState(isEnabled: enabled) } .store(in: &cancellables) viewModel.$canSend .sink { [weak self] canSend in - self?.sendButton.isEnabled = canSend - self?.sendButton.alpha = canSend ? 1.0 : 0.5 + self?.functionBar.updateSendState(canSend: canSend) } .store(in: &cancellables) - viewModel.$isSending - .sink { [weak self] isSending in - self?.sendButton.isEnabled = !isSending - if isSending { - // TODO: 添加加载动画 - } + viewModel.$hasAttachments + .sink { [weak self] hasAttachments in + self?.updateImageBarVisibility(hasAttachments) + } + .store(in: &cancellables) + + viewModel.$attachments + .sink { [weak self] attachments in + self?.updateImageBarContent(attachments) } .store(in: &cancellables) } - private func setupConstraints() { + func setupConstraints() { containerView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } @@ -217,20 +146,6 @@ class InputBox: UIView { make.edges.equalToSuperview().inset(16) } - addButton.snp.makeConstraints { make in - make.size.equalTo(38) - } - - for button in [toolButton, webButton, reactButton] { - button.snp.makeConstraints { make in - make.size.equalTo(24) - } - } - - sendButton.snp.makeConstraints { make in - make.size.equalTo(38) - } - textView.snp.makeConstraints { make in textViewHeightConstraint = make.height.equalTo(minTextViewHeight).constraint } @@ -241,61 +156,93 @@ class InputBox: UIView { } } - private func updateTextViewHeight() { + 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 } + UIView.animate( withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 1.0, - options: [.curveEaseInOut] + options: .curveEaseInOut ) { self.layoutIfNeeded() self.superview?.layoutIfNeeded() } } - private func updatePlaceholderVisibility() { + func updatePlaceholderVisibility() { placeholderLabel.isHidden = !textView.text.isEmpty } - @objc private func addButtonTapped() { - delegate?.inputBoxDidTapAddAttachment() + func updateImageBarVisibility(_ hasAttachments: Bool) { + if hasAttachments, !mainStackView.arrangedSubviews.contains(imageBar) { + mainStackView.insertArrangedSubview(imageBar, at: 1) + } else if !hasAttachments, mainStackView.arrangedSubviews.contains(imageBar) { + mainStackView.removeArrangedSubview(imageBar) + imageBar.removeFromSuperview() + } } - @objc private func toolButtonTapped() { + func updateImageBarContent(_ attachments: [InputAttachment]) { + imageBar.clear() + + for attachment in attachments { + if attachment.type == .image, let data = attachment.data, let image = UIImage(data: data) { + imageBar.addImage(image) + } + } + } + + // 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) + ) + + viewModel.addAttachment(attachment) + } + + public var inputBoxData: InputBoxData { + viewModel.prepareSendData() + } +} + +// MARK: - InputBoxFunctionBarDelegate + +extension InputBox: InputBoxFunctionBarDelegate { + func functionBarDidTapAttachment(_: InputBoxFunctionBar) { + delegate?.inputBoxDidSelectAttachment(self) + } + + func functionBarDidTapTool(_: InputBoxFunctionBar) { viewModel.toggleTool() - delegate?.inputBoxDidTapTool() } - @objc private func webButtonTapped() { + func functionBarDidTapNetwork(_: InputBoxFunctionBar) { viewModel.toggleNetwork() - delegate?.inputBoxDidTapNetwork() } - @objc private func reactButtonTapped() { + func functionBarDidTapDeepThinking(_: InputBoxFunctionBar) { viewModel.toggleDeepThinking() - delegate?.inputBoxDidTapDeepThinking() } - @objc private func sendButtonTapped() { - let data = viewModel.prepareSendData() - delegate?.inputBoxDidTapSend(data: data) - } -} - -// MARK: - UITextViewDelegate - -extension InputBox: UITextViewDelegate { - func textViewDidChange(_ textView: UITextView) { - updatePlaceholderVisibility() - updateTextViewHeight() - viewModel.updateText(textView.text) - delegate?.inputBoxTextDidChange(textView.text) + func functionBarDidTapSend(_: InputBoxFunctionBar) { + delegate?.inputBoxDidSend(self) } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift new file mode 100644 index 0000000000..f62a37df2a --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift @@ -0,0 +1,29 @@ +// +// InputBoxDelegate.swift +// Intelligents +// +// Created by 秋星桥 on 6/18/25. +// + +import UIKit + +protocol InputBoxDelegate: AnyObject { + func inputBoxDidSelectAttachment(_ inputBox: InputBox) + func inputBoxDidSend(_ inputBox: InputBox) + func inputBoxTextDidChange(_ text: String) +} + +extension InputBox: InputBoxImageBarDelegate { + func inputBoxImageBar(_: InputBoxImageBar, didRemoveImageAt index: Int) { + viewModel.removeAttachment(at: index) + } +} + +extension InputBox: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + viewModel.updateText(textView.text ?? "") + delegate?.inputBoxTextDidChange(textView.text ?? "") + updatePlaceholderVisibility() + updateTextViewHeight() + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift new file mode 100644 index 0000000000..52ae48188a --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift @@ -0,0 +1,155 @@ +import SnapKit +import Then +import UIKit + +protocol InputBoxFunctionBarDelegate: AnyObject { + func functionBarDidTapAttachment(_ functionBar: InputBoxFunctionBar) + func functionBarDidTapTool(_ functionBar: InputBoxFunctionBar) + func functionBarDidTapNetwork(_ functionBar: InputBoxFunctionBar) + func functionBarDidTapDeepThinking(_ functionBar: InputBoxFunctionBar) + func functionBarDidTapSend(_ functionBar: InputBoxFunctionBar) +} + +class InputBoxFunctionBar: UIView { + weak var delegate: InputBoxFunctionBarDelegate? + + lazy var attachmentButton = UIButton(type: .system).then { + $0.backgroundColor = .systemBackground + $0.setImage(UIImage(named: "inputbox.add.attachment", in: .module, with: nil), for: .normal) + $0.tintColor = .secondaryLabel + $0.imageView?.contentMode = .scaleAspectFit + $0.addTarget(self, action: #selector(attachmentButtonTapped), for: .touchUpInside) + } + + lazy var toolButton = UIButton(type: .system).then { + $0.setImage(UIImage(named: "inputbox.tool", in: .module, with: nil), for: .normal) + $0.tintColor = .secondaryLabel + $0.imageView?.contentMode = .scaleAspectFit + $0.addTarget(self, action: #selector(toolButtonTapped), for: .touchUpInside) + } + + lazy var networkButton = UIButton(type: .system).then { + $0.setImage(UIImage(named: "inputbox.network", in: .module, with: nil), for: .normal) + $0.tintColor = .secondaryLabel + $0.imageView?.contentMode = .scaleAspectFit + $0.addTarget(self, action: #selector(networkButtonTapped), for: .touchUpInside) + } + + lazy var deepThinkingButton = UIButton(type: .system).then { + $0.setImage(UIImage(named: "inputbox.deep.thinking", in: .module, with: nil), for: .normal) + $0.tintColor = .secondaryLabel + $0.imageView?.contentMode = .scaleAspectFit + $0.addTarget(self, action: #selector(deepThinkingButtonTapped), for: .touchUpInside) + } + + lazy var sendButton = UIButton(type: .system).then { + $0.backgroundColor = UIColor.systemBlue + $0.setImage(UIImage(named: "inputbox.send", in: .module, with: nil), for: .normal) + $0.tintColor = .white + $0.imageView?.contentMode = .scaleAspectFit + $0.addTarget(self, action: #selector(sendButtonTapped), for: .touchUpInside) + } + + 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) + setupViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + addSubview(stackView) + } + + private func setupConstraints() { + 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) + } + } + } + + + override func layoutSubviews() { + super.layoutSubviews() + sendButton.layer.cornerRadius = sendButton.bounds.height / 2 + } + + // MARK: - Public Methods + + func updateToolState(isEnabled: Bool) { + toolButton.isSelected = isEnabled + toolButton.tintColor = isEnabled ? .systemBlue : .secondaryLabel + } + + func updateNetworkState(isEnabled: Bool) { + networkButton.isSelected = isEnabled + networkButton.tintColor = isEnabled ? .systemBlue : .secondaryLabel + } + + func updateDeepThinkingState(isEnabled: Bool) { + deepThinkingButton.isSelected = isEnabled + deepThinkingButton.tintColor = isEnabled ? .systemBlue : .secondaryLabel + } + + func updateSendState(canSend: Bool) { + sendButton.isEnabled = canSend + sendButton.alpha = canSend ? 1.0 : 0.5 + } + + // MARK: - Actions + + @objc private func attachmentButtonTapped() { + delegate?.functionBarDidTapAttachment(self) + } + + @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) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift new file mode 100644 index 0000000000..9de8681d37 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift @@ -0,0 +1,152 @@ +// +// InputBoxImageBar.swift +// Intelligents +// +// Created by 秋星桥 on 6/18/25. +// + +import SnapKit +import Then +import UIKit + +protocol InputBoxImageBarDelegate: AnyObject { + func inputBoxImageBar(_ imageBar: InputBoxImageBar, didRemoveImageAt index: Int) +} + +private let constantHeight: CGFloat = 108 + +class InputBoxImageBar: UIScrollView { + weak var imageBarDelegate: InputBoxImageBarDelegate? + + private lazy var stackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 8 + $0.alignment = .center + $0.distribution = .equalSpacing + } + + private var imageCells: [ImageCell] = [] + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + setupViews() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + private func setupViews() { + showsHorizontalScrollIndicator = false + showsVerticalScrollIndicator = false + addSubview(stackView) + } + + private func setupConstraints() { + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.height.equalTo(constantHeight) + } + + snp.makeConstraints { make in + make.height.equalTo(constantHeight) + } + } + + func addImage(_ image: UIImage) { + let imageCell = ImageCell(image: image) + imageCell.onRemove = { [weak self] cell in + self?.removeImageCell(cell) + } + + imageCells.append(imageCell) + stackView.addArrangedSubview(imageCell) + updateContentSize() + } + + func removeImageCell(_ cell: ImageCell) { + if let index = imageCells.firstIndex(of: cell) { + imageCells.remove(at: index) + stackView.removeArrangedSubview(cell) + cell.removeFromSuperview() + imageBarDelegate?.inputBoxImageBar(self, didRemoveImageAt: index) + updateContentSize() + } + } + + func clear() { + for cell in imageCells { + stackView.removeArrangedSubview(cell) + cell.removeFromSuperview() + } + imageCells.removeAll() + updateContentSize() + } + + private func updateContentSize() { + layoutIfNeeded() + contentSize = stackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + } +} + +extension InputBoxImageBar { + class ImageCell: UIView { + var onRemove: ((ImageCell) -> Void)? + + private lazy var imageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.clipsToBounds = true + $0.layer.cornerRadius = 12 + $0.backgroundColor = .systemGray6 + } + + private lazy var removeButton = UIButton(type: .system).then { + $0.backgroundColor = UIColor.black.withAlphaComponent(0.52) + $0.layer.cornerRadius = 8.5 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1).cgColor + $0.setImage(UIImage(systemName: "xmark"), for: .normal) + $0.tintColor = .white + $0.addTarget(self, action: #selector(removeButtonTapped), for: .touchUpInside) + } + + init(image: UIImage) { + super.init(frame: .zero) + setupViews() + setupConstraints() + imageView.image = image + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + private func setupViews() { + addSubview(imageView) + addSubview(removeButton) + } + + private func setupConstraints() { + // 设置固定高度和1:1宽高比 + snp.makeConstraints { make in + make.width.height.equalTo(constantHeight) + } + + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + removeButton.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(6.5) + make.width.height.equalTo(17) + } + } + + @objc private func removeButtonTapped() { + onRemove?(self) + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift index d59faca7ff..3b7828a9fc 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift @@ -8,123 +8,23 @@ import Combine import Foundation -public class InputBoxViewModel: ObservableObject { - // MARK: - Published Properties +// MARK: - Data Models - @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 isSending: Bool = false +public enum AttachmentType: Equatable { + case image + case document + case video + case audio + case other(String) - // MARK: - Private Properties - - private var cancellables = Set() - - // 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: - Public Properties - - @Published public var canSend: Bool = false - - // MARK: - Public Methods - - public func updateText(_ text: String) { - inputText = text - } - - public func toggleTool() { - isToolEnabled.toggle() - } - - public func toggleNetwork() { - isNetworkEnabled.toggle() - } - - public func toggleDeepThinking() { - isDeepThinkingEnabled.toggle() - } - - public func addAttachment(_ attachment: InputAttachment) { - attachments.append(attachment) - } - - public func removeAttachment(at index: Int) { - guard index < attachments.count else { return } - attachments.remove(at: index) - } - - public func clearAttachments() { - attachments.removeAll() - } - - public func prepareSendData() -> InputBoxData { - InputBoxData( - text: inputText.trimmingCharacters(in: .whitespacesAndNewlines), - attachments: attachments, - isToolEnabled: isToolEnabled, - isNetworkEnabled: isNetworkEnabled, - isDeepThinkingEnabled: isDeepThinkingEnabled - ) - } - - public func resetInput() { - inputText = "" - attachments.removeAll() - isToolEnabled = false - isNetworkEnabled = false - isDeepThinkingEnabled = false - isSending = false - } - - public func setSending(_ sending: Bool) { - isSending = sending - } -} - -// MARK: - Supporting Types - -public struct InputBoxData { - public let text: String - public let attachments: [InputAttachment] - public let isToolEnabled: Bool - public let isNetworkEnabled: Bool - public let 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 + public var displayName: String { + switch self { + case .image: "Image" + case .document: "Document" + case .video: "Video" + case .audio: "Audio" + case let .other(type): type + } } } @@ -153,20 +53,127 @@ public struct InputAttachment { } } -public enum AttachmentType { - case image - case document - case video - case audio - case other(String) +public struct InputBoxData { + public let text: String + public let attachments: [InputAttachment] + public let isToolEnabled: Bool + public let isNetworkEnabled: Bool + public let isDeepThinkingEnabled: Bool - public var displayName: String { - switch self { - case .image: "Image" - case .document: "Document" - case .video: "Video" - case .audio: "Audio" - case let .other(type): type - } + 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() + + // 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(at index: Int) { + guard index < attachments.count else { return } + attachments.remove(at: index) + } + + 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 } } From 1e861e54ee8a35c25b315a1d2dbaf7fdce05c6b3 Mon Sep 17 00:00:00 2001 From: Lakr Date: Thu, 19 Jun 2025 01:08:35 +0800 Subject: [PATCH 3/8] chore: update ui --- .../xcshareddata/swiftpm/Package.resolved | 9 +++ .../App/Packages/Intelligents/Package.swift | 2 + .../MainViewController+Header.swift | 22 ++++++++ .../MainViewController+Input.swift | 22 ++++++++ .../MainViewController.swift | 55 ++----------------- .../Interface/Supplement/AccentColor.swift | 12 ++++ .../Interface/View/InputBox/InputBox.swift | 21 ++++--- .../InputBoxBackground.colorset/Contents.json | 38 +++++++++++++ .../View/InputBox/InputBoxFunctionBar.swift | 26 ++++----- .../View/MainHeaderView/MainHeaderView.swift | 3 +- 10 files changed, 136 insertions(+), 74 deletions(-) create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Header.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Supplement/AccentColor.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.xcassets/InputBoxBackground.colorset/Contents.json diff --git a/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved b/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6c76aeb265..0973b9f21f 100644 --- a/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -45,6 +45,15 @@ "version" : "1.0.4" } }, + { + "identity" : "swifterswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SwifterSwift/SwifterSwift.git", + "state" : { + "revision" : "39fa28c90a3ebe3d53f80289304fd880cf2c42d0", + "version" : "6.2.0" + } + }, { "identity" : "then", "kind" : "remoteSourceControl", diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift index eb76742bed..16facb48c6 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift @@ -18,12 +18,14 @@ let package = Package( .package(url: "https://github.com/apple/swift-collections", from: "1.2.0"), .package(url: "https://github.com/devxoul/Then", from: "3.0.0"), .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.0.1"), + .package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0") ], targets: [ .target(name: "Intelligents", dependencies: [ "AffineGraphQL", "SnapKit", "Then", + "SwifterSwift", .product(name: "Apollo", package: "apollo-ios"), .product(name: "OrderedCollections", package: "swift-collections"), ], resources: [ diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Header.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Header.swift new file mode 100644 index 0000000000..69bc267a05 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Header.swift @@ -0,0 +1,22 @@ +// +// File.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) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift new file mode 100644 index 0000000000..b1b4efa52b --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift @@ -0,0 +1,22 @@ +// +// MainViewController+Input.swift +// Intelligents +// +// Created by 秋星桥 on 6/19/25. +// + +import UIKit + +extension MainViewController: InputBoxDelegate { + func inputBoxDidSelectAttachment(_ inputBox: InputBox) { + print(#function, inputBox) + } + + func inputBoxDidSend(_ inputBox: InputBox) { + print(#function, inputBox, inputBox.viewModel) + } + + func inputBoxTextDidChange(_ text: String) { + print(#function, text) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift index 456f7b2ee9..d57faaf982 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift @@ -23,19 +23,15 @@ class MainViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - view.layoutIfNeeded() setupUI() - view.layoutIfNeeded() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController!.setNavigationBarHidden(true, animated: animated) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - inputBox.textView.becomeFirstResponder() + DispatchQueue.main.async { + self.inputBox.textView.becomeFirstResponder() + } } override func viewWillDisappear(_ animated: Bool) { @@ -47,19 +43,10 @@ class MainViewController: UIViewController { private func setupUI() { view.backgroundColor = .systemBackground - - // 计算 InputBox 的初始 frame 以避免布局动画 - let inputBoxFrame = CGRect( - x: 0, - y: view.bounds.height - 150, // 预估高度 - width: view.bounds.width, - height: 150 - ) - let inputBox = InputBox(frame: inputBoxFrame).then { + let inputBox = InputBox().then { $0.delegate = self } self.inputBox = inputBox - self.inputBox.layoutIfNeeded() view.addSubview(headerView) view.addSubview(inputBox) @@ -75,37 +62,3 @@ class MainViewController: UIViewController { } } } - -// MARK: - MainHeaderViewDelegate - -extension MainViewController: MainHeaderViewDelegate { - func mainHeaderViewDidTapClose() { - dismiss(animated: true) - } - - func mainHeaderViewDidTapDropdown() { - // TODO: 实现下拉功能 - print("Dropdown tapped") - } - - func mainHeaderViewDidTapMenu() { - // TODO: 实现菜单功能 - print("Menu tapped") - } -} - -// MARK: - InputBoxDelegate - -extension MainViewController: InputBoxDelegate { - func inputBoxDidSelectAttachment(_ inputBox: InputBox) { - print(#function, inputBox) - } - - func inputBoxDidSend(_ inputBox: InputBox) { - print(#function, inputBox, inputBox.viewModel) - } - - func inputBoxTextDidChange(_ text: String) { - print(#function, text) - } -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Supplement/AccentColor.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Supplement/AccentColor.swift new file mode 100644 index 0000000000..3a60a305da --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Supplement/AccentColor.swift @@ -0,0 +1,12 @@ +// +// File.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) +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift index 1092968c2e..638a25c530 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift @@ -10,14 +10,19 @@ class InputBox: UIView { var cancellables = Set() lazy var containerView = UIView().then { - $0.backgroundColor = .systemBackground - $0.layer.cornerRadius = 12 - $0.layer.borderWidth = 0.5 - $0.layer.borderColor = UIColor.systemGray4.cgColor + $0.backgroundColor = UIColor( + named: "InputBoxBackground", + in: .module, + compatibleWith: nil + )! + $0.layer.cornerRadius = 16 + $0.layer.cornerCurve = .continuous + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.systemGray.withAlphaComponent(0.1).cgColor $0.layer.shadowColor = UIColor.black.cgColor - $0.layer.shadowOffset = CGSize(width: 0, height: 2) - $0.layer.shadowRadius = 6 - $0.layer.shadowOpacity = 0.04 + $0.layer.shadowOffset = CGSize(width: 0, height: 0) + $0.layer.shadowRadius = 12 + $0.layer.shadowOpacity = 0.075 $0.clipsToBounds = false } @@ -29,7 +34,7 @@ class InputBox: UIView { $0.textContainer.lineFragmentPadding = 0 $0.textContainerInset = .zero $0.delegate = self - $0.text = "This is AFFiNE AI" + $0.text = "" } lazy var placeholderLabel = UILabel().then { diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.xcassets/InputBoxBackground.colorset/Contents.json b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.xcassets/InputBoxBackground.colorset/Contents.json new file mode 100644 index 0000000000..0cba25aa67 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.xcassets/InputBoxBackground.colorset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift index 52ae48188a..c643f018ce 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift @@ -10,40 +10,41 @@ protocol InputBoxFunctionBarDelegate: AnyObject { func functionBarDidTapSend(_ functionBar: InputBoxFunctionBar) } +private let unselectedColor: UIColor = .secondaryLabel +private let selectedColor: UIColor = .accent + class InputBoxFunctionBar: UIView { weak var delegate: InputBoxFunctionBarDelegate? lazy var attachmentButton = UIButton(type: .system).then { - $0.backgroundColor = .systemBackground $0.setImage(UIImage(named: "inputbox.add.attachment", in: .module, with: nil), for: .normal) - $0.tintColor = .secondaryLabel + $0.tintColor = unselectedColor $0.imageView?.contentMode = .scaleAspectFit $0.addTarget(self, action: #selector(attachmentButtonTapped), for: .touchUpInside) } lazy var toolButton = UIButton(type: .system).then { $0.setImage(UIImage(named: "inputbox.tool", in: .module, with: nil), for: .normal) - $0.tintColor = .secondaryLabel + $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(named: "inputbox.network", in: .module, with: nil), for: .normal) - $0.tintColor = .secondaryLabel + $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(named: "inputbox.deep.thinking", in: .module, with: nil), for: .normal) - $0.tintColor = .secondaryLabel + $0.tintColor = unselectedColor $0.imageView?.contentMode = .scaleAspectFit $0.addTarget(self, action: #selector(deepThinkingButtonTapped), for: .touchUpInside) } lazy var sendButton = UIButton(type: .system).then { - $0.backgroundColor = UIColor.systemBlue $0.setImage(UIImage(named: "inputbox.send", in: .module, with: nil), for: .normal) $0.tintColor = .white $0.imageView?.contentMode = .scaleAspectFit @@ -103,27 +104,26 @@ class InputBoxFunctionBar: UIView { } } - 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 + } } // MARK: - Public Methods func updateToolState(isEnabled: Bool) { - toolButton.isSelected = isEnabled - toolButton.tintColor = isEnabled ? .systemBlue : .secondaryLabel + toolButton.tintColor = isEnabled ? selectedColor : unselectedColor } func updateNetworkState(isEnabled: Bool) { - networkButton.isSelected = isEnabled - networkButton.tintColor = isEnabled ? .systemBlue : .secondaryLabel + networkButton.tintColor = isEnabled ? selectedColor : unselectedColor } func updateDeepThinkingState(isEnabled: Bool) { - deepThinkingButton.isSelected = isEnabled - deepThinkingButton.tintColor = isEnabled ? .systemBlue : .secondaryLabel + deepThinkingButton.tintColor = isEnabled ? selectedColor : unselectedColor } func updateSendState(canSend: Bool) { diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/MainHeaderView/MainHeaderView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/MainHeaderView/MainHeaderView.swift index 195cfd666e..fc8112cf37 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/MainHeaderView/MainHeaderView.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/MainHeaderView/MainHeaderView.swift @@ -22,7 +22,7 @@ class MainHeaderView: UIView { private lazy var titleLabel = UILabel().then { $0.text = "AFFiNE" $0.font = .systemFont(ofSize: 16, weight: .medium) - $0.textColor = .black + $0.textColor = .label $0.textAlignment = .center } @@ -68,7 +68,6 @@ class MainHeaderView: UIView { init() { super.init(frame: .zero) - backgroundColor = .white addSubview(mainStackView) mainStackView.snp.makeConstraints { make in From 92887791fc32e8bca80375f4bd12de3b7e8bf887 Mon Sep 17 00:00:00 2001 From: Lakr Date: Thu, 19 Jun 2025 01:25:11 +0800 Subject: [PATCH 4/8] feat: input box menu --- .../App/Packages/Intelligents/Package.swift | 2 +- .../MainViewController+Header.swift | 2 +- .../MainViewController+Input.swift | 16 ++++++ .../Interface/Supplement/AccentColor.swift | 2 +- .../Interface/View/InputBox/InputBox.swift | 26 +++++++--- .../View/InputBox/InputBoxDelegate.swift | 5 +- .../View/InputBox/InputBoxFunctionBar.swift | 51 +++++++++++++++++-- 7 files changed, 88 insertions(+), 16 deletions(-) diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift index 16facb48c6..79502ef077 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift @@ -18,7 +18,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-collections", from: "1.2.0"), .package(url: "https://github.com/devxoul/Then", from: "3.0.0"), .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.0.1"), - .package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0") + .package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0"), ], targets: [ .target(name: "Intelligents", dependencies: [ diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Header.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Header.swift index 69bc267a05..36fb59af9f 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Header.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Header.swift @@ -1,5 +1,5 @@ // -// File.swift +// MainViewController+Header.swift // Intelligents // // Created by 秋星桥 on 6/19/25. diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift index b1b4efa52b..c43474e924 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift @@ -8,6 +8,22 @@ import UIKit extension MainViewController: InputBoxDelegate { + func inputBoxDidSelectTakePhoto(_ inputBox: InputBox) { + print(#function, inputBox) + } + + func inputBoxDidSelectPhotoLibrary(_ inputBox: InputBox) { + print(#function, inputBox) + } + + func inputBoxDidSelectAttachFiles(_ inputBox: InputBox) { + print(#function, inputBox) + } + + func inputBoxDidSelectEmbedDocs(_ inputBox: InputBox) { + print(#function, inputBox) + } + func inputBoxDidSelectAttachment(_ inputBox: InputBox) { print(#function, inputBox) } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Supplement/AccentColor.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Supplement/AccentColor.swift index 3a60a305da..eec75f6f0a 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Supplement/AccentColor.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Supplement/AccentColor.swift @@ -1,5 +1,5 @@ // -// File.swift +// AccentColor.swift // Intelligents // // Created by 秋星桥 on 6/19/25. diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift index 638a25c530..9e315245f0 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift @@ -70,7 +70,7 @@ class InputBox: UIView { updateTextViewHeight() } } - + override init(frame: CGRect = .zero) { super.init(frame: frame) setupViews() @@ -164,15 +164,15 @@ class InputBox: UIView { 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 } - + UIView.animate( withDuration: 0.5, delay: 0, @@ -231,8 +231,20 @@ class InputBox: UIView { // MARK: - InputBoxFunctionBarDelegate extension InputBox: InputBoxFunctionBarDelegate { - func functionBarDidTapAttachment(_: InputBoxFunctionBar) { - delegate?.inputBoxDidSelectAttachment(self) + 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) { diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift index f62a37df2a..636afbc918 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift @@ -8,7 +8,10 @@ import UIKit protocol InputBoxDelegate: AnyObject { - func inputBoxDidSelectAttachment(_ inputBox: InputBox) + func inputBoxDidSelectTakePhoto(_ inputBox: InputBox) + func inputBoxDidSelectPhotoLibrary(_ inputBox: InputBox) + func inputBoxDidSelectAttachFiles(_ inputBox: InputBox) + func inputBoxDidSelectEmbedDocs(_ inputBox: InputBox) func inputBoxDidSend(_ inputBox: InputBox) func inputBoxTextDidChange(_ text: String) } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift index c643f018ce..4fd5e3b38a 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift @@ -3,7 +3,10 @@ import Then import UIKit protocol InputBoxFunctionBarDelegate: AnyObject { - func functionBarDidTapAttachment(_ functionBar: InputBoxFunctionBar) + 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) @@ -20,7 +23,8 @@ class InputBoxFunctionBar: UIView { $0.setImage(UIImage(named: "inputbox.add.attachment", in: .module, with: nil), for: .normal) $0.tintColor = unselectedColor $0.imageView?.contentMode = .scaleAspectFit - $0.addTarget(self, action: #selector(attachmentButtonTapped), for: .touchUpInside) + $0.showsMenuAsPrimaryAction = true + $0.menu = createAttachmentMenu() } lazy var toolButton = UIButton(type: .system).then { @@ -131,12 +135,49 @@ class InputBoxFunctionBar: UIView { sendButton.alpha = canSend ? 1.0 : 0.5 } - // MARK: - Actions + // MARK: - Private Methods - @objc private func attachmentButtonTapped() { - delegate?.functionBarDidTapAttachment(self) + private func createAttachmentMenu() -> UIMenu { + let takePhotoAction = UIAction( + title: "Take Photo or Video", + image: UIImage(systemName: "camera") + ) { [weak self] _ in + guard let self else { return } + delegate?.functionBarDidTapTakePhoto(self) + } + + let photoLibraryAction = UIAction( + title: "Photo Library", + image: UIImage(systemName: "photo.on.rectangle") + ) { [weak self] _ in + guard let self else { return } + delegate?.functionBarDidTapPhotoLibrary(self) + } + + let attachFilesAction = UIAction( + title: "Attach Files (pdf, txt, csv)", + image: UIImage(systemName: "arrow.up.doc") + ) { [weak self] _ in + guard let self else { return } + delegate?.functionBarDidTapAttachFiles(self) + } + + let embedDocsAction = UIAction( + title: "Embed AFFINE Docs", + image: UIImage(systemName: "doc.text") + ) { [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) } From 876ea3a98792e88edb34f6ff09e7b35393710f6b Mon Sep 17 00:00:00 2001 From: Lakr Date: Thu, 19 Jun 2025 02:34:53 +0800 Subject: [PATCH 5/8] feat: editor working --- .../App/Packages/Intelligents/Package.swift | 1 + .../BlurTransition.swift | 22 +- .../MainViewController+Input.swift | 97 ++++- .../MainViewController.swift | 36 +- .../Effect/ParticleView+Removal.swift | 45 +++ .../Effect/ParticleView+Renderer.swift | 331 ++++++++++++++++++ .../Interface/Effect/ParticleView.swift | 79 +++++ .../Effect/UIView+createViewSnapshot.swift | 22 ++ .../Interface/Supplement/Animation.swift | 23 ++ .../Interface/View/InputBox/InputBox.swift | 186 +++++----- .../View/InputBox/InputBoxDelegate.swift | 7 +- .../View/InputBox/InputBoxImageBar.swift | 182 +++++----- .../View/InputBox/InputBoxViewModel.swift | 51 +-- .../IntelligentsButton+Control.swift | 14 +- .../SupplementView/DeleteButtonView.swift | 49 +++ .../Model/IntelligentContext.swift | 25 +- .../Sources/Intelligents/Resources/main.metal | 87 +++++ 17 files changed, 994 insertions(+), 263 deletions(-) create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/ParticleView+Removal.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/ParticleView+Renderer.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/ParticleView.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/UIView+createViewSnapshot.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Supplement/Animation.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/SupplementView/DeleteButtonView.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/main.metal diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift index 79502ef077..5eff7a644b 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift @@ -29,6 +29,7 @@ let package = Package( .product(name: "Apollo", package: "apollo-ios"), .product(name: "OrderedCollections", package: "swift-collections"), ], resources: [ + .process("Resources/main.metal"), .process("Interface/View/InputBox/InputBox.xcassets"), ]), ] diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/IntelligentsController/BlurTransition.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/IntelligentsController/BlurTransition.swift index 7a01044370..f2929ea946 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/IntelligentsController/BlurTransition.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/IntelligentsController/BlurTransition.swift @@ -35,7 +35,6 @@ class BlurTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { private let presenting: Bool - private let duration: TimeInterval = 0.75 private let snapshotViewTag = "snapshotView".hashValue private let blurViewTag = "blurView".hashValue @@ -45,23 +44,8 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { super.init() } - private func performAnimation( - animations: @escaping () -> Void, - completion: @escaping (Bool) -> Void - ) { - UIView.animate( - withDuration: duration, - delay: 0, - usingSpringWithDamping: 0.8, - initialSpringVelocity: 0.8, - options: [.beginFromCurrentState, .allowAnimatedContent, .curveEaseInOut], - animations: animations, - completion: completion - ) - } - func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval { - duration + 0.5 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { @@ -108,7 +92,7 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { toView.layoutIfNeeded() - performAnimation(animations: { + performWithAnimation(animations: { blurEffectView.effect = UIBlurEffect(style: .systemMaterial) fromViewSnapshot.transform = CGAffineTransform(scaleX: 0.95, y: 0.95) toView.alpha = 1 @@ -150,7 +134,7 @@ class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { return } - performAnimation(animations: { + performWithAnimation(animations: { fromViewSnapshot.transform = .identity blurEffectView.effect = nil fromView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05) diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift index c43474e924..c1304562d2 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift @@ -5,19 +5,36 @@ // Created by 秋星桥 on 6/19/25. // +import PhotosUI import UIKit +import UniformTypeIdentifiers extension MainViewController: InputBoxDelegate { - func inputBoxDidSelectTakePhoto(_ inputBox: InputBox) { - print(#function, inputBox) + func inputBoxDidSelectTakePhoto(_: InputBox) { + let imagePickerController = UIImagePickerController() + imagePickerController.delegate = self + imagePickerController.sourceType = .camera + imagePickerController.allowsEditing = false + present(imagePickerController, animated: true) } - func inputBoxDidSelectPhotoLibrary(_ inputBox: InputBox) { - print(#function, inputBox) + 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: InputBox) { - print(#function, inputBox) + 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) { @@ -36,3 +53,71 @@ extension MainViewController: InputBoxDelegate { 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)") + } + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift index d57faaf982..46ea8a009f 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift @@ -6,11 +6,11 @@ import UIKit class MainViewController: UIViewController { // MARK: - UI Components - private lazy var headerView = MainHeaderView().then { + lazy var headerView = MainHeaderView().then { $0.delegate = self } - private lazy var inputBox = InputBox().then { + lazy var inputBox = InputBox().then { $0.delegate = self } @@ -23,25 +23,6 @@ class MainViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - setupUI() - } - - 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) - } - - // MARK: - Setup - - private func setupUI() { view.backgroundColor = .systemBackground let inputBox = InputBox().then { $0.delegate = self @@ -61,4 +42,17 @@ class MainViewController: UIViewController { 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) + } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/ParticleView+Removal.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/ParticleView+Removal.swift new file mode 100644 index 0000000000..51e1e621cf --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/ParticleView+Removal.swift @@ -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() + } + }) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/ParticleView+Renderer.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/ParticleView+Renderer.swift new file mode 100644 index 0000000000..4e7e1bf676 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/ParticleView+Renderer.swift @@ -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.size, + index: 1 + ) + } + renderCommandEncoder.setVertexBuffer(particleBuffer, offset: 0, index: 2) + withUnsafeBytes(of: &targetFrameSize) { pointer in + renderCommandEncoder.setVertexBytes( + pointer.baseAddress!, + length: MemoryLayout.size, + index: 3 + ) + } + withUnsafeBytes(of: &stepSize) { pointer in + renderCommandEncoder.setVertexBytes( + pointer.baseAddress!, + length: MemoryLayout.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.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.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()! + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/ParticleView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/ParticleView.swift new file mode 100644 index 0000000000..f2c8b666ed --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/ParticleView.swift @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/UIView+createViewSnapshot.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/UIView+createViewSnapshot.swift new file mode 100644 index 0000000000..a366391784 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Effect/UIView+createViewSnapshot.swift @@ -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) + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Supplement/Animation.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Supplement/Animation.swift new file mode 100644 index 0000000000..bac7c40b81 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Supplement/Animation.swift @@ -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 + ) +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift index 9e315245f0..bb24292548 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift @@ -48,12 +48,15 @@ class InputBox: UIView { $0.delegate = self } - lazy var imageBar = InputBoxImageBar(frame: bounds) + lazy var imageBar = InputBoxImageBar().then { + $0.imageBarDelegate = self + } lazy var mainStackView = UIStackView().then { $0.axis = .vertical $0.spacing = 16 $0.alignment = .fill + $0.addArrangedSubview(imageBar) $0.addArrangedSubview(textView) $0.addArrangedSubview(functionBar) } @@ -73,76 +76,13 @@ class InputBox: UIView { override init(frame: CGRect = .zero) { super.init(frame: frame) - setupViews() - setupConstraints() - setupBindings() - updatePlaceholderVisibility() - } - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func setupViews() { backgroundColor = .clear addSubview(containerView) containerView.addSubview(mainStackView) containerView.addSubview(placeholderLabel) + imageBar.isHidden = true - imageBar.imageBarDelegate = self - } - - func setupBindings() { - // 绑定 ViewModel 到 UI - viewModel.$inputText - .sink { [weak self] text in - if self?.textView.text != text { - self?.textView.text = text - self?.updatePlaceholderVisibility() - self?.updateTextViewHeight() - } - } - .store(in: &cancellables) - - viewModel.$isToolEnabled - .sink { [weak self] enabled in - self?.functionBar.updateToolState(isEnabled: enabled) - } - .store(in: &cancellables) - - viewModel.$isNetworkEnabled - .sink { [weak self] enabled in - self?.functionBar.updateNetworkState(isEnabled: enabled) - } - .store(in: &cancellables) - - viewModel.$isDeepThinkingEnabled - .sink { [weak self] enabled in - self?.functionBar.updateDeepThinkingState(isEnabled: enabled) - } - .store(in: &cancellables) - - viewModel.$canSend - .sink { [weak self] canSend in - self?.functionBar.updateSendState(canSend: canSend) - } - .store(in: &cancellables) - - viewModel.$hasAttachments - .sink { [weak self] hasAttachments in - self?.updateImageBarVisibility(hasAttachments) - } - .store(in: &cancellables) - - viewModel.$attachments - .sink { [weak self] attachments in - self?.updateImageBarContent(attachments) - } - .store(in: &cancellables) - } - - func setupConstraints() { containerView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } @@ -151,6 +91,10 @@ class InputBox: UIView { 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 } @@ -159,6 +103,74 @@ class InputBox: UIView { make.left.right.equalTo(textView) make.top.equalTo(textView) } + + setupBindings() + updatePlaceholderVisibility() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + 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() { @@ -173,13 +185,7 @@ class InputBox: UIView { if height == 0 || superview == nil || window == nil || isHidden { return } - UIView.animate( - withDuration: 0.5, - delay: 0, - usingSpringWithDamping: 0.8, - initialSpringVelocity: 1.0, - options: .curveEaseInOut - ) { + performWithAnimation { self.layoutIfNeeded() self.superview?.layoutIfNeeded() } @@ -190,22 +196,11 @@ class InputBox: UIView { } func updateImageBarVisibility(_ hasAttachments: Bool) { - if hasAttachments, !mainStackView.arrangedSubviews.contains(imageBar) { - mainStackView.insertArrangedSubview(imageBar, at: 1) - } else if !hasAttachments, mainStackView.arrangedSubviews.contains(imageBar) { - mainStackView.removeArrangedSubview(imageBar) - imageBar.removeFromSuperview() - } + imageBar.isHidden = !hasAttachments } func updateImageBarContent(_ attachments: [InputAttachment]) { - imageBar.clear() - - for attachment in attachments { - if attachment.type == .image, let data = attachment.data, let image = UIImage(data: data) { - imageBar.addImage(image) - } - } + imageBar.updateImageBarContent(attachments) } // MARK: - Public Methods @@ -220,7 +215,26 @@ class InputBox: UIView { size: Int64(imageData.count) ) - viewModel.addAttachment(attachment) + 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 { diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift index 636afbc918..b894949139 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxDelegate.swift @@ -17,8 +17,11 @@ protocol InputBoxDelegate: AnyObject { } extension InputBox: InputBoxImageBarDelegate { - func inputBoxImageBar(_: InputBoxImageBar, didRemoveImageAt index: Int) { - viewModel.removeAttachment(at: index) + func inputBoxImageBar(_: InputBoxImageBar, didRemoveImageWithId id: InputAttachment.ID) { + performWithAnimation { [self] in + viewModel.removeAttachment(withId: id) + layoutIfNeeded() + } } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift index 9de8681d37..352e272bad 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxImageBar.swift @@ -10,27 +10,34 @@ import Then import UIKit protocol InputBoxImageBarDelegate: AnyObject { - func inputBoxImageBar(_ imageBar: InputBoxImageBar, didRemoveImageAt index: Int) + func inputBoxImageBar(_ imageBar: InputBoxImageBar, didRemoveImageWithId id: UUID) } -private let constantHeight: CGFloat = 108 +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 lazy var stackView = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 8 - $0.alignment = .center - $0.distribution = .equalSpacing - } - - private var imageCells: [ImageCell] = [] + private var attachmentViewModels: [AttachmentViewModel] = [] + private let cellSpacing: CGFloat = 8 + private let constantHeight: CGFloat = 80 override init(frame: CGRect = .zero) { super.init(frame: frame) - setupViews() - setupConstraints() + showsHorizontalScrollIndicator = false + showsVerticalScrollIndicator = false + + snp.makeConstraints { make in + make.height.equalTo(constantHeight) + } } @available(*, unavailable) @@ -38,113 +45,128 @@ class InputBoxImageBar: UIScrollView { fatalError() } - private func setupViews() { - showsHorizontalScrollIndicator = false - showsVerticalScrollIndicator = false - addSubview(stackView) - } + func updateImageBarContent(_ attachments: [InputAttachment]) { + let currentIds = Set(attachmentViewModels.map(\.attachment.id)) + let imageAttachments = attachments.filter { $0.type == .image } + let newIds = Set(imageAttachments.map(\.id)) - private func setupConstraints() { - stackView.snp.makeConstraints { make in - make.edges.equalToSuperview() - make.height.equalTo(constantHeight) + // 移除不再存在的附件 + 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() + } } - snp.makeConstraints { make in - make.height.equalTo(constantHeight) - } - } + // 添加新的附件 + let idsToAdd = newIds.subtracting(currentIds) + 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: 0, y: 0, width: constantHeight, height: constantHeight), + image: image, + attachmentId: attachment.id + ) + imageCell.onRemove = { [weak self] cell in + self?.removeImageCell(cell) + } - func addImage(_ image: UIImage) { - let imageCell = ImageCell(image: image) - imageCell.onRemove = { [weak self] cell in - self?.removeImageCell(cell) + let viewModel = AttachmentViewModel(attachment: attachment, imageCell: imageCell) + attachmentViewModels.append(viewModel) + addSubview(imageCell) + } } - imageCells.append(imageCell) - stackView.addArrangedSubview(imageCell) - updateContentSize() + layoutImageCells() } func removeImageCell(_ cell: ImageCell) { - if let index = imageCells.firstIndex(of: cell) { - imageCells.remove(at: index) - stackView.removeArrangedSubview(cell) - cell.removeFromSuperview() - imageBarDelegate?.inputBoxImageBar(self, didRemoveImageAt: index) - updateContentSize() + 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 cell in imageCells { - stackView.removeArrangedSubview(cell) - cell.removeFromSuperview() + for viewModel in attachmentViewModels { + viewModel.imageCell.removeFromSuperview() } - imageCells.removeAll() - updateContentSize() + attachmentViewModels.removeAll() + contentSize = .zero } - private func updateContentSize() { - layoutIfNeeded() - contentSize = stackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + 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().then { + 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 = UIButton(type: .system).then { - $0.backgroundColor = UIColor.black.withAlphaComponent(0.52) - $0.layer.cornerRadius = 8.5 - $0.layer.borderWidth = 1 - $0.layer.borderColor = UIColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1).cgColor - $0.setImage(UIImage(systemName: "xmark"), for: .normal) - $0.tintColor = .white - $0.addTarget(self, action: #selector(removeButtonTapped), for: .touchUpInside) + private lazy var removeButton = DeleteButtonView(frame: removeButtonFrame).then { + $0.onTapped = { [weak self] in + self?.removeButtonTapped() + } } - init(image: UIImage) { - super.init(frame: .zero) - setupViews() - setupConstraints() + 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() } - private func setupViews() { - addSubview(imageView) - addSubview(removeButton) - } - - private func setupConstraints() { - // 设置固定高度和1:1宽高比 - snp.makeConstraints { make in - make.width.height.equalTo(constantHeight) - } - - imageView.snp.makeConstraints { make in - make.edges.equalToSuperview() - } - - removeButton.snp.makeConstraints { make in - make.top.trailing.equalToSuperview().inset(6.5) - make.width.height.equalTo(17) - } - } - @objc private func removeButtonTapped() { onRemove?(self) } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift index 3b7828a9fc..9a3a249122 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxViewModel.swift @@ -10,41 +10,27 @@ import Foundation // MARK: - Data Models -public enum AttachmentType: Equatable { - case image - case document - case video - case audio - case other(String) +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 var displayName: String { - switch self { - case .image: "Image" - case .document: "Document" - case .video: "Video" - case .audio: "Audio" - case let .other(type): type - } + public enum AttachmentType: String, Equatable, Hashable, Codable { + case image + case document + case file } -} - -public struct InputAttachment { - public let id: String - public let type: AttachmentType - public let data: Data? - public let url: URL? - public let name: String - public let size: Int64 public init( - id: String = UUID().uuidString, type: AttachmentType, data: Data? = nil, url: URL? = nil, name: String, size: Int64 = 0 ) { - self.id = id self.type = type self.data = data self.url = url @@ -54,11 +40,11 @@ public struct InputAttachment { } public struct InputBoxData { - public let text: String - public let attachments: [InputAttachment] - public let isToolEnabled: Bool - public let isNetworkEnabled: Bool - public let isDeepThinkingEnabled: Bool + public var text: String + public var attachments: [InputAttachment] + public var isToolEnabled: Bool + public var isNetworkEnabled: Bool + public var isDeepThinkingEnabled: Bool public init( text: String, @@ -146,9 +132,8 @@ public extension InputBoxViewModel { attachments.append(attachment) } - func removeAttachment(at index: Int) { - guard index < attachments.count else { return } - attachments.remove(at: index) + func removeAttachment(withId id: UUID) { + attachments.removeAll { $0.id == id } } func clearAttachments() { diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/IntelligentsButton/IntelligentsButton+Control.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/IntelligentsButton/IntelligentsButton+Control.swift index 9dae17c6c3..d389a056bc 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/IntelligentsButton/IntelligentsButton+Control.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/IntelligentsButton/IntelligentsButton+Control.swift @@ -48,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() @@ -78,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() diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/SupplementView/DeleteButtonView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/SupplementView/DeleteButtonView.swift new file mode 100644 index 0000000000..abed5cd439 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/SupplementView/DeleteButtonView.swift @@ -0,0 +1,49 @@ +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 layoutSubviews() { + super.layoutSubviews() + blur.layer.cornerRadius = min(bounds.width, bounds.height) / 2 + } + + @objc func tapped() { + onTapped() + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/IntelligentContext.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/IntelligentContext.swift index 71a7b173e0..605ff9261e 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/IntelligentContext.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/IntelligentContext.swift @@ -15,13 +15,30 @@ public class 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) { - // used to gathering information, populate content from webview, etc. - // TODO: if needed - completion() + DispatchQueue.global(qos: .userInitiated).async { [self] in + prepareTemporaryDirectory() + // TODO: used to gathering information, populate content from webview, etc. + DispatchQueue.main.async { + completion() + } + } } - // MARK: - Input Processing + func prepareTemporaryDirectory() { + if FileManager.default.fileExists(atPath: temporaryDirectory.path) { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + try? FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/main.metal b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/main.metal new file mode 100644 index 0000000000..d79a65f82d --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/main.metal @@ -0,0 +1,87 @@ +#include + +using namespace metal; + +namespace ParticleTransitionSystem { + + struct TrollParticle { + float2 position; + float2 velocity; + float life; + float duration; + }; + + struct TrollVertex { + float4 position [[position]]; + float2 uv; + float opacity; + }; + +} + +// 顶点着色器 负责将粒子数据转换为顶点数据 +vertex ParticleTransitionSystem::TrollVertex PTS_ParticleVertex(const device ParticleTransitionSystem::TrollVertex *vertices [[buffer(0)]], + const device float2 &resolution [[buffer(1)]], + const device ParticleTransitionSystem::TrollParticle *particles [[buffer(2)]], + const device float2 &targetFrameSize [[buffer(3)]], + const device float &stepSize [[buffer(4)]], + unsigned int vid [[vertex_id]], + unsigned int particleId [[instance_id]]) { + ParticleTransitionSystem::TrollVertex v = vertices[vid]; + ParticleTransitionSystem::TrollParticle p = particles[particleId]; + + int particlesPerRow = int(targetFrameSize.x / stepSize); + int row = particleId / particlesPerRow; + int col = particleId % particlesPerRow; + + float2 originalPos = float2(col * stepSize + stepSize / 2.0, row * stepSize + stepSize / 2.0); + float2 currentPos = p.position; + + // 计算目标帧在屏幕中的居中偏移 + float2 offset = (resolution - targetFrameSize) / 2.0; + + // 设置UV坐标用于纹理采样 + v.uv.x = originalPos.x / targetFrameSize.x; + v.uv.y = originalPos.y / targetFrameSize.y; + + // 计算最终的屏幕位置 + float particleSize = stepSize; + float2 worldPos = v.position.xy * particleSize + currentPos + offset; + + // 转换到NDC坐标系 (-1到1) + v.position.x = (worldPos.x / resolution.x) * 2.0 - 1.0; + v.position.y = 1.0 - (worldPos.y / resolution.y) * 2.0; + + // 逐渐消失 + v.opacity = p.life / p.duration; + + return v; +} + +// 片段着色器 负责将顶点数据转换为像素颜色 +fragment float4 PTS_ParticleFragment(ParticleTransitionSystem::TrollVertex in [[stage_in]], + const texture2d texture [[texture(0)]], + const sampler textureSampler [[sampler(0)]]) { + constexpr sampler samplr; + float4 color = texture.sample(samplr, in.uv); + float a = color.a * in.opacity; // apply texture alpha + color *= in.opacity; // apply opacity from vertex shader aka pre-multiplied alpha + color.a = a; + return color; +} + +// 计算粒子位置和速度的计算着色器 负责更新粒子的位置和速度 +kernel void PTS_UpdateParticles(device ParticleTransitionSystem::TrollParticle *particles [[buffer(0)]], + unsigned int index [[thread_position_in_grid]]) { + if (particles[index].life >= 0) { + particles[index].position += particles[index].velocity; + + // 模拟空气阻力,降低速度 x, y 分量 + particles[index].velocity.x *= 0.99; + particles[index].velocity.y *= 0.99; + + // 模拟重力影响,增加 y 分量 + particles[index].velocity.y += 0.1; + } + particles[index].life -= 1.0; +} From cbcc708073212bc31bdde3b16811bf67ccb18885 Mon Sep 17 00:00:00 2001 From: Lakr Date: Thu, 19 Jun 2025 02:47:54 +0800 Subject: [PATCH 6/8] chore: downgrade target 16.0 --- .../ios/App/App.xcodeproj/project.pbxproj | 22 +++++++++---------- packages/frontend/apps/ios/App/App/Info.plist | 2 +- .../App/Packages/Intelligents/Package.swift | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj index 44b56f612f..4061b00d98 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -77,8 +77,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ C45499AB2D140B5000E21978 /* NBStore */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = NBStore; sourceTree = ""; }; @@ -326,9 +324,13 @@ ); inputFileListPaths = ( ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n"; @@ -514,14 +516,13 @@ baseConfigurationReference = 3256F4410D881A03FE77D092 /* Pods-AFFiNE.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "Apple Distribution: TOEVERYTHING PTE. LTD. (73YMMDVT2M)"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 12; - DEVELOPMENT_TEAM = 73YMMDVT2M; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 73YMMDVT2M; + DEVELOPMENT_TEAM = 964G86XT2P; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -559,7 +560,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 73YMMDVT2M; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -572,8 +573,7 @@ ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "AppStore app.affine.pro"; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "AppStore app.affine.pro"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; diff --git a/packages/frontend/apps/ios/App/App/Info.plist b/packages/frontend/apps/ios/App/App/Info.plist index ff1e14a218..7bc4ac16e4 100644 --- a/packages/frontend/apps/ios/App/App/Info.plist +++ b/packages/frontend/apps/ios/App/App/Info.plist @@ -42,7 +42,7 @@ NSPhotoLibraryUsageDescription AFFiNE requires access to select photos from your photo library and insert them into your documents NSUserTrackingUsageDescription - Rest assured, enabling this permission won't access your private info on other sites. It's only used to identify your device and improve security and product experience. + Rest assured, enabling this permission won't access your private info on other sites. It's only used to identify your device and improve security and product experience. UILaunchScreen UIImageName diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift index 5eff7a644b..e8104d16eb 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift @@ -7,7 +7,7 @@ let package = Package( name: "Intelligents", defaultLocalization: "en", platforms: [ - .iOS(.v17), + .iOS(.v16), ], products: [ .library(name: "Intelligents", targets: ["Intelligents"]), From a505e65f323c12d072d47e735d2e8c5daf7c44bd Mon Sep 17 00:00:00 2001 From: Lakr Date: Thu, 19 Jun 2025 02:52:03 +0800 Subject: [PATCH 7/8] chore: easy delete image --- .../Interface/View/SupplementView/DeleteButtonView.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/SupplementView/DeleteButtonView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/SupplementView/DeleteButtonView.swift index abed5cd439..25597c7596 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/SupplementView/DeleteButtonView.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/SupplementView/DeleteButtonView.swift @@ -37,6 +37,13 @@ class DeleteButtonView: UIView { 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() From ab758b01b3e762a68572aeb55346776e8a7559d0 Mon Sep 17 00:00:00 2001 From: Lakr Date: Thu, 19 Jun 2025 02:58:34 +0800 Subject: [PATCH 8/8] chore: clean up --- .../frontend/apps/ios/App/App/AffineViewController.swift | 6 +++++- .../View/IntelligentsButton/IntelligentsButton.swift | 6 +++++- .../Interface/View/SupplementView/DeleteButtonView.swift | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/frontend/apps/ios/App/App/AffineViewController.swift b/packages/frontend/apps/ios/App/App/AffineViewController.swift index ddc1208580..22227845ab 100644 --- a/packages/frontend/apps/ios/App/App/AffineViewController.swift +++ b/packages/frontend/apps/ios/App/App/AffineViewController.swift @@ -11,7 +11,7 @@ class AFFiNEViewController: CAPBridgeViewController { edgesForExtendedLayout = [] let intelligentsButton = installIntelligentsButton() intelligentsButton.delegate = self - presentIntelligentsButton() // from v2.0 always visible + dismissIntelligentsButton() } override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration { @@ -38,7 +38,11 @@ class AFFiNEViewController: CAPBridgeViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) navigationController?.setNavigationBarHidden(false, animated: animated) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.presentIntelligentsButton() + } } } + diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/IntelligentsButton/IntelligentsButton.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/IntelligentsButton/IntelligentsButton.swift index 59c1c32d01..a633a5c12e 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/IntelligentsButton/IntelligentsButton.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/IntelligentsButton/IntelligentsButton.swift @@ -6,6 +6,7 @@ // import SnapKit +import SwifterSwift import Then import UIKit @@ -17,7 +18,10 @@ public class IntelligentsButton: UIView { } lazy var background = UIView().then { - $0.backgroundColor = .white + $0.backgroundColor = .init( + light: .systemBackground, + dark: .darkGray.withAlphaComponent(0.25) + ) } lazy var activityIndicator = UIActivityIndicatorView() diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/SupplementView/DeleteButtonView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/SupplementView/DeleteButtonView.swift index 25597c7596..33fbf1f290 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/SupplementView/DeleteButtonView.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/SupplementView/DeleteButtonView.swift @@ -37,7 +37,7 @@ class DeleteButtonView: UIView { 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)