feat: vibe chat

This commit is contained in:
Lakr 2025-06-26 15:19:24 +08:00
parent 0dc032ee9e
commit fa565bc052
13 changed files with 287 additions and 320 deletions

View File

@ -79,18 +79,15 @@ extension MainViewController: InputBoxDelegate {
Task { @MainActor in
do {
// Ensure we have a current session or create one
let chatManager = ChatManager.shared
if let currentSession = chatManager.currentSession {
// Send message to existing session
try await chatManager.sendMessage(
content: inputData.text,
attachments: [], // TODO: Handle attachments
sessionId: currentSession.id
)
} else {
// Create new session first
guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String,
!workspaceId.isEmpty
else {
@ -100,7 +97,6 @@ extension MainViewController: InputBoxDelegate {
let session = try await chatManager.createSession(workspaceId: workspaceId)
// Send message to new session
try await chatManager.sendMessage(
content: inputData.text,
attachments: [], // TODO: Handle attachments
@ -108,7 +104,6 @@ extension MainViewController: InputBoxDelegate {
)
}
// Clear input after successful send
inputBox.text = ""
inputBox.viewModel.clearAllAttachments()

View File

@ -10,6 +10,27 @@ class MainViewController: UIViewController {
$0.delegate = self
}
lazy var tableView = UITableView().then {
$0.backgroundColor = .clear
$0.separatorStyle = .none
$0.delegate = self
$0.dataSource = self
$0.register(ChatCell.self, forCellReuseIdentifier: "ChatCell")
$0.keyboardDismissMode = .interactive
$0.contentInsetAdjustmentBehavior = .never
}
lazy var emptyStateView = UIView().then {
$0.isHidden = true
}
lazy var emptyStateLabel = UILabel().then {
$0.text = "Start a conversation..."
$0.font = .systemFont(ofSize: 18, weight: .medium)
$0.textColor = .systemGray
$0.textAlignment = .center
}
lazy var inputBox = InputBox().then {
$0.delegate = self
}
@ -28,8 +49,10 @@ class MainViewController: UIViewController {
// MARK: - Properties
private var messages: [ChatMessage] = []
private var cancellables = Set<AnyCancellable>()
private let intelligentContext = IntelligentContext.shared
private let chatManager = ChatManager.shared
var terminateEditGesture: UITapGestureRecognizer!
// MARK: - Lifecycle
@ -38,21 +61,46 @@ class MainViewController: UIViewController {
super.viewDidLoad()
view.backgroundColor = .affineLayerBackgroundPrimary
let inputBox = InputBox().then {
$0.delegate = self
}
self.inputBox = inputBox
setupUI()
setupBindings()
view.isUserInteractionEnabled = true
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
view.addGestureRecognizer(terminateEditGesture)
}
// MARK: - Setup
private func setupUI() {
view.addSubview(headerView)
view.addSubview(tableView)
view.addSubview(emptyStateView)
view.addSubview(inputBox)
view.addSubview(documentPickerHideDetector)
view.addSubview(documentPickerView)
emptyStateView.addSubview(emptyStateLabel)
headerView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
}
tableView.snp.makeConstraints { make in
make.top.equalTo(headerView.snp.bottom)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(inputBox.snp.top)
}
emptyStateView.snp.makeConstraints { make in
make.center.equalTo(tableView)
make.width.lessThanOrEqualTo(tableView).inset(32)
}
emptyStateLabel.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
inputBox.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
@ -67,10 +115,24 @@ class MainViewController: UIViewController {
make.leading.trailing.equalToSuperview()
make.height.equalTo(500)
}
}
view.isUserInteractionEnabled = true
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
view.addGestureRecognizer(terminateEditGesture)
private func setupBindings() {
chatManager.$currentSession
.receive(on: DispatchQueue.main)
.sink { [weak self] session in
self?.updateMessages(for: session?.id)
}
.store(in: &cancellables)
chatManager.$messages
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
if let sessionId = self?.chatManager.currentSession?.id {
self?.updateMessages(for: sessionId)
}
}
.store(in: &cancellables)
}
override func viewWillAppear(_ animated: Bool) {
@ -90,4 +152,66 @@ class MainViewController: UIViewController {
@objc func terminateEditing() {
view.endEditing(true)
}
// MARK: - Chat Methods
private func updateMessages(for sessionId: String?) {
guard let sessionId else {
messages = []
updateEmptyState()
tableView.reloadData()
return
}
messages = chatManager.messages[sessionId] ?? []
updateEmptyState()
tableView.reloadData()
if !messages.isEmpty {
let indexPath = IndexPath(row: messages.count - 1, section: 0)
tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
}
}
private func updateEmptyState() {
emptyStateView.isHidden = !messages.isEmpty
tableView.isHidden = messages.isEmpty
}
// MARK: - Internal Methods for Preview/Testing
#if DEBUG
func setMessagesForPreview(_ previewMessages: [ChatMessage]) {
messages = previewMessages
updateEmptyState()
tableView.reloadData()
}
#endif
}
// MARK: - UITableViewDataSource
extension MainViewController: UITableViewDataSource {
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
messages.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ChatCell", for: indexPath) as! ChatCell
let message = messages[indexPath.row]
cell.configure(with: message)
return cell
}
}
// MARK: - UITableViewDelegate
extension MainViewController: UITableViewDelegate {
func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat {
UITableView.automaticDimension
}
func tableView(_: UITableView, estimatedHeightForRowAt _: IndexPath) -> CGFloat {
60
}
}

View File

@ -0,0 +1,155 @@
//
// ChatCell.swift
// Intelligents
//
// Created by on 6/26/25.
//
import SnapKit
import Then
import UIKit
class ChatCell: UITableViewCell {
// MARK: - UI Components
private lazy var avatarImageView = UIImageView().then {
$0.contentMode = .scaleAspectFit
$0.layer.cornerRadius = 16
$0.layer.cornerCurve = .continuous
$0.clipsToBounds = true
$0.backgroundColor = .systemGray5
}
private lazy var messageContainerView = UIView().then {
$0.layer.cornerRadius = 12
$0.layer.cornerCurve = .continuous
}
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 16)
$0.textColor = .label
}
private lazy var timestampLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .systemGray
$0.textAlignment = .right
}
private lazy var stackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 12
$0.alignment = .top
}
private lazy var messageStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 4
}
// MARK: - Properties
private var message: ChatMessage?
// MARK: - Initialization
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")
}
// MARK: - Setup
private func setupUI() {
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(stackView)
messageStackView.addArrangedSubview(messageContainerView)
messageStackView.addArrangedSubview(timestampLabel)
messageContainerView.addSubview(messageLabel)
stackView.addArrangedSubview(avatarImageView)
stackView.addArrangedSubview(messageStackView)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
avatarImageView.snp.makeConstraints { make in
make.size.equalTo(32)
}
messageLabel.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(12)
}
messageStackView.snp.makeConstraints { make in
make.width.lessThanOrEqualTo(250)
}
}
// MARK: - Configuration
func configure(with message: ChatMessage) {
self.message = message
messageLabel.text = message.content
if let createdDate = message.createdDate {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
timestampLabel.text = formatter.string(from: createdDate)
} else {
timestampLabel.text = ""
}
switch message.role {
case .user:
configureUserMessage()
case .assistant:
configureAssistantMessage()
case .system:
configureSystemMessage()
}
}
private func configureUserMessage() {
// User message - align to right
stackView.semanticContentAttribute = .forceRightToLeft
messageContainerView.backgroundColor = .systemBlue
messageLabel.textColor = .white
avatarImageView.image = UIImage(systemName: "person.circle.fill")
avatarImageView.tintColor = .systemBlue
timestampLabel.textAlignment = .left
}
private func configureAssistantMessage() {
// Assistant message - align to left
stackView.semanticContentAttribute = .forceLeftToRight
messageContainerView.backgroundColor = .systemGray6
messageLabel.textColor = .label
avatarImageView.image = UIImage(systemName: "brain.head.profile")
avatarImageView.tintColor = .systemPurple
timestampLabel.textAlignment = .right
}
private func configureSystemMessage() {
// System message - center aligned
stackView.semanticContentAttribute = .forceLeftToRight
messageContainerView.backgroundColor = .systemYellow.withAlphaComponent(0.3)
messageLabel.textColor = .label
avatarImageView.image = UIImage(systemName: "gear")
avatarImageView.tintColor = .systemOrange
timestampLabel.textAlignment = .center
}
}

View File

@ -1,157 +0,0 @@
//
// ChatCell.swift
// Intelligents
//
// Created by on 6/26/25.
//
import SnapKit
import Then
import UIKit
extension ChatListView {
class ChatCell: UITableViewCell {
// MARK: - UI Components
private lazy var avatarImageView = UIImageView().then {
$0.contentMode = .scaleAspectFit
$0.layer.cornerRadius = 16
$0.layer.cornerCurve = .continuous
$0.clipsToBounds = true
$0.backgroundColor = .systemGray5
}
private lazy var messageContainerView = UIView().then {
$0.layer.cornerRadius = 12
$0.layer.cornerCurve = .continuous
}
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 16)
$0.textColor = .label
}
private lazy var timestampLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .systemGray
$0.textAlignment = .right
}
private lazy var stackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 12
$0.alignment = .top
}
private lazy var messageStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 4
}
// MARK: - Properties
private var message: ChatMessage?
// MARK: - Initialization
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")
}
// MARK: - Setup
private func setupUI() {
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(stackView)
messageStackView.addArrangedSubview(messageContainerView)
messageStackView.addArrangedSubview(timestampLabel)
messageContainerView.addSubview(messageLabel)
stackView.addArrangedSubview(avatarImageView)
stackView.addArrangedSubview(messageStackView)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
avatarImageView.snp.makeConstraints { make in
make.size.equalTo(32)
}
messageLabel.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(12)
}
messageStackView.snp.makeConstraints { make in
make.width.lessThanOrEqualTo(250)
}
}
// MARK: - Configuration
func configure(with message: ChatMessage) {
self.message = message
messageLabel.text = message.content
if let createdDate = message.createdDate {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
timestampLabel.text = formatter.string(from: createdDate)
} else {
timestampLabel.text = ""
}
switch message.role {
case .user:
configureUserMessage()
case .assistant:
configureAssistantMessage()
case .system:
configureSystemMessage()
}
}
private func configureUserMessage() {
// User message - align to right
stackView.semanticContentAttribute = .forceRightToLeft
messageContainerView.backgroundColor = .systemBlue
messageLabel.textColor = .white
avatarImageView.image = UIImage(systemName: "person.circle.fill")
avatarImageView.tintColor = .systemBlue
timestampLabel.textAlignment = .left
}
private func configureAssistantMessage() {
// Assistant message - align to left
stackView.semanticContentAttribute = .forceLeftToRight
messageContainerView.backgroundColor = .systemGray6
messageLabel.textColor = .label
avatarImageView.image = UIImage(systemName: "brain.head.profile")
avatarImageView.tintColor = .systemPurple
timestampLabel.textAlignment = .right
}
private func configureSystemMessage() {
// System message - center aligned
stackView.semanticContentAttribute = .forceLeftToRight
messageContainerView.backgroundColor = .systemYellow.withAlphaComponent(0.3)
messageLabel.textColor = .label
avatarImageView.image = UIImage(systemName: "gear")
avatarImageView.tintColor = .systemOrange
timestampLabel.textAlignment = .center
}
}
}

View File

@ -1,150 +0,0 @@
//
// ChatListView.swift
// Intelligents
//
// Created by on 6/26/25.
//
import AffineGraphQL
import Combine
import SnapKit
import Then
import UIKit
class ChatListView: UIView {
// MARK: - UI Components
lazy var tableView = UITableView().then {
$0.backgroundColor = .clear
$0.separatorStyle = .none
$0.delegate = self
$0.dataSource = self
$0.register(ChatCell.self, forCellReuseIdentifier: "ChatCell")
$0.keyboardDismissMode = .interactive
$0.contentInsetAdjustmentBehavior = .never
}
lazy var emptyStateView = UIView().then {
$0.isHidden = true
}
lazy var emptyStateLabel = UILabel().then {
$0.text = "Start a conversation..."
$0.font = .systemFont(ofSize: 18, weight: .medium)
$0.textColor = .systemGray
$0.textAlignment = .center
}
// MARK: - Properties
private var messages: [ChatMessage] = []
private var cancellables = Set<AnyCancellable>()
private let chatManager = ChatManager.shared
// MARK: - Initialization
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
setupBindings()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupUI() {
backgroundColor = .affineLayerBackgroundPrimary
addSubview(tableView)
addSubview(emptyStateView)
emptyStateView.addSubview(emptyStateLabel)
tableView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
emptyStateView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.lessThanOrEqualToSuperview().inset(32)
}
emptyStateLabel.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
private func setupBindings() {
// Listen to current session changes
chatManager.$currentSession
.receive(on: DispatchQueue.main)
.sink { [weak self] session in
self?.updateMessages(for: session?.id)
}
.store(in: &cancellables)
// Listen to messages changes
chatManager.$messages
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
if let sessionId = self?.chatManager.currentSession?.id {
self?.updateMessages(for: sessionId)
}
}
.store(in: &cancellables)
}
private func updateMessages(for sessionId: String?) {
guard let sessionId else {
messages = []
updateEmptyState()
tableView.reloadData()
return
}
messages = chatManager.messages[sessionId] ?? []
updateEmptyState()
tableView.reloadData()
// Scroll to bottom for new messages
if !messages.isEmpty {
let indexPath = IndexPath(row: messages.count - 1, section: 0)
tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
}
}
private func updateEmptyState() {
emptyStateView.isHidden = !messages.isEmpty
tableView.isHidden = messages.isEmpty
}
}
// MARK: - UITableViewDataSource
extension ChatListView: UITableViewDataSource {
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
messages.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ChatCell", for: indexPath) as! ChatCell
let message = messages[indexPath.row]
cell.configure(with: message)
return cell
}
}
// MARK: - UITableViewDelegate
extension ChatListView: UITableViewDelegate {
func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat {
UITableView.automaticDimension
}
func tableView(_: UITableView, estimatedHeightForRowAt _: IndexPath) -> CGFloat {
60
}
}

View File

@ -66,7 +66,7 @@ public class InputBoxViewModel: ObservableObject {
.assign(to: \.canSend, on: self)
.store(in: &cancellables)
}
public func clearAllAttachments() {
imageAttachments.removeAll()
fileAttachments.removeAll()