2026-02-20 00:09:02 -08:00
|
|
|
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 initiatedLabel: String?
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct PendingChatState {
|
|
|
|
|
var chatID: String?
|
|
|
|
|
var messages: [Message]
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
private enum ActiveSendContext: Equatable {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
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 selectedItem: SidebarSelection?
|
|
|
|
|
var selectedChat: ChatDetail?
|
|
|
|
|
var selectedSearch: SearchDetail?
|
|
|
|
|
var draftKind: DraftKind?
|
|
|
|
|
|
|
|
|
|
var isLoadingCollections = false
|
|
|
|
|
var isLoadingSelection = false
|
|
|
|
|
var isSending = false
|
2026-05-02 16:48:01 -07:00
|
|
|
var isCreatingSearchChat = false
|
2026-02-20 00:09:02 -08:00
|
|
|
var errorMessage: String?
|
|
|
|
|
|
|
|
|
|
var composer = ""
|
2026-05-02 19:47:38 -07:00
|
|
|
var composerAttachments: [ChatAttachment] = []
|
2026-02-20 00:09:02 -08:00
|
|
|
var provider: Provider
|
|
|
|
|
var modelCatalog: [Provider: ProviderModelInfo] = [:]
|
|
|
|
|
var model: String
|
|
|
|
|
|
2026-05-02 22:18:33 -07:00
|
|
|
@ObservationIgnored
|
2026-02-20 00:09:02 -08:00
|
|
|
private var hasBootstrapped = false
|
|
|
|
|
private var pendingChatState: PendingChatState?
|
2026-05-03 21:42:28 -07:00
|
|
|
private var activeSendContext: ActiveSendContext?
|
|
|
|
|
private var draftIdentity = UUID()
|
2026-05-02 22:18:33 -07:00
|
|
|
@ObservationIgnored
|
2026-02-20 00:09:02 -08:00
|
|
|
private var selectionTask: Task<Void, Never>?
|
2026-05-02 22:18:33 -07:00
|
|
|
@ObservationIgnored
|
|
|
|
|
private var chatBackgroundTask: SybilBackgroundTaskAssertion?
|
|
|
|
|
@ObservationIgnored
|
2026-05-03 16:42:49 -07:00
|
|
|
private var isAppActive = true
|
|
|
|
|
@ObservationIgnored
|
|
|
|
|
private var appLifecycleGeneration = 0
|
|
|
|
|
@ObservationIgnored
|
2026-05-02 22:18:33 -07:00
|
|
|
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
private let fallbackModels: [Provider: [String]] = [
|
|
|
|
|
.openai: ["gpt-4.1-mini"],
|
|
|
|
|
.anthropic: ["claude-3-5-sonnet-latest"],
|
|
|
|
|
.xai: ["grok-3-mini"]
|
|
|
|
|
]
|
|
|
|
|
|
2026-05-02 22:18:33 -07:00
|
|
|
init(
|
|
|
|
|
settings: SybilSettingsStore = SybilSettingsStore(),
|
|
|
|
|
clientFactory: @escaping (APIConfiguration) -> any SybilAPIClienting = { configuration in
|
|
|
|
|
SybilAPIClient(configuration: configuration)
|
|
|
|
|
}
|
|
|
|
|
) {
|
2026-02-20 00:09:02 -08:00
|
|
|
self.settings = settings
|
2026-05-02 22:18:33 -07:00
|
|
|
self.clientFactory = clientFactory
|
2026-02-20 00:09:02 -08:00
|
|
|
self.provider = settings.preferredProvider
|
|
|
|
|
self.model = settings.preferredModelByProvider[settings.preferredProvider] ?? "gpt-4.1-mini"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var providerModelOptions: [String] {
|
2026-05-02 16:23:00 -07:00
|
|
|
modelOptions(for: provider)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func modelOptions(for candidate: Provider) -> [String] {
|
|
|
|
|
let serverModels = modelCatalog[candidate]?.models ?? []
|
2026-02-20 00:09:02 -08:00
|
|
|
if !serverModels.isEmpty {
|
|
|
|
|
return serverModels
|
|
|
|
|
}
|
2026-05-02 16:23:00 -07:00
|
|
|
return fallbackModels[candidate] ?? []
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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:
|
2026-05-03 21:42:28 -07:00
|
|
|
if let selectedChat = currentSelectedChat {
|
2026-02-20 00:09:02 -08:00
|
|
|
return chatTitle(title: selectedChat.title, messages: selectedChat.messages)
|
|
|
|
|
}
|
|
|
|
|
if let summary = selectedChatSummary {
|
|
|
|
|
return chatTitle(title: summary.title, messages: nil)
|
|
|
|
|
}
|
|
|
|
|
return "Chat"
|
|
|
|
|
|
|
|
|
|
case .search:
|
2026-05-03 21:42:28 -07:00
|
|
|
if let selectedSearch = currentSelectedSearch {
|
2026-02-20 00:09:02 -08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 19:47:38 -07:00
|
|
|
var canSendComposer: Bool {
|
|
|
|
|
if isSending {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
if isSearchMode {
|
|
|
|
|
return !content.isEmpty
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return !content.isEmpty || !composerAttachments.isEmpty
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
var displayedMessages: [Message] {
|
2026-05-03 21:42:28 -07:00
|
|
|
let canonical = displayableMessages(currentSelectedChat?.messages ?? [])
|
2026-02-20 00:09:02 -08:00
|
|
|
guard let pending = pendingChatState else {
|
|
|
|
|
return canonical
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let pendingID = pending.chatID {
|
|
|
|
|
if case let .chat(selectedID) = selectedItem, selectedID == pendingID {
|
2026-05-02 16:48:01 -07:00
|
|
|
return displayableMessages(pending.messages)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
return canonical
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if draftKind == .chat {
|
2026-05-02 16:48:01 -07:00
|
|
|
return displayableMessages(pending.messages)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return canonical
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
var displayedSearch: SearchDetail? {
|
|
|
|
|
currentSelectedSearch
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var isSendingVisibleChat: Bool {
|
|
|
|
|
guard isSending, pendingChatState != nil else {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch activeSendContext {
|
|
|
|
|
case let .draftChat(identity):
|
|
|
|
|
return draftKind == .chat && identity == draftIdentity
|
|
|
|
|
case let .chat(chatID):
|
|
|
|
|
return selectedItem == .chat(chatID)
|
|
|
|
|
case .draftSearch, .search, nil:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var isRunningVisibleSearch: Bool {
|
|
|
|
|
guard isSending else {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch activeSendContext {
|
|
|
|
|
case let .draftSearch(identity):
|
|
|
|
|
return draftKind == .search && identity == draftIdentity
|
|
|
|
|
case let .search(searchID):
|
|
|
|
|
return selectedItem == .search(searchID)
|
|
|
|
|
case .draftChat, .chat, nil:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
var sidebarItems: [SidebarItem] {
|
|
|
|
|
let chatItems: [SidebarItem] = chats.map { chat in
|
|
|
|
|
let initiatedLabel: String?
|
|
|
|
|
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
|
|
|
|
if let provider = chat.initiatedProvider {
|
2026-05-02 16:23:00 -07:00
|
|
|
initiatedLabel = "\(provider.displayName) • \(model)"
|
2026-02-20 00:09:02 -08:00
|
|
|
} else {
|
|
|
|
|
initiatedLabel = model
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
initiatedLabel = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return SidebarItem(
|
|
|
|
|
selection: .chat(chat.id),
|
|
|
|
|
kind: .chat,
|
|
|
|
|
title: chatTitle(title: chat.title, messages: nil),
|
|
|
|
|
updatedAt: chat.updatedAt,
|
|
|
|
|
initiatedLabel: initiatedLabel
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let searchItems: [SidebarItem] = searches.map { search in
|
|
|
|
|
SidebarItem(
|
|
|
|
|
selection: .search(search.id),
|
|
|
|
|
kind: .search,
|
|
|
|
|
title: searchTitle(title: search.title, query: search.query),
|
|
|
|
|
updatedAt: search.updatedAt,
|
2026-05-03 22:11:29 -07:00
|
|
|
initiatedLabel: "exa"
|
2026-02-20 00:09:02 -08:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (chatItems + searchItems).sorted { $0.updatedAt > $1.updatedAt }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 22:18:33 -07:00
|
|
|
var hasRefreshableSelection: Bool {
|
|
|
|
|
guard draftKind == nil, let selectedItem else {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch selectedItem {
|
|
|
|
|
case .chat, .search:
|
|
|
|
|
return true
|
|
|
|
|
case .settings:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
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
|
2026-05-03 21:42:28 -07:00
|
|
|
resetSelectionLoading()
|
2026-02-20 00:09:02 -08:00
|
|
|
pendingChatState = nil
|
2026-05-03 21:42:28 -07:00
|
|
|
activeSendContext = nil
|
|
|
|
|
draftIdentity = UUID()
|
2026-05-02 19:47:38 -07:00
|
|
|
composerAttachments = []
|
2026-02-20 00:09:02 -08:00
|
|
|
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)
|
|
|
|
|
} catch {
|
|
|
|
|
isAuthenticated = false
|
|
|
|
|
authMode = nil
|
|
|
|
|
chats = []
|
|
|
|
|
searches = []
|
|
|
|
|
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)")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 16:23:00 -07:00
|
|
|
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)")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
func startNewChat() {
|
|
|
|
|
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
2026-05-03 21:42:28 -07:00
|
|
|
resetSelectionLoading()
|
|
|
|
|
draftIdentity = UUID()
|
2026-02-20 00:09:02 -08:00
|
|
|
draftKind = .chat
|
|
|
|
|
selectedItem = nil
|
|
|
|
|
selectedChat = nil
|
|
|
|
|
selectedSearch = nil
|
2026-05-03 21:42:28 -07:00
|
|
|
pendingChatState = nil
|
2026-02-20 00:09:02 -08:00
|
|
|
errorMessage = nil
|
|
|
|
|
composer = ""
|
2026-05-02 19:47:38 -07:00
|
|
|
composerAttachments = []
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func startNewSearch() {
|
|
|
|
|
SybilLog.debug(SybilLog.ui, "Starting draft search")
|
2026-05-03 21:42:28 -07:00
|
|
|
resetSelectionLoading()
|
|
|
|
|
draftIdentity = UUID()
|
2026-02-20 00:09:02 -08:00
|
|
|
draftKind = .search
|
|
|
|
|
selectedItem = nil
|
|
|
|
|
selectedChat = nil
|
|
|
|
|
selectedSearch = nil
|
2026-05-03 21:42:28 -07:00
|
|
|
pendingChatState = nil
|
2026-02-20 00:09:02 -08:00
|
|
|
errorMessage = nil
|
|
|
|
|
composer = ""
|
2026-05-02 19:47:38 -07:00
|
|
|
composerAttachments = []
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func openSettings() {
|
|
|
|
|
SybilLog.debug(SybilLog.ui, "Opening settings")
|
2026-05-03 21:42:28 -07:00
|
|
|
resetSelectionLoading()
|
2026-02-20 00:09:02 -08:00
|
|
|
draftKind = nil
|
|
|
|
|
selectedItem = .settings
|
|
|
|
|
selectedChat = nil
|
|
|
|
|
selectedSearch = nil
|
2026-05-03 21:42:28 -07:00
|
|
|
pendingChatState = nil
|
2026-02-20 00:09:02 -08:00
|
|
|
errorMessage = nil
|
2026-05-02 19:47:38 -07:00
|
|
|
composerAttachments = []
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func select(_ selection: SidebarSelection) {
|
2026-05-03 21:42:28 -07:00
|
|
|
_ = 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>? {
|
2026-02-20 00:09:02 -08:00
|
|
|
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
|
2026-05-03 21:42:28 -07:00
|
|
|
|
|
|
|
|
if draftKind == nil, selectedItem == selection {
|
|
|
|
|
errorMessage = nil
|
|
|
|
|
if case .search = selection {
|
|
|
|
|
composerAttachments = []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if needsSelectionLoad(selection) {
|
|
|
|
|
return startSelectionRefreshTask()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return selectionTask
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resetSelectionLoading()
|
2026-02-20 00:09:02 -08:00
|
|
|
draftKind = nil
|
|
|
|
|
selectedItem = selection
|
|
|
|
|
errorMessage = nil
|
2026-05-03 21:42:28 -07:00
|
|
|
|
|
|
|
|
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
|
2026-05-02 19:47:38 -07:00
|
|
|
composerAttachments = []
|
2026-02-20 00:09:02 -08:00
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
case .settings:
|
2026-02-20 00:09:02 -08:00
|
|
|
selectedChat = nil
|
|
|
|
|
selectedSearch = nil
|
2026-05-03 21:42:28 -07:00
|
|
|
pendingChatState = nil
|
|
|
|
|
return nil
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
return startSelectionRefreshTask()
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
2026-05-02 23:50:51 -07:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
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 refreshAfterSettingsChange() async {
|
|
|
|
|
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
|
|
|
|
|
settings.persist()
|
|
|
|
|
await reconnect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 16:42:49 -07:00
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 22:18:33 -07:00
|
|
|
func refreshVisibleContent(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
|
|
|
|
guard isAuthenticated, !isCheckingSession else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard shouldRefreshCollections || shouldRefreshSelection else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SybilLog.info(
|
|
|
|
|
SybilLog.ui,
|
2026-05-03 23:06:39 -07:00
|
|
|
"Visible content refresh requested (collections=\(shouldRefreshCollections), selection=\(shouldRefreshSelection))"
|
2026-05-02 22:18:33 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if shouldRefreshCollections {
|
|
|
|
|
await refreshCollections(preferredSelection: selectedItem, refreshSelection: shouldRefreshSelection)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if shouldRefreshSelection {
|
|
|
|
|
await refreshSelectionIfNeeded()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
func sendComposer() async {
|
|
|
|
|
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
2026-05-02 19:47:38 -07:00
|
|
|
let attachments = composerAttachments
|
2026-05-03 21:42:28 -07:00
|
|
|
let sendContext = currentSendContext
|
2026-05-02 19:47:38 -07:00
|
|
|
|
|
|
|
|
guard !isSending else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
if sendContext.isSearch {
|
2026-05-02 19:47:38 -07:00
|
|
|
guard !content.isEmpty else { return }
|
|
|
|
|
} else if content.isEmpty && attachments.isEmpty {
|
2026-02-20 00:09:02 -08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
composer = ""
|
2026-05-02 19:47:38 -07:00
|
|
|
composerAttachments = []
|
2026-02-20 00:09:02 -08:00
|
|
|
errorMessage = nil
|
2026-05-03 21:42:28 -07:00
|
|
|
activeSendContext = sendContext
|
2026-02-20 00:09:02 -08:00
|
|
|
isSending = true
|
|
|
|
|
|
|
|
|
|
do {
|
2026-05-03 21:42:28 -07:00
|
|
|
if sendContext.isSearch {
|
2026-02-20 00:09:02 -08:00
|
|
|
SybilLog.info(SybilLog.ui, "Sending search query")
|
|
|
|
|
try await sendSearch(query: content)
|
|
|
|
|
} else {
|
|
|
|
|
SybilLog.info(SybilLog.ui, "Sending chat prompt")
|
2026-05-02 19:47:38 -07:00
|
|
|
try await sendChat(content: content, attachments: attachments)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
} catch {
|
2026-05-03 21:42:28 -07:00
|
|
|
let shouldSurfaceError = isSendContextVisible(sendContext) || (activeSendContext.map { isSendContextVisible($0) } ?? false)
|
|
|
|
|
if shouldSurfaceError {
|
|
|
|
|
errorMessage = normalizeAPIError(error)
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
SybilLog.error(SybilLog.ui, "Send failed", error: error)
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
if shouldSurfaceError, case let .chat(chatID) = selectedItem {
|
2026-02-20 00:09:02 -08:00
|
|
|
do {
|
|
|
|
|
let chat = try await client().getChat(chatID: chatID)
|
2026-05-03 21:42:28 -07:00
|
|
|
if selectedItem == .chat(chatID), draftKind == nil {
|
|
|
|
|
selectedChat = chat
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
} catch {
|
|
|
|
|
SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
if shouldSurfaceError, case let .search(searchID) = selectedItem {
|
2026-02-20 00:09:02 -08:00
|
|
|
do {
|
|
|
|
|
let search = try await client().getSearch(searchID: searchID)
|
2026-05-03 21:42:28 -07:00
|
|
|
if selectedItem == .search(searchID), draftKind == nil {
|
|
|
|
|
selectedSearch = search
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
} catch {
|
|
|
|
|
SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
if !sendContext.isSearch, shouldSurfaceError {
|
2026-05-02 19:47:38 -07:00
|
|
|
composer = content
|
|
|
|
|
composerAttachments = attachments
|
|
|
|
|
}
|
2026-05-03 21:42:28 -07:00
|
|
|
pendingChatState = nil
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isSending = false
|
2026-05-03 21:42:28 -07:00
|
|
|
activeSendContext = nil
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
2026-05-02 19:47:38 -07:00
|
|
|
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 }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 16:48:01 -07:00
|
|
|
func startChatFromSelectedSearch() async {
|
2026-05-03 21:42:28 -07:00
|
|
|
guard let search = currentSelectedSearch, !isCreatingSearchChat, !isSending else {
|
2026-05-02 16:48:01 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
let sourceSelection = SidebarSelection.search(search.id)
|
2026-05-02 16:48:01 -07:00
|
|
|
isCreatingSearchChat = true
|
|
|
|
|
errorMessage = nil
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let client = try client()
|
2026-05-02 22:18:33 -07:00
|
|
|
let chat = try await client.createChatFromSearch(searchID: search.id, title: nil)
|
2026-05-03 21:42:28 -07:00
|
|
|
|
|
|
|
|
guard selectedItem == sourceSelection, draftKind == nil else {
|
|
|
|
|
chats.removeAll(where: { $0.id == chat.id })
|
|
|
|
|
chats.insert(chat, at: 0)
|
|
|
|
|
isCreatingSearchChat = false
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 16:48:01 -07:00
|
|
|
draftKind = nil
|
|
|
|
|
pendingChatState = nil
|
|
|
|
|
composer = ""
|
2026-05-02 19:47:38 -07:00
|
|
|
composerAttachments = []
|
2026-05-02 16:48:01 -07:00
|
|
|
|
|
|
|
|
chats.removeAll(where: { $0.id == chat.id })
|
|
|
|
|
chats.insert(chat, at: 0)
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 22:18:33 -07:00
|
|
|
private func loadInitialData(using client: any SybilAPIClienting) async {
|
2026-02-20 00:09:02 -08:00
|
|
|
isLoadingCollections = true
|
|
|
|
|
errorMessage = nil
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
async let chatsValue = client.listChats()
|
|
|
|
|
async let searchesValue = client.listSearches()
|
|
|
|
|
let (nextChats, nextSearches) = try await (chatsValue, searchesValue)
|
|
|
|
|
|
|
|
|
|
chats = nextChats
|
|
|
|
|
searches = nextSearches
|
|
|
|
|
|
|
|
|
|
SybilLog.info(
|
|
|
|
|
SybilLog.app,
|
|
|
|
|
"Loaded collections: \(nextChats.count) chats, \(nextSearches.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: nextChats, searches: nextSearches) {
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
errorMessage = normalizeAPIError(error)
|
|
|
|
|
SybilLog.error(SybilLog.app, "Initial data load failed", error: error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isLoadingCollections = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func syncModelSelectionWithServerCatalog() {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
settings.persist()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 22:18:33 -07:00
|
|
|
private func refreshCollections(
|
|
|
|
|
preferredSelection: SidebarSelection?,
|
|
|
|
|
refreshSelection: Bool = true
|
|
|
|
|
) async {
|
2026-02-20 00:09:02 -08:00
|
|
|
isLoadingCollections = true
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let client = try client()
|
|
|
|
|
async let chatsValue = client.listChats()
|
|
|
|
|
async let searchesValue = client.listSearches()
|
|
|
|
|
let (nextChats, nextSearches) = try await (chatsValue, searchesValue)
|
|
|
|
|
|
|
|
|
|
chats = nextChats
|
|
|
|
|
searches = nextSearches
|
|
|
|
|
|
|
|
|
|
SybilLog.info(
|
|
|
|
|
SybilLog.app,
|
|
|
|
|
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
|
|
|
|
)
|
2026-05-03 16:42:49 -07:00
|
|
|
errorMessage = nil
|
2026-02-20 00:09:02 -08:00
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
if draftKind != nil {
|
|
|
|
|
isLoadingCollections = false
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
if case .settings = selectedItem {
|
|
|
|
|
isLoadingCollections = false
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let preferredSelection,
|
|
|
|
|
hasSelection(preferredSelection, chats: nextChats, searches: nextSearches) {
|
|
|
|
|
selectedItem = preferredSelection
|
|
|
|
|
} else if let existing = selectedItem,
|
|
|
|
|
hasSelection(existing, chats: nextChats, searches: nextSearches) {
|
|
|
|
|
selectedItem = existing
|
|
|
|
|
} else {
|
|
|
|
|
selectedItem = sidebarItems.first?.selection
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 22:18:33 -07:00
|
|
|
if refreshSelection, selectedItem != nil {
|
2026-02-20 00:09:02 -08:00
|
|
|
await refreshSelectionIfNeeded()
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
2026-05-03 16:42:49 -07:00
|
|
|
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)
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isLoadingCollections = false
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
private func refreshSelectionIfNeeded() async {
|
2026-05-03 21:42:28 -07:00
|
|
|
guard let target = selectedItem else {
|
2026-02-20 00:09:02 -08:00
|
|
|
selectedChat = nil
|
|
|
|
|
selectedSearch = nil
|
2026-05-03 21:42:28 -07:00
|
|
|
isLoadingSelection = false
|
2026-02-20 00:09:02 -08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
guard case .settings = target else {
|
2026-02-20 00:09:02 -08:00
|
|
|
isLoadingSelection = true
|
|
|
|
|
do {
|
|
|
|
|
let client = try client()
|
2026-05-03 21:42:28 -07:00
|
|
|
switch target {
|
2026-02-20 00:09:02 -08:00
|
|
|
case let .chat(chatID):
|
|
|
|
|
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
|
2026-05-03 21:42:28 -07:00
|
|
|
let chat = try await client.getChat(chatID: chatID)
|
|
|
|
|
guard selectedItem == target, draftKind == nil else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
selectedChat = chat
|
2026-02-20 00:09:02 -08:00
|
|
|
selectedSearch = nil
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
if let provider = chat.lastUsedProvider,
|
|
|
|
|
let model = chat.lastUsedModel,
|
2026-02-20 00:09:02 -08:00
|
|
|
!model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
|
|
|
self.provider = provider
|
|
|
|
|
self.model = model
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case let .search(searchID):
|
|
|
|
|
SybilLog.debug(SybilLog.app, "Refreshing search \(searchID)")
|
2026-05-03 21:42:28 -07:00
|
|
|
let search = try await client.getSearch(searchID: searchID)
|
|
|
|
|
guard selectedItem == target, draftKind == nil else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
selectedSearch = search
|
2026-02-20 00:09:02 -08:00
|
|
|
selectedChat = nil
|
|
|
|
|
|
|
|
|
|
case .settings:
|
|
|
|
|
break
|
|
|
|
|
}
|
2026-05-03 16:42:49 -07:00
|
|
|
errorMessage = nil
|
2026-02-20 00:09:02 -08:00
|
|
|
} catch {
|
|
|
|
|
if isCancellation(error) {
|
2026-05-03 21:42:28 -07:00
|
|
|
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(target.id)")
|
2026-05-03 16:42:49 -07:00
|
|
|
} else if shouldSuppressInactiveTransportError(error) {
|
|
|
|
|
SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive")
|
2026-05-03 21:42:28 -07:00
|
|
|
} else if selectedItem == target, draftKind == nil {
|
2026-02-20 00:09:02 -08:00
|
|
|
errorMessage = normalizeAPIError(error)
|
|
|
|
|
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-03 21:42:28 -07:00
|
|
|
if selectedItem == target, draftKind == nil {
|
|
|
|
|
isLoadingSelection = false
|
|
|
|
|
selectionTask = nil
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
selectedChat = nil
|
|
|
|
|
selectedSearch = nil
|
2026-05-03 21:42:28 -07:00
|
|
|
isLoadingSelection = false
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
2026-05-02 19:47:38 -07:00
|
|
|
private func sendChat(content: String, attachments: [ChatAttachment]) async throws {
|
2026-02-20 00:09:02 -08:00
|
|
|
let optimisticUser = Message(
|
|
|
|
|
id: "temp-user-\(UUID().uuidString)",
|
|
|
|
|
createdAt: Date(),
|
|
|
|
|
role: .user,
|
|
|
|
|
content: content,
|
2026-05-02 19:47:38 -07:00
|
|
|
name: nil,
|
|
|
|
|
metadata: SybilChatAttachmentSupport.metadataValue(for: attachments)
|
2026-02-20 00:09:02 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
let optimisticAssistant = Message(
|
|
|
|
|
id: "temp-assistant-\(UUID().uuidString)",
|
|
|
|
|
createdAt: Date(),
|
|
|
|
|
role: .assistant,
|
|
|
|
|
content: "",
|
|
|
|
|
name: nil
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
pendingChatState = PendingChatState(
|
|
|
|
|
chatID: currentChatID,
|
2026-05-03 21:42:28 -07:00
|
|
|
messages: (currentSelectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]
|
2026-02-20 00:09:02 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
let client = try client()
|
|
|
|
|
|
|
|
|
|
var chatID = currentChatID
|
|
|
|
|
if chatID == nil {
|
2026-05-02 22:18:33 -07:00
|
|
|
let created = try await client.createChat(title: nil)
|
2026-02-20 00:09:02 -08:00
|
|
|
chatID = created.id
|
2026-05-03 21:42:28 -07:00
|
|
|
let shouldShowCreatedChat = activeSendContext.map { isSendContextVisible($0) } ?? true
|
|
|
|
|
activeSendContext = .chat(created.id)
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
chats.removeAll(where: { $0.id == created.id })
|
|
|
|
|
chats.insert(created, at: 0)
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
if shouldShowCreatedChat {
|
|
|
|
|
draftKind = nil
|
|
|
|
|
selectedItem = .chat(created.id)
|
|
|
|
|
|
|
|
|
|
selectedChat = ChatDetail(
|
|
|
|
|
id: created.id,
|
|
|
|
|
title: created.title,
|
|
|
|
|
createdAt: created.createdAt,
|
|
|
|
|
updatedAt: created.updatedAt,
|
|
|
|
|
initiatedProvider: created.initiatedProvider,
|
|
|
|
|
initiatedModel: created.initiatedModel,
|
|
|
|
|
lastUsedProvider: created.lastUsedProvider,
|
|
|
|
|
lastUsedModel: created.lastUsedModel,
|
|
|
|
|
messages: []
|
|
|
|
|
)
|
|
|
|
|
selectedSearch = nil
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
SybilLog.info(SybilLog.app, "Created chat \(created.id)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard let chatID else {
|
|
|
|
|
throw APIError.invalidResponse
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pendingChatState?.chatID = chatID
|
|
|
|
|
|
|
|
|
|
let baseChat: ChatDetail
|
2026-05-03 21:42:28 -07:00
|
|
|
if let selectedChat = currentSelectedChat, selectedChat.id == chatID {
|
2026-02-20 00:09:02 -08:00
|
|
|
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] =
|
2026-03-02 16:18:10 -08:00
|
|
|
baseChat.messages
|
|
|
|
|
.filter { !$0.isToolCallLog }
|
|
|
|
|
.map {
|
2026-05-02 19:47:38 -07:00
|
|
|
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)]
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
let streamStatus = CompletionStreamStatus()
|
2026-05-03 16:42:49 -07:00
|
|
|
let streamLifecycleGeneration = appLifecycleGeneration
|
|
|
|
|
let streamStartedWhileInactive = !isAppActive
|
2026-02-20 00:09:02 -08:00
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
if isUntitledChat(chatID: chatID, detail: currentSelectedChat) {
|
2026-02-20 00:09:02 -08:00
|
|
|
Task { [weak self] in
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
do {
|
2026-05-02 19:47:38 -07:00
|
|
|
let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
|
|
|
|
|
let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
|
2026-02-20 00:09:02 -08:00
|
|
|
await MainActor.run {
|
|
|
|
|
self.chats = self.chats.map { existing in
|
|
|
|
|
if existing.id == updated.id {
|
|
|
|
|
return updated
|
|
|
|
|
}
|
|
|
|
|
return existing
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if self.selectedChat?.id == updated.id {
|
|
|
|
|
self.selectedChat?.title = updated.title
|
|
|
|
|
self.selectedChat?.updatedAt = updated.updatedAt
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 22:18:33 -07:00
|
|
|
chatBackgroundTask?.end()
|
|
|
|
|
chatBackgroundTask = SybilBackgroundTaskAssertion(name: "Sybil Chat Response") {
|
|
|
|
|
SybilLog.warning(SybilLog.app, "Chat response background time expired")
|
|
|
|
|
}
|
|
|
|
|
defer {
|
|
|
|
|
chatBackgroundTask?.end()
|
|
|
|
|
chatBackgroundTask = nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 16:42:49 -07:00
|
|
|
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, streamStatus: streamStatus)
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
if shouldSuppressLifecycleTransportError(
|
|
|
|
|
error,
|
|
|
|
|
startedAt: streamLifecycleGeneration,
|
|
|
|
|
startedWhileInactive: streamStartedWhileInactive
|
|
|
|
|
) {
|
|
|
|
|
SybilLog.info(SybilLog.app, "Suppressing chat stream transport interruption after app lifecycle change")
|
|
|
|
|
pendingChatState = nil
|
|
|
|
|
if isAppActive {
|
|
|
|
|
await refreshInterruptedStream(preferredSelection: .chat(chatID))
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw error
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let streamError = await streamStatus.error() {
|
|
|
|
|
throw APIError.httpError(statusCode: 502, message: streamError)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 16:42:49 -07:00
|
|
|
guard isAppActive else {
|
|
|
|
|
pendingChatState = nil
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
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 {
|
|
|
|
|
pendingChatState = nil
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 16:42:49 -07:00
|
|
|
do {
|
2026-05-03 21:42:28 -07:00
|
|
|
let refreshedChat = try await client.getChat(chatID: chatID)
|
|
|
|
|
guard selectedItem == sentChatSelection, draftKind == nil else {
|
|
|
|
|
pendingChatState = nil
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
selectedChat = refreshedChat
|
2026-05-03 16:42:49 -07:00
|
|
|
} catch {
|
|
|
|
|
if shouldSuppressLifecycleTransportError(
|
|
|
|
|
error,
|
|
|
|
|
startedAt: streamLifecycleGeneration,
|
|
|
|
|
startedWhileInactive: streamStartedWhileInactive
|
|
|
|
|
) {
|
|
|
|
|
SybilLog.info(SybilLog.app, "Suppressing chat refresh transport interruption after app lifecycle change")
|
|
|
|
|
pendingChatState = nil
|
|
|
|
|
if isAppActive {
|
|
|
|
|
await refreshInterruptedStream(preferredSelection: .chat(chatID))
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw error
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
pendingChatState = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func applyCompletionEvent(_ event: CompletionStreamEvent, streamStatus: CompletionStreamStatus) async {
|
|
|
|
|
switch event {
|
|
|
|
|
case let .meta(payload):
|
|
|
|
|
pendingChatState?.chatID = payload.chatId
|
|
|
|
|
|
2026-03-02 16:18:10 -08:00
|
|
|
case let .toolCall(payload):
|
|
|
|
|
insertPendingToolCallMessage(payload)
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
case let .delta(payload):
|
|
|
|
|
guard !payload.text.isEmpty else { return }
|
|
|
|
|
mutatePendingAssistantMessage { existing in
|
|
|
|
|
existing + payload.text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case let .done(payload):
|
|
|
|
|
mutatePendingAssistantMessage { _ in
|
|
|
|
|
payload.text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case let .error(payload):
|
|
|
|
|
await streamStatus.setError(payload.message)
|
|
|
|
|
|
|
|
|
|
case .ignored:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func sendSearch(query: String) 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
|
2026-05-03 21:42:28 -07:00
|
|
|
let shouldShowCreatedSearch = activeSendContext.map { isSendContextVisible($0) } ?? true
|
|
|
|
|
activeSendContext = .search(created.id)
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
searches.removeAll(where: { $0.id == created.id })
|
|
|
|
|
searches.insert(created, at: 0)
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
if shouldShowCreatedSearch {
|
|
|
|
|
draftKind = nil
|
|
|
|
|
selectedItem = .search(created.id)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
SybilLog.info(SybilLog.app, "Created search \(created.id)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard let searchID else {
|
|
|
|
|
throw APIError.invalidResponse
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let now = Date()
|
2026-05-03 21:42:28 -07:00
|
|
|
if selectedItem == .search(searchID), draftKind == nil {
|
|
|
|
|
selectedSearch = SearchDetail(
|
|
|
|
|
id: searchID,
|
|
|
|
|
title: String(query.prefix(80)),
|
|
|
|
|
query: query,
|
|
|
|
|
createdAt: currentSelectedSearch?.createdAt ?? now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
requestId: nil,
|
|
|
|
|
latencyMs: nil,
|
|
|
|
|
error: nil,
|
|
|
|
|
answerText: nil,
|
|
|
|
|
answerRequestId: nil,
|
|
|
|
|
answerCitations: nil,
|
|
|
|
|
answerError: nil,
|
|
|
|
|
results: []
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
let streamStatus = SearchStreamStatus()
|
2026-05-03 16:42:49 -07:00
|
|
|
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")
|
|
|
|
|
if isAppActive {
|
|
|
|
|
await refreshInterruptedStream(preferredSelection: .search(searchID))
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
|
2026-05-03 16:42:49 -07:00
|
|
|
throw error
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let streamError = await streamStatus.error() {
|
|
|
|
|
throw APIError.httpError(statusCode: 502, message: streamError)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 16:42:49 -07:00
|
|
|
guard isAppActive else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
let sentSearchSelection = SidebarSelection.search(searchID)
|
|
|
|
|
let shouldKeepSentSearchSelected = selectedItem == sentSearchSelection && draftKind == nil
|
|
|
|
|
await refreshCollections(
|
|
|
|
|
preferredSelection: shouldKeepSentSearchSelected ? sentSearchSelection : selectedItem,
|
|
|
|
|
refreshSelection: false
|
|
|
|
|
)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func applySearchEvent(
|
|
|
|
|
_ event: SearchStreamEvent,
|
|
|
|
|
searchID: String,
|
|
|
|
|
streamStatus: SearchStreamStatus
|
|
|
|
|
) async {
|
2026-05-03 21:42:28 -07:00
|
|
|
guard let current = currentSelectedSearch, current.id == searchID else {
|
|
|
|
|
if case let .done(payload) = event,
|
|
|
|
|
selectedItem == .search(searchID),
|
|
|
|
|
draftKind == nil {
|
2026-02-20 00:09:02 -08:00
|
|
|
selectedSearch = payload.search
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch event {
|
|
|
|
|
case let .searchResults(payload):
|
|
|
|
|
selectedSearch?.requestId = payload.requestId ?? current.requestId
|
|
|
|
|
selectedSearch?.error = nil
|
|
|
|
|
selectedSearch?.results = payload.results
|
|
|
|
|
|
|
|
|
|
case let .searchError(payload):
|
|
|
|
|
selectedSearch?.error = payload.error
|
|
|
|
|
|
|
|
|
|
case let .answer(payload):
|
|
|
|
|
selectedSearch?.answerText = payload.answerText
|
|
|
|
|
selectedSearch?.answerRequestId = payload.answerRequestId
|
|
|
|
|
selectedSearch?.answerCitations = payload.answerCitations
|
|
|
|
|
selectedSearch?.answerError = nil
|
|
|
|
|
|
|
|
|
|
case let .answerError(payload):
|
|
|
|
|
selectedSearch?.answerError = payload.error
|
|
|
|
|
|
|
|
|
|
case let .done(payload):
|
|
|
|
|
selectedSearch = payload.search
|
|
|
|
|
selectedChat = nil
|
|
|
|
|
|
|
|
|
|
case let .error(payload):
|
|
|
|
|
await streamStatus.setError(payload.message)
|
|
|
|
|
|
|
|
|
|
case .ignored:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func mutatePendingAssistantMessage(_ transform: (String) -> String) {
|
|
|
|
|
guard var pending = pendingChatState, !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
|
|
|
|
|
pendingChatState = pending
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 16:18:10 -08:00
|
|
|
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall) {
|
|
|
|
|
guard var pending = pendingChatState else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if pending.messages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
let message = Message(
|
|
|
|
|
id: "temp-tool-\(payload.toolCallId)",
|
|
|
|
|
createdAt: Date(),
|
|
|
|
|
role: .tool,
|
|
|
|
|
content: summary,
|
|
|
|
|
name: payload.name,
|
|
|
|
|
metadata: metadata
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pendingChatState = pending
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
private var currentChatID: String? {
|
|
|
|
|
if draftKind == .chat {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if case let .chat(chatID) = selectedItem {
|
|
|
|
|
return chatID
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
private var currentSearchID: String? {
|
|
|
|
|
if draftKind == .search {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if case let .search(searchID) = selectedItem {
|
|
|
|
|
return searchID
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 21:42:28 -07:00
|
|
|
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 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 16:48:01 -07:00
|
|
|
private func displayableMessages(_ messages: [Message]) -> [Message] {
|
|
|
|
|
messages.filter { $0.role != .system }
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 19:47:38 -07:00
|
|
|
if let firstUserMessage = messages?.first(where: { $0.role == .user }) {
|
|
|
|
|
let attachmentSummary = SybilChatAttachmentSupport.attachmentSummary(firstUserMessage.attachments)
|
|
|
|
|
if !attachmentSummary.isEmpty {
|
|
|
|
|
return String(attachmentSummary.prefix(48))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 16:42:49 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 22:18:33 -07:00
|
|
|
private func client() throws -> any SybilAPIClienting {
|
2026-02-20 00:09:02 -08:00
|
|
|
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"))"
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-02 22:18:33 -07:00
|
|
|
return clientFactory(
|
|
|
|
|
APIConfiguration(
|
2026-02-20 00:09:02 -08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|