Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift

1298 lines
40 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 initiatedLabel: String?
}
private struct PendingChatState {
var chatID: String?
var messages: [Message]
}
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
var isCreatingSearchChat = false
var errorMessage: String?
var composer = ""
var composerAttachments: [ChatAttachment] = []
var provider: Provider
var modelCatalog: [Provider: ProviderModelInfo] = [:]
var model: String
@ObservationIgnored
private var hasBootstrapped = false
private var pendingChatState: PendingChatState?
@ObservationIgnored
private var selectionTask: Task<Void, Never>?
@ObservationIgnored
private var chatBackgroundTask: SybilBackgroundTaskAssertion?
@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"]
]
init(
settings: SybilSettingsStore = SybilSettingsStore(),
clientFactory: @escaping (APIConfiguration) -> any SybilAPIClienting = { configuration in
SybilAPIClient(configuration: configuration)
}
) {
self.settings = settings
self.clientFactory = clientFactory
self.provider = settings.preferredProvider
self.model = settings.preferredModelByProvider[settings.preferredProvider] ?? "gpt-4.1-mini"
}
var providerModelOptions: [String] {
modelOptions(for: provider)
}
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 {
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 {
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
}
var canSendComposer: Bool {
if isSending {
return false
}
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
if isSearchMode {
return !content.isEmpty
}
return !content.isEmpty || !composerAttachments.isEmpty
}
var displayedMessages: [Message] {
let canonical = displayableMessages(selectedChat?.messages ?? [])
guard let pending = pendingChatState else {
return canonical
}
if let pendingID = pending.chatID {
if case let .chat(selectedID) = selectedItem, selectedID == pendingID {
return displayableMessages(pending.messages)
}
return canonical
}
if draftKind == .chat {
return displayableMessages(pending.messages)
}
return canonical
}
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 {
initiatedLabel = "\(provider.displayName)\(model)"
} 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,
initiatedLabel: nil
)
}
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 })
}
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
pendingChatState = nil
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)
} 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)")
}
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 startNewChat() {
SybilLog.debug(SybilLog.ui, "Starting draft chat")
draftKind = .chat
selectedItem = nil
selectedChat = nil
selectedSearch = nil
errorMessage = nil
composer = ""
composerAttachments = []
}
func startNewSearch() {
SybilLog.debug(SybilLog.ui, "Starting draft search")
draftKind = .search
selectedItem = nil
selectedChat = nil
selectedSearch = nil
errorMessage = nil
composer = ""
composerAttachments = []
}
func openSettings() {
SybilLog.debug(SybilLog.ui, "Opening settings")
draftKind = nil
selectedItem = .settings
selectedChat = nil
selectedSearch = nil
errorMessage = nil
composerAttachments = []
}
func select(_ selection: SidebarSelection) {
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
draftKind = nil
selectedItem = selection
errorMessage = nil
if case .search = selection {
composerAttachments = []
}
if case .settings = selection {
selectedChat = nil
selectedSearch = nil
return
}
selectionTask?.cancel()
selectionTask = Task { [weak self] in
await self?.refreshSelectionIfNeeded()
}
}
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 refreshAfterSettingsChange() async {
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
settings.persist()
await reconnect()
}
func refreshVisibleContent(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
guard isAuthenticated, !isCheckingSession else {
return
}
guard shouldRefreshCollections || shouldRefreshSelection else {
return
}
SybilLog.info(
SybilLog.ui,
"Foreground refresh requested (collections=\(shouldRefreshCollections), selection=\(shouldRefreshSelection))"
)
if shouldRefreshCollections {
await refreshCollections(preferredSelection: selectedItem, refreshSelection: shouldRefreshSelection)
return
}
if shouldRefreshSelection {
await refreshSelectionIfNeeded()
}
}
func sendComposer() async {
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
let attachments = composerAttachments
guard !isSending else {
return
}
if isSearchMode {
guard !content.isEmpty else { return }
} else if content.isEmpty && attachments.isEmpty {
return
}
composer = ""
composerAttachments = []
errorMessage = nil
isSending = true
do {
if isSearchMode {
SybilLog.info(SybilLog.ui, "Sending search query")
try await sendSearch(query: content)
} else {
SybilLog.info(SybilLog.ui, "Sending chat prompt")
try await sendChat(content: content, attachments: attachments)
}
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Send failed", error: error)
if case let .chat(chatID) = selectedItem {
do {
let chat = try await client().getChat(chatID: chatID)
selectedChat = chat
} catch {
SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error)
}
}
if case let .search(searchID) = selectedItem {
do {
let search = try await client().getSearch(searchID: searchID)
selectedSearch = search
} catch {
SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error)
}
}
if !isSearchMode {
composer = content
composerAttachments = attachments
pendingChatState = nil
}
}
isSending = false
}
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 = selectedSearch, !isCreatingSearchChat, !isSending else {
return
}
isCreatingSearchChat = true
errorMessage = nil
do {
let client = try client()
let chat = try await client.createChatFromSearch(searchID: search.id, title: nil)
draftKind = nil
pendingChatState = nil
composer = ""
composerAttachments = []
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
}
private func loadInitialData(using client: any SybilAPIClienting) async {
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()
}
private func refreshCollections(
preferredSelection: SidebarSelection?,
refreshSelection: Bool = true
) async {
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"
)
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
}
if refreshSelection, selectedItem != nil {
await refreshSelectionIfNeeded()
}
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
}
isLoadingCollections = false
}
private func refreshSelectionIfNeeded() async {
guard let selectedItem else {
selectedChat = nil
selectedSearch = nil
return
}
guard case .settings = selectedItem else {
isLoadingSelection = true
do {
let client = try client()
switch selectedItem {
case let .chat(chatID):
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
selectedChat = try await client.getChat(chatID: chatID)
selectedSearch = nil
if let detail = selectedChat,
let provider = detail.lastUsedProvider,
let model = detail.lastUsedModel,
!model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.provider = provider
self.model = model
}
case let .search(searchID):
SybilLog.debug(SybilLog.app, "Refreshing search \(searchID)")
selectedSearch = try await client.getSearch(searchID: searchID)
selectedChat = nil
case .settings:
break
}
} catch {
if isCancellation(error) {
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(selectedItem.id)")
} else {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
}
}
isLoadingSelection = false
return
}
selectedChat = nil
selectedSearch = nil
}
private func sendChat(content: String, attachments: [ChatAttachment]) 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
)
pendingChatState = PendingChatState(
chatID: currentChatID,
messages: (selectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]
)
let client = try client()
var chatID = currentChatID
if chatID == nil {
let created = try await client.createChat(title: nil)
chatID = created.id
draftKind = nil
selectedItem = .chat(created.id)
chats.removeAll(where: { $0.id == created.id })
chats.insert(created, at: 0)
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
SybilLog.info(SybilLog.app, "Created chat \(created.id)")
}
guard let chatID else {
throw APIError.invalidResponse
}
pendingChatState?.chatID = chatID
let baseChat: ChatDetail
if let selectedChat, 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()
if isUntitledChat(chatID: chatID, detail: selectedChat) {
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.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))")
}
}
}
chatBackgroundTask?.end()
chatBackgroundTask = SybilBackgroundTaskAssertion(name: "Sybil Chat Response") {
SybilLog.warning(SybilLog.app, "Chat response background time expired")
}
defer {
chatBackgroundTask?.end()
chatBackgroundTask = nil
}
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)
}
if let streamError = await streamStatus.error() {
throw APIError.httpError(statusCode: 502, message: streamError)
}
await refreshCollections(preferredSelection: .chat(chatID))
selectedChat = try await client.getChat(chatID: chatID)
pendingChatState = nil
}
private func applyCompletionEvent(_ event: CompletionStreamEvent, streamStatus: CompletionStreamStatus) async {
switch event {
case let .meta(payload):
pendingChatState?.chatID = payload.chatId
case let .toolCall(payload):
insertPendingToolCallMessage(payload)
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
draftKind = nil
selectedItem = .search(created.id)
searches.removeAll(where: { $0.id == created.id })
searches.insert(created, at: 0)
SybilLog.info(SybilLog.app, "Created search \(created.id)")
}
guard let searchID else {
throw APIError.invalidResponse
}
let now = Date()
selectedSearch = SearchDetail(
id: searchID,
title: String(query.prefix(80)),
query: query,
createdAt: selectedSearch?.createdAt ?? now,
updatedAt: now,
requestId: nil,
latencyMs: nil,
error: nil,
answerText: nil,
answerRequestId: nil,
answerCitations: nil,
answerError: nil,
results: []
)
let streamStatus = SearchStreamStatus()
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)
}
if let streamError = await streamStatus.error() {
throw APIError.httpError(statusCode: 502, message: streamError)
}
await refreshCollections(preferredSelection: .search(searchID))
}
private func applySearchEvent(
_ event: SearchStreamEvent,
searchID: String,
streamStatus: SearchStreamStatus
) async {
guard let current = selectedSearch, current.id == searchID else {
if case let .done(payload) = event {
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
}
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
}
private var currentChatID: String? {
if draftKind == .chat {
return nil
}
if case let .chat(chatID) = selectedItem {
return chatID
}
return nil
}
private var currentSearchID: String? {
if draftKind == .search {
return nil
}
if case let .search(searchID) = selectedItem {
return searchID
}
return nil
}
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 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
}
}