ios: quick question UI
This commit is contained in:
17
ios/Apps/Sybil/Info.plist
Normal file
17
ios/Apps/Sybil/Info.plist
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIApplicationShortcutItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UIApplicationShortcutItemType</key>
|
||||
<string>net.buzzert.sybil2.quick-question</string>
|
||||
<key>UIApplicationShortcutItemTitle</key>
|
||||
<string>Quick question</string>
|
||||
<key>UIApplicationShortcutItemIconSymbolName</key>
|
||||
<string>sparkles</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
297
ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift
Normal file
297
ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift
Normal file
@@ -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")."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Void, Never>] = [:]
|
||||
@ObservationIgnored
|
||||
private var quickQuestionTask: Task<Void, Never>?
|
||||
@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<Void, Never>? {
|
||||
let content = quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty, !isQuickQuestionSending, !isConvertingQuickQuestion else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let selectedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedModel.isEmpty else {
|
||||
quickQuestionError = "No model available for selected provider."
|
||||
return nil
|
||||
}
|
||||
|
||||
cancelQuickQuestion()
|
||||
let selectedProvider = quickQuestionProvider
|
||||
let task = Task { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
||||
}
|
||||
quickQuestionTask = task
|
||||
return task
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func convertQuickQuestionToChat() async -> Bool {
|
||||
let question = quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let answer = quickQuestionAnswerText
|
||||
guard !question.isEmpty,
|
||||
!answer.isEmpty,
|
||||
let submittedProvider = quickQuestionSubmittedProvider,
|
||||
let submittedModel = quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!submittedModel.isEmpty,
|
||||
!isQuickQuestionSending,
|
||||
!isConvertingQuickQuestion
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
isConvertingQuickQuestion = true
|
||||
quickQuestionError = nil
|
||||
defer {
|
||||
isConvertingQuickQuestion = false
|
||||
}
|
||||
|
||||
do {
|
||||
let titleSeed = question.split(whereSeparator: \.isNewline).first.map(String.init) ?? question
|
||||
let title = String(titleSeed.trimmingCharacters(in: .whitespacesAndNewlines).prefix(48))
|
||||
let chat = try await client().createChat(
|
||||
title: title.isEmpty ? "Quick question" : title,
|
||||
provider: submittedProvider,
|
||||
model: submittedModel,
|
||||
messages: [
|
||||
CompletionRequestMessage(role: .user, content: question),
|
||||
CompletionRequestMessage(role: .assistant, content: answer)
|
||||
]
|
||||
)
|
||||
|
||||
setProvider(submittedProvider, model: submittedModel)
|
||||
chats.removeAll(where: { $0.id == chat.id })
|
||||
chats.insert(chat, at: 0)
|
||||
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? {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user