2608 lines
88 KiB
Swift
2608 lines
88 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
enum DraftKind {
|
|
case chat
|
|
case search
|
|
}
|
|
|
|
enum SidebarSelection: Hashable {
|
|
case chat(String)
|
|
case search(String)
|
|
case settings
|
|
|
|
var id: String {
|
|
switch self {
|
|
case let .chat(chatID):
|
|
return "chat:\(chatID)"
|
|
case let .search(searchID):
|
|
return "search:\(searchID)"
|
|
case .settings:
|
|
return "settings"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SidebarItem: Identifiable, Hashable {
|
|
enum Kind: Hashable {
|
|
case chat
|
|
case search
|
|
}
|
|
|
|
var id: String { selection.id }
|
|
var selection: SidebarSelection
|
|
var kind: Kind
|
|
var title: String
|
|
var updatedAt: Date
|
|
var starred: Bool
|
|
var starredAt: Date?
|
|
var initiatedLabel: String?
|
|
var isRunning: Bool
|
|
}
|
|
|
|
private struct PendingChatState {
|
|
var chatID: String?
|
|
var messages: [Message]
|
|
}
|
|
|
|
private enum ActiveSendContext: Hashable {
|
|
case draftChat(UUID)
|
|
case chat(String)
|
|
case draftSearch(UUID)
|
|
case search(String)
|
|
|
|
var isSearch: Bool {
|
|
switch self {
|
|
case .draftSearch, .search:
|
|
return true
|
|
case .draftChat, .chat:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
private actor CompletionStreamStatus {
|
|
private var streamError: String?
|
|
|
|
func setError(_ value: String) {
|
|
streamError = value
|
|
}
|
|
|
|
func error() -> String? {
|
|
streamError
|
|
}
|
|
}
|
|
|
|
private actor SearchStreamStatus {
|
|
private var streamError: String?
|
|
|
|
func setError(_ value: String) {
|
|
streamError = value
|
|
}
|
|
|
|
func error() -> String? {
|
|
streamError
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class SybilViewModel {
|
|
let settings: SybilSettingsStore
|
|
|
|
var isCheckingSession = true
|
|
var isAuthenticated = false
|
|
var authMode: String?
|
|
var authError: String?
|
|
|
|
var chats: [ChatSummary] = []
|
|
var searches: [SearchSummary] = []
|
|
var workspaceItems: [WorkspaceItem] = []
|
|
|
|
var selectedItem: SidebarSelection?
|
|
var selectedChat: ChatDetail?
|
|
var selectedSearch: SearchDetail?
|
|
var draftKind: DraftKind?
|
|
|
|
var isLoadingCollections = false
|
|
var isLoadingSelection = false
|
|
var isCreatingSearchChat = false
|
|
var errorMessage: String?
|
|
|
|
var composer = ""
|
|
var composerAttachments: [ChatAttachment] = []
|
|
var provider: Provider
|
|
var modelCatalog: [Provider: ProviderModelInfo] = [:]
|
|
var model: String
|
|
var quickQuestionPrompt = ""
|
|
var quickQuestionMessages: [Message] = []
|
|
var quickQuestionError: String?
|
|
var quickQuestionProvider: Provider
|
|
var quickQuestionModel: String
|
|
var quickQuestionSubmittedPrompt: String?
|
|
var quickQuestionSubmittedProvider: Provider?
|
|
var quickQuestionSubmittedModel: String?
|
|
var isQuickQuestionSending = false
|
|
var isConvertingQuickQuestion = false
|
|
|
|
@ObservationIgnored
|
|
private var hasBootstrapped = false
|
|
private var pendingDraftChatState: PendingChatState?
|
|
private var pendingChatStates: [String: PendingChatState] = [:]
|
|
private var activeSearchDetails: [String: SearchDetail] = [:]
|
|
private var activeDraftSendContexts: Set<ActiveSendContext> = []
|
|
private var localActiveChatIDs: Set<String> = []
|
|
private var localActiveSearchIDs: Set<String> = []
|
|
private var serverActiveChatIDs: Set<String> = []
|
|
private var serverActiveSearchIDs: Set<String> = []
|
|
private var draftIdentity = UUID()
|
|
@ObservationIgnored
|
|
private var selectionTask: Task<Void, Never>?
|
|
@ObservationIgnored
|
|
private var activeRunPollingTask: Task<Void, Never>?
|
|
@ObservationIgnored
|
|
private var activeChatAttachTasks: [String: Task<Void, Never>] = [:]
|
|
@ObservationIgnored
|
|
private var activeSearchAttachTasks: [String: Task<Void, Never>] = [:]
|
|
@ObservationIgnored
|
|
private var quickQuestionTask: Task<Void, Never>?
|
|
@ObservationIgnored
|
|
private var quickQuestionRunID: UUID?
|
|
@ObservationIgnored
|
|
private var isAppActive = true
|
|
@ObservationIgnored
|
|
private var appLifecycleGeneration = 0
|
|
@ObservationIgnored
|
|
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
|
|
|
private let fallbackModels: [Provider: [String]] = [
|
|
.openai: ["gpt-4.1-mini"],
|
|
.anthropic: ["claude-3-5-sonnet-latest"],
|
|
.xai: ["grok-3-mini"],
|
|
.hermesAgent: ["hermes-agent"]
|
|
]
|
|
|
|
init(
|
|
settings: SybilSettingsStore = SybilSettingsStore(),
|
|
clientFactory: @escaping (APIConfiguration) -> any SybilAPIClienting = { configuration in
|
|
SybilAPIClient(configuration: configuration)
|
|
}
|
|
) {
|
|
self.settings = settings
|
|
self.clientFactory = clientFactory
|
|
let initialProvider = settings.preferredProvider
|
|
let initialModel = settings.preferredModelByProvider[initialProvider] ?? "gpt-4.1-mini"
|
|
self.provider = initialProvider
|
|
self.model = initialModel
|
|
let initialQuickQuestionProvider = settings.quickQuestionPreferredProvider
|
|
let initialQuickQuestionModel = settings.quickQuestionPreferredModelByProvider[initialQuickQuestionProvider] ?? initialModel
|
|
self.quickQuestionProvider = initialQuickQuestionProvider
|
|
self.quickQuestionModel = initialQuickQuestionModel
|
|
}
|
|
|
|
var providerModelOptions: [String] {
|
|
modelOptions(for: provider)
|
|
}
|
|
|
|
var providerOptions: [Provider] {
|
|
Provider.allCases.filter { candidate in
|
|
candidate != .hermesAgent || modelCatalog[candidate] != nil
|
|
}
|
|
}
|
|
|
|
var quickQuestionProviderModelOptions: [String] {
|
|
modelOptions(for: quickQuestionProvider)
|
|
}
|
|
|
|
var canSendQuickQuestion: Bool {
|
|
!isQuickQuestionSending &&
|
|
!isConvertingQuickQuestion &&
|
|
!quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
|
!quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
var quickQuestionAnswerText: String {
|
|
for message in quickQuestionMessages.reversed() where message.role == .assistant {
|
|
let content = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !content.isEmpty {
|
|
return content
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var canConvertQuickQuestion: Bool {
|
|
!isQuickQuestionSending &&
|
|
!isConvertingQuickQuestion &&
|
|
!(quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) &&
|
|
!quickQuestionAnswerText.isEmpty &&
|
|
quickQuestionSubmittedProvider != nil &&
|
|
!(quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
|
|
}
|
|
|
|
func modelOptions(for candidate: Provider) -> [String] {
|
|
let serverModels = modelCatalog[candidate]?.models ?? []
|
|
if !serverModels.isEmpty {
|
|
return serverModels
|
|
}
|
|
return fallbackModels[candidate] ?? []
|
|
}
|
|
|
|
var selectedTitle: String {
|
|
if case .settings = selectedItem {
|
|
return "Settings"
|
|
}
|
|
if draftKind == .chat {
|
|
return "New chat"
|
|
}
|
|
if draftKind == .search {
|
|
return "New search"
|
|
}
|
|
|
|
guard let selectedItem else {
|
|
return "Sybil"
|
|
}
|
|
|
|
switch selectedItem {
|
|
case .chat:
|
|
if let selectedChat = currentSelectedChat {
|
|
return chatTitle(title: selectedChat.title, messages: selectedChat.messages)
|
|
}
|
|
if let summary = selectedChatSummary {
|
|
return chatTitle(title: summary.title, messages: nil)
|
|
}
|
|
return "Chat"
|
|
|
|
case .search:
|
|
if let selectedSearch = currentSelectedSearch {
|
|
return searchTitle(title: selectedSearch.title, query: selectedSearch.query)
|
|
}
|
|
if let summary = selectedSearchSummary {
|
|
return searchTitle(title: summary.title, query: summary.query)
|
|
}
|
|
return "Search"
|
|
|
|
case .settings:
|
|
return "Settings"
|
|
}
|
|
}
|
|
|
|
var subtitle: String {
|
|
if case .settings = selectedItem {
|
|
return "Configure API connectivity"
|
|
}
|
|
|
|
let modeLabel: String
|
|
if authMode == "open" {
|
|
modeLabel = "open mode"
|
|
} else if authMode == "token" {
|
|
modeLabel = "token mode"
|
|
} else {
|
|
modeLabel = "offline"
|
|
}
|
|
|
|
if isSearchMode {
|
|
return "Sybil iOS (\(modeLabel)) • Exa Search"
|
|
}
|
|
return "Sybil iOS (\(modeLabel))"
|
|
}
|
|
|
|
var isSearchMode: Bool {
|
|
if draftKind == .search {
|
|
return true
|
|
}
|
|
if draftKind == .chat {
|
|
return false
|
|
}
|
|
if case .search = selectedItem {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
var showsComposer: Bool {
|
|
if case .settings = selectedItem {
|
|
return false
|
|
}
|
|
return draftKind != nil || selectedItem != nil
|
|
}
|
|
|
|
private var activeChatIDs: Set<String> {
|
|
localActiveChatIDs.union(serverActiveChatIDs)
|
|
}
|
|
|
|
private var activeSearchIDs: Set<String> {
|
|
localActiveSearchIDs.union(serverActiveSearchIDs)
|
|
}
|
|
|
|
private func isChatRowRunning(_ chatID: String) -> Bool {
|
|
pendingChatStates[chatID] != nil || activeChatIDs.contains(chatID)
|
|
}
|
|
|
|
private func isSearchRowRunning(_ searchID: String) -> Bool {
|
|
activeSearchDetails[searchID] != nil || activeSearchIDs.contains(searchID)
|
|
}
|
|
|
|
var isSending: Bool {
|
|
!activeDraftSendContexts.isEmpty || !activeChatIDs.isEmpty || !activeSearchIDs.isEmpty
|
|
}
|
|
|
|
var isActiveSelectionSending: Bool {
|
|
isSendContextActive(currentSendContext)
|
|
}
|
|
|
|
var canSendComposer: Bool {
|
|
if isActiveSelectionSending {
|
|
return false
|
|
}
|
|
|
|
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if isSearchMode {
|
|
return !content.isEmpty
|
|
}
|
|
|
|
return !content.isEmpty || !composerAttachments.isEmpty
|
|
}
|
|
|
|
var displayedMessages: [Message] {
|
|
let canonical = displayableMessages(currentSelectedChat?.messages ?? [])
|
|
if case let .chat(chatID) = selectedItem,
|
|
let pending = pendingChatStates[chatID] {
|
|
return displayableMessages(pending.messages)
|
|
}
|
|
|
|
if draftKind == .chat, let pending = pendingDraftChatState {
|
|
return displayableMessages(pending.messages)
|
|
}
|
|
|
|
return canonical
|
|
}
|
|
|
|
var displayedSearch: SearchDetail? {
|
|
if case let .search(searchID) = selectedItem,
|
|
let activeSearch = activeSearchDetails[searchID] {
|
|
return activeSearch
|
|
}
|
|
return currentSelectedSearch
|
|
}
|
|
|
|
var isSendingVisibleChat: Bool {
|
|
if draftKind == .chat {
|
|
return activeDraftSendContexts.contains(.draftChat(draftIdentity))
|
|
}
|
|
|
|
if case let .chat(chatID) = selectedItem {
|
|
return isChatRowRunning(chatID)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
var isRunningVisibleSearch: Bool {
|
|
if draftKind == .search {
|
|
return activeDraftSendContexts.contains(.draftSearch(draftIdentity))
|
|
}
|
|
|
|
if case let .search(searchID) = selectedItem {
|
|
return isSearchRowRunning(searchID)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
var sidebarItems: [SidebarItem] {
|
|
workspaceItems.map { item in
|
|
switch item.type {
|
|
case .chat:
|
|
let initiatedLabel: String?
|
|
if let model = item.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
|
if let provider = item.initiatedProvider {
|
|
initiatedLabel = "\(provider.displayName) • \(model)"
|
|
} else {
|
|
initiatedLabel = model
|
|
}
|
|
} else {
|
|
initiatedLabel = nil
|
|
}
|
|
|
|
return SidebarItem(
|
|
selection: .chat(item.id),
|
|
kind: .chat,
|
|
title: chatTitle(title: item.title, messages: nil),
|
|
updatedAt: item.updatedAt,
|
|
starred: item.starred,
|
|
starredAt: item.starredAt,
|
|
initiatedLabel: initiatedLabel,
|
|
isRunning: isChatRowRunning(item.id)
|
|
)
|
|
|
|
case .search:
|
|
return SidebarItem(
|
|
selection: .search(item.id),
|
|
kind: .search,
|
|
title: searchTitle(title: item.title, query: item.query),
|
|
updatedAt: item.updatedAt,
|
|
starred: item.starred,
|
|
starredAt: item.starredAt,
|
|
initiatedLabel: "exa",
|
|
isRunning: isSearchRowRunning(item.id)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
var selectedChatSummary: ChatSummary? {
|
|
guard case let .chat(chatID) = selectedItem else { return nil }
|
|
return chats.first(where: { $0.id == chatID })
|
|
}
|
|
|
|
var selectedSearchSummary: SearchSummary? {
|
|
guard case let .search(searchID) = selectedItem else { return nil }
|
|
return searches.first(where: { $0.id == searchID })
|
|
}
|
|
|
|
var hasRefreshableSelection: Bool {
|
|
guard draftKind == nil, let selectedItem else {
|
|
return false
|
|
}
|
|
|
|
switch selectedItem {
|
|
case .chat, .search:
|
|
return true
|
|
case .settings:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func bootstrap() async {
|
|
guard !hasBootstrapped else {
|
|
return
|
|
}
|
|
|
|
SybilLog.info(SybilLog.app, "Bootstrapping Sybil iOS session")
|
|
hasBootstrapped = true
|
|
await reconnect()
|
|
}
|
|
|
|
func reconnect() async {
|
|
isCheckingSession = true
|
|
authError = nil
|
|
errorMessage = nil
|
|
resetSelectionLoading()
|
|
stopActiveRunPolling()
|
|
cancelActiveStreamAttachTasks()
|
|
pendingDraftChatState = nil
|
|
pendingChatStates = [:]
|
|
activeSearchDetails = [:]
|
|
activeDraftSendContexts = []
|
|
localActiveChatIDs = []
|
|
localActiveSearchIDs = []
|
|
serverActiveChatIDs = []
|
|
serverActiveSearchIDs = []
|
|
resetQuickQuestion()
|
|
draftIdentity = UUID()
|
|
composerAttachments = []
|
|
settings.persist()
|
|
|
|
SybilLog.info(
|
|
SybilLog.app,
|
|
"Reconnecting with API base URL \(settings.normalizedAPIBaseURL?.absoluteString ?? "<invalid>")"
|
|
)
|
|
|
|
do {
|
|
let client = try client()
|
|
let session = try await client.verifySession()
|
|
isAuthenticated = session.authenticated
|
|
authMode = session.mode
|
|
authError = nil
|
|
|
|
SybilLog.info(
|
|
SybilLog.app,
|
|
"Session verified (authenticated=\(session.authenticated), mode=\(session.mode))"
|
|
)
|
|
|
|
await loadInitialData(using: client)
|
|
startActiveRunPolling()
|
|
} catch {
|
|
stopActiveRunPolling()
|
|
isAuthenticated = false
|
|
authMode = nil
|
|
chats = []
|
|
searches = []
|
|
workspaceItems = []
|
|
selectedItem = .settings
|
|
selectedChat = nil
|
|
selectedSearch = nil
|
|
draftKind = nil
|
|
authError = normalizeAuthError(error)
|
|
SybilLog.error(SybilLog.app, "Reconnect failed", error: error)
|
|
}
|
|
|
|
isCheckingSession = false
|
|
}
|
|
|
|
func setProvider(_ nextProvider: Provider) {
|
|
provider = nextProvider
|
|
|
|
let preferred = settings.preferredModelByProvider[nextProvider]
|
|
let options = providerModelOptions
|
|
if let preferred, options.contains(preferred) {
|
|
model = preferred
|
|
} else if let first = options.first {
|
|
model = first
|
|
}
|
|
|
|
settings.preferredProvider = nextProvider
|
|
settings.persist()
|
|
SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(model)")
|
|
}
|
|
|
|
func setModel(_ nextModel: String) {
|
|
model = nextModel
|
|
settings.preferredModelByProvider[provider] = nextModel
|
|
settings.persist()
|
|
SybilLog.info(SybilLog.ui, "Model changed to \(nextModel)")
|
|
}
|
|
|
|
func setProvider(_ nextProvider: Provider, model nextModel: String) {
|
|
provider = nextProvider
|
|
model = nextModel
|
|
settings.preferredProvider = nextProvider
|
|
settings.preferredModelByProvider[nextProvider] = nextModel
|
|
settings.persist()
|
|
SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)")
|
|
}
|
|
|
|
func setQuickQuestionProvider(_ nextProvider: Provider) {
|
|
quickQuestionProvider = nextProvider
|
|
|
|
let options = modelOptions(for: nextProvider)
|
|
if let preferred = settings.quickQuestionPreferredModelByProvider[nextProvider], options.contains(preferred) {
|
|
quickQuestionModel = preferred
|
|
} else if let first = options.first {
|
|
quickQuestionModel = first
|
|
} else {
|
|
quickQuestionModel = ""
|
|
}
|
|
|
|
persistQuickQuestionModelSelection()
|
|
}
|
|
|
|
func setQuickQuestionModel(_ nextModel: String) {
|
|
quickQuestionModel = nextModel
|
|
persistQuickQuestionModelSelection()
|
|
}
|
|
|
|
private func persistQuickQuestionModelSelection() {
|
|
settings.quickQuestionPreferredProvider = quickQuestionProvider
|
|
let trimmedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmedModel.isEmpty {
|
|
settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = trimmedModel
|
|
}
|
|
settings.persist()
|
|
}
|
|
|
|
func updateQuickQuestionPrompt(_ nextPrompt: String) {
|
|
guard nextPrompt != quickQuestionPrompt else {
|
|
return
|
|
}
|
|
|
|
if isQuickQuestionSending || quickQuestionSubmittedPrompt != nil || !quickQuestionMessages.isEmpty {
|
|
cancelQuickQuestion()
|
|
quickQuestionSubmittedPrompt = nil
|
|
quickQuestionSubmittedProvider = nil
|
|
quickQuestionSubmittedModel = nil
|
|
quickQuestionMessages = []
|
|
quickQuestionError = nil
|
|
}
|
|
|
|
quickQuestionPrompt = nextPrompt
|
|
}
|
|
|
|
func resetQuickQuestion() {
|
|
cancelQuickQuestion()
|
|
quickQuestionPrompt = ""
|
|
quickQuestionMessages = []
|
|
quickQuestionError = nil
|
|
quickQuestionSubmittedPrompt = nil
|
|
quickQuestionSubmittedProvider = nil
|
|
quickQuestionSubmittedModel = nil
|
|
isConvertingQuickQuestion = false
|
|
}
|
|
|
|
func cancelQuickQuestion() {
|
|
quickQuestionTask?.cancel()
|
|
quickQuestionTask = nil
|
|
quickQuestionRunID = nil
|
|
isQuickQuestionSending = false
|
|
}
|
|
|
|
@discardableResult
|
|
func sendQuickQuestion() -> Task<Void, Never>? {
|
|
let content = quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !content.isEmpty, !isQuickQuestionSending, !isConvertingQuickQuestion else {
|
|
return nil
|
|
}
|
|
|
|
let selectedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !selectedModel.isEmpty else {
|
|
quickQuestionError = "No model available for selected provider."
|
|
return nil
|
|
}
|
|
|
|
cancelQuickQuestion()
|
|
let selectedProvider = quickQuestionProvider
|
|
let task = Task { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
|
}
|
|
quickQuestionTask = task
|
|
return task
|
|
}
|
|
|
|
@discardableResult
|
|
func convertQuickQuestionToChat() async -> Bool {
|
|
let question = quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let answer = quickQuestionAnswerText
|
|
guard !question.isEmpty,
|
|
!answer.isEmpty,
|
|
let submittedProvider = quickQuestionSubmittedProvider,
|
|
let submittedModel = quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!submittedModel.isEmpty,
|
|
!isQuickQuestionSending,
|
|
!isConvertingQuickQuestion
|
|
else {
|
|
return false
|
|
}
|
|
|
|
isConvertingQuickQuestion = true
|
|
quickQuestionError = nil
|
|
defer {
|
|
isConvertingQuickQuestion = false
|
|
}
|
|
|
|
do {
|
|
let titleSeed = question.split(whereSeparator: \.isNewline).first.map(String.init) ?? question
|
|
let title = String(titleSeed.trimmingCharacters(in: .whitespacesAndNewlines).prefix(48))
|
|
let chat = try await client().createChat(
|
|
title: title.isEmpty ? "Quick question" : title,
|
|
provider: submittedProvider,
|
|
model: submittedModel,
|
|
messages: [
|
|
CompletionRequestMessage(role: .user, content: question),
|
|
CompletionRequestMessage(role: .assistant, content: answer)
|
|
]
|
|
)
|
|
|
|
setProvider(submittedProvider, model: submittedModel)
|
|
chats.removeAll(where: { $0.id == chat.id })
|
|
chats.insert(chat, at: 0)
|
|
upsertWorkspaceChat(chat)
|
|
draftKind = nil
|
|
selectedItem = .chat(chat.id)
|
|
selectedChat = ChatDetail(
|
|
id: chat.id,
|
|
title: chat.title,
|
|
createdAt: chat.createdAt,
|
|
updatedAt: chat.updatedAt,
|
|
starred: chat.starred,
|
|
starredAt: chat.starredAt,
|
|
initiatedProvider: chat.initiatedProvider,
|
|
initiatedModel: chat.initiatedModel,
|
|
lastUsedProvider: chat.lastUsedProvider,
|
|
lastUsedModel: chat.lastUsedModel,
|
|
messages: []
|
|
)
|
|
selectedSearch = nil
|
|
composer = ""
|
|
composerAttachments = []
|
|
|
|
await refreshCollections(preferredSelection: .chat(chat.id))
|
|
resetQuickQuestion()
|
|
return true
|
|
} catch {
|
|
quickQuestionError = normalizeAPIError(error)
|
|
SybilLog.error(SybilLog.ui, "Convert quick question to chat failed", error: error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
func startNewChat() {
|
|
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
|
resetSelectionLoading()
|
|
draftIdentity = UUID()
|
|
draftKind = .chat
|
|
selectedItem = nil
|
|
selectedChat = nil
|
|
selectedSearch = nil
|
|
pendingDraftChatState = nil
|
|
errorMessage = nil
|
|
composer = ""
|
|
composerAttachments = []
|
|
}
|
|
|
|
func startNewSearch() {
|
|
SybilLog.debug(SybilLog.ui, "Starting draft search")
|
|
resetSelectionLoading()
|
|
draftIdentity = UUID()
|
|
draftKind = .search
|
|
selectedItem = nil
|
|
selectedChat = nil
|
|
selectedSearch = nil
|
|
pendingDraftChatState = nil
|
|
errorMessage = nil
|
|
composer = ""
|
|
composerAttachments = []
|
|
}
|
|
|
|
func openSettings() {
|
|
SybilLog.debug(SybilLog.ui, "Opening settings")
|
|
resetSelectionLoading()
|
|
draftKind = nil
|
|
selectedItem = .settings
|
|
selectedChat = nil
|
|
selectedSearch = nil
|
|
pendingDraftChatState = nil
|
|
errorMessage = nil
|
|
composerAttachments = []
|
|
}
|
|
|
|
func select(_ selection: SidebarSelection) {
|
|
_ = beginSelecting(selection)
|
|
}
|
|
|
|
func selectForNavigation(_ selection: SidebarSelection, preloadTimeout: Duration = .seconds(3)) async {
|
|
guard beginSelecting(selection) != nil else {
|
|
return
|
|
}
|
|
|
|
await waitForSelectionLoad(timeout: preloadTimeout)
|
|
}
|
|
|
|
@discardableResult
|
|
private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
|
|
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
|
|
|
|
if draftKind == nil, selectedItem == selection {
|
|
errorMessage = nil
|
|
if case .search = selection {
|
|
composerAttachments = []
|
|
}
|
|
|
|
if needsSelectionLoad(selection) {
|
|
return startSelectionRefreshTask()
|
|
}
|
|
|
|
return selectionTask
|
|
}
|
|
|
|
resetSelectionLoading()
|
|
draftKind = nil
|
|
selectedItem = selection
|
|
errorMessage = nil
|
|
|
|
switch selection {
|
|
case let .chat(chatID):
|
|
if selectedChat?.id != chatID {
|
|
selectedChat = nil
|
|
}
|
|
selectedSearch = nil
|
|
|
|
case let .search(searchID):
|
|
if selectedSearch?.id != searchID {
|
|
selectedSearch = nil
|
|
}
|
|
selectedChat = nil
|
|
composerAttachments = []
|
|
|
|
case .settings:
|
|
selectedChat = nil
|
|
selectedSearch = nil
|
|
return nil
|
|
}
|
|
|
|
return startSelectionRefreshTask()
|
|
}
|
|
|
|
func selectPreviousSidebarItem() {
|
|
selectAdjacentSidebarItem(offset: -1)
|
|
}
|
|
|
|
func selectNextSidebarItem() {
|
|
selectAdjacentSidebarItem(offset: 1)
|
|
}
|
|
|
|
private func selectAdjacentSidebarItem(offset: Int) {
|
|
let items = sidebarItems
|
|
guard !items.isEmpty else {
|
|
return
|
|
}
|
|
|
|
let currentIndex = selectedItem.flatMap { selection in
|
|
items.firstIndex { $0.selection == selection }
|
|
}
|
|
let startingIndex = currentIndex ?? (offset < 0 ? items.count : -1)
|
|
let nextIndex = (startingIndex + offset + items.count) % items.count
|
|
let nextSelection = items[nextIndex].selection
|
|
|
|
guard draftKind != nil || selectedItem != nextSelection else {
|
|
return
|
|
}
|
|
|
|
select(nextSelection)
|
|
}
|
|
|
|
func deleteItem(_ selection: SidebarSelection) async {
|
|
guard isAuthenticated else {
|
|
return
|
|
}
|
|
guard case .settings = selection else {
|
|
SybilLog.info(SybilLog.ui, "Deleting item \(selection.id)")
|
|
do {
|
|
let client = try client()
|
|
switch selection {
|
|
case let .chat(chatID):
|
|
try await client.deleteChat(chatID: chatID)
|
|
case let .search(searchID):
|
|
try await client.deleteSearch(searchID: searchID)
|
|
case .settings:
|
|
break
|
|
}
|
|
await refreshCollections(preferredSelection: nil)
|
|
} catch {
|
|
errorMessage = normalizeAPIError(error)
|
|
SybilLog.error(SybilLog.ui, "Delete failed", error: error)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
func renameChat(chatID: String, title: String) async {
|
|
guard isAuthenticated else {
|
|
return
|
|
}
|
|
|
|
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmedTitle.isEmpty else {
|
|
errorMessage = "Enter a chat title."
|
|
return
|
|
}
|
|
|
|
SybilLog.info(SybilLog.ui, "Renaming chat \(chatID)")
|
|
errorMessage = nil
|
|
|
|
do {
|
|
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
|
|
applyChatSummary(updated, moveToFront: true)
|
|
} catch {
|
|
errorMessage = normalizeAPIError(error)
|
|
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
|
|
}
|
|
}
|
|
|
|
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
|
|
guard isAuthenticated else {
|
|
return
|
|
}
|
|
|
|
guard case .settings = selection else {
|
|
errorMessage = nil
|
|
|
|
do {
|
|
let client = try client()
|
|
switch selection {
|
|
case let .chat(chatID):
|
|
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
|
|
applyChatSummary(updated, moveToFront: false)
|
|
case let .search(searchID):
|
|
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
|
|
applySearchSummary(updated, moveToFront: false)
|
|
case .settings:
|
|
break
|
|
}
|
|
} catch {
|
|
errorMessage = normalizeAPIError(error)
|
|
SybilLog.error(SybilLog.ui, "Star update failed", error: error)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
func refreshAfterSettingsChange() async {
|
|
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
|
|
settings.persist()
|
|
await reconnect()
|
|
}
|
|
|
|
func markAppInactiveForNetwork() {
|
|
guard isAppActive else {
|
|
return
|
|
}
|
|
|
|
isAppActive = false
|
|
appLifecycleGeneration += 1
|
|
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
|
|
}
|
|
|
|
func markAppActiveForNetwork() {
|
|
isAppActive = true
|
|
}
|
|
|
|
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
|
markAppActiveForNetwork()
|
|
|
|
guard isAuthenticated, !isCheckingSession else {
|
|
return
|
|
}
|
|
|
|
guard shouldRefreshCollections || shouldRefreshSelection else {
|
|
return
|
|
}
|
|
|
|
try? await Task.sleep(for: .milliseconds(150))
|
|
await refreshVisibleContent(
|
|
refreshCollections: shouldRefreshCollections,
|
|
refreshSelection: shouldRefreshSelection
|
|
)
|
|
}
|
|
|
|
func refreshVisibleContent(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
|
guard isAuthenticated, !isCheckingSession else {
|
|
return
|
|
}
|
|
|
|
guard shouldRefreshCollections || shouldRefreshSelection else {
|
|
return
|
|
}
|
|
|
|
SybilLog.info(
|
|
SybilLog.ui,
|
|
"Visible content refresh requested (collections=\(shouldRefreshCollections), selection=\(shouldRefreshSelection))"
|
|
)
|
|
|
|
if shouldRefreshCollections {
|
|
await refreshCollections(preferredSelection: selectedItem, refreshSelection: shouldRefreshSelection)
|
|
return
|
|
}
|
|
|
|
if shouldRefreshSelection {
|
|
await refreshActiveRunsFromServer()
|
|
await refreshSelectionIfNeeded()
|
|
}
|
|
}
|
|
|
|
func refreshSidebarCollectionsFromPullToRefresh() async {
|
|
guard isAuthenticated, !isCheckingSession else {
|
|
return
|
|
}
|
|
|
|
SybilLog.info(
|
|
SybilLog.ui,
|
|
"Sidebar pull-to-refresh requested"
|
|
)
|
|
|
|
let preferredSelection = selectedItem
|
|
let refreshTask = Task { @MainActor in
|
|
await refreshCollections(preferredSelection: preferredSelection, refreshSelection: false)
|
|
}
|
|
await refreshTask.value
|
|
}
|
|
|
|
func sendComposer() async {
|
|
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let attachments = composerAttachments
|
|
let sendContext = currentSendContext
|
|
|
|
guard !isSendContextActive(sendContext) else {
|
|
return
|
|
}
|
|
|
|
if sendContext.isSearch {
|
|
guard !content.isEmpty else { return }
|
|
} else if content.isEmpty && attachments.isEmpty {
|
|
return
|
|
}
|
|
|
|
composer = ""
|
|
composerAttachments = []
|
|
errorMessage = nil
|
|
markSendContextActive(sendContext)
|
|
defer {
|
|
markSendContextInactive(sendContext)
|
|
}
|
|
|
|
do {
|
|
if sendContext.isSearch {
|
|
SybilLog.info(SybilLog.ui, "Sending search query")
|
|
try await sendSearch(query: content, sendContext: sendContext)
|
|
} else {
|
|
SybilLog.info(SybilLog.ui, "Sending chat prompt")
|
|
try await sendChat(content: content, attachments: attachments, sendContext: sendContext)
|
|
}
|
|
} catch {
|
|
let shouldSurfaceError = isSendContextVisible(sendContext)
|
|
if shouldSurfaceError {
|
|
errorMessage = normalizeAPIError(error)
|
|
}
|
|
SybilLog.error(SybilLog.ui, "Send failed", error: error)
|
|
|
|
if shouldSurfaceError, case let .chat(chatID) = selectedItem {
|
|
do {
|
|
let chat = try await client().getChat(chatID: chatID)
|
|
if selectedItem == .chat(chatID), draftKind == nil {
|
|
selectedChat = chat
|
|
}
|
|
} catch {
|
|
SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error)
|
|
}
|
|
}
|
|
|
|
if shouldSurfaceError, case let .search(searchID) = selectedItem {
|
|
do {
|
|
let search = try await client().getSearch(searchID: searchID)
|
|
if selectedItem == .search(searchID), draftKind == nil {
|
|
selectedSearch = search
|
|
}
|
|
} catch {
|
|
SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error)
|
|
}
|
|
}
|
|
|
|
if !sendContext.isSearch, shouldSurfaceError {
|
|
composer = content
|
|
composerAttachments = attachments
|
|
}
|
|
clearPendingChatState(for: sendContext)
|
|
}
|
|
}
|
|
|
|
func appendComposerAttachments(_ attachments: [ChatAttachment]) throws {
|
|
guard !attachments.isEmpty else {
|
|
return
|
|
}
|
|
|
|
guard !isSearchMode else {
|
|
errorMessage = "Attachments are only available in chat mode."
|
|
return
|
|
}
|
|
|
|
if composerAttachments.count + attachments.count > SybilChatAttachmentSupport.maxAttachmentsPerMessage {
|
|
throw ChatAttachmentError.tooManyAttachments(SybilChatAttachmentSupport.maxAttachmentsPerMessage)
|
|
}
|
|
|
|
composerAttachments += attachments
|
|
errorMessage = nil
|
|
}
|
|
|
|
func removeComposerAttachment(id: String) {
|
|
composerAttachments.removeAll { $0.id == id }
|
|
}
|
|
|
|
func startChatFromSelectedSearch() async {
|
|
guard let search = currentSelectedSearch, !isCreatingSearchChat, !isActiveSelectionSending else {
|
|
return
|
|
}
|
|
|
|
let sourceSelection = SidebarSelection.search(search.id)
|
|
isCreatingSearchChat = true
|
|
errorMessage = nil
|
|
|
|
do {
|
|
let client = try client()
|
|
let chat = try await client.createChatFromSearch(searchID: search.id, title: nil)
|
|
|
|
guard selectedItem == sourceSelection, draftKind == nil else {
|
|
chats.removeAll(where: { $0.id == chat.id })
|
|
chats.insert(chat, at: 0)
|
|
upsertWorkspaceChat(chat)
|
|
isCreatingSearchChat = false
|
|
return
|
|
}
|
|
|
|
draftKind = nil
|
|
pendingDraftChatState = nil
|
|
composer = ""
|
|
composerAttachments = []
|
|
|
|
chats.removeAll(where: { $0.id == chat.id })
|
|
chats.insert(chat, at: 0)
|
|
upsertWorkspaceChat(chat)
|
|
|
|
selectedItem = .chat(chat.id)
|
|
selectedSearch = nil
|
|
|
|
await refreshCollections(preferredSelection: .chat(chat.id))
|
|
} catch {
|
|
errorMessage = normalizeAPIError(error)
|
|
SybilLog.error(SybilLog.ui, "Create chat from search failed", error: error)
|
|
}
|
|
|
|
isCreatingSearchChat = false
|
|
}
|
|
|
|
private func runQuickQuestion(prompt: String, provider: Provider, model: String) async {
|
|
let runID = UUID()
|
|
quickQuestionRunID = runID
|
|
quickQuestionError = nil
|
|
quickQuestionSubmittedPrompt = prompt
|
|
quickQuestionSubmittedProvider = provider
|
|
quickQuestionSubmittedModel = model
|
|
quickQuestionMessages = [
|
|
Message(
|
|
id: "temp-assistant-quick-\(UUID().uuidString)",
|
|
createdAt: Date(),
|
|
role: .assistant,
|
|
content: "",
|
|
name: nil
|
|
)
|
|
]
|
|
isQuickQuestionSending = true
|
|
|
|
defer {
|
|
if quickQuestionRunID == runID {
|
|
quickQuestionTask = nil
|
|
quickQuestionRunID = nil
|
|
isQuickQuestionSending = false
|
|
}
|
|
}
|
|
|
|
let streamStatus = CompletionStreamStatus()
|
|
|
|
do {
|
|
try await client().runCompletionStream(
|
|
body: CompletionStreamRequest(
|
|
chatId: nil,
|
|
persist: false,
|
|
provider: provider,
|
|
model: model,
|
|
messages: [CompletionRequestMessage(role: .user, content: prompt)]
|
|
)
|
|
) { [weak self] event in
|
|
guard let self else { return }
|
|
await self.applyQuickQuestionCompletionEvent(event, streamStatus: streamStatus)
|
|
}
|
|
|
|
if let streamError = await streamStatus.error() {
|
|
throw APIError.httpError(statusCode: 502, message: streamError)
|
|
}
|
|
} catch {
|
|
guard quickQuestionRunID == runID else {
|
|
return
|
|
}
|
|
if isCancellation(error) {
|
|
return
|
|
}
|
|
|
|
quickQuestionError = normalizeAPIError(error)
|
|
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
|
|
}
|
|
}
|
|
|
|
private func applyQuickQuestionCompletionEvent(_ event: CompletionStreamEvent, streamStatus: CompletionStreamStatus) async {
|
|
switch event {
|
|
case .meta:
|
|
break
|
|
|
|
case let .toolCall(payload):
|
|
insertQuickQuestionToolCallMessage(payload)
|
|
|
|
case let .delta(payload):
|
|
guard !payload.text.isEmpty else { return }
|
|
mutateQuickQuestionAssistantMessage { existing in
|
|
existing + payload.text
|
|
}
|
|
|
|
case let .done(payload):
|
|
mutateQuickQuestionAssistantMessage { _ in
|
|
payload.text
|
|
}
|
|
|
|
case let .error(payload):
|
|
await streamStatus.setError(payload.message)
|
|
|
|
case .ignored:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func loadInitialData(using client: any SybilAPIClienting) async {
|
|
isLoadingCollections = true
|
|
errorMessage = nil
|
|
|
|
do {
|
|
async let workspaceItemsValue = client.listWorkspaceItems()
|
|
async let activeRunsValue = client.getActiveRuns()
|
|
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
|
|
|
applyWorkspaceItems(nextWorkspaceItems)
|
|
applyActiveRuns(nextActiveRuns)
|
|
|
|
SybilLog.info(
|
|
SybilLog.app,
|
|
"Loaded collections: \(chats.count) chats, \(searches.count) searches"
|
|
)
|
|
|
|
do {
|
|
let nextCatalog = try await client.listModels()
|
|
self.modelCatalog = nextCatalog.providers
|
|
syncModelSelectionWithServerCatalog()
|
|
SybilLog.info(SybilLog.app, "Loaded model catalog for \(nextCatalog.providers.count) providers")
|
|
} catch {
|
|
SybilLog.error(SybilLog.app, "Model catalog load failed, using provider fallback models", error: error)
|
|
errorMessage = "Loaded chats/searches, but failed to load models: \(normalizeAPIError(error))"
|
|
}
|
|
|
|
let nextSelection: SidebarSelection?
|
|
if case .settings = selectedItem {
|
|
nextSelection = .settings
|
|
} else if let currentSelection = selectedItem,
|
|
hasSelection(currentSelection, chats: chats, searches: searches) {
|
|
nextSelection = currentSelection
|
|
} else {
|
|
nextSelection = sidebarItems.first?.selection
|
|
}
|
|
|
|
selectedItem = nextSelection
|
|
if nextSelection == nil {
|
|
draftKind = .chat
|
|
}
|
|
|
|
if let nextSelection {
|
|
switch nextSelection {
|
|
case .settings:
|
|
selectedChat = nil
|
|
selectedSearch = nil
|
|
case .chat, .search:
|
|
await refreshSelectionIfNeeded()
|
|
}
|
|
}
|
|
attachToVisibleActiveRunIfNeeded()
|
|
} catch {
|
|
errorMessage = normalizeAPIError(error)
|
|
SybilLog.error(SybilLog.app, "Initial data load failed", error: error)
|
|
}
|
|
|
|
isLoadingCollections = false
|
|
}
|
|
|
|
private func syncModelSelectionWithServerCatalog() {
|
|
if !providerOptions.contains(provider), let firstProvider = providerOptions.first {
|
|
provider = firstProvider
|
|
settings.preferredProvider = firstProvider
|
|
}
|
|
|
|
if !providerModelOptions.contains(model), let first = providerModelOptions.first {
|
|
model = first
|
|
settings.preferredModelByProvider[provider] = first
|
|
}
|
|
|
|
if let preferred = settings.preferredModelByProvider[provider], providerModelOptions.contains(preferred) {
|
|
model = preferred
|
|
}
|
|
|
|
if !providerOptions.contains(quickQuestionProvider), let firstProvider = providerOptions.first {
|
|
quickQuestionProvider = firstProvider
|
|
settings.quickQuestionPreferredProvider = firstProvider
|
|
}
|
|
|
|
if !quickQuestionProviderModelOptions.contains(quickQuestionModel), let first = quickQuestionProviderModelOptions.first {
|
|
quickQuestionModel = first
|
|
settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = first
|
|
}
|
|
|
|
if let preferred = settings.quickQuestionPreferredModelByProvider[quickQuestionProvider],
|
|
quickQuestionProviderModelOptions.contains(preferred)
|
|
{
|
|
quickQuestionModel = preferred
|
|
}
|
|
|
|
settings.persist()
|
|
}
|
|
|
|
private func refreshCollections(
|
|
preferredSelection: SidebarSelection?,
|
|
refreshSelection: Bool = true,
|
|
attachVisibleActiveRun: Bool = true
|
|
) async {
|
|
isLoadingCollections = true
|
|
|
|
do {
|
|
let client = try client()
|
|
async let workspaceItemsValue = client.listWorkspaceItems()
|
|
async let activeRunsValue = client.getActiveRuns()
|
|
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
|
|
|
applyWorkspaceItems(nextWorkspaceItems)
|
|
applyActiveRuns(nextActiveRuns)
|
|
|
|
SybilLog.info(
|
|
SybilLog.app,
|
|
"Refreshed collections: \(chats.count) chats, \(searches.count) searches"
|
|
)
|
|
errorMessage = nil
|
|
|
|
if draftKind != nil {
|
|
if attachVisibleActiveRun {
|
|
attachToVisibleActiveRunIfNeeded()
|
|
}
|
|
isLoadingCollections = false
|
|
return
|
|
}
|
|
|
|
if case .settings = selectedItem {
|
|
isLoadingCollections = false
|
|
return
|
|
}
|
|
|
|
if let preferredSelection,
|
|
hasSelection(preferredSelection, chats: chats, searches: searches) {
|
|
selectedItem = preferredSelection
|
|
} else if let existing = selectedItem,
|
|
hasSelection(existing, chats: chats, searches: searches) {
|
|
selectedItem = existing
|
|
} else {
|
|
selectedItem = sidebarItems.first?.selection
|
|
}
|
|
|
|
if refreshSelection, selectedItem != nil {
|
|
await refreshSelectionIfNeeded()
|
|
}
|
|
if attachVisibleActiveRun {
|
|
attachToVisibleActiveRunIfNeeded()
|
|
}
|
|
} catch {
|
|
if isCancellation(error) {
|
|
SybilLog.debug(SybilLog.app, "Collection refresh cancelled")
|
|
} else if shouldSuppressInactiveTransportError(error) {
|
|
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
|
|
} else {
|
|
errorMessage = normalizeAPIError(error)
|
|
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
|
|
}
|
|
}
|
|
|
|
isLoadingCollections = false
|
|
}
|
|
|
|
private func startActiveRunPolling() {
|
|
activeRunPollingTask?.cancel()
|
|
activeRunPollingTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(3))
|
|
guard !Task.isCancelled else {
|
|
return
|
|
}
|
|
guard let self else {
|
|
return
|
|
}
|
|
await self.refreshActiveRunsFromServer()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func stopActiveRunPolling() {
|
|
activeRunPollingTask?.cancel()
|
|
activeRunPollingTask = nil
|
|
}
|
|
|
|
private func cancelActiveStreamAttachTasks() {
|
|
for task in activeChatAttachTasks.values {
|
|
task.cancel()
|
|
}
|
|
for task in activeSearchAttachTasks.values {
|
|
task.cancel()
|
|
}
|
|
activeChatAttachTasks = [:]
|
|
activeSearchAttachTasks = [:]
|
|
}
|
|
|
|
private func refreshActiveRunsFromServer(
|
|
using providedClient: (any SybilAPIClienting)? = nil,
|
|
attachVisibleActiveRun: Bool = true
|
|
) async {
|
|
guard isAuthenticated, !isCheckingSession else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
let apiClient: any SybilAPIClienting
|
|
if let providedClient {
|
|
apiClient = providedClient
|
|
} else {
|
|
apiClient = try client()
|
|
}
|
|
|
|
let activeRuns = try await apiClient.getActiveRuns()
|
|
applyActiveRuns(activeRuns)
|
|
|
|
if attachVisibleActiveRun {
|
|
attachToVisibleActiveRunIfNeeded()
|
|
}
|
|
} catch {
|
|
if shouldSuppressInactiveTransportError(error) {
|
|
SybilLog.info(SybilLog.app, "Suppressing active-run refresh transport interruption while app is inactive")
|
|
} else {
|
|
SybilLog.warning(SybilLog.app, "Active-run refresh failed: \(SybilLog.describe(error))")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func applyActiveRuns(_ activeRuns: ActiveRunsResponse) {
|
|
serverActiveChatIDs = Set(activeRuns.chats)
|
|
serverActiveSearchIDs = Set(activeRuns.searches)
|
|
}
|
|
|
|
private func applyWorkspaceItems(_ items: [WorkspaceItem]) {
|
|
workspaceItems = items
|
|
chats = items.compactMap(\.chatSummary)
|
|
searches = items.compactMap(\.searchSummary)
|
|
}
|
|
|
|
private func applyChatSummary(_ chat: ChatSummary, moveToFront: Bool) {
|
|
if let existingIndex = chats.firstIndex(where: { $0.id == chat.id }) {
|
|
chats.remove(at: existingIndex)
|
|
chats.insert(chat, at: moveToFront ? 0 : existingIndex)
|
|
} else {
|
|
chats.insert(chat, at: 0)
|
|
}
|
|
|
|
upsertWorkspaceChat(chat, moveToFront: moveToFront)
|
|
|
|
if selectedChat?.id == chat.id {
|
|
selectedChat?.title = chat.title
|
|
selectedChat?.updatedAt = chat.updatedAt
|
|
selectedChat?.starred = chat.starred
|
|
selectedChat?.starredAt = chat.starredAt
|
|
selectedChat?.initiatedProvider = chat.initiatedProvider
|
|
selectedChat?.initiatedModel = chat.initiatedModel
|
|
selectedChat?.lastUsedProvider = chat.lastUsedProvider
|
|
selectedChat?.lastUsedModel = chat.lastUsedModel
|
|
}
|
|
}
|
|
|
|
private func applySearchSummary(_ search: SearchSummary, moveToFront: Bool) {
|
|
if let existingIndex = searches.firstIndex(where: { $0.id == search.id }) {
|
|
searches.remove(at: existingIndex)
|
|
searches.insert(search, at: moveToFront ? 0 : existingIndex)
|
|
} else {
|
|
searches.insert(search, at: 0)
|
|
}
|
|
|
|
upsertWorkspaceSearch(search, moveToFront: moveToFront)
|
|
|
|
if selectedSearch?.id == search.id {
|
|
selectedSearch?.title = search.title
|
|
selectedSearch?.query = search.query
|
|
selectedSearch?.updatedAt = search.updatedAt
|
|
selectedSearch?.starred = search.starred
|
|
selectedSearch?.starredAt = search.starredAt
|
|
}
|
|
}
|
|
|
|
private func upsertWorkspaceChat(_ chat: ChatSummary, moveToFront: Bool = true) {
|
|
upsertWorkspaceItem(WorkspaceItem(chat: chat), moveToFront: moveToFront)
|
|
}
|
|
|
|
private func upsertWorkspaceSearch(_ search: SearchSummary, moveToFront: Bool = true) {
|
|
upsertWorkspaceItem(WorkspaceItem(search: search), moveToFront: moveToFront)
|
|
}
|
|
|
|
private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
|
|
if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
|
|
workspaceItems.remove(at: existingIndex)
|
|
if moveToFront {
|
|
workspaceItems.insert(item, at: 0)
|
|
} else {
|
|
workspaceItems.insert(item, at: existingIndex)
|
|
}
|
|
return
|
|
}
|
|
|
|
workspaceItems.insert(item, at: 0)
|
|
}
|
|
|
|
private func attachToVisibleActiveRunIfNeeded() {
|
|
guard draftKind == nil else {
|
|
return
|
|
}
|
|
|
|
switch selectedItem {
|
|
case let .chat(chatID):
|
|
guard serverActiveChatIDs.contains(chatID),
|
|
!localActiveChatIDs.contains(chatID),
|
|
activeChatAttachTasks[chatID] == nil else {
|
|
return
|
|
}
|
|
|
|
activeChatAttachTasks[chatID] = Task { [weak self] in
|
|
await self?.attachToActiveChatStream(chatID: chatID)
|
|
}
|
|
|
|
case let .search(searchID):
|
|
guard serverActiveSearchIDs.contains(searchID),
|
|
!localActiveSearchIDs.contains(searchID),
|
|
activeSearchAttachTasks[searchID] == nil else {
|
|
return
|
|
}
|
|
|
|
activeSearchAttachTasks[searchID] = Task { [weak self] in
|
|
await self?.attachToActiveSearchStream(searchID: searchID)
|
|
}
|
|
|
|
case .settings, nil:
|
|
return
|
|
}
|
|
}
|
|
|
|
private func attachToActiveChatStream(chatID: String) async {
|
|
defer {
|
|
activeChatAttachTasks[chatID] = nil
|
|
}
|
|
|
|
let selection = SidebarSelection.chat(chatID)
|
|
|
|
do {
|
|
let client = try client()
|
|
|
|
if pendingChatStates[chatID] == nil {
|
|
let baseChat: ChatDetail
|
|
if let currentChat = currentSelectedChat, currentChat.id == chatID {
|
|
baseChat = currentChat
|
|
} else {
|
|
baseChat = try await client.getChat(chatID: chatID)
|
|
if selectedItem == selection, draftKind == nil {
|
|
selectedChat = baseChat
|
|
selectedSearch = nil
|
|
}
|
|
}
|
|
|
|
pendingChatStates[chatID] = PendingChatState(
|
|
chatID: chatID,
|
|
messages: baseChat.messages + [
|
|
Message(
|
|
id: "temp-assistant-attach-\(UUID().uuidString)",
|
|
createdAt: Date(),
|
|
role: .assistant,
|
|
content: "",
|
|
name: nil
|
|
)
|
|
]
|
|
)
|
|
}
|
|
|
|
let streamStatus = CompletionStreamStatus()
|
|
try await client.attachCompletionStream(chatID: chatID) { [weak self] event in
|
|
guard let self else { return }
|
|
await self.applyCompletionEvent(event, chatID: chatID, streamStatus: streamStatus)
|
|
}
|
|
|
|
if let streamError = await streamStatus.error() {
|
|
throw APIError.httpError(statusCode: 502, message: streamError)
|
|
}
|
|
|
|
serverActiveChatIDs.remove(chatID)
|
|
pendingChatStates[chatID] = nil
|
|
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
|
|
|
|
if selectedItem == selection, draftKind == nil {
|
|
selectedChat = try await client.getChat(chatID: chatID)
|
|
selectedSearch = nil
|
|
}
|
|
} catch {
|
|
serverActiveChatIDs.remove(chatID)
|
|
pendingChatStates[chatID] = nil
|
|
|
|
if isCancellation(error) {
|
|
return
|
|
}
|
|
|
|
if isActiveStreamNotFound(error) {
|
|
SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
|
|
} else if shouldSuppressInactiveTransportError(error) {
|
|
SybilLog.info(SybilLog.app, "Suppressing active chat stream transport interruption while app is inactive")
|
|
} else {
|
|
if selectedItem == selection, draftKind == nil {
|
|
errorMessage = normalizeAPIError(error)
|
|
}
|
|
SybilLog.error(SybilLog.app, "Active chat stream attach failed", error: error)
|
|
}
|
|
|
|
if selectedItem == selection, draftKind == nil {
|
|
do {
|
|
selectedChat = try await client().getChat(chatID: chatID)
|
|
selectedSearch = nil
|
|
} catch {
|
|
SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
|
|
}
|
|
}
|
|
|
|
await refreshActiveRunsFromServer(attachVisibleActiveRun: false)
|
|
}
|
|
}
|
|
|
|
private func attachToActiveSearchStream(searchID: String) async {
|
|
defer {
|
|
activeSearchAttachTasks[searchID] = nil
|
|
}
|
|
|
|
let selection = SidebarSelection.search(searchID)
|
|
|
|
do {
|
|
let client = try client()
|
|
|
|
if currentSelectedSearch?.id != searchID {
|
|
let search = try await client.getSearch(searchID: searchID)
|
|
activeSearchDetails[searchID] = search
|
|
if selectedItem == selection, draftKind == nil {
|
|
selectedSearch = search
|
|
selectedChat = nil
|
|
}
|
|
} else if let currentSearch = currentSelectedSearch {
|
|
activeSearchDetails[searchID] = currentSearch
|
|
}
|
|
|
|
let streamStatus = SearchStreamStatus()
|
|
try await client.attachSearchStream(searchID: searchID) { [weak self] event in
|
|
guard let self else { return }
|
|
await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
|
|
}
|
|
|
|
if let streamError = await streamStatus.error() {
|
|
throw APIError.httpError(statusCode: 502, message: streamError)
|
|
}
|
|
|
|
serverActiveSearchIDs.remove(searchID)
|
|
activeSearchDetails[searchID] = nil
|
|
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
|
|
|
|
if selectedItem == selection, draftKind == nil {
|
|
selectedSearch = try await client.getSearch(searchID: searchID)
|
|
selectedChat = nil
|
|
}
|
|
} catch {
|
|
serverActiveSearchIDs.remove(searchID)
|
|
activeSearchDetails[searchID] = nil
|
|
|
|
if isCancellation(error) {
|
|
return
|
|
}
|
|
|
|
if isActiveStreamNotFound(error) {
|
|
SybilLog.info(SybilLog.app, "Active search stream \(searchID) no longer exists")
|
|
} else if shouldSuppressInactiveTransportError(error) {
|
|
SybilLog.info(SybilLog.app, "Suppressing active search stream transport interruption while app is inactive")
|
|
} else {
|
|
if selectedItem == selection, draftKind == nil {
|
|
errorMessage = normalizeAPIError(error)
|
|
}
|
|
SybilLog.error(SybilLog.app, "Active search stream attach failed", error: error)
|
|
}
|
|
|
|
if selectedItem == selection, draftKind == nil {
|
|
do {
|
|
selectedSearch = try await client().getSearch(searchID: searchID)
|
|
selectedChat = nil
|
|
} catch {
|
|
SybilLog.warning(SybilLog.app, "Search refresh after attach failure failed: \(SybilLog.describe(error))")
|
|
}
|
|
}
|
|
|
|
await refreshActiveRunsFromServer(attachVisibleActiveRun: false)
|
|
}
|
|
}
|
|
|
|
private func resetSelectionLoading() {
|
|
selectionTask?.cancel()
|
|
selectionTask = nil
|
|
isLoadingSelection = false
|
|
}
|
|
|
|
private func startSelectionRefreshTask() -> Task<Void, Never> {
|
|
isLoadingSelection = true
|
|
let task = Task { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
await self.refreshSelectionIfNeeded()
|
|
}
|
|
selectionTask = task
|
|
return task
|
|
}
|
|
|
|
private func waitForSelectionLoad(timeout: Duration) async {
|
|
let clock = ContinuousClock()
|
|
let deadline = clock.now.advanced(by: timeout)
|
|
|
|
while isLoadingSelection, clock.now < deadline {
|
|
try? await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
}
|
|
|
|
private func needsSelectionLoad(_ selection: SidebarSelection) -> Bool {
|
|
switch selection {
|
|
case let .chat(chatID):
|
|
return selectedChat?.id != chatID
|
|
case let .search(searchID):
|
|
return selectedSearch?.id != searchID
|
|
case .settings:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func refreshSelectionIfNeeded() async {
|
|
guard let target = selectedItem else {
|
|
selectedChat = nil
|
|
selectedSearch = nil
|
|
isLoadingSelection = false
|
|
return
|
|
}
|
|
|
|
guard case .settings = target else {
|
|
isLoadingSelection = true
|
|
do {
|
|
let client = try client()
|
|
switch target {
|
|
case let .chat(chatID):
|
|
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
|
|
let chat = try await client.getChat(chatID: chatID)
|
|
guard selectedItem == target, draftKind == nil else {
|
|
return
|
|
}
|
|
selectedChat = chat
|
|
selectedSearch = nil
|
|
|
|
if let provider = chat.lastUsedProvider,
|
|
let model = chat.lastUsedModel,
|
|
!model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
self.provider = provider
|
|
self.model = model
|
|
}
|
|
|
|
case let .search(searchID):
|
|
SybilLog.debug(SybilLog.app, "Refreshing search \(searchID)")
|
|
let search = try await client.getSearch(searchID: searchID)
|
|
guard selectedItem == target, draftKind == nil else {
|
|
return
|
|
}
|
|
selectedSearch = search
|
|
selectedChat = nil
|
|
|
|
case .settings:
|
|
break
|
|
}
|
|
errorMessage = nil
|
|
} catch {
|
|
if isCancellation(error) {
|
|
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(target.id)")
|
|
} else if shouldSuppressInactiveTransportError(error) {
|
|
SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive")
|
|
} else if selectedItem == target, draftKind == nil {
|
|
errorMessage = normalizeAPIError(error)
|
|
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
|
|
}
|
|
}
|
|
if selectedItem == target, draftKind == nil {
|
|
isLoadingSelection = false
|
|
selectionTask = nil
|
|
attachToVisibleActiveRunIfNeeded()
|
|
}
|
|
return
|
|
}
|
|
|
|
selectedChat = nil
|
|
selectedSearch = nil
|
|
isLoadingSelection = false
|
|
}
|
|
|
|
private func sendChat(content: String, attachments: [ChatAttachment], sendContext: ActiveSendContext) async throws {
|
|
let optimisticUser = Message(
|
|
id: "temp-user-\(UUID().uuidString)",
|
|
createdAt: Date(),
|
|
role: .user,
|
|
content: content,
|
|
name: nil,
|
|
metadata: SybilChatAttachmentSupport.metadataValue(for: attachments)
|
|
)
|
|
|
|
let optimisticAssistant = Message(
|
|
id: "temp-assistant-\(UUID().uuidString)",
|
|
createdAt: Date(),
|
|
role: .assistant,
|
|
content: "",
|
|
name: nil
|
|
)
|
|
|
|
let optimisticMessages = (currentSelectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]
|
|
|
|
let client = try client()
|
|
|
|
var chatID = currentChatID
|
|
if let chatID {
|
|
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
|
|
} else {
|
|
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
|
}
|
|
|
|
if chatID == nil {
|
|
let created = try await client.createChat(title: nil)
|
|
chatID = created.id
|
|
let shouldShowCreatedChat = isSendContextVisible(sendContext)
|
|
markSendContextInactive(sendContext)
|
|
localActiveChatIDs.insert(created.id)
|
|
|
|
chats.removeAll(where: { $0.id == created.id })
|
|
chats.insert(created, at: 0)
|
|
upsertWorkspaceChat(created)
|
|
|
|
if shouldShowCreatedChat {
|
|
draftKind = nil
|
|
selectedItem = .chat(created.id)
|
|
|
|
selectedChat = ChatDetail(
|
|
id: created.id,
|
|
title: created.title,
|
|
createdAt: created.createdAt,
|
|
updatedAt: created.updatedAt,
|
|
starred: created.starred,
|
|
starredAt: created.starredAt,
|
|
initiatedProvider: created.initiatedProvider,
|
|
initiatedModel: created.initiatedModel,
|
|
lastUsedProvider: created.lastUsedProvider,
|
|
lastUsedModel: created.lastUsedModel,
|
|
messages: []
|
|
)
|
|
selectedSearch = nil
|
|
}
|
|
|
|
SybilLog.info(SybilLog.app, "Created chat \(created.id)")
|
|
}
|
|
|
|
guard let chatID else {
|
|
throw APIError.invalidResponse
|
|
}
|
|
|
|
localActiveChatIDs.insert(chatID)
|
|
defer {
|
|
localActiveChatIDs.remove(chatID)
|
|
}
|
|
|
|
if let draftPending = pendingDraftChatState {
|
|
pendingDraftChatState = nil
|
|
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages)
|
|
} else if pendingChatStates[chatID] == nil {
|
|
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
|
|
} else {
|
|
pendingChatStates[chatID]?.chatID = chatID
|
|
}
|
|
|
|
let baseChat: ChatDetail
|
|
if let selectedChat = currentSelectedChat, selectedChat.id == chatID {
|
|
baseChat = selectedChat
|
|
} else {
|
|
baseChat = try await client.getChat(chatID: chatID)
|
|
}
|
|
|
|
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !selectedModel.isEmpty else {
|
|
throw APIError.invalidResponse
|
|
}
|
|
|
|
let requestMessages: [CompletionRequestMessage] =
|
|
baseChat.messages
|
|
.filter { !$0.isToolCallLog }
|
|
.map {
|
|
CompletionRequestMessage(role: $0.role, content: $0.content, name: $0.name, attachments: $0.attachments.isEmpty ? nil : $0.attachments)
|
|
} + [CompletionRequestMessage(role: .user, content: content, attachments: attachments.isEmpty ? nil : attachments)]
|
|
|
|
let streamStatus = CompletionStreamStatus()
|
|
let streamLifecycleGeneration = appLifecycleGeneration
|
|
let streamStartedWhileInactive = !isAppActive
|
|
|
|
if isUntitledChat(chatID: chatID, detail: currentSelectedChat) {
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
do {
|
|
let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
|
|
let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
|
|
await MainActor.run {
|
|
self.applyChatSummary(updated, moveToFront: false)
|
|
}
|
|
} catch {
|
|
SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))")
|
|
}
|
|
}
|
|
}
|
|
|
|
let chatBackgroundTask = SybilBackgroundTaskAssertion(name: "Sybil Chat Response") {
|
|
SybilLog.warning(SybilLog.app, "Chat response background time expired")
|
|
}
|
|
defer {
|
|
chatBackgroundTask?.end()
|
|
}
|
|
|
|
do {
|
|
try await client.runCompletionStream(
|
|
body: CompletionStreamRequest(
|
|
chatId: chatID,
|
|
provider: provider,
|
|
model: selectedModel,
|
|
messages: requestMessages
|
|
)
|
|
) { [weak self] event in
|
|
guard let self else { return }
|
|
await self.applyCompletionEvent(event, chatID: chatID, streamStatus: streamStatus)
|
|
}
|
|
} catch {
|
|
if shouldSuppressLifecycleTransportError(
|
|
error,
|
|
startedAt: streamLifecycleGeneration,
|
|
startedWhileInactive: streamStartedWhileInactive
|
|
) {
|
|
SybilLog.info(SybilLog.app, "Suppressing chat stream transport interruption after app lifecycle change")
|
|
pendingChatStates[chatID] = nil
|
|
if isAppActive {
|
|
await refreshInterruptedStream(preferredSelection: .chat(chatID))
|
|
}
|
|
return
|
|
}
|
|
|
|
throw error
|
|
}
|
|
|
|
if let streamError = await streamStatus.error() {
|
|
throw APIError.httpError(statusCode: 502, message: streamError)
|
|
}
|
|
|
|
guard isAppActive else {
|
|
pendingChatStates[chatID] = nil
|
|
return
|
|
}
|
|
|
|
let sentChatSelection = SidebarSelection.chat(chatID)
|
|
let shouldKeepSentChatSelected = selectedItem == sentChatSelection && draftKind == nil
|
|
await refreshCollections(
|
|
preferredSelection: shouldKeepSentChatSelected ? sentChatSelection : selectedItem,
|
|
refreshSelection: false
|
|
)
|
|
|
|
guard selectedItem == sentChatSelection, draftKind == nil else {
|
|
pendingChatStates[chatID] = nil
|
|
return
|
|
}
|
|
|
|
do {
|
|
let refreshedChat = try await client.getChat(chatID: chatID)
|
|
guard selectedItem == sentChatSelection, draftKind == nil else {
|
|
pendingChatStates[chatID] = nil
|
|
return
|
|
}
|
|
selectedChat = refreshedChat
|
|
} catch {
|
|
if shouldSuppressLifecycleTransportError(
|
|
error,
|
|
startedAt: streamLifecycleGeneration,
|
|
startedWhileInactive: streamStartedWhileInactive
|
|
) {
|
|
SybilLog.info(SybilLog.app, "Suppressing chat refresh transport interruption after app lifecycle change")
|
|
pendingChatStates[chatID] = nil
|
|
if isAppActive {
|
|
await refreshInterruptedStream(preferredSelection: .chat(chatID))
|
|
}
|
|
return
|
|
}
|
|
|
|
throw error
|
|
}
|
|
pendingChatStates[chatID] = nil
|
|
}
|
|
|
|
private func applyCompletionEvent(_ event: CompletionStreamEvent, chatID: String, streamStatus: CompletionStreamStatus) async {
|
|
switch event {
|
|
case let .meta(payload):
|
|
if payload.chatId == chatID {
|
|
pendingChatStates[chatID]?.chatID = payload.chatId
|
|
}
|
|
|
|
case let .toolCall(payload):
|
|
insertPendingToolCallMessage(payload, chatID: chatID)
|
|
|
|
case let .delta(payload):
|
|
guard !payload.text.isEmpty else { return }
|
|
mutatePendingAssistantMessage(chatID: chatID) { existing in
|
|
existing + payload.text
|
|
}
|
|
|
|
case let .done(payload):
|
|
mutatePendingAssistantMessage(chatID: chatID) { _ in
|
|
payload.text
|
|
}
|
|
|
|
case let .error(payload):
|
|
await streamStatus.setError(payload.message)
|
|
|
|
case .ignored:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func sendSearch(query: String, sendContext: ActiveSendContext) async throws {
|
|
let client = try client()
|
|
|
|
var searchID = currentSearchID
|
|
if searchID == nil {
|
|
let created = try await client.createSearch(title: String(query.prefix(80)), query: query)
|
|
searchID = created.id
|
|
let shouldShowCreatedSearch = isSendContextVisible(sendContext)
|
|
markSendContextInactive(sendContext)
|
|
localActiveSearchIDs.insert(created.id)
|
|
|
|
searches.removeAll(where: { $0.id == created.id })
|
|
searches.insert(created, at: 0)
|
|
upsertWorkspaceSearch(created)
|
|
|
|
if shouldShowCreatedSearch {
|
|
draftKind = nil
|
|
selectedItem = .search(created.id)
|
|
}
|
|
|
|
SybilLog.info(SybilLog.app, "Created search \(created.id)")
|
|
}
|
|
|
|
guard let searchID else {
|
|
throw APIError.invalidResponse
|
|
}
|
|
|
|
localActiveSearchIDs.insert(searchID)
|
|
defer {
|
|
localActiveSearchIDs.remove(searchID)
|
|
}
|
|
|
|
let now = Date()
|
|
let optimisticSearch = SearchDetail(
|
|
id: searchID,
|
|
title: String(query.prefix(80)),
|
|
query: query,
|
|
createdAt: currentSelectedSearch?.createdAt ?? now,
|
|
updatedAt: now,
|
|
starred: currentSelectedSearch?.starred ?? false,
|
|
starredAt: currentSelectedSearch?.starredAt,
|
|
requestId: nil,
|
|
latencyMs: nil,
|
|
error: nil,
|
|
answerText: nil,
|
|
answerRequestId: nil,
|
|
answerCitations: nil,
|
|
answerError: nil,
|
|
results: []
|
|
)
|
|
activeSearchDetails[searchID] = optimisticSearch
|
|
|
|
if selectedItem == .search(searchID), draftKind == nil {
|
|
selectedSearch = optimisticSearch
|
|
}
|
|
|
|
let streamStatus = SearchStreamStatus()
|
|
let streamLifecycleGeneration = appLifecycleGeneration
|
|
let streamStartedWhileInactive = !isAppActive
|
|
|
|
do {
|
|
try await client.runSearchStream(
|
|
searchID: searchID,
|
|
body: SearchRunRequest(query: query, title: String(query.prefix(80)), type: "auto", numResults: 10)
|
|
) { [weak self] event in
|
|
guard let self else { return }
|
|
await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
|
|
}
|
|
} catch {
|
|
if shouldSuppressLifecycleTransportError(
|
|
error,
|
|
startedAt: streamLifecycleGeneration,
|
|
startedWhileInactive: streamStartedWhileInactive
|
|
) {
|
|
SybilLog.info(SybilLog.app, "Suppressing search stream transport interruption after app lifecycle change")
|
|
activeSearchDetails[searchID] = nil
|
|
if isAppActive {
|
|
await refreshInterruptedStream(preferredSelection: .search(searchID))
|
|
}
|
|
return
|
|
}
|
|
|
|
activeSearchDetails[searchID] = nil
|
|
throw error
|
|
}
|
|
|
|
if let streamError = await streamStatus.error() {
|
|
activeSearchDetails[searchID] = nil
|
|
throw APIError.httpError(statusCode: 502, message: streamError)
|
|
}
|
|
|
|
guard isAppActive else {
|
|
activeSearchDetails[searchID] = nil
|
|
return
|
|
}
|
|
|
|
let sentSearchSelection = SidebarSelection.search(searchID)
|
|
let shouldKeepSentSearchSelected = selectedItem == sentSearchSelection && draftKind == nil
|
|
await refreshCollections(
|
|
preferredSelection: shouldKeepSentSearchSelected ? sentSearchSelection : selectedItem,
|
|
refreshSelection: false
|
|
)
|
|
activeSearchDetails[searchID] = nil
|
|
}
|
|
|
|
private func applySearchEvent(
|
|
_ event: SearchStreamEvent,
|
|
searchID: String,
|
|
streamStatus: SearchStreamStatus
|
|
) async {
|
|
switch event {
|
|
case let .searchResults(payload):
|
|
guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else {
|
|
return
|
|
}
|
|
search.requestId = payload.requestId ?? search.requestId
|
|
search.error = nil
|
|
search.results = payload.results
|
|
setActiveSearch(search, searchID: searchID)
|
|
|
|
case let .searchError(payload):
|
|
guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else {
|
|
return
|
|
}
|
|
search.error = payload.error
|
|
setActiveSearch(search, searchID: searchID)
|
|
|
|
case let .answer(payload):
|
|
guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else {
|
|
return
|
|
}
|
|
search.answerText = payload.answerText
|
|
search.answerRequestId = payload.answerRequestId
|
|
search.answerCitations = payload.answerCitations
|
|
search.answerError = nil
|
|
setActiveSearch(search, searchID: searchID)
|
|
|
|
case let .answerError(payload):
|
|
guard var search = activeSearchDetails[searchID] ?? matchingSelectedSearch(searchID) else {
|
|
return
|
|
}
|
|
search.answerError = payload.error
|
|
setActiveSearch(search, searchID: searchID)
|
|
|
|
case let .done(payload):
|
|
setActiveSearch(payload.search, searchID: searchID)
|
|
|
|
case let .error(payload):
|
|
await streamStatus.setError(payload.message)
|
|
|
|
case .ignored:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func matchingSelectedSearch(_ searchID: String) -> SearchDetail? {
|
|
guard let current = currentSelectedSearch, current.id == searchID else {
|
|
return nil
|
|
}
|
|
return current
|
|
}
|
|
|
|
private func setActiveSearch(_ search: SearchDetail, searchID: String) {
|
|
activeSearchDetails[searchID] = search
|
|
if selectedItem == .search(searchID), draftKind == nil {
|
|
selectedSearch = search
|
|
selectedChat = nil
|
|
}
|
|
}
|
|
|
|
private func mutatePendingAssistantMessage(chatID: String, _ transform: (String) -> String) {
|
|
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
|
return
|
|
}
|
|
|
|
let index = pending.messages.indices.last { pending.messages[$0].id.hasPrefix("temp-assistant-") }
|
|
guard let index else {
|
|
return
|
|
}
|
|
|
|
var message = pending.messages[index]
|
|
message.content = transform(message.content)
|
|
pending.messages[index] = message
|
|
pendingChatStates[chatID] = pending
|
|
}
|
|
|
|
private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
|
|
let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
|
|
guard let index else {
|
|
return
|
|
}
|
|
|
|
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
|
|
}
|
|
|
|
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
|
guard var pending = pendingChatStates[chatID] else {
|
|
return
|
|
}
|
|
|
|
if pending.messages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
|
return
|
|
}
|
|
|
|
let message = toolCallMessage(for: payload)
|
|
|
|
if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) {
|
|
pending.messages.insert(message, at: assistantIndex)
|
|
} else {
|
|
pending.messages.append(message)
|
|
}
|
|
|
|
pendingChatStates[chatID] = pending
|
|
}
|
|
|
|
private func insertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
|
if quickQuestionMessages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
|
return
|
|
}
|
|
|
|
let message = toolCallMessage(for: payload)
|
|
if let assistantIndex = quickQuestionMessages.indices.last(where: { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }) {
|
|
quickQuestionMessages.insert(message, at: assistantIndex)
|
|
} else {
|
|
quickQuestionMessages.append(message)
|
|
}
|
|
}
|
|
|
|
private func toolCallMessage(for payload: CompletionStreamToolCall) -> Message {
|
|
let metadata: JSONValue = .object([
|
|
"kind": .string("tool_call"),
|
|
"toolCallId": .string(payload.toolCallId),
|
|
"toolName": .string(payload.name),
|
|
"status": .string(payload.status),
|
|
"summary": .string(payload.summary),
|
|
"args": .object(payload.args),
|
|
"startedAt": .string(payload.startedAt),
|
|
"completedAt": .string(payload.completedAt),
|
|
"durationMs": .number(Double(payload.durationMs)),
|
|
"error": payload.error.map { .string($0) } ?? .null,
|
|
"resultPreview": payload.resultPreview.map { .string($0) } ?? .null
|
|
])
|
|
|
|
let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
? "Ran tool '\(payload.name)'."
|
|
: payload.summary
|
|
|
|
return Message(
|
|
id: "temp-tool-\(payload.toolCallId)",
|
|
createdAt: Date(),
|
|
role: .tool,
|
|
content: summary,
|
|
name: payload.name,
|
|
metadata: metadata
|
|
)
|
|
}
|
|
|
|
private var currentChatID: String? {
|
|
if draftKind == .chat {
|
|
return nil
|
|
}
|
|
if case let .chat(chatID) = selectedItem {
|
|
return chatID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private var currentSelectedChat: ChatDetail? {
|
|
guard case let .chat(chatID) = selectedItem,
|
|
selectedChat?.id == chatID else {
|
|
return nil
|
|
}
|
|
return selectedChat
|
|
}
|
|
|
|
private var currentSelectedSearch: SearchDetail? {
|
|
guard case let .search(searchID) = selectedItem,
|
|
selectedSearch?.id == searchID else {
|
|
return nil
|
|
}
|
|
return selectedSearch
|
|
}
|
|
|
|
private var currentSearchID: String? {
|
|
if draftKind == .search {
|
|
return nil
|
|
}
|
|
if case let .search(searchID) = selectedItem {
|
|
return searchID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private var currentSendContext: ActiveSendContext {
|
|
if isSearchMode {
|
|
if let searchID = currentSearchID {
|
|
return .search(searchID)
|
|
}
|
|
return .draftSearch(draftIdentity)
|
|
}
|
|
|
|
if let chatID = currentChatID {
|
|
return .chat(chatID)
|
|
}
|
|
return .draftChat(draftIdentity)
|
|
}
|
|
|
|
private func isSendContextActive(_ context: ActiveSendContext) -> Bool {
|
|
switch context {
|
|
case let .draftChat(identity):
|
|
return activeDraftSendContexts.contains(.draftChat(identity))
|
|
case let .chat(chatID):
|
|
return activeChatIDs.contains(chatID)
|
|
case let .draftSearch(identity):
|
|
return activeDraftSendContexts.contains(.draftSearch(identity))
|
|
case let .search(searchID):
|
|
return activeSearchIDs.contains(searchID)
|
|
}
|
|
}
|
|
|
|
private func markSendContextActive(_ context: ActiveSendContext) {
|
|
switch context {
|
|
case .draftChat, .draftSearch:
|
|
activeDraftSendContexts.insert(context)
|
|
case let .chat(chatID):
|
|
localActiveChatIDs.insert(chatID)
|
|
case let .search(searchID):
|
|
localActiveSearchIDs.insert(searchID)
|
|
}
|
|
}
|
|
|
|
private func markSendContextInactive(_ context: ActiveSendContext) {
|
|
switch context {
|
|
case .draftChat, .draftSearch:
|
|
activeDraftSendContexts.remove(context)
|
|
case let .chat(chatID):
|
|
localActiveChatIDs.remove(chatID)
|
|
case let .search(searchID):
|
|
localActiveSearchIDs.remove(searchID)
|
|
}
|
|
}
|
|
|
|
private func clearPendingChatState(for context: ActiveSendContext) {
|
|
switch context {
|
|
case .draftChat:
|
|
pendingDraftChatState = nil
|
|
case let .chat(chatID):
|
|
pendingChatStates[chatID] = nil
|
|
case .draftSearch, .search:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func isSendContextVisible(_ context: ActiveSendContext) -> Bool {
|
|
switch context {
|
|
case let .draftChat(identity):
|
|
return draftKind == .chat && draftIdentity == identity
|
|
case let .chat(chatID):
|
|
return selectedItem == .chat(chatID)
|
|
case let .draftSearch(identity):
|
|
return draftKind == .search && draftIdentity == identity
|
|
case let .search(searchID):
|
|
return selectedItem == .search(searchID)
|
|
}
|
|
}
|
|
|
|
private func hasSelection(_ selection: SidebarSelection, chats: [ChatSummary], searches: [SearchSummary]) -> Bool {
|
|
switch selection {
|
|
case let .chat(chatID):
|
|
return chats.contains(where: { $0.id == chatID })
|
|
case let .search(searchID):
|
|
return searches.contains(where: { $0.id == searchID })
|
|
case .settings:
|
|
return true
|
|
}
|
|
}
|
|
|
|
private func displayableMessages(_ messages: [Message]) -> [Message] {
|
|
messages.filter { $0.role != .system }
|
|
}
|
|
|
|
private func chatTitle(title: String?, messages: [Message]?) -> String {
|
|
if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
|
return title
|
|
}
|
|
|
|
if let firstUserMessage = messages?.first(where: { $0.role == .user })?.content.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!firstUserMessage.isEmpty {
|
|
return String(firstUserMessage.prefix(48))
|
|
}
|
|
|
|
if let firstUserMessage = messages?.first(where: { $0.role == .user }) {
|
|
let attachmentSummary = SybilChatAttachmentSupport.attachmentSummary(firstUserMessage.attachments)
|
|
if !attachmentSummary.isEmpty {
|
|
return String(attachmentSummary.prefix(48))
|
|
}
|
|
}
|
|
|
|
return "New chat"
|
|
}
|
|
|
|
private func searchTitle(title: String?, query: String?) -> String {
|
|
if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
|
return title
|
|
}
|
|
|
|
if let query = query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty {
|
|
return String(query.prefix(64))
|
|
}
|
|
|
|
return "New search"
|
|
}
|
|
|
|
private func normalizeAuthError(_ error: Error) -> String {
|
|
let normalized = normalizeAPIError(error)
|
|
if normalized.contains("missing bearer token") || normalized.contains("invalid bearer token") {
|
|
return "Authentication failed. Enter the ADMIN_TOKEN configured in server/.env."
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
private func normalizeAPIError(_ error: Error) -> String {
|
|
if let apiError = error as? APIError {
|
|
switch apiError {
|
|
case .invalidBaseURL:
|
|
return "Set a valid API URL in Settings."
|
|
|
|
case let .httpError(_, message):
|
|
return message
|
|
|
|
case let .networkError(message):
|
|
return appendLoopbackHintIfNeeded(to: message)
|
|
|
|
case let .decodingError(message):
|
|
return message
|
|
|
|
case .invalidResponse:
|
|
return "Unexpected server response."
|
|
|
|
case .noResponseStream:
|
|
return "No response stream from server."
|
|
}
|
|
}
|
|
|
|
if let decodingError = error as? DecodingError {
|
|
return "Failed to decode server response: \(SybilLog.describe(decodingError))"
|
|
}
|
|
|
|
if let urlError = error as? URLError {
|
|
let base = "Network error \(urlError.code.rawValue): \(urlError.localizedDescription)"
|
|
return appendLoopbackHintIfNeeded(to: base)
|
|
}
|
|
|
|
if error is CancellationError {
|
|
return "Request was cancelled."
|
|
}
|
|
|
|
return (error as NSError).localizedDescription
|
|
}
|
|
|
|
private func appendLoopbackHintIfNeeded(to message: String) -> String {
|
|
guard let baseURL = settings.normalizedAPIBaseURL,
|
|
let host = baseURL.host?.lowercased(),
|
|
host == "127.0.0.1" || host == "localhost" else {
|
|
return message
|
|
}
|
|
|
|
#if targetEnvironment(simulator)
|
|
return message
|
|
#else
|
|
return message + " On physical devices, localhost/127.0.0.1 points to the phone. Use your Mac's LAN IP in Settings."
|
|
#endif
|
|
}
|
|
|
|
private func refreshInterruptedStream(preferredSelection: SidebarSelection) async {
|
|
try? await Task.sleep(for: .milliseconds(150))
|
|
await refreshCollections(preferredSelection: preferredSelection)
|
|
}
|
|
|
|
private func shouldSuppressLifecycleTransportError(
|
|
_ error: Error,
|
|
startedAt generation: Int,
|
|
startedWhileInactive: Bool
|
|
) -> Bool {
|
|
guard generation != appLifecycleGeneration || startedWhileInactive || !isAppActive else {
|
|
return false
|
|
}
|
|
|
|
return isTransientTransportInterruption(error)
|
|
}
|
|
|
|
private func shouldSuppressInactiveTransportError(_ error: Error) -> Bool {
|
|
!isAppActive && isTransientTransportInterruption(error)
|
|
}
|
|
|
|
private func isTransientTransportInterruption(_ error: Error) -> Bool {
|
|
if isCancellation(error) {
|
|
return true
|
|
}
|
|
|
|
if let apiError = error as? APIError,
|
|
case let .networkError(message) = apiError {
|
|
let lowercased = message.lowercased()
|
|
return lowercased.contains("network error -999")
|
|
|| lowercased.contains("network error -1005")
|
|
|| lowercased.contains("network connection was lost")
|
|
|| lowercased.contains("software caused connection abort")
|
|
|| lowercased.contains("socket is not connected")
|
|
}
|
|
|
|
let nsError = error as NSError
|
|
if nsError.domain == NSURLErrorDomain {
|
|
return nsError.code == URLError.cancelled.rawValue
|
|
|| nsError.code == URLError.networkConnectionLost.rawValue
|
|
|| nsError.code == URLError.notConnectedToInternet.rawValue
|
|
|| nsError.code == URLError.timedOut.rawValue
|
|
}
|
|
|
|
if nsError.domain == NSPOSIXErrorDomain {
|
|
return nsError.code == 53 || nsError.code == 57
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private func isActiveStreamNotFound(_ error: Error) -> Bool {
|
|
if let apiError = error as? APIError,
|
|
case let .httpError(statusCode, _) = apiError {
|
|
return statusCode == 404
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func isCancellation(_ error: Error) -> Bool {
|
|
if error is CancellationError {
|
|
return true
|
|
}
|
|
if let urlError = error as? URLError, urlError.code == .cancelled {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func client() throws -> any SybilAPIClienting {
|
|
guard let baseURL = settings.normalizedAPIBaseURL else {
|
|
throw APIError.invalidBaseURL
|
|
}
|
|
|
|
SybilLog.debug(
|
|
SybilLog.app,
|
|
"Creating API client for \(baseURL.absoluteString) (token: \(settings.trimmedTokenOrNil == nil ? "none" : "set"))"
|
|
)
|
|
|
|
return clientFactory(
|
|
APIConfiguration(
|
|
baseURL: baseURL,
|
|
authToken: settings.trimmedTokenOrNil
|
|
)
|
|
)
|
|
}
|
|
|
|
private func isUntitledChat(chatID: String, detail: ChatDetail?) -> Bool {
|
|
if let detail, detail.id == chatID {
|
|
if let title = detail.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
if let summary = chats.first(where: { $0.id == chatID }) {
|
|
if let title = summary.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|