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
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(©)
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
===============================================
|
||||
|
||||
*/
|
||||
@@ -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: "")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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([])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// The Swift Programming Language
|
||||
// https://docs.swift.org/swift-book
|
||||
|
||||
import AffineGraphQL
|
||||
import Apollo
|
||||
import Foundation
|
||||
|
||||
public enum Intelligents {}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Page.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AiFileClip attachment icon.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AiFileClip attachment icon.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AiFileClip attachment icon.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AiFileClip attachment icon.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()!
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//
|
||||
// IntelligentsButton+Delegate.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol IntelligentsButtonDelegate: AnyObject {
|
||||
func onIntelligentsButtonTapped(_ button: IntelligentsButton)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// MainHeaderViewDelegate.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/25/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol MainHeaderViewDelegate: AnyObject {
|
||||
func mainHeaderViewDidTapClose()
|
||||
func mainHeaderViewDidTapDropdown()
|
||||
func mainHeaderViewDidTapMenu()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "close.svg",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "spark.svg",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
},
|
||||
"properties": {
|
||||
"template-rendering-intent": "template"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user