diff --git a/app/javascript/dashboard/api/captain/copilotMessages.js b/app/javascript/dashboard/api/captain/copilotMessages.js new file mode 100644 index 000000000..49e05398a --- /dev/null +++ b/app/javascript/dashboard/api/captain/copilotMessages.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CopilotMessages extends ApiClient { + constructor() { + super('captain/copilot_threads', { accountScoped: true }); + } + + get(threadId) { + return axios.get(`${this.url}/${threadId}/copilot_messages`); + } + + create({ threadId, ...rest }) { + return axios.post(`${this.url}/${threadId}/copilot_messages`, rest); + } +} + +export default new CopilotMessages(); diff --git a/app/javascript/dashboard/api/captain/copilotThreads.js b/app/javascript/dashboard/api/captain/copilotThreads.js new file mode 100644 index 000000000..7fdce3b91 --- /dev/null +++ b/app/javascript/dashboard/api/captain/copilotThreads.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CopilotThreads extends ApiClient { + constructor() { + super('captain/copilot_threads', { accountScoped: true }); + } +} + +export default new CopilotThreads(); diff --git a/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue b/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue index a4e4aa729..a573e7868 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue @@ -40,7 +40,7 @@ const senderName = computed(() => { -
+
{{ t(senderTranslationKey, { diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 515806b31..991576e66 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -33,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector { 'conversation.read': this.onConversationRead, 'conversation.updated': this.onConversationUpdated, 'account.cache_invalidated': this.onCacheInvalidate, + 'copilot.message.created': this.onCopilotMessageCreated, }; } @@ -189,6 +190,10 @@ class ActionCableConnector extends BaseActionCableConnector { this.app.$store.dispatch('notifications/updateNotification', data); }; + onCopilotMessageCreated = data => { + this.app.$store.dispatch('copilotMessages/upsert', data); + }; + onCacheInvalidate = data => { const keys = data.cache_keys; this.app.$store.dispatch('labels/revalidate', { newKey: keys.label }); diff --git a/app/javascript/dashboard/helper/specs/actionCable.spec.js b/app/javascript/dashboard/helper/specs/actionCable.spec.js new file mode 100644 index 000000000..4ad8a52c6 --- /dev/null +++ b/app/javascript/dashboard/helper/specs/actionCable.spec.js @@ -0,0 +1,67 @@ +import { describe, it, beforeEach, expect, vi } from 'vitest'; +import ActionCableConnector from '../actionCable'; + +vi.mock('shared/helpers/mitt', () => ({ + emitter: { + emit: vi.fn(), + }, +})); + +vi.mock('dashboard/composables/useImpersonation', () => ({ + useImpersonation: () => ({ + isImpersonating: { value: false }, + }), +})); + +global.chatwootConfig = { + websocketURL: 'wss://test.chatwoot.com', +}; + +describe('ActionCableConnector - Copilot Tests', () => { + let store; + let actionCable; + let mockDispatch; + + beforeEach(() => { + vi.clearAllMocks(); + mockDispatch = vi.fn(); + store = { + $store: { + dispatch: mockDispatch, + getters: { + getCurrentAccountId: 1, + }, + }, + }; + + actionCable = ActionCableConnector.init(store.$store, 'test-token'); + }); + describe('copilot event handlers', () => { + it('should register the copilot.message.created event handler', () => { + expect(Object.keys(actionCable.events)).toContain( + 'copilot.message.created' + ); + expect(actionCable.events['copilot.message.created']).toBe( + actionCable.onCopilotMessageCreated + ); + }); + + it('should handle the copilot.message.created event through the ActionCable system', () => { + const copilotData = { + id: 2, + content: 'This is a copilot message from ActionCable', + conversation_id: 456, + created_at: '2025-05-27T15:58:04-06:00', + account_id: 1, + }; + actionCable.onReceived({ + event: 'copilot.message.created', + data: copilotData, + }); + expect(mockDispatch).toHaveBeenCalledWith( + 'copilotMessages/upsert', + copilotData + ); + }); + }); +}); diff --git a/app/javascript/dashboard/store/captain/copilotMessages.js b/app/javascript/dashboard/store/captain/copilotMessages.js new file mode 100644 index 000000000..83b7fddce --- /dev/null +++ b/app/javascript/dashboard/store/captain/copilotMessages.js @@ -0,0 +1,19 @@ +import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages'; +import { createStore } from './storeFactory'; + +export default createStore({ + name: 'CopilotMessages', + API: CopilotMessagesAPI, + getters: { + getMessagesByThreadId: state => copilotThreadId => { + return state.records.filter( + record => record.copilot_thread?.id === Number(copilotThreadId) + ); + }, + }, + actions: mutationTypes => ({ + upsert({ commit }, data) { + commit(mutationTypes.UPSERT, data); + }, + }), +}); diff --git a/app/javascript/dashboard/store/captain/copilotThreads.js b/app/javascript/dashboard/store/captain/copilotThreads.js new file mode 100644 index 000000000..8d820f305 --- /dev/null +++ b/app/javascript/dashboard/store/captain/copilotThreads.js @@ -0,0 +1,7 @@ +import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads'; +import { createStore } from './storeFactory'; + +export default createStore({ + name: 'CopilotThreads', + API: CopilotThreadsAPI, +}); diff --git a/app/javascript/dashboard/store/captain/storeFactory.js b/app/javascript/dashboard/store/captain/storeFactory.js index a55522062..ad669f62b 100644 --- a/app/javascript/dashboard/store/captain/storeFactory.js +++ b/app/javascript/dashboard/store/captain/storeFactory.js @@ -1,5 +1,11 @@ -import { throwErrorMessage } from 'dashboard/store/utils/api'; import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; +import { + createRecord, + deleteRecord, + getRecords, + showRecord, + updateRecord, +} from './storeFactoryHelper'; export const generateMutationTypes = name => { const capitalizedName = name.toUpperCase(); @@ -10,6 +16,7 @@ export const generateMutationTypes = name => { EDIT: `EDIT_${capitalizedName}`, DELETE: `DELETE_${capitalizedName}`, SET_META: `SET_${capitalizedName}_META`, + UPSERT: `UPSERT_${capitalizedName}`, }; }; @@ -33,7 +40,6 @@ export const createGetters = () => ({ getMeta: state => state.meta, }); -// store/mutations.js export const createMutations = mutationTypes => ({ [mutationTypes.SET_UI_FLAG](state, data) { state.uiFlags = { @@ -51,78 +57,19 @@ export const createMutations = mutationTypes => ({ [mutationTypes.ADD]: MutationHelpers.create, [mutationTypes.EDIT]: MutationHelpers.update, [mutationTypes.DELETE]: MutationHelpers.destroy, + [mutationTypes.UPSERT]: MutationHelpers.setSingleRecord, }); -// store/actions/crud.js export const createCrudActions = (API, mutationTypes) => ({ - async get({ commit }, params = {}) { - commit(mutationTypes.SET_UI_FLAG, { fetchingList: true }); - try { - const response = await API.get(params); - commit(mutationTypes.SET, response.data.payload); - commit(mutationTypes.SET_META, response.data.meta); - return response.data.payload; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { fetchingList: false }); - } - }, - - async show({ commit }, id) { - commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true }); - try { - const response = await API.show(id); - commit(mutationTypes.ADD, response.data); - return response.data; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false }); - } - }, - - async create({ commit }, dataObj) { - commit(mutationTypes.SET_UI_FLAG, { creatingItem: true }); - try { - const response = await API.create(dataObj); - commit(mutationTypes.ADD, response.data); - return response.data; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { creatingItem: false }); - } - }, - - async update({ commit }, { id, ...updateObj }) { - commit(mutationTypes.SET_UI_FLAG, { updatingItem: true }); - try { - const response = await API.update(id, updateObj); - commit(mutationTypes.EDIT, response.data); - return response.data; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { updatingItem: false }); - } - }, - - async delete({ commit }, id) { - commit(mutationTypes.SET_UI_FLAG, { deletingItem: true }); - try { - await API.delete(id); - commit(mutationTypes.DELETE, id); - return id; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { deletingItem: false }); - } - }, + get: getRecords(mutationTypes, API), + show: showRecord(mutationTypes, API), + create: createRecord(mutationTypes, API), + update: updateRecord(mutationTypes, API), + delete: deleteRecord(mutationTypes, API), }); + export const createStore = options => { - const { name, API, actions } = options; + const { name, API, actions, getters } = options; const mutationTypes = generateMutationTypes(name); const customActions = actions ? actions(mutationTypes) : {}; @@ -130,7 +77,10 @@ export const createStore = options => { return { namespaced: true, state: createInitialState(), - getters: createGetters(), + getters: { + ...createGetters(), + ...(getters || {}), + }, mutations: createMutations(mutationTypes), actions: { ...createCrudActions(API, mutationTypes), diff --git a/app/javascript/dashboard/store/captain/storeFactory.spec.js b/app/javascript/dashboard/store/captain/storeFactory.spec.js new file mode 100644 index 000000000..0aec26e40 --- /dev/null +++ b/app/javascript/dashboard/store/captain/storeFactory.spec.js @@ -0,0 +1,380 @@ +import { throwErrorMessage } from 'dashboard/store/utils/api'; +import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; +import { + generateMutationTypes, + createInitialState, + createGetters, + createMutations, + createCrudActions, + createStore, +} from './storeFactory'; + +vi.mock('dashboard/store/utils/api', () => ({ + throwErrorMessage: vi.fn(), +})); + +vi.mock('shared/helpers/vuex/mutationHelpers', () => ({ + set: vi.fn(), + create: vi.fn(), + update: vi.fn(), + destroy: vi.fn(), + setSingleRecord: vi.fn(), +})); + +describe('storeFactory', () => { + describe('generateMutationTypes', () => { + it('generates correct mutation types with capitalized name', () => { + const result = generateMutationTypes('test'); + expect(result).toEqual({ + SET_UI_FLAG: 'SET_TEST_UI_FLAG', + SET: 'SET_TEST', + ADD: 'ADD_TEST', + EDIT: 'EDIT_TEST', + DELETE: 'DELETE_TEST', + SET_META: 'SET_TEST_META', + UPSERT: 'UPSERT_TEST', + }); + }); + }); + + describe('createInitialState', () => { + it('returns the correct initial state structure', () => { + const result = createInitialState(); + expect(result).toEqual({ + records: [], + meta: {}, + uiFlags: { + fetchingList: false, + fetchingItem: false, + creatingItem: false, + updatingItem: false, + deletingItem: false, + }, + }); + }); + }); + + describe('createGetters', () => { + it('returns getters with correct implementations', () => { + const getters = createGetters(); + + const state = { + records: [{ id: 2 }, { id: 1 }, { id: 3 }], + uiFlags: { fetchingList: true }, + meta: { totalCount: 10, page: 1 }, + }; + expect(getters.getRecords(state)).toEqual([ + { id: 3 }, + { id: 2 }, + { id: 1 }, + ]); + + expect(getters.getRecord(state)(2)).toEqual({ id: 2 }); + expect(getters.getRecord(state)(4)).toEqual({}); + + expect(getters.getUIFlags(state)).toEqual({ + fetchingList: true, + }); + + expect(getters.getMeta(state)).toEqual({ + totalCount: 10, + page: 1, + }); + }); + }); + + describe('createMutations', () => { + it('creates mutations with correct implementations', () => { + const mutationTypes = generateMutationTypes('test'); + const mutations = createMutations(mutationTypes); + + const state = { uiFlags: { fetchingList: false } }; + mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true }); + expect(state.uiFlags).toEqual({ fetchingList: true }); + + const metaState = { meta: {} }; + mutations[mutationTypes.SET_META](metaState, { + total_count: '10', + page: '2', + }); + expect(metaState.meta).toEqual({ totalCount: 10, page: 2 }); + + expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set); + expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create); + expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update); + expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy); + expect(mutations[mutationTypes.UPSERT]).toBe( + MutationHelpers.setSingleRecord + ); + }); + }); + + describe('createCrudActions', () => { + let API; + let commit; + let mutationTypes; + let actions; + + beforeEach(() => { + API = { + get: vi.fn(), + show: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + commit = vi.fn(); + mutationTypes = generateMutationTypes('test'); + actions = createCrudActions(API, mutationTypes); + }); + + describe('get action', () => { + it('handles successful API response', async () => { + const payload = [{ id: 1 }]; + const meta = { total_count: 10, page: 1 }; + API.get.mockResolvedValue({ data: { payload, meta } }); + + const result = await actions.get({ commit }); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingList: true, + }); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingList: false, + }); + expect(result).toEqual(payload); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.get.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.get({ commit }); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingList: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingList: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + + describe('show action', () => { + it('handles successful API response', async () => { + const data = { id: 1, name: 'Test' }; + API.show.mockResolvedValue({ data }); + + const result = await actions.show({ commit }, 1); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingItem: true, + }); + expect(commit).toHaveBeenCalledWith(mutationTypes.ADD, data); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingItem: false, + }); + expect(result).toEqual(data); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.show.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.show({ commit }, 1); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingItem: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingItem: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + + describe('create action', () => { + it('handles successful API response', async () => { + const data = { id: 1, name: 'Test' }; + API.create.mockResolvedValue({ data }); + + const result = await actions.create({ commit }, { name: 'Test' }); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + creatingItem: true, + }); + expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + creatingItem: false, + }); + expect(result).toEqual(data); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.create.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.create({ commit }, { name: 'Test' }); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + creatingItem: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + creatingItem: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + + describe('update action', () => { + it('handles successful API response', async () => { + const data = { id: 1, name: 'Updated' }; + API.update.mockResolvedValue({ data }); + + const result = await actions.update( + { commit }, + { id: 1, name: 'Updated' } + ); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + updatingItem: true, + }); + expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' }); + expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + updatingItem: false, + }); + expect(result).toEqual(data); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.update.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.update( + { commit }, + { id: 1, name: 'Updated' } + ); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + updatingItem: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + updatingItem: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + + describe('delete action', () => { + it('handles successful API response', async () => { + API.delete.mockResolvedValue({}); + + const result = await actions.delete({ commit }, 1); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + deletingItem: true, + }); + expect(API.delete).toHaveBeenCalledWith(1); + expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + deletingItem: false, + }); + expect(result).toEqual(1); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.delete.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.delete({ commit }, 1); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + deletingItem: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + deletingItem: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + }); + + describe('createStore', () => { + it('creates a complete store with default options', () => { + const API = {}; + const store = createStore({ name: 'test', API }); + + expect(store.namespaced).toBe(true); + expect(store.state).toEqual(createInitialState()); + expect(Object.keys(store.getters)).toEqual([ + 'getRecords', + 'getRecord', + 'getUIFlags', + 'getMeta', + ]); + expect(Object.keys(store.mutations)).toEqual([ + 'SET_TEST_UI_FLAG', + 'SET_TEST_META', + 'SET_TEST', + 'ADD_TEST', + 'EDIT_TEST', + 'DELETE_TEST', + 'UPSERT_TEST', + ]); + expect(Object.keys(store.actions)).toEqual([ + 'get', + 'show', + 'create', + 'update', + 'delete', + ]); + }); + + it('creates a store with custom actions and getters', () => { + const API = {}; + const customGetters = { customGetter: () => 'custom' }; + const customActions = () => ({ + customAction: () => 'custom', + }); + + const store = createStore({ + name: 'test', + API, + getters: customGetters, + actions: customActions, + }); + + expect(store.getters).toHaveProperty('customGetter'); + expect(store.actions).toHaveProperty('customAction'); + expect(Object.keys(store.getters)).toEqual([ + 'getRecords', + 'getRecord', + 'getUIFlags', + 'getMeta', + 'customGetter', + ]); + expect(Object.keys(store.actions)).toEqual([ + 'get', + 'show', + 'create', + 'update', + 'delete', + 'customAction', + ]); + }); + }); +}); diff --git a/app/javascript/dashboard/store/captain/storeFactoryHelper.js b/app/javascript/dashboard/store/captain/storeFactoryHelper.js new file mode 100644 index 000000000..7a04d2e81 --- /dev/null +++ b/app/javascript/dashboard/store/captain/storeFactoryHelper.js @@ -0,0 +1,77 @@ +import { throwErrorMessage } from 'dashboard/store/utils/api'; + +export const getRecords = + (mutationTypes, API) => + async ({ commit }, params = {}) => { + commit(mutationTypes.SET_UI_FLAG, { fetchingList: true }); + try { + const response = await API.get(params); + commit(mutationTypes.SET, response.data.payload); + commit(mutationTypes.SET_META, response.data.meta); + return response.data.payload; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { fetchingList: false }); + } + }; + +export const showRecord = + (mutationTypes, API) => + async ({ commit }, id) => { + commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true }); + try { + const response = await API.show(id); + commit(mutationTypes.ADD, response.data); + return response.data; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false }); + } + }; + +export const createRecord = + (mutationTypes, API) => + async ({ commit }, dataObj) => { + commit(mutationTypes.SET_UI_FLAG, { creatingItem: true }); + try { + const response = await API.create(dataObj); + commit(mutationTypes.UPSERT, response.data); + return response.data; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { creatingItem: false }); + } + }; + +export const updateRecord = + (mutationTypes, API) => + async ({ commit }, { id, ...updateObj }) => { + commit(mutationTypes.SET_UI_FLAG, { updatingItem: true }); + try { + const response = await API.update(id, updateObj); + commit(mutationTypes.EDIT, response.data); + return response.data; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { updatingItem: false }); + } + }; + +export const deleteRecord = + (mutationTypes, API) => + async ({ commit }, id) => { + commit(mutationTypes.SET_UI_FLAG, { deletingItem: true }); + try { + await API.delete(id); + commit(mutationTypes.DELETE, id); + return id; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { deletingItem: false }); + } + }; diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 5daf73ae1..960285ebf 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -51,6 +51,9 @@ import captainDocuments from './captain/document'; import captainResponses from './captain/response'; import captainInboxes from './captain/inboxes'; import captainBulkActions from './captain/bulkActions'; +import copilotThreads from './captain/copilotThreads'; +import copilotMessages from './captain/copilotMessages'; + const plugins = []; export default createStore({ @@ -106,6 +109,8 @@ export default createStore({ captainResponses, captainInboxes, captainBulkActions, + copilotThreads, + copilotMessages, }, plugins, }); diff --git a/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb index d7d79e9af..75abc0cdc 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb @@ -12,9 +12,10 @@ class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts: def create @copilot_message = @copilot_thread.copilot_messages.create!( - message: params[:message], + message: { content: params[:message] }, message_type: :user ) + @copilot_message.enqueue_response_job(params[:conversation_id], Current.user.id) end private diff --git a/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb index 482acd7e7..533859b95 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb @@ -18,7 +18,12 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts:: assistant: assistant ) - @copilot_thread.copilot_messages.create!(message_type: :user, message: copilot_thread_params[:message]) + copilot_message = @copilot_thread.copilot_messages.create!( + message_type: :user, + message: { content: copilot_thread_params[:message] } + ) + + copilot_message.enqueue_response_job(copilot_thread_params[:conversation_id], Current.user.id) end end @@ -33,7 +38,7 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts:: end def copilot_thread_params - params.permit(:message, :assistant_id) + params.permit(:message, :assistant_id, :conversation_id) end def permitted_params diff --git a/enterprise/app/helpers/captain/chat_helper.rb b/enterprise/app/helpers/captain/chat_helper.rb index e326f3094..924f11269 100644 --- a/enterprise/app/helpers/captain/chat_helper.rb +++ b/enterprise/app/helpers/captain/chat_helper.rb @@ -13,6 +13,9 @@ module Captain::ChatHelper ) handle_response(response) + rescue StandardError => e + Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}" + raise e end private diff --git a/enterprise/app/jobs/captain/copilot/response_job.rb b/enterprise/app/jobs/captain/copilot/response_job.rb new file mode 100644 index 000000000..fca5b0cce --- /dev/null +++ b/enterprise/app/jobs/captain/copilot/response_job.rb @@ -0,0 +1,25 @@ +class Captain::Copilot::ResponseJob < ApplicationJob + queue_as :default + + def perform(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:) + Rails.logger.info("#{self.class.name} Copilot response job for assistant_id=#{assistant.id} user_id=#{user_id}") + generate_chat_response( + assistant: assistant, + conversation_id: conversation_id, + user_id: user_id, + copilot_thread_id: copilot_thread_id, + message: message + ) + end + + private + + def generate_chat_response(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:) + Captain::Copilot::ChatService.new( + assistant, + user_id: user_id, + copilot_thread_id: copilot_thread_id, + conversation_id: conversation_id + ).generate_response(message) + end +end diff --git a/enterprise/app/models/copilot_message.rb b/enterprise/app/models/copilot_message.rb index f3816685c..5941656c3 100644 --- a/enterprise/app/models/copilot_message.rb +++ b/enterprise/app/models/copilot_message.rb @@ -19,14 +19,12 @@ class CopilotMessage < ApplicationRecord belongs_to :copilot_thread belongs_to :account - before_validation :ensure_account - enum message_type: { user: 0, assistant: 1, assistant_thinking: 2 } - validates :message_type, presence: true, inclusion: { in: message_types.keys } + validates :message_type, presence: true validates :message, presence: true + before_validation :ensure_account validate :validate_message_attributes - after_create_commit :broadcast_message def push_event_data @@ -39,10 +37,20 @@ class CopilotMessage < ApplicationRecord } end + def enqueue_response_job(conversation_id, user_id) + Captain::Copilot::ResponseJob.perform_later( + assistant: copilot_thread.assistant, + conversation_id: conversation_id, + user_id: user_id, + copilot_thread_id: copilot_thread.id, + message: message['content'] + ) + end + private def ensure_account - self.account = copilot_thread.account + self.account_id = copilot_thread&.account_id end def broadcast_message diff --git a/enterprise/app/services/captain/copilot/chat_service.rb b/enterprise/app/services/captain/copilot/chat_service.rb index 0c4811c7e..701837f55 100644 --- a/enterprise/app/services/captain/copilot/chat_service.rb +++ b/enterprise/app/services/captain/copilot/chat_service.rb @@ -51,7 +51,7 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService "#{self.class.name} Assistant: #{@assistant.id}, Previous History: #{config[:previous_history]&.length || 0}, Language: #{config[:language]}" ) - @copilot_thread = @account.copilot_threads.find_by(id: config[:thread_id]) if config[:thread_id].present? + @copilot_thread = @account.copilot_threads.find_by(id: config[:copilot_thread_id]) if config[:copilot_thread_id].present? @previous_history = if @copilot_thread.present? @copilot_thread.previous_history else diff --git a/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb index ac5116303..0d59e194d 100644 --- a/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb +++ b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb @@ -46,7 +46,7 @@ class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseS end def active? - @user.present? && @assistant.account.hooks.find_by(app_id: 'linear').present? + @user.present? && @assistant.account.hooks.exists?(app_id: 'linear') end private diff --git a/lib/tasks/db_enhancements.rake b/lib/tasks/db_enhancements.rake index d9cc22334..5fddb6cde 100644 --- a/lib/tasks/db_enhancements.rake +++ b/lib/tasks/db_enhancements.rake @@ -18,7 +18,7 @@ db_namespace = namespace :db do ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| ActiveRecord::Base.establish_connection(db_config.configuration_hash) unless ActiveRecord::Base.connection.table_exists? 'ar_internal_metadata' - db_namespace['load_config'].invoke if ActiveRecord::Base.schema_format == :ruby + db_namespace['load_config'].invoke if ActiveRecord.schema_format == :ruby ActiveRecord::Tasks::DatabaseTasks.load_schema_current(:ruby, ENV.fetch('SCHEMA', nil)) db_namespace['seed'].invoke end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb index d435013cf..511f4ea73 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb @@ -44,7 +44,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r end.to change(CopilotMessage, :count).by(1) expect(response).to have_http_status(:success) - expect(CopilotMessage.last.message).to eq(message_content) + expect(CopilotMessage.last.message).to eq({ 'content' => message_content }) expect(CopilotMessage.last.message_type).to eq('user') expect(CopilotMessage.last.copilot_thread_id).to eq(copilot_thread.id) end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb index 748c3adf6..b8fc628d1 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb @@ -4,6 +4,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do let(:account) { create(:account) } let(:admin) { create(:user, account: account, role: :administrator) } let(:agent) { create(:user, account: account, role: :agent) } + let(:conversation) { create(:conversation, account: account) } def json_response JSON.parse(response.body, symbolize_names: true) @@ -50,7 +51,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do let(:assistant) { create(:captain_assistant, account: account) } - let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id } } + let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id, conversation_id: conversation.display_id } } context 'when it is an un-authenticated user' do it 'returns unauthorized' do @@ -103,7 +104,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do message = thread.copilot_messages.last expect(message.message_type).to eq('user') - expect(message.message).to eq(valid_params[:message]) + expect(message.message).to eq({ 'content' => valid_params[:message] }) end end end diff --git a/spec/enterprise/jobs/captain/copilot/response_job_spec.rb b/spec/enterprise/jobs/captain/copilot/response_job_spec.rb new file mode 100644 index 000000000..c8aacda5e --- /dev/null +++ b/spec/enterprise/jobs/captain/copilot/response_job_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe Captain::Copilot::ResponseJob, type: :job do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) } + let(:conversation_id) { 123 } + let(:message) { { 'content' => 'Test message' } } + + describe '#perform' do + let(:chat_service) { instance_double(Captain::Copilot::ChatService) } + + before do + allow(Captain::Copilot::ChatService).to receive(:new).with( + assistant, + user_id: user.id, + copilot_thread_id: copilot_thread.id, + conversation_id: conversation_id + ).and_return(chat_service) + allow(chat_service).to receive(:generate_response).with(message) + end + + it 'initializes ChatService with correct parameters and calls generate_response' do + expect(Captain::Copilot::ChatService).to receive(:new).with( + assistant, + user_id: user.id, + copilot_thread_id: copilot_thread.id, + conversation_id: conversation_id + ) + expect(chat_service).to receive(:generate_response).with(message) + described_class.perform_now( + assistant: assistant, + conversation_id: conversation_id, + user_id: user.id, + copilot_thread_id: copilot_thread.id, + message: message + ) + end + end +end diff --git a/spec/enterprise/models/copilot_message_spec.rb b/spec/enterprise/models/copilot_message_spec.rb index e5c904c45..6c0e496f2 100644 --- a/spec/enterprise/models/copilot_message_spec.rb +++ b/spec/enterprise/models/copilot_message_spec.rb @@ -1,15 +1,14 @@ require 'rails_helper' RSpec.describe CopilotMessage, type: :model do - describe 'associations' do - it { is_expected.to belong_to(:copilot_thread) } - it { is_expected.to belong_to(:account) } - end + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) } describe 'validations' do it { is_expected.to validate_presence_of(:message_type) } it { is_expected.to validate_presence_of(:message) } - it { is_expected.to validate_inclusion_of(:message_type).in_array(described_class.message_types.keys) } end describe 'callbacks' do @@ -31,7 +30,7 @@ RSpec.describe CopilotMessage, type: :model do message = build(:captain_copilot_message, copilot_thread: copilot_thread) expect(Rails.configuration.dispatcher).to receive(:dispatch) - .with(COPILOT_MESSAGE_CREATED, anything, copilot_message: message) + .with('copilot.message.created', anything, copilot_message: message) message.save! end diff --git a/spec/enterprise/services/captain/copilot/chat_service_spec.rb b/spec/enterprise/services/captain/copilot/chat_service_spec.rb index e498fa83a..a4274ec4e 100644 --- a/spec/enterprise/services/captain/copilot/chat_service_spec.rb +++ b/spec/enterprise/services/captain/copilot/chat_service_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Captain::Copilot::ChatService do let(:previous_history) { [{ role: copilot_message.message_type, content: copilot_message.message['content'] }] } let(:config) do - { user_id: user.id, thread_id: copilot_thread.id, conversation_id: conversation.display_id } + { user_id: user.id, copilot_thread_id: copilot_thread.id, conversation_id: conversation.display_id } end before do @@ -157,16 +157,16 @@ RSpec.describe Captain::Copilot::ChatService do end describe '#setup_message_history' do - context 'when thread_id is present' do + context 'when copilot_thread_id is present' do it 'finds the copilot thread and sets previous history from it' do - service = described_class.new(assistant, { thread_id: copilot_thread.id }) + service = described_class.new(assistant, { copilot_thread_id: copilot_thread.id }) expect(service.copilot_thread).to eq(copilot_thread) expect(service.previous_history).to eq previous_history end end - context 'when thread_id is not present' do + context 'when copilot_thread_id is not present' do it 'uses previous_history from config if present' do custom_history = [{ role: 'user', content: 'Custom message' }] service = described_class.new(assistant, { previous_history: custom_history }) @@ -222,7 +222,7 @@ RSpec.describe Captain::Copilot::ChatService do }.with_indifferent_access) expect do - described_class.new(assistant, { thread_id: copilot_thread.id }).generate_response('Hello') + described_class.new(assistant, { copilot_thread_id: copilot_thread.id }).generate_response('Hello') end.to change(CopilotMessage, :count).by(1) last_message = CopilotMessage.last diff --git a/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb index e4504d7bd..eb11e0d69 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb @@ -102,10 +102,6 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do end context 'when no articles are found' do - before do - allow(Article).to receive(:where).and_return(Article.none) - end - it 'returns no articles found message' do expect(service.execute({ 'query' => 'test' })).to eq('No articles found') end @@ -113,13 +109,8 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do context 'when articles are found' do let(:portal) { create(:portal, account: account) } - let(:article1) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 1', content: 'Content 1') } - let(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') } - - before do - article1 - article2 - end + let!(:article1) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 1', content: 'Content 1') } + let!(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') } it 'returns formatted articles with count' do result = service.execute({ 'query' => 'Test' }) @@ -130,11 +121,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do context 'when filtered by category' do let(:category) { create(:category, slug: 'test-category', portal: portal, account: account) } - let(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') } - - before do - article3 - end + let!(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') } it 'returns only articles from the specified category' do result = service.execute({ 'query' => 'Test', 'category_id' => category.id }) @@ -146,15 +133,8 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do end context 'when filtered by status' do - let(:article3) do - create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published') - end - let(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') } - - before do - article3 - article4 - end + let!(:article3) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published') } + let!(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') } it 'returns only articles with the specified status' do result = service.execute({ 'query' => 'Test', 'status' => 'published' }) diff --git a/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb index f4a5225b1..f987b7a6a 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb @@ -3,7 +3,8 @@ require 'rails_helper' RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do let(:account) { create(:account) } let(:assistant) { create(:captain_assistant, account: account) } - let(:service) { described_class.new(assistant) } + let(:user) { create(:user, account: account) } + let(:service) { described_class.new(assistant, user: user) } describe '#name' do it 'returns the correct service name' do @@ -40,14 +41,34 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do create(:integrations_hook, :linear, account: account) end - it 'returns true' do - expect(service.active?).to be true + context 'when user is present' do + it 'returns true' do + expect(service.active?).to be true + end + end + + context 'when user is not present' do + let(:service) { described_class.new(assistant) } + + it 'returns false' do + expect(service.active?).to be false + end end end context 'when Linear integration is not enabled' do - it 'returns false' do - expect(service.active?).to be false + context 'when user is present' do + it 'returns false' do + expect(service.active?).to be false + end + end + + context 'when user is not present' do + let(:service) { described_class.new(assistant) } + + it 'returns false' do + expect(service.active?).to be false + end end end end diff --git a/spec/factories/articles.rb b/spec/factories/articles.rb index e53549090..b86e422a9 100644 --- a/spec/factories/articles.rb +++ b/spec/factories/articles.rb @@ -1,13 +1,14 @@ FactoryBot.define do factory :article, class: 'Article' do - account_id { 1 } - category_id { 1 } + account + category { nil } + portal locale { 'en' } - author_id { 1 } + association :author, factory: :user title { "#{Faker::Movie.title} #{SecureRandom.hex}" } content { 'MyText' } description { 'MyDescrption' } - status { 1 } + status { :published } views { 0 } end end diff --git a/spec/factories/categories.rb b/spec/factories/categories.rb index f7becf8fc..f9685b8b0 100644 --- a/spec/factories/categories.rb +++ b/spec/factories/categories.rb @@ -1,9 +1,10 @@ FactoryBot.define do factory :category, class: 'Category' do - portal { portal } + portal name { 'MyString' } description { 'MyText' } position { 1 } + slug { name.parameterize } after(:build) do |category| category.account ||= category.portal.account