Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1e1682f
feat(OpenResponse): Summarize student responses in grading tool (#2259)
hirokiterashima Jan 27, 2026
d99fdcc
feat(Discussion): Summarize student responses in grading tool (#2262)
hirokiterashima Feb 4, 2026
e098623
refactor: OpenResponseSummaryDisplay now extends AiSummaryDisplay
hirokiterashima Feb 4, 2026
2c0276b
refactor: make code more concise
hirokiterashima Feb 4, 2026
dc62b55
Merge branch 'develop' into cm-component-ai-summarizer
hirokiterashima Feb 4, 2026
2eeb2d2
Merge branch 'develop' into cm-component-ai-summarizer
hirokiterashima Mar 12, 2026
60dd9ec
Merge branch 'develop' into cm-component-ai-summarizer
hirokiterashima Mar 16, 2026
e240501
Rename class to DiscussionAISummaryComponent
hirokiterashima Mar 16, 2026
efe24eb
Rename class to OpenResponseAiSummaryComponent
hirokiterashima Mar 16, 2026
e44a4ac
Merge branch 'develop' into cm-component-ai-summarizer
hirokiterashima Mar 17, 2026
ebd1f27
AiSummaryDisplayComponent is now its own base class, no longer extend…
hirokiterashima Mar 17, 2026
2831717
Rename AiSummaryDisplayComponent -> AiSummaryComponent
hirokiterashima Mar 18, 2026
c25dc72
Rename to AwsBedRockChatService. Rename class variable to chatService…
hirokiterashima Mar 18, 2026
c2f085a
Move generateChatTitle() to ChatbotComponent
hirokiterashima Mar 18, 2026
e4827e8
Make ChatService abstract, and create OpenAiChatService that extends …
hirokiterashima Mar 18, 2026
3d22f9a
Change AiSummary to use OpenAI model as default
hirokiterashima Mar 19, 2026
2c3501f
fix unit tests
hirokiterashima Mar 19, 2026
1db69cd
Updated styles; only show AI summary if AI is enabled
breity Mar 24, 2026
ac7de6a
Add title to summary dialog component collapse button
breity Mar 24, 2026
02e25fb
Update AI summary styles and layout; Override Discussion caption text
breity Mar 24, 2026
ba1ce92
Updated messages
github-actions[bot] Mar 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 239 additions & 31 deletions package-lock.json

Large diffs are not rendered by default.

55 changes: 0 additions & 55 deletions src/app/chatbot/chat.service.ts

This file was deleted.

34 changes: 17 additions & 17 deletions src/app/chatbot/chatbot.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ChatbotComponent } from './chatbot.component';
import { ChatbotService } from './chatbot.service';
import { AwsBedRockService } from './awsBedRock.service';
import { ConfigService } from '../../assets/wise5/services/configService';
import { DataService } from '../services/data.service';
import { ProjectService } from '../../assets/wise5/services/projectService';
import { BreakpointObserver } from '@angular/cdk/layout';
import { of, throwError } from 'rxjs';
import { Chat, ChatMessage } from './chat';
import { provideHttpClient } from '@angular/common/http';
import { OpenAiChatService } from '../services/chat/openAiChat.service';

describe('ChatbotComponent', () => {
let component: ChatbotComponent;
let fixture: ComponentFixture<ChatbotComponent>;
let chatbotService: jasmine.SpyObj<ChatbotService>;
let awsBedRockService: jasmine.SpyObj<AwsBedRockService>;
let chatService: jasmine.SpyObj<OpenAiChatService>;
let configService: jasmine.SpyObj<ConfigService>;
let dataService: jasmine.SpyObj<DataService>;
let projectService: jasmine.SpyObj<ProjectService>;
Expand All @@ -41,7 +41,7 @@ describe('ChatbotComponent', () => {
'updateChat',
'deleteChat'
]);
const awsBedRockServiceSpy = jasmine.createSpyObj('AwsBedRockService', [
const chatServiceSpy = jasmine.createSpyObj('OpenAiChatService', [
'sendMessage',
'generateChatTitle'
]);
Expand All @@ -66,7 +66,7 @@ describe('ChatbotComponent', () => {
imports: [ChatbotComponent],
providers: [
{ provide: ChatbotService, useValue: chatbotServiceSpy },
{ provide: AwsBedRockService, useValue: awsBedRockServiceSpy },
{ provide: OpenAiChatService, useValue: chatServiceSpy },
{ provide: ConfigService, useValue: configServiceSpy },
{ provide: DataService, useValue: dataServiceSpy },
{ provide: ProjectService, useValue: projectServiceSpy },
Expand All @@ -76,7 +76,7 @@ describe('ChatbotComponent', () => {
}).compileComponents();

chatbotService = TestBed.inject(ChatbotService) as jasmine.SpyObj<ChatbotService>;
awsBedRockService = TestBed.inject(AwsBedRockService) as jasmine.SpyObj<AwsBedRockService>;
chatService = TestBed.inject(OpenAiChatService) as jasmine.SpyObj<OpenAiChatService>;
configService = TestBed.inject(ConfigService) as jasmine.SpyObj<ConfigService>;
dataService = TestBed.inject(DataService) as jasmine.SpyObj<DataService>;
projectService = TestBed.inject(ProjectService) as jasmine.SpyObj<ProjectService>;
Expand Down Expand Up @@ -134,8 +134,8 @@ describe('ChatbotComponent', () => {
const assistantResponse = 'Hi there!';
component['userInput'] = userMessage;

awsBedRockService.sendMessage.and.returnValue(Promise.resolve(assistantResponse));
awsBedRockService.generateChatTitle.and.returnValue(Promise.resolve('New Title'));
chatService.sendMessage.and.returnValue(Promise.resolve(assistantResponse));
spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve('New Title'));
await component['sendMessage']();

expect(component['messages'].length).toBe(2);
Expand All @@ -156,12 +156,12 @@ describe('ChatbotComponent', () => {

// First user message (only system message exists initially)
component['messages'] = [];
awsBedRockService.sendMessage.and.returnValue(Promise.resolve(assistantResponse));
awsBedRockService.generateChatTitle.and.returnValue(Promise.resolve(newTitle));
chatService.sendMessage.and.returnValue(Promise.resolve(assistantResponse));
spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve(newTitle));

await component['sendMessage']();

expect(awsBedRockService.generateChatTitle).toHaveBeenCalledWith(userMessage);
expect(component['generateChatTitle']).toHaveBeenCalledWith(userMessage);
expect(component['currentChat']?.title).toBe(newTitle);
expect(chatbotService.updateChat).toHaveBeenCalled();
});
Expand All @@ -171,7 +171,7 @@ describe('ChatbotComponent', () => {

await component['sendMessage']();

expect(awsBedRockService.sendMessage).not.toHaveBeenCalled();
expect(chatService.sendMessage).not.toHaveBeenCalled();
expect(component['messages'].length).toBe(0);
});

Expand All @@ -181,7 +181,7 @@ describe('ChatbotComponent', () => {

await component['sendMessage']();

expect(awsBedRockService.sendMessage).not.toHaveBeenCalled();
expect(chatService.sendMessage).not.toHaveBeenCalled();
});

it('should not send messages when no current chat', async () => {
Expand All @@ -190,12 +190,12 @@ describe('ChatbotComponent', () => {

await component['sendMessage']();

expect(awsBedRockService.sendMessage).not.toHaveBeenCalled();
expect(chatService.sendMessage).not.toHaveBeenCalled();
});

it('should handle errors when sending messages', async () => {
component['userInput'] = 'Hello';
awsBedRockService.sendMessage.and.returnValue(Promise.reject(new Error('API error')));
chatService.sendMessage.and.returnValue(Promise.reject(new Error('API error')));

await component['sendMessage']();

Expand Down Expand Up @@ -336,8 +336,8 @@ describe('ChatbotComponent', () => {

it('should send message on Enter key press', () => {
component['userInput'] = 'Hello';
awsBedRockService.sendMessage.and.returnValue(Promise.resolve('Response'));
awsBedRockService.generateChatTitle.and.returnValue(Promise.resolve('New Title'));
chatService.sendMessage.and.returnValue(Promise.resolve('Response'));
spyOn(component, 'generateChatTitle').and.returnValue(Promise.resolve('New Title'));

const event = new KeyboardEvent('keypress', { key: 'Enter' });
spyOn(event, 'preventDefault');
Expand All @@ -356,7 +356,7 @@ describe('ChatbotComponent', () => {
component['handleKeyPress'](event);

expect(event.preventDefault).not.toHaveBeenCalled();
expect(awsBedRockService.sendMessage).not.toHaveBeenCalled();
expect(chatService.sendMessage).not.toHaveBeenCalled();
});
});

Expand Down
30 changes: 24 additions & 6 deletions src/app/chatbot/chatbot.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { BreakpointObserver } from '@angular/cdk/layout';
import { skip, Subscription } from 'rxjs';
import { Subscription } from 'rxjs';
import { ChatbotService } from './chatbot.service';
import { ConfigService } from '../../assets/wise5/services/configService';
import { DataService } from '../services/data.service';
import { Chat, ChatMessage } from './chat';
import { AwsBedRockService } from './awsBedRock.service';
import { ProjectService } from '../../assets/wise5/services/projectService';
import { MarkdownComponent } from 'ngx-markdown';
import { ChatHistoryDialogComponent } from './chat-history-dialog.component';
import { MatDividerModule } from '@angular/material/divider';
import { ChatService } from '../services/chat/chat.service';
import { OpenAiChatService } from '../services/chat/openAiChat.service';

@Component({
imports: [
Expand All @@ -42,7 +42,7 @@ import { MatDividerModule } from '@angular/material/divider';
export class ChatbotComponent {
private breakpointObserver = inject(BreakpointObserver);
private chatbotService: ChatbotService = inject(ChatbotService);
private awsBedRockService: AwsBedRockService = inject(AwsBedRockService);
private chatService: ChatService = inject(OpenAiChatService);
private configService: ConfigService = inject(ConfigService);
private dataService: DataService = inject(DataService);
private projectService = inject(ProjectService);
Expand Down Expand Up @@ -112,7 +112,7 @@ export class ChatbotComponent {
this.loading = true;
this.scrollToBottom();
try {
const response = await this.awsBedRockService.sendMessage(this.messages);
const response = await this.chatService.sendMessage(this.messages);
this.messages.push(
new ChatMessage('assistant', response, this.dataService.getCurrentNode().id)
);
Expand Down Expand Up @@ -154,7 +154,7 @@ export class ChatbotComponent {
*/
private async generateAndSetChatTitle(firstUserMessage: ChatMessage): Promise<void> {
try {
let newTitle = await this.awsBedRockService.generateChatTitle(firstUserMessage.content);
let newTitle = await this.generateChatTitle(firstUserMessage.content);
// Remove surrounding quotes if any
newTitle = newTitle.replace(/^["'](.*)["']$/, '$1').trim();
if (newTitle) {
Expand All @@ -165,6 +165,24 @@ export class ChatbotComponent {
}
}

/**
* Generates a short, concise title for a chat based on the first message.
* @param message The first user message content.
* @returns A promise that resolves to the generated title.
*/
async generateChatTitle(message: string): Promise<string> {
const prompt = `Generate a short, concise title (max 5 words) for a chat that starts with this message: "${message}". Respond only with the title, no quotes or extra text. If the language of the message is not English, return the title in that language.`;
const messages: ChatMessage[] = [
new ChatMessage(
'system',
'You are a helpful assistant that generates short titles for chat conversations.',
''
),
new ChatMessage('user', prompt, '')
];
return this.chatService.sendMessage(messages);
}

protected switchToChat(chat: Chat): void {
this.currentChat = chat;
this.messages = [...chat.messages];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { ChatService } from './chat.service';

@Injectable({ providedIn: 'root' })
export class AwsBedRockService extends ChatService {
export class AwsBedRockChatService extends ChatService {
protected chatEndpoint = '/api/aws-bedrock/chat';
protected model: string = 'google.gemma-3-27b-it';

Expand Down
37 changes: 37 additions & 0 deletions src/app/services/chat/chat.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { inject } from '@angular/core';
import { ChatMessage } from '../../chatbot/chat';
import { firstValueFrom } from 'rxjs';
import { HttpClient } from '@angular/common/http';

export abstract class ChatService {
protected abstract chatEndpoint: string;
protected abstract model: string;

private http = inject(HttpClient);

/**
* Sends a message to the chat endpoint.
* @param messages The conversation history.
* @returns A promise that resolves to the response from the chat endpoint.
*/
async sendMessage(messages: ChatMessage[]): Promise<string> {
const payload = {
messages: messages.map((msg) => ({
role: msg.role,
content: msg.content
})),
model: this.model
};
try {
const response = await firstValueFrom(this.http.post<any>(`${this.chatEndpoint}`, payload));
return this.processResponse(response.choices[0].message.content);
} catch (error) {
console.error('Error calling chat endpoint:', error);
throw error;
}
}

processResponse(response: string): string {
return response;
}
}
8 changes: 8 additions & 0 deletions src/app/services/chat/openAiChat.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Injectable } from '@angular/core';
import { ChatService } from './chat.service';

@Injectable({ providedIn: 'root' })
export class OpenAiChatService extends ChatService {
protected chatEndpoint = '/api/chat-gpt';
protected model: string = 'gpt-4o';
}
40 changes: 40 additions & 0 deletions src/app/services/localStorageService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Injectable } from '@angular/core';

@Injectable({
providedIn: 'root'
})
export class LocalStorageService {
setItem(key: string, value: any): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error('Error saving to local storage', e);
}
}

getItem(key: string): any {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (e) {
console.error('Error reading from local storage', e);
return null;
}
}

removeItem(key: string): void {
try {
localStorage.removeItem(key);
} catch (e) {
console.error('Error removing from local storage', e);
}
}

clear(): void {
try {
localStorage.clear();
} catch (e) {
console.error('Error clearing local storage', e);
}
}
}
Loading