1143 lines
35 KiB
Swift
1143 lines
35 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 provider: Provider
|
|
var modelCatalog: [Provider: ProviderModelInfo] = [:]
|
|
var model: String
|
|
|
|
private var hasBootstrapped = false
|
|
private var pendingChatState: PendingChatState?
|
|
private var selectionTask: Task<Void, Never>?
|
|
|
|
private let fallbackModels: [Provider: [String]] = [
|
|
.openai: ["gpt-4.1-mini"],
|
|
.anthropic: ["claude-3-5-sonnet-latest"],
|
|
.xai: ["grok-3-mini"]
|
|
]
|
|
|
|
init(settings: SybilSettingsStore = SybilSettingsStore()) {
|
|
self.settings = settings
|
|
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 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 })
|
|
}
|
|
|
|
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
|
|
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 = ""
|
|
}
|
|
|
|
func startNewSearch() {
|
|
SybilLog.debug(SybilLog.ui, "Starting draft search")
|
|
draftKind = .search
|
|
selectedItem = nil
|
|
selectedChat = nil
|
|
selectedSearch = nil
|
|
errorMessage = nil
|
|
composer = ""
|
|
}
|
|
|
|
func openSettings() {
|
|
SybilLog.debug(SybilLog.ui, "Opening settings")
|
|
draftKind = nil
|
|
selectedItem = .settings
|
|
selectedChat = nil
|
|
selectedSearch = nil
|
|
errorMessage = nil
|
|
}
|
|
|
|
func select(_ selection: SidebarSelection) {
|
|
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
|
|
draftKind = nil
|
|
selectedItem = selection
|
|
errorMessage = nil
|
|
|
|
if case .settings = selection {
|
|
selectedChat = nil
|
|
selectedSearch = nil
|
|
return
|
|
}
|
|
|
|
selectionTask?.cancel()
|
|
selectionTask = Task { [weak self] in
|
|
await self?.refreshSelectionIfNeeded()
|
|
}
|
|
}
|
|
|
|
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 sendComposer() async {
|
|
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !content.isEmpty, !isSending else {
|
|
return
|
|
}
|
|
|
|
composer = ""
|
|
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)
|
|
}
|
|
} 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)
|
|
}
|
|
}
|
|
|
|
pendingChatState = nil
|
|
}
|
|
|
|
isSending = false
|
|
}
|
|
|
|
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)
|
|
draftKind = nil
|
|
pendingChatState = nil
|
|
composer = ""
|
|
|
|
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: SybilAPIClient) 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?) 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 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) async throws {
|
|
let optimisticUser = Message(
|
|
id: "temp-user-\(UUID().uuidString)",
|
|
createdAt: Date(),
|
|
role: .user,
|
|
content: content,
|
|
name: nil
|
|
)
|
|
|
|
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()
|
|
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)
|
|
} + [CompletionRequestMessage(role: .user, content: content)]
|
|
|
|
let streamStatus = CompletionStreamStatus()
|
|
|
|
if isUntitledChat(chatID: chatID, detail: selectedChat) {
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
do {
|
|
let updated = try await client.suggestChatTitle(chatID: chatID, content: content)
|
|
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))")
|
|
}
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
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 -> SybilAPIClient {
|
|
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 SybilAPIClient(
|
|
configuration: 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
|
|
}
|
|
}
|