Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation

public struct ChatConversation: Identifiable, Codable, Equatable, Sendable {
public let id: UUID
public var messages: [ChatMessage]
public let createdAt: Date
public var updatedAt: Date

public init(
id: UUID = UUID(),
messages: [ChatMessage] = [],
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.id = id
self.messages = messages
self.createdAt = createdAt
self.updatedAt = updatedAt
}

public var title: String {
messages.first(where: { $0.role == .user })?.text
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty ?? "New chat"
}

public var turnCount: Int {
messages.filter { $0.role == .user }.count
}
}

private extension String {
var nonEmpty: String? { isEmpty ? nil : self }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Foundation

public final class ChatConversationStore: @unchecked Sendable {
private static let maximumStoredConversations = 100
private static let maximumFileBytes = 8 * 1_024 * 1_024

private let fileURL: URL?
private let fileManager: FileManager

public init(fileURL: URL? = nil, fileManager: FileManager = .default) {
self.fileURL = fileURL
self.fileManager = fileManager
}

public static func applicationSupport(fileManager: FileManager = .default) -> ChatConversationStore {
let root = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
return ChatConversationStore(
fileURL: root?
.appendingPathComponent("Wayfinder", isDirectory: true)
.appendingPathComponent("chat-history.json", isDirectory: false),
fileManager: fileManager
)
}

public func load() -> [ChatConversation] {
guard let fileURL,
let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path),
let byteCount = attributes[.size] as? NSNumber,
byteCount.intValue <= Self.maximumFileBytes,
let data = try? Data(contentsOf: fileURL, options: .mappedIfSafe),
let conversations = try? JSONDecoder().decode([ChatConversation].self, from: data) else {
return []
}
return Array(
conversations
.filter { !$0.messages.isEmpty }
.sorted { $0.updatedAt > $1.updatedAt }
.prefix(Self.maximumStoredConversations)
)
}

public func save(_ conversations: [ChatConversation]) {
guard let fileURL else { return }
let bounded = Array(
conversations
.filter { !$0.messages.isEmpty }
.sorted { $0.updatedAt > $1.updatedAt }
.prefix(Self.maximumStoredConversations)
)
guard let data = try? JSONEncoder().encode(bounded),
data.count <= Self.maximumFileBytes else {
return
}
do {
try fileManager.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try data.write(to: fileURL, options: [.atomic, .completeFileProtection])
} catch {
// Chat remains usable in memory if local history cannot be written.
}
}
}
57 changes: 53 additions & 4 deletions macos/WayfinderMac/Sources/WayfinderMac/State/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,37 @@ public final class AppState: ObservableObject {
@Published public var chatDestination: ChatDestination = .automatic
@Published public private(set) var chatDestinations: [ChatDestination] = [.automatic]
@Published public private(set) var chatMessages: [ChatMessage]
@Published public private(set) var chatConversations: [ChatConversation]
@Published public private(set) var activeChatConversationID: UUID
@Published public private(set) var isSendingMessage = false
@Published public private(set) var setupAssessment: SetupAssessment = .checking

public let chatDestinationNameStore: ChatDestinationNameStore

private let client: any WayfinderClient
private let chatConversationStore: ChatConversationStore
private let setupService = SetupService()
private var chatTask: Task<Void, Never>?

public init(
client: any WayfinderClient,
chatDestinationNameStore: ChatDestinationNameStore? = nil
chatDestinationNameStore: ChatDestinationNameStore? = nil,
chatConversationStore: ChatConversationStore = ChatConversationStore()
) {
self.client = client
self.chatDestinationNameStore = chatDestinationNameStore ?? ChatDestinationNameStore()
self.chatConversationStore = chatConversationStore
self.routingStats = .empty
self.gatewayOverview = .checking
self.chatMessages = []
let storedConversations = chatConversationStore.load()
self.chatConversations = storedConversations
if let mostRecent = storedConversations.first {
self.activeChatConversationID = mostRecent.id
self.chatMessages = mostRecent.messages
} else {
self.activeChatConversationID = UUID()
self.chatMessages = []
}
}

public var canAnalyse: Bool {
Expand Down Expand Up @@ -139,6 +152,7 @@ public final class AppState: ObservableObject {
text: "",
state: .streaming
))
saveActiveConversation()

chatTask = Task {
do {
Expand Down Expand Up @@ -178,6 +192,7 @@ public final class AppState: ObservableObject {
}
self.isSendingMessage = false
self.chatTask = nil
self.saveActiveConversation()
self.refreshStats()
} catch {
if Task.isCancelled || error is CancellationError {
Expand All @@ -200,11 +215,25 @@ public final class AppState: ObservableObject {
chatTask?.cancel()
}

public func clearChat() {
public func startNewChat() {
guard canClearChat else {
return
}
chatMessages.removeAll()
saveActiveConversation()
activeChatConversationID = UUID()
chatMessages = []
chatDraft = ""
}

public func selectChatConversation(_ id: UUID) {
guard !isSendingMessage, id != activeChatConversationID,
let conversation = chatConversations.first(where: { $0.id == id }) else {
return
}
saveActiveConversation()
activeChatConversationID = conversation.id
chatMessages = conversation.messages
chatDraft = ""
}

public func retryLastChatTurn() {
Expand Down Expand Up @@ -318,6 +347,7 @@ public final class AppState: ObservableObject {
}
isSendingMessage = false
chatTask = nil
saveActiveConversation()
}

private func finishFailedChatMessage(
Expand All @@ -332,8 +362,27 @@ public final class AppState: ObservableObject {
}
isSendingMessage = false
chatTask = nil
saveActiveConversation()
refreshStats()
}

private func saveActiveConversation() {
guard !chatMessages.isEmpty else { return }
let now = Date()
if let index = chatConversations.firstIndex(where: { $0.id == activeChatConversationID }) {
chatConversations[index].messages = chatMessages
chatConversations[index].updatedAt = now
} else {
chatConversations.append(ChatConversation(
id: activeChatConversationID,
messages: chatMessages,
createdAt: chatMessages.first?.createdAt ?? now,
updatedAt: now
))
}
chatConversations.sort { $0.updatedAt > $1.updatedAt }
chatConversationStore.save(chatConversations)
}
}

public enum SettingsSection: String, CaseIterable, Codable, Identifiable, Sendable {
Expand Down
Loading
Loading