snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 1s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Release / Release Docker Image (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
//
// ChatError.swift
// Intelligents
//
// Created by 秋星桥 on 6/30/25.
//
import Foundation
public enum ChatError: LocalizedError {
case invalidServerConfiguration
case invalidStreamURL
case invalidResponse
case networkError(Error)
case unknownError
public var errorDescription: String? {
switch self {
case .invalidServerConfiguration:
"Invalid server configuration"
case .invalidStreamURL:
"Invalid stream URL"
case .invalidResponse:
"Invalid response from server"
case let .networkError(error):
"Network error: \(error.localizedDescription)"
case .unknownError:
"An unknown error occurred"
}
}
}

View File

@@ -0,0 +1,33 @@
//
// ChatManager+CURD.swift
// Intelligents
//
// Created by 秋星桥 on 7/14/25.
//
import AffineGraphQL
import Apollo
import ApolloAPI
import EventSource
import Foundation
import MarkdownParser
import MarkdownView
extension ChatManager {
func clearCurrentSession() {
guard let session = IntelligentContext.shared.currentSession else {
print("[-] no current session to clear")
return
}
let mutation = CleanupCopilotSessionMutation(input: .init(
docId: session.docId ?? "",
sessionIds: [session.id],
workspaceId: session.workspaceId
))
QLService.shared.client.perform(mutation: mutation) { result in
print("[+] cleanup session result: \(result)")
}
}
}

View File

@@ -0,0 +1,21 @@
//
// ChatManager+Closable.swift
// Intelligents
//
// Created by 秋星桥 on 6/30/25.
//
import Foundation
protocol Closable { func close() }
class ClosableTask: Closable {
let detachedTask: Task<Void, Never>
init(detachedTask: Task<Void, Never>) {
self.detachedTask = detachedTask
}
func close() {
detachedTask.cancel()
}
}

View File

@@ -0,0 +1,225 @@
//
// ChatManager+Stream.swift
// Intelligents
//
// Created by 秋星桥 on 6/30/25.
//
import AffineGraphQL
import EventSource
import Foundation
import MarkdownParser
import MarkdownView
import UniformTypeIdentifiers
private let loadingIndicator = " ●"
private extension InputBoxData {
var hasAttachment: Bool {
if !imageAttachments.isEmpty { return false }
if !fileAttachments.isEmpty { return false }
if !documentAttachments.isEmpty { return false }
return true
}
}
public extension ChatManager {
@MainActor
func startUserRequest(editorData: InputBoxData, sessionId: String) {
append(sessionId: sessionId, UserMessageCellViewModel(
id: .init(),
content: editorData.text,
timestamp: .init()
))
append(sessionId: sessionId, UserHintCellViewModel(
id: .init(),
timestamp: .init(),
imageAttachments: editorData.imageAttachments,
fileAttachments: editorData.fileAttachments,
docAttachments: editorData.documentAttachments
))
let viewModelId = append(sessionId: sessionId, AssistantMessageCellViewModel(
id: .init(),
content: "...",
timestamp: .init()
))
scrollToBottomPublisher.send(sessionId)
guard let workspaceId = IntelligentContext.shared.currentWorkspaceId,
!workspaceId.isEmpty
else {
report(sessionId, ChatError.unknownError)
assertionFailure("Invalid workspace ID")
return
}
DispatchQueue.global().async {
self.startCopilotResponse(
workspaceId: workspaceId,
editorData: editorData,
sessionId: sessionId,
viewModelId: viewModelId
)
}
}
}
private extension ChatManager {
func startCopilotResponse(
workspaceId: String,
editorData: InputBoxData,
sessionId: String,
viewModelId: UUID
) {
assert(!Thread.isMainThread)
print("[+] starting copilot response for session: \(sessionId)")
let uploadableAttachments: [CopilotAttachmentUpload] = [
editorData.fileAttachments.map { file -> CopilotAttachmentUpload in
.init(
data: file.data ?? .init(),
mimeType: mimeType(text: file.name),
originalName: file.name
)
},
editorData.imageAttachments.map { image -> CopilotAttachmentUpload in
.init(
data: image.imageData,
mimeType: mimeType(pathExtension: "jpg"),
originalName: "image.jpg"
)
},
].flatMap(\.self)
assert(uploadableAttachments.allSatisfy { !$0.data.isEmpty })
Task {
do {
let selectedDocumentIds = editorData.documentAttachments.map(\.documentID)
if !selectedDocumentIds.isEmpty {
try await IntelligentContext.shared.waitForSelectedSources(selectedDocumentIds)
}
let messageParameters: AffineGraphQL.JSON = [
// packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx
"scopeSelectors": editorData.documentAttachments.map { attachment in
[
"kind": "document",
"id": attachment.documentID,
"name": attachment.title,
"source": "draft",
]
},
"searchMode": editorData.isSearchEnabled ? "MUST" : "AUTO",
]
let messageIdentifier = try await QLService.shared.createCopilotMessage(
workspaceId: workspaceId,
sessionId: sessionId,
content: editorData.text,
params: messageParameters,
attachments: uploadableAttachments
)
DispatchQueue.main.async {
self.startStreamingResponse(
sessionId: sessionId,
messageId: messageIdentifier,
applyingTo: viewModelId
)
}
} catch {
DispatchQueue.main.async {
self.report(sessionId, error)
self.delete(sessionId: sessionId, vmId: viewModelId)
}
}
}
}
private func pathExtension(for text: String) -> String {
(text as NSString).pathExtension
}
private func mimeType(pathExtension: String) -> String {
let type = UTType(filenameExtension: pathExtension) ?? .data
return type.preferredMIMEType ?? "application/octet-stream"
}
private func mimeType(text: String) -> String {
let pathExt = pathExtension(for: text)
return mimeType(pathExtension: pathExt)
}
}
private extension ChatManager {
func startStreamingResponse(sessionId: String, messageId: String, applyingTo vmId: UUID) {
print("[+] starting streaming response for session: \(sessionId), message: \(messageId)")
let base = IntelligentContext.shared.webViewMetadata[.currentServerBaseUrl] as? String
guard let base, let url = URL(string: base) else {
report(sessionId, ChatError.invalidServerConfiguration)
return
}
let streamUrl = url
.appendingPathComponent("api")
.appendingPathComponent("copilot")
.appendingPathComponent("chat")
.appendingPathComponent(sessionId)
.appendingPathComponent("stream")
var comps = URLComponents(url: streamUrl, resolvingAgainstBaseURL: false)
comps?.queryItems = [
.init(name: "messageId", value: messageId),
.init(name: "retry", value: "false"), // TODO: IMPL FROM UI
]
guard let finalUrl = comps?.url else {
report(sessionId, ChatError.invalidStreamURL)
return
}
var request = URLRequest(
url: finalUrl,
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
timeoutInterval: 10
)
request.setValue("close", forHTTPHeaderField: "Connection")
request = QLService.shared.authorized(request)
let closable = ClosableTask(detachedTask: .detached(operation: {
let eventSource = EventSource()
let dataTask = eventSource.dataTask(for: request)
var document = ""
await self.writeMarkdownContent(document + loadingIndicator, sessionId: sessionId, vmId: vmId)
for await event in dataTask.events() {
switch event {
case .open:
print("[*] connection opened")
case let .error(error):
print("[!] error occurred", error)
case let .event(event):
guard let data = event.data else { continue }
document += data
await self.writeMarkdownContent(
document + loadingIndicator,
sessionId: sessionId,
vmId: vmId
)
self.scrollToBottomPublisher.send(sessionId)
case .closed:
print("[*] connection closed")
}
}
await self.writeMarkdownContent(document, sessionId: sessionId, vmId: vmId)
self.closeAll()
}))
self.closable.append(closable)
}
@MainActor private func writeMarkdownContent(
_ document: String,
sessionId: SessionID,
vmId: UUID
) {
let result = MarkdownParser().parse(document)
let content = MarkdownTextView.PreprocessedContent(parserResult: result, theme: .default)
with(sessionId: sessionId, vmId: vmId) { (viewModel: inout AssistantMessageCellViewModel) in
viewModel.content = document
viewModel.preprocessedContent = content
}
}
}

View File

@@ -0,0 +1,92 @@
//
// ChatManager.swift
// Intelligents
//
// Created by 秋星桥 on 6/26/25.
//
import AffineGraphQL
import Apollo
import ApolloAPI
import Combine
import Foundation
import OrderedCollections
public class ChatManager: ObservableObject, @unchecked Sendable {
public static let shared = ChatManager()
public typealias SessionID = String
public typealias MessageID = UUID // ChatCellViewModel ID
@Published public private(set) var viewModels: OrderedDictionary<
SessionID,
OrderedDictionary<MessageID, any ChatCellViewModel>
> = [:]
public let scrollToBottomPublisher = PassthroughSubject<SessionID, Never>()
var closable: [Closable] = []
private init() {}
public func closeAll() {
print("[+] terminating all closables...")
closable.forEach { $0.close() }
closable.removeAll()
}
public func clearAll() {
assert(Thread.isMainThread)
closeAll()
viewModels.removeAll()
}
public func with(sessionId: String, _ action: (inout OrderedDictionary<MessageID, any ChatCellViewModel>) -> Void) {
if Thread.isMainThread {
if var sessionViewModels = viewModels[sessionId] {
action(&sessionViewModels)
viewModels[sessionId] = sessionViewModels
} else {
var sessionViewModels = OrderedDictionary<MessageID, any ChatCellViewModel>()
action(&sessionViewModels)
viewModels[sessionId] = sessionViewModels
}
} else {
DispatchQueue.main.asyncAndWait {
self.with(sessionId: sessionId, action)
}
}
}
public func with<T>(sessionId: String, vmId: UUID, _ action: (inout T) -> Void) {
with(sessionId: sessionId) { sessionViewModels in
if let read = sessionViewModels[vmId], var convert = read as? T {
action(&convert)
guard let vm = convert as? any ChatCellViewModel else {
assertionFailure()
return
}
sessionViewModels[vmId] = vm
}
}
}
public func delete(sessionId: String, vmId: UUID) {
with(sessionId: sessionId) { $0.removeValue(forKey: vmId) }
}
@discardableResult
public func append(sessionId: String, _ viewModel: any ChatCellViewModel) -> UUID {
with(sessionId: sessionId) { $0.updateValue(viewModel, forKey: viewModel.id) }
return viewModel.id
}
@discardableResult
public func report(_ sessionID: String, _ error: Error) -> UUID {
let model = ErrorCellViewModel(
id: .init(),
errorMessage: error.localizedDescription,
timestamp: .init()
)
append(sessionId: sessionID, model)
return model.id
}
}

View File

@@ -0,0 +1,54 @@
//
// ChatSessionObject.swift
// Intelligents
//
// Created by 秋星桥 on 6/26/25.
//
import AffineGraphQL
import Foundation
public struct ChatSessionObject: Codable, Identifiable, Equatable, Hashable {
public var id: String
public var workspaceId: String
public var docId: String?
public var promptName: String
public var model: String?
public var pinned: Bool
public var tokens: Int
public var createdAt: DateTime?
public var updatedAt: DateTime?
public var parentSessionId: String?
public var createdDate: Date? {
createdAt?.decoded
}
public var updatedDate: Date? {
updatedAt?.decoded
}
public init(
id: String,
workspaceId: String,
docId: String? = nil,
promptName: String,
model: String? = nil,
pinned: Bool,
tokens: Int,
createdAt: DateTime? = nil,
updatedAt: DateTime? = nil,
parentSessionId: String? = nil
) {
self.id = id
self.workspaceId = workspaceId
self.docId = docId
self.promptName = promptName
self.model = model
self.pinned = pinned
self.tokens = tokens
self.createdAt = createdAt
self.updatedAt = updatedAt
self.parentSessionId = parentSessionId
}
}

View File

@@ -0,0 +1,28 @@
//
// PromptName.swift
// Intelligents
//
// Created by 秋星桥 on 6/30/25.
//
import Foundation
public enum PromptName: String, Codable {
case summary = "Summary"
case summaryAsTitle = "Summary as title"
case explainThis = "Explain this"
case writeAnArticleAboutThis = "Write an article about this"
case writeATwitterAboutThis = "Write a twitter about this"
case writeAPoemAboutThis = "Write a poem about this"
case writeABlogPostAboutThis = "Write a blog post about this"
case writeOutline = "Write outline"
case changeToneTo = "Change tone to"
case improveWritingForIt = "Improve writing for it"
case improveGrammarForIt = "Improve grammar for it"
case fixSpellingForIt = "Fix spelling for it"
case createHeadings = "Create headings"
case makeItLonger = "Make it longer"
case makeItShorter = "Make it shorter"
case continueWriting = "Continue writing"
case chatWithAffineAI = "Chat With AFFiNE AI"
}

View File

@@ -0,0 +1,55 @@
//
// BridgedWindowScript.swift
// App
//
// Created by 秋星桥 on 2025/1/8.
//
import Foundation
import WebKit
/*
packages/frontend/apps/ios/src/app.tsx
*/
enum BridgedWindowScript: String {
case getCurrentDocContentInMarkdown = "return await window.getCurrentDocContentInMarkdown();"
case getCurrentServerBaseUrl = "window.getCurrentServerBaseUrl()"
case getCurrentWorkspaceId = "window.getCurrentWorkspaceId();"
case getCurrentDocId = "window.getCurrentDocId();"
case getAiButtonFeatureFlag = "window.getAiButtonFeatureFlag();"
case getCurrentI18nLocale = "window.getCurrentI18nLocale();"
case createNewDocByMarkdownInCurrentWorkspace = "return await window.createNewDocByMarkdownInCurrentWorkspace(markdown, title);"
case waitForSelectedSources = "return await window.waitForSelectedSources(documentIds);"
var requiresAsyncContext: Bool {
switch self {
case .getCurrentDocContentInMarkdown,
.createNewDocByMarkdownInCurrentWorkspace,
.waitForSelectedSources: true
default: false
}
}
}
extension WKWebView {
func evaluateScript(_ script: BridgedWindowScript, callback: @escaping (Any?) -> Void) {
if script.requiresAsyncContext {
callAsyncJavaScript(
script.rawValue,
arguments: [:],
in: nil,
in: .page
) { result in
switch result {
case let .success(input):
callback(input)
case .failure:
callback(nil)
}
}
} else {
evaluateJavaScript(script.rawValue) { output, _ in callback(output) }
}
}
}

View File

@@ -0,0 +1,79 @@
import AffineResources
import SwiftUI
extension Color {
/// Primary icon color
static var affineIconPrimary: Color {
AffineColors.iconPrimary.color
}
/// Primary background layer color
static var affineLayerBackgroundPrimary: Color {
AffineColors.layerBackgroundPrimary.color
}
/// Secondary background layer color
static var affineLayerBackgroundSecondary: Color {
AffineColors.layerBackgroundSecondary.color
}
/// Border layer color
static var affineLayerBorder: Color {
AffineColors.layerBorder.color
}
/// Pure white layer color
static var affineLayerPureWhite: Color {
AffineColors.layerPureWhite.color
}
/// Primary button color
static var affineButtonPrimary: Color {
AffineColors.buttonPrimary.color
}
/// Activated icon color
static var affineIconActivated: Color {
AffineColors.iconActivated.color
}
/// Text emphasis color
static var affineTextEmphasis: Color {
AffineColors.textEmphasis.color
}
/// Text link color
static var affineTextLink: Color {
AffineColors.textLink.color
}
/// List dot and number color
static var affineTextListDotAndNumber: Color {
AffineColors.textListDotAndNumber.color
}
/// Placeholder text color
static var affineTextPlaceholder: Color {
AffineColors.textPlaceholder.color
}
/// Primary text color
static var affineTextPrimary: Color {
AffineColors.textPrimary.color
}
/// Pure white text color
static var affineTextPureWhite: Color {
AffineColors.textPureWhite.color
}
/// Secondary text color
static var affineTextSecondary: Color {
AffineColors.textSecondary.color
}
/// Tertiary text color
static var affineTextTertiary: Color {
AffineColors.textTertiary.color
}
}

View File

@@ -0,0 +1,29 @@
//
// DateTime.swift
// Intelligents
//
// Created by 秋星桥 on 6/25/25.
//
import AffineGraphQL
import Apollo
import ApolloAPI
import Foundation
/// A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
extension DateTime {
private static let formatter: DateFormatter = {
let fmt = DateFormatter()
fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
fmt.timeZone = TimeZone(identifier: "UTC")
return fmt
}()
init(date: Date) {
self.init(Self.formatter.string(from: date))
}
var decoded: Date? {
Self.formatter.date(from: self)
}
}

View File

@@ -0,0 +1,94 @@
import AffineResources
import SwiftUI
extension Image {
/// Check circle icon
static var affineCheckCircle: Image {
AffineIcons.checkCircle.image
}
/// More options icon
static var affineMore: Image {
AffineIcons.more.image
}
/// Arrow down icon
static var affineArrowDown: Image {
AffineIcons.arrowDown.image
}
/// Tools icon
static var affineTools: Image {
AffineIcons.tools.image
}
/// Box icon
static var affineBox: Image {
AffineIcons.box.image
}
/// Think icon
static var affineThink: Image {
AffineIcons.think.image
}
/// Web icon
static var affineWeb: Image {
AffineIcons.web.image
}
/// Calendar icon
static var affineCalendar: Image {
AffineIcons.calendar.image
}
/// Camera icon
static var affineCamera: Image {
AffineIcons.camera.image
}
/// Close icon
static var affineClose: Image {
AffineIcons.close.image
}
/// Big arrow up icon
static var affineArrowUpBig: Image {
AffineIcons.arrowUpBig.image
}
/// Broom icon
static var affineBroom: Image {
AffineIcons.broom.image
}
/// Bubble icon
static var affineBubble: Image {
AffineIcons.bubble.image
}
/// Image icon
static var affineImage: Image {
AffineIcons.image.image
}
/// Page icon
static var affinePage: Image {
AffineIcons.page.image
}
/// Plus icon
static var affinePlus: Image {
AffineIcons.plus.image
}
/// Settings icon
static var affineSettings: Image {
AffineIcons.settings.image
}
/// Upload icon
static var affineUpload: Image {
AffineIcons.upload.image
}
}

View File

@@ -0,0 +1,100 @@
// The MIT License (MIT)
//
// Copyright (c) 2015 Suyeol Jeon (xoul.kr)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
// this package uses swift version 5.0 which is currently broken
import Foundation
#if !os(Linux)
import CoreGraphics
#endif
#if os(iOS) || os(tvOS)
import UIKit.UIGeometry
#endif
public protocol Then {}
extension Then where Self: Any {
/// Makes it available to set properties with closures just after initializing and copying the value types.
///
/// let frame = CGRect().with {
/// $0.origin.x = 10
/// $0.size.width = 100
/// }
@inlinable
public func with(_ block: (inout Self) throws -> Void) rethrows -> Self {
var copy = self
try block(&copy)
return copy
}
/// Makes it available to execute something with closures.
///
/// UserDefaults.standard.do {
/// $0.set("devxoul", forKey: "username")
/// $0.set("devxoul@gmail.com", forKey: "email")
/// $0.synchronize()
/// }
@inlinable
public func `do`(_ block: (Self) throws -> Void) rethrows {
try block(self)
}
}
extension Then where Self: AnyObject {
/// Makes it available to set properties with closures just after initializing.
///
/// let label = UILabel().then {
/// $0.textAlignment = .center
/// $0.textColor = UIColor.black
/// $0.text = "Hello, World!"
/// }
@inlinable
public func then(_ block: (Self) throws -> Void) rethrows -> Self {
try block(self)
return self
}
}
extension NSObject: Then {}
#if !os(Linux)
extension CGPoint: Then {}
extension CGRect: Then {}
extension CGSize: Then {}
extension CGVector: Then {}
#endif
extension Array: Then {}
extension Dictionary: Then {}
extension Set: Then {}
extension JSONDecoder: Then {}
extension JSONEncoder: Then {}
#if os(iOS) || os(tvOS)
extension UIEdgeInsets: Then {}
extension UIOffset: Then {}
extension UIRectEdge: Then {}
#endif

View File

@@ -0,0 +1,79 @@
import AffineResources
import UIKit
extension UIColor {
/// Primary icon color
static var affineIconPrimary: UIColor {
AffineColors.iconPrimary.uiColor
}
/// Primary background layer color
static var affineLayerBackgroundPrimary: UIColor {
AffineColors.layerBackgroundPrimary.uiColor
}
/// Secondary background layer color
static var affineLayerBackgroundSecondary: UIColor {
AffineColors.layerBackgroundSecondary.uiColor
}
/// Border layer color
static var affineLayerBorder: UIColor {
AffineColors.layerBorder.uiColor
}
/// Pure white layer color
static var affineLayerPureWhite: UIColor {
AffineColors.layerPureWhite.uiColor
}
/// Primary button color
static var affineButtonPrimary: UIColor {
AffineColors.buttonPrimary.uiColor
}
/// Activated icon color
static var affineIconActivated: UIColor {
AffineColors.iconActivated.uiColor
}
/// Text emphasis color
static var affineTextEmphasis: UIColor {
AffineColors.textEmphasis.uiColor
}
/// Text link color
static var affineTextLink: UIColor {
AffineColors.textLink.uiColor
}
/// List dot and number color
static var affineTextListDotAndNumber: UIColor {
AffineColors.textListDotAndNumber.uiColor
}
/// Placeholder text color
static var affineTextPlaceholder: UIColor {
AffineColors.textPlaceholder.uiColor
}
/// Primary text color
static var affineTextPrimary: UIColor {
AffineColors.textPrimary.uiColor
}
/// Pure white text color
static var affineTextPureWhite: UIColor {
AffineColors.textPureWhite.uiColor
}
/// Secondary text color
static var affineTextSecondary: UIColor {
AffineColors.textSecondary.uiColor
}
/// Tertiary text color
static var affineTextTertiary: UIColor {
AffineColors.textTertiary.uiColor
}
}

View File

@@ -0,0 +1,19 @@
//
// UIColor.swift
// Intelligents
//
// Created by 秋星桥 on 6/24/25.
//
import UIKit
private extension UIColor {
convenience init(hex: Int, alpha: CGFloat = 1.0) {
self.init(
red: CGFloat((hex & 0xFF0000) >> 16) / 255.0,
green: CGFloat((hex & 0x00FF00) >> 8) / 255.0,
blue: CGFloat(hex & 0x0000FF) / 255.0,
alpha: alpha
)
}
}

View File

@@ -0,0 +1,94 @@
import AffineResources
import UIKit
extension UIImage {
/// Check circle icon
static var affineCheckCircle: UIImage {
AffineIcons.checkCircle.uiImage
}
/// More options icon
static var affineMore: UIImage {
AffineIcons.more.uiImage
}
/// Arrow down icon
static var affineArrowDown: UIImage {
AffineIcons.arrowDown.uiImage
}
/// Tools icon
static var affineTools: UIImage {
AffineIcons.tools.uiImage
}
/// Box icon
static var affineBox: UIImage {
AffineIcons.box.uiImage
}
/// Think icon
static var affineThink: UIImage {
AffineIcons.think.uiImage
}
/// Web icon
static var affineWeb: UIImage {
AffineIcons.web.uiImage
}
/// Calendar icon
static var affineCalendar: UIImage {
AffineIcons.calendar.uiImage
}
/// Camera icon
static var affineCamera: UIImage {
AffineIcons.camera.uiImage
}
/// Close icon
static var affineClose: UIImage {
AffineIcons.close.uiImage
}
/// Big arrow up icon
static var affineArrowUpBig: UIImage {
AffineIcons.arrowUpBig.uiImage
}
/// Broom icon
static var affineBroom: UIImage {
AffineIcons.broom.uiImage
}
/// Bubble icon
static var affineBubble: UIImage {
AffineIcons.bubble.uiImage
}
/// Image icon
static var affineImage: UIImage {
AffineIcons.image.uiImage
}
/// Page icon
static var affinePage: UIImage {
AffineIcons.page.uiImage
}
/// Plus icon
static var affinePlus: UIImage {
AffineIcons.plus.uiImage
}
/// Settings icon
static var affineSettings: UIImage {
AffineIcons.settings.uiImage
}
/// Upload icon
static var affineUpload: UIImage {
AffineIcons.upload.uiImage
}
}

View File

@@ -0,0 +1,52 @@
//
// ViewPreview.swift
// Intelligents
//
// Created by 秋星桥 on 6/24/25.
//
import Foundation
import UIKit
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct UIViewControllerPreview<ViewController: UIViewController>: UIViewControllerRepresentable {
let viewController: ViewController
init(_ builder: @escaping () -> ViewController) {
viewController = builder()
}
func makeUIViewController(context _: Context) -> ViewController {
viewController
}
func updateUIViewController(_: ViewController, context _: Context) {}
}
#endif
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct UIViewPreview<View: UIView>: UIViewRepresentable {
let view: View
init(_ builder: @escaping () -> View) {
view = builder()
}
// MARK: UIViewRepresentable
func makeUIView(context _: Context) -> UIView {
view
}
func updateUIView(_ view: UIView, context _: Context) {
view.setContentCompressionResistancePriority(.fittingSizeLevel, for: .horizontal)
view.setContentCompressionResistancePriority(.fittingSizeLevel, for: .vertical)
view.setContentHuggingPriority(.defaultHigh, for: .horizontal)
view.setContentHuggingPriority(.defaultHigh, for: .vertical)
}
}
#endif

View File

@@ -0,0 +1,57 @@
//
// IntelligentContext+CreateSession.swift
// Intelligents
//
// Created by 秋星桥 on 6/27/25.
//
import AffineGraphQL
import Apollo
import ApolloAPI
import Foundation
public extension IntelligentContext {
func createSession(
workspaceId: String,
promptName: PromptName = .chatWithAffineAI,
docId: String? = nil,
pinned: Bool = false,
completion: @escaping (Result<ChatSessionObject, Error>) -> Void
) {
let input = CreateChatSessionInput(
docId: docId.map { .some($0) } ?? .null,
pinned: .some(pinned),
promptName: promptName.rawValue,
workspaceId: workspaceId
)
let mutation = CreateCopilotSessionWithHistoryMutation(options: input)
QLService.shared.client.perform(mutation: mutation) { result in
switch result {
case let .success(graphQLResult):
guard let history = graphQLResult.data?.createCopilotSessionWithHistory else {
completion(.failure(IntelligentError.sessionCreationFailed("No session ID returned.")))
return
}
let session = ChatSessionObject(
id: history.sessionId,
workspaceId: history.workspaceId,
docId: history.docId,
promptName: history.promptName,
model: nil,
pinned: history.pinned,
tokens: 0,
createdAt: history.createdAt,
updatedAt: history.updatedAt,
parentSessionId: history.parentSessionId
)
completion(.success(session))
case let .failure(error):
completion(.failure(IntelligentError.sessionCreationFailed(error.localizedDescription)))
}
}
}
}

View File

@@ -0,0 +1,73 @@
//
// IntelligentContext+GraphQL.swift
// Intelligents
//
// Created by 秋星桥 on 6/23/25.
//
import AffineGraphQL
import Apollo
import ApolloAPI
import UIKit
extension IntelligentContext {
func prepareMetadataFromGraphQlClient(completion: @escaping ([QLMetadataKey: Any]) -> Void) {
var newMetadata: [QLMetadataKey: Any] = [:]
let dispatchGroup = DispatchGroup()
let service = QLService.shared
dispatchGroup.enter()
service.fetchCurrentUser { user in
if let user {
newMetadata[.userIdentifierKey] = user.id
newMetadata[.userNameKey] = user.name
newMetadata[.userEmailKey] = user.email
if let avatarUrl = user.avatarUrl {
newMetadata[.userAvatarKey] = avatarUrl
}
}
dispatchGroup.leave()
}
dispatchGroup.enter()
service.fetchUserSettings { settings in
if let settings {
newMetadata[.userSettingsKey] = settings
}
dispatchGroup.leave()
}
dispatchGroup.enter()
service.fetchWorkspaces { workspaces in
newMetadata[.workspacesCountKey] = workspaces.count
newMetadata[.workspacesKey] = workspaces.map { workspace in
[
"id": workspace.id,
"team": workspace.team,
]
}
dispatchGroup.leave()
}
dispatchGroup.enter()
service.fetchSubscription { subscription in
if let subscription {
newMetadata[.subscriptionStatusKey] = subscription.status
newMetadata[.subscriptionPlanKey] = subscription.plan
}
dispatchGroup.leave()
}
dispatchGroup.enter()
service.fetchQuota { quota in
if let quota {
newMetadata[.storageQuotaKey] = quota.storageQuota
}
dispatchGroup.leave()
}
dispatchGroup.notify(queue: .main) {
completion(newMetadata)
}
}
}

View File

@@ -0,0 +1,16 @@
//
// IntelligentContext+Markdown.swift
// Intelligents
//
// Created by 秋星桥 on 7/4/25.
//
import Foundation
import MarkdownView
extension IntelligentContext {
@MainActor func prepareMarkdownViewThemes() {
MarkdownTheme.default.colors.body = .affineTextPrimary
MarkdownTheme.default.colors.highlight = .affineTextLink
}
}

View File

@@ -0,0 +1,44 @@
//
// IntelligentContext+WebView.swift
// Intelligents
//
// Created by 秋星桥 on 6/23/25.
//
import UIKit
import WebKit
extension IntelligentContext {
@MainActor
func waitForSelectedSources(_ documentIds: [String]) async throws {
_ = try await webView.callAsyncJavaScript(
BridgedWindowScript.waitForSelectedSources.rawValue,
arguments: ["documentIds": documentIds],
in: nil,
contentWorld: .page
)
}
func prepareMetadataFrom(webView: WKWebView, completion: @escaping ([WebViewMetadataKey: Any]) -> Void) {
var newMetadata: [WebViewMetadataKey: Any] = [:]
let dispatchGroup = DispatchGroup()
let keysAndScripts: [(WebViewMetadataKey, BridgedWindowScript)] = [
(.currentDocId, .getCurrentDocId),
(.currentWorkspaceId, .getCurrentWorkspaceId),
(.currentServerBaseUrl, .getCurrentServerBaseUrl),
(.currentI18nLocale, .getCurrentI18nLocale),
(.currentAiButtonFeatureFlag, .getAiButtonFeatureFlag),
]
for (key, script) in keysAndScripts {
DispatchQueue.main.async {
webView.evaluateScript(script) { value in
newMetadata[key] = value // if unable to fetch, clear it
dispatchGroup.leave()
}
}
dispatchGroup.enter()
}
dispatchGroup.wait()
completion(newMetadata)
}
}

View File

@@ -0,0 +1,212 @@
//
// IntelligentContext.swift
// Intelligents
//
// Created by 秋星桥 on 6/17/25.
//
import Combine
import Foundation
import WebKit
public class IntelligentContext {
public static let shared = IntelligentContext()
public var webView: WKWebView!
public private(set) var qlMetadata: [QLMetadataKey: Any] = [:]
public enum QLMetadataKey: String, CaseIterable {
case accountIdentifier
case userIdentifierKey
case userNameKey
case userEmailKey
case userAvatarKey
case userSettingsKey
case workspacesCountKey
case workspacesKey
case subscriptionStatusKey
case subscriptionPlanKey
case storageQuotaKey
case storageUsedKey
}
var isAccountValid: Bool {
true
}
public private(set) var webViewMetadata: [WebViewMetadataKey: Any] = [:]
public enum WebViewMetadataKey: String, CaseIterable {
case currentDocId
case currentWorkspaceId
case currentServerBaseUrl
case currentI18nLocale
case currentAiButtonFeatureFlag
}
@Published public private(set) var currentSession: ChatSessionObject?
@Published public private(set) var currentWorkspaceId: String?
public lazy var temporaryDirectory: URL = {
let tempDir = FileManager.default.temporaryDirectory
return tempDir.appendingPathComponent("IntelligentContext")
}()
public enum IntelligentError: Error, LocalizedError {
case loginRequired(String)
case sessionCreationFailed(String)
case featureClosed
public var errorDescription: String? {
switch self {
case let .loginRequired(reason):
"Login required: \(reason)"
case let .sessionCreationFailed(reason):
"Session creation failed: \(reason)"
case .featureClosed:
"Intelligent feature closed"
}
}
}
private init() {}
public func preparePresent(
createSession shouldCreateSession: Bool = true,
_ completion: @escaping (Result<Void, Error>) -> Void
) {
assert(webView != nil)
DispatchQueue.global(qos: .userInitiated).async { [self] in
prepareTemporaryDirectory()
prepareMarkdownViewThemes()
let webViewGroup = DispatchGroup()
var webViewMetadataResult: [WebViewMetadataKey: Any] = [:]
webViewGroup.enter()
prepareMetadataFrom(webView: webView) { metadata in
webViewMetadataResult = metadata
webViewGroup.leave()
}
webViewGroup.wait()
webViewMetadata = webViewMetadataResult
if webViewMetadataResult[.currentAiButtonFeatureFlag] as? Bool == false {
completion(.failure(IntelligentError.featureClosed))
return
}
// Check required webView metadata
guard let baseUrlString = webViewMetadataResult[.currentServerBaseUrl] as? String,
!baseUrlString.isEmpty,
let url = URL(string: baseUrlString)
else {
completion(.failure(IntelligentError.loginRequired("Missing server base URL")))
return
}
guard let workspaceId = webViewMetadataResult[.currentWorkspaceId] as? String,
!workspaceId.isEmpty
else {
completion(.failure(IntelligentError.loginRequired("Missing workspace ID")))
return
}
currentWorkspaceId = workspaceId
QLService.shared.setEndpoint(base: url)
let gqlGroup = DispatchGroup()
var gqlMetadataResult: [QLMetadataKey: Any] = [:]
gqlGroup.enter()
prepareMetadataFromGraphQlClient { metadata in
gqlMetadataResult = metadata
gqlGroup.leave()
}
gqlGroup.wait()
qlMetadata = gqlMetadataResult
guard let userIdentifier = gqlMetadataResult[.userIdentifierKey] as? String,
!userIdentifier.isEmpty
else {
completion(.failure(IntelligentError.loginRequired("Missing user identifier")))
return
}
let currentDocumentId: String? = webViewMetadata[.currentDocId] as? String
dumpMetadataContents()
guard shouldCreateSession else {
completion(.success(()))
return
}
createSession(
workspaceId: workspaceId,
docId: currentDocumentId
) { result in
switch result {
case let .success(session):
self.currentSession = session
completion(.success(()))
case let .failure(error):
completion(.failure(error))
}
}
}
}
func dumpMetadataContents() {
print("\n========== IntelligentContext Metadata ==========")
print("-- QL Metadata --")
for key in QLMetadataKey.allCases {
let value = qlMetadata[key] ?? "<nil>"
print("\(key.rawValue): \(value)")
}
print("\n-- WebView Metadata --")
for key in WebViewMetadataKey.allCases {
let value = webViewMetadata[key] ?? "<nil>"
print("\(key.rawValue): \(value)")
}
print("===============================================\n")
}
func prepareTemporaryDirectory() {
if FileManager.default.fileExists(atPath: temporaryDirectory.path) {
try? FileManager.default.removeItem(at: temporaryDirectory)
}
try? FileManager.default.createDirectory(
at: temporaryDirectory,
withIntermediateDirectories: true
)
}
}
/*
dumpMetadataContents sample:
========== IntelligentContext Metadata ==========
-- QL Metadata --
accountIdentifier: <nil>
userIdentifierKey: 82a5a6f0-xxxx-xxxx-xxxx-0de4be320696
userNameKey: Dev User
userEmailKey: xxx@xxxxx.xxx
userAvatarKey: https://avatar.affineassets.com/82a5a6f0-xxxx-xxxx-xxxx-0de4be320696-avatar-1733191099480
userSettingsKey: {
"__typename" = UserSettingsType;
receiveInvitationEmail = 1;
receiveMentionEmail = 1;
}
workspacesCountKey: 8
workspacesKey: [["id": "a0d781bf-xxxx-xxxx-xxxx-19394bad7f24", "team": false], ["id": "b00d1110-xxxx-xxxx-xxxx-4bc39685af7c", "team": false], ["id": "5559196a-xxxx-xxxx-xxxx-fc9ee6e2dbf9", "team": false], ["team": true, "id": "0f58ea6f-xxxx-xxxx-xxxx-30c4b01a346a"], ["id": "c4e72530-xxxx-xxxx-xxxx-888a166c8155", "team": true], ["id": "c924e653-xxxx-xxxx-xxxx-ed4be3a7d7c8", "team": false], ["id": "ac772e5a-xxxx-xxxx-xxxx-4e2049259408", "team": true], ["id": "4dc9c0ca-xxxx-xxxx-xxxx-7b84184f7e1d", "team": true]]
subscriptionStatusKey: case(AffineGraphQL.SubscriptionStatus.active)
subscriptionPlanKey: case(AffineGraphQL.SubscriptionPlan.pro)
storageQuotaKey: 10737418240
storageUsedKey: <nil>
-- WebView Metadata --
currentDocId: <null>
currentWorkspaceId: <null>
currentServerBaseUrl: https://affine.fail
currentI18nLocale: en
===============================================
*/

View File

@@ -0,0 +1,139 @@
import AffineGraphQL
import CryptoKit
import Foundation
struct CopilotAttachmentUpload {
let data: Data
let mimeType: String
let originalName: String
}
extension QLService {
func createCopilotMessage(
workspaceId: String,
sessionId: String,
content: String,
params: AffineGraphQL.JSON,
attachments: [CopilotAttachmentUpload]
) async throws -> String {
if attachments.isEmpty {
return try await createCopilotMessage(
input: CreateChatMessageInput(
attachments: .none,
blob: .none,
blobs: .none,
content: .some(content),
params: .some(params),
sessionId: sessionId
)
)
}
let attachmentURLs = try await uploadCopilotAttachments(
workspaceId: workspaceId,
sessionId: sessionId,
attachments: attachments
)
return try await createCopilotMessage(
input: CreateChatMessageInput(
attachments: .some(attachmentURLs),
blob: .none,
blobs: .none,
content: .some(content),
params: .some(params),
sessionId: sessionId
)
)
}
private func createCopilotMessage(
input: CreateChatMessageInput
) async throws -> String {
let mutation = CreateCopilotMessageMutation(options: input)
let data = try await perform(mutation: mutation)
return data.createCopilotMessage
}
private func uploadCopilotAttachments(
workspaceId: String,
sessionId: String,
attachments: [CopilotAttachmentUpload]
) async throws -> [String] {
var uploadedURLs = [String]()
uploadedURLs.reserveCapacity(attachments.count)
for attachment in attachments {
let uploadedURL = try await uploadCopilotAttachment(
workspaceId: workspaceId,
sessionId: sessionId,
attachment: attachment
)
uploadedURLs.append(uploadedURL)
}
return uploadedURLs
}
private func uploadCopilotAttachment(
workspaceId: String,
sessionId: String,
attachment: CopilotAttachmentUpload
) async throws -> String {
let blobKey = blobKey(for: attachment)
var components = URLComponents(
url: serverBaseURL
.appendingPathComponent("api")
.appendingPathComponent("copilot")
.appendingPathComponent("chat")
.appendingPathComponent(sessionId)
.appendingPathComponent("attachments")
.appendingPathComponent(blobKey),
resolvingAgainstBaseURL: false
)
components?.queryItems = [
URLQueryItem(name: "workspaceId", value: workspaceId),
URLQueryItem(name: "mimeType", value: attachment.mimeType),
URLQueryItem(name: "fileName", value: attachment.originalName),
]
guard let url = components?.url else {
throw NSError(
domain: "QLService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid Copilot attachment URL"]
)
}
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.httpBody = attachment.data
request.setValue(String(attachment.data.count), forHTTPHeaderField: "Content-Length")
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
let (data, response) = try await sendAuthenticatedRequest(request)
guard (200 ..< 300).contains(response.statusCode) else {
throw NSError(
domain: "QLService",
code: response.statusCode,
userInfo: [NSLocalizedDescriptionKey: "Copilot attachment upload failed"]
)
}
guard
let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let attachmentURL = payload["url"] as? String
else {
throw NSError(
domain: "QLService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Missing Copilot attachment URL"]
)
}
return attachmentURL
}
private func blobKey(for attachment: CopilotAttachmentUpload) -> String {
let digest = SHA256.hash(data: attachment.data)
return Data(digest).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}

View File

@@ -0,0 +1,53 @@
//
// QLService+URLSessionCookieClient.swift
// Intelligents
//
// Created by 秋星桥 on 6/23/25.
//
import Apollo
import AffineGraphQL
import Foundation
extension QLService {
final class URLSessionCookieClient: URLSessionClient {
init() {
super.init()
session.configuration.httpCookieStorage = .init()
HTTPCookieStorage.shared.cookies?.forEach { cookie in
self.session.configuration.httpCookieStorage?.setCookie(cookie)
}
}
@discardableResult
override func sendRequest(
_ request: URLRequest,
taskDescription: String?,
rawTaskCompletionHandler: RawCompletion?,
completion: @escaping Completion
) -> URLSessionTask {
super.sendRequest(
QLService.shared.authorized(request),
taskDescription: taskDescription,
rawTaskCompletionHandler: rawTaskCompletionHandler,
completion: completion
)
}
}
}
extension QLService {
func authorized(_ request: URLRequest) -> URLRequest {
guard request.value(forHTTPHeaderField: "Authorization") == nil,
let requestURL = request.url,
let token = AuthAccessTokenCache.shared.token(
for: requestURL, matching: serverBaseURL)
else {
return request
}
var authorized = request
authorized.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return authorized
}
}

View File

@@ -0,0 +1,188 @@
import AffineGraphQL
import Apollo
import Foundation
public final class QLService {
public static let shared = QLService()
private var endpointURL: URL
private var urlSessionClient: URLSessionCookieClient
public var client: ApolloClient
private init() {
let store = ApolloStore()
endpointURL = URL(string: "https://app.affine.pro/graphql")!
urlSessionClient = URLSessionCookieClient()
let networkTransport = RequestChainNetworkTransport(
interceptorProvider: DefaultInterceptorProvider(client: urlSessionClient, store: store),
endpointURL: endpointURL
)
client = ApolloClient(networkTransport: networkTransport, store: store)
}
public func setEndpoint(base: URL) {
var url: URL = base
if url.lastPathComponent != "graphql" {
url = url.appendingPathComponent("graphql")
}
print("[*] setting endpoint for qlservice: \(url.absoluteString)")
let store = ApolloStore()
endpointURL = url
urlSessionClient = URLSessionCookieClient()
let networkTransport = RequestChainNetworkTransport(
interceptorProvider: DefaultInterceptorProvider(client: urlSessionClient, store: store),
endpointURL: url
)
client = ApolloClient(networkTransport: networkTransport, store: store)
}
var serverBaseURL: URL {
if endpointURL.lastPathComponent == "graphql" {
return endpointURL.deletingLastPathComponent()
}
return endpointURL
}
func sendAuthenticatedRequest(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
try await withCheckedThrowingContinuation { continuation in
urlSessionClient.sendRequest(request) { result in
continuation.resume(with: result)
}
}
}
func perform<Mutation: GraphQLMutation>(mutation: Mutation) async throws -> Mutation.Data {
try await withCheckedThrowingContinuation { continuation in
client.perform(mutation: mutation) { result in
switch result {
case let .success(graphQLResult):
if let data = graphQLResult.data {
continuation.resume(returning: data)
} else if let error = graphQLResult.errors?.first {
continuation.resume(throwing: error)
} else {
continuation.resume(throwing: NSError(
domain: "QLService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Missing GraphQL data"]
))
}
case let .failure(error):
continuation.resume(throwing: error)
}
}
}
}
func fetch<Query: GraphQLQuery>(query: Query) async throws -> Query.Data {
try await withCheckedThrowingContinuation { continuation in
client.fetch(query: query) { result in
switch result {
case let .success(graphQLResult):
if let data = graphQLResult.data {
continuation.resume(returning: data)
} else if let error = graphQLResult.errors?.first {
continuation.resume(throwing: error)
} else {
continuation.resume(throwing: NSError(
domain: "QLService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Missing GraphQL data"]
))
}
case let .failure(error):
continuation.resume(throwing: error)
}
}
}
}
public func fetchCurrentUser(completion: @escaping (GetCurrentUserQuery.Data.CurrentUser?) -> Void) {
client.fetch(query: GetCurrentUserQuery()) { result in
switch result {
case let .success(graphQLResult):
completion(graphQLResult.data?.currentUser)
case .failure:
completion(nil)
}
}
}
public func fetchUserSettings(completion: @escaping (GetUserSettingsQuery.Data.CurrentUser.Settings?) -> Void) {
client.fetch(query: GetUserSettingsQuery()) { result in
switch result {
case let .success(graphQLResult):
completion(graphQLResult.data?.currentUser?.settings)
case .failure:
completion(nil)
}
}
}
public func fetchWorkspaces(completion: @escaping ([GetWorkspacesQuery.Data.Workspace]) -> Void) {
client.fetch(query: GetWorkspacesQuery()) { result in
switch result {
case let .success(graphQLResult):
completion(graphQLResult.data?.workspaces ?? [])
case .failure:
completion([])
}
}
}
public func fetchSubscription(completion: @escaping (SubscriptionQuery.Data.CurrentUser.Subscription?) -> Void) {
client.fetch(query: SubscriptionQuery()) { result in
switch result {
case let .success(graphQLResult):
completion(graphQLResult.data?.currentUser?.subscriptions.first)
case .failure:
completion(nil)
}
}
}
public func fetchQuota(completion: @escaping (QuotaQuery.Data.CurrentUser.Quota?) -> Void) {
client.fetch(query: QuotaQuery()) { result in
switch result {
case let .success(graphQLResult):
completion(graphQLResult.data?.currentUser?.quota)
case .failure:
completion(nil)
}
}
}
public func searchDocuments(
workspaceId: String,
keyword: String,
limit: Int = 20,
completion: @escaping ([IndexerSearchDocsQuery.Data.Workspace.SearchDoc]) -> Void
) {
let input = SearchDocsInput(keyword: keyword, limit: .some(limit))
client.fetch(query: IndexerSearchDocsQuery(id: workspaceId, input: input)) { result in
switch result {
case let .success(graphQLResult):
completion(graphQLResult.data?.workspace.searchDocs ?? [])
case .failure:
completion([])
}
}
}
public func fetchRecentlyUpdatedDocs(
workspaceId: String,
first: Int = 20,
completion: @escaping ([GetRecentlyUpdatedDocsQuery.Data.Workspace.RecentlyUpdatedDocs.Edge.Node]) -> Void
) {
let pagination = PaginationInput(first: .some(first))
client.fetch(query: GetRecentlyUpdatedDocsQuery(workspaceId: workspaceId, pagination: pagination)) { result in
switch result {
case let .success(graphQLResult):
let docs = graphQLResult.data?.workspace.recentlyUpdatedDocs.edges.map(\.node) ?? []
completion(docs)
case .failure:
completion([])
}
}
}
}

View File

@@ -0,0 +1,8 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book
import AffineGraphQL
import Apollo
import Foundation
public enum Intelligents {}

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Page.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AiFileClip attachment icon.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AiFileClip attachment icon.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AiFileClip attachment icon.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AiFileClip attachment icon.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,303 @@
//
// AttachmentManagementController.swift
// Intelligents
//
// Created by 秋星桥 on 6/25/25.
//
import SnapKit
import UIKit
class AttachmentManagementController: UINavigationController {
private let _viewController: _AttachmentManagementController
init(delegate: AttachmentManagementControllerDelegate) {
let attachmentManagementController = _AttachmentManagementController(delegate: delegate)
_viewController = attachmentManagementController
super.init(rootViewController: attachmentManagementController)
_viewController.delegateController = self
navigationBar.isHidden = false
modalPresentationStyle = .formSheet
modalTransitionStyle = .coverVertical
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
func set(documentAttachments attachments: [DocumentAttachment]) {
_ = _viewController.view // trigger view did load
_viewController.documentAttachments = attachments
}
func set(fileAttachments attachments: [FileAttachment]) {
_ = _viewController.view // trigger view did load
_viewController.fileAttachments = attachments
}
}
private class _AttachmentManagementController: UIViewController {
weak var delegateController: AttachmentManagementController?
weak var delegate: AttachmentManagementControllerDelegate?
private let tableView: UITableView = .init(frame: .zero, style: .plain)
private lazy var dataSource: UITableViewDiffableDataSource<
Section,
Item
> = .init(tableView: tableView) { [weak self] tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(withIdentifier: "AttachmentManagementCell", for: indexPath) as! AttachmentManagementCell
cell.configure(with: item)
cell.onDelete = { [weak self] in
guard let delegateController = self?.delegateController else { return }
switch item.type {
case let .file(file):
self?.delegate?.deleteFileAttachment(controller: delegateController, file)
case let .document(doc):
self?.delegate?.deleteDocumentAttachment(controller: delegateController, doc)
}
}
return cell
}
enum Section: Int, CaseIterable {
case files
case documents
}
struct Item: Hashable {
let id: UUID
let title: String
let icon: UIImage?
let type: ItemType
enum ItemType: Hashable {
case file(FileAttachment)
case document(DocumentAttachment)
}
static func == (lhs: Item, rhs: Item) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
var fileAttachments: [FileAttachment] = [] {
didSet {
NSObject.cancelPreviousPerformRequests(withTarget: self)
perform(#selector(reloadDataSource), with: nil, afterDelay: 0)
}
}
var documentAttachments: [DocumentAttachment] = [] {
didSet {
NSObject.cancelPreviousPerformRequests(withTarget: self)
perform(#selector(reloadDataSource), with: nil, afterDelay: 0)
}
}
init(delegate: AttachmentManagementControllerDelegate) {
self.delegate = delegate
super.init(nibName: nil, bundle: nil)
title = "Attachments & Docs"
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
navigationItem.title = "Attachments & Docs"
navigationItem.rightBarButtonItem = .init(systemItem: .done, primaryAction: .init { [weak self] _ in
self?.doneTapped()
})
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.register(AttachmentManagementCell.self, forCellReuseIdentifier: "AttachmentManagementCell")
tableView.clipsToBounds = true
tableView.rowHeight = UITableView.automaticDimension
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
applySnapshot()
}
@objc private func doneTapped() {
dismiss(animated: true)
}
@objc func reloadDataSource() {
applySnapshot()
if fileAttachments.isEmpty, documentAttachments.isEmpty {
dismiss(animated: true)
}
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.files, .documents])
let fileItems = fileAttachments.map { file in
Item(id: file.id, title: file.name, icon: file.icon, type: .file(file))
}
let docItems = documentAttachments.map { doc in
Item(id: doc.id, title: doc.title, icon: .init(named: "FileAttachment", in: .module, with: nil)!, type: .document(doc))
}
snapshot.appendItems(fileItems, toSection: .files)
snapshot.appendItems(docItems, toSection: .documents)
dataSource.apply(snapshot, animatingDifferences: true)
}
}
private class AttachmentManagementCell: UITableViewCell {
let container = UIView().then {
$0.layer.cornerRadius = 4
$0.layer.borderWidth = 0.5
$0.layer.borderColor = UIColor.affineLayerBorder.cgColor
}
let iconView = UIImageView().then {
$0.contentMode = .scaleAspectFit
$0.tintColor = .affineIconPrimary
$0.setContentCompressionResistancePriority(.required, for: .horizontal)
}
let titleLabel = UILabel().then {
$0.textColor = .label
$0.textAlignment = .left
$0.font = .preferredFont(forTextStyle: .body)
$0.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
let deleteButton = UIButton(type: .system).then {
$0.setImage(UIImage(systemName: "xmark"), for: .normal)
$0.tintColor = .affineIconPrimary
$0.setContentCompressionResistancePriority(.required, for: .horizontal)
}
var onDelete: (() -> Void)?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
setupUI()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
private let inset: CGFloat = 10
private func setupUI() {
contentView.addSubview(container)
container.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(4)
make.left.right.equalToSuperview().inset(inset)
}
container.addSubview(iconView)
container.addSubview(titleLabel)
container.addSubview(deleteButton)
iconView.snp.makeConstraints { make in
make.left.equalToSuperview().inset(inset)
make.centerY.equalToSuperview()
make.top.greaterThanOrEqualToSuperview().offset(inset)
make.bottom.lessThanOrEqualToSuperview().offset(-inset)
}
titleLabel.snp.makeConstraints { make in
make.left.equalTo(iconView.snp.right).offset(inset)
make.right.lessThanOrEqualTo(deleteButton.snp.left).offset(-inset)
make.centerY.equalToSuperview()
make.top.greaterThanOrEqualToSuperview().offset(inset)
make.bottom.lessThanOrEqualToSuperview().offset(-inset)
}
deleteButton.snp.makeConstraints { make in
make.right.equalToSuperview().offset(-inset)
make.centerY.equalToSuperview()
make.width.height.equalTo(12)
make.centerY.equalToSuperview()
make.top.greaterThanOrEqualToSuperview().offset(inset)
make.bottom.lessThanOrEqualToSuperview().offset(-inset)
}
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
container.layer.borderColor = UIColor.affineLayerBorder.cgColor
}
func configure(with item: _AttachmentManagementController.Item) {
iconView.image = item.icon
titleLabel.text = item.title
}
override func prepareForReuse() {
super.prepareForReuse()
iconView.image = nil
titleLabel.text = nil
onDelete = nil
}
@objc private func deleteTapped() {
onDelete?()
}
}
private extension FileAttachment {
var icon: UIImage? {
switch url.pathExtension.lowercased() {
case "pdf":
.init(named: "FileAttachment_pdf", in: .module, with: nil)!
case "json":
.init(named: "FileAttachment_json", in: .module, with: nil)!
case "md":
.init(named: "FileAttachment_md", in: .module, with: nil)!
case "txt":
.init(named: "FileAttachment_txt", in: .module, with: nil)!
default:
.init(named: "FileAttachment", in: .module, with: nil)!
}
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
private class MockDelegate: AttachmentManagementControllerDelegate {
static let shared = MockDelegate()
func deleteFileAttachment(controller _: AttachmentManagementController, _: FileAttachment) {}
func deleteDocumentAttachment(controller _: AttachmentManagementController, _: DocumentAttachment) {}
}
struct AttachmentManagementController_Previews: PreviewProvider {
static var previews: some View {
UIViewControllerPreview {
let vc = AttachmentManagementController(delegate: MockDelegate.shared)
let fileAttachments = [
FileAttachment(url: .init(fileURLWithPath: "/p.pdf"), name: "File 1.pdf"),
FileAttachment(url: .init(fileURLWithPath: "/p.md"), name: "File 2.md"),
FileAttachment(url: .init(fileURLWithPath: "/p.txt"), name: "File 3.txt"),
FileAttachment(url: .init(fileURLWithPath: "/p.json"), name: "File 4.json"),
FileAttachment(url: .init(fileURLWithPath: "/p.xls"), name: "File 4.xls"),
]
let documentAttachments = [
DocumentAttachment(title: "Cloud Document A"),
DocumentAttachment(title: "Cloud Document B"),
]
vc.set(fileAttachments: fileAttachments)
vc.set(documentAttachments: documentAttachments)
return vc
}
.edgesIgnoringSafeArea(.all)
}
}
#endif

View File

@@ -0,0 +1,13 @@
//
// AttachmentManagementControllerDelegate.swift
// Intelligents
//
// Created by 秋星桥 on 6/25/25.
//
import Foundation
protocol AttachmentManagementControllerDelegate: AnyObject {
func deleteFileAttachment(controller: AttachmentManagementController, _ attachment: FileAttachment)
func deleteDocumentAttachment(controller: AttachmentManagementController, _ attachment: DocumentAttachment)
}

View File

@@ -0,0 +1,158 @@
//
// BlurTransition.swift
// BlurTransition
//
// Created by 秋星桥 on 6/16/23.
//
import UIKit
extension UIViewController {
func presentWithFullScreenBlurTransition(_ viewController: UIViewController) {
viewController.modalPresentationStyle = .custom
viewController.transitioningDelegate = BlurTransitioningDelegate.shared
present(viewController, animated: true)
}
}
class BlurTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
static let shared = BlurTransitioningDelegate()
func animationController(
forPresented _: UIViewController,
presenting _: UIViewController,
source _: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
BlurTransitionAnimator(presenting: true)
}
func animationController(
forDismissed _: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
BlurTransitionAnimator(presenting: false)
}
}
class BlurTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
private let presenting: Bool
private let snapshotViewTag = "snapshotView".hashValue
private let blurViewTag = "blurView".hashValue
init(presenting: Bool) {
self.presenting = presenting
super.init()
}
func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval {
0.5
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
if presenting {
animatePresentation(using: transitionContext)
} else {
animateDismissal(using: transitionContext)
}
}
private func animatePresentation(using transitionContext: UIViewControllerContextTransitioning) {
guard let toViewController = transitionContext.viewController(forKey: .to),
let fromViewController = transitionContext.viewController(forKey: .from)
else {
transitionContext.completeTransition(false)
assertionFailure()
return
}
let toView = toViewController.view!
let fromView = fromViewController.view!
let containerView = transitionContext.containerView
guard let fromViewSnapshot = fromView.snapshotView(afterScreenUpdates: false) else {
transitionContext.completeTransition(false)
assertionFailure()
return
}
fromViewSnapshot.frame = fromView.frame
fromViewSnapshot.tag = snapshotViewTag
containerView.addSubview(fromViewSnapshot)
fromView.isHidden = true
let blurEffectView = UIVisualEffectView()
blurEffectView.frame = containerView.bounds
blurEffectView.tag = blurViewTag
containerView.addSubview(blurEffectView)
toView.frame = containerView.bounds
toView.alpha = 0
toView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
containerView.addSubview(toView)
toView.layoutIfNeeded()
performWithAnimation(animations: {
blurEffectView.effect = UIBlurEffect(style: .systemMaterial)
fromViewSnapshot.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
toView.alpha = 1
toView.transform = .identity
fromView.layoutIfNeeded()
toView.layoutIfNeeded()
}) { _ in
let success = !transitionContext.transitionWasCancelled
if !success {
assertionFailure()
fromView.isHidden = false
fromViewSnapshot.removeFromSuperview()
blurEffectView.removeFromSuperview()
toView.removeFromSuperview()
}
transitionContext.completeTransition(success)
}
}
private func animateDismissal(using transitionContext: UIViewControllerContextTransitioning) {
guard let fromViewController = transitionContext.viewController(forKey: .from),
let toViewController = transitionContext.viewController(forKey: .to)
else {
transitionContext.completeTransition(false)
assertionFailure()
return
}
let fromView = fromViewController.view!
let toView = toViewController.view!
let containerView = transitionContext.containerView
guard let fromViewSnapshot = containerView.viewWithTag(snapshotViewTag),
let blurEffectView = containerView.viewWithTag(blurViewTag) as? UIVisualEffectView
else {
toView.isHidden = false
assertionFailure()
transitionContext.completeTransition(true)
return
}
performWithAnimation(animations: {
fromViewSnapshot.transform = .identity
blurEffectView.effect = nil
fromView.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
fromView.alpha = 0
}) { _ in
let success = !transitionContext.transitionWasCancelled
if success {
toView.isHidden = false
fromViewSnapshot.removeFromSuperview()
blurEffectView.removeFromSuperview()
fromView.layoutIfNeeded()
toView.layoutIfNeeded()
} else {
assertionFailure()
fromView.transform = .identity
fromView.alpha = 1
}
transitionContext.completeTransition(success)
}
}
}

View File

@@ -0,0 +1,27 @@
//
// IntelligentsController.swift
// Intelligents
//
// Created by 秋星桥 on 6/17/25.
//
import UIKit
public class IntelligentsController: UINavigationController {
public init() {
super.init(rootViewController: MainViewController())
modalPresentationStyle = .custom
transitioningDelegate = BlurTransitioningDelegate.shared
setNavigationBarHidden(true, animated: false)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
override public func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
}
}

View File

@@ -0,0 +1,22 @@
//
// MainViewController+Header.swift
// Intelligents
//
// Created by 秋星桥 on 6/19/25.
//
import UIKit
extension MainViewController: MainHeaderViewDelegate {
func mainHeaderViewDidTapClose() {
dismiss(animated: true)
}
func mainHeaderViewDidTapDropdown() {
print(#function)
}
func mainHeaderViewDidTapMenu() {
print(#function)
}
}

View File

@@ -0,0 +1,201 @@
//
// MainViewController+Input.swift
// Intelligents
//
// Created by 秋星桥 on 6/19/25.
//
import PhotosUI
import SnapKit
import UIKit
import UniformTypeIdentifiers
extension MainViewController: InputBoxDelegate {
func inputBoxDidSelectTakePhoto(_: InputBox) {
let imagePickerController = UIImagePickerController()
imagePickerController.delegate = self
imagePickerController.sourceType = .camera
imagePickerController.allowsEditing = false
present(imagePickerController, animated: true)
}
func inputBoxDidSelectPhotoLibrary(_: InputBox) {
var configuration = PHPickerConfiguration()
configuration.filter = .images
configuration.selectionLimit = 0 // 0 means no limit
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
}
func inputBoxDidSelectAttachFiles(_: InputBox) {
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [
.pdf, .plainText, .commaSeparatedText, .data,
])
documentPicker.delegate = self
documentPicker.allowsMultipleSelection = false
present(documentPicker, animated: true)
}
func inputBoxDidSelectEmbedDocs(_: InputBox) {
showDocumentPicker()
}
@objc func showDocumentPicker() {
view.endEditing(true)
terminateEditGesture.isEnabled = false
documentPickerView.snp.remakeConstraints { make in
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
make.leading.trailing.equalToSuperview()
make.height.equalTo(300)
}
documentPickerHideDetector.isHidden = false
documentPickerView.setSelectedDocuments(inputBox.viewModel.documentAttachments)
performWithAnimation(duration: 0.75) {
self.view.layoutIfNeeded()
} completion: { _ in
self.documentPickerView.updateDocumentsFromRecentDocs()
self.documentPickerView.searchTextField.becomeFirstResponder()
}
}
@objc func hideDocumentPicker() {
terminateEditGesture.isEnabled = true
documentPickerView.snp.remakeConstraints { make in
make.top.equalTo(view.snp.bottom).offset(200)
make.leading.trailing.equalToSuperview()
make.height.equalTo(300)
}
documentPickerHideDetector.isHidden = true
performWithAnimation(duration: 0.75) {
self.view.layoutIfNeeded()
}
}
func inputBoxDidSend(_ inputBox: InputBox) {
let inputData = inputBox.inputBoxData
inputBox.text = ""
inputBox.viewModel.clearAllAttachments()
guard let currentSession = IntelligentContext.shared.currentSession else {
showAlert(title: "Error", message: "No active session available")
return
}
ChatManager.shared.closeAll()
ChatManager.shared.startUserRequest(editorData: inputData, sessionId: currentSession.id)
}
private func showAlert(title: String, message: String) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
func inputBoxTextDidChange(_ text: String) {
print(#function, text)
}
}
// MARK: - UIImagePickerControllerDelegate
extension MainViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
defer { picker.dismiss(animated: true) }
guard let image = info[.originalImage] as? UIImage else { return }
inputBox.addImageAttachment(image)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
// MARK: - PHPickerViewControllerDelegate
extension MainViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
defer { picker.dismiss(animated: true) }
for result in results {
if result.itemProvider.canLoadObject(ofClass: UIImage.self) {
result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
guard let image = object as? UIImage, error == nil else { return }
DispatchQueue.main.async {
self?.inputBox.addImageAttachment(image)
}
}
}
}
}
}
// MARK: - UIDocumentPickerDelegate
extension MainViewController: UIDocumentPickerDelegate {
func documentPicker(_: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
for url in urls {
// Start accessing security-scoped resource
guard url.startAccessingSecurityScopedResource() else { continue }
defer { url.stopAccessingSecurityScopedResource() }
// Copy file to temporary directory
let context = IntelligentContext.shared
context.prepareTemporaryDirectory()
let tempURL = context.temporaryDirectory.appendingPathComponent(url.lastPathComponent)
do {
// Remove existing file if it exists
if FileManager.default.fileExists(atPath: tempURL.path) {
try FileManager.default.removeItem(at: tempURL)
}
// Copy file to temporary directory
try FileManager.default.copyItem(at: url, to: tempURL)
// Add file attachment using the temporary URL
try inputBox.addFileAttachment(tempURL)
} catch {
let alert = UIAlertController(
title: "Error",
message: "Failed to process file: \(error.localizedDescription)",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}
}
}
// MARK: - DocumentPickerViewDelegate
extension MainViewController: DocumentPickerViewDelegate {
func documentPickerView(_: DocumentPickerView, didSelectDocument document: DocumentItem) {
// Get current workspace ID
guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String,
!workspaceId.isEmpty
else {
return
}
// Create DocumentAttachment from DocumentItem
let documentAttachment = DocumentAttachment(
title: document.title,
workspaceID: workspaceId,
documentID: document.id,
updatedAt: document.updatedAt
)
// Add to InputBox
inputBox.addDocumentAttachment(documentAttachment)
// Hide document picker
hideDocumentPicker()
}
}

View File

@@ -0,0 +1,113 @@
import Combine
import SnapKit
import UIKit
class MainViewController: UIViewController {
// MARK: - UI Components
lazy var headerView = MainHeaderView().then {
$0.delegate = self
}
lazy var listView = ChatListView()
lazy var inputBox = InputBox().then {
$0.delegate = self
}
lazy var documentPickerHideDetector = UIView().then {
$0.isUserInteractionEnabled = true
$0.isHidden = true
$0.addGestureRecognizer(
UITapGestureRecognizer(target: self, action: #selector(hideDocumentPicker))
)
}
lazy var documentPickerView = DocumentPickerView().then {
$0.delegate = self
}
// MARK: - Properties
var cancellables = Set<AnyCancellable>()
let intelligentContext = IntelligentContext.shared
let chatManager = ChatManager.shared
var terminateEditGesture: UITapGestureRecognizer!
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .affineLayerBackgroundPrimary
setupUI()
view.isUserInteractionEnabled = true
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
view.addGestureRecognizer(terminateEditGesture)
}
// MARK: - Setup
private func setupUI() {
view.addSubview(headerView)
view.addSubview(listView)
view.addSubview(inputBox)
view.addSubview(documentPickerHideDetector)
view.addSubview(documentPickerView)
headerView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
}
listView.snp.makeConstraints { make in
make.top.equalTo(headerView.snp.bottom)
make.left.right.equalToSuperview()
make.bottom.equalToSuperview()
}
inputBox.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
}
documentPickerHideDetector.snp.makeConstraints { make in
make.left.top.right.equalToSuperview()
make.bottom.equalTo(documentPickerView.snp.top)
}
documentPickerView.snp.makeConstraints { make in
make.top.equalTo(view.snp.bottom)
make.leading.trailing.equalToSuperview()
make.height.equalTo(500)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController!.setNavigationBarHidden(true, animated: animated)
documentPickerView.updateDocumentsFromRecentDocs()
DispatchQueue.main.async {
self.inputBox.textView.becomeFirstResponder()
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController!.setNavigationBarHidden(false, animated: animated)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let bottomAnchor = inputBox.frame.minY
let bottomInset = view.bounds.height - bottomAnchor + 64
if listView.listView.bottomInset != bottomInset {
listView.listView.bottomInset = bottomInset
}
}
@objc func terminateEditing() {
view.endEditing(true)
}
}

View File

@@ -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()
}
})
}
}

View File

@@ -0,0 +1,331 @@
//
// ParticleView+Renderer.swift
// UIEffectKit
//
// Created by 秋星桥 on 6/13/25.
//
import MetalKit
extension ParticleView {
class Renderer: NSObject, MTKViewDelegate {
private struct Particle {
var position: simd_float2
var velocity: simd_float2
var life: simd_float1
var duration: simd_float1
}
private struct Vertex {
var position: simd_float4
var uv: simd_float2
var opacity: simd_float1
}
private var isPrepared = false
private var renderPipeline: MTLRenderPipelineState!
private var computePipeline: MTLComputePipelineState!
private var vertexBuffer: MTLBuffer!
private var particleBuffer: MTLBuffer!
private var particleCount: Int = 0
private var texture: MTLTexture!
private var targetFrameSize: simd_float2 = .zero
private var stepSize: Float = 0
private var commandQueue: MTLCommandQueue!
private var maxLife: Float = 0
private var onComplete: (() -> Void)?
private var onFirstFrameRendered: (() -> Void)?
private var hasRenderedFirstFrame = false
private var device: MTLDevice!
func prepareResources(
with device: MTLDevice,
image: CGImage,
targetFrame: CGRect,
onComplete: @escaping () -> Void,
onFirstFrameRendered: @escaping () -> Void
) {
guard !isPrepared else { return }
self.device = device
self.onComplete = onComplete
self.onFirstFrameRendered = onFirstFrameRendered
let integralTargetFrame = targetFrame.integral
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self else { return }
setupPipelineStates(with: device)
setupVertexBuffer(with: device)
setupParticleSystem(targetFrame: integralTargetFrame, device: device)
setupTexture(from: image, device: device)
finalizeSetup(targetFrame: integralTargetFrame, device: device)
DispatchQueue.main.async { self.isPrepared = true }
}
}
private func setupPipelineStates(with device: MTLDevice) {
let library = try! device.makeDefaultLibrary(bundle: .module)
let particleVertexFunction = library.makeFunction(name: "PTS_ParticleVertex")!
let particleFragmentFunction = library.makeFunction(name: "PTS_ParticleFragment")!
let updateParticlesFunction = library.makeFunction(name: "PTS_UpdateParticles")!
let renderPipelineDescriptor = createRenderPipelineDescriptor(
vertexFunction: particleVertexFunction,
fragmentFunction: particleFragmentFunction
)
do {
renderPipeline = try device.makeRenderPipelineState(descriptor: renderPipelineDescriptor)
computePipeline = try device.makeComputePipelineState(function: updateParticlesFunction)
} catch {
fatalError("failed to create pipeline states: \(error)")
}
}
private func createRenderPipelineDescriptor(
vertexFunction: MTLFunction,
fragmentFunction: MTLFunction
) -> MTLRenderPipelineDescriptor {
let descriptor = MTLRenderPipelineDescriptor()
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
descriptor.colorAttachments[0].isBlendingEnabled = true
descriptor.colorAttachments[0].rgbBlendOperation = .add
descriptor.colorAttachments[0].alphaBlendOperation = .add
descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha
descriptor.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha
descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
descriptor.vertexFunction = vertexFunction
descriptor.fragmentFunction = fragmentFunction
return descriptor
}
func mtkView(_: MTKView, drawableSizeWillChange _: CGSize) {
// No-op since view is not subject to resize
}
func draw(in view: MTKView) {
guard isPrepared else { return }
updateParticles()
if checkAllParticlesDead() {
DispatchQueue.main.async { [weak self] in
self?.onComplete?()
}
return
}
renderParticles(in: view)
if !hasRenderedFirstFrame {
hasRenderedFirstFrame = true
DispatchQueue.main.async { [weak self] in
self?.onFirstFrameRendered?()
}
}
}
private func updateParticles() {
let maxThreadsPerThreadgroup = computePipeline.maxTotalThreadsPerThreadgroup
let threadgroupSize = min(maxThreadsPerThreadgroup, 2048)
let threadgroupCount = (particleCount + threadgroupSize - 1) / threadgroupSize
let computeCommandBuffer = commandQueue.makeCommandBuffer()!
let computeCommandEncoder = computeCommandBuffer.makeComputeCommandEncoder()!
computeCommandEncoder.setComputePipelineState(computePipeline)
computeCommandEncoder.setBuffer(particleBuffer, offset: 0, index: 0)
computeCommandEncoder.dispatchThreadgroups(
.init(width: threadgroupCount, height: 1, depth: 1),
threadsPerThreadgroup: .init(width: threadgroupSize, height: 1, depth: 1)
)
computeCommandEncoder.endEncoding()
computeCommandBuffer.commit()
}
private func checkAllParticlesDead() -> Bool {
let particleData = particleBuffer
.contents()
.bindMemory(to: Particle.self, capacity: particleCount)
for i in 0 ..< particleCount {
if particleData[i].life >= 0 {
return false
}
}
return true
}
private func renderParticles(in view: MTKView) {
let viewCGSize = view.bounds.size
var viewSize = simd_float2(Float(viewCGSize.width), Float(viewCGSize.height))
let renderCommandBuffer = commandQueue.makeCommandBuffer()!
guard let renderPassDescriptor = view.currentRenderPassDescriptor else { return }
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.colorAttachments[0].clearColor = .init(red: 0, green: 0, blue: 0, alpha: 0)
let renderCommandEncoder = renderCommandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)!
renderCommandEncoder.setRenderPipelineState(renderPipeline)
renderCommandEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
withUnsafeBytes(of: &viewSize) { pointer in
renderCommandEncoder.setVertexBytes(
pointer.baseAddress!,
length: MemoryLayout<simd_float2>.size,
index: 1
)
}
renderCommandEncoder.setVertexBuffer(particleBuffer, offset: 0, index: 2)
withUnsafeBytes(of: &targetFrameSize) { pointer in
renderCommandEncoder.setVertexBytes(
pointer.baseAddress!,
length: MemoryLayout<simd_float2>.size,
index: 3
)
}
withUnsafeBytes(of: &stepSize) { pointer in
renderCommandEncoder.setVertexBytes(
pointer.baseAddress!,
length: MemoryLayout<Float>.size,
index: 4
)
}
renderCommandEncoder.setFragmentTexture(texture, index: 0)
setupSampler(renderCommandEncoder: renderCommandEncoder)
renderCommandEncoder.drawPrimitives(
type: .triangleStrip,
vertexStart: 0,
vertexCount: 4,
instanceCount: particleCount
)
renderCommandEncoder.endEncoding()
renderCommandBuffer.present(view.currentDrawable!)
renderCommandBuffer.commit()
}
private func setupSampler(renderCommandEncoder: MTLRenderCommandEncoder) {
let samplerDescriptor = MTLSamplerDescriptor()
samplerDescriptor.minFilter = .linear
samplerDescriptor.magFilter = .linear
samplerDescriptor.mipFilter = .notMipmapped
samplerDescriptor.sAddressMode = .clampToEdge
samplerDescriptor.tAddressMode = .clampToEdge
let samplerState = device.makeSamplerState(descriptor: samplerDescriptor)
renderCommandEncoder.setFragmentSamplerState(samplerState, index: 0)
}
}
}
extension ParticleView.Renderer {
private func setupVertexBuffer(with device: MTLDevice) {
let vertices: [Vertex] = [
.init(position: .init(0, 0, 0, 1), uv: .init(0, 0), opacity: .zero),
.init(position: .init(1, 0, 0, 1), uv: .init(1, 0), opacity: .zero),
.init(position: .init(0, 1, 0, 1), uv: .init(0, 1), opacity: .zero),
.init(position: .init(1, 1, 0, 1), uv: .init(1, 1), opacity: .zero),
]
let vertexBuffer = vertices.withUnsafeBytes { pointer in
device.makeBuffer(
bytes: pointer.baseAddress!,
length: MemoryLayout<Vertex>.stride * vertices.count,
options: .storageModeShared
)
}
self.vertexBuffer = vertexBuffer!
}
private func setupParticleSystem(targetFrame: CGRect, device: MTLDevice) {
var particles = [Particle]()
let targetFrameHeight = Float(targetFrame.height)
let targetFrameWidth = Float(targetFrame.width)
let particleStep = 1
let estimatedParticleCount = 1
* Int(targetFrameWidth / Float(particleStep))
* Int(targetFrameHeight / Float(particleStep))
let pixelMultiplier = 1
particles.reserveCapacity(estimatedParticleCount * pixelMultiplier)
for y in stride(from: 0, to: Int(targetFrameHeight), by: particleStep) {
for x in stride(from: 0, to: Int(targetFrameWidth), by: particleStep) {
let particle = createParticle(x: x, y: y, step: particleStep)
for _ in 0 ..< pixelMultiplier {
particles.append(particle)
}
}
}
particleCount = particles.count
let particleBuffer = particles.withUnsafeBytes { pointer in
device.makeBuffer(
bytes: pointer.baseAddress!,
length: MemoryLayout<Particle>.stride * particles.count,
options: .storageModeShared
)
}
self.particleBuffer = particleBuffer!
stepSize = Float(particleStep)
}
private func createParticle(x: Int, y: Int, step: Int) -> Particle {
let particleDuration: Float = .random(in: 20 ... 60)
let initialX = Float(x) + Float(step) / 2.0
let initialY = Float(y) + Float(step) / 2.0
return .init(
position: .init(initialX, initialY),
velocity: .init(
cos(Float.random(in: 0 ... (2 * Float.pi))) * Float.random(in: 1 ... 4),
sin(Float.random(in: 0 ... (2 * Float.pi))) * Float.random(in: 1 ... 4) - 2.5
),
life: simd_float1(particleDuration),
duration: simd_float1(particleDuration)
)
}
private func setupTexture(from image: CGImage, device: MTLDevice) {
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
guard let context = CGContext(
data: nil,
width: image.width,
height: image.height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: bitmapInfo.rawValue
) else { return }
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
guard let convertedImage = context.makeImage() else { return }
let textureLoader = MTKTextureLoader(device: device)
let textureLoaderOptions: [MTKTextureLoader.Option: Any] = [
.textureStorageMode: MTLStorageMode.private.rawValue,
.SRGB: false,
]
guard let texture = try? textureLoader.newTexture(
cgImage: convertedImage,
options: textureLoaderOptions
) else { return }
self.texture = texture
}
private func finalizeSetup(targetFrame: CGRect, device: MTLDevice) {
let targetFrameWidth = Float(targetFrame.width)
let targetFrameHeight = Float(targetFrame.height)
targetFrameSize = .init(targetFrameWidth, targetFrameHeight)
commandQueue = device.makeCommandQueue()!
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,12 @@
//
// AccentColor.swift
// Intelligents
//
// Created by 秋星桥 on 6/19/25.
//
import UIKit
extension UIColor {
static let accent: UIColor = .init(red: 30 / 255, green: 150 / 255, blue: 235 / 255, alpha: 1.0)
}

View File

@@ -0,0 +1,24 @@
//
// Animation.swift
// Intelligents
//
// Created by 秋星桥 on 6/19/25.
//
import UIKit
func performWithAnimation(
duration: TimeInterval = 0.5,
animations: @escaping () -> Void,
completion: @escaping (Bool) -> Void = { _ in }
) {
UIView.animate(
withDuration: duration,
delay: 0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 0.8,
options: [.beginFromCurrentState, .allowAnimatedContent, .curveEaseInOut],
animations: animations,
completion: completion
)
}

View File

@@ -0,0 +1,51 @@
//
// AssistantMessageCell.swift
// Intelligents
//
// Created by 秋星桥 on 6/27/25.
//
import Litext
import MarkdownView
import SnapKit
import UIKit
private let markdownViewForSizeCalculation: MarkdownTextView = .init()
class AssistantMessageCell: ChatBaseCell {
let markdownView = MarkdownTextView()
override func prepareContentView(inside contentView: UIView) {
super.prepareContentView(inside: contentView)
contentView.addSubview(markdownView)
}
override func configure(with viewModel: any ChatCellViewModel) {
super.configure(with: viewModel)
guard let vm = viewModel as? AssistantMessageCellViewModel else {
assertionFailure()
return
}
markdownView.setMarkdown(vm.preprocessedContent)
}
override func layoutContentView(bounds: CGRect) {
super.layoutContentView(bounds: bounds)
markdownView.frame = bounds
}
override class func heightForContent(
for viewModel: any ChatCellViewModel,
width: CGFloat
) -> CGFloat {
let vm = viewModel as! AssistantMessageCellViewModel
markdownViewForSizeCalculation.theme = .default
markdownViewForSizeCalculation.frame = .init(
x: 0, y: 0, width: width, height: .greatestFiniteMagnitude
)
markdownViewForSizeCalculation.setMarkdownManually(vm.preprocessedContent)
let boundingSize = markdownViewForSizeCalculation.boundingSize(for: width)
return ceil(boundingSize.height)
}
}

View File

@@ -0,0 +1,77 @@
//
// ChatBaseCell.swift
// Intelligents
//
// Created by 秋星桥 on 6/27/25.
//
import ListViewKit
import Litext
import MarkdownView
import SnapKit
import UIKit
class ChatBaseCell: ListRowView {
static var contentInsets: UIEdgeInsets {
.init(top: 0, left: 16, bottom: 16, right: 16)
}
private let contentView = UIView()
init() {
super.init(frame: .zero)
addSubview(contentView)
prepareContentView(inside: contentView)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func prepareContentView(inside contentView: UIView) {
_ = contentView
}
override func layoutSubviews() {
super.layoutSubviews()
let contentInsets = Self.contentInsets
contentView.frame = .init(
x: contentInsets.left,
y: contentInsets.top,
width: bounds.width - contentInsets.left - contentInsets.right,
height: bounds.height - contentInsets.top - contentInsets.bottom
)
layoutContentView(bounds: contentView.bounds)
}
override func addSubview(_ view: UIView) {
assert(view == contentView)
super.addSubview(view)
}
func layoutContentView(bounds: CGRect) {
_ = bounds // override pass
}
class func heightForContent(
for viewModel: any ChatCellViewModel,
width: CGFloat
) -> CGFloat {
_ = viewModel
_ = width
return 0 // override pass
}
static func heightForCell(for viewModel: any ChatCellViewModel, width: CGFloat) -> CGFloat {
let contentWidth = width - contentInsets.left - contentInsets.right
return heightForContent(
for: viewModel,
width: contentWidth
) + contentInsets.top + contentInsets.bottom
}
func configure(with viewModel: any ChatCellViewModel) {
_ = viewModel
}
}

View File

@@ -0,0 +1,70 @@
//
// ErrorCell.swift
// Intelligents
//
// Created by 秋星桥 on 6/27/25.
//
import Litext
import SnapKit
import UIKit
class ErrorCell: ChatBaseCell {
let label = UILabel()
override func prepareContentView(inside contentView: UIView) {
super.prepareContentView(inside: contentView)
contentView.addSubview(label)
}
override func prepareForReuse() {
super.prepareForReuse()
label.attributedText = nil
}
override func layoutContentView(bounds: CGRect) {
super.layoutContentView(bounds: bounds)
let width = bounds.width * 0.8
label.frame = .init(
x: (bounds.width - width) / 2,
y: 0,
width: width,
height: bounds.height
)
}
override func configure(with viewModel: any ChatCellViewModel) {
super.configure(with: viewModel)
guard let vm = viewModel as? ErrorCellViewModel else {
assertionFailure("Invalid view model type")
return
}
label.attributedText = Self.attributeText(for: vm.errorMessage)
}
static func attributeText(for text: String) -> NSAttributedString {
.init(string: text, attributes: [
.font: UIFont.preferredFont(forTextStyle: .footnote),
.foregroundColor: UIColor.affineTextSecondary,
.paragraphStyle: NSMutableParagraphStyle().then {
$0.lineBreakMode = .byWordWrapping
$0.alignment = .center
},
])
}
override class func heightForContent(
for viewModel: any ChatCellViewModel,
width: CGFloat
) -> CGFloat {
let vm = viewModel as! ErrorCellViewModel
let text = Self.attributeText(for: vm.errorMessage)
let boundingRect = text.boundingRect(
with: CGSize(width: width * 0.8, height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: nil
)
let boundingSize = boundingRect.size
return ceil(boundingSize.height)
}
}

View File

@@ -0,0 +1,33 @@
//
// LoadingCell.swift
// Intelligents
//
// Created by 秋星桥 on 6/27/25.
//
import Litext
import SnapKit
import UIKit
class LoadingCell: ChatBaseCell {
override func prepareContentView(inside contentView: UIView) {
super.prepareContentView(inside: contentView)
}
override func prepareForReuse() {
super.prepareForReuse()
}
override func layoutContentView(bounds: CGRect) {
super.layoutContentView(bounds: bounds)
}
override class func heightForContent(
for viewModel: any ChatCellViewModel,
width: CGFloat
) -> CGFloat {
_ = viewModel
_ = width
return 0
}
}

View File

@@ -0,0 +1,79 @@
//
// SystemMessageCell.swift
// Intelligents
//
// Created by 秋星桥 on 6/27/25.
//
import Litext
import SnapKit
import UIKit
private let labelForSizeCalculation = LTXLabel()
class SystemMessageCell: ChatBaseCell {
let contentLabel = LTXLabel().then {
$0.isSelectable = false
}
override func prepareContentView(inside contentView: UIView) {
super.prepareContentView(inside: contentView)
contentView.addSubview(contentLabel)
}
override func prepareForReuse() {
super.prepareForReuse()
contentLabel.attributedText = .init()
}
override func configure(with viewModel: any ChatCellViewModel) {
super.configure(with: viewModel)
guard let vm = viewModel as? SystemMessageCellViewModel else {
assertionFailure("")
return
}
contentLabel.attributedText = Self.prepareAttributeText(vm.content)
}
override func layoutContentView(bounds: CGRect) {
super.layoutContentView(bounds: bounds)
let textMaxWidth = bounds.width * 0.8
contentLabel.preferredMaxLayoutWidth = textMaxWidth
let textSize = contentLabel.intrinsicContentSize
let labelWidth = textSize.width
let labelHeight = textSize.height
contentLabel.frame = .init(
x: (bounds.width - labelWidth) / 2,
y: 0,
width: labelWidth,
height: labelHeight
)
}
class func prepareAttributeText(_ text: String) -> NSAttributedString {
.init(string: text, attributes: [
.font: UIFont.preferredFont(forTextStyle: .footnote),
.foregroundColor: UIColor.affineTextSecondary,
.paragraphStyle: NSMutableParagraphStyle().then {
$0.lineBreakMode = .byWordWrapping
$0.alignment = .center
$0.lineSpacing = 2
$0.paragraphSpacing = 4
},
])
}
override class func heightForContent(
for viewModel: any ChatCellViewModel,
width: CGFloat
) -> CGFloat {
guard let vm = viewModel as? SystemMessageCellViewModel else {
assertionFailure()
return 0
}
labelForSizeCalculation.attributedText = prepareAttributeText(vm.content)
labelForSizeCalculation.preferredMaxLayoutWidth = width * 0.8
let textSize = labelForSizeCalculation.intrinsicContentSize
return textSize.height
}
}

View File

@@ -0,0 +1,102 @@
//
// UserHintCell.swift
// Intelligents
//
// Created by 秋星桥 on 7/4/25.
//
import Litext
import UIKit
private let labelForSizeCalculation = LTXLabel()
private let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
formatter.locale = .current
return formatter
}()
class UserHintCell: ChatBaseCell {
let contentLabel = LTXLabel().then {
$0.isSelectable = true
}
override func prepareContentView(inside contentView: UIView) {
super.prepareContentView(inside: contentView)
contentView.addSubview(contentLabel)
}
override func prepareForReuse() {
super.prepareForReuse()
contentLabel.attributedText = .init()
}
override func configure(with viewModel: any ChatCellViewModel) {
super.configure(with: viewModel)
guard let vm = viewModel as? UserHintCellViewModel else {
assertionFailure("")
return
}
contentLabel.attributedText = Self.prepareAttributeText(Self.prepareText(vm))
}
override func layoutContentView(bounds: CGRect) {
super.layoutContentView(bounds: bounds)
contentLabel.preferredMaxLayoutWidth = bounds.width
let textSize = contentLabel.intrinsicContentSize
contentLabel.frame = CGRect(
x: bounds.width - textSize.width,
y: 0,
width: textSize.width,
height: bounds.height
)
}
class func prepareText(_ vm: UserHintCellViewModel) -> String {
let attachmentsCount = [
vm.docAttachments.count,
vm.imageAttachments.count,
vm.fileAttachments.count,
].reduce(0, +)
let text: [String] = [
formatter.string(from: vm.timestamp),
{
if attachmentsCount > 0 {
String(localized: "\(attachmentsCount) attachments")
} else {
""
}
}(),
].filter { !$0.isEmpty }
return text.joined(separator: " ")
}
class func prepareAttributeText(_ text: String) -> NSAttributedString {
.init(string: text, attributes: [
.font: UIFont.preferredFont(forTextStyle: .footnote),
.foregroundColor: UIColor.affineTextSecondary,
.paragraphStyle: NSMutableParagraphStyle().then {
$0.lineBreakMode = .byWordWrapping
$0.alignment = .natural
$0.lineSpacing = 2
$0.paragraphSpacing = 4
},
])
}
override class func heightForContent(
for viewModel: any ChatCellViewModel,
width: CGFloat
) -> CGFloat {
guard let vm = viewModel as? UserHintCellViewModel else {
assertionFailure()
return 0
}
labelForSizeCalculation.attributedText = prepareAttributeText(prepareText(vm))
labelForSizeCalculation.preferredMaxLayoutWidth = width
return labelForSizeCalculation.intrinsicContentSize.height
}
}

View File

@@ -0,0 +1,91 @@
//
// UserMessageCell.swift
// Intelligents
//
// Created by 秋星桥 on 6/27/25.
//
import Litext
import SnapKit
import UIKit
private let labelForSizeCalculation = LTXLabel()
class UserMessageCell: ChatBaseCell {
let backgroundView = UIView().then {
$0.backgroundColor = .gray.withAlphaComponent(0.05)
$0.layer.cornerRadius = 8
}
let contentLabel = LTXLabel().then {
$0.isSelectable = true
}
override func prepareContentView(inside contentView: UIView) {
super.prepareContentView(inside: contentView)
contentView.addSubview(backgroundView)
backgroundView.addSubview(contentLabel)
}
override func prepareForReuse() {
super.prepareForReuse()
contentLabel.attributedText = .init()
}
override func configure(with viewModel: any ChatCellViewModel) {
super.configure(with: viewModel)
guard let vm = viewModel as? UserMessageCellViewModel else {
assertionFailure("")
return
}
contentLabel.attributedText = Self.prepareAttributeText(vm.content)
}
override func layoutContentView(bounds: CGRect) {
super.layoutContentView(bounds: bounds)
let inset: CGFloat = 8
let textMaxWidth = bounds.width * 0.8 - inset * 2
contentLabel.preferredMaxLayoutWidth = textMaxWidth
let textSize = contentLabel.intrinsicContentSize
let backgroundWidth = textSize.width + inset * 2
backgroundView.frame = .init(
x: bounds.width - backgroundWidth, // right aligned
y: 0,
width: backgroundWidth,
height: bounds.height
)
contentLabel.frame = backgroundView.bounds.insetBy(dx: inset, dy: inset)
}
class func prepareAttributeText(_ text: String) -> NSAttributedString {
.init(string: text, attributes: [
.font: UIFont.preferredFont(forTextStyle: .body),
.foregroundColor: UIColor.affineTextPrimary,
.paragraphStyle: NSMutableParagraphStyle().then {
$0.lineBreakMode = .byWordWrapping
$0.alignment = .natural
$0.lineSpacing = 2
$0.paragraphSpacing = 4
},
])
}
override class func heightForContent(
for viewModel: any ChatCellViewModel,
width: CGFloat
) -> CGFloat {
guard let vm = viewModel as? UserMessageCellViewModel else {
assertionFailure()
return 0
}
labelForSizeCalculation.attributedText = prepareAttributeText(vm.content)
let inset: CGFloat = 8
labelForSizeCalculation.preferredMaxLayoutWidth = width * 0.8 - inset * 2
let textSize = labelForSizeCalculation.intrinsicContentSize
return textSize.height + inset * 2
}
}

View File

@@ -0,0 +1,97 @@
//
// CCVM+Assistant.swift
// Intelligents
//
// Created by 秋星桥 on 6/27/25.
//
import Foundation
import MarkdownParser
import MarkdownView
struct AssistantMessageCellViewModel: ChatCellViewModel {
static func == (lhs: AssistantMessageCellViewModel, rhs: AssistantMessageCellViewModel) -> Bool {
lhs.hashValue == rhs.hashValue
}
func hash(into hasher: inout Hasher) {
hasher.combine(cellType)
hasher.combine(id)
hasher.combine(content)
hasher.combine(timestamp)
hasher.combine(isStreaming)
hasher.combine(model)
hasher.combine(tokens)
hasher.combine(canRetry)
hasher.combine(citations)
hasher.combine(actions)
}
var cellType: ChatCellType = .assistantMessage
var id: UUID
var content: String
var timestamp: Date
var isStreaming: Bool = false
var model: String?
var tokens: Int?
var canRetry: Bool = false
var citations: [CitationViewModel]?
var actions: [MessageActionViewModel]?
var preprocessedContent: MarkdownTextView.PreprocessedContent
@MainActor init(
id: UUID,
content: String,
timestamp: Date,
isStreaming: Bool = false,
model: String? = nil,
tokens: Int? = nil,
canRetry: Bool = false,
citations: [CitationViewModel]? = nil,
actions: [MessageActionViewModel]? = nil
) {
// time expensive rendering should not happen here
assert(!Thread.isMainThread || content.count < 10) // allow placeholder content
self.id = id
self.content = content
self.timestamp = timestamp
self.isStreaming = isStreaming
self.model = model
self.tokens = tokens
self.canRetry = canRetry
self.citations = citations
self.actions = actions
let parser = MarkdownParser()
let parserResult = parser.parse(content)
preprocessedContent = MarkdownTextView.PreprocessedContent(
parserResult: parserResult,
theme: .default,
)
}
}
struct CitationViewModel: Codable, Identifiable, Equatable, Hashable {
var id: String
var title: String
var url: String?
var snippet: String?
}
struct MessageActionViewModel: Codable, Identifiable, Equatable, Hashable {
var id: String
var title: String
var actionType: ActionType
var data: [String: String]?
enum ActionType: String, Codable {
case copy
case regenerate
case like
case dislike
case share
case edit
}
}

View File

@@ -0,0 +1,15 @@
//
// CCVM+Error.swift
// Intelligents
//
// Created by 秋星桥 on 6/26/25.
//
import Foundation
struct ErrorCellViewModel: ChatCellViewModel {
var cellType: ChatCellType = .error
var id: UUID
var errorMessage: String
var timestamp: Date
}

View File

@@ -0,0 +1,16 @@
//
// CCVM+Loading.swift
// Intelligents
//
// Created by 秋星桥 on 6/26/25.
//
import Foundation
struct LoadingCellViewModel: ChatCellViewModel {
var cellType: ChatCellType = .loading
var id: UUID
var timestamp: Date
var message: String?
var progress: Double?
}

View File

@@ -0,0 +1,15 @@
//
// CCVM+System.swift
// Intelligents
//
// Created by 秋星桥 on 6/27/25.
//
import Foundation
struct SystemMessageCellViewModel: ChatCellViewModel {
var cellType: ChatCellType = .systemMessage
var id: UUID
var content: String
var timestamp: Date
}

View File

@@ -0,0 +1,24 @@
//
// CCVM+User.swift
// Intelligents
//
// Created by 秋星桥 on 6/27/25.
//
import Foundation
struct UserMessageCellViewModel: ChatCellViewModel {
var cellType: ChatCellType = .userMessage
var id: UUID
var content: String
var timestamp: Date
}
struct UserHintCellViewModel: ChatCellViewModel {
var cellType: ChatCellType = .userAttachmentsHint
var id: UUID
var timestamp: Date
var imageAttachments: [ImageAttachment]
var fileAttachments: [FileAttachment]
var docAttachments: [DocumentAttachment]
}

View File

@@ -0,0 +1,17 @@
//
// ChatCellType.swift
// Intelligents
//
// Created by 秋星桥 on 6/26/25.
//
import Foundation
public enum ChatCellType: String, CaseIterable {
case userMessage
case userAttachmentsHint
case assistantMessage
case systemMessage
case loading
case error
}

View File

@@ -0,0 +1,14 @@
//
// ChatCellViewModel.swift
// Intelligents
//
// Created by 秋星桥 on 6/26/25.
//
import Foundation
public protocol ChatCellViewModel: Identifiable, Equatable, Hashable {
var id: UUID { get }
var cellType: ChatCellType { get }
var timestamp: Date { get }
}

View File

@@ -0,0 +1,24 @@
//
// ChatItemEntity.swift
// Intelligents
//
// Created by 秋星桥 on 7/2/25.
//
import Foundation
import UIKit
struct ChatItemEntity: Identifiable, Hashable, Equatable {
var id: UUID
var object: any ChatCellViewModel
static func == (lhs: ChatItemEntity, rhs: ChatItemEntity) -> Bool {
lhs.id == rhs.id && lhs.object.cellType == rhs.object.cellType && lhs.object.hashValue == rhs.object.hashValue
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(object.cellType)
hasher.combine(object.hashValue)
}
}

View File

@@ -0,0 +1,88 @@
//
// ChatListView+Adapter.swift
// Intelligents
//
// Created by 秋星桥 on 7/2/25.
//
import ListViewKit
import UIKit
private let dayDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
extension ChatListView: ListViewAdapter {
func fill(viewModels: [any ChatCellViewModel]) {
assert(!Thread.isMainThread)
var items = viewModels.map { ChatItemEntity(id: $0.id, object: $0) }
items = preprocessItems(items)
DispatchQueue.main.asyncAndWait { [self] in
dataSource.applySnapshot(using: items, animatingDifferences: true)
}
}
private func preprocessItems(_ items: [ChatItemEntity]) -> [ChatItemEntity] {
var ans = [ChatItemEntity]()
// prepend a date hint for each day
let calendar = Calendar.current
var currentDayAnchor: Date?
for item in items {
defer { ans.append(item) }
guard item.object.cellType == .userMessage,
let userMessage = item.object as? UserMessageCellViewModel
else { continue }
let messageDate = userMessage.timestamp
let dayAnchor = calendar.startOfDay(for: messageDate)
if currentDayAnchor == nil || dayAnchor > currentDayAnchor! {
currentDayAnchor = dayAnchor
let dateHint = SystemMessageCellViewModel(
id: .init(),
content: dayDateFormatter.string(from: dayAnchor),
timestamp: .init()
)
ans.append(ChatItemEntity(id: dateHint.id, object: dateHint))
}
}
return ans
}
func listView(_: ListViewKit.ListView, rowKindFor item: ItemType, at _: Int) -> RowKind {
let item = item as! ChatItemEntity
return item.object.cellType
}
func listViewMakeRow(for kind: RowKind) -> ListViewKit.ListRowView {
switch kind as! ChatCellType {
case .userMessage: UserMessageCell()
case .userAttachmentsHint: UserHintCell()
case .assistantMessage: AssistantMessageCell()
case .systemMessage: SystemMessageCell()
case .loading: LoadingCell()
case .error: ErrorCell()
}
}
func listView(_ list: ListViewKit.ListView, heightFor item: ItemType, at _: Int) -> CGFloat {
let item = item as! ChatItemEntity
return switch item.object.cellType {
case .userMessage: UserMessageCell.heightForCell(for: item.object, width: list.bounds.width)
case .userAttachmentsHint: UserHintCell.heightForCell(for: item.object, width: list.bounds.width)
case .assistantMessage: AssistantMessageCell.heightForCell(for: item.object, width: list.bounds.width)
case .systemMessage: SystemMessageCell.heightForCell(for: item.object, width: list.bounds.width)
case .loading: LoadingCell.heightForCell(for: item.object, width: list.bounds.width)
case .error: ErrorCell.heightForCell(for: item.object, width: list.bounds.width)
}
}
func listView(_: ListViewKit.ListView, configureRowView rowView: ListViewKit.ListRowView, for item: ItemType, at _: Int) {
let base = rowView as! ChatBaseCell
let item = item as! ChatItemEntity
base.configure(with: item.object)
}
}

View File

@@ -0,0 +1,86 @@
//
// ChatListView.swift
// Intelligents
//
// Created by 秋星桥 on 7/2/25.
//
import Combine
import ListViewKit
import MarkdownView
import UIKit
class ChatListView: UIView {
private(set) lazy var listView = ListView()
private(set) lazy var dataSource = ListViewDiffableDataSource<ChatItemEntity>(listView: listView)
var cancellables: Set<AnyCancellable> = []
init() {
super.init(frame: .zero)
listView.topInset = 8
listView.bottomInset = 64
listView.adapter = self
addSubview(listView)
listView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
let dataSourceQueue = DispatchQueue(label: "com.affine.intelligents.chat.list.dataSource", qos: .userInteractive)
Publishers.CombineLatest(
IntelligentContext.shared.$currentSession
.map { $0?.id ?? "default_session" }
.removeDuplicates(),
ChatManager.shared.$viewModels
)
.receive(on: dataSourceQueue)
.map { sessionIdentifier, viewModels in
.init(viewModels[sessionIdentifier]?.map(\.value) ?? [])
}
.sink { [weak self] viewModels in
guard let self else { return }
fill(viewModels: viewModels)
}
.store(in: &cancellables)
Publishers.CombineLatest(
IntelligentContext.shared.$currentSession
.map { $0?.id ?? "default_session" }
.removeDuplicates(),
ChatManager.shared.scrollToBottomPublisher
)
.receive(on: dataSourceQueue)
.filter { $0 == $1 }
.map { _ in () }
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self else { return }
scrollToBottom()
}
.store(in: &cancellables)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
deinit {
cancellables.forEach { $0.cancel() }
cancellables.removeAll()
}
func scrollToBottom() {
if listView.contentSize.height <= listView.bounds.height {
// If the content size is smaller than the bounds, no need to scroll.
return
}
let contentOffset = CGPoint(
x: 0,
y: listView.contentSize.height - listView.bounds.height
)
listView.scroll(to: contentOffset)
}
}

View File

@@ -0,0 +1,28 @@
//
// DocumentItem.swift
// Intelligents
//
// Created by 秋星桥 on 6/25/25.
//
import Foundation
struct DocumentItem: Hashable {
let id: String
let title: String
let updatedAt: Date?
init(id: String, title: String, updatedAt: Date? = nil) {
self.id = id
self.title = title
self.updatedAt = updatedAt
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: DocumentItem, rhs: DocumentItem) -> Bool {
lhs.id == rhs.id
}
}

View File

@@ -0,0 +1,293 @@
//
// DocumentPickerView.swift
// Intelligents
//
// Created by 秋星桥 on 6/24/25.
//
import AffineGraphQL
import SnapKit
import UIKit
class DocumentPickerView: UIView {
// MARK: - Properties
weak var delegate: DocumentPickerViewDelegate?
private var documents: [DocumentItem] = []
private var selectedDocumentIds: Set<String> = []
private let updateQueue = DispatchQueue(label: "com.affine.documentpicker.update", qos: .userInitiated)
private var lastSearchKeyword: String = ""
// MARK: - DiffableDataSource
private enum Section {
case main
}
private var dataSource: UITableViewDiffableDataSource<Section, DocumentItem>!
// MARK: - UI Components
lazy var containerView = UIView().then {
$0.backgroundColor = .systemBackground
$0.layer.cornerRadius = 10
$0.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
$0.layer.shadowColor = UIColor.black.cgColor
$0.layer.shadowOffset = CGSize(width: 0, height: -3)
$0.layer.shadowRadius = 5
$0.layer.shadowOpacity = 0.07
}
lazy var searchContainerView = UIView().then {
$0.backgroundColor = .systemBackground
$0.layer.cornerRadius = 10
$0.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
$0.layer.borderWidth = 0.5
$0.layer.borderColor = UIColor(hex: 0xE6E6E6)?.cgColor
}
lazy var searchIconImageView = UIImageView().then {
$0.image = UIImage(systemName: "magnifyingglass")
$0.tintColor = .affineIconPrimary
$0.contentMode = .scaleAspectFit
}
lazy var searchTextField = UITextField().then {
$0.placeholder = "Search documents..."
$0.font = .systemFont(ofSize: 17, weight: .regular)
$0.textColor = .affineTextPrimary
$0.backgroundColor = .clear
$0.addTarget(self, action: #selector(searchTextChanged), for: .editingChanged)
}
lazy var activityIndicator = UIActivityIndicatorView(style: .medium).then {
$0.hidesWhenStopped = true
$0.color = .affineIconPrimary
}
private lazy var tableView = UITableView().then {
$0.backgroundColor = .white
$0.separatorStyle = .none
$0.delegate = self
$0.register(DocumentTableViewCell.self, forCellReuseIdentifier: "DocumentCell")
}
// MARK: - Initialization
init() {
super.init(frame: .zero)
isUserInteractionEnabled = true
clipsToBounds = false // for shadow
backgroundColor = .systemBackground
addSubview(containerView)
containerView.addSubview(searchContainerView)
containerView.addSubview(tableView)
searchContainerView.addSubview(searchIconImageView)
searchContainerView.addSubview(searchTextField)
searchContainerView.addSubview(activityIndicator)
setupConstraints()
setupDataSource()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupConstraints() {
containerView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
searchContainerView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(searchIconImageView.snp.bottom).offset(DocumentTableViewCell.cellInset)
make.top.equalTo(searchIconImageView.snp.top).offset(-DocumentTableViewCell.cellInset)
}
searchIconImageView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(DocumentTableViewCell.cellInset)
make.centerY.equalToSuperview()
make.width.height.equalTo(DocumentTableViewCell.iconSize)
}
searchTextField.snp.makeConstraints { make in
make.leading.equalTo(searchIconImageView.snp.trailing).offset(DocumentTableViewCell.spacing)
make.trailing.equalTo(activityIndicator.snp.leading).offset(-DocumentTableViewCell.cellInset)
make.centerY.equalToSuperview()
}
activityIndicator.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-DocumentTableViewCell.cellInset)
make.centerY.equalToSuperview()
make.width.height.equalTo(DocumentTableViewCell.iconSize)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(searchContainerView.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
}
private func setupDataSource() {
dataSource = UITableViewDiffableDataSource<Section, DocumentItem>(tableView: tableView) { [weak self] tableView, indexPath, document in
let cell = tableView.dequeueReusableCell(withIdentifier: "DocumentCell", for: indexPath) as! DocumentTableViewCell
let isSelected = self?.selectedDocumentIds.contains(document.id) ?? false
cell.configure(with: document, isSelected: isSelected)
return cell
}
}
// MARK: - Public Methods
func updateDocuments(_ documents: [DocumentItem]) {
updateQueue.async { [weak self] in
guard let self else { return }
DispatchQueue.main.async {
self.documents = documents
var snapshot = NSDiffableDataSourceSnapshot<Section, DocumentItem>()
snapshot.appendSections([.main])
snapshot.appendItems(documents)
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
func updateDocumentsFromRecentDocs() {
guard searchTextField.text?.isEmpty ?? true else {
return
}
guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String,
!workspaceId.isEmpty
else {
activityIndicator.stopAnimating()
return
}
activityIndicator.startAnimating()
QLService.shared.fetchRecentlyUpdatedDocs(workspaceId: workspaceId, first: 20) { [weak self] docs in
guard let self else { return }
DispatchQueue.main.async {
self.activityIndicator.stopAnimating()
self.updateDocuments(docs.compactMap { DocumentItem(
id: $0.id,
title: $0.title ?? "Unknown Document",
updatedAt: $0.updatedAt?.decoded
) })
}
}
}
func setSelectedDocuments(_ documentAttachments: [DocumentAttachment]) {
selectedDocumentIds = Set(documentAttachments.map(\.documentID))
DispatchQueue.main.async { [weak self] in
self?.tableView.reloadData()
}
}
// MARK: - Actions
@objc private func searchTextChanged() {
guard let text = searchTextField.text else { return }
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
lastSearchKeyword = trimmedText
NSObject.cancelPreviousPerformRequests(
withTarget: self,
selector: #selector(performCloudIndexerDocumentSearch(_:)),
object: nil
)
if trimmedText.isEmpty {
DispatchQueue.main.async { [weak self] in
self?.activityIndicator.stopAnimating()
}
updateDocumentsFromRecentDocs()
} else {
DispatchQueue.main.async { [weak self] in
self?.activityIndicator.startAnimating()
}
perform(#selector(performCloudIndexerDocumentSearch(_:)), with: trimmedText, afterDelay: 0.25)
}
}
@objc func performCloudIndexerDocumentSearch(_ keyword: String) {
let trimmedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedKeyword.isEmpty else { return }
guard trimmedKeyword == lastSearchKeyword else { return }
guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String,
!workspaceId.isEmpty
else {
activityIndicator.stopAnimating()
return
}
QLService.shared.searchDocuments(
workspaceId: workspaceId,
keyword: trimmedKeyword,
limit: 20
) { [weak self] searchResults in
guard let self, lastSearchKeyword == trimmedKeyword else {
return
}
DispatchQueue.main.async {
self.activityIndicator.stopAnimating()
self.updateDocuments(searchResults.map {
.init(id: $0.docId, title: $0.title, updatedAt: $0.updatedAt.decoded)
})
}
}
}
}
// MARK: - UITableViewDelegate
extension DocumentPickerView: UITableViewDelegate {
func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat {
DocumentTableViewCell.cellInset * 2 + DocumentTableViewCell.iconSize
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let document = dataSource.itemIdentifier(for: indexPath) else { return }
delegate?.documentPickerView(self, didSelectDocument: document)
}
}
// MARK: - Preview
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct DocumentPickerView_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview {
let view = DocumentPickerView()
let mockDocuments = [
DocumentItem(id: "1", title: "Project Proposal.docx"),
DocumentItem(id: "2", title: "Budget Analysis.xlsx"),
DocumentItem(id: "3", title: "Meeting Notes.pdf"),
DocumentItem(id: "4", title: "Design Guidelines.sketch"),
DocumentItem(id: "5", title: "Code Review.md"),
DocumentItem(id: "6", title: "User Research.pptx"),
DocumentItem(id: "7", title: "Technical Specification.docx"),
DocumentItem(id: "8", title: "Database Schema.sql"),
]
view.updateDocuments(mockDocuments)
return view
}
.previewLayout(.fixed(width: 400, height: 600))
.previewDisplayName("Document Picker")
}
}
#endif

View File

@@ -0,0 +1,12 @@
//
// DocumentPickerViewDelegate.swift
// Intelligents
//
// Created by 秋星桥 on 6/25/25.
//
import Foundation
protocol DocumentPickerViewDelegate: AnyObject {
func documentPickerView(_ view: DocumentPickerView, didSelectDocument document: DocumentItem)
}

View File

@@ -0,0 +1,78 @@
//
// DocumentTableViewCell.swift
// Intelligents
//
// Created by 秋星桥 on 6/24/25.
//
import SnapKit
import UIKit
class DocumentTableViewCell: UITableViewCell {
static let cellInset: CGFloat = 16
static let iconSize: CGFloat = 20
static let spacing: CGFloat = 16
private lazy var iconImageView = UIImageView().then {
$0.contentMode = .scaleAspectFit
$0.tintColor = UIColor(hex: 0x141414)
}
private lazy var titleLabel = UILabel().then {
$0.font = .systemFont(ofSize: 17, weight: .regular)
$0.textColor = UIColor(hex: 0x141414)
$0.textAlignment = .left
}
private lazy var checkmarkImageView = UIImageView().then {
$0.image = UIImage(systemName: "checkmark.circle.fill")
$0.tintColor = .systemBlue
$0.contentMode = .scaleAspectFit
$0.isHidden = true
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
backgroundColor = .white
selectionStyle = .none
contentView.clipsToBounds = true
contentView.addSubview(iconImageView)
contentView.addSubview(titleLabel)
contentView.addSubview(checkmarkImageView)
iconImageView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(Self.cellInset)
make.centerY.equalToSuperview()
make.width.height.equalTo(Self.iconSize)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(iconImageView.snp.trailing).offset(Self.spacing)
make.trailing.equalTo(checkmarkImageView.snp.leading).offset(-Self.spacing)
make.centerY.equalToSuperview()
}
checkmarkImageView.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-Self.cellInset)
make.centerY.equalToSuperview()
make.width.height.equalTo(Self.iconSize)
}
}
func configure(with document: DocumentItem, isSelected: Bool = false) {
iconImageView.image = UIImage(systemName: "doc.text")
titleLabel.text = document.title
checkmarkImageView.isHidden = !isSelected
}
}

View File

@@ -0,0 +1,175 @@
import SnapKit
import UIKit
final class FileAttachmentHeaderView: UIView {
// MARK: - Properties
weak var delegate: FileAttachmentHeaderViewDelegate?
// MARK: - UI Components
private lazy var iconImageView = UIImageView().then {
$0.contentMode = .scaleAspectFit
$0.image = UIImage(systemName: "doc.fill")
$0.tintColor = UIColor.systemBlue
$0.isUserInteractionEnabled = true
let tap = UITapGestureRecognizer(target: self, action: #selector(iconTapped))
$0.addGestureRecognizer(tap)
}
private lazy var textStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 2
$0.alignment = .leading
$0.distribution = .equalSpacing
}
private lazy var primaryLabel = UILabel().then {
$0.text = "" // 3 attachment, 1 AFFiNE docs
$0.font = UIFont.preferredFont(forTextStyle: .footnote).bold
$0.textColor = .label
$0.numberOfLines = 1
}
private lazy var secondaryLabel = UILabel().then {
$0.text = "Referenced for AI"
$0.font = UIFont.preferredFont(forTextStyle: .footnote)
$0.textColor = .affineTextSecondary
$0.numberOfLines = 1
}
private lazy var arrowButton = UIImageView().then {
$0.image = UIImage(systemName: "chevron.down")
$0.contentMode = .scaleAspectFit
$0.tintColor = .affineIconPrimary
}
// MARK: - Initialization
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupUI() {
backgroundColor = UIColor.white
layer.cornerRadius = 12
layer.borderWidth = 0.5
layer.borderColor = UIColor.systemGray5.cgColor
layer.shadowColor = UIColor.black.cgColor
layer.shadowOffset = CGSize(width: 0, height: 2)
layer.shadowRadius = 6
layer.shadowOpacity = 0.04
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(viewTapped))
addGestureRecognizer(tapGesture)
addSubviews()
setupConstraints()
setupStackView()
}
private func addSubviews() {
addSubview(iconImageView)
addSubview(textStackView)
addSubview(arrowButton)
}
private func setupConstraints() {
iconImageView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.size.equalTo(24)
make.centerY.equalToSuperview()
make.top.greaterThanOrEqualToSuperview().inset(12)
make.bottom.lessThanOrEqualToSuperview().inset(12)
}
textStackView.snp.makeConstraints { make in
make.leading.equalTo(iconImageView.snp.trailing).offset(12)
make.trailing.lessThanOrEqualTo(arrowButton.snp.leading).offset(-12)
make.centerY.equalToSuperview()
make.top.greaterThanOrEqualToSuperview().inset(12)
make.bottom.lessThanOrEqualToSuperview().inset(12)
}
arrowButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-12)
make.centerY.equalToSuperview()
make.width.equalTo(18)
make.height.equalTo(18)
}
}
private func setupStackView() {
textStackView.addArrangedSubview(primaryLabel)
textStackView.addArrangedSubview(secondaryLabel)
}
// MARK: - Actions
@objc private func viewTapped() {
delegate?.headerViewDidTapManagement(self)
}
@objc private func iconTapped() {
delegate?.headerViewDidPickMore(self)
}
// MARK: - Public Methods
func updateContent(attachmentCount: Int, docsCount: Int) {
var components: [String] = []
if attachmentCount > 0 {
components.append("\(attachmentCount) attachment\(attachmentCount > 1 ? "s" : "")")
}
if docsCount > 0 {
components.append("\(docsCount) AFFiNE doc\(docsCount > 1 ? "s" : "")")
}
primaryLabel.text = components.joined(separator: ", ")
}
func setIconImage(_ image: UIImage?) {
iconImageView.image = image
}
// MARK: - Trait Collection
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
layer.borderColor = UIColor.systemGray5.cgColor
}
}
}
// MARK: - Preview
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct FileAttachmentHeaderView_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview {
let view = FileAttachmentHeaderView()
view.updateContent(attachmentCount: 5, docsCount: 2)
view.snp.makeConstraints { make in
make.width.equalTo(400)
}
return view
}
.previewLayout(.fixed(width: 400, height: 100))
.previewDisplayName("File Attachment Header")
}
}
#endif

View File

@@ -0,0 +1,13 @@
//
// FileAttachmentHeaderViewDelegate.swift
// Intelligents
//
// Created by 秋星桥 on 6/25/25.
//
import Foundation
protocol FileAttachmentHeaderViewDelegate: AnyObject {
func headerViewDidPickMore(_ headerView: FileAttachmentHeaderView)
func headerViewDidTapManagement(_ headerView: FileAttachmentHeaderView)
}

View File

@@ -0,0 +1,151 @@
//
// ImageAttachmentBar.swift
// Intelligents
//
// Created by 秋星桥 on 6/18/25.
//
import SnapKit
import UIKit
class ImageAttachmentBar: UICollectionView {
weak var imageBarDelegate: ImageAttachmentBarDelegate?
enum Section {
case main
}
private var attachments: [ImageAttachment] = []
private let cellSpacing: CGFloat = 8
private let constantHeight: CGFloat = 80
var myDataSource: UICollectionViewDiffableDataSource<
Section,
ImageAttachment
>!
init(frame: CGRect = .zero) {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.itemSize = CGSize(width: 80, height: 80)
layout.minimumInteritemSpacing = 8
layout.minimumLineSpacing = 8
layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
super.init(frame: frame, collectionViewLayout: layout)
showsHorizontalScrollIndicator = false
showsVerticalScrollIndicator = false
backgroundColor = .clear
setupDataSource()
delegate = self
register(ImageCollectionViewCell.self, forCellWithReuseIdentifier: "ImageCell")
snp.makeConstraints { make in
make.height.equalTo(constantHeight)
}
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
func updateImageBarContent(_ attachments: [ImageAttachment]) {
self.attachments = attachments
applySnapshot()
}
func clear() {
attachments.removeAll()
applySnapshot()
}
private func setupDataSource() {
myDataSource = .init(collectionView: self) { [weak self] collectionView, indexPath, attachment in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCell", for: indexPath) as! ImageCollectionViewCell
if let image = UIImage(data: attachment.imageData) {
cell.configure(with: image, attachmentId: attachment.id) { [weak self] attachmentId in
self?.imageBarDelegate?.inputBoxImageBar(self!, didRemoveImageWithId: attachmentId)
}
}
return cell
}
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<
Section,
ImageAttachment
>()
snapshot.appendSections([.main])
snapshot.appendItems(attachments)
myDataSource.apply(snapshot, animatingDifferences: true)
}
}
// MARK: - UICollectionViewDelegate
extension ImageAttachmentBar: UICollectionViewDelegate {}
// MARK: - Preview
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct InputBoxImageBar_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview {
let imageBar = ImageAttachmentBar()
let mockAttachments = [
createMockImageAttachment(color: .red),
createMockImageAttachment(color: .blue),
createMockImageAttachment(color: .green),
createMockImageAttachment(color: .orange),
createMockImageAttachment(color: .purple),
]
imageBar.updateImageBarContent(mockAttachments)
return imageBar
}
.previewLayout(.fixed(width: 400, height: 100))
.previewDisplayName("Image Bar with Multiple Images")
UIViewPreview {
let imageBar = ImageAttachmentBar()
let singleAttachment = [createMockImageAttachment(color: .systemBlue)]
imageBar.updateImageBarContent(singleAttachment)
return imageBar
}
.previewLayout(.fixed(width: 400, height: 100))
.previewDisplayName("Image Bar with Single Image")
UIViewPreview {
let imageBar = ImageAttachmentBar()
imageBar.updateImageBarContent([])
return imageBar
}
.previewLayout(.fixed(width: 400, height: 100))
.previewDisplayName("Empty Image Bar")
}
private static func createMockImageAttachment(color: UIColor) -> ImageAttachment {
let size = CGSize(width: 100, height: 100)
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { context in
color.setFill()
context.fill(CGRect(origin: .zero, size: size))
UIColor.white.withAlphaComponent(0.3).setFill()
let circleRect = CGRect(x: 25, y: 25, width: 50, height: 50)
context.cgContext.fillEllipse(in: circleRect)
}
return ImageAttachment(image: image)
}
}
#endif

View File

@@ -0,0 +1,12 @@
//
// ImageAttachmentBarDelegate.swift
// Intelligents
//
// Created by 秋星桥 on 6/25/25.
//
import Foundation
protocol ImageAttachmentBarDelegate: AnyObject {
func inputBoxImageBar(_ imageBar: ImageAttachmentBar, didRemoveImageWithId id: UUID)
}

View File

@@ -0,0 +1,61 @@
//
// ImageCollectionViewCell.swift
// Intelligents
//
// Created by 秋星桥 on 6/25/25.
//
import UIKit
// MARK: - ImageCollectionViewCell
class ImageCollectionViewCell: UICollectionViewCell {
private var attachmentId: UUID?
private var onRemove: ((UUID) -> Void)?
private lazy var imageView = UIImageView().then {
$0.contentMode = .scaleAspectFill
$0.clipsToBounds = true
$0.layer.cornerRadius = 12
$0.layer.cornerCurve = .continuous
$0.backgroundColor = .systemGray6
}
private lazy var removeButton = DeleteButtonView().then {
$0.onTapped = { [weak self] in
if let attachmentId = self?.attachmentId {
self?.onRemove?(attachmentId)
}
}
}
override init(frame: CGRect) {
super.init(frame: frame)
setupViews()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupViews() {
contentView.addSubview(imageView)
contentView.addSubview(removeButton)
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
removeButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(6)
make.size.equalTo(18)
}
}
func configure(with image: UIImage, attachmentId: UUID, onRemove: @escaping (UUID) -> Void) {
imageView.image = image
self.attachmentId = attachmentId
self.onRemove = onRemove
}
}

View File

@@ -0,0 +1,104 @@
//
// InputBox+Delegates.swift
// Intelligents
//
// Created by 秋星桥 on 6/18/25.
//
import SwifterSwift
import UIKit
extension InputBox: ImageAttachmentBarDelegate {
func inputBoxImageBar(_: ImageAttachmentBar, didRemoveImageWithId id: ImageAttachment.ID) {
performWithAnimation { [self] in
viewModel.removeImageAttachment(withId: id)
layoutIfNeeded()
}
}
}
extension InputBox: FileAttachmentHeaderViewDelegate {
func headerViewDidPickMore(_: FileAttachmentHeaderView) {
delegate?.inputBoxDidSelectAttachFiles(self)
}
func headerViewDidTapManagement(_: FileAttachmentHeaderView) {
let controller = AttachmentManagementController(delegate: self)
controller.set(fileAttachments: viewModel.fileAttachments)
controller.set(documentAttachments: viewModel.documentAttachments)
parentViewController?.present(controller, animated: true)
}
}
extension InputBox: AttachmentManagementControllerDelegate {
func deleteFileAttachment(controller: AttachmentManagementController, _ attachment: FileAttachment) {
viewModel.removeFileAttachment(withId: attachment.id)
controller.set(fileAttachments: viewModel.fileAttachments)
layoutIfNeeded()
}
func deleteDocumentAttachment(controller: AttachmentManagementController, _ attachment: DocumentAttachment) {
viewModel.removeDocumentAttachment(withId: attachment.id)
controller.set(documentAttachments: viewModel.documentAttachments)
layoutIfNeeded()
}
}
extension InputBox: InputBoxFunctionBarDelegate {
func functionBarDidTapTakePhoto(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSelectTakePhoto(self)
}
func functionBarDidTapPhotoLibrary(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSelectPhotoLibrary(self)
}
func functionBarDidTapAttachFiles(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSelectAttachFiles(self)
}
func functionBarDidTapEmbedDocs(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSelectEmbedDocs(self)
}
func functionBarDidTapTool(_: InputBoxFunctionBar) {
viewModel.toggleTool()
}
func functionBarDidTapNetwork(_: InputBoxFunctionBar) {
viewModel.toggleNetwork()
}
func functionBarDidTapDeepThinking(_: InputBoxFunctionBar) {
viewModel.toggleDeepThinking()
}
func functionBarDidTapSend(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSend(self)
}
}
extension InputBox: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
viewModel.updateText(textView.text ?? "")
delegate?.inputBoxTextDidChange(textView.text ?? "")
updatePlaceholderVisibility()
updateTextViewHeight()
}
func textView(_: UITextView, shouldChangeTextIn _: NSRange, replacementText text: String) -> Bool {
if text == "\n" {
delegate?.inputBoxDidSend(self)
return false
}
return true
}
func textView(_ textView: UITextView, editMenuForTextIn _: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
let insertNewLineAction = UIAction(title: "Insert New Line") { _ in
textView.insertText("\n")
}
return UIMenu(children: suggestedActions + [insertNewLineAction])
}
}

View File

@@ -0,0 +1,304 @@
import Combine
import SnapKit
import UIKit
class InputBox: UIView {
weak var delegate: InputBoxDelegate?
let viewModel = InputBoxViewModel()
var cancellables = Set<AnyCancellable>()
lazy var containerView = UIView().then {
$0.backgroundColor = UIColor.affineLayerBackgroundPrimary
$0.layer.cornerRadius = 16
$0.layer.cornerCurve = .continuous
$0.layer.borderWidth = 1
$0.layer.shadowColor = UIColor.black.cgColor
$0.layer.shadowOffset = CGSize(width: 0, height: 0)
$0.layer.shadowRadius = 12
$0.layer.shadowOpacity = 0.075
$0.clipsToBounds = false
}
lazy var textView = UITextView().then {
$0.backgroundColor = .clear
$0.font = .systemFont(ofSize: 16)
$0.textColor = UIColor.affineTextPrimary
$0.isScrollEnabled = false
$0.textContainer.lineFragmentPadding = 0
$0.textContainerInset = .zero
$0.delegate = self
$0.text = ""
$0.returnKeyType = .send
}
lazy var placeholderLabel = UILabel().then {
$0.text = "Write your message..."
$0.font = .systemFont(ofSize: 16)
$0.textColor = UIColor.affineTextPlaceholder
$0.isHidden = true
}
lazy var functionBar = InputBoxFunctionBar().then {
$0.delegate = self
}
lazy var imageBar = ImageAttachmentBar().then {
$0.imageBarDelegate = self
}
lazy var mainStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 16
$0.alignment = .fill
$0.clipsToBounds = true
$0.addArrangedSubview(imageBar)
$0.addArrangedSubview(textView)
$0.addArrangedSubview(functionBar)
}
lazy var fileAttachmentHeader = FileAttachmentHeaderView().then {
$0.delegate = self
}
var textViewHeightConstraint: Constraint?
let minTextViewHeight: CGFloat = 22
let maxTextViewHeight: CGFloat = 100
var text: String {
get { textView.text ?? "" }
set {
textView.text = newValue
updatePlaceholderVisibility()
updateTextViewHeight()
}
}
override init(frame: CGRect = .zero) {
super.init(frame: frame)
backgroundColor = .clear
addSubview(fileAttachmentHeader)
addSubview(containerView)
containerView.addSubview(mainStackView)
containerView.addSubview(placeholderLabel)
imageBar.isHidden = true
containerView.snp.makeConstraints { make in
make.left.bottom.right.equalToSuperview().inset(16)
make.top.greaterThanOrEqualToSuperview().offset(16)
}
mainStackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
imageBar.snp.makeConstraints { make in
make.left.right.equalToSuperview()
}
textView.snp.makeConstraints { make in
textViewHeightConstraint = make.height.equalTo(minTextViewHeight).constraint
}
placeholderLabel.snp.makeConstraints { make in
make.left.right.equalTo(textView)
make.top.equalTo(textView)
}
// for initial status
fileAttachmentHeader.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(32).priority(.high)
}
setupBindings()
updatePlaceholderVisibility()
updateColors()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
updateColors()
}
}
func setupBindings() {
viewModel.$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.$isSearchEnabled
.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.$imageAttachments
.dropFirst() // for view setup to remove animation
.map { !$0.isEmpty /* -> hasAttachments */ }
.removeDuplicates()
.sink { [weak self] hasAttachments in
performWithAnimation {
self?.updateImageBarVisibility(hasAttachments)
self?.layoutIfNeeded()
}
}
.store(in: &cancellables)
viewModel.$imageAttachments
.removeDuplicates()
.sink { [weak self] attachments in
self?.updateImageBarContent(attachments)
}
.store(in: &cancellables)
Publishers.CombineLatest(viewModel.$fileAttachments, viewModel.$documentAttachments)
.dropFirst() // for view setup to remove animation
.removeDuplicates { $0.0 == $1.0 && $0.1 == $1.1 }
.sink { [weak self] fileAttachments, documentAttachments in
self?.updateFileAttachmentHeader(fileCount: fileAttachments.count, documentCount: documentAttachments.count)
}
.store(in: &cancellables)
}
func updateTextViewHeight() {
let size = textView.sizeThatFits(CGSize(width: textView.frame.width, height: CGFloat.greatestFiniteMagnitude))
let newHeight = max(minTextViewHeight, min(maxTextViewHeight, size.height))
let height = textView.frame.height
guard height != newHeight else { return }
textViewHeightConstraint?.update(offset: newHeight)
textView.isScrollEnabled = size.height > maxTextViewHeight
if height == 0 || superview == nil || window == nil || isHidden { return }
performWithAnimation {
self.layoutIfNeeded()
self.superview?.layoutIfNeeded()
}
}
func updatePlaceholderVisibility() {
placeholderLabel.isHidden = !textView.text.isEmpty
}
func updateImageBarVisibility(_ hasAttachments: Bool) {
imageBar.isHidden = !hasAttachments
}
func updateImageBarContent(_ attachments: [ImageAttachment]) {
imageBar.updateImageBarContent(attachments)
}
func updateFileAttachmentHeader(fileCount: Int, documentCount: Int) {
let hasAttachments = fileCount > 0 || documentCount > 0
fileAttachmentHeader.snp.remakeConstraints { make in
if hasAttachments {
make.leading.trailing.equalToSuperview().inset(32)
make.bottom.equalTo(self.containerView.snp.top).offset(8)
make.top.equalToSuperview().offset(8)
} else {
make.edges.equalToSuperview().inset(32).priority(.high)
}
}
performWithAnimation {
self.fileAttachmentHeader.isHidden = !hasAttachments
if hasAttachments {
self.fileAttachmentHeader.updateContent(attachmentCount: fileCount, docsCount: documentCount)
self.fileAttachmentHeader.setIconImage(UIImage(systemName: "doc"))
}
self.layoutIfNeeded()
self.superview?.layoutIfNeeded()
}
}
func updateColors() {
containerView.layer.borderColor = UIColor.affineLayerBorder.cgColor
}
// MARK: - Public Methods
func addImageAttachment(_ image: UIImage) {
let attachment = ImageAttachment(image: image)
performWithAnimation { [self] in
viewModel.addImageAttachment(attachment)
layoutIfNeeded()
}
}
func addFileAttachment(_ url: URL) throws {
// check less then 15mb
let fileSizeLimit: Int64 = 15 * 1024 * 1024 // 15 MB
let fileAttributes = try FileManager.default.attributesOfItem(atPath: url.path)
guard let fileSize = fileAttributes[.size] as? Int64, fileSize <= fileSizeLimit else {
throw NSError(
domain: "FileAttachmentErrorDomain",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "File size exceeds 15 MB limit."]
)
}
let fileData = try Data(contentsOf: url)
let attachment = FileAttachment(
data: fileData,
url: url,
name: url.lastPathComponent,
size: Int64(fileData.count)
)
performWithAnimation { [self] in
viewModel.addFileAttachment(attachment)
layoutIfNeeded()
}
}
func addDocumentAttachment(_ documentAttachment: DocumentAttachment) {
performWithAnimation { [self] in
viewModel.addDocumentAttachment(documentAttachment)
layoutIfNeeded()
}
}
var inputBoxData: InputBoxData {
viewModel.prepareSendData()
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,17 @@
//
// InputBoxDelegate 2.swift
// Intelligents
//
// Created by 秋星桥 on 6/25/25.
//
import Foundation
protocol InputBoxDelegate: AnyObject {
func inputBoxDidSelectTakePhoto(_ inputBox: InputBox)
func inputBoxDidSelectPhotoLibrary(_ inputBox: InputBox)
func inputBoxDidSelectAttachFiles(_ inputBox: InputBox)
func inputBoxDidSelectEmbedDocs(_ inputBox: InputBox)
func inputBoxDidSend(_ inputBox: InputBox)
func inputBoxTextDidChange(_ text: String)
}

View File

@@ -0,0 +1,198 @@
import SnapKit
import UIKit
private let unselectedColor: UIColor = .affineIconPrimary
private let selectedColor: UIColor = .affineIconActivated
private let configurableOptions: [ConfigurableOptions] = [
// .networking,
// .reasoning,
]
enum ConfigurableOptions {
case tool
case networking
case reasoning
}
class InputBoxFunctionBar: UIView {
weak var delegate: InputBoxFunctionBarDelegate?
lazy var attachmentButton = UIButton(type: .system).then {
$0.setImage(UIImage.affinePlus, for: .normal)
$0.tintColor = unselectedColor
$0.layer.borderWidth = 1
$0.layer.cornerRadius = 4
$0.imageView?.contentMode = .scaleAspectFit
$0.showsMenuAsPrimaryAction = true
$0.menu = createAttachmentMenu()
}
lazy var toolButton = UIButton(type: .system).then {
$0.setImage(UIImage.affineTools, for: .normal)
$0.tintColor = unselectedColor
$0.imageView?.contentMode = .scaleAspectFit
$0.addTarget(self, action: #selector(toolButtonTapped), for: .touchUpInside)
$0.isHidden = !configurableOptions.contains(.tool)
}
lazy var networkButton = UIButton(type: .system).then {
$0.setImage(UIImage.affineWeb, for: .normal)
$0.tintColor = unselectedColor
$0.imageView?.contentMode = .scaleAspectFit
$0.addTarget(self, action: #selector(networkButtonTapped), for: .touchUpInside)
$0.isHidden = !configurableOptions.contains(.networking)
}
lazy var deepThinkingButton = UIButton(type: .system).then {
$0.setImage(UIImage.affineThink, for: .normal)
$0.tintColor = unselectedColor
$0.imageView?.contentMode = .scaleAspectFit
$0.addTarget(self, action: #selector(deepThinkingButtonTapped), for: .touchUpInside)
$0.isHidden = !configurableOptions.contains(.reasoning)
}
lazy var sendButton = UIButton(type: .system).then {
$0.setImage(UIImage.affineArrowUpBig, for: .normal)
$0.tintColor = UIColor.affineTextPureWhite
$0.backgroundColor = UIColor.affineButtonPrimary
$0.imageView?.contentMode = .scaleAspectFit
$0.addTarget(self, action: #selector(sendButtonTapped), for: .touchUpInside)
$0.clipsToBounds = true
}
lazy var leftButtonsStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 16
$0.alignment = .center
$0.addArrangedSubview(attachmentButton)
}
lazy var rightButtonsStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 16
$0.alignment = .center
$0.addArrangedSubview(toolButton)
$0.addArrangedSubview(networkButton)
$0.addArrangedSubview(deepThinkingButton)
$0.addArrangedSubview(sendButton)
}
lazy var stackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 12
$0.alignment = .center
$0.addArrangedSubview(leftButtonsStackView)
$0.addArrangedSubview(UIView()) // spacer
$0.addArrangedSubview(rightButtonsStackView)
}
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(stackView)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
for button in [attachmentButton, toolButton, networkButton, deepThinkingButton, sendButton] {
button.snp.makeConstraints { make in
make.width.height.equalTo(32)
}
}
sendButton.layer.cornerRadius = 16
updateColors()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
updateColors()
}
}
// MARK: - Public Methods
func updateToolState(isEnabled: Bool) {
toolButton.tintColor = isEnabled ? selectedColor : unselectedColor
}
func updateNetworkState(isEnabled: Bool) {
networkButton.tintColor = isEnabled ? selectedColor : unselectedColor
}
func updateDeepThinkingState(isEnabled: Bool) {
deepThinkingButton.tintColor = isEnabled ? selectedColor : unselectedColor
}
func updateSendState(canSend: Bool) {
sendButton.isEnabled = canSend
sendButton.alpha = canSend ? 1.0 : 0.5
}
// MARK: - Private Methods
private func updateColors() {
attachmentButton.layer.borderColor = UIColor.affineLayerBorder.cgColor
}
private func createAttachmentMenu() -> UIMenu {
let takePhotoAction = UIAction(
title: "Take Photo or Video",
image: UIImage.affineCamera
) { [weak self] _ in
guard let self else { return }
delegate?.functionBarDidTapTakePhoto(self)
}
let photoLibraryAction = UIAction(
title: "Photo Library",
image: UIImage.affineImage
) { [weak self] _ in
guard let self else { return }
delegate?.functionBarDidTapPhotoLibrary(self)
}
let attachFilesAction = UIAction(
title: "Attach Files (.pdf, .txt, .csv)",
image: UIImage.affineUpload
) { [weak self] _ in
guard let self else { return }
delegate?.functionBarDidTapAttachFiles(self)
}
let embedDocsAction = UIAction(
title: "Add AFFiNE Docs",
image: UIImage.affinePage
) { [weak self] _ in
guard let self else { return }
delegate?.functionBarDidTapEmbedDocs(self)
}
return UIMenu(
options: [.displayInline],
children: [takePhotoAction, photoLibraryAction, attachFilesAction, embedDocsAction].reversed()
)
}
// MARK: - Actions
@objc private func toolButtonTapped() {
delegate?.functionBarDidTapTool(self)
}
@objc private func networkButtonTapped() {
delegate?.functionBarDidTapNetwork(self)
}
@objc private func deepThinkingButtonTapped() {
delegate?.functionBarDidTapDeepThinking(self)
}
@objc private func sendButtonTapped() {
delegate?.functionBarDidTapSend(self)
}
}

View File

@@ -0,0 +1,19 @@
//
// InputBoxFunctionBarDelegate.swift
// Intelligents
//
// Created by 秋星桥 on 6/25/25.
//
import Foundation
protocol InputBoxFunctionBarDelegate: AnyObject {
func functionBarDidTapTakePhoto(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapPhotoLibrary(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapAttachFiles(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapEmbedDocs(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapTool(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapNetwork(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapDeepThinking(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapSend(_ functionBar: InputBoxFunctionBar)
}

View File

@@ -0,0 +1,24 @@
//
// DocumentAttachment.swift
// Intelligents
//
// Created by 秋星桥 on 6/24/25.
//
import Foundation
public struct DocumentAttachment: Identifiable, Equatable, Hashable, Codable {
public var id: UUID = .init()
public var title: String = ""
public var workspaceID: String = ""
public var documentID: String = ""
public var updatedAt: Date?
public init(id: UUID = .init(), title: String = "", workspaceID: String = "", documentID: String = "", updatedAt: Date? = nil) {
self.id = id
self.title = title
self.workspaceID = workspaceID
self.documentID = documentID
self.updatedAt = updatedAt
}
}

View File

@@ -0,0 +1,28 @@
//
// FileAttachment.swift
// Intelligents
//
// Created by 秋星桥 on 6/24/25.
//
import Foundation
public struct FileAttachment: Identifiable, Equatable, Hashable, Codable {
public var id: UUID = .init()
public var data: Data?
public var url: URL
public var name: String
public var size: Int64
public init(
data: Data? = nil,
url: URL,
name: String,
size: Int64 = 0
) {
self.data = data
self.url = url
self.name = name
self.size = size
}
}

View File

@@ -0,0 +1,17 @@
//
// ImageAttachment.swift
// Intelligents
//
// Created by 秋星桥 on 6/24/25.
//
import UIKit
public struct ImageAttachment: Identifiable, Equatable, Hashable, Codable {
public var id: UUID = .init()
public var imageData: Data
public init(image: UIImage) {
imageData = image.jpegData(compressionQuality: 0.5) ?? Data()
}
}

View File

@@ -0,0 +1,165 @@
//
// InputBoxViewModel.swift
// Intelligents
//
// Created by AI Assistant on 6/17/25.
//
import Combine
import Foundation
// MARK: - Data Models
public struct InputBoxData {
public var text: String
public var imageAttachments: [ImageAttachment]
public var fileAttachments: [FileAttachment] = []
public var documentAttachments: [DocumentAttachment] = []
public var isToolEnabled: Bool
public var isSearchEnabled: Bool
public var isDeepThinkingEnabled: Bool
public init(text: String, imageAttachments: [ImageAttachment], fileAttachments: [FileAttachment], documentAttachments: [DocumentAttachment], isToolEnabled: Bool, isSearchEnabled: Bool, isDeepThinkingEnabled: Bool) {
self.text = text
self.imageAttachments = imageAttachments
self.fileAttachments = fileAttachments
self.documentAttachments = documentAttachments
self.isToolEnabled = isToolEnabled
self.isSearchEnabled = isSearchEnabled
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 isSearchEnabled: Bool = false
@Published public var isDeepThinkingEnabled: Bool = false
@Published public var imageAttachments: [ImageAttachment] = []
@Published public var fileAttachments: [FileAttachment] = []
@Published public var documentAttachments: [DocumentAttachment] = []
@Published public var canSend: Bool = false
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
public init() {
setupBindings()
}
// MARK: - Private Methods
private func setupBindings() {
Publishers.CombineLatest4($inputText, $imageAttachments, $fileAttachments, $documentAttachments)
.map { text, images, files, docs in
let hasText = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasAnyAttachments = !images.isEmpty || !files.isEmpty || !docs.isEmpty
return hasText || hasAnyAttachments
}
.assign(to: \.canSend, on: self)
.store(in: &cancellables)
}
public func clearAllAttachments() {
imageAttachments.removeAll()
fileAttachments.removeAll()
documentAttachments.removeAll()
}
}
// MARK: - Text Management
public extension InputBoxViewModel {
func updateText(_ text: String) {
inputText = text
}
}
// MARK: - Feature Toggles
public extension InputBoxViewModel {
func toggleTool() {
isToolEnabled.toggle()
}
func toggleNetwork() {
isSearchEnabled.toggle()
}
func toggleDeepThinking() {
isDeepThinkingEnabled.toggle()
}
}
// MARK: - Attachment Management
public extension InputBoxViewModel {
func addImageAttachment(_ attachment: ImageAttachment) {
imageAttachments.append(attachment)
}
func removeImageAttachment(withId id: UUID) {
imageAttachments.removeAll { $0.id == id }
}
func clearImageAttachments() {
imageAttachments.removeAll()
}
func addFileAttachment(_ attachment: FileAttachment) {
fileAttachments.append(attachment)
}
func removeFileAttachment(withId id: UUID) {
fileAttachments.removeAll { $0.id == id }
}
func clearFileAttachments() {
fileAttachments.removeAll()
}
func addDocumentAttachment(_ attachment: DocumentAttachment) {
documentAttachments.append(attachment)
}
func removeDocumentAttachment(withId id: UUID) {
documentAttachments.removeAll { $0.id == id }
}
func clearDocumentAttachments() {
documentAttachments.removeAll()
}
}
// MARK: - Send Management
public extension InputBoxViewModel {
func prepareSendData() -> InputBoxData {
InputBoxData(
text: inputText.trimmingCharacters(in: .whitespacesAndNewlines),
imageAttachments: imageAttachments,
fileAttachments: fileAttachments,
documentAttachments: documentAttachments,
isToolEnabled: isToolEnabled,
isSearchEnabled: isSearchEnabled,
isDeepThinkingEnabled: isDeepThinkingEnabled
)
}
func resetInput() {
inputText = ""
imageAttachments.removeAll()
fileAttachments.removeAll()
documentAttachments.removeAll()
isToolEnabled = false
isSearchEnabled = false
isDeepThinkingEnabled = false
}
}

View File

@@ -0,0 +1,85 @@
//
// IntelligentsButton+Control.swift
// Intelligents
//
// Created by 秋星桥 on 2024/11/18.
//
import SnapKit
import UIKit
public extension UIViewController {
@discardableResult
func installIntelligentsButton() -> IntelligentsButton {
print("[*] \(#function)")
if let button = findIntelligentsButton() { return button }
let button = IntelligentsButton()
view.addSubview(button)
view.bringSubviewToFront(button)
button.snp.makeConstraints { make in
make.trailing.equalTo(view.safeAreaLayoutGuide).offset(-20)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-20 - 44)
make.width.height.equalTo(50)
}
button.transform = .init(scaleX: 0, y: 0)
if view.frame != .zero {
view.layoutIfNeeded()
}
return button
}
private func findIntelligentsButton() -> IntelligentsButton? {
for subview in view.subviews { // for for depth 1
if let button = subview as? IntelligentsButton {
return button
}
}
return nil
}
func presentIntelligentsButton() {
guard let button = findIntelligentsButton() else { return }
print("[*] \(button) is calling \(#function)")
button.alpha = 0
button.isHidden = false
button.setNeedsLayout()
button.stopProgress()
view.layoutIfNeeded()
performWithAnimation {
button.alpha = 1
button.transform = .identity
button.setNeedsLayout()
self.view.layoutIfNeeded()
} completion: { _ in
button.isUserInteractionEnabled = true
}
}
func dismissIntelligentsButton(animated: Bool = true) {
guard let button = findIntelligentsButton() else { return }
print("[*] \(button) is calling \(#function)")
button.isUserInteractionEnabled = false
if !animated {
button.stopProgress()
button.isHidden = true
return
}
button.stopProgress()
button.setNeedsLayout()
view.layoutIfNeeded()
performWithAnimation {
button.alpha = 0
button.transform = .init(scaleX: 0, y: 0)
button.setNeedsLayout()
self.view.layoutIfNeeded()
} completion: { _ in
button.isHidden = true
}
}
}

View File

@@ -0,0 +1,12 @@
//
// IntelligentsButton+Delegate.swift
// Intelligents
//
// Created by 秋星桥 on 2024/11/21.
//
import Foundation
public protocol IntelligentsButtonDelegate: AnyObject {
func onIntelligentsButtonTapped(_ button: IntelligentsButton)
}

View File

@@ -0,0 +1,112 @@
//
// IntelligentsButton.swift
//
//
// Created by 秋星桥 on 2024/11/18.
//
import SnapKit
import SwifterSwift
import UIKit
// floating button to open intelligent panel
public class IntelligentsButton: UIView {
lazy var image = UIImageView().then {
$0.image = .init(named: "spark", in: .module, with: .none)
$0.contentMode = .scaleAspectFit
}
lazy var background = UIView().then {
$0.backgroundColor = .init(
light: .systemBackground,
dark: .darkGray.withAlphaComponent(0.25)
)
}
lazy var activityIndicator = UIActivityIndicatorView()
public weak var delegate: (any IntelligentsButtonDelegate)? {
didSet { assert(Thread.isMainThread) }
}
public init() {
super.init(frame: .zero)
setupViews()
setupConstraints()
setupGesture()
setupAppearance()
stopProgress()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
deinit {
delegate = nil
}
override public func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.width / 2
}
private var allowedTap = true
@objc func tapped() {
guard allowedTap else { return }
delegate?.onIntelligentsButtonTapped(self)
}
public func beginProgress() {
allowedTap = false
activityIndicator.startAnimating()
activityIndicator.isHidden = false
image.isHidden = true
bringSubviewToFront(activityIndicator)
}
public func stopProgress() {
allowedTap = true
activityIndicator.stopAnimating()
activityIndicator.isHidden = true
image.isHidden = false
}
}
// MARK: - Setup Methods
private extension IntelligentsButton {
func setupViews() {
addSubview(background)
addSubview(image)
addSubview(activityIndicator)
}
func setupConstraints() {
background.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
image.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(12)
}
activityIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
}
func setupGesture() {
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
addGestureRecognizer(tap)
isUserInteractionEnabled = true
}
func setupAppearance() {
clipsToBounds = true
layer.borderWidth = 2
layer.borderColor = UIColor.gray.withAlphaComponent(0.1).cgColor
}
}

View File

@@ -0,0 +1,130 @@
import SnapKit
import UIKit
class MainHeaderView: UIView {
weak var delegate: MainHeaderViewDelegate?
private lazy var closeButton = UIButton(type: .system).then {
$0.imageView?.contentMode = .scaleAspectFit
$0.setImage(UIImage.affineClose, for: .normal)
$0.tintColor = UIColor.affineIconPrimary
$0.backgroundColor = UIColor.affineLayerBackgroundSecondary
$0.layer.cornerRadius = 8
$0.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside)
$0.setContentHuggingPriority(.required, for: .horizontal)
}
private lazy var titleLabel = UILabel().then {
$0.text = "AFFiNE AI"
$0.font = .systemFont(ofSize: 16, weight: .medium)
$0.textColor = UIColor.affineTextPrimary
$0.textAlignment = .center
}
private lazy var modelMenu = UIDeferredMenuElement.uncached { completion in
completion([])
}
private lazy var dropdownButton = UIButton(type: .system).then {
$0.imageView?.contentMode = .scaleAspectFit
$0.setImage(UIImage.affineArrowDown, for: .normal)
$0.tintColor = UIColor.affineIconPrimary
$0.addTarget(self, action: #selector(dropdownButtonTapped), for: .touchUpInside)
$0.showsMenuAsPrimaryAction = true
$0.menu = UIMenu(options: [.displayInline], children: [
modelMenu,
])
$0.isHidden = true
}
private lazy var centerStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 8
$0.alignment = .center
$0.addArrangedSubview(titleLabel)
$0.addArrangedSubview(dropdownButton)
}
private lazy var menuButton = UIButton(type: .system).then {
$0.imageView?.contentMode = .scaleAspectFit
$0.setImage(UIImage.affineMore, for: .normal)
$0.tintColor = UIColor.affineIconPrimary
$0.backgroundColor = UIColor.affineLayerBackgroundSecondary
$0.layer.cornerRadius = 8
$0.addTarget(self, action: #selector(menuButtonTapped), for: .touchUpInside)
$0.setContentHuggingPriority(.required, for: .horizontal)
$0.showsMenuAsPrimaryAction = true
$0.menu = .init(options: [.displayInline], children: [
UIAction(title: "Clear History", image: .affineBroom, handler: { _ in
ChatManager.shared.clearCurrentSession()
ChatManager.shared.clearAll()
}),
])
}
private lazy var leftSpacerView = UIView()
private lazy var rightSpacerView = UIView()
private lazy var mainStackView = UIStackView().then {
$0.axis = .horizontal
$0.alignment = .center
$0.distribution = .fill
$0.spacing = 16
$0.addArrangedSubview(closeButton)
$0.addArrangedSubview(leftSpacerView)
$0.addArrangedSubview(centerStackView)
$0.addArrangedSubview(rightSpacerView)
$0.addArrangedSubview(menuButton)
}
init() {
super.init(frame: .zero)
backgroundColor = UIColor.affineLayerBackgroundPrimary
addSubview(mainStackView)
mainStackView.snp.makeConstraints { make in
make.left.right.equalToSuperview().inset(16)
make.centerY.equalToSuperview()
}
closeButton.snp.makeConstraints { make in
make.size.equalTo(titleLabel.font.pointSize + 16)
}
menuButton.snp.makeConstraints { make in
make.size.equalTo(titleLabel.font.pointSize + 16)
}
dropdownButton.snp.makeConstraints { make in
make.size.equalTo(titleLabel.font.pointSize + 16)
}
// ensure center stack to be center
leftSpacerView.snp.makeConstraints { make in
make.width.equalTo(rightSpacerView)
}
snp.makeConstraints { make in
make.height.equalTo(52)
}
}
required init?(coder: NSCoder) {
super.init(coder: coder)
fatalError()
}
@objc private func closeButtonTapped() {
delegate?.mainHeaderViewDidTapClose()
}
@objc private func dropdownButtonTapped() {
delegate?.mainHeaderViewDidTapDropdown()
}
@objc private func menuButtonTapped() {
delegate?.mainHeaderViewDidTapMenu()
}
}

View File

@@ -0,0 +1,14 @@
//
// MainHeaderViewDelegate.swift
// Intelligents
//
// Created by 秋星桥 on 6/25/25.
//
import Foundation
protocol MainHeaderViewDelegate: AnyObject {
func mainHeaderViewDidTapClose()
func mainHeaderViewDidTapDropdown()
func mainHeaderViewDidTapMenu()
}

View File

@@ -0,0 +1,29 @@
//
// CircleImageView.swift
// Intelligents
//
// Created by 秋星桥 on 2024/12/13.
//
import UIKit
class CircleImageView: UIImageView {
init() {
super.init(frame: .zero)
contentMode = .scaleAspectFill
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
override func layoutSubviews() {
super.layoutSubviews()
clipsToBounds = true
layer.cornerRadius = (bounds.width + bounds.height) / 2 / 2
layer.masksToBounds = true
}
}

View File

@@ -0,0 +1,74 @@
//
// DarkActionButton.swift
// Intelligents
//
// Created by 秋星桥 on 2024/11/21.
//
import UIKit
class DarkActionButton: UIView {
var iconSystemName: String {
set { iconView.image = UIImage(systemName: newValue) }
get { fatalError() }
}
var title: String {
set { titleLabel.text = newValue }
get { titleLabel.text ?? "" }
}
let titleLabel = UILabel()
let iconView = UIImageView()
var action: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white.withAlphaComponent(0.25)
layer.cornerRadius = 12
let layoutGuide = UILayoutGuide()
addLayoutGuide(layoutGuide)
titleLabel.textAlignment = .center
titleLabel.font = .systemFont(ofSize: UIFont.labelFontSize, weight: .semibold)
titleLabel.textColor = .white
addSubview(titleLabel)
iconView.contentMode = .scaleAspectFit
iconView.tintColor = .white
addSubview(iconView)
[
layoutGuide.centerXAnchor.constraint(equalTo: centerXAnchor),
layoutGuide.centerYAnchor.constraint(equalTo: centerYAnchor),
iconView.topAnchor.constraint(greaterThanOrEqualTo: layoutGuide.topAnchor),
iconView.leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor),
iconView.bottomAnchor.constraint(lessThanOrEqualTo: layoutGuide.bottomAnchor),
iconView.centerYAnchor.constraint(equalTo: layoutGuide.centerYAnchor),
titleLabel.topAnchor.constraint(greaterThanOrEqualTo: layoutGuide.topAnchor),
titleLabel.trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor),
titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: layoutGuide.bottomAnchor),
titleLabel.centerYAnchor.constraint(equalTo: layoutGuide.centerYAnchor),
titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
].forEach { $0.isActive = true }
isUserInteractionEnabled = true
addGestureRecognizer(UITapGestureRecognizer(
target: self,
action: #selector(onTapped)
))
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
@objc func onTapped() {
action?()
}
}

View File

@@ -0,0 +1,56 @@
import UIKit
class DeleteButtonView: UIView {
let imageView = UIImageView(image: .init(systemName: "xmark")).then {
$0.tintColor = .white
$0.contentMode = .scaleAspectFit
}
let blur = UIVisualEffectView(
effect: UIBlurEffect(style: .systemUltraThinMaterialDark)
).then {
$0.clipsToBounds = true
}
var onTapped: () -> Void = {}
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = true
addSubview(blur)
addSubview(imageView)
blur.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(2)
}
let gesture = UITapGestureRecognizer(target: self, action: #selector(tapped))
addGestureRecognizer(gesture)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
if bounds.width < 50 || bounds.height < 50 {
return bounds.insetBy(dx: -20, dy: -20).contains(point)
}
return super.point(inside: point, with: event)
}
override func layoutSubviews() {
super.layoutSubviews()
blur.layer.cornerRadius = min(bounds.width, bounds.height) / 2
}
@objc func tapped() {
onTapped()
}
}

View File

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "close.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -0,0 +1,5 @@
<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.922363" y="0.5" width="17" height="17" rx="8.5" fill="#2A2A2A"/>
<rect x="0.922363" y="0.5" width="17" height="17" rx="8.5" stroke="#E6E6E6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.1572 5.73483C6.30364 5.58839 6.54108 5.58839 6.68753 5.73483L9.42236 8.46967L12.1572 5.73484C12.3036 5.58839 12.5411 5.58839 12.6875 5.73484C12.834 5.88128 12.834 6.11872 12.6875 6.26517L9.95269 9L12.6875 11.7348C12.834 11.8813 12.834 12.1187 12.6875 12.2652C12.5411 12.4116 12.3036 12.4116 12.1572 12.2652L9.42236 9.53033L6.68753 12.2652C6.54108 12.4116 6.30364 12.4116 6.1572 12.2652C6.01075 12.1187 6.01075 11.8813 6.1572 11.7348L8.89203 9L6.1572 6.26516C6.01075 6.11872 6.01075 5.88128 6.1572 5.73483Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 831 B

View File

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "spark.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"template-rendering-intent": "template"
}
}

View File

@@ -0,0 +1,8 @@
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.1738 5.49104C12.1329 5.13024 11.8278 4.85751 11.4647 4.85714C11.1016 4.85677 10.796 5.12887 10.7544 5.48959C10.4839 7.83515 9.78261 9.48448 8.65126 10.6158C7.51992 11.7472 5.87058 12.4485 3.52502 12.719C3.16431 12.7606 2.89221 13.0662 2.89258 13.4293C2.89295 13.7924 3.16568 14.0975 3.52647 14.1383C5.83327 14.3996 7.51766 15.1006 8.67586 16.2379C9.82971 17.3709 10.5456 19.0198 10.7525 21.3489C10.7853 21.7178 11.0945 22.0004 11.4648 22C11.8351 21.9996 12.1437 21.7162 12.1756 21.3473C12.3739 19.0565 13.0892 17.3729 14.2487 16.2133C15.4083 15.0537 17.092 14.3385 19.3827 14.1402C19.7517 14.1083 20.035 13.7997 20.0354 13.4294C20.0359 13.0591 19.7532 12.7499 19.3843 12.7171C17.0553 12.5102 15.4063 11.7943 14.2733 10.6404C13.136 9.48222 12.435 7.79783 12.1738 5.49104Z"
fill="#1E96EB" />
<path
d="M19.8353 2.24651C19.8194 2.1062 19.7007 2.00014 19.5595 2C19.4183 1.99986 19.2995 2.10567 19.2833 2.24595C19.1781 3.15811 18.9054 3.79952 18.4654 4.23949C18.0254 4.67946 17.384 4.95218 16.4719 5.05739C16.3316 5.07356 16.2258 5.19241 16.2259 5.33362C16.2261 5.47482 16.3321 5.59345 16.4724 5.60935C17.3695 5.71096 18.0246 5.98357 18.475 6.42584C18.9237 6.86644 19.2021 7.50771 19.2826 8.41347C19.2953 8.55691 19.4156 8.66683 19.5596 8.66667C19.7036 8.6665 19.8236 8.55632 19.836 8.41284C19.9131 7.52199 20.1913 6.86723 20.6422 6.41629C21.0931 5.96534 21.7479 5.68719 22.6388 5.61008C22.7822 5.59766 22.8924 5.47765 22.8926 5.33365C22.8927 5.18964 22.7828 5.06939 22.6394 5.05664C21.7336 4.97619 21.0924 4.69777 20.6517 4.24905C20.2095 3.79864 19.9369 3.1436 19.8353 2.24651Z"
fill="#1E96EB" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,87 @@
#include <metal_stdlib>
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<float> 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;
}