ios: quick question UI

This commit is contained in:
2026-05-06 21:53:51 -07:00
parent 0c9b4d1ed3
commit bd0200ac98
12 changed files with 1007 additions and 19 deletions

View File

@@ -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? {