migrate to pinia store

This commit is contained in:
Nemo Godebski-Pedersen 2025-03-19 11:52:56 +00:00
parent 43c7934e0c
commit 74a45b8c83
6 changed files with 231 additions and 88 deletions

View File

@ -10,47 +10,45 @@ import ChatMessage from './ChatMessage.vue';
import { v4 as uuidv4 } from 'uuid';
import { OpeyStreamContext, OpeyMessage, UserMessage, sendOpeyMessage, getobpConsent } from '@/obp/opey-functions';
import { getCurrentUser } from '@/obp';
import { useChat } from '@/stores/chat';
export default {
setup () {
return {
Close,
ElTop
ElTop,
}
},
data() {
return {
chatOpen: false,
thread_id: uuidv4(),
input: '',
lastUserMessasgeFailed: false,
userHasConsented: false,
opeyContext: reactive({
currentAssistantMessage: {
id: '',
role: 'assistant',
content: ''
},
messages: new Array<OpeyMessage>(),
status: 'ready'
} as OpeyStreamContext),
chat: useChat(),
}
},
components: {
ChatMessage,
},
async mounted() {
this.chat = useChat()
const isLoggedIn = await this.checkLoginStatus()
console.log('Is logged in: ', isLoggedIn)
if (isLoggedIn) {
this.initiateConsentFlow()
try {
await this.chat.handleAuthentication()
} catch (error) {
console.error('Error in chat:', error);
ElMessage.error('Failed to authenticate.')
}
}
},
methods: {
async toggleChat() {
this.chatOpen = !this.chatOpen
if (!this.userHasConsented) {
await this.initiateConsentFlow()
if (!this.chat.userIsAuthenticated) {
await this.chat.handleAuthentication()
}
},
async checkLoginStatus(): Promise<boolean> {
@ -62,22 +60,7 @@ export default {
return false
}
},
async initiateConsentFlow() {
// get consent for Opey from user
const consentResponse = await getobpConsent()
if (consentResponse) {
const consentId = consentResponse.consent_id
if (consentId) {
this.userHasConsented = true
ElMessage.success('Consent granted. You can now chat with Opey.')
} else {
ElMessage.error('Failed to grant consent. Please try again.')
}
} else {
ElMessage.error('Failed to grant consent. Please try again.')
}
},
async onSubmit() {
// Add user message to the messages array
const userMessage: UserMessage = {
@ -86,18 +69,18 @@ export default {
content: this.input,
isToolCallApproval: false,
};
this.opeyContext.messages.push(userMessage);
this.chat.addMessage(userMessage);
// Create a placeholder for the assistant's response
this.opeyContext.currentAssistantMessage = {
this.chat.currentAssistantMessage = {
id: uuidv4(),
role: 'assistant',
content: ''
};
this.opeyContext.messages.push(this.opeyContext.currentAssistantMessage);
this.chat.addMessage(this.chat.currentAssistantMessage);
// Set status to loading
this.opeyContext.status = 'loading';
this.chat.status = 'loading';
// Clear input field after sending
this.input = '';
@ -107,21 +90,21 @@ export default {
try {
await sendOpeyMessage(
userMessage,
this.thread_id,
this.opeyContext
await this.chat.stream({
message: userMessage,
}
)
console.log('Opey Status: ', this.opeyContext.status)
console.log('Opey Status: ', this.chat.status)
} catch (error) {
console.error('Error in chat:', error);
// on error, remove the assistant message placeholder, as it will be empty.
this.opeyContext.messages = this.opeyContext.messages.filter(m => m.id !== this.opeyContext.currentAssistantMessage.id);
this.chat.removeMessage(this.chat.currentAssistantMessage.id);
this.lastUserMessasgeFailed = true;
this.opeyContext.messages[this.opeyContext.messages.length - 1].error = "Failed to send message. Please try again.";
this.chat.messages[this.chat.messages.length - 1].error = "Failed to send message. Please try again.";
} finally {
this.opeyContext.status = 'ready';
this.chat.status = 'ready';
}
},
},
@ -148,14 +131,14 @@ export default {
<el-main>
<div class="messages-container">
<el-scrollbar>
<ChatMessage v-for="message in opeyContext.messages" :key="message.id" :message="message" />
<ChatMessage v-for="message in chat.messages" :key="message.id" :message="message" />
</el-scrollbar>
</div>
</el-main>
<el-footer>
<div class="user-input-container">
<div class="user-input">
<textarea v-model="input" type="textarea" placeholder="Type your message..." :disabled="opeyContext.status !== 'ready'" @keypress.enter="onSubmit" />
<textarea v-model="input" type="textarea" placeholder="Type your message..." :disabled="chat.status !== 'ready'" @keypress.enter="onSubmit" />
</div>
<el-button type="primary" @click="onSubmit" color="#253047" :icon="ElTop" circle></el-button>
</div>

View File

@ -65,11 +65,13 @@ import { getCacheStorageInfo } from './obp/common-functions'
fallbackLocale: 'ES',
messages
})
app.provide('i18n', i18n)
const pinia = createPinia()
app.provide('i18n', i18n)
app.use(ElementPlus)
app.use(i18n)
app.use(createPinia())
app.use(pinia)
app.use(router)
app.mount('#app')

9
src/models/ChatModel.ts Normal file
View File

@ -0,0 +1,9 @@
import { OpeyMessage } from "@/models/MessageModel"
export interface Chat {
messages: OpeyMessage[];
currentAssistantMessage: OpeyMessage;
status: 'ready' | 'streaming' | 'error' | 'loading';
userIsAuthenticated: boolean;
threadId: string;
}

View File

@ -0,0 +1,18 @@
export interface OpeyMessage {
id: string; // i.e. UUID4
role: string;
content: string;
error?: string;
}
export interface UserMessage extends OpeyMessage {
isToolCallApproval: boolean;
}
export interface AssistantMessage extends OpeyMessage {
// Probably we will need some fields here for tool call/ tool call approval requests
}
export interface ChatStreamInput {
message: UserMessage;
}

View File

@ -25,53 +25,148 @@
*
*/
import { OpeyMessage, ChatStreamInput } from '@/models/MessageModel'
import { Chat } from '@/models/ChatModel'
import { getobpConsent, processOpeyStream } from '@/obp/opey-functions'
import { defineStore } from 'pinia'
import { socket } from '@/socket'
import { v4 as uuidv4 } from 'uuid'
/**
* Represents a Pinia store for managing chat messages and chatbot responses.
*/
export const useChatStore = defineStore('chat', {
state: () => ({
// Messages a list of messages in the OpenAI format
chatMessages: [] as {role: string; content: string}[],
// Tells us wether a response from the chatbot is currently being streamed or not
isStreaming: false,
// The partial message at a particular moment in time
currentMessageSnapshot: "" as string,
lastError: "" as string,
waitingForResponse: false,
}),
actions: {
bindEvents() {
// TODO: Maybe we don't need to log this except for DEBUG, keep same for now
socket.on("connect", () => {
console.log("Connected to chatbot");
})
export const useChat = defineStore('chat', {
// When the assistant stream response starts, we set isStreaming to true
socket.on('response stream start', (response) => {
this.isStreaming = true;
this.waitingForResponse = true;
// We create a temporary blank assistant message for the ChatWidget to render and add text deltas to when they come in
this.chatMessages.push({ role: 'assistant', content: " "})
});
// Text deltas received from the assistant stream (they are like little snippets of the generated response)
socket.on('response stream delta', (response) => {
this.currentMessageSnapshot += response.assistant;
});
socket.on('error', (error) => {
this.lastError = error.error;
console.error(error.error);
})
socket.on('response stream end', (response) => {
this.isStreaming = false;
this.chatMessages[this.chatMessages.length - 1].content = this.currentMessageSnapshot
this.currentMessageSnapshot = ""
});
state: (): Chat => {
return {
messages: [] as OpeyMessage[],
currentAssistantMessage: {
content: '',
role: 'assistant',
id: '',
} as OpeyMessage,
status: 'ready' as 'ready' | 'streaming' | 'error' | 'loading',
userIsAuthenticated: false,
threadId: '',
}
},
getters: {
/**
* Retrieves or creates a thread ID for the chat.
*
* @param store - The store object that holds the thread ID
* @param threadId - Optional thread ID to set
* @returns The current or newly created thread ID
*
* If a threadId is provided, it will be set in the store and returned.
* This is useful if the you want to match the thread ID on the chatbot server side.
*
* If no threadId is provided and none exists in the store, a new UUID will be generated.
* Otherwise, the existing threadId from the store is returned.
*/
getThreadId: (store) => {
return (id?: string): string => {
if (id) {
if (!store.threadId) {
store.threadId = id
return store.threadId
} else {
console.warn('Cannot set thread ID on already instantiated store. Create a new store instead.')
return store.threadId
}
}
if (!store.threadId) {
store.threadId = uuidv4()
return store.threadId
} else {
return store.threadId
}
}
}
},
actions: {
/**
* Adds a message to the chat.
*
* Works in a reducer-like fashion, updating the message if it already exists.
*
* @param message - The message to add to the chat
*/
async addMessage(message: OpeyMessage): Promise<void> {
const existingMessage = this.messages.find(m => m.id === message.id);
if (existingMessage) {
// Update the existing message
existingMessage.content = message.content;
} else {
// Add the new message
this.messages.push(message);
}
},
async removeMessage(messageId: string): Promise<void> {
this.messages = this.messages.filter(m => m.id !== messageId);
},
async handleAuthentication(): Promise<void> {
// Handle authentication
// get consent for Opey from user
const consentResponse = await getobpConsent()
if (consentResponse) {
const consentId = consentResponse.consent_id
if (consentId) {
this.userIsAuthenticated = true
} else {
throw new Error('Failed to grant consent. Please try again.')
}
} else {
throw new Error('Failed to grant consent. Please try again.')
}
},
async stream(input: ChatStreamInput): Promise<void> {
// Handle stream
try {
const response = await fetch('/api/opey/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
thread_id: this.threadId,
message: input.message.content,
is_tool_call_approval: input.message.isToolCallApproval
})
})
const stream = response.body;
if (!stream) {
throw new Error('No stream returned from API')
}
if (response.status !== 200) {
throw new Error(`Error sending Opey message: ${response.statusText}`);
}
let context = {
currentAssistantMessage: this.currentAssistantMessage,
messages: this.messages,
status: this.status
};
await processOpeyStream(stream, context);
} catch (error) {
console.error('Error sending Opey message:', error);
this.status = 'ready';
throw new Error(`Error sending Opey message: ${error}`);
}
}
}
})

36
src/test/chat.test.ts Normal file
View File

@ -0,0 +1,36 @@
// Tesing the Pinia chat store in src/stores/chat.ts
import { useChat } from '@/stores/chat'
import { beforeEach, describe, it, expect } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
describe('Chat Store', () => {
beforeEach(() => {
// Set the active Pinia store
setActivePinia(createPinia())
})
it('should be able to create its own thread ID', () => {
const chatStore = useChat()
const threadId = chatStore.getThreadId()
expect(threadId).toBeDefined()
expect(threadId).not.toBe('')
})
it('should accept a set thread ID and not change it later', () => {
const chatStore = useChat()
const threadId = chatStore.getThreadId('1234')
expect(chatStore.threadId).toBe('1234')
const newThreadId = chatStore.getThreadId()
expect(newThreadId).toBe('1234')
})
it('should not change the thread ID if it is already set', () => {
const chatStore = useChat()
const threadId = chatStore.getThreadId('1234')
expect(chatStore.threadId).toBe('1234')
const newThreadId = chatStore.getThreadId('5678')
expect(newThreadId).toBe('1234')
})
})