diff --git a/Core/Core/Common/CommonModels/API/API.swift b/Core/Core/Common/CommonModels/API/API.swift index 877996292c..3df52bf6b8 100644 --- a/Core/Core/Common/CommonModels/API/API.swift +++ b/Core/Core/Common/CommonModels/API/API.swift @@ -16,6 +16,7 @@ // along with this program. If not, see . // +import Combine import Foundation public class API { diff --git a/Core/Core/Features/Inbox/ComposeMessage/View/WrappingHStack.swift b/Core/Core/Features/Inbox/ComposeMessage/View/WrappingHStack.swift index a19d4345e8..9d5fa0e8e6 100644 --- a/Core/Core/Features/Inbox/ComposeMessage/View/WrappingHStack.swift +++ b/Core/Core/Features/Inbox/ComposeMessage/View/WrappingHStack.swift @@ -22,17 +22,21 @@ public struct WrappingHStack: View where Model: Hashable, V: View { public typealias ViewGenerator = (Model) -> V var models: [Model] var viewGenerator: ViewGenerator - var horizontalSpacing: CGFloat = 5 - var verticalSpacing: CGFloat = 5 + var horizontalSpacing: CGFloat + var verticalSpacing: CGFloat @State private var totalHeight = CGFloat.zero public init( models: [Model], + horizontalSpacing: CGFloat = 5, + verticalSpacing: CGFloat = 5, viewGenerator: @escaping ViewGenerator ) { self.models = models self.viewGenerator = viewGenerator + self.horizontalSpacing = horizontalSpacing + self.verticalSpacing = horizontalSpacing } public var body: some View { diff --git a/Horizon/Horizon/Resources/Localizable.xcstrings b/Horizon/Horizon/Resources/Localizable.xcstrings index f85dcd6615..52714abcbf 100644 --- a/Horizon/Horizon/Resources/Localizable.xcstrings +++ b/Horizon/Horizon/Resources/Localizable.xcstrings @@ -210,6 +210,9 @@ }, "Cairo (+02:00/+03:00)" : { + }, + "Can I answer any questions about this document for you?" : { + }, "Canberra (+10:00/+11:00)" : { @@ -285,6 +288,9 @@ }, "Copenhagen (+01:00/+02:00)" : { + }, + "Create a Quiz" : { + }, "Create message" : { @@ -390,9 +396,6 @@ }, "Filter by person" : { - }, - "Flash cards" : { - }, "Fortaleza (-03:00/-03:00)" : { @@ -402,6 +405,9 @@ }, "Full Name can only be changed by your institution." : { + }, + "Generate Flash Cards" : { + }, "Georgetown (-04:00/-04:00)" : { @@ -435,6 +441,9 @@ }, "Hello, Career!" : { + }, + "Hello! Which course you'd like to discuss today?" : { + }, "Helsinki (+02:00/+03:00)" : { @@ -454,7 +463,7 @@ "hours" : { }, - "How can I help today?" : { + "How can I help you with this page?" : { }, "Important" : { @@ -504,9 +513,6 @@ }, "Kathmandu (+05:45/+05:45)" : { - }, - "Key takeaways" : { - }, "Kolkata (+05:30)" : { @@ -534,9 +540,6 @@ }, "Learn" : { - }, - "Learner" : { - }, "Lima (-05:00/-05:00)" : { @@ -648,9 +651,18 @@ }, "No" : { + }, + "No additional information found." : { + + }, + "No key takeaways found." : { + }, "No notification activity yet." : { + }, + "No summary found." : { + }, "Norfolk Island (+11:00/+12:00)" : { @@ -758,9 +770,6 @@ }, "Quito (-05:00/-05:00)" : { - }, - "Quiz" : { - }, "Rangoon (+06:30/+06:30)" : { @@ -866,6 +875,12 @@ }, "Something went wrong" : { + }, + "Sorry, can we try that again? Which course is it you'd like to discuss?" : { + + }, + "Sorry, I don't have an answer for that right now." : { + }, "Sort By" : { @@ -896,9 +911,6 @@ }, "Submitted" : { - }, - "Summarize" : { - }, "Support" : { @@ -927,10 +939,10 @@ "Tehran (+03:30/+03:30)" : { }, - "Tell me more" : { + "Text" : { }, - "Text" : { + "Thank you for your feedback!" : { }, "There are no scored activities in this course." : { @@ -1037,6 +1049,12 @@ }, "West Central Africa (+01:00)" : { + }, + "What would you like to discuss about the course %@?" : { + + }, + "What would you like to discuss today?" : { + }, "Yakutsk (+09:00/+09:00)" : { diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistAssembly.swift b/Horizon/Horizon/Sources/Features/Assist/AssistAssembly.swift index 6bc257f0e8..f65815c0a5 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistAssembly.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistAssembly.swift @@ -44,21 +44,19 @@ final class AssistAssembly { return navigationController } - static func makeChatBotInteractor(courseId: String? = nil, pageUrl: String? = nil, fileId: String? = nil) -> AssistChatInteractor { - if let courseId = courseId, let pageUrl = pageUrl { - return AssistChatInteractorLive( - courseId: courseId, - pageUrl: pageUrl - ) - } - if let courseId = courseId, let fileId = fileId { - return AssistChatInteractorLive( - courseId: courseId, - fileId: fileId, - downloadFileInteractor: DownloadFileInteractorLive(courseID: courseId) - ) - } - return AssistChatInteractorLive() + static func makeChatBotInteractor( + courseId: String? = nil, + pageUrl: String? = nil, + fileId: String? = nil + ) -> AssistChatInteractor { + AssistChatInteractorLive( + courseID: courseId, + fileID: fileId, + pageURL: pageUrl, + downloadFileInteractor: courseId.map { + DownloadFileInteractorLive(courseID: $0) + } + ) } static func makeAIQuizView( diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatAction.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatAction.swift index 0413898b77..90610cc809 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatAction.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatAction.swift @@ -20,8 +20,10 @@ import Core /// ChatBotActions are published to the AssistChatInteractor. The Interactor reacts to the action and publishes one or more ChatBotResponses. enum AssistChatAction { + case begin + /// the user is chatting with the bot - case chat(prompt: String = "", history: [AssistChatMessage] = []) + case chat(prompt: String?, history: [AssistChatMessage] = []) /// the user has selected a chip while viewing a file case chip(option: AssistChipOption, history: [AssistChatMessage] = []) diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatMessage.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatMessage.swift index 19d1e5b837..8e2640578c 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatMessage.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatMessage.swift @@ -19,7 +19,7 @@ import Foundation /// A message returned from the interactor -struct AssistChatMessage: Codable, Equatable { +struct AssistChatMessage { let id: UUID @@ -27,43 +27,77 @@ struct AssistChatMessage: Codable, Equatable { /// If set to null, then it is removed from the list of messages sent to the AI let prompt: String? - /// The text shown to the user in the history. This may be different from the prompt sent to the AI - let text: String + /// The text shown to the user on screen. This may be different from the prompt sent to the AI + let text: String? /// Whether or not this came from the AI let role: Role - init(botResponse: String) { - prompt = botResponse - text = botResponse - role = .Assistant - id = UUID() + /// A list of options that the user can select from. + let chipOptions: [AssistChipOption]? + + let flashCards: [AssistChatFlashCard]? + + let quizItems: [QuizItem]? + + init(botResponse: String, chipOptions: [AssistChipOption] = []) { + self.init( + role: .Assistant, + prompt: botResponse, + text: botResponse, + chipOptions: chipOptions + ) } - init(userResponse: String) { - prompt = userResponse - text = userResponse - role = .User - id = UUID() + /// The user has asked for FlashCards + init(flashCards: [AssistChatFlashCard]) { + self.init( + role: .Assistant, + flashCards: flashCards + ) } - init(prompt: String?, text: String, role: Role = .User) { - self.prompt = prompt - self.text = text - id = UUID() + /// The user has asked for a quiz + init(quizItems: [QuizItem]) { + self.init( + role: .Assistant, + quizItems: quizItems + ) + } - self.role = role + init(userResponse: String, prompt: String? = nil) { + self.init( + role: .User, + prompt: prompt ?? userResponse, + text: userResponse + ) } - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(prompt, forKey: .prompt) - try container.encode(text, forKey: .text) - try container.encode(role, forKey: .role) + private init( + role: Role, + prompt: String? = nil, + text: String? = nil, + chipOptions: [AssistChipOption] = [], + flashCards: [AssistChatFlashCard] = [], + quizItems: [QuizItem]? = nil + ) { + self.id = UUID() + self.role = role + self.prompt = prompt + self.text = text + self.chipOptions = chipOptions + self.flashCards = flashCards + self.quizItems = quizItems } enum Role: String, Codable, Equatable { case Assistant case User } + + struct QuizItem: Codable, Equatable { + let question: String + let answers: [String] + let correctAnswerIndex: Int + } } diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatPageContext.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatPageContext.swift deleted file mode 100644 index 39269d35b0..0000000000 --- a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatPageContext.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// This file is part of Canvas. -// Copyright (C) 2025-present Instructure, Inc. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . -// - -/// The AssistChatPageContext is used to capture the context of the page that the user is interacting with. -/// This can be sent with the prompt for tha AI to have more context when generating responses. -struct AssistChatPageContext { - let title: String? - let body: String? - - let format: AssistChatDocumentType? - let name: String? - let source: String? - - let chips: [AssistChipOption.Default] - - init() { - title = nil - body = nil - format = nil - name = nil - source = nil - chips = [] - } - - init(title: String, body: String) { - self.title = title - self.body = body - - format = nil - name = nil - source = nil - - chips = [.summarize, .keyTakeaways, .tellMeMore, .flashcards, .quiz] - } - - init(format: AssistChatDocumentType, name: String, source: String) { - self.format = format - self.name = name - self.source = source - - title = nil - body = nil - - chips = [.summarize, .keyTakeaways, .tellMeMore, .flashcards] - } - - var prompt: String? { - if let title = title, let body = body { - return "This is a document with the title '\(title)' and the body '\(body)'" - } - if let format = format, let name = name, let source = source { - return "This is a file with the format '\(format)', the name '\(name)', and the source '\(source)'" - } - return nil - } -} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatResponse.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatResponse.swift index 9c9847d0c4..def652e4da 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatResponse.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChatResponse.swift @@ -25,58 +25,16 @@ struct AssistChatResponse { // MARK: - Optional - let chipOptions: [AssistChipOption]? - let flashCards: [AssistChatFlashCard]? let isLoading: Bool - let quizItems: [QuizItem]? - - init(chipOptions: [AssistChipOption], chatHistory: [AssistChatMessage] = []) { - self.chipOptions = chipOptions - self.chatHistory = chatHistory - - self.isLoading = false - self.flashCards = nil - self.quizItems = nil - } - - /// The user has asked for FlashCards, so we're giving it to them - init(flashCards: [AssistChatFlashCard], chatHistory: [AssistChatMessage]) { - self.flashCards = flashCards - self.chatHistory = chatHistory - - self.isLoading = false - self.chipOptions = nil - self.quizItems = nil - } - - /// The user has asked for a quiz, so we're giving it to them - init(quizItems: [QuizItem], chatHistory: [AssistChatMessage]) { - self.chatHistory = chatHistory - self.quizItems = quizItems - - self.isLoading = false - self.chipOptions = nil - self.flashCards = nil - } /// Publishing an updated chat history. This happens when chatting with the bot init( - message: AssistChatMessage, - chipOptions: [AssistChipOption] = [], + _ message: AssistChatMessage, chatHistory: [AssistChatMessage] = [], - isLoading: Bool = false + isLoading: Bool = false, + isFreeTextAvailable: Bool = true ) { self.chatHistory = chatHistory + [message] - self.chipOptions = chipOptions self.isLoading = isLoading - - self.flashCards = nil - self.quizItems = nil - } - - struct QuizItem { - let question: String - let answers: [String] - let correctAnswerIndex: Int } } diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChipOption.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChipOption.swift index 512f4c6a47..4feb1ae1c2 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChipOption.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/AssistChipOption.swift @@ -16,76 +16,34 @@ // along with this program. If not, see . // -struct AssistChipOption: Codable, Hashable { +struct AssistChipOption: Equatable { let chip: String - let prompt: String + let prompt: String? - init(chip: String, prompt: String = "") { + init(chip: String, prompt: String? = nil) { self.chip = chip - self.prompt = prompt + self.prompt = prompt ?? chip + } +} + +extension AssistChipOption: Codable, Hashable { + enum CodingKeys: String, CodingKey { + case chip, prompt } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - chip = try container.decode(String.self, forKey: .chip) - prompt = try container.decode(String.self, forKey: .prompt) + self.chip = try container.decode(String.self, forKey: .chip) + self.prompt = try container.decodeIfPresent(String.self, forKey: .prompt) } - enum Default: CaseIterable { - case summarize - case keyTakeaways - case tellMeMore - case flashcards - case quiz - - var rawValue: String { - switch self { - case .summarize: - return String(localized: "Summarize", bundle: .horizon) - case .keyTakeaways: - return String(localized: "Key takeaways", bundle: .horizon) - case .tellMeMore: - return String(localized: "Tell me more", bundle: .horizon) - case .flashcards: - return String(localized: "Flash cards", bundle: .horizon) - case .quiz: - return String(localized: "Quiz", bundle: .horizon) - } - } + // Overload `==` for Equatable conformance + static func == (lhs: AssistChipOption, rhs: AssistChipOption) -> Bool { + return lhs.chip == rhs.chip && lhs.prompt == rhs.prompt } - // swiftlint:disable line_length - init(_ option: Default, userShortName: String? = nil) { - chip = option.rawValue - - var introduction = "" - if let userShortName = userShortName { - introduction = "You can address me as \(userShortName)." - } - switch option { - case .summarize: - prompt = "\(introduction) Give me a 1-2 paragraph summary of the content; don't use any information besides the provided content." - case .keyTakeaways: - prompt = "\(introduction) Give some key takeaways from this content; don't use any information besides the provided content. Return the response as a bulleted list." - case .tellMeMore: - prompt = "\(introduction) In 1-2 paragraphs, tell me more about this content." - case .flashcards: - prompt = """ - \(introduction) please generate exactly 20 questions and answers based on the provided content for the front and back of flashcards, respectively. If the content contains only an iframe dont try to generate an answer. Flashcards are best suited for definitions and terminology, key concepts and theories, language learning, historical events and dates, and other content that might benefit from active recall and repetition. Prioritize this type of content within the flashcards. - - Return the flashcards as a valid JSON array in the following format: - [ - { - "question": "What is the title of the video?", - "answer": "What Is Accountability?" - } - ] - - without any further description or text. Please keep the questions and answers concise (under 35 words). Each question and answer will be shown on a flashcard, so no need to repeat the question in the answer. Make sure the JSON is valid. - """ - case .quiz: - prompt = "Generate a quiz" - } + func hash(into hasher: inout Hasher) { + hasher.combine(chip) + hasher.combine(prompt) } - // swiftlint:enable line_length } diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarAnswerPromptMutation.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarAnswerPromptMutation.swift index 55e2bcdf9b..76333b4cd9 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarAnswerPromptMutation.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarAnswerPromptMutation.swift @@ -18,7 +18,7 @@ import Core -class CedarAnswerPromptMutation: APIGraphQLRequestable { +struct CedarAnswerPromptMutation: APIGraphQLRequestable { let variables: Input var path: String { @@ -70,20 +70,3 @@ struct CedarAnswerPromptMutationResponse: Codable { let data: ResponseData } - -extension CedarAnswerPromptMutation.DocumentInput { - /// A document block can be included in the CedarAnswerPromptMutation to provide additional context for the model to generate a response. - /// This is used when the user is viewing a document and wants to generate a response based on the document. - static func build(from pageContext: AssistChatPageContext?) -> CedarAnswerPromptMutation.DocumentInput? { - guard let pageContext = pageContext, - let documentFormat = pageContext.format, - let source = pageContext.source - else { - return nil - } - return CedarAnswerPromptMutation.DocumentInput( - format: documentFormat, - base64Source: source - ) - } -} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarConversationMutation.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarConversationMutation.swift new file mode 100644 index 0000000000..c3a15665d8 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarConversationMutation.swift @@ -0,0 +1,74 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core + +struct CedarConversationMutation: APIGraphQLRequestable { + let variables: Input + + var path: String { + "/graphql" + } + + var headers: [String: String?] { + [ + "x-apollo-operation-name": "\(Self.operationName)", + HttpHeader.accept: "application/json" + ] + } + + public init( + systemPrompt: String, + messages: [DomainServiceConversationMessage] + ) { + self.variables = Variables( + messages: messages, + systemPrompt: systemPrompt + ) + } + + public static let operationName: String = "Conversation" + public static var query: String = """ + mutation \(operationName)($systemPrompt: String!, $messages: [MessageInput!]!) { + conversation(input: { systemPrompt: $systemPrompt, messages: $messages } ) { + response + } + } + """ + + typealias Response = CedarConversationMutationResponse + + struct Input: Codable, Equatable { + let messages: [DomainServiceConversationMessage] + let systemPrompt: String + } +} + +// MARK: - Codeables + +struct CedarConversationMutationResponse: Codable { + struct Conversation: Codable, Equatable { + let response: String + } + + struct ResponseData: Codable, Equatable { + let conversation: Conversation + } + + let data: ResponseData +} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarSummarizeContentMutation.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarSummarizeContentMutation.swift new file mode 100644 index 0000000000..1e11e817d9 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarSummarizeContentMutation.swift @@ -0,0 +1,70 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core + +struct CedarSummarizeContentMutation: APIGraphQLRequestable { + let variables: Input + + var path: String { + "/graphql" + } + + var headers: [String: String?] { + [ + "x-apollo-operation-name": "\(Self.operationName)", + HttpHeader.accept: "application/json" + ] + } + + public init( + content: String, + numParagraphs: Int = 1 + ) { + self.variables = Variables(content: content, numParagraphs: numParagraphs) + } + + public static let operationName: String = "SummarizeContent" + public static var query: String = """ + mutation \(operationName)($content: String!, $numParagraphs: Float!) { + summarizeContent(input: { content: $content, numParagraphs: $numParagraphs}) { + summarization + } + } + """ + + typealias Response = CedarSummarizeContentMutationResponse + + struct Input: Codable, Equatable { + let content: String + let numParagraphs: Int + } +} + +// MARK: - Codeables + +struct CedarSummarizeContentMutationResponse: Codable { + struct SummarizeContent: Codable { + let summarization: [String] + } + struct ResponseData: Codable { + let summarizeContent: SummarizeContent + } + + let data: ResponseData +} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarTranslateHTMLMutation.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarTranslateHTMLMutation.swift new file mode 100644 index 0000000000..c6a4e942cd --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarTranslateHTMLMutation.swift @@ -0,0 +1,79 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core + +struct CedarTranslateHTMLMutation: APIGraphQLRequestable { + let variables: Input + + var path: String { + "/graphql" + } + + var headers: [String: String?] { + [ + "x-apollo-operation-name": "\(Self.operationName)", + HttpHeader.accept: "application/json" + ] + } + + public init( + content: String, + targetLanguage: String = "es", + sourceLanguage: String = "en" + ) { + self.variables = Variables( + content: content, + targetLanguage: targetLanguage, + sourceLanguage: sourceLanguage + ) + } + + public static let operationName: String = "TranslateHTML" + public static var query: String = """ + mutation \(operationName)($content: String!, $targetLanguage: String!, $sourceLanguage: String!) { + translateHTML(input: { content: $content, targetLanguage: $targetLanguage, sourceLanguage: $sourceLanguage }) { + sourceLanguage + translation + } + } + """ + + typealias Response = CedarTranslatHTMLMutationResponse + + struct Input: Codable, Equatable { + let content: String + let targetLanguage: String + let sourceLanguage: String + } +} + +// MARK: - Codeables + +struct CedarTranslatHTMLMutationResponse: Codable { + struct TranslateHTML: Codable { + let sourceLanguage: String + let translation: String + } + + struct Data: Codable { + let translateHTML: TranslateHTML + } + + let data: Data +} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarTranslateTextMutation.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarTranslateTextMutation.swift new file mode 100644 index 0000000000..395f01920c --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/CedarTranslateTextMutation.swift @@ -0,0 +1,75 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core + +struct CedarTranslateTextMutation: APIGraphQLRequestable { + let variables: Input + + var path: String { + "/graphql" + } + + var headers: [String: String?] { + [ + "x-apollo-operation-name": "\(Self.operationName)", + HttpHeader.accept: "application/json" + ] + } + + public init( + content: String, + targetLanguage: String = "es", + sourceLanguage: String = "en" + ) { + self.variables = Variables( + content: content, + targetLanguage: targetLanguage, + sourceLanguage: sourceLanguage + ) + } + + public static let operationName: String = "TranslateText" + public static var query: String = """ + mutation \(operationName)($content: String!, $targetLanguage: String!, $sourceLanguage: String!) { + translateText(input: { content: $content, targetLanguage: $targetLanguage, sourceLanguage: $sourceLanguage }) { + sourceLanguage + translation + } + } + """ + + typealias Response = CedarTranslateTextMutationResponse + + struct Input: Codable, Equatable { + let content: String + let targetLanguage: String + let sourceLanguage: String + } +} + +// MARK: - Codeables + +struct CedarTranslateTextMutationResponse: Codable { + struct TranslateText: Codable { + let sourceLanguage: String + let translation: String + } + + let data: TranslateText +} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/DomainServiceConversationMessage.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/DomainServiceConversationMessage.swift new file mode 100644 index 0000000000..89c360143f --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/cedar/DomainServiceConversationMessage.swift @@ -0,0 +1,32 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +struct DomainServiceConversationMessage: Codable, Equatable { + let role: Role + let text: String + + init(text: String, role: Role) { + self.text = text + self.role = role + } + + enum Role: String, Codable { + case Assistant + case User + } +} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/pine/PineQueryMutation.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/pine/PineQueryMutation.swift index 65427a69af..f959b9f2df 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/pine/PineQueryMutation.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Data/pine/PineQueryMutation.swift @@ -18,7 +18,7 @@ import Core -class PineQueryMutation: APIGraphQLRequestable { +struct PineQueryMutation: APIGraphQLRequestable { let variables: Variables var path: String { @@ -32,20 +32,20 @@ class PineQueryMutation: APIGraphQLRequestable { ] } - public init(messages: [APIMessageInput]) { + public init(messages: [DomainServiceConversationMessage], courseID: String) { self.variables = Variables( - input: RagQueryInput( + input: RagQueryInput( messages: messages, source: "canvas", - metadata: "" + metadata: Metadata(courseId: courseID) ) ) } - public static let operationName: String = "query" + public static let operationName: String = "ChatPrompt" public static var query: String = """ mutation \(operationName)($input: RagQueryInput!) { - \(operationName)(input: $input) { + query(input: $input) { response } } @@ -58,19 +58,9 @@ class PineQueryMutation: APIGraphQLRequestable { } struct RagQueryInput: Codable, Equatable { - let messages: [APIMessageInput] + let messages: [DomainServiceConversationMessage] let source: String - let metadata: String - } - - struct APIMessageInput: Codable, Equatable { - let role: Role - let text: String - - init(text: String, role: Role) { - self.text = text - self.role = role - } + let metadata: Metadata } struct RagData: Codable { @@ -85,8 +75,7 @@ class PineQueryMutation: APIGraphQLRequestable { let response: String } - enum Role: String, Codable { - case Assistant - case User + struct Metadata: Codable, Equatable { + let courseId: String } } diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/AssistChatInteractor.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/AssistChatInteractor.swift index b40968fd2a..0a8982d1a7 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/AssistChatInteractor.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/AssistChatInteractor.swift @@ -22,9 +22,9 @@ import Core import Foundation protocol AssistChatInteractor { + var listen: AnyPublisher { get } func publish(action: AssistChatAction) func setInitialState() - var listen: AnyPublisher { get } } final class AssistChatInteractorLive: AssistChatInteractor { @@ -32,519 +32,126 @@ final class AssistChatInteractorLive: AssistChatInteractor { case success(AssistChatResponse) case failure(Error) } - // MARK: - Dependencies - - private let cedarDomainService: DomainService - private let downloadFileInteractor: DownloadFileInteractor? - private let pineDomainService: DomainService // MARK: - Private - private let actionPublisher = CurrentValueRelay(nil) - private var pageContextPublisher: AnyPublisher? - private let initialStatePublisher = PassthroughSubject() - private var actionCancellable: AnyCancellable? - private let responsePublisher = PassthroughSubject() + private var assistDataEnvironment: AssistDataEnvironment = AssistDataEnvironment() + private var assistDateEnvironmentOriginal: AssistDataEnvironment = AssistDataEnvironment() + private var goalCancellable: AnyCancellable? + private let downloadFileInteractor: DownloadFileInteractor? + private let responsePublisher = PassthroughSubject() private var subscriptions = Set() + private var goals: [any AssistGoal] - // MARK: - init - - /// Initializes the interactor when viewing a page for context - convenience init( - courseId: String, - pageUrl: String, - cedarDomainService: DomainService = DomainService(.cedar), - pineDomainService: DomainService = DomainService(.pine) - ) { - self.init( - pageContextPublisher: AssistChatInteractorLive.pageContextPublisher( - courseId: courseId, - pageUrl: pageUrl - ), - cedarDomainService: cedarDomainService, - pineDomainService: pineDomainService - ) - } - - /// Initializes the interactor when viewing a file for context - convenience init( - courseId: String, - fileId: String, - downloadFileInteractor: DownloadFileInteractor, - cedarDomainService: DomainService = DomainService(.cedar), - pineDomainService: DomainService = DomainService(.pine) - ) { - self.init( - pageContextPublisher: AssistChatInteractorLive.pageContextPublisher( - downloadFileInteractor: downloadFileInteractor, - courseId: courseId, - fileId: fileId - ), - cedarDomainService: cedarDomainService, - pineDomainService: pineDomainService, - downloadFileInteractor: downloadFileInteractor - ) - } - + // MARK: - Init init( - pageContextPublisher: AnyPublisher? = nil, - cedarDomainService: DomainService = DomainService(.cedar), - pineDomainService: DomainService = DomainService(.pine), + courseID: String? = nil, + fileID: String? = nil, + pageURL: String? = nil, downloadFileInteractor: DownloadFileInteractor? = nil ) { - self.cedarDomainService = cedarDomainService - self.pineDomainService = pineDomainService self.downloadFileInteractor = downloadFileInteractor - self.pageContextPublisher = pageContextPublisher - initialStatePublisher - .flatMap { [weak self] _ -> AnyPublisher<(AssistChatPageContext, String), Error> in - guard let self = self else { - return Empty(completeImmediately: true).eraseToAnyPublisher() - } - return self.prepareCombinedPublisher() - } - .flatMap { [weak self] context, userShortName -> AnyPublisher in - guard let self = self else { - return Empty(completeImmediately: true).eraseToAnyPublisher() - } - return self.actionHandler( - action: .chat(prompt: "", history: []), - pageContext: context, - userShortName: userShortName - ) - } - .sink( - receiveCompletion: { _ in }, - receiveValue: { [weak self] response in - self?.responsePublisher.send(.success(response)) - } - ) - .store(in: &subscriptions) + self.assistDataEnvironment = .init( + courseID: courseID, + fileID: fileID, + pageURL: pageURL + ) + self.goals = AssistChatInteractorLive.initializeGoals( + assistDataEnvironment: assistDataEnvironment, + downloadFileInteractor: downloadFileInteractor + ) + self.assistDateEnvironmentOriginal = assistDataEnvironment.duplicate() } // MARK: - Inputs - /// Publishes a new user action to the interactor func publish(action: AssistChatAction) { - actionPublisher.accept(action) - actionCancellable = actionPublisher - .compactMap { $0 } - .flatMap { [weak self] action -> AnyPublisher<(AssistChatAction, AssistChatPageContext, String), Error> in - guard let self = self else { - return Just((action, AssistChatPageContext(), "")) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - return self.prepareCombinedPublisher() - .map { (pageContext, userShortName) in - (action, pageContext, userShortName) - } - .eraseToAnyPublisher() - } - .flatMap { [weak self] action, pageContext, userShortName in - guard let self = self else { - return Empty(completeImmediately: true).eraseToAnyPublisher() - } - return self.actionHandler( - action: action, - pageContext: pageContext, - userShortName: userShortName - ) - } - .sink( - receiveCompletion: { [weak self] completion in - if case .failure(let error) = completion { - self?.responsePublisher.send(.failure(error)) - } - }, - receiveValue: { [weak self] response in - self?.responsePublisher.send(.success(response)) - } - ) - } - - func setInitialState() { - // Cancel any previously triggered API calls. - actionCancellable?.cancel() - actionCancellable = nil - initialStatePublisher.send(()) - } - - /// Subscribe to the responses from the interactor - var listen: AnyPublisher { - responsePublisher - .eraseToAnyPublisher() - } - - // MARK: - Private - - private func prepareCombinedPublisher() -> AnyPublisher<(AssistChatPageContext, String), Error> { - let contextPublisher = pageContextPublisher ?? Just(AssistChatPageContext()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - - return Publishers.CombineLatest(contextPublisher, userShortNamePublisher) - .eraseToAnyPublisher() - } - - /// When a new action comes in from the user, this function starts processing it - private func actionHandler( - action: AssistChatAction, - pageContext: AssistChatPageContext, - userShortName: String - ) -> AnyPublisher { - var prompt: String! - var useAdvancedChat = false + var prompt: String? + var history: [AssistChatMessage] = [] switch action { - case .chat(let message, _): + case .chat(let message, let chatHistory): prompt = message - // only use the advanced chat if we don't have a document context - useAdvancedChat = pageContext.prompt == nil - case .chip(let option, _): + history = chatHistory + case .chip(let option, let chatHistory): prompt = option.prompt - useAdvancedChat = false + history = chatHistory + default: + break } - // This should really only happen when the user first opens the chat - if prompt.isEmpty { - return buildInitialResponse(for: pageContext, with: userShortName) - .eraseToAnyPublisher() + if let prompt = prompt { + let message: AssistChatMessage = .init(userResponse: prompt) + let response: AssistChatResponse = .init(message, chatHistory: history, isLoading: true) + responsePublisher.send(.success(response)) + history = response.chatHistory } - return publish(using: action, with: userShortName) - .flatMap { [weak self] newHistory -> AnyPublisher in - guard let self = self else { - return Empty(completeImmediately: true).eraseToAnyPublisher() - } - return self.classifier( - prompt: prompt, - pageContext: pageContext, - userShortName: userShortName, - action: action, - history: newHistory - ) - .flatMap { classification -> AnyPublisher in - return self.handleClassifierPromptResponse( - classification: classification, - action: action, - pageContext: pageContext, - history: newHistory, - userShortName: userShortName, - useAdvancedChat: useAdvancedChat - ) + goalCancellable = executeNextGoal(prompt: prompt, history: history)?.sink( + receiveCompletion: { [weak self] completion in + if case let .failure(error) = completion { + self?.responsePublisher.send(.failure(error)) } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } - - /// Makes a request to the pine endpoint using the given history - private func advancedChat(history: [AssistChatMessage]) -> AnyPublisher { - pineDomainService.api() - .flatMap { pineApi in - pineApi.makeRequest( - PineQueryMutation( - messages: history.reversed().filter { $0.prompt != nil }.map { - PineQueryMutation.APIMessageInput( - text: $0.prompt ?? "", role: $0.role == .Assistant ? .Assistant : .User) - } - ) - ) - .compactMap { ragData in - ragData.map { $0.data.query.response } - } - } - .eraseToAnyPublisher() - } - - /// Returns any configured chips to show based on the context. If there are none, we return a default message - private func buildInitialResponse(for pageContext: AssistChatPageContext, with userShortName: String) -> AnyPublisher { - let options = pageContext.chips.map { option in - AssistChipOption( - option, - userShortName: userShortName - ) - } - let message = String(localized: "How can I help today?", bundle: .horizon) - let chatBotResponse = - options.isEmpty - ? AssistChatResponse(message: AssistChatMessage(prompt: nil, text: message, role: .Assistant)) - : AssistChatResponse(chipOptions: options) - - return Just(chatBotResponse) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - /// If necessary, downloads the file and returns the page context. - /// If we can't determine the format, we return an empty page context - private static func pageContextPublisher( - downloadFileInteractor: DownloadFileInteractor, - courseId: String, - fileId: String - ) -> AnyPublisher { - ReactiveStore(useCase: GetFile(context: .course(courseId), fileID: fileId)) - .getEntities() - .map { files in files.first } - .flatMap { (file: File?) in - guard let file = file, - let format = AssistChatDocumentType.from(mimeType: file.contentType) else { - return Just(AssistChatPageContext()).setFailureType(to: Error.self).eraseToAnyPublisher() + }, + receiveValue: { [weak self] assistChatResponse in + guard let assistChatResponse = assistChatResponse else { + self?.publish(action: .chat(prompt: nil, history: history)) + return } - return downloadFileInteractor - .download(fileID: fileId) - .map { try? Data(contentsOf: $0) } - .map { $0?.base64EncodedString() } - .map { (base64String: String?) in - guard let base64String = base64String else { - return AssistChatPageContext() - } - return AssistChatPageContext( - format: format, - name: file.filename, - source: base64String - ) - } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } - - /// Fetches a page to use for AI context - private static func pageContextPublisher(courseId: String, pageUrl: String) -> AnyPublisher { - ReactiveStore(useCase: GetPage(context: .course(courseId), url: pageUrl)) - .getEntities() - .map { AssistChatPageContext(title: $0.first?.title ?? "", body: $0.first?.body ?? "") } - .eraseToAnyPublisher() - } - - /// Makes a request to the cedar endpoint using the given prompt and returns an answer - private func basicChat( - prompt: String, - pageContext: AssistChatPageContext? = nil - ) -> AnyPublisher { - cedarDomainService.api() - .flatMap { cedarApi in - cedarApi.makeRequest( - CedarAnswerPromptMutation( - prompt: prompt, - document: CedarAnswerPromptMutation.DocumentInput.build(from: pageContext) - ) - ) + let response: AssistChatResponse = .init(assistChatResponse, chatHistory: history) + self?.responsePublisher.send(.success(response)) } - .map { graphQlResponse, _ in graphQlResponse.data.answerPrompt } - .eraseToAnyPublisher() - } - - /// Given the prompt, ask the AI to classify it to one of our ClassifierOptions (e.g., chat, flashcards, quiz) - private func classifier( - prompt: String, - pageContext: AssistChatPageContext, - userShortName: String, - action: AssistChatAction, - history: [AssistChatMessage] - ) -> AnyPublisher { - let longExplanations = ClassifierOption.allCases.map { $0.longExplanation }.joined(separator: ", ") - let defaultOption = ClassifierOption.defaultOption.rawValue - let shortOptions = ClassifierOption.allCases.map { $0.rawValue }.joined(separator: ", ") - - // swiftlint:disable line_length - let classifierPrompt = - "You are an agent designed to route a learner's question to the appropriate assistant. The possible assistants are \(longExplanations). If you're not sure, choose \(defaultOption). ALWAYS answer with a single word - either \(shortOptions). Here's the learner's question: \(prompt). Here is our chat history in JSON: \(history.json)" - // swiftlint:enable line_length - - return basicChat(prompt: classifierPrompt, pageContext: pageContext) - } - - /// Calls the basic chat endpoint to generate flashcards - private func flashcards( - action: AssistChatAction, - pageContext: AssistChatPageContext, - history: [AssistChatMessage] = [], - userShortName: String - ) -> AnyPublisher { - let chipPrompt = AssistChipOption(AssistChipOption.Default.flashcards, userShortName: userShortName).prompt - let pageContextPrompt = pageContext.prompt ?? "" - let prompt = "\(chipPrompt) \(pageContextPrompt) \(history.json)" - return basicChat( - prompt: prompt, - pageContext: pageContext ) - .compactMap { response in - AssistChatResponse( - flashCards: AssistChatFlashCard.build(from: response) ?? [], - chatHistory: history - ) - } - .eraseToAnyPublisher() - } - - // Given the classification string returned from the simpleChat, - // act on the classification given - private func handleClassifierPromptResponse( - classification: String, - action: AssistChatAction, - pageContext: AssistChatPageContext, - history: [AssistChatMessage], - userShortName: String, - useAdvancedChat: Bool = true - ) -> AnyPublisher { - let defaultOption = ClassifierOption.defaultOption - let classifierOption = ClassifierOption(rawValue: classification) ?? defaultOption - switch classifierOption { - case .chat: - let chatMethod = - useAdvancedChat - ? advancedChat(history: history) - : basicChat(prompt: "\(history.json) \(pageContext.prompt ?? "")", pageContext: pageContext) - return - chatMethod - .map { AssistChatResponse(message: AssistChatMessage(botResponse: $0), chatHistory: history) } - .eraseToAnyPublisher() - case .flashcards: - return flashcards( - action: action, - pageContext: pageContext, - history: history, - userShortName: userShortName - ) - case .quiz: - return quiz( - action: action, - pageContext: pageContext, - history: history, - userShortName: userShortName - ) - } } - /// publishes an updated history based on the action the user took, then returns that updated history - private func publish(using action: AssistChatAction, with userShortName: String) -> AnyPublisher<[AssistChatMessage], Never> { - var response: AssistChatResponse! - switch action { - case .chat(let prompt, let history): - response = AssistChatResponse( - message: AssistChatMessage(userResponse: prompt), - chatHistory: history, - isLoading: true - ) - case .chip(let option, let history): - response = AssistChatResponse( - message: AssistChatMessage( - prompt: option.prompt, - text: option.chip - ), - chatHistory: history, - isLoading: true - ) - } - - responsePublisher.send(.success(response)) - - return Just(response.chatHistory) - .eraseToAnyPublisher() + /// Subscribe to the responses from the interactor + var listen: AnyPublisher { + responsePublisher.eraseToAnyPublisher() } - /// Calls the cedar endpoint to generate a quiz - private func quiz( - action: AssistChatAction, - pageContext: AssistChatPageContext, - history: [AssistChatMessage], - userShortName: String - ) -> AnyPublisher { - cedarDomainService.api() - .flatMap { cedarApi in - // swiftlint:disable line_length - let prompt = - "\(pageContext.prompt ?? "You may choose the topic. Once you've selected a topic, keep to that topic using the chat history as reference."). Do not reuse questions. Do not mention the chat history in your response. Here is the chat history in JSON: \(history.json)" - // swiftlint:enable line_length - return cedarApi.makeRequest( - CedarGenerateQuizMutation(context: prompt) + func setInitialState() { + goalCancellable?.cancel() + self.assistDataEnvironment = self.assistDateEnvironmentOriginal.duplicate() + self.goals = AssistChatInteractorLive.initializeGoals( + assistDataEnvironment: assistDataEnvironment, + downloadFileInteractor: downloadFileInteractor + ) + publish(action: .begin) + } + + // MARK: - Static + private static func initializeGoals( + assistDataEnvironment: AssistDataEnvironment, + downloadFileInteractor: DownloadFileInteractor? + ) -> [any AssistGoal] { + // order matters + var goals = [any AssistGoal]() + if let downloadFileInteractor = downloadFileInteractor { + goals.append( + AssistCourseDocumentGoal( + environment: assistDataEnvironment, + downloadFileInteractor: downloadFileInteractor ) - .compactMap { (quizOutput: CedarGenerateQuizMutation.QuizOutput?) in - quizOutput.map { quizOutput in - return AssistChatResponse( - quizItems: quizOutput.quizItems, - chatHistory: history - ) - } - } - } - .eraseToAnyPublisher() - } - - /// Fetches the user's short name - private var userShortNamePublisher: AnyPublisher { - ReactiveStore(useCase: GetUserProfile()) - .getEntities() - .map { $0.first?.shortName ?? String(localized: "Learner", bundle: .horizon) } - .eraseToAnyPublisher() - } - - // MARK: - Enum - - /// When requesting classification from basic chat, these are the options asked for - private enum ClassifierOption: String, CaseIterable { - case chat - case flashcards - case quiz - - static var defaultOption: ClassifierOption { - .chat - } - - var longExplanation: String { - switch self { - case .chat: - return "chat (an assistant that has access to knowledge about their current course content and structure)" - case .flashcards: - return "flashcards (an assistant to help learner check their understanding with flashcards)" - case .quiz: - // swiftlint:disable line_length - return - "quiz (an assistant that will prepare multiple choice quiz questions to help a user check their understanding); intended for terms/definitions or memorization). You should only respond with this if they are asking for a quiz." - // swiftlint:enable line_length - } - } - } -} - -// MARK: - Extensions - -private extension CedarGenerateQuizMutation.QuizOutput { - var quizItems: [AssistChatResponse.QuizItem] { - data.generateQuiz.map { - AssistChatResponse.QuizItem( - question: $0.question, - answers: $0.options, - correctAnswerIndex: $0.result ) } + goals += [ + AssistCoursePageGoal(environment: assistDataEnvironment), + AssistSelectCourseActionGoal(environment: assistDataEnvironment), + AssistSelectCourseGoal(environment: assistDataEnvironment) + ] + return goals } -} -private extension Array where Element == AssistChatMessage { - var json: String { - guard let encoded = try? JSONEncoder().encode(self) else { - return "[]" - } - return String(data: encoded, encoding: .utf8) ?? "[]" - } -} - -private extension String { - func toChipOptions() -> [AssistChipOption] { - guard let data = self.data(using: .utf8) else { - return [] - } + // MARK: - Private - do { - let chipOptions = try JSONDecoder().decode([AssistChipOption].self, from: data) - return chipOptions - } catch { - return [] + private func executeNextGoal( + prompt: String? = nil, + history: [AssistChatMessage] = [] + ) -> AnyPublisher? { + guard let goal = goals.first(where: { $0.isRequested() }) else { + return nil } + return goal.execute(response: prompt, history: history) } } @@ -552,36 +159,24 @@ struct AssistChatInteractorPreview: AssistChatInteractor { var hasAssistChipOptions: Bool = true func publish(action: AssistChatAction) {} - var listen: AnyPublisher = Just( + var listen: AnyPublisher = Just( .success( AssistChatResponse( - quizItems: [ - .init( - question: "What is the capital of France?", - answers: ["Paris", "London", "Berlin", "Madrid"], - correctAnswerIndex: 0 - ) - ], + AssistChatMessage( + quizItems: [ + .init( + question: "What is the capital of France?", + answers: ["Paris", "London", "Berlin", "Madrid"], + correctAnswerIndex: 0 + ) + ] + ), chatHistory: [] ) ) ) + .setFailureType(to: Error.self) .eraseToAnyPublisher() func setInitialState() {} } - -extension API { - func makeRequest(_ requestable: Request) -> AnyPublisher { - AnyPublisher { [weak self] subscriber in - self?.makeRequest(requestable) { response, _, error in - if let error = error { - subscriber.send(completion: .failure(error)) - return - } - subscriber.send(response) - } - return AnyCancellable { } - } - } -} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/AssistGoal.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/AssistGoal.swift new file mode 100644 index 0000000000..088712c529 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/AssistGoal.swift @@ -0,0 +1,69 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import Core +import Foundation + +/// The purpose of the AssistGoal is to provide a base class for goals that can be executed within the Assist chat system. +/// The "Goal"s are used to define specific tasks or objectives that the Assist system can help the user achieve. +protocol AssistGoal { + /// After a choice of options is made, we execute + func execute(response: String?, history: [AssistChatMessage]) -> AnyPublisher + + /// Whether or not this goal should be selected in this list of goals + func isRequested() -> Bool + + func choose( + from options: [String], + with userResponse: String, + using cedar: DomainService + ) -> AnyPublisher +} + +extension AssistGoal { + func choose( + from options: [String], + with userResponse: String, + using cedar: DomainService + ) -> AnyPublisher { + cedar.api().flatMap { cedarAPI in + cedarAPI.makeRequest( + CedarConversationMutation( + systemPrompt: .optionSelection(from: options), + messages: [ + .init(text: userResponse, role: .User) + ] + ) + ) + } + .tryMap { (response, _) in + let result = response.data.conversation.response.replacing(/\"\"/, with: "") + return result.isEmpty == true ? nil : result + } + .eraseToAnyPublisher() + } +} + +extension String { + // swiftlint:disable line_length + static func optionSelection(from options: [String]) -> String { + "The user has been asked to select from a list of options. Here is that list of options comma separated: \(options.joined(separator: ", ")). Given the users response, tell me which option they've selected. Their answer doesn't have to be exact, but it should be close. If it appears to match none of the options, return an empty string; just the empty string without any explanation. If you find a match, return only the option selected without any additional information." + } + // swiftlint:enable line_length +} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/AssistSelectCourseActionGoal.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/AssistSelectCourseActionGoal.swift new file mode 100644 index 0000000000..2c4bea41e2 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/AssistSelectCourseActionGoal.swift @@ -0,0 +1,113 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import Core +import Foundation + +class AssistSelectCourseActionGoal: AssistGoal { + + private let environment: AssistDataEnvironment + private let pine: DomainService + private var userID: String + + init( + environment: AssistDataEnvironment, + userID: String = AppEnvironment.shared.currentSession?.userID ?? "", + pine: DomainService = DomainService(.pine) + ) { + self.environment = environment + self.userID = userID + self.pine = pine + } + + func isRequested() -> Bool { + environment.courseID.value != nil + } + + func execute(response: String?, history: [AssistChatMessage] = []) -> AnyPublisher { + guard let courseID = environment.courseID.value else { + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + guard let response = response, response.isNotEmpty else { + return initialPrompt(history: history) + } + return askAQuestion(response: response, history: history, courseID: courseID) + } + + private func askAQuestion(response: String, history: [AssistChatMessage], courseID: String) -> AnyPublisher { + pine.api() + .flatMap { pineApi in + pineApi.makeRequest( + PineQueryMutation( + messages: history.domainServiceConversationMessages, + courseID: courseID + ) + ) + .compactMap { (ragData, _) in + .init(botResponse: ragData.data.query.response) + } + } + .eraseToAnyPublisher() + } + + private var courseName: AnyPublisher { + ReactiveStore( + useCase: GetHCoursesProgressionUseCase(userId: userID) + ) + .getEntities() + .map { [weak self] courses in + courses.first { $0.courseID == self?.environment.courseID.value }?.course.name ?? "" + } + .eraseToAnyPublisher() + } + + private func initialPrompt(history: [AssistChatMessage]) -> AnyPublisher { + courseName.map { courseName in + var prompt = String(localized: "What would you like to discuss today?", bundle: .horizon) + if let courseName = courseName { + let format = String(localized: "What would you like to discuss about the course %@?", bundle: .horizon) + prompt = String(format: format, courseName) + } + return AssistChatMessage(botResponse: prompt) + } + .eraseToAnyPublisher() + } +} + +extension Array where Element == AssistChatMessage { + var domainServiceConversationMessages: [DomainServiceConversationMessage] { + prependUserMessage() + .map { + DomainServiceConversationMessage( + text: $0.text ?? $0.prompt ?? "", + role: $0.role == .Assistant ? .Assistant : .User + ) + } + } + + /// The API requires that the first message is from the user, so we prepend a user message if the first message is not from the user. + private func prependUserMessage() -> [AssistChatMessage] { + guard let first = first, first.role != .User else { + return self + } + return [.init(userResponse: "Hello")] + self + } +} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/AssistSelectCourseGoal.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/AssistSelectCourseGoal.swift new file mode 100644 index 0000000000..12b22131d0 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/AssistSelectCourseGoal.swift @@ -0,0 +1,100 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import Core +import Foundation + +class AssistSelectCourseGoal: AssistGoal { + private let cedar: DomainService + private let environment: AssistDataEnvironment + private let userID: String + + init( + environment: AssistDataEnvironment, + cedar: DomainService = DomainService(.cedar), + userID: String = AppEnvironment.shared.currentSession?.userID ?? "" + ) { + self.environment = environment + self.cedar = cedar + self.userID = userID + } + + func isRequested() -> Bool { + environment.courseID.value == nil + } + + func execute(response: String? = nil, history: [AssistChatMessage] = []) -> AnyPublisher { + guard let response = response, response.isNotEmpty else { + return initialPrompt(history: history) + } + + return selectCourseFrom(response: response, history: history) + } + + private func selectCourseFrom(response: String, history: [AssistChatMessage]) -> AnyPublisher { + weak var weakSelf = self + return courses.flatMap { courseOptions in + guard let weakSelf = weakSelf else { + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + let courseNames = courseOptions.compactMap { $0.course.name } + return weakSelf.choose(from: courseNames, with: response, using: weakSelf.cedar) + .map { courseSelected in + if let courseSelected = courseSelected, + let courseID = courseOptions.first(where: { courseSelected.contains($0.course.name ?? "") == true })?.courseID { + weakSelf.environment.courseID.accept(courseID) + } + return nil + } + .eraseToAnyPublisher() + } + .map { _ in nil } + .eraseToAnyPublisher() + } + + private func initialPrompt(history: [AssistChatMessage]) -> AnyPublisher { + let promptFirstTime = String(localized: "Hello! Which course you'd like to discuss today?", bundle: .horizon) + let promptAgain = String(localized: "Sorry, can we try that again? Which course is it you'd like to discuss?", bundle: .horizon) + let didIJustAskThis = history.count > 1 && history[history.count - 2].text?.contains(promptFirstTime) == true + let prompt = didIJustAskThis ? promptAgain : promptFirstTime + return courses.flatMap { courses in + Just( + .init( + botResponse: prompt, + chipOptions: courses + .prefix(5) + .compactMap { $0.course.name } + .map { .init(chip: $0, prompt: $0) } + ) + ) + .setFailureType(to: Error.self) + } + .eraseToAnyPublisher() + } + + private var courses: AnyPublisher<[CDHCourse], any Error> { + ReactiveStore( + useCase: GetHCoursesProgressionUseCase(userId: userID) + ) + .getEntities() + .eraseToAnyPublisher() + } +} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/CourseItem/AssistCourseDocumentGoal.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/CourseItem/AssistCourseDocumentGoal.swift new file mode 100644 index 0000000000..62ae3ac865 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/CourseItem/AssistCourseDocumentGoal.swift @@ -0,0 +1,96 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import Core +import Foundation + +/// A goal for interacting with a course document in the Assist chat. +class AssistCourseDocumentGoal: AssistCourseItemGoal { + // MARK: - Private Properties + private var fileID: String? { + environment.fileID.value + } + private let initialPrompt = String( + localized: "Can I answer any questions about this document for you?", + bundle: .horizon + ) + + // MARK: - Dependencies + private let downloadFileInteractor: DownloadFileInteractor + + // MARK: - Initializer + init( + environment: AssistDataEnvironment, + downloadFileInteractor: DownloadFileInteractor, + cedar: DomainService = DomainService(.cedar) + ) { + self.downloadFileInteractor = downloadFileInteractor + super.init( + initialPrompt: initialPrompt, + environment: environment, + cedar: cedar + ) + } + + // MARK: - Overrides + /// If necessary, downloads the file and returns the page context. + /// If we can't determine the format, we return an empty page context + override + var document: AnyPublisher { + guard let courseID = courseID, let fileID = fileID else { + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + return ReactiveStore(useCase: GetFile(context: .course(courseID), fileID: fileID)) + .getEntities() + .map { files in files.first } + .flatMap { [weak self] (file: File?) in + guard let self = self, + let file = file, + let format = AssistChatDocumentType.from(mimeType: file.contentType) else { + return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher() + } + return self.downloadFileInteractor + .download(fileID: fileID) + .map { try? Data(contentsOf: $0) } + .map { $0?.base64EncodedString() } + .compactMap { (base64String: String?) in + guard let base64String = base64String else { + return nil + } + return CedarAnswerPromptMutation.DocumentInput( + format: format, + base64Source: base64String + ) + } + .eraseToAnyPublisher() + } + .compactMap { $0 } + .eraseToAnyPublisher() + } + + override + func isRequested() -> Bool { courseID != nil && fileID != nil } + + override + var options: [Option] { + Option.allCases.filter { $0 != .Quiz } + } +} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/CourseItem/AssistCourseItemGoal.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/CourseItem/AssistCourseItemGoal.swift new file mode 100644 index 0000000000..1a99518e3e --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/CourseItem/AssistCourseItemGoal.swift @@ -0,0 +1,313 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine + +/// This is a base class for the course page and course document goals +/// It's not meant to be instantiated directly, but rather to be subclassed +class AssistCourseItemGoal: AssistGoal { + + enum Option: String, CaseIterable { + case Summarize = "Summarize" + case KeyTakeaways = "Key Takeaways" + case TellMeMore = "Tell me more" + case FlashCards = "Flash Cards" + case Quiz = "Quiz Questions" + } + + // MARK: - Properties + var courseID: String? { + environment.courseID.value + } + + var document: AnyPublisher { + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + var options: [Option] { + Option.allCases + } + + // MARK: - Dependencies + let environment: AssistDataEnvironment + let cedar: DomainService + private let initialPrompt: String + + // MARK: - Private + private var chipOptions: [String] { + options.map(\.rawValue) + } + + // MARK: - Initializers + init( + initialPrompt: String, + environment: AssistDataEnvironment, + cedar: DomainService = DomainService(.cedar) + ) { + self.initialPrompt = initialPrompt + self.environment = environment + self.cedar = cedar + } + + /// Executes the goal based on the response from the user. + /// Chooses from one of the options or answers the user's question if no option is selected. + func execute(response: String?, history: [AssistChatMessage]) -> AnyPublisher { + guard let response = response, response.isNotEmpty else { + return Just( + .init( + botResponse: initialPrompt, + chipOptions: chipOptions.map { .init(chip: $0) } + ) + ) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + return choose(from: chipOptions, with: response, using: cedar) + .flatMap { [weak self] chip in + let nilResponse = Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher() + + guard let self = self else { + return nilResponse + } + + // If a chip wasn't chosen, just try to answer what they said + guard let chip = chip, + let option = self.options.first(where: { chip.contains($0.rawValue) }) else { + return self.cedarAnswerPrompt(prompt: response) + } + + switch option { + case .KeyTakeaways: + return self.keyTakeaways() + case .Summarize: + return self.summarizeContent() + case .TellMeMore: + return self.tellMeMore() + case .Quiz: + return self.quiz() + case .FlashCards: + return self.flashcards() + } + } + .eraseToAnyPublisher() + } + + func isRequested() -> Bool { + false + } + + /// Summarizes the content of the document + func summarizeContent() -> AnyPublisher { + document.flatMap { [weak self] document in + guard let self = self, + let document = document else { + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + return self.cedarAnswerPrompt( + prompt: .summarizeContent, + document: document + ) + .map { response in + AssistChatMessage( + botResponse: response ?? String(localized: "No summary found.", bundle: .horizon) + ) + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func quiz() -> AnyPublisher { + Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + // MARK: - Private Functions + /// Given a prompt, fetches the page document and makes a request to the cedar endpoint for answering a question + private func cedarAnswerPrompt(prompt: String) -> AnyPublisher { + document + .flatMap { [weak self] document in + guard let self = self, let document = document else { + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + return self.cedarAnswerPrompt(prompt: prompt, document: document) + .map { response in + AssistChatMessage( + botResponse: response ?? String(localized: "Sorry, I don't have an answer for that right now.", bundle: .horizon) + ) + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + /// Given a prompt and a document, makes a request to the cedar endpoint for answering a question + /// https://github.com/instructure-internal/cedar/blob/main/docs/index.md#answer-prompt + private func cedarAnswerPrompt( + prompt: String, + document: CedarAnswerPromptMutation.DocumentInput + ) -> AnyPublisher { + cedar.api() + .flatMap { cedarApi in + cedarApi.makeRequest( + CedarAnswerPromptMutation( + prompt: prompt, + document: document + ) + ) + .map { (response, _) in + response.data.answerPrompt + } + } + .eraseToAnyPublisher() + } + + /// Makes a request to the cedar endpoint using the given prompt and returns an answer + /// https://github.com/instructure-internal/cedar/blob/main/docs/index.md#conversation + private func cedarConversation( + prompt: String, + history: [AssistChatMessage] = [] + ) -> AnyPublisher { + cedar.api() + .flatMap { cedarApi in + cedarApi.makeRequest( + CedarConversationMutation( + systemPrompt: prompt, + messages: history.domainServiceConversationMessages + ) + ) + .map { (response, _) in + response.data.conversation.response + } + } + .eraseToAnyPublisher() + } + + /// Calls the Cedar endpoint for generating flashcards based on the document content + private func flashcards() -> AnyPublisher { + document.flatMap { [weak self] document in + guard + let self = self, + let document = document else { + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + return self.cedarAnswerPrompt( + prompt: .flashCards, + document: document + ) + .map { (response: String?) in + AssistChatMessage( + flashCards: AssistChatFlashCard.build(from: response ?? "") ?? [] + ) + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + /// Returns the key takeaways of the document + private func keyTakeaways() -> AnyPublisher { + document.flatMap { [weak self] document in + guard let self = self, + let document = document else { + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + return self.cedarAnswerPrompt( + prompt: .keyTakeaways, + document: document + ) + .map { (response: String?) in + .init(botResponse: response ?? String(localized: "No key takeaways found.", bundle: .horizon)) + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + /// Returns more information about the document + private func tellMeMore() -> AnyPublisher { + document.flatMap { [weak self] document in + guard let self = self, + let document = document else { + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + return self.cedarAnswerPrompt( + prompt: .tellMeMore, + document: document + ) + .map { (response: String?) in + AssistChatMessage( + botResponse: response ?? String(localized: "No additional information found.", bundle: .horizon) + ) + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } +} + +extension String { + var base64EncodedString: String? { + guard let data = data(using: .utf8) else { return nil } + return data.base64EncodedString() + } +} + +// swiftlint:disable line_length +extension String { + static var flashCards: String { + """ + generate exactly 20 questions and answers based on the provided content for the front and back of flashcards, respectively. If the content contains only an iframe dont try to generate an answer. Flashcards are best suited for definitions and terminology, key concepts and theories, language learning, historical events and dates, and other content that might benefit from active recall and repetition. Prioritize this type of content within the flashcards. + Return the flashcards as a valid JSON array in the following format: + [ + { + "question": "What is the title of the video?", + "answer": "What Is Accountability?" + } + ] + without any further description or text. Please keep the questions and answers concise (under 35 words). Each question and answer will be shown on a flashcard, so no need to repeat the question in the answer. Make sure the JSON is valid. + """ + } + static var keyTakeaways: String { + "You are a teaching assistant creating key takeaways for a student. Give me 3 key takeaways based on the included document contents. Ignore any HTML. Return the result in paragraph form. Each key takeaway is a single sentence bulletpoint. You should not refer to the format of the content, but rather the content itself." + } + static var summarizeContent: String { + """ + You are a teaching assistant summarizing content. Give me a summary based on the included document contents. Ignore any HTML. Return the result in paragraph form. + """ + } + static var tellMeMore: String { + """ + You are a teaching assistant providing more information about the content. Give me more details based on the included document contents. Ignore any HTML. Return the result in paragraph form. + """ + } +} +// swiftlint:enable line_length diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/CourseItem/AssistCoursePageGoal.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/CourseItem/AssistCoursePageGoal.swift new file mode 100644 index 0000000000..ece889ae3c --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/Domain/Goal/CourseItem/AssistCoursePageGoal.swift @@ -0,0 +1,148 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import Core +import Foundation + +/// Interacting with a course page in the context of the Assist feature. +class AssistCoursePageGoal: AssistCourseItemGoal { + // MARK: - Private + private var pageURL: String? { + environment.pageURL.value + } + + private let initialPrompt = String(localized: "How can I help you with this page?", bundle: .horizon) + + // MARK: - Initializers + init(environment: AssistDataEnvironment, cedar: DomainService = DomainService(.cedar)) { + super.init( + initialPrompt: initialPrompt, + environment: environment, + cedar: cedar + ) + } + + // MARK: - Overrides + /// Converts the course page content into a document format suitable for Cedar API requests. + override + var document: AnyPublisher { + body.map { body in + guard let base64Source = body?.base64EncodedString else { + return nil + } + return CedarAnswerPromptMutation.DocumentInput( + format: .txt, + base64Source: base64Source + ) + } + .eraseToAnyPublisher() + } + + override + func isRequested() -> Bool { courseID != nil && pageURL != nil } + + /// Generates a quiz from the page contents using the Cedar API. + override + func quiz() -> AnyPublisher { + body.flatMap { [weak self] body in + guard let self = self, + let body = body else { + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + return cedar.api() + .flatMap { cedarApi in + cedarApi.makeRequest( + CedarGenerateQuizMutation(context: body) + ) + .compactMap { (quizOutput, _) in + AssistChatMessage( + quizItems: quizOutput.quizItems + ) + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + /// Summarizes the page contents using the Cedar endpoint for content summarization. + override + func summarizeContent() -> AnyPublisher { + body.flatMap { [weak self] body in + guard let self = self, + let body = body else { + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + return self.cedarSummarizeContent(content: body) + .map { (summaries: [String]?) in + AssistChatMessage( + botResponse: (summaries ?? []).joined(separator: "\n\n") + ) + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + // MARK: - Private Methods + /// Fetches the body of the course page and returns it as a string. + private var body: AnyPublisher { + guard let courseID = courseID, + let pageURL = pageURL else { + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + return ReactiveStore(useCase: GetPage(context: .course(courseID), url: pageURL)) + .getEntities() + .map { $0.first?.body } + .eraseToAnyPublisher() + } + + /// Given the document content, it returns a publisher that emits the summarized content. + private func cedarSummarizeContent(content: String) -> AnyPublisher<[String]?, Error> { + cedar.api() + .flatMap { cedarApi in + cedarApi.makeRequest( + CedarSummarizeContentMutation(content: content) + ) + .map { (response, _) in + response.data.summarizeContent.summarization + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } +} + +extension CedarGenerateQuizMutation.QuizOutput { + var quizItems: [AssistChatMessage.QuizItem] { + data.generateQuiz.map { + .init( + question: $0.question, + answers: $0.options, + correctAnswerIndex: $0.result + ) + } + } +} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatMessageView.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatMessageView.swift index 7152eb19fd..623e41814e 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatMessageView.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatMessageView.swift @@ -24,31 +24,53 @@ struct AssistChatMessageView: View { let message: AssistChatMessageViewModel var body: some View { - VStack(spacing: .zero) { - if !message.isLoading { - messageContent - .frame(maxWidth: .infinity, alignment: message.alignment) - .onTapGesture { - message.onTap?() - } - } - WrappingHStack(models: message.chipOptions) { quickResponse in + VStack(alignment: .leading, spacing: .zero) { + messageContent + .frame(maxWidth: .infinity, alignment: message.alignment) + .onTapGesture { + message.onTap?() + } + WrappingHStack( + models: message.chipOptions, + horizontalSpacing: .zero + ) { quickResponse in HorizonUI.Pill(title: quickResponse.chip, style: .outline(.light)) .onTapGesture { message.onTapChipOption?(quickResponse) } + .padding(.vertical, .huiSpaces.space4) + .padding(.trailing, .huiSpaces.space4) } + .padding(.vertical, .huiSpaces.space8) .frame(maxWidth: .infinity, alignment: .leading) + feedback + } + } + + @ViewBuilder + private var feedback: some View { + if let onFeedbackChange = message.onFeedbackChange { + AssistFeedbackView(onChange: onFeedbackChange) } } private var messageContent: some View { - Text(message.content.toAttributedStringWithLinks()) - .frame(maxWidth: message.maxWidth, alignment: .leading) - .padding(message.padding) - .background(message.backgroundColor) - .foregroundColor(message.foregroundColor) - .cornerRadius(message.cornerRadius) + VStack(alignment: .center) { + if message.isLoading { + HStack(alignment: .center) { + HorizonUI.Spinner(size: .xSmall, foregroundColor: .huiColors.surface.cardPrimary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, .huiSpaces.space8) + } else { + Text(message.content.toAttributedStringWithLinks()) + .frame(maxWidth: message.maxWidth, alignment: .leading) + .padding(message.padding) + .background(message.backgroundColor) + .foregroundColor(message.foregroundColor) + .cornerRadius(message.cornerRadius) + } + } } } @@ -56,7 +78,15 @@ struct AssistChatMessageView: View { #Preview { VStack { AssistChatMessageView(message: .init(content: "Hi Horizon App", style: .semitransparent)) - AssistChatMessageView(message: .init(content: "Hi Horizon App", style: .white)) + AssistChatMessageView( + message: .init( + content: "Hi Horizon App", + style: .white, + onFeedbackChange: { _ in + + } + ) + ) AssistChatMessageView(message: .init()) AssistChatMessageView(message: .init( content: "You are a duck", diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatMessageViewModel.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatMessageViewModel.swift index 10546b06b9..74a4505565 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatMessageViewModel.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatMessageViewModel.swift @@ -21,6 +21,7 @@ import HorizonUI import SwiftUI struct AssistChatMessageViewModel: Identifiable, Equatable { + typealias OnFeedbackChange = (Bool?) -> Void typealias OnTapChipOption = (AssistChipOption) -> Void typealias OnTap = () -> Void @@ -30,25 +31,28 @@ struct AssistChatMessageViewModel: Identifiable, Equatable { case transparent } - let id: UUID + let id: String let content: String let style: Style let isLoading: Bool let chipOptions: [AssistChipOption] + let onFeedbackChange: OnFeedbackChange? let onTap: OnTap? let onTapChipOption: OnTapChipOption? init( - id: UUID = UUID(), + id: String = UUID().uuidString, content: String = "", style: Style = .white, isLoading: Bool = false, chipOptions: [AssistChipOption] = [], + onFeedbackChange: OnFeedbackChange? = nil, onTapChipOption: OnTapChipOption? = nil, onTap: OnTap? = nil ) { self.id = id self.content = content + self.onFeedbackChange = onFeedbackChange self.style = style self.isLoading = isLoading self.chipOptions = chipOptions @@ -58,9 +62,10 @@ struct AssistChatMessageViewModel: Identifiable, Equatable { /// For when it's just a loading spinner init() { - self.id = UUID() + self.id = UUID().uuidString self.isLoading = true self.content = "" + self.onFeedbackChange = nil self.style = .transparent self.chipOptions = [] self.onTapChipOption = nil diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatView.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatView.swift index b92e91e211..31cb49bd2e 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatView.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatView.swift @@ -74,10 +74,9 @@ struct AssistChatView: View { private func contentView() -> some View { ScrollViewReader { scrollViewProxy in LazyVStack(alignment: .leading, spacing: .huiSpaces.space16) { - ForEach(viewModel.messages) { message in + ForEach(viewModel.messages, id: \.id) { message in AssistChatMessageView(message: message) - .id(message.id.uuidString) - .transition(.scaleAndFade) + .id(message.id) } .animation(.smooth, value: viewModel.isRetryButtonVisible) .animation(.smooth, value: viewModel.messages) @@ -92,6 +91,7 @@ struct AssistChatView: View { .id(retryViewId) } } + .animation(.smooth, value: viewModel.messages) .onReceive(viewModel.showMoreButtonPublisher) { id in DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { withAnimation { @@ -124,7 +124,7 @@ struct AssistChatView: View { HStack(spacing: .huiSpaces.space16) { TextEditor(text: $viewModel.message) - .frame(minHeight: 44) + .frame(minHeight: 36) .frame(maxHeight: 100) .fixedSize(horizontal: false, vertical: true) .huiTypography(.p1) @@ -149,25 +149,6 @@ struct AssistChatView: View { } } -extension AnyTransition { - static var scaleAndFade: AnyTransition { - AnyTransition.opacity - .combined(with: .modifier( - active: ScaleEffectModifier(scale: 0.8), - identity: ScaleEffectModifier(scale: 1.0) - )) - } -} - -struct ScaleEffectModifier: ViewModifier { - let scale: CGFloat - - func body(content: Content) -> some View { - content - .scaleEffect(scale) - } -} - #if DEBUG #Preview { AssistChatView( diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatViewModel.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatViewModel.swift index ff22837266..205dd167bc 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatViewModel.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistChatViewModel.swift @@ -17,10 +17,9 @@ // import Combine +import CombineSchedulers import Core -import Observation import Foundation -import CombineSchedulers @Observable final class AssistChatViewModel { @@ -49,7 +48,7 @@ final class AssistChatViewModel { // MARK: - Dependencies - private var chatBotInteractor: AssistChatInteractor + private var assistChatInteractor: AssistChatInteractor private let router: Router // MARK: - Private @@ -61,7 +60,7 @@ final class AssistChatViewModel { private let courseId: String? private let pageUrl: String? private let fileId: String? - private var viewController = WeakViewController() + private weak var viewController: WeakViewController? private var hasAssistChipOptions: Bool = false // MARK: - Init @@ -78,40 +77,48 @@ final class AssistChatViewModel { self.fileId = fileId self.router = router self.scheduler = scheduler - self.chatBotInteractor = chatBotInteractor + self.assistChatInteractor = chatBotInteractor - self.chatBotInteractor + self.assistChatInteractor .listen .receive(on: scheduler) - .sink { [weak self] result in - guard let self else { return } - switch result { - case .success(let message): - onMessage(message, viewController: viewController) - case .failure: - isRetryButtonVisible = true - isLoaderVisible = false - canSendMessage = true - isErrorToastPresented = true + .sink( + receiveCompletion: { [weak self] completion in + guard let self else { return } + switch completion { + case .finished: + break // No action needed on completion + case .failure: + self.onFailure() + } + }, + receiveValue: { [weak self] result in + guard let self else { return } + switch result { + case .success(let message): + onMessage(message, viewController: viewController) + case .failure: + self.onFailure() + } } - } + ) .store(in: &subscriptions) - chatBotInteractor.publish(action: .chat()) + self.assistChatInteractor.publish(action: .begin) } // MARK: - Inputs func setInitialState() { - chatBotInteractor.setInitialState() isRetryButtonVisible = false isBackButtonVisible = false + assistChatInteractor.setInitialState() } func retry() { guard let lastMessage = messages.popLast() else { return } chatMessages = chatMessages.dropLast() - chatBotInteractor.publish(action: .chat(prompt: lastMessage.content, history: chatMessages)) + assistChatInteractor.publish(action: .chat(prompt: lastMessage.content, history: chatMessages)) isRetryButtonVisible = false shouldOpenKeyboardPublisher.send(false) } @@ -126,74 +133,75 @@ final class AssistChatViewModel { func send() { isRetryButtonVisible = false - isLoaderVisible = true shouldOpenKeyboardPublisher.send(true) send(message: message.trimmedEmptyLines) } func send(chipOption: AssistChipOption) { - chatBotInteractor.publish(action: .chip(option: chipOption, history: chatMessages)) + assistChatInteractor.publish(action: .chip(option: chipOption, history: chatMessages)) isBackButtonVisible = true } func send(message: String) { - chatBotInteractor.publish(action: .chat(prompt: message, history: chatMessages)) - if hasAssistChipOptions { - isBackButtonVisible = true - } + assistChatInteractor.publish(action: .chat(prompt: message, history: chatMessages)) + isBackButtonVisible = true self.message = "" } func scrollToBottom() { - showMoreButtonPublisher.send(messages.last?.id.uuidString ?? "") + showMoreButtonPublisher.send(messages.last?.id ?? "") } // MARK: - Private + private func onFailure() { + isRetryButtonVisible = true + isLoaderVisible = false + canSendMessage = true + isErrorToastPresented = true + remove(notAppearingIn: messages) + } /// handle the response from the interactor - private func onMessage(_ response: AssistChatResponse, viewController: WeakViewController) { + private func onMessage(_ response: AssistChatResponse, viewController: WeakViewController?) { + guard let viewController else { return } + weak var weakSelf = self self.chatMessages = response.chatHistory - var newMessages: [AssistChatMessageViewModel] = [] - // How the chips are displayed will depend on the history - // If we have no history, they are displayed as semitransparent message bubbles - // If we do have a history, they are pills at the end of the last message - if response.chatHistory.isEmpty { - let chipOptions = response.chipOptions ?? [] - hasAssistChipOptions = true - shouldOpenKeyboardPublisher.send(false) - newMessages = chipOptions.map { chipOption in - chipOption.viewModel { [weak self] in - self?.send(chipOption: chipOption) - } - } - } else { - shouldOpenKeyboardPublisher.send(messages.isEmpty) - newMessages = response.chatHistory.map { - $0.viewModel(response: response) { [weak self] quickResponse in - self?.send(chipOption: quickResponse) - } + shouldOpenKeyboardPublisher.send(messages.count == 1) + newMessages = response.chatHistory.map { message in + let onFeedbackChange: ((Bool?) -> Void)? = message.isSolicitingFeedback(with: response) ? { isGood in + weakSelf?.onFeedbackChange(isGood) + } : nil + + return message.viewModel( + response: response, + onFeedbackChange: onFeedbackChange + ) { quickResponse in + weakSelf?.send(chipOption: quickResponse) } } add(newMessages: newMessages) remove(notAppearingIn: newMessages) canSendMessage = !response.isLoading - isLoaderVisible = response.isLoading + + if response.isLoading { + messages.append(.init(isLoading: true)) + } let params = ["courseId": courseId, "pageUrl": pageUrl, "fileId": fileId].map { (key, value) in guard let value = value else { return nil } return "\(key)=\(value)" }.compactMap { $0 }.joined(separator: "&") - if let flashCards = response.flashCards?.flashCardModels, flashCards.count > 0 { + if let flashCards = response.chatHistory.last?.flashCards?.flashCardModels, flashCards.count > 0 { router.route( to: "/assistant/flashcards?\(params)", userInfo: ["flashCards": flashCards], from: viewController ) - } else if let quizItems = response.quizItems { + } else if let quizItems = response.chatHistory.last?.quizItems { let quizzes = quizItems.map { AssistQuizModel(from: $0) } router.route( to: "/assistant/quiz?\(params)", @@ -205,14 +213,23 @@ final class AssistChatViewModel { /// add new messages to the list of messages private func add(newMessages: [AssistChatMessageViewModel]) { - newMessages.filter { newMessage in - !self.messages.contains { message in - message.id == newMessage.id + weak var weakSelf = self + newMessages + .filter { newMessage in + guard let self = weakSelf else { return false } + return !self.messages.contains { message in + message.id == newMessage.id + } + }.forEach { message in + weakSelf?.messages.append(message) } - }.forEach { message in - self.messages.append(message) - } - showMoreButtonPublisher.send(newMessages.last?.id.uuidString ?? "") + showMoreButtonPublisher.send(newMessages.last?.id ?? "") + } + + private func onFeedbackChange(_ isGood: Bool?) { + guard let isGood = isGood else { return } + let responseType = isGood ? "good" : "bad" + Analytics.shared.logEvent("learning-assist-chat\(responseType)-response") } /// remove any messages that are not in the new list of messages returned from the interactor @@ -253,12 +270,27 @@ private extension AssistChipOption { } private extension AssistChatMessage { - func viewModel(response: AssistChatResponse, onTapChipOption: AssistChatMessageViewModel.OnTapChipOption? = nil) -> AssistChatMessageViewModel { - AssistChatMessageViewModel( - id: self.id, - content: self.text, + func isFinalMessage(in history: [AssistChatMessage]) -> Bool { + guard let lastMessage = history.last else { return false } + return self.id == lastMessage.id && self.role == .Assistant + } + + func isSolicitingFeedback(with response: AssistChatResponse) -> Bool { + return self.role == .Assistant && self.isFinalMessage(in: response.chatHistory) && !response.isLoading + } + + func viewModel( + response: AssistChatResponse, + onFeedbackChange: AssistChatMessageViewModel.OnFeedbackChange? = nil, + onTapChipOption: AssistChatMessageViewModel.OnTapChipOption? = nil + ) -> AssistChatMessageViewModel { + let chipOptions = self.id == response.chatHistory.last?.id ? (response.chatHistory.last?.chipOptions ?? []) : [] + return .init( + id: "\(self.id)\(chipOptions.count)\(onFeedbackChange != nil ? "feedback" : ""))", + content: self.text ?? "", style: self.role == .Assistant ? .transparent : .white, - chipOptions: self == response.chatHistory.last ? (response.chipOptions ?? []) : [], + chipOptions: chipOptions, + onFeedbackChange: onFeedbackChange, onTapChipOption: onTapChipOption ) } diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistDataEnvironment.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistDataEnvironment.swift new file mode 100644 index 0000000000..21b68bc462 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistDataEnvironment.swift @@ -0,0 +1,43 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import CombineExt + +struct AssistDataEnvironment { + private(set) var courseID = CurrentValueRelay(nil) + private(set) var fileID = CurrentValueRelay(nil) + private(set) var pageURL = CurrentValueRelay(nil) + + init( + courseID: String? = nil, + fileID: String? = nil, + pageURL: String? = nil + ) { + self.courseID.accept(courseID) + self.fileID.accept(fileID) + self.pageURL.accept(pageURL) + } + + func duplicate() -> AssistDataEnvironment { + AssistDataEnvironment( + courseID: courseID.value, + fileID: fileID.value, + pageURL: pageURL.value + ) + } +} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistFeedbackView.swift b/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistFeedbackView.swift new file mode 100644 index 0000000000..4c67464c4c --- /dev/null +++ b/Horizon/Horizon/Sources/Features/Assist/AssistChat/View/AssistFeedbackView.swift @@ -0,0 +1,120 @@ +// +// This file is part of Canvas. +// Copyright (C) 2024-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import HorizonUI +import SwiftUI + +struct AssistFeedbackView: View { + private var onChange: AssistChatMessageViewModel.OnFeedbackChange + @State private var selected: Bool? + @State private var thumbsOpacity: Double = 1.0 + @State private var thanksOpacity: Double = 0.0 + + init(onChange: @escaping AssistChatMessageViewModel.OnFeedbackChange) { + self.onChange = onChange + } + + private var thumbsUpOpacity: Double { + selected == true ? 1 : 0 + } + private var thumbsDownOpacity: Double { + selected == false ? 1 : 0 + } + + var body: some View { + ZStack(alignment: .leading) { + HStack(spacing: HorizonUI.spaces.space8) { + thumbUpIcon + thumbDownIcon + } + .opacity(thumbsOpacity) + .animation(.easeInOut(duration: 0.2), value: thumbsOpacity) + + Text(String(localized: "Thank you for your feedback!", bundle: .horizon)) + .huiTypography(.p1) + .foregroundColor(HorizonUI.colors.text.surfaceColored) + .opacity(thanksOpacity) + .animation(.easeInOut(duration: 0.2), value: thanksOpacity) + } + } + + private var thumbUpIcon: some View { + ZStack { + HorizonUI.IconButton( + .huiIcons.thumbUp, + type: .whiteOutline + ) { + onTap(true) + } + + HorizonUI.IconButton( + .huiIcons.thumbUpFilled, + type: .whiteOutline + ) { + onTap(true) + } + .opacity(thumbsUpOpacity) + .animation(.easeInOut(duration: 0.2), value: thumbsUpOpacity) + } + } + + private var thumbDownIcon: some View { + ZStack { + HorizonUI.IconButton( + .huiIcons.thumbDown, + type: .whiteOutline + ) { + onTap(false) + } + + HorizonUI.IconButton( + .huiIcons.thumbDownFilled, + type: .whiteOutline + ) { + onTap(false) + } + .opacity(thumbsDownOpacity) + .animation(.easeInOut(duration: 0.2), value: thumbsDownOpacity) + } + } + + private func onTap(_ value: Bool) { + thumbsOpacity = 0.0 + thanksOpacity = 1.0 + selected = selected == value ? nil : value + onChange(selected) + + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + thanksOpacity = 0.0 + } + } +} + +#Preview { + @Previewable @State var selected: Bool? + + VStack(spacing: HorizonUI.spaces.space16) { + AssistFeedbackView { + selected = $0 + } + } + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + .background(HorizonUI.colors.surface.igniteAIPrimaryGradient) +} diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistFlashCard/View/AssistFlashCardView.swift b/Horizon/Horizon/Sources/Features/Assist/AssistFlashCard/View/AssistFlashCardView.swift index cf31376808..8d8c813ad7 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistFlashCard/View/AssistFlashCardView.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistFlashCard/View/AssistFlashCardView.swift @@ -25,6 +25,26 @@ struct AssistFlashCardView: View { @Environment(\.viewController) private var viewController var body: some View { + ZStack { + loaderView + content + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .applyHorizonGradient() + } +} + +// MARK: - Components + +extension AssistFlashCardView { + @ViewBuilder + private var loaderView: some View { + HorizonUI.Spinner(size: .small, foregroundColor: Color.huiColors.surface.cardPrimary) + .opacity(viewModel.isLoaderVisible ? 1 : 0) + .animation(.smooth, value: viewModel.isLoaderVisible) + } + + private var content: some View { VStack { headerView .padding(.horizontal, .huiSpaces.space24) @@ -58,25 +78,8 @@ struct AssistFlashCardView: View { } .padding([.bottom, .top], .huiSpaces.space16) } - .applyHorizonGradient() - .overlay { loaderView } - } -} - -// MARK: - Components - -extension AssistFlashCardView { - @ViewBuilder - private var loaderView: some View { - if viewModel.isLoaderVisible { - ZStack { - Rectangle() - .fill(Color.clear) - .applyHorizonGradient() - .ignoresSafeArea() - HorizonUI.Spinner(size: .small, foregroundColor: Color.huiColors.surface.cardPrimary) - } - } + .opacity(viewModel.isLoaderVisible ? 0 : 1) + .animation(.smooth, value: viewModel.isLoaderVisible) } private var headerView: some View { diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistFlashCard/View/AssistFlashCardViewModel.swift b/Horizon/Horizon/Sources/Features/Assist/AssistFlashCard/View/AssistFlashCardViewModel.swift index 3c7c8b25a2..7bbffbbd25 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistFlashCard/View/AssistFlashCardViewModel.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistFlashCard/View/AssistFlashCardViewModel.swift @@ -74,15 +74,26 @@ final class AssistFlashCardViewModel { self.chatBotInteractor .listen .receive(on: scheduler) - .sink { [weak self] result in - switch result { - case .success(let message): - self?.onMessage(message) - case .failure(let error): - self?.isLoaderVisible = false - self?.errorMessage = error.localizedDescription + .sink( + receiveCompletion: { [weak self] completion in + switch completion { + case .finished: + self?.isLoaderVisible = false + case .failure(let error): + self?.isLoaderVisible = false + self?.errorMessage = error.localizedDescription + } + }, + receiveValue: { [weak self] result in + switch result { + case .success(let message): + self?.onMessage(message) + case .failure(let error): + self?.isLoaderVisible = false + self?.errorMessage = error.localizedDescription + } } - } + ) .store(in: &subscriptions) } @@ -115,7 +126,13 @@ final class AssistFlashCardViewModel { isLoaderVisible = true currentPage = 0 chatBotInteractor.publish( - action: .chip(option: AssistChipOption(.flashcards), history: chatHistory) + action: .chip( + option: AssistChipOption( + chip: String(localized: "Generate Flash Cards", bundle: .horizon), + prompt: "Generate Flash Cards" + ), + history: chatHistory + ) ) return } @@ -126,13 +143,13 @@ final class AssistFlashCardViewModel { private func onMessage(_ response: AssistChatResponse) { chatHistory = response.chatHistory - guard let flashCardModels = response.flashCards?.flashCardModels else { + guard let flashCardModels = response.chatHistory.last?.flashCards?.flashCardModels else { return } currentCardIndex = 0 currentPage = 0 paginatedFlashCards = flashCardModels.chunked(into: 5) flashCards = paginatedFlashCards.first ?? [] - isLoaderVisible = false + isLoaderVisible = response.isLoading } } diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistQuiz/Data/AssistQuizModel.swift b/Horizon/Horizon/Sources/Features/Assist/AssistQuiz/Data/AssistQuizModel.swift index 7b08a95463..6b606251c2 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistQuiz/Data/AssistQuizModel.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistQuiz/Data/AssistQuizModel.swift @@ -26,7 +26,7 @@ struct AssistQuizModel { let options: [AnswerOption] let correctAnswerIndex: Int - init(from quiz: AssistChatResponse.QuizItem) { + init(from quiz: AssistChatMessage.QuizItem) { id = UUID() question = quiz.question options = quiz.answers.map { AnswerOption($0) } diff --git a/Horizon/Horizon/Sources/Features/Assist/AssistQuiz/View/AssistQuizViewModel.swift b/Horizon/Horizon/Sources/Features/Assist/AssistQuiz/View/AssistQuizViewModel.swift index 3451a9741c..561c783c93 100644 --- a/Horizon/Horizon/Sources/Features/Assist/AssistQuiz/View/AssistQuizViewModel.swift +++ b/Horizon/Horizon/Sources/Features/Assist/AssistQuiz/View/AssistQuizViewModel.swift @@ -75,16 +75,27 @@ final class AssistQuizViewModel { self.chatBotInteractor .listen .receive(on: scheduler) - .sink { [weak self] result in - switch result { - case .success(let message): - self?.onMessage(message) - case .failure(let error): - self?.isLoaderVisible = false - self?.errorMessage = error.localizedDescription + .sink( + receiveCompletion: { [weak self] completion in + switch completion { + case .finished: + self?.isLoaderVisible = false + case .failure(let error): + self?.isLoaderVisible = false + self?.errorMessage = error.localizedDescription + } + }, + receiveValue: { [weak self] result in + switch result { + case .success(let message): + self?.onMessage(message) + case .failure(let error): + self?.isLoaderVisible = false + self?.errorMessage = error.localizedDescription + } } - } - .store(in: &subscriptions) + ) + .store(in: &subscriptions) quiz = quizzes.first } @@ -128,7 +139,7 @@ final class AssistQuizViewModel { private func onMessage(_ response: AssistChatResponse) { chatHistory = response.chatHistory - guard let quizItems = response.quizItems else { + guard let quizItems = response.chatHistory.last?.quizItems else { return } let quizzes = quizItems.map { AssistQuizModel(from: $0) } @@ -139,7 +150,13 @@ final class AssistQuizViewModel { isLoaderVisible = true currentQuizIndex = 0 chatBotInteractor.publish( - action: .chip(option: AssistChipOption(.quiz), history: chatHistory) + action: .chip( + option: AssistChipOption( + chip: String(localized: "Create a Quiz", bundle: .horizon), + prompt: "Create a Quiz" + ), + history: chatHistory + ) ) } } diff --git a/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/thumb_down_filled.imageset/Contents.json b/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/thumb_down_filled.imageset/Contents.json new file mode 100644 index 0000000000..c202c40e27 --- /dev/null +++ b/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/thumb_down_filled.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "thumb_down_filled.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/thumb_down_filled.imageset/thumb_down_filled.svg b/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/thumb_down_filled.imageset/thumb_down_filled.svg new file mode 100644 index 0000000000..6f6b5af5c1 --- /dev/null +++ b/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/thumb_down_filled.imageset/thumb_down_filled.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/thumb_up_filled.imageset/Contents.json b/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/thumb_up_filled.imageset/Contents.json new file mode 100644 index 0000000000..0630011f3a --- /dev/null +++ b/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/thumb_up_filled.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "thumb_up_filled.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/thumb_up_filled.imageset/thumb_up_filled.svg b/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/thumb_up_filled.imageset/thumb_up_filled.svg new file mode 100644 index 0000000000..a426b1f31c --- /dev/null +++ b/packages/HorizonUI/Sources/HorizonUI/Resources/Assets.xcassets/Icons/thumb_up_filled.imageset/thumb_up_filled.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/HorizonUI/Sources/HorizonUI/Sources/Foundation/Icons/HorizonUI.Icons.swift b/packages/HorizonUI/Sources/HorizonUI/Sources/Foundation/Icons/HorizonUI.Icons.swift index 8479df6c78..88ff19b70c 100644 --- a/packages/HorizonUI/Sources/HorizonUI/Sources/Foundation/Icons/HorizonUI.Icons.swift +++ b/packages/HorizonUI/Sources/HorizonUI/Sources/Foundation/Icons/HorizonUI.Icons.swift @@ -403,7 +403,9 @@ public extension HorizonUI { public let image = Image(.image) public let videocam = Image(.videocam) public let thumbUp = Image(.thumbUp) + public let thumbUpFilled = Image(.thumbUpFilled) public let thumbDown = Image(.thumbDown) + public let thumbDownFilled = Image(.thumbDownFilled) public let keepPin = Image(.keepPin) public let book2Filled = Image(.book2Filled) public let book2 = Image(.book2)