WIP log in frontend

This commit is contained in:
Nemo Godebski-Pedersen 2025-03-21 10:47:36 +00:00
parent 8c646e0053
commit c6b0dd5a42
7 changed files with 159 additions and 95 deletions

1
components.d.ts vendored
View File

@ -38,6 +38,7 @@ declare module 'vue' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElText: typeof import('element-plus/es')['ElText']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
GlossarySearchNav: typeof import('./src/components/GlossarySearchNav.vue')['default']
HeaderNav: typeof import('./src/components/HeaderNav.vue')['default']

View File

@ -3,13 +3,11 @@ import { OpeyController } from "../controllers/OpeyIIController";
import OpeyClientService from '../services/OpeyClientService';
import OBPClientService from '../services/OBPClientService';
import OBPConsentsService from '../services/OBPConsentsService';
import { OpeyConfig } from '../schema/OpeySchema';
import Stream, { Readable } from 'stream';
import { Request, Response } from 'express';
import httpMocks from 'node-mocks-http'
import { EventEmitter } from 'events';
import { InlineResponse2017 } from 'obp-api-typescript';
import { c } from 'vitest/dist/reporters-5f784f42.js';
vi.mock("../../server/services/OpeyClientService", () => {
return {
@ -43,7 +41,9 @@ describe('OpeyController', () => {
let opeyController: OpeyController
// Mock the OpeyClientService class
const { mockClear } = getMockRes()
beforeEach(() => {
mockClear()
})
beforeAll(() => {
@ -171,16 +171,7 @@ describe('OpeyController consents', () => {
const MockOpeyClientService = {
authConfig: {},
opeyConfig: {
baseUri: 'http://localhost:8080',
paths: {
invoke: '/invoke',
status: '/status',
stream: '/stream',
approve_tool: '/approve_tool/{thread_id}',
feedback: '/feedback',
}
},
opeyConfig: {},
getOpeyStatus: vi.fn(async () => {
return {status: 'running'}
}),
@ -200,36 +191,17 @@ describe('OpeyController consents', () => {
return {
content: 'Hi this is Opey',
}
}),
getOpeyConfig: vi.fn(async (partialConfig?) => {
return {
baseUri: 'http://localhost:8080',
paths: {
invoke: '/invoke',
status: '/status',
stream: '/stream',
approve_tool: '/approve_tool/{thread_id}',
feedback: '/feedback',
}
}
})
} as unknown as OpeyClientService
const MockOBPConsentsService = {
createConsent: vi.fn(async (session) => {
const mockConsentResponse = {
createConsent: vi.fn(async () => {
return {
"consent_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0",
"jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9CUCBDb25zZW50IFRva2VuIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
"status": "INITIATED",
} as InlineResponse2017
session['opeyConfig'] = {
authConfig: { obpConsent: mockConsentResponse }
}
return mockConsentResponse
}),
})
} as unknown as OBPConsentsService
// Instantiate OpeyController with the mocked OpeyClientService
@ -242,21 +214,10 @@ describe('OpeyController consents', () => {
it('should return 200 and consent ID when consent is created at OBP', async () => {
// Mock the request and response objects
const req = {}
const req = getMockReq()
const session = {}
const res: Partial<Response> = {
status: vi.fn().mockImplementation((status) => {
return res
}),
json: vi.fn().mockImplementation((data) => {
return data
})
}
const { res } = getMockRes()
await opeyController.getConsent(session, req, res)
expect(res.status).toHaveBeenCalledWith(200)
// Obviously if you change the MockOBPConsentsService.createConsent mock implementation, you will need to change this test
@ -265,14 +226,9 @@ describe('OpeyController consents', () => {
})
// Expect that the consent object was saved in the session
expect(session).toHaveProperty('opeyConfig')
const opeyConfig = session['opeyConfig']
console.log(opeyConfig)
expect(opeyConfig).toHaveProperty('authConfig')
expect(session['opeyConfig']).toHaveProperty('authConfig')
expect(session['opeyConfig']['authConfig']).toHaveProperty('obpConsent')
expect(session['opeyConfig']['authConfig']['obpConsent']).toHaveProperty('consent_id', "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0")
expect(session['opeyConfig']['authConfig']['obpConsent']).toHaveProperty('jwt', "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9CUCBDb25zZW50IFRva2VuIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")
expect(session['opeyConfig']['authConfig']['obpConsent']).toHaveProperty('status', "INITIATED")
expect(session).toHaveProperty('obpConsent')
expect(session['obpConsent']).toHaveProperty('consent_id', "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0")
expect(session['obpConsent']).toHaveProperty('jwt', "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9CUCBDb25zZW50IFRva2VuIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")
expect(session['obpConsent']).toHaveProperty('status', "INITIATED")
})
})

View File

@ -69,26 +69,11 @@ export default {
content: this.input,
isToolCallApproval: false,
};
this.chat.addMessage(userMessage);
// Create a placeholder for the assistant's response
this.chat.currentAssistantMessage = {
id: uuidv4(),
role: 'assistant',
content: ''
};
this.chat.addMessage(this.chat.currentAssistantMessage);
// Set status to loading
// Set status to loading // Clear input field after sending
this.chat.status = 'loading';
// Clear input field after sending
this.input = '';
try {
await this.chat.stream({
message: userMessage,
@ -129,16 +114,20 @@ export default {
<el-button type="danger" :icon="Close" @click="toggleChat" size="small" circle></el-button>
</el-header>
<el-main>
<div class="messages-container">
<div v-if="!chat.userIsAuthenticated" class="login-container">
<p class="login-message" size="large">Opey is only available once logged on.</p>
<a href="/api/connect" class="login-button router-link">Log on</a>
</div>
<div v-else class="messages-container" v-bind:class="{ disabled: !chat.userIsAuthenticated }">
<el-scrollbar>
<ChatMessage v-for="message in chat.messages" :key="message.id" :message="message" />
</el-scrollbar>
</div>
</el-main>
<el-footer>
<el-footer v-bind:class="{ disabled: !chat.userIsAuthenticated }">
<div class="user-input-container">
<div class="user-input">
<textarea v-model="input" type="textarea" placeholder="Type your message..." :disabled="chat.status !== 'ready'" @keypress.enter="onSubmit" />
<textarea v-model="input" type="textarea" placeholder="Type your message..." :disabled="(chat.status !== 'ready') || (!chat.userIsAuthenticated)" @keypress.enter="onSubmit" />
</div>
<el-button type="primary" @click="onSubmit" color="#253047" :icon="ElTop" circle></el-button>
</div>
@ -179,7 +168,7 @@ export default {
min-height: 470px;
max-height: 90vh;
max-width: 90vw;
background-color: tomato;
background-color: #151d30;
resize: both;
overflow: auto;
transform: rotate(180deg);
@ -187,6 +176,25 @@ export default {
box-shadow: 0 10px 20px 0 rgba(0, 0, 0, 0.2);
}
.login-container {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
height: 100%;
margin:auto;
width: 100%;
}
.login-message {
color: #fff;
font-family: 'Courier New', Courier, monospace;
font-size: large;
font-weight: bold;
margin-left: 100px;
margin-right: 100px;
}
.chat-header {
font-family: Roboto-Light, sans-serif;
}
@ -197,6 +205,12 @@ export default {
align-items: center;
}
.disabled {
pointer-events: none;
cursor: not-allowed;
filter: blur(2px);
}
.chat-container .el-container {
height: 100%;
}

View File

@ -84,6 +84,16 @@ export const useChat = defineStore('chat', {
return store.threadId
}
}
},
getLastAssistantMessage(store): OpeyMessage | undefined {
return this.getMessageById(store.currentAssistantMessage.id)
},
getMessageById: (store) => {
return (id: string): OpeyMessage | undefined => {
return store.messages.find(m => m.id === id)
}
}
},
@ -112,6 +122,13 @@ export const useChat = defineStore('chat', {
this.messages = this.messages.filter(m => m.id !== messageId);
},
async applyErrorToMessage(messageId: string, errorMessageString: string): Promise<void> {
const message = this.getMessageById(messageId);
if (message) {
message.error = errorMessageString;
}
},
async handleAuthentication(): Promise<void> {
// Handle authentication
// get consent for Opey from user
@ -130,6 +147,17 @@ export const useChat = defineStore('chat', {
},
async stream(input: ChatStreamInput): Promise<void> {
// Add user message to chat
this.addMessage(input.message)
// Create a placecholder for the assistant message
this.currentAssistantMessage = {
content: '',
role: 'assistant',
id: uuidv4(),
}
this.addMessage(this.currentAssistantMessage)
// Handle stream
try {
const response = await fetch('/api/opey/stream', {
@ -163,8 +191,13 @@ export const useChat = defineStore('chat', {
await processOpeyStream(stream, context);
} catch (error) {
console.error('Error sending Opey message:', error);
const errorMessage = "Hmmm, Looks like smething went wrong. Please try again later.";
// Apply error state to the assistant message
await this.applyErrorToMessage(this.currentAssistantMessage.id, errorMessage);
this.status = 'ready';
throw new Error(`Error sending Opey message: ${error}`);
}
}

View File

@ -41,25 +41,13 @@ describe('ChatWidget', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('should call fetch when sending a user message', async () => {
it('should call the stream function when sending a user message', async () => {
const wrapper = mount(ChatWidget, {})
wrapper.vm.chat.stream = vi.fn(async () => {})
await wrapper.vm.onSubmit()
expect(global.fetch).toHaveBeenCalled()
})
it('should clear the assistant message placeholder from messages list on error', async () => {
// mock the fetch function with a rejected promise
const wrapper = mount(ChatWidget, {})
global.fetch = vi.fn(() =>
Promise.reject(new Error('Test error'))
);
await wrapper.vm.onSubmit()
expect(wrapper.vm.opeyContext.messages.find(m => m.id === wrapper.vm.opeyContext.currentAssistantMessage.id)).toBeUndefined()
expect(wrapper.vm.chat.stream).toHaveBeenCalled()
})
it('should trigger onSubmit when enter key is pressed in the input', async () => {
const wrapper = mount(ChatWidget, {})
@ -71,12 +59,22 @@ describe('ChatWidget', () => {
// This will probably fail if the class name of the parent div is changed, or if the input type is moved i.e. from textarea to input or el-input
const input = wrapper.get('.user-input-container textarea')
input.trigger('keypress.enter')
expect(global.fetch).toHaveBeenCalled()
})
it('displays chat when chatOpen is set to true', async () => {
const wrapper = mount(ChatWidget, {})
wrapper.vm.chatOpen = true
await wrapper.vm.$nextTick()
expect(wrapper.find('.chat-container').exists()).toBe(true)
})
it('should show a log in screen if user is not authenticated', async () => {
const wrapper = mount(ChatWidget, {})
wrapper.vm.chat.userIsAuthenticated = false
wrapper.vm.chatOpen = true
await wrapper.vm.$nextTick()
console.log(wrapper.html())
expect(wrapper.find('.login-container').exists()).toBe(true)
})
})

View File

@ -1,7 +1,7 @@
// Tesing the Pinia chat store in src/stores/chat.ts
import { useChat } from '@/stores/chat'
import { beforeEach, describe, it, expect } from 'vitest'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
describe('Chat Store', () => {
@ -33,4 +33,62 @@ describe('Chat Store', () => {
const newThreadId = chatStore.getThreadId('5678')
expect(newThreadId).toBe('1234')
})
it('should apply an error state to the assistant message on error', async () => {
// mock the fetch function with a rejected promise
global.fetch = vi.fn(() =>
Promise.reject(new Error('Test error'))
);
const chatStore = useChat()
await chatStore.stream({message: {
content: 'Hello Opey',
role: 'user',
id: '123',
isToolCallApproval: false
}})
console.log("Messages: ", chatStore.messages)
const assistantMessage = chatStore.getLastAssistantMessage
expect(assistantMessage).toBeDefined()
expect(assistantMessage?.error).toBeDefined()
})
it('should stream messages correctly', async () => {
// create a mock stream
const mockStream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"test"}\n`));
controller.close();
},
});
// mock the fetch function
global.fetch = vi.fn(() =>
Promise.resolve(new Response(mockStream, {
headers: { 'content-type': 'text/event-stream' },
status: 200,
}))
);
const chatStore = useChat()
await chatStore.stream({message: {
content: 'Hello Opey',
role: 'user',
id: '123',
isToolCallApproval: false
}})
const assistantMessage = chatStore.getLastAssistantMessage
expect(assistantMessage).toBeDefined()
expect(assistantMessage?.content).toBe('test')
})
it('should be able to handle tool messages', async () => {
const mockStream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(`data: {"type":"tool","content":"test"}\n`));
controller.close();
},
});
})
})

View File

@ -65,6 +65,10 @@ describe('processOpeyStream', async () => {
.toThrow('Stream closed by server')
})
it('should be able to handle empty content', async () => {
})
it('should throw an error when the chunk is not valid json', async () => {
const invalidJsonStream = new ReadableStream<Uint8Array>({
start(controller) {