feat: ai now working again (#13196)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for displaying title and summary fields in workspace
pages.
* Introduced a menu in the chat header with a "Clear History" option to
remove chat history.

* **Improvements**
* Enhanced chat message handling with asynchronous context preparation
and improved markdown processing.
* Simplified chat input and assistant message rendering for better
performance and maintainability.
* Updated dependency versions for improved stability and compatibility.

* **Bug Fixes**
* Ensured chat features are available in all build configurations, not
just debug mode.

* **Chores**
* Removed unused dependencies and internal code, and disabled certain
function bar options.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Lakr
2025-07-21 10:49:20 +08:00
committed by GitHub
parent 013a6ceb7e
commit b7c026bbe8
14 changed files with 229 additions and 127 deletions

View File

@@ -27,15 +27,6 @@
"version" : "1.1.6"
}
},
{
"identity" : "litext",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/Litext",
"state" : {
"revision" : "c37f3ab5826659854311e20d6c3942d4905b00b6",
"version" : "0.5.0"
}
},
{
"identity" : "lrucache",
"kind" : "remoteSourceControl",
@@ -50,8 +41,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/MarkdownView",
"state" : {
"revision" : "29a9da19d6dc21af4e629c423961b0f453ffe192",
"version" : "2.3.8"
"revision" : "446dba45be81c67d0717d19277367dcbe5b2fb12",
"version" : "3.1.9"
}
},
{
@@ -68,8 +59,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/Splash",
"state" : {
"revision" : "4d997712fe07f75695aacdf287aeb3b1f2c6ab88",
"version" : "0.17.0"
"revision" : "de9cde249fdb7a173a6e6b950ab18b11f6c2a557",
"version" : "0.18.0"
}
},
{

View File

@@ -13,23 +13,5 @@ extension AFFiNEViewController: IntelligentsButtonDelegate {
// if it shows up then we are ready to go
let controller = IntelligentsController()
self.present(controller, animated: true)
// IntelligentContext.shared.webView = webView
// button.beginProgress()
// IntelligentContext.shared.preparePresent { result in
// DispatchQueue.main.async {
// button.stopProgress()
// switch result {
// case .success:
// case let .failure(failure):
// let alert = UIAlertController(
// title: "Error",
// message: failure.localizedDescription,
// preferredStyle: .alert
// )
// alert.addAction(UIAlertAction(title: "OK", style: .default))
// self.present(alert, animated: true)
// }
// }
// }
}
}

View File

@@ -64,12 +64,7 @@ class AFFiNEViewController: CAPBridgeViewController {
switch result {
case .failure: break
case .success:
#if DEBUG
// only show the button in debug mode before we get done
self.presentIntelligentsButton()
#else
break
#endif
}
}
}

View File

@@ -7,7 +7,7 @@ public class GetCopilotRecentSessionsQuery: GraphQLQuery {
public static let operationName: String = "getCopilotRecentSessions"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename chats( pagination: { first: $limit } options: { fork: false, sessionOrder: desc, withMessages: true } ) { __typename ...PaginatedCopilotChats } } } }"#,
#"query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename chats( pagination: { first: $limit } options: { fork: false, sessionOrder: desc, withMessages: false } ) { __typename ...PaginatedCopilotChats } } } }"#,
fragments: [CopilotChatHistory.self, CopilotChatMessage.self, PaginatedCopilotChats.self]
))
@@ -69,7 +69,7 @@ public class GetCopilotRecentSessionsQuery: GraphQLQuery {
"options": [
"fork": false,
"sessionOrder": "desc",
"withMessages": true
"withMessages": false
]
]),
] }

View File

@@ -7,7 +7,7 @@ public class GetWorkspacePageByIdQuery: GraphQLQuery {
public static let operationName: String = "getWorkspacePageById"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getWorkspacePageById($workspaceId: String!, $pageId: String!) { workspace(id: $workspaceId) { __typename doc(docId: $pageId) { __typename id mode defaultRole public } } }"#
#"query getWorkspacePageById($workspaceId: String!, $pageId: String!) { workspace(id: $workspaceId) { __typename doc(docId: $pageId) { __typename id mode defaultRole public title summary } } }"#
))
public var workspaceId: String
@@ -68,12 +68,16 @@ public class GetWorkspacePageByIdQuery: GraphQLQuery {
.field("mode", GraphQLEnum<AffineGraphQL.PublicDocMode>.self),
.field("defaultRole", GraphQLEnum<AffineGraphQL.DocRole>.self),
.field("public", Bool.self),
.field("title", String?.self),
.field("summary", String?.self),
] }
public var id: String { __data["id"] }
public var mode: GraphQLEnum<AffineGraphQL.PublicDocMode> { __data["mode"] }
public var defaultRole: GraphQLEnum<AffineGraphQL.DocRole> { __data["defaultRole"] }
public var `public`: Bool { __data["public"] }
public var title: String? { __data["title"] }
public var summary: String? { __data["summary"] }
}
}
}

View File

@@ -21,7 +21,7 @@ let package = Package(
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0"),
.package(url: "https://github.com/Recouse/EventSource", from: "0.1.4"),
.package(url: "https://github.com/Lakr233/ListViewKit", from: "1.1.6"),
.package(url: "https://github.com/Lakr233/MarkdownView", exact: "2.3.8"),
.package(url: "https://github.com/Lakr233/MarkdownView", from: "3.1.9"),
],
targets: [
.target(name: "Intelligents", dependencies: [

View File

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

View File

@@ -24,53 +24,173 @@ private extension InputBoxData {
}
}
extension ChatManager {
public func startUserRequest(
content: String,
inputBoxData: InputBoxData,
sessionId: String
) {
public extension ChatManager {
func startUserRequest(editorData: InputBoxData, sessionId: String) {
append(sessionId: sessionId, UserMessageCellViewModel(
id: .init(),
content: inputBoxData.text,
content: editorData.text,
timestamp: .init()
))
append(sessionId: sessionId, UserHintCellViewModel(
id: .init(),
timestamp: .init(),
imageAttachments: inputBoxData.imageAttachments,
fileAttachments: inputBoxData.fileAttachments,
docAttachments: inputBoxData.documentAttachments
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.prepareContext(
workspaceId: workspaceId,
sessionId: sessionId,
editorData: editorData,
viewModelId: viewModelId
)
}
}
}
private extension ChatManager {
func prepareContext(
workspaceId: String,
sessionId: String,
editorData: InputBoxData,
viewModelId: UUID
) {
assert(!Thread.isMainThread)
let createContext = CreateCopilotContextMutation(
workspaceId: workspaceId,
sessionId: sessionId
)
QLService.shared.client.perform(mutation: createContext) { result in
DispatchQueue.main.async {
switch result {
case let .success(graphQLResult):
guard let contextId = graphQLResult.data?.createCopilotContext else {
self.report(sessionId, ChatError.invalidResponse)
return
}
print("[+] copilot context created: \(contextId)")
DispatchQueue.global().async {
let docAttachGroup = DispatchGroup()
for docAttach in editorData.documentAttachments {
let addDoc = AddContextDocMutation(
options: .init(
contextId: contextId,
docId: docAttach.documentID
)
)
docAttachGroup.enter()
QLService.shared.client.perform(mutation: addDoc) { result in
switch result {
case .success:
print("[+] doc \(docAttach.documentID) added to context")
case let .failure(error):
print("[-] addContextDoc failed: \(error)")
}
docAttachGroup.leave()
}
}
docAttachGroup.notify(queue: .global()) {
var contextSnippet = ""
if !editorData.documentAttachments.isEmpty {
let sem = DispatchSemaphore(value: 0)
let matchQuery = MatchContextQuery(
contextId: .some(contextId),
workspaceId: .some(workspaceId),
content: editorData.text,
limit: .none,
scopedThreshold: .none,
threshold: .none
)
QLService.shared.client.fetch(query: matchQuery) { result in
switch result {
case let .success(queryResult):
let matches = queryResult.data?.currentUser?.copilot.contexts ?? []
let matchDocs = matches.compactMap(\.matchWorkspaceDocs).flatMap(\.self)
for context in matchDocs {
contextSnippet += "<file docId=\"\(context.docId)\" chunk=\"\(context.chunk)\">\(context.content)</file>\n"
}
case let .failure(error):
print("[-] matchContext failed: \(error)")
// self.report(sessionId, error)
}
sem.signal()
}
sem.wait()
}
print("[+] context snippet prepared: \(contextSnippet)")
self.startCopilotResponse(
editorData: editorData,
contextSnippet: contextSnippet,
sessionId: sessionId,
viewModelId: viewModelId
)
}
}
case let .failure(error):
self.report(sessionId, error)
return
}
}
}
}
func startCopilotResponse(
editorData: InputBoxData,
contextSnippet: String,
sessionId: String,
viewModelId: UUID
) {
assert(!Thread.isMainThread)
let messageParameters: [String: AnyHashable] = [
// packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx
"docs": inputBoxData.documentAttachments.map(\.documentID), // affine doc
"docs": editorData.documentAttachments.map(\.documentID), // affine doc
"files": [String](), // attachment in context, keep nil for now
"searchMode": inputBoxData.isSearchEnabled ? "MUST" : "AUTO",
"searchMode": editorData.isSearchEnabled ? "MUST" : "AUTO",
]
let uploadableAttachments: [GraphQLFile] = [
inputBoxData.fileAttachments.map { file -> GraphQLFile in
.init(
fieldName: file.name,
originalName: file.name,
data: file.data ?? .init()
)
let attachmentFieldName = "options.blobs"
var uploadableAttachments: [GraphQLFile] = [
editorData.fileAttachments.map { file -> GraphQLFile in
.init(fieldName: attachmentFieldName, originalName: file.name, data: file.data ?? .init())
},
inputBoxData.imageAttachments.map { image -> GraphQLFile in
.init(
fieldName: image.hashValue.description,
originalName: "image.jpg",
data: image.imageData
)
editorData.imageAttachments.map { image -> GraphQLFile in
.init(fieldName: attachmentFieldName, originalName: "image.jpg", data: image.imageData)
},
].flatMap(\.self)
assert(uploadableAttachments.allSatisfy { !($0.data?.isEmpty ?? true) })
// in Apollo, filed name is handled as attached object to field when there is only one attachment
// to use array on our server, we need to append a dummy attachment
// which is ignored if data is empty and name is empty
if uploadableAttachments.count == 1 {
uploadableAttachments.append(.init(fieldName: attachmentFieldName, originalName: "", data: .init()))
}
guard let input = try? CreateChatMessageInput(
content: .some(content),
attachments: [],
blobs: .some([]), // must have the placeholder
content: .some(contextSnippet.isEmpty ? editorData.text : "\(contextSnippet)\n\(editorData.text)"),
params: .some(AffineGraphQL.JSON(_jsonValue: messageParameters)),
sessionId: sessionId
) else {
report(sessionId, ChatError.unknownError)
assertionFailure() // very unlikely to happen
return
}
@@ -83,11 +203,6 @@ extension ChatManager {
self.report(sessionId, ChatError.invalidResponse)
return
}
let viewModelId = self.append(sessionId: sessionId, AssistantMessageCellViewModel(
id: .init(),
content: .init(),
timestamp: .init()
))
self.startStreamingResponse(
sessionId: sessionId,
messageId: messageIdentifier,
@@ -99,8 +214,10 @@ extension ChatManager {
}
}
}
}
private func startStreamingResponse(sessionId: String, messageId: String, applyingTo vmId: UUID) {
private extension ChatManager {
func startStreamingResponse(sessionId: String, messageId: String, applyingTo vmId: UUID) {
let base = IntelligentContext.shared.webViewMetadata[.currentServerBaseUrl] as? String
guard let base, let url = URL(string: base) else {
report(sessionId, ChatError.invalidServerConfiguration)
@@ -164,24 +281,11 @@ extension ChatManager {
vmId: UUID
) {
let result = MarkdownParser().parse(document)
var renderedContexts: [String: RenderedItem] = [:]
for (key, value) in result.mathContext {
let image = MathRenderer.renderToImage(
latex: value,
fontSize: MarkdownTheme.default.fonts.body.pointSize,
textColor: MarkdownTheme.default.colors.body
)?.withRenderingMode(.alwaysTemplate)
let renderedContext = RenderedItem(
image: image,
text: value
)
renderedContexts["math://\(key)"] = renderedContext
}
let content = MarkdownTextView.PreprocessContent(parserResult: result, theme: .default)
with(sessionId: sessionId, vmId: vmId) { (viewModel: inout AssistantMessageCellViewModel) in
viewModel.content = document
viewModel.documentBlocks = result.document
viewModel.documentRenderedContent = renderedContexts
viewModel.preprocessedContent = content
}
}
}

View File

@@ -33,6 +33,12 @@ public class ChatManager: ObservableObject, @unchecked Sendable {
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] {
@@ -59,8 +65,6 @@ public class ChatManager: ObservableObject, @unchecked Sendable {
return
}
sessionViewModels[vmId] = vm
} else {
assertionFailure()
}
}
}

View File

@@ -85,11 +85,7 @@ extension MainViewController: InputBoxDelegate {
}
ChatManager.shared.closeAll()
ChatManager.shared.startUserRequest(
content: inputData.text,
inputBoxData: inputData,
sessionId: currentSession.id
)
ChatManager.shared.startUserRequest(editorData: inputData, sessionId: currentSession.id)
}
private func showAlert(title: String, message: String) {

View File

@@ -21,11 +21,6 @@ class AssistantMessageCell: ChatBaseCell {
contentView.addSubview(markdownView)
}
override func prepareForReuse() {
super.prepareForReuse()
markdownView.prepareForReuse()
}
override func configure(with viewModel: any ChatCellViewModel) {
super.configure(with: viewModel)
@@ -33,10 +28,7 @@ class AssistantMessageCell: ChatBaseCell {
assertionFailure()
return
}
markdownView.setMarkdown(
vm.documentBlocks,
renderedContent: vm.documentRenderedContent
)
markdownView.setMarkdown(vm.preprocessedContent)
}
override func layoutContentView(bounds: CGRect) {
@@ -53,10 +45,7 @@ class AssistantMessageCell: ChatBaseCell {
markdownViewForSizeCalculation.frame = .init(
x: 0, y: 0, width: width, height: .greatestFiniteMagnitude
)
markdownViewForSizeCalculation.setMarkdown(
vm.documentBlocks,
renderedContent: vm.documentRenderedContent
)
markdownViewForSizeCalculation.setMarkdownManually(vm.preprocessedContent)
let boundingSize = markdownViewForSizeCalculation.boundingSize(for: width)
return ceil(boundingSize.height)
}

View File

@@ -38,8 +38,7 @@ struct AssistantMessageCellViewModel: ChatCellViewModel {
var citations: [CitationViewModel]?
var actions: [MessageActionViewModel]?
var documentBlocks: [MarkdownBlockNode]
var documentRenderedContent: RenderContext
var preprocessedContent: MarkdownTextView.PreprocessContent
init(
id: UUID,
@@ -53,7 +52,7 @@ struct AssistantMessageCellViewModel: ChatCellViewModel {
actions: [MessageActionViewModel]? = nil
) {
// time expensive rendering should not happen here
assert(!Thread.isMainThread || content.isEmpty)
assert(!Thread.isMainThread || content.count < 10) // allow placeholder content
self.id = id
self.content = content
@@ -67,21 +66,10 @@ struct AssistantMessageCellViewModel: ChatCellViewModel {
let parser = MarkdownParser()
let parserResult = parser.parse(content)
documentBlocks = parserResult.document
var renderedContexts: [String: RenderedItem] = [:]
for (key, value) in parserResult.mathContext {
let image = MathRenderer.renderToImage(
latex: value,
fontSize: MarkdownTheme.default.fonts.body.pointSize,
textColor: MarkdownTheme.default.colors.body
)?.withRenderingMode(.alwaysTemplate)
let renderedContext = RenderedItem(
image: image,
text: value
)
renderedContexts["math://\(key)"] = renderedContext
}
documentRenderedContent = renderedContexts
preprocessedContent = MarkdownTextView.PreprocessContent(
parserResult: parserResult,
theme: .default,
)
}
}

View File

@@ -6,8 +6,8 @@ private let unselectedColor: UIColor = .affineIconPrimary
private let selectedColor: UIColor = .affineIconActivated
private let configurableOptions: [ConfigurableOptions] = [
.networking,
.reasoning,
// .networking,
// .reasoning,
]
enum ConfigurableOptions {
case tool

View File

@@ -22,11 +22,20 @@ class MainHeaderView: UIView {
$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 {
@@ -45,6 +54,13 @@ class MainHeaderView: UIView {
$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()