diff --git a/chatapi/package.json b/chatapi/package.json index c01f6bc76a..bf7a090f82 100644 --- a/chatapi/package.json +++ b/chatapi/package.json @@ -28,6 +28,7 @@ }, "homepage": "https://github.com/open-learning-exchange/planet#readme", "dependencies": { + "@google/generative-ai": "^0.3.0", "@types/cors": "^2.8.13", "@types/express": "^4.17.17", "cors": "^2.8.5", diff --git a/chatapi/src/config/gemini.config.ts b/chatapi/src/config/gemini.config.ts new file mode 100644 index 0000000000..35bed6f594 --- /dev/null +++ b/chatapi/src/config/gemini.config.ts @@ -0,0 +1,9 @@ +import { GoogleGenerativeAI } from '@google/generative-ai'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const gemini = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || ''); + +export default gemini; + diff --git a/chatapi/src/index.ts b/chatapi/src/index.ts index ec1024505c..d393b12d73 100644 --- a/chatapi/src/index.ts +++ b/chatapi/src/index.ts @@ -21,20 +21,20 @@ app.get('/', (req: any, res: any) => { app.post('/', async (req: any, res: any) => { try { - const { data, save, usePerplexity } = req.body; + const { data, save, aiProvider } = req.body; if (typeof data !== 'object' || Array.isArray(data) || Object.keys(data).length === 0) { res.status(400).json({ 'error': 'Bad Request', 'message': 'The "data" field must be a non-empty object' }); } if (!save) { - const response = await chatNoSave(data.content, usePerplexity); + const response = await chatNoSave(data.content, aiProvider); res.status(200).json({ 'status': 'Success', 'chat': response }); } else if (save) { - const response = await chat(data, usePerplexity); + const response = await chat(data, aiProvider); res.status(201).json({ 'status': 'Success', 'chat': response?.completionText, @@ -51,7 +51,8 @@ app.post('/', async (req: any, res: any) => { app.get('/checkproviders', (req: any, res: any) => { res.status(200).json({ 'openai': process.env.OPENAI_API_KEY ? true : false, - 'perplexity': process.env.PERPLEXITY_API_KEY ? true : false + 'perplexity': process.env.PERPLEXITY_API_KEY ? true : false, + 'gemini': process.env.GEMINI_API_KEY ? true : false }); }); diff --git a/chatapi/src/models/ai-providers.model.ts b/chatapi/src/models/ai-providers.model.ts new file mode 100644 index 0000000000..b1d9aab1bc --- /dev/null +++ b/chatapi/src/models/ai-providers.model.ts @@ -0,0 +1,6 @@ +export type ProviderName = 'openai' | 'perplexity' | 'gemini'; + +export interface AIProvider { + name: ProviderName; + model?: string; +} diff --git a/chatapi/src/models/chat-message.model.ts b/chatapi/src/models/chat-message.model.ts index 315857a6bd..f2452a9139 100644 --- a/chatapi/src/models/chat-message.model.ts +++ b/chatapi/src/models/chat-message.model.ts @@ -2,3 +2,8 @@ export interface ChatMessage { role: 'user' | 'assistant'; content: string; } + +export interface GeminiMessage { + role: 'user' | 'model'; + parts: any[]; +} diff --git a/chatapi/src/models/db-doc.model.ts b/chatapi/src/models/db-doc.model.ts index 81d3295026..5bb5af7107 100644 --- a/chatapi/src/models/db-doc.model.ts +++ b/chatapi/src/models/db-doc.model.ts @@ -4,5 +4,6 @@ export interface DbDoc { user: any; title: string; createdDate: number; + aiProvider: string; conversations: []; } diff --git a/chatapi/src/services/chat.service.ts b/chatapi/src/services/chat.service.ts index 76f407d0f8..93778e78c8 100644 --- a/chatapi/src/services/chat.service.ts +++ b/chatapi/src/services/chat.service.ts @@ -1,9 +1,10 @@ import { DocumentInsertResponse } from 'nano'; import db from '../config/nano.config'; -import { gptChat } from '../utils/gpt-chat.utils'; +import { aiChat } from '../utils/chat.utils'; import { retrieveChatHistory } from '../utils/db.utils'; import { handleChatError } from '../utils/chat-error.utils'; +import { AIProvider } from '../models/ai-providers.model'; import { ChatMessage } from '../models/chat-message.model'; /** @@ -11,7 +12,7 @@ import { ChatMessage } from '../models/chat-message.model'; * @param data - Chat data including content and additional information * @returns Object with completion text and CouchDB save response */ -export async function chat(data: any, usePerplexity: boolean): Promise<{ +export async function chat(data: any, aiProvider: AIProvider): Promise<{ completionText: string; couchSaveResponse: DocumentInsertResponse; } | undefined> { @@ -28,6 +29,7 @@ export async function chat(data: any, usePerplexity: boolean): Promise<{ dbData.title = content; dbData.conversations = []; dbData.createdDate = Date.now(); + dbData.aiProvider = aiProvider.name; } dbData.conversations.push({ 'query': content, 'response': '' }); @@ -36,7 +38,7 @@ export async function chat(data: any, usePerplexity: boolean): Promise<{ messages.push({ 'role': 'user', content }); try { - const completionText = await gptChat(messages, usePerplexity); + const completionText = await aiChat(messages, aiProvider); dbData.conversations[dbData.conversations.length - 1].response = completionText; @@ -54,13 +56,13 @@ export async function chat(data: any, usePerplexity: boolean): Promise<{ } } -export async function chatNoSave(content: any, usePerplexity: boolean): Promise< string | undefined> { +export async function chatNoSave(content: any, aiProvider: AIProvider): Promise< string | undefined> { const messages: ChatMessage[] = []; messages.push({ 'role': 'user', content }); try { - const completionText = await gptChat(messages, usePerplexity); + const completionText = await aiChat(messages, aiProvider); messages.push({ 'role': 'assistant', 'content': completionText }); diff --git a/chatapi/src/utils/chat.utils.ts b/chatapi/src/utils/chat.utils.ts new file mode 100644 index 0000000000..5886c28c3f --- /dev/null +++ b/chatapi/src/utils/chat.utils.ts @@ -0,0 +1,68 @@ +import openai from '../config/openai.config'; +import perplexity from '../config/perplexity.config'; +import gemini from '../config/gemini.config'; +import { AIProvider, ProviderName } from '../models/ai-providers.model'; +import { ChatMessage, GeminiMessage } from '../models/chat-message.model'; + +const providers: { [key in ProviderName]: { ai: any; defaultModel: string } } = { + 'openai': { 'ai': openai, 'defaultModel': 'gpt-3.5-turbo' }, + 'perplexity': { 'ai': perplexity, 'defaultModel': 'pplx-7b-online' }, + 'gemini': { 'ai': gemini, 'defaultModel': 'gemini-pro' }, +}; + +async function handleGemini(messages: ChatMessage[], model: string): Promise { + const geminiModel = gemini.getGenerativeModel({ model }); + + const msg = messages[messages.length - 1].content; + + const geminiMessages: GeminiMessage[] = messages.map((message) => ({ + 'role': message.role === 'assistant' ? 'model' : message.role, + 'parts': [{ 'text': message.content }], + })); + + geminiMessages.pop(); + + const chat = geminiModel.startChat({ + 'history': geminiMessages, + 'generationConfig': { + 'maxOutputTokens': 100, + }, + }); + + const result = await chat.sendMessage(msg); + const response = await result.response; + const text = response.text(); + + return text; +} + + + +/** + * Uses openai's createChatCompletion endpoint to generate chat completions + * @param messages - Array of chat messages + * @returns Completion text + */ +export async function aiChat(messages: ChatMessage[], aiProvider: AIProvider): Promise { + const provider = providers[aiProvider.name]; + if (!provider) { + throw new Error('Unsupported AI provider'); + } + const model = aiProvider.model ?? provider.defaultModel; + + if (aiProvider.name === 'gemini') { + return handleGemini(messages, model); + } else { + const completion = await provider.ai.chat.completions.create({ + model, + messages, + }); + + const completionText = completion.choices[0]?.message?.content; + if (!completionText) { + throw new Error('Unexpected API response'); + } + + return completionText; + } +} diff --git a/chatapi/src/utils/db.utils.ts b/chatapi/src/utils/db.utils.ts index 483b83b428..dccdeff46f 100644 --- a/chatapi/src/utils/db.utils.ts +++ b/chatapi/src/utils/db.utils.ts @@ -14,6 +14,7 @@ async function getChatDocument(id: string) { 'conversations': res.conversations, 'title': res.title, 'createdDate': res.createdDate, + 'aiProvider': res.aiProvider }; // Should return user, team data as well particularly for the "/conversations" endpoint } catch (error) { @@ -25,10 +26,11 @@ async function getChatDocument(id: string) { } export async function retrieveChatHistory(dbData: any, messages: ChatMessage[]) { - const { conversations, title, createdDate } = await getChatDocument(dbData._id); + const { conversations, title, createdDate, aiProvider } = await getChatDocument(dbData._id); dbData.conversations = conversations; dbData.title = title; dbData.createdDate = createdDate; + dbData.aiProvider = aiProvider; for (const { query, response } of conversations) { messages.push({ 'role': 'user', 'content': query }); diff --git a/chatapi/src/utils/gpt-chat.utils.ts b/chatapi/src/utils/gpt-chat.utils.ts deleted file mode 100644 index dff2df6eab..0000000000 --- a/chatapi/src/utils/gpt-chat.utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import openai from '../config/openai.config'; -import perplexity from '../config/perplexity.config'; -import { ChatMessage } from '../models/chat-message.model'; - -/** - * Uses openai's createChatCompletion endpoint to generate chat completions - * @param messages - Array of chat messages - * @returns Completion text - */ -export async function gptChat(messages: ChatMessage[], usePerplexity: boolean): Promise { - const ai = usePerplexity ? perplexity : openai; - const completion = await ai.chat.completions.create({ - 'model': usePerplexity ? 'pplx-7b-online' : 'gpt-3.5-turbo', - messages, - }); - - const completionText = completion.choices[0]?.message?.content; - if (!completionText) { - throw new Error('Unexpected API response'); - } - - return completionText; -} diff --git a/docker/chat.env.example b/docker/chat.env.example index 7e74980417..0cfa25459d 100644 --- a/docker/chat.env.example +++ b/docker/chat.env.example @@ -1,2 +1,3 @@ OPENAI_API_KEY= PERPLEXITY_API_KEY= +GEMINI_API_KEY= diff --git a/package.json b/package.json index f5dc5b4c04..6eac8aa814 100755 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "planet", "license": "AGPL-3.0", - "version": "0.14.17", + "version": "0.14.18", "myplanet": { - "latest": "v0.14.5", - "min": "v0.13.57" + "latest": "v0.14.21", + "min": "v0.13.69" }, "scripts": { "ng": "ng", diff --git a/src/app/chat/chat-window/chat-window.component.ts b/src/app/chat/chat-window/chat-window.component.ts index ba3a61b885..573d572bb1 100644 --- a/src/app/chat/chat-window/chat-window.component.ts +++ b/src/app/chat/chat-window/chat-window.component.ts @@ -4,7 +4,7 @@ import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { CustomValidators } from '../../validators/custom-validators'; -import { ConversationForm } from '../chat.model'; +import { ConversationForm, AIProvider } from '../chat.model'; import { ChatService } from '../../shared/chat.service'; import { showFormErrors } from '../../shared/table-helpers'; import { UserService } from '../../shared/user.service'; @@ -17,7 +17,7 @@ import { UserService } from '../../shared/user.service'; export class ChatWindowComponent implements OnInit, OnDestroy { private onDestroy$ = new Subject(); spinnerOn = true; - usePerplexity: boolean; + provider: AIProvider; conversations: any[] = []; selectedConversationId: any; promptForm: FormGroup; @@ -75,7 +75,9 @@ export class ChatWindowComponent implements OnInit, OnDestroy { this.chatService.toggleAIService$ .pipe(takeUntil(this.onDestroy$)) .subscribe((aiService => { - this.usePerplexity = aiService === 'perplexity' ? true : false; + this.provider = { + name: aiService + }; })); } @@ -123,9 +125,9 @@ export class ChatWindowComponent implements OnInit, OnDestroy { postSubmit() { this.changeDetectorRef.detectChanges(); - this.spinnerOn = false; + this.spinnerOn = true; this.scrollTo('bottom'); - this.promptForm.reset(); + this.promptForm.controls['prompt'].setValue(''); } onSubmit() { @@ -142,7 +144,7 @@ export class ChatWindowComponent implements OnInit, OnDestroy { this.setSelectedConversation(); - this.chatService.getPrompt(this.data, true, this.usePerplexity).subscribe( + this.chatService.getPrompt(this.data, true, this.provider).subscribe( (completion: any) => { this.conversations.push({ query: content, response: completion?.chat }); this.selectedConversationId = { diff --git a/src/app/chat/chat.component.html b/src/app/chat/chat.component.html index d4fe2752e4..3ac261d28c 100644 --- a/src/app/chat/chat.component.html +++ b/src/app/chat/chat.component.html @@ -2,13 +2,11 @@ AI Chat - - Open AI - Perplexity AI + + {{service.name}} - Open AI - Perplexity AI + {{activeService}} diff --git a/src/app/chat/chat.component.ts b/src/app/chat/chat.component.ts index 46f00b9c79..dcd6b96d96 100644 --- a/src/app/chat/chat.component.ts +++ b/src/app/chat/chat.component.ts @@ -4,7 +4,7 @@ import { catchError } from 'rxjs/operators'; import { of } from 'rxjs'; import { ChatService } from '../shared/chat.service'; -import { AIServices } from './chat.model'; +import { AIServices, ProviderName } from './chat.model'; @Component({ selector: 'planet-chat', @@ -12,7 +12,8 @@ import { AIServices } from './chat.model'; styleUrls: [ './chat.scss' ] }) export class ChatComponent implements OnInit { - aiService: 'openai' | 'perplexity'; + activeService: string; + aiServices: { name: ProviderName, value: ProviderName }[] = []; displayToggle: boolean; constructor( @@ -25,11 +26,21 @@ export class ChatComponent implements OnInit { this.chatService.fetchAIProviders().pipe( catchError(err => { console.error(err); - return of({ openai: false, perplexity: false }); + return of({ openai: false, perplexity: false, gemini: false }); }) - ).subscribe((aiServices: AIServices) => { - this.displayToggle = aiServices.openai && aiServices.perplexity; - this.aiService = aiServices.openai ? 'openai' : 'perplexity'; + ).subscribe((services: AIServices) => { + for (const [ key, value ] of Object.entries(services)) { + if (value === true) { + this.aiServices.push({ + name: key as ProviderName, + value: key as ProviderName + }); + } + } + + this.activeService = this.aiServices[0].value; + this.displayToggle = this.aiServices.length > 0; + this.chatService.toggleAIServiceSignal(this.activeService); }); } @@ -38,7 +49,7 @@ export class ChatComponent implements OnInit { } toggleAIService(): void { - this.chatService.toggleAIServiceSignal(this.aiService); + this.chatService.toggleAIServiceSignal(this.activeService); } } diff --git a/src/app/chat/chat.model.ts b/src/app/chat/chat.model.ts index 06fc8ea8aa..2c3232e25c 100644 --- a/src/app/chat/chat.model.ts +++ b/src/app/chat/chat.model.ts @@ -20,7 +20,15 @@ export interface Message { response: string; } +export type ProviderName = 'openai' | 'perplexity' | 'gemini'; + +export interface AIProvider { + name: string; + model?: string; +} + export interface AIServices { openai: boolean; perplexity: boolean; + gemini: boolean; } diff --git a/src/app/courses/search-courses/courses-search.component.ts b/src/app/courses/search-courses/courses-search.component.ts new file mode 100644 index 0000000000..f998b73957 --- /dev/null +++ b/src/app/courses/search-courses/courses-search.component.ts @@ -0,0 +1,130 @@ +import { + Component, + Input, + ViewEncapsulation, + OnChanges, + Output, + EventEmitter, + OnInit, + ViewChildren, + QueryList, + ViewChild +} from '@angular/core'; +import * as constants from '../constants'; +import { languages } from '../../shared/languages'; +import { dedupeShelfReduce } from '../../shared/utils'; +import { MatSelectionList } from '@angular/material/list'; + +@Component({ + template: ` + {category, select, + subject {Subject} + grade {Grade} + language {Language} + } + + + + {{item.label}} + + + `, + selector: 'planet-courses-search-list', + styleUrls: [ './courses-search.scss' ], + encapsulation: ViewEncapsulation.None +}) +export class CoursesSearchListComponent { + + @Input() category; + @Input() items; + @Input() selected: string[] = []; + @Output() selectChange = new EventEmitter(); + @ViewChild(MatSelectionList) selectionList: MatSelectionList; + + selectionChange(event) { + this.emitChange(event.source.selectedOptions.selected.map(option => option.value)); + } + + emitChange(items) { + this.selectChange.emit({ + items, + category: this.category + }); + } + + reset() { + this.selectionList.deselectAll(); + } + + isSelected(item) { + return this.selected.indexOf(item) > -1; + } + +} + +@Component({ + template: ` + + + `, + styleUrls: [ './courses-search.scss' ], + selector: 'planet-courses-search', + encapsulation: ViewEncapsulation.None +}) +export class CoursesSearchComponent implements OnInit, OnChanges { + + @Input() filteredData: any[]; + @Input() startingSelection: any; + @Output() searchChange = new EventEmitter(); + @ViewChildren(CoursesSearchListComponent) searchListComponents: QueryList; + + categories = [ + { 'label': 'language', 'options': languages }, + { 'label': 'subject', 'options': constants.subjectLevels }, + { 'label': 'grade', 'options': constants.gradeLevels }, + ]; + + searchLists = []; + selected: any = {}; + + ngOnInit() { + this.reset({ startingSelection: this.startingSelection, isInit: true }); + } + + ngOnChanges() { + this.searchLists = this.categories.reduce((lists, category) => { + return lists.concat(this.createSearchList(category, this.filteredData)); + }, []); + } + + reset({ startingSelection = {}, isInit = false } = {}) { + this.selected = this.categories.reduce((select, category) => ({ ...select, [category.label]: [] }), {}); + this.selected = { ...this.selected, ...startingSelection }; + if (!isInit) { + this.searchListComponents.forEach((component) => component.reset()); + } + } + + createSearchList(category, data) { + return ({ + category: category.label, + items: data.reduce((list, { doc }) => list.concat(doc[category.label]), []).reduce(dedupeShelfReduce, []).filter(item => item) + .sort((a, b) => a.toLowerCase() > b.toLowerCase() ? 1 : -1).map(item => category.options.find(opt => opt.value === item)) + }); + } + + selectChange({ items, category }) { + this.selected[category] = items; + this.searchChange.emit({ items, category }); + } + + trackByFn(index, item: { category: string, items: string[] }) { + return item.category; + } + +} diff --git a/src/app/courses/search-courses/courses-search.scss b/src/app/courses/search-courses/courses-search.scss new file mode 100644 index 0000000000..52367bb5a0 --- /dev/null +++ b/src/app/courses/search-courses/courses-search.scss @@ -0,0 +1,25 @@ +@import '../courses'; + +planet-courses-search { + width: 100%; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(125px, 1fr)); + grid-template-rows: $toolbar-height; + grid-auto-flow: column; + align-items: start; + grid-column-gap: 0.75rem; + padding-bottom: 0.5rem; + + &> planet-courses-search-list { + height: inherit; + display: grid; + grid-template-rows: $label-height calc(#{$toolbar-height} - #{$label-height}); + + .mat-selection-list { + height: calc(#{$toolbar-height} - #{$label-height}); + padding: 0; + overflow-y: auto; + } + } + +} diff --git a/src/app/shared/chat.service.ts b/src/app/shared/chat.service.ts index d42cdc8798..c4c929c526 100644 --- a/src/app/shared/chat.service.ts +++ b/src/app/shared/chat.service.ts @@ -31,11 +31,11 @@ import { CouchService } from '../shared/couchdb.service'; return this.httpClient.get(`${this.baseUrl}/checkproviders`); } - getPrompt(data: Object, save: boolean, usePerplexity: boolean): Observable { + getPrompt(data: Object, save: boolean, aiProvider: Object): Observable { return this.httpClient.post(this.baseUrl, { data, save, - usePerplexity + aiProvider }); }