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
|
@main
|
||||||
struct SybilApp: App
|
struct SybilApp: App
|
||||||
{
|
{
|
||||||
|
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
SplitView()
|
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
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||||
TARGETED_DEVICE_FAMILY: "1,2,6"
|
TARGETED_DEVICE_FAMILY: "1,2,6"
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: 1.7
|
MARKETING_VERSION: 1.7
|
||||||
CURRENT_PROJECT_VERSION: 8
|
CURRENT_PROJECT_VERSION: 8
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import SwiftUI
|
|||||||
|
|
||||||
public struct SplitView: View {
|
public struct SplitView: View {
|
||||||
@State private var viewModel = SybilViewModel()
|
@State private var viewModel = SybilViewModel()
|
||||||
|
@ObservedObject private var quickActionRouter = SybilQuickActionRouter.shared
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var shouldRefreshOnForeground = false
|
@State private var shouldRefreshOnForeground = false
|
||||||
@State private var composerFocusRequest = 0
|
@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
|
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
|
||||||
|
|
||||||
private var keyboardActions: SybilKeyboardActions? {
|
private var keyboardActions: SybilKeyboardActions? {
|
||||||
@@ -74,8 +78,28 @@ public struct SplitView: View {
|
|||||||
.font(.sybil(.body))
|
.font(.sybil(.body))
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||||
|
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
||||||
|
SybilQuickQuestionView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
focusRequest: quickQuestionFocusRequest
|
||||||
|
)
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.bootstrap()
|
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
|
.onChange(of: scenePhase) { _, nextPhase in
|
||||||
switch nextPhase {
|
switch nextPhase {
|
||||||
@@ -112,6 +136,28 @@ public struct SplitView: View {
|
|||||||
columnVisibility = .all
|
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 {
|
public struct SybilCommands: Commands {
|
||||||
|
|||||||
@@ -49,11 +49,16 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return response.chats
|
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(
|
let response = try await request(
|
||||||
"/v1/chats",
|
"/v1/chats",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: AnyEncodable(ChatCreateBody(title: title)),
|
body: AnyEncodable(ChatCreateBody(title: title, provider: provider, model: model, messages: messages)),
|
||||||
responseType: ChatCreateResponse.self
|
responseType: ChatCreateResponse.self
|
||||||
)
|
)
|
||||||
return response.chat
|
return response.chat
|
||||||
@@ -617,6 +622,7 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
|
|
||||||
struct CompletionStreamRequest: Codable, Sendable {
|
struct CompletionStreamRequest: Codable, Sendable {
|
||||||
var chatId: String?
|
var chatId: String?
|
||||||
|
var persist: Bool? = nil
|
||||||
var provider: Provider
|
var provider: Provider
|
||||||
var model: String
|
var model: String
|
||||||
var messages: [CompletionRequestMessage]
|
var messages: [CompletionRequestMessage]
|
||||||
@@ -624,6 +630,9 @@ struct CompletionStreamRequest: Codable, Sendable {
|
|||||||
|
|
||||||
private struct ChatCreateBody: Encodable {
|
private struct ChatCreateBody: Encodable {
|
||||||
var title: String?
|
var title: String?
|
||||||
|
var provider: Provider?
|
||||||
|
var model: String?
|
||||||
|
var messages: [CompletionRequestMessage]?
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SearchCreateBody: Encodable {
|
private struct SearchCreateBody: Encodable {
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import Foundation
|
|||||||
protocol SybilAPIClienting: Sendable {
|
protocol SybilAPIClienting: Sendable {
|
||||||
func verifySession() async throws -> AuthSession
|
func verifySession() async throws -> AuthSession
|
||||||
func listChats() async throws -> [ChatSummary]
|
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 getChat(chatID: String) async throws -> ChatDetail
|
||||||
func deleteChat(chatID: String) async throws
|
func deleteChat(chatID: String) async throws
|
||||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
||||||
@@ -32,3 +37,9 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
) async throws
|
) 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 struct CompletionStreamMeta: Codable, Sendable {
|
||||||
public var chatId: String
|
public var chatId: String?
|
||||||
public var callId: String
|
public var callId: String?
|
||||||
public var provider: Provider
|
public var provider: Provider
|
||||||
public var model: String
|
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 preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
||||||
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
||||||
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
|
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
|
private let defaults: UserDefaults
|
||||||
@@ -20,6 +25,8 @@ final class SybilSettingsStore {
|
|||||||
var adminToken: String
|
var adminToken: String
|
||||||
var preferredProvider: Provider
|
var preferredProvider: Provider
|
||||||
var preferredModelByProvider: [Provider: String]
|
var preferredModelByProvider: [Provider: String]
|
||||||
|
var quickQuestionPreferredProvider: Provider
|
||||||
|
var quickQuestionPreferredModelByProvider: [Provider: String]
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
@@ -33,12 +40,22 @@ final class SybilSettingsStore {
|
|||||||
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
||||||
self.preferredProvider = provider
|
self.preferredProvider = provider
|
||||||
|
|
||||||
self.preferredModelByProvider = [
|
let preferredModels: [Provider: String] = [
|
||||||
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
||||||
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
||||||
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
|
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
|
||||||
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
|
.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() {
|
func persist() {
|
||||||
@@ -56,6 +73,12 @@ final class SybilSettingsStore {
|
|||||||
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
|
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
|
||||||
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
||||||
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
|
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? {
|
var trimmedTokenOrNil: String? {
|
||||||
@@ -71,7 +94,7 @@ final class SybilSettingsStore {
|
|||||||
raw.removeLast()
|
raw.removeLast()
|
||||||
}
|
}
|
||||||
|
|
||||||
guard var components = URLComponents(string: raw) else {
|
guard let components = URLComponents(string: raw) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,16 @@ final class SybilViewModel {
|
|||||||
var provider: Provider
|
var provider: Provider
|
||||||
var modelCatalog: [Provider: ProviderModelInfo] = [:]
|
var modelCatalog: [Provider: ProviderModelInfo] = [:]
|
||||||
var model: String
|
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
|
@ObservationIgnored
|
||||||
private var hasBootstrapped = false
|
private var hasBootstrapped = false
|
||||||
@@ -132,6 +142,10 @@ final class SybilViewModel {
|
|||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var activeSearchAttachTasks: [String: Task<Void, Never>] = [:]
|
private var activeSearchAttachTasks: [String: Task<Void, Never>] = [:]
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
|
private var quickQuestionTask: Task<Void, Never>?
|
||||||
|
@ObservationIgnored
|
||||||
|
private var quickQuestionRunID: UUID?
|
||||||
|
@ObservationIgnored
|
||||||
private var isAppActive = true
|
private var isAppActive = true
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var appLifecycleGeneration = 0
|
private var appLifecycleGeneration = 0
|
||||||
@@ -153,8 +167,14 @@ final class SybilViewModel {
|
|||||||
) {
|
) {
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.clientFactory = clientFactory
|
self.clientFactory = clientFactory
|
||||||
self.provider = settings.preferredProvider
|
let initialProvider = settings.preferredProvider
|
||||||
self.model = settings.preferredModelByProvider[settings.preferredProvider] ?? "gpt-4.1-mini"
|
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] {
|
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] {
|
func modelOptions(for candidate: Provider) -> [String] {
|
||||||
let serverModels = modelCatalog[candidate]?.models ?? []
|
let serverModels = modelCatalog[candidate]?.models ?? []
|
||||||
if !serverModels.isEmpty {
|
if !serverModels.isEmpty {
|
||||||
@@ -422,6 +472,7 @@ final class SybilViewModel {
|
|||||||
localActiveSearchIDs = []
|
localActiveSearchIDs = []
|
||||||
serverActiveChatIDs = []
|
serverActiveChatIDs = []
|
||||||
serverActiveSearchIDs = []
|
serverActiveSearchIDs = []
|
||||||
|
resetQuickQuestion()
|
||||||
draftIdentity = UUID()
|
draftIdentity = UUID()
|
||||||
composerAttachments = []
|
composerAttachments = []
|
||||||
settings.persist()
|
settings.persist()
|
||||||
@@ -494,6 +545,159 @@ final class SybilViewModel {
|
|||||||
SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)")
|
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() {
|
func startNewChat() {
|
||||||
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
||||||
resetSelectionLoading()
|
resetSelectionLoading()
|
||||||
@@ -837,6 +1041,91 @@ final class SybilViewModel {
|
|||||||
isCreatingSearchChat = false
|
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 {
|
private func loadInitialData(using client: any SybilAPIClienting) async {
|
||||||
isLoadingCollections = true
|
isLoadingCollections = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
@@ -914,6 +1203,22 @@ final class SybilViewModel {
|
|||||||
model = preferred
|
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()
|
settings.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1764,6 +2069,15 @@ final class SybilViewModel {
|
|||||||
pendingChatStates[chatID] = pending
|
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) {
|
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||||
guard var pending = pendingChatStates[chatID] else {
|
guard var pending = pendingChatStates[chatID] else {
|
||||||
return
|
return
|
||||||
@@ -1773,6 +2087,31 @@ final class SybilViewModel {
|
|||||||
return
|
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([
|
let metadata: JSONValue = .object([
|
||||||
"kind": .string("tool_call"),
|
"kind": .string("tool_call"),
|
||||||
"toolCallId": .string(payload.toolCallId),
|
"toolCallId": .string(payload.toolCallId),
|
||||||
@@ -1791,7 +2130,7 @@ final class SybilViewModel {
|
|||||||
? "Ran tool '\(payload.name)'."
|
? "Ran tool '\(payload.name)'."
|
||||||
: payload.summary
|
: payload.summary
|
||||||
|
|
||||||
let message = Message(
|
return Message(
|
||||||
id: "temp-tool-\(payload.toolCallId)",
|
id: "temp-tool-\(payload.toolCallId)",
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
role: .tool,
|
role: .tool,
|
||||||
@@ -1799,14 +2138,6 @@ final class SybilViewModel {
|
|||||||
name: payload.name,
|
name: payload.name,
|
||||||
metadata: metadata
|
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? {
|
private var currentChatID: String? {
|
||||||
|
|||||||
@@ -6,13 +6,22 @@ import Testing
|
|||||||
private struct MockClientCallSnapshot: Sendable {
|
private struct MockClientCallSnapshot: Sendable {
|
||||||
var listChats = 0
|
var listChats = 0
|
||||||
var listSearches = 0
|
var listSearches = 0
|
||||||
|
var createChat = 0
|
||||||
var getChat = 0
|
var getChat = 0
|
||||||
var getSearch = 0
|
var getSearch = 0
|
||||||
var getActiveRuns = 0
|
var getActiveRuns = 0
|
||||||
|
var runCompletionStream = 0
|
||||||
var attachCompletionStream = 0
|
var attachCompletionStream = 0
|
||||||
var attachSearchStream = 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 struct UnexpectedClientCall: Error {}
|
||||||
|
|
||||||
private actor MockSybilClient: SybilAPIClienting {
|
private actor MockSybilClient: SybilAPIClienting {
|
||||||
@@ -24,6 +33,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private let activeRunsResponse: ActiveRunsResponse
|
private let activeRunsResponse: ActiveRunsResponse
|
||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
|
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
||||||
|
private var lastCompletionStreamBody: CompletionStreamRequest?
|
||||||
|
private var completionStreamEvents: [CompletionStreamEvent]?
|
||||||
private var getChatDelayNanoseconds: UInt64 = 0
|
private var getChatDelayNanoseconds: UInt64 = 0
|
||||||
private var getSearchDelayNanoseconds: UInt64 = 0
|
private var getSearchDelayNanoseconds: UInt64 = 0
|
||||||
private var completionStreamNetworkErrorMessage: String?
|
private var completionStreamNetworkErrorMessage: String?
|
||||||
@@ -55,6 +67,19 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
snapshot
|
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) {
|
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
||||||
completionStreamNetworkErrorMessage = message
|
completionStreamNetworkErrorMessage = message
|
||||||
completionStreamDelayNanoseconds = delayNanoseconds
|
completionStreamDelayNanoseconds = delayNanoseconds
|
||||||
@@ -100,7 +125,19 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
return chatsResponse
|
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 {
|
if let createChatResponse {
|
||||||
return createChatResponse
|
return createChatResponse
|
||||||
}
|
}
|
||||||
@@ -167,12 +204,20 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws {
|
) async throws {
|
||||||
|
snapshot.runCompletionStream += 1
|
||||||
|
lastCompletionStreamBody = body
|
||||||
if completionStreamDelayNanoseconds > 0 {
|
if completionStreamDelayNanoseconds > 0 {
|
||||||
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
||||||
}
|
}
|
||||||
if let completionStreamNetworkErrorMessage {
|
if let completionStreamNetworkErrorMessage {
|
||||||
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
|
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
|
||||||
}
|
}
|
||||||
|
if let completionStreamEvents {
|
||||||
|
for event in completionStreamEvents {
|
||||||
|
await onEvent(event)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +515,117 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
await sendTask.value
|
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
|
@MainActor
|
||||||
@Test func reconnectAttachesSelectedActiveChatStream() async throws {
|
@Test func reconnectAttachesSelectedActiveChatStream() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_260)
|
let date = Date(timeIntervalSince1970: 1_700_000_260)
|
||||||
|
|||||||
Reference in New Issue
Block a user