ios: quick question UI
This commit is contained in:
@@ -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? {
|
||||
|
||||
Reference in New Issue
Block a user