diff --git a/ios/Apps/Sybil/Info.plist b/ios/Apps/Sybil/Info.plist new file mode 100644 index 0000000..154b30b --- /dev/null +++ b/ios/Apps/Sybil/Info.plist @@ -0,0 +1,17 @@ + + + + + UIApplicationShortcutItems + + + UIApplicationShortcutItemType + net.buzzert.sybil2.quick-question + UIApplicationShortcutItemTitle + Quick question + UIApplicationShortcutItemIconSymbolName + sparkles + + + + diff --git a/ios/Apps/Sybil/Sources/SybilApp.swift b/ios/Apps/Sybil/Sources/SybilApp.swift index c812842..df206cf 100644 --- a/ios/Apps/Sybil/Sources/SybilApp.swift +++ b/ios/Apps/Sybil/Sources/SybilApp.swift @@ -5,6 +5,8 @@ import UIKit @main struct SybilApp: App { + @UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate + var body: some Scene { WindowGroup { SplitView() @@ -14,3 +16,79 @@ struct SybilApp: App } } } + +@MainActor +final class SybilAppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + SybilHomeScreenQuickActionHandler.configureQuickActions() + return true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + configuration.delegateClass = SybilSceneDelegate.self + return configuration + } + + func application( + _ application: UIApplication, + performActionFor shortcutItem: UIApplicationShortcutItem, + completionHandler: @escaping (Bool) -> Void + ) { + completionHandler(SybilHomeScreenQuickActionHandler.handle(shortcutItem)) + } +} + +@MainActor +final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate { + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + if let shortcutItem = connectionOptions.shortcutItem { + _ = SybilHomeScreenQuickActionHandler.handle(shortcutItem) + } + } + + func windowScene( + _ windowScene: UIWindowScene, + performActionFor shortcutItem: UIApplicationShortcutItem, + completionHandler: @escaping (Bool) -> Void + ) { + completionHandler(SybilHomeScreenQuickActionHandler.handle(shortcutItem)) + } + + func sceneWillResignActive(_ scene: UIScene) { + SybilHomeScreenQuickActionHandler.configureQuickActions() + } +} + +@MainActor +private enum SybilHomeScreenQuickActionHandler { + static func configureQuickActions() { + // The quick question action is static in Info.plist so it is available before first launch. + UIApplication.shared.shortcutItems = [] + } + + static func handle(_ shortcutItem: UIApplicationShortcutItem) -> Bool { + guard shortcutItem.type == SybilHomeScreenQuickAction.quickQuestionType else { + return false + } + + Task { @MainActor in + SybilQuickActionRouter.shared.requestQuickQuestionPresentation() + } + return true + } +} diff --git a/ios/Apps/Sybil/project.yml b/ios/Apps/Sybil/project.yml index aff0e87..ef89fde 100644 --- a/ios/Apps/Sybil/project.yml +++ b/ios/Apps/Sybil/project.yml @@ -22,6 +22,7 @@ targets: SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO TARGETED_DEVICE_FAMILY: "1,2,6" GENERATE_INFOPLIST_FILE: YES + INFOPLIST_FILE: Apps/Sybil/Info.plist ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon MARKETING_VERSION: 1.7 CURRENT_PROJECT_VERSION: 8 diff --git a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift index ada0966..5b235a8 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift @@ -2,10 +2,14 @@ import SwiftUI public struct SplitView: View { @State private var viewModel = SybilViewModel() + @ObservedObject private var quickActionRouter = SybilQuickActionRouter.shared @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.scenePhase) private var scenePhase @State private var shouldRefreshOnForeground = false @State private var composerFocusRequest = 0 + @State private var quickQuestionFocusRequest = 0 + @State private var hasPendingQuickQuestionPresentation = false + @State private var isQuickQuestionPresented = false @State private var columnVisibility: NavigationSplitViewVisibility = .automatic private var keyboardActions: SybilKeyboardActions? { @@ -74,8 +78,28 @@ public struct SplitView: View { .font(.sybil(.body)) .preferredColorScheme(.dark) .focusedSceneValue(\.sybilKeyboardActions, keyboardActions) + .sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) { + SybilQuickQuestionView( + viewModel: viewModel, + focusRequest: quickQuestionFocusRequest + ) + .presentationDragIndicator(.visible) + } .task { await viewModel.bootstrap() + presentPendingQuickQuestionIfPossible() + } + .onReceive(quickActionRouter.$quickQuestionPresentationRequest) { request in + guard request > 0 else { + return + } + queueQuickQuestionPresentation() + } + .onChange(of: viewModel.isCheckingSession) { _, _ in + presentPendingQuickQuestionIfPossible() + } + .onChange(of: viewModel.isAuthenticated) { _, _ in + presentPendingQuickQuestionIfPossible() } .onChange(of: scenePhase) { _, nextPhase in switch nextPhase { @@ -112,6 +136,28 @@ public struct SplitView: View { columnVisibility = .all } } + + private func queueQuickQuestionPresentation() { + hasPendingQuickQuestionPresentation = true + presentPendingQuickQuestionIfPossible() + } + + private func presentPendingQuickQuestionIfPossible() { + guard hasPendingQuickQuestionPresentation, + !viewModel.isCheckingSession, + viewModel.isAuthenticated + else { + return + } + + hasPendingQuickQuestionPresentation = false + quickQuestionFocusRequest += 1 + isQuickQuestionPresented = true + } + + private func handleQuickQuestionDismissed() { + viewModel.cancelQuickQuestion() + } } public struct SybilCommands: Commands { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift index c272e62..5f9ba48 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift @@ -49,11 +49,16 @@ actor SybilAPIClient: SybilAPIClienting { return response.chats } - func createChat(title: String? = nil) async throws -> ChatSummary { + func createChat( + title: String? = nil, + provider: Provider? = nil, + model: String? = nil, + messages: [CompletionRequestMessage]? = nil + ) async throws -> ChatSummary { let response = try await request( "/v1/chats", method: "POST", - body: AnyEncodable(ChatCreateBody(title: title)), + body: AnyEncodable(ChatCreateBody(title: title, provider: provider, model: model, messages: messages)), responseType: ChatCreateResponse.self ) return response.chat @@ -617,6 +622,7 @@ actor SybilAPIClient: SybilAPIClienting { struct CompletionStreamRequest: Codable, Sendable { var chatId: String? + var persist: Bool? = nil var provider: Provider var model: String var messages: [CompletionRequestMessage] @@ -624,6 +630,9 @@ struct CompletionStreamRequest: Codable, Sendable { private struct ChatCreateBody: Encodable { var title: String? + var provider: Provider? + var model: String? + var messages: [CompletionRequestMessage]? } private struct SearchCreateBody: Encodable { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift index a6ac5d3..c7e961e 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClienting.swift @@ -3,7 +3,12 @@ import Foundation protocol SybilAPIClienting: Sendable { func verifySession() async throws -> AuthSession func listChats() async throws -> [ChatSummary] - func createChat(title: String?) async throws -> ChatSummary + func createChat( + title: String?, + provider: Provider?, + model: String?, + messages: [CompletionRequestMessage]? + ) async throws -> ChatSummary func getChat(chatID: String) async throws -> ChatDetail func deleteChat(chatID: String) async throws func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary @@ -32,3 +37,9 @@ protocol SybilAPIClienting: Sendable { onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void ) async throws } + +extension SybilAPIClienting { + func createChat(title: String?) async throws -> ChatSummary { + try await createChat(title: title, provider: nil, model: nil, messages: nil) + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift index 998f718..bb0dce4 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift @@ -406,8 +406,8 @@ public struct CompletionRequestMessage: Codable, Sendable { } public struct CompletionStreamMeta: Codable, Sendable { - public var chatId: String - public var callId: String + public var chatId: String? + public var callId: String? public var provider: Provider public var model: String } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilQuickActionRouting.swift b/ios/Packages/Sybil/Sources/Sybil/SybilQuickActionRouting.swift new file mode 100644 index 0000000..858d3f5 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilQuickActionRouting.swift @@ -0,0 +1,19 @@ +import Combine +import Foundation + +public enum SybilHomeScreenQuickAction { + public static let quickQuestionType = "net.buzzert.sybil2.quick-question" +} + +@MainActor +public final class SybilQuickActionRouter: ObservableObject { + public static let shared = SybilQuickActionRouter() + + @Published public private(set) var quickQuestionPresentationRequest = 0 + + private init() {} + + public func requestQuickQuestionPresentation() { + quickQuestionPresentationRequest += 1 + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift new file mode 100644 index 0000000..342e337 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift @@ -0,0 +1,297 @@ +import MarkdownUI +import Observation +import SwiftUI + +struct SybilQuickQuestionView: View { + @Bindable var viewModel: SybilViewModel + var focusRequest: Int + + @Environment(\.dismiss) private var dismiss + @FocusState private var promptFocused: Bool + + private var hasAnswerContent: Bool { + !viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil + } + + var body: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 16) { + header + + answerArea + + composer + } + .padding(.horizontal, 16) + .padding(.top, 18) + .padding(.bottom, 12) + .frame(maxWidth: 640, maxHeight: .infinity, alignment: .top) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(SybilTheme.backgroundGradient) + .preferredColorScheme(.dark) + .task(id: focusRequest) { + try? await Task.sleep(for: .milliseconds(260)) + guard !Task.isCancelled else { + return + } + promptFocused = true + } + } + + private var header: some View { + HStack { + Image(systemName: "sparkles") + .font(.system(size: 21, weight: .semibold)) + .foregroundStyle(SybilTheme.primary) + + Text("Quick question") + .font(.title3.weight(.semibold)) + .foregroundStyle(SybilTheme.text) + .lineLimit(1) + + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var answerArea: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if hasAnswerContent { + ForEach(viewModel.quickQuestionMessages) { message in + QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending) + } + + if let error = viewModel.quickQuestionError { + Text(error) + .font(.caption) + .foregroundStyle(SybilTheme.danger) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(14) + } + .scrollDismissesKeyboard(.interactively) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.36)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(SybilTheme.border.opacity(0.55), lineWidth: 1) + ) + } + + private var composer: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .bottom, spacing: 10) { + TextField( + "Ask anything...", + text: Binding( + get: { viewModel.quickQuestionPrompt }, + set: { viewModel.updateQuickQuestionPrompt($0) } + ), + axis: .vertical + ) + .focused($promptFocused) + .font(.body) + .textInputAutocapitalization(.sentences) + .autocorrectionDisabled(false) + .lineLimit(1 ... 6) + .submitLabel(.send) + .onSubmit(submitQuestion) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(SybilTheme.composerGradient) + .opacity(0.98) + ) + .foregroundStyle(SybilTheme.text) + + Button(action: submitQuestion) { + Image(systemName: "arrow.up") + .font(.body.weight(.semibold)) + .frame(width: 40, height: 40) + .background( + Circle() + .fill( + viewModel.canSendQuickQuestion + ? AnyShapeStyle(SybilTheme.primaryGradient) + : AnyShapeStyle(SybilTheme.surfaceStrong.opacity(0.92)) + ) + ) + .foregroundStyle(viewModel.canSendQuickQuestion ? SybilTheme.text : SybilTheme.textMuted) + } + .buttonStyle(.plain) + .disabled(!viewModel.canSendQuickQuestion) + .accessibilityLabel("Ask quick question") + } + + controlsRow + } + } + + private var convertButton: some View { + Button { + Task { + let didConvert = await viewModel.convertQuickQuestionToChat() + if didConvert { + dismiss() + } + } + } label: { + Label("Chat", systemImage: "bubble.left") + .font(.caption.weight(.medium)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .buttonStyle(.plain) + .foregroundStyle(viewModel.canConvertQuickQuestion ? SybilTheme.text : SybilTheme.textMuted) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, minHeight: 40) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(SybilTheme.surfaceStrong.opacity(0.78)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(SybilTheme.border.opacity(0.78), lineWidth: 1) + ) + ) + .disabled(!viewModel.canConvertQuickQuestion) + } + + private var controlsRow: some View { + HStack(alignment: .center, spacing: 10) { + providerMenu + modelMenu + convertButton + } + } + + private var providerMenu: some View { + Menu { + ForEach(viewModel.providerOptions, id: \.self) { provider in + Button { + viewModel.setQuickQuestionProvider(provider) + } label: { + if viewModel.quickQuestionProvider == provider { + Label(provider.displayName, systemImage: "checkmark") + } else { + Text(provider.displayName) + } + } + } + } label: { + QuickQuestionPickerPill(title: viewModel.quickQuestionProvider.displayName) + } + .frame(maxWidth: .infinity) + .disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion) + .accessibilityLabel("Quick question provider") + } + + private var modelMenu: some View { + Menu { + if viewModel.quickQuestionProviderModelOptions.isEmpty { + Text("No models") + } else { + ForEach(viewModel.quickQuestionProviderModelOptions, id: \.self) { model in + Button { + viewModel.setQuickQuestionModel(model) + } label: { + if viewModel.quickQuestionModel == model { + Label(model, systemImage: "checkmark") + } else { + Text(model) + } + } + } + } + } label: { + QuickQuestionPickerPill(title: viewModel.quickQuestionModel.isEmpty ? "No model" : viewModel.quickQuestionModel) + } + .frame(maxWidth: .infinity) + .disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion) + .accessibilityLabel("Quick question model") + } + + private func submitQuestion() { + _ = viewModel.sendQuickQuestion() + } +} + +private struct QuickQuestionPickerPill: View { + var title: String + + var body: some View { + HStack(spacing: 8) { + Text(title) + .font(.caption.weight(.medium)) + .foregroundStyle(SybilTheme.text) + .lineLimit(1) + .minimumScaleFactor(0.8) + + Image(systemName: "chevron.down") + .font(.caption.weight(.semibold)) + .foregroundStyle(SybilTheme.textMuted) + } + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, minHeight: 40) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(SybilTheme.surfaceStrong.opacity(0.78)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(SybilTheme.border.opacity(0.78), lineWidth: 1) + ) + ) + } +} + +private struct QuickQuestionMessageView: View { + var message: Message + var isSending: Bool + + private var isPendingAssistant: Bool { + message.id.hasPrefix("temp-assistant-quick-") && + isSending && + message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + if let metadata = message.toolCallMetadata { + Text(toolCallSummary(for: metadata, fallbackContent: message.content)) + .font(.caption) + .foregroundStyle(SybilTheme.textMuted) + .fixedSize(horizontal: false, vertical: true) + } else if isPendingAssistant { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + .tint(SybilTheme.primary) + Text("Thinking...") + .font(.caption) + .foregroundStyle(SybilTheme.textMuted) + } + } else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Markdown(message.content) + .font(.body) + .tint(SybilTheme.primary) + .foregroundStyle(SybilTheme.text.opacity(0.96)) + .textSelection(.enabled) + } + } + + private func toolCallSummary(for metadata: ToolCallMetadata, fallbackContent: String) -> String { + if let summary = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !summary.isEmpty { + return summary + } + if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return fallbackContent + } + return "Ran \(metadata.toolName ?? "tool")." + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift index 80c5e82..41c9945 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift @@ -12,6 +12,11 @@ final class SybilSettingsStore { static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel" static let preferredXAIModel = "sybil.ios.preferredXAIModel" static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel" + static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider" + static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel" + static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel" + static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel" + static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel" } private let defaults: UserDefaults @@ -20,6 +25,8 @@ final class SybilSettingsStore { var adminToken: String var preferredProvider: Provider var preferredModelByProvider: [Provider: String] + var quickQuestionPreferredProvider: Provider + var quickQuestionPreferredModelByProvider: [Provider: String] init(defaults: UserDefaults = .standard) { self.defaults = defaults @@ -33,12 +40,22 @@ final class SybilSettingsStore { let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai self.preferredProvider = provider - self.preferredModelByProvider = [ + let preferredModels: [Provider: String] = [ .openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini", .anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest", .xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini", .hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent" ] + self.preferredModelByProvider = preferredModels + + self.quickQuestionPreferredProvider = + defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider + self.quickQuestionPreferredModelByProvider = [ + .openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini", + .anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest", + .xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini", + .hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent" + ] } func persist() { @@ -56,6 +73,12 @@ final class SybilSettingsStore { defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel) defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel) defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel) + + defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider) + defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel) + defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel) + defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel) + defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel) } var trimmedTokenOrNil: String? { @@ -71,7 +94,7 @@ final class SybilSettingsStore { raw.removeLast() } - guard var components = URLComponents(string: raw) else { + guard let components = URLComponents(string: raw) else { return nil } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index 682567c..862359c 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -111,6 +111,16 @@ final class SybilViewModel { var provider: Provider var modelCatalog: [Provider: ProviderModelInfo] = [:] var model: String + var quickQuestionPrompt = "" + var quickQuestionMessages: [Message] = [] + var quickQuestionError: String? + var quickQuestionProvider: Provider + var quickQuestionModel: String + var quickQuestionSubmittedPrompt: String? + var quickQuestionSubmittedProvider: Provider? + var quickQuestionSubmittedModel: String? + var isQuickQuestionSending = false + var isConvertingQuickQuestion = false @ObservationIgnored private var hasBootstrapped = false @@ -132,6 +142,10 @@ final class SybilViewModel { @ObservationIgnored private var activeSearchAttachTasks: [String: Task] = [:] @ObservationIgnored + private var quickQuestionTask: Task? + @ObservationIgnored + private var quickQuestionRunID: UUID? + @ObservationIgnored private var isAppActive = true @ObservationIgnored private var appLifecycleGeneration = 0 @@ -153,8 +167,14 @@ final class SybilViewModel { ) { self.settings = settings self.clientFactory = clientFactory - self.provider = settings.preferredProvider - self.model = settings.preferredModelByProvider[settings.preferredProvider] ?? "gpt-4.1-mini" + let initialProvider = settings.preferredProvider + let initialModel = settings.preferredModelByProvider[initialProvider] ?? "gpt-4.1-mini" + self.provider = initialProvider + self.model = initialModel + let initialQuickQuestionProvider = settings.quickQuestionPreferredProvider + let initialQuickQuestionModel = settings.quickQuestionPreferredModelByProvider[initialQuickQuestionProvider] ?? initialModel + self.quickQuestionProvider = initialQuickQuestionProvider + self.quickQuestionModel = initialQuickQuestionModel } var providerModelOptions: [String] { @@ -167,6 +187,36 @@ final class SybilViewModel { } } + var quickQuestionProviderModelOptions: [String] { + modelOptions(for: quickQuestionProvider) + } + + var canSendQuickQuestion: Bool { + !isQuickQuestionSending && + !isConvertingQuickQuestion && + !quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var quickQuestionAnswerText: String { + for message in quickQuestionMessages.reversed() where message.role == .assistant { + let content = message.content.trimmingCharacters(in: .whitespacesAndNewlines) + if !content.isEmpty { + return content + } + } + return "" + } + + var canConvertQuickQuestion: Bool { + !isQuickQuestionSending && + !isConvertingQuickQuestion && + !(quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) && + !quickQuestionAnswerText.isEmpty && + quickQuestionSubmittedProvider != nil && + !(quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + } + func modelOptions(for candidate: Provider) -> [String] { let serverModels = modelCatalog[candidate]?.models ?? [] if !serverModels.isEmpty { @@ -422,6 +472,7 @@ final class SybilViewModel { localActiveSearchIDs = [] serverActiveChatIDs = [] serverActiveSearchIDs = [] + resetQuickQuestion() draftIdentity = UUID() composerAttachments = [] settings.persist() @@ -494,6 +545,159 @@ final class SybilViewModel { SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)") } + func setQuickQuestionProvider(_ nextProvider: Provider) { + quickQuestionProvider = nextProvider + + let options = modelOptions(for: nextProvider) + if let preferred = settings.quickQuestionPreferredModelByProvider[nextProvider], options.contains(preferred) { + quickQuestionModel = preferred + } else if let first = options.first { + quickQuestionModel = first + } else { + quickQuestionModel = "" + } + + persistQuickQuestionModelSelection() + } + + func setQuickQuestionModel(_ nextModel: String) { + quickQuestionModel = nextModel + persistQuickQuestionModelSelection() + } + + private func persistQuickQuestionModelSelection() { + settings.quickQuestionPreferredProvider = quickQuestionProvider + let trimmedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedModel.isEmpty { + settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = trimmedModel + } + settings.persist() + } + + func updateQuickQuestionPrompt(_ nextPrompt: String) { + guard nextPrompt != quickQuestionPrompt else { + return + } + + if isQuickQuestionSending || quickQuestionSubmittedPrompt != nil || !quickQuestionMessages.isEmpty { + cancelQuickQuestion() + quickQuestionSubmittedPrompt = nil + quickQuestionSubmittedProvider = nil + quickQuestionSubmittedModel = nil + quickQuestionMessages = [] + quickQuestionError = nil + } + + quickQuestionPrompt = nextPrompt + } + + func resetQuickQuestion() { + cancelQuickQuestion() + quickQuestionPrompt = "" + quickQuestionMessages = [] + quickQuestionError = nil + quickQuestionSubmittedPrompt = nil + quickQuestionSubmittedProvider = nil + quickQuestionSubmittedModel = nil + isConvertingQuickQuestion = false + } + + func cancelQuickQuestion() { + quickQuestionTask?.cancel() + quickQuestionTask = nil + quickQuestionRunID = nil + isQuickQuestionSending = false + } + + @discardableResult + func sendQuickQuestion() -> Task? { + let content = quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty, !isQuickQuestionSending, !isConvertingQuickQuestion else { + return nil + } + + let selectedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines) + guard !selectedModel.isEmpty else { + quickQuestionError = "No model available for selected provider." + return nil + } + + cancelQuickQuestion() + let selectedProvider = quickQuestionProvider + let task = Task { [weak self] in + guard let self else { + return + } + await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel) + } + quickQuestionTask = task + return task + } + + @discardableResult + func convertQuickQuestionToChat() async -> Bool { + let question = quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let answer = quickQuestionAnswerText + guard !question.isEmpty, + !answer.isEmpty, + let submittedProvider = quickQuestionSubmittedProvider, + let submittedModel = quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines), + !submittedModel.isEmpty, + !isQuickQuestionSending, + !isConvertingQuickQuestion + else { + return false + } + + isConvertingQuickQuestion = true + quickQuestionError = nil + defer { + isConvertingQuickQuestion = false + } + + do { + let titleSeed = question.split(whereSeparator: \.isNewline).first.map(String.init) ?? question + let title = String(titleSeed.trimmingCharacters(in: .whitespacesAndNewlines).prefix(48)) + let chat = try await client().createChat( + title: title.isEmpty ? "Quick question" : title, + provider: submittedProvider, + model: submittedModel, + messages: [ + CompletionRequestMessage(role: .user, content: question), + CompletionRequestMessage(role: .assistant, content: answer) + ] + ) + + setProvider(submittedProvider, model: submittedModel) + chats.removeAll(where: { $0.id == chat.id }) + chats.insert(chat, at: 0) + draftKind = nil + selectedItem = .chat(chat.id) + selectedChat = ChatDetail( + id: chat.id, + title: chat.title, + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + initiatedProvider: chat.initiatedProvider, + initiatedModel: chat.initiatedModel, + lastUsedProvider: chat.lastUsedProvider, + lastUsedModel: chat.lastUsedModel, + messages: [] + ) + selectedSearch = nil + composer = "" + composerAttachments = [] + + await refreshCollections(preferredSelection: .chat(chat.id)) + resetQuickQuestion() + return true + } catch { + quickQuestionError = normalizeAPIError(error) + SybilLog.error(SybilLog.ui, "Convert quick question to chat failed", error: error) + return false + } + } + func startNewChat() { SybilLog.debug(SybilLog.ui, "Starting draft chat") resetSelectionLoading() @@ -837,6 +1041,91 @@ final class SybilViewModel { isCreatingSearchChat = false } + private func runQuickQuestion(prompt: String, provider: Provider, model: String) async { + let runID = UUID() + quickQuestionRunID = runID + quickQuestionError = nil + quickQuestionSubmittedPrompt = prompt + quickQuestionSubmittedProvider = provider + quickQuestionSubmittedModel = model + quickQuestionMessages = [ + Message( + id: "temp-assistant-quick-\(UUID().uuidString)", + createdAt: Date(), + role: .assistant, + content: "", + name: nil + ) + ] + isQuickQuestionSending = true + + defer { + if quickQuestionRunID == runID { + quickQuestionTask = nil + quickQuestionRunID = nil + isQuickQuestionSending = false + } + } + + let streamStatus = CompletionStreamStatus() + + do { + try await client().runCompletionStream( + body: CompletionStreamRequest( + chatId: nil, + persist: false, + provider: provider, + model: model, + messages: [CompletionRequestMessage(role: .user, content: prompt)] + ) + ) { [weak self] event in + guard let self else { return } + await self.applyQuickQuestionCompletionEvent(event, streamStatus: streamStatus) + } + + if let streamError = await streamStatus.error() { + throw APIError.httpError(statusCode: 502, message: streamError) + } + } catch { + guard quickQuestionRunID == runID else { + return + } + if isCancellation(error) { + return + } + + quickQuestionError = normalizeAPIError(error) + SybilLog.error(SybilLog.ui, "Quick question failed", error: error) + } + } + + private func applyQuickQuestionCompletionEvent(_ event: CompletionStreamEvent, streamStatus: CompletionStreamStatus) async { + switch event { + case .meta: + break + + case let .toolCall(payload): + insertQuickQuestionToolCallMessage(payload) + + case let .delta(payload): + guard !payload.text.isEmpty else { return } + mutateQuickQuestionAssistantMessage { existing in + existing + payload.text + } + + case let .done(payload): + mutateQuickQuestionAssistantMessage { _ in + payload.text + } + + case let .error(payload): + await streamStatus.setError(payload.message) + + case .ignored: + break + } + } + private func loadInitialData(using client: any SybilAPIClienting) async { isLoadingCollections = true errorMessage = nil @@ -914,6 +1203,22 @@ final class SybilViewModel { model = preferred } + if !providerOptions.contains(quickQuestionProvider), let firstProvider = providerOptions.first { + quickQuestionProvider = firstProvider + settings.quickQuestionPreferredProvider = firstProvider + } + + if !quickQuestionProviderModelOptions.contains(quickQuestionModel), let first = quickQuestionProviderModelOptions.first { + quickQuestionModel = first + settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = first + } + + if let preferred = settings.quickQuestionPreferredModelByProvider[quickQuestionProvider], + quickQuestionProviderModelOptions.contains(preferred) + { + quickQuestionModel = preferred + } + settings.persist() } @@ -1764,6 +2069,15 @@ final class SybilViewModel { pendingChatStates[chatID] = pending } + private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) { + let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") } + guard let index else { + return + } + + quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content) + } + private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) { guard var pending = pendingChatStates[chatID] else { return @@ -1773,6 +2087,31 @@ final class SybilViewModel { return } + let message = toolCallMessage(for: payload) + + if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) { + pending.messages.insert(message, at: assistantIndex) + } else { + pending.messages.append(message) + } + + pendingChatStates[chatID] = pending + } + + private func insertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) { + if quickQuestionMessages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) { + return + } + + let message = toolCallMessage(for: payload) + if let assistantIndex = quickQuestionMessages.indices.last(where: { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }) { + quickQuestionMessages.insert(message, at: assistantIndex) + } else { + quickQuestionMessages.append(message) + } + } + + private func toolCallMessage(for payload: CompletionStreamToolCall) -> Message { let metadata: JSONValue = .object([ "kind": .string("tool_call"), "toolCallId": .string(payload.toolCallId), @@ -1791,7 +2130,7 @@ final class SybilViewModel { ? "Ran tool '\(payload.name)'." : payload.summary - let message = Message( + return Message( id: "temp-tool-\(payload.toolCallId)", createdAt: Date(), role: .tool, @@ -1799,14 +2138,6 @@ final class SybilViewModel { 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) - } - - pendingChatStates[chatID] = pending } private var currentChatID: String? { diff --git a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift index b73751e..3835851 100644 --- a/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift +++ b/ios/Packages/Sybil/Tests/SybilTests/SybilTests.swift @@ -6,13 +6,22 @@ import Testing private struct MockClientCallSnapshot: Sendable { var listChats = 0 var listSearches = 0 + var createChat = 0 var getChat = 0 var getSearch = 0 var getActiveRuns = 0 + var runCompletionStream = 0 var attachCompletionStream = 0 var attachSearchStream = 0 } +private struct ChatCreateCallSnapshot: Sendable { + var title: String? + var provider: Provider? + var model: String? + var messages: [CompletionRequestMessage]? +} + private struct UnexpectedClientCall: Error {} private actor MockSybilClient: SybilAPIClienting { @@ -24,6 +33,9 @@ private actor MockSybilClient: SybilAPIClienting { private let activeRunsResponse: ActiveRunsResponse private var snapshot = MockClientCallSnapshot() + private var lastCreateChatCall: ChatCreateCallSnapshot? + private var lastCompletionStreamBody: CompletionStreamRequest? + private var completionStreamEvents: [CompletionStreamEvent]? private var getChatDelayNanoseconds: UInt64 = 0 private var getSearchDelayNanoseconds: UInt64 = 0 private var completionStreamNetworkErrorMessage: String? @@ -55,6 +67,19 @@ private actor MockSybilClient: SybilAPIClienting { snapshot } + func currentCreateChatCall() -> ChatCreateCallSnapshot? { + lastCreateChatCall + } + + func currentCompletionStreamBody() -> CompletionStreamRequest? { + lastCompletionStreamBody + } + + func setCompletionStreamEvents(_ events: [CompletionStreamEvent], delayNanoseconds: UInt64 = 0) { + completionStreamEvents = events + completionStreamDelayNanoseconds = delayNanoseconds + } + func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) { completionStreamNetworkErrorMessage = message completionStreamDelayNanoseconds = delayNanoseconds @@ -100,7 +125,19 @@ private actor MockSybilClient: SybilAPIClienting { return chatsResponse } - func createChat(title: String?) async throws -> ChatSummary { + func createChat( + title: String?, + provider: Provider?, + model: String?, + messages: [CompletionRequestMessage]? + ) async throws -> ChatSummary { + snapshot.createChat += 1 + lastCreateChatCall = ChatCreateCallSnapshot( + title: title, + provider: provider, + model: model, + messages: messages + ) if let createChatResponse { return createChatResponse } @@ -167,12 +204,20 @@ private actor MockSybilClient: SybilAPIClienting { body: CompletionStreamRequest, onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void ) async throws { + snapshot.runCompletionStream += 1 + lastCompletionStreamBody = body if completionStreamDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds) } if let completionStreamNetworkErrorMessage { throw APIError.networkError(message: completionStreamNetworkErrorMessage) } + if let completionStreamEvents { + for event in completionStreamEvents { + await onEvent(event) + } + return + } throw UnexpectedClientCall() } @@ -470,6 +515,117 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD await sendTask.value } +@MainActor +@Test func quickQuestionRunsNonPersistentCompletionStream() async throws { + let client = MockSybilClient() + await client.setCompletionStreamEvents([ + .delta(CompletionStreamDelta(text: "Reset it from ")), + .done(CompletionStreamDone(text: "Reset it from Settings.")) + ]) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.isAuthenticated = true + viewModel.isCheckingSession = false + viewModel.quickQuestionPrompt = "How do I reset my password?" + + let task = viewModel.sendQuickQuestion() + await task?.value + + let snapshot = await client.currentSnapshot() + let body = await client.currentCompletionStreamBody() + #expect(snapshot.runCompletionStream == 1) + #expect(body?.persist == false) + #expect(body?.chatId == nil) + #expect(body?.provider == .openai) + #expect(body?.messages.first?.role == .user) + #expect(body?.messages.first?.content == "How do I reset my password?") + #expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.") + #expect(!viewModel.isQuickQuestionSending) +} + +@MainActor +@Test func quickQuestionConvertCreatesSeededChat() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_250) + let chat = makeChatSummary(id: "quick-chat", date: date) + let detail = ChatDetail( + id: chat.id, + title: chat.title, + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + initiatedProvider: .openai, + initiatedModel: "gpt-4.1-mini", + lastUsedProvider: .openai, + lastUsedModel: "gpt-4.1-mini", + messages: [ + Message(id: "quick-user", createdAt: date, role: .user, content: "How do I reset my password?", name: nil), + Message(id: "quick-assistant", createdAt: date, role: .assistant, content: "Reset it from Settings.", name: nil) + ] + ) + let client = MockSybilClient( + chatsResponse: [chat], + chatDetails: [chat.id: detail], + createChatResponse: chat + ) + let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } + viewModel.isAuthenticated = true + viewModel.isCheckingSession = false + viewModel.quickQuestionSubmittedPrompt = "How do I reset my password?" + viewModel.quickQuestionSubmittedProvider = .openai + viewModel.quickQuestionSubmittedModel = "gpt-4.1-mini" + viewModel.quickQuestionMessages = [ + Message( + id: "temp-assistant-quick", + createdAt: date, + role: .assistant, + content: "Reset it from Settings.", + name: nil + ) + ] + + let didConvert = await viewModel.convertQuickQuestionToChat() + + let snapshot = await client.currentSnapshot() + let createCall = await client.currentCreateChatCall() + #expect(didConvert) + #expect(snapshot.createChat == 1) + #expect(createCall?.title == "How do I reset my password?") + #expect(createCall?.provider == .openai) + #expect(createCall?.model == "gpt-4.1-mini") + #expect(createCall?.messages?.map(\.role) == [.user, .assistant]) + #expect(createCall?.messages?.map(\.content) == ["How do I reset my password?", "Reset it from Settings."]) + #expect(viewModel.selectedItem == .chat("quick-chat")) + #expect(viewModel.quickQuestionPrompt.isEmpty) +} + +@MainActor +@Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws { + let defaults = UserDefaults(suiteName: #function)! + defaults.removePersistentDomain(forName: #function) + let settings = SybilSettingsStore(defaults: defaults) + settings.apiBaseURL = "http://127.0.0.1:8787" + let viewModel = SybilViewModel(settings: settings) { _ in MockSybilClient() } + viewModel.modelCatalog = [ + .openai: ProviderModelInfo(models: ["gpt-4.1-mini", "gpt-4o"], loadedAt: nil, error: nil), + .anthropic: ProviderModelInfo(models: ["claude-3-5-sonnet-latest", "claude-3-haiku"], loadedAt: nil, error: nil) + ] + + viewModel.setQuickQuestionProvider(.anthropic) + viewModel.setQuickQuestionModel("claude-3-haiku") + + #expect(viewModel.quickQuestionProvider == .anthropic) + #expect(viewModel.quickQuestionModel == "claude-3-haiku") + #expect(settings.preferredProvider == .openai) + + let reloadedSettings = SybilSettingsStore(defaults: defaults) + #expect(reloadedSettings.quickQuestionPreferredProvider == .anthropic) + #expect(reloadedSettings.quickQuestionPreferredModelByProvider[.anthropic] == "claude-3-haiku") + #expect(reloadedSettings.preferredProvider == .openai) + + let reloadedViewModel = SybilViewModel(settings: reloadedSettings) { _ in MockSybilClient() } + #expect(reloadedViewModel.quickQuestionProvider == .anthropic) + #expect(reloadedViewModel.quickQuestionModel == "claude-3-haiku") + #expect(reloadedViewModel.provider == .openai) +} + @MainActor @Test func reconnectAttachesSelectedActiveChatStream() async throws { let date = Date(timeIntervalSince1970: 1_700_000_260)