diff --git a/chatapi/src/config/nano.config.ts b/chatapi/src/config/nano.config.ts new file mode 100644 index 0000000000..5b936d59b2 --- /dev/null +++ b/chatapi/src/config/nano.config.ts @@ -0,0 +1,8 @@ +import nano from 'nano'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const db = nano(process.env.COUCHDB_HOST || 'http://couchdb:5984').use('chat_history'); + +export default db; diff --git a/chatapi/src/config/openai.config.ts b/chatapi/src/config/openai.config.ts new file mode 100644 index 0000000000..f0cb495c88 --- /dev/null +++ b/chatapi/src/config/openai.config.ts @@ -0,0 +1,12 @@ +import { Configuration, OpenAIApi } from 'openai'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const configuration = new Configuration({ + 'apiKey': process.env.OPENAI_API_KEY, +}); + +const openai = new OpenAIApi(configuration); + +export default openai; diff --git a/chatapi/src/index.ts b/chatapi/src/index.ts index 6cc1c5a0e5..9e37b247e3 100644 --- a/chatapi/src/index.ts +++ b/chatapi/src/index.ts @@ -1,8 +1,10 @@ import express from 'express'; -import { chatWithGpt } from './services/gpt-prompt.service'; import dotenv from 'dotenv'; import cors from 'cors'; +import { getChatDocument } from './utils/db.utils'; +import { chat, chatNoSave } from './services/chat.service'; + dotenv.config(); const app = express(); @@ -20,24 +22,42 @@ app.get('/', (req: any, res: any) => { app.post('/', async (req: any, res: any) => { try { - const userInput = req.body.content; + const { data, save } = req.body; - if (userInput && typeof userInput === 'string') { - const response = await chatWithGpt(userInput); + if (!save) { + const response = await chatNoSave(data.content); res.status(200).json({ + 'status': 'Success', + 'chat': response + }); + } else if (save && data && typeof data === 'object') { + const response = await chat(data); + res.status(201).json({ 'status': 'Success', 'chat': response?.completionText, - 'history': response?.history, 'couchDBResponse': response?.couchSaveResponse }); } else { - res.status(400).json({ 'error': 'Bad Request', 'message': 'The "content" field must be a non-empty string.' }); + res.status(400).json({ 'error': 'Bad Request', 'message': 'The "data" field must be a non-empty object' }); } } catch (error: any) { res.status(500).json({ 'error': 'Internal Server Error', 'message': error.message }); } }); +app.get('/conversation/:id', async (req: any, res: any) => { + try { + const { id } = req.params; + const conversation = await getChatDocument(id); + res.status(200).json({ + 'status': 'Success', + conversation + }); + } catch (error: any) { + res.status(500).json({ 'error': 'Internal Server Error', 'message': error.message }); + } +}); + const port = process.env.SERVE_PORT || 5000; app.listen(port, () => console.log(`Server running on port ${port}`)); // eslint-disable-line no-console diff --git a/chatapi/src/models/database-actions.model.ts b/chatapi/src/models/database-actions.model.ts deleted file mode 100644 index 75a396be09..0000000000 --- a/chatapi/src/models/database-actions.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { DocumentInsertResponse } from 'nano'; - -export interface DatabaseActions { - insert(data: any): Promise; -} diff --git a/chatapi/src/models/db-doc.model.ts b/chatapi/src/models/db-doc.model.ts new file mode 100644 index 0000000000..cc5797fd43 --- /dev/null +++ b/chatapi/src/models/db-doc.model.ts @@ -0,0 +1,7 @@ +export interface DbDoc { + _id: string; + _rev: string; + user: string; + time: number; + conversations: []; +} diff --git a/chatapi/src/services/chat.service.ts b/chatapi/src/services/chat.service.ts new file mode 100644 index 0000000000..0b8f2bc38e --- /dev/null +++ b/chatapi/src/services/chat.service.ts @@ -0,0 +1,72 @@ +import { DocumentInsertResponse } from 'nano'; + +import db from '../config/nano.config'; +import { gptChat } from '../utils/gpt-chat.utils'; +import { getChatDocument } from '../utils/db.utils'; +import { handleChatError } from '../utils/chat-error.utils'; +import { ChatMessage } from '../models/chat-message.model'; + +/** + * Create a chat conversation & save in couchdb + * @param data - Chat data including content and additional information + * @returns Object with completion text and CouchDB save response + */ +export async function chat(data: any): Promise<{ + completionText: string; + couchSaveResponse: DocumentInsertResponse; +} | undefined> { + const { content, ...dbData } = data; + const messages: ChatMessage[] = []; + + if (dbData._id) { + const history = await getChatDocument(dbData._id); + dbData.conversations = history; + + for (const { query, response } of history) { + messages.push({ 'role': 'user', 'content': query }); + messages.push({ 'role': 'assistant', 'content': response }); + } + } else { + dbData.conversations = []; + } + + dbData.conversations.push({ 'query': content, 'response': '' }); + const res = await db.insert(dbData); + + messages.push({ 'role': 'user', content }); + + try { + const completionText = await gptChat(messages); + + dbData.conversations.pop(); + dbData.conversations.push({ 'query': content, 'response': completionText }); + + dbData._id = res?.id; + dbData._rev = res?.rev; + const couchSaveResponse = await db.insert(dbData); + + return { + completionText, + couchSaveResponse + }; + } catch (error: any) { + handleChatError(error); + } +} + +export async function chatNoSave(content: any): Promise< string | undefined> { + const messages: ChatMessage[] = []; + + messages.push({ 'role': 'user', content }); + + try { + const completionText = await gptChat(messages); + messages.push({ + 'role': 'assistant', 'content': completionText + }); + + return completionText; + } catch (error: any) { + handleChatError(error); + } +} diff --git a/chatapi/src/services/gpt-prompt.service.ts b/chatapi/src/services/gpt-prompt.service.ts deleted file mode 100644 index 90c02eea07..0000000000 --- a/chatapi/src/services/gpt-prompt.service.ts +++ /dev/null @@ -1,68 +0,0 @@ -import dotenv from 'dotenv'; -import { Configuration, OpenAIApi } from 'openai'; -import { DocumentInsertResponse } from 'nano'; - -import { ChatItem } from '../models/chat-item.model'; -import { ChatMessage } from '../models/chat-message.model'; -import { NanoCouchService } from '../utils/nano-couchdb'; - -dotenv.config(); - -const configuration = new Configuration({ - 'apiKey': process.env.OPENAI_API_KEY, -}); -const openai = new OpenAIApi(configuration); - -const db = new NanoCouchService( - process.env.COUCHDB_HOST || 'http://couchdb:5984', - 'chat_history', -); - -// history = db.get the history | [] if empty; -const history: ChatItem[] = []; - -export async function chatWithGpt(userInput: string): Promise<{ - completionText: string; - history: ChatItem[]; - couchSaveResponse: DocumentInsertResponse; -} | undefined> { - const messages: ChatMessage[] = []; - - for (const { query, response } of history) { - messages.push({ 'role': 'user', 'content': query }); - messages.push({ 'role': 'assistant', 'content': response }); - } - - messages.push({ 'role': 'user', 'content': userInput }); - // Should insert query to db here - - try { - const completion = await openai.createChatCompletion({ - 'model': 'gpt-3.5-turbo', - messages, - }); - - if (!completion.data.choices[0]?.message?.content) { - throw new Error('Unexpected API response'); - } - - const completionText = completion.data.choices[0]?.message?.content; - history.push({ 'query': userInput, 'response': completionText }); - - db.conversations = history; - const couchSaveResponse = await db.insert(); - // Should update the db with query response here - - return { - completionText, - history, - couchSaveResponse - }; - } catch (error: any) { - if (error.response) { - throw new Error(`GPT Service Error: ${error.response.status} - ${error.response.data?.error?.code}`); - } else { - throw new Error(error.message); - } - } -} diff --git a/chatapi/src/utils/chat-error.utils.ts b/chatapi/src/utils/chat-error.utils.ts new file mode 100644 index 0000000000..071dc6e78d --- /dev/null +++ b/chatapi/src/utils/chat-error.utils.ts @@ -0,0 +1,7 @@ +export function handleChatError(error: any) { + if (error.response) { + throw new Error(`GPT Service Error: ${error.response.status} - ${error.response.data?.error?.code}`); + } else { + throw new Error(error.message); + } +} diff --git a/chatapi/src/utils/db.utils.ts b/chatapi/src/utils/db.utils.ts new file mode 100644 index 0000000000..bc3eded874 --- /dev/null +++ b/chatapi/src/utils/db.utils.ts @@ -0,0 +1,17 @@ +import db from '../config/nano.config'; +import { DbDoc } from '../models/db-doc.model'; + +/** + * Retrieves chat history from CouchDB for a given document ID. + * @param id - Document ID + * @returns Array of chat conversations + */ +export async function getChatDocument(id: string) { + try { + const res = await db.get(id) as DbDoc; + return res.conversations; + // Should return user, team data as well particularly for the /conversations endpoint + } catch (error) { + return []; + } +} diff --git a/chatapi/src/utils/gpt-chat.utils.ts b/chatapi/src/utils/gpt-chat.utils.ts new file mode 100644 index 0000000000..ef5d3515f1 --- /dev/null +++ b/chatapi/src/utils/gpt-chat.utils.ts @@ -0,0 +1,21 @@ +import openai from '../config/openai.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[]): Promise { + const completion = await openai.createChatCompletion({ + 'model': 'gpt-3.5-turbo', + messages, + }); + + const completionText = completion.data.choices[0]?.message?.content; + if (!completionText) { + throw new Error('Unexpected API response'); + } + + return completionText; +} diff --git a/chatapi/src/utils/nano-couchdb.ts b/chatapi/src/utils/nano-couchdb.ts deleted file mode 100644 index 137a1bcd2b..0000000000 --- a/chatapi/src/utils/nano-couchdb.ts +++ /dev/null @@ -1,32 +0,0 @@ -import nano, { DocumentInsertResponse } from 'nano'; -import { DatabaseActions } from '../models/database-actions.model'; - -export class NanoCouchService implements DatabaseActions { - _id?: string; - _rev?: string; - [key: string]: any; - private db: DatabaseActions; - - constructor(databaseUrl: string, databaseName: string) { - const nanoDB = nano(databaseUrl); - this.db = nanoDB.use(databaseName); - } - - async processAPIResponse(response: DocumentInsertResponse): Promise { - if (response.ok === true) { - this._id = response.id; - this._rev = response.rev; - } - } - - async insert(): Promise { - try { - const { db, ...document } = this; // eslint-disable-line @typescript-eslint/no-unused-vars - const res = await this.db.insert(document); - this.processAPIResponse(res); - return res; - } catch (error) { - throw new Error(`Nano Service Error: ${error}`); - } - } -} diff --git a/src/app/chat/chat.component.html b/src/app/chat/chat.component.html index 0e80936322..af8c0a01be 100644 --- a/src/app/chat/chat.component.html +++ b/src/app/chat/chat.component.html @@ -24,7 +24,10 @@ + + + Save Chat + diff --git a/src/app/chat/chat.component.scss b/src/app/chat/chat.component.scss index e243b9851d..e15f4844e8 100644 --- a/src/app/chat/chat.component.scss +++ b/src/app/chat/chat.component.scss @@ -53,3 +53,7 @@ textarea:focus { border-color: #007bff; box-shadow: 0 0 5px rgba(0, 123, 255, 0.5); } + +mat-slide-toggle { + margin-right: auto; +} diff --git a/src/app/chat/chat.component.ts b/src/app/chat/chat.component.ts index cc7eb4b7e7..0cfd72092f 100644 --- a/src/app/chat/chat.component.ts +++ b/src/app/chat/chat.component.ts @@ -5,6 +5,8 @@ import { ActivatedRoute, Router } from '@angular/router'; import { CustomValidators } from '../validators/custom-validators'; import { showFormErrors } from '../shared/table-helpers'; import { ChatService } from '../shared/chat.service'; +import { UserService } from '../shared/user.service'; +import { CouchService } from '../shared/couchdb.service'; @Component({ selector: 'planet-chat', @@ -14,6 +16,11 @@ import { ChatService } from '../shared/chat.service'; export class ChatComponent implements OnInit { spinnerOn = true; promptForm: FormGroup; + data = { + user: this.userService.get(), + time: this.couchService.datePlaceholder, + content: '' + }; messages: any[] = []; conversations: any[] = []; @@ -24,7 +31,9 @@ export class ChatComponent implements OnInit { private route: ActivatedRoute, private router: Router, private changeDetectorRef: ChangeDetectorRef, - private chatService: ChatService + private chatService: ChatService, + private userService: UserService, + private couchService: CouchService ) {} ngOnInit() { @@ -41,6 +50,7 @@ export class ChatComponent implements OnInit { createForm() { this.promptForm = this.formBuilder.group({ prompt: [ '', CustomValidators.required ], + saveChat: [ false ], }); } @@ -58,10 +68,13 @@ export class ChatComponent implements OnInit { } submitPrompt() { + const save = this.promptForm.get('saveChat').value; const content = this.promptForm.get('prompt').value; this.messages.push({ role: 'user', content }); - this.chatService.getPrompt(content).subscribe( + this.data.content = content; + + this.chatService.getPrompt(this.data, save).subscribe( (completion: any) => { this.conversations.push({ query: content, diff --git a/src/app/shared/chat.service.ts b/src/app/shared/chat.service.ts index 0aa7c955fa..16e7b9e6ec 100644 --- a/src/app/shared/chat.service.ts +++ b/src/app/shared/chat.service.ts @@ -13,9 +13,10 @@ import { environment } from '../../environments/environment'; private httpClient: HttpClient ) {} - getPrompt(input: string): Observable { + getPrompt(data: Object, save: boolean): Observable { return this.httpClient.post(this.baseUrl, { - content: input + data, + save }); }