HH-598: hide unavailable AI takeover (#155)
* fix(HH-598): hide unavailable AI takeover * fix: isolate inbox assistant availability --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -533,6 +533,28 @@ func (s *ConversationCrudTestSuite) TestInboxAssistant_ReturnsNilWhenUnbound() {
|
||||
assert.Nil(s.T(), resp["assistant"])
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestInboxAssistant_ReturnsNilWhenBoundAssistantInactive() {
|
||||
assistant := &model.CaptainAssistant{AccountID: s.testAccount.ID, Name: "Inactive Helper", Config: json.RawMessage(`{}`), Status: model.AssistantStatusDraft}
|
||||
s.Require().NoError(s.db.Create(assistant).Error)
|
||||
s.Require().NoError(s.db.Create(&model.CaptainInbox{AccountID: s.testAccount.ID, AssistantID: assistant.ID, InboxID: s.testInbox.ID}).Error)
|
||||
|
||||
assertUnavailable := func() {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", s.convURL(s.testConv.ID)+"/inbox_assistant", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Nil(s.T(), resp["assistant"])
|
||||
}
|
||||
|
||||
assertUnavailable()
|
||||
assistant.Status = model.AssistantStatusArchived
|
||||
s.Require().NoError(s.db.Save(assistant).Error)
|
||||
assertUnavailable()
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestInboxAssistant_UsesDisplayIDRoute() {
|
||||
displayID := uint(77)
|
||||
s.testConv.DisplayID = &displayID
|
||||
|
||||
@@ -207,7 +207,7 @@ func (s *ConversationService) GetInboxAssistant(ctx context.Context, accountID,
|
||||
var assistant model.CaptainAssistant
|
||||
err = s.repo.DB().WithContext(ctx).
|
||||
Joins("JOIN captain_inboxes ON captain_inboxes.captain_assistant_id = captain_assistants.id").
|
||||
Where("captain_inboxes.account_id = ? AND captain_inboxes.inbox_id = ? AND captain_assistants.account_id = ?", accountID, conversation.InboxID, accountID).
|
||||
Where("captain_inboxes.account_id = ? AND captain_inboxes.inbox_id = ? AND captain_inboxes.deleted_at IS NULL AND captain_assistants.account_id = ? AND captain_assistants.status = ?", accountID, conversation.InboxID, accountID, model.AssistantStatusActive).
|
||||
Order("captain_inboxes.id DESC").
|
||||
First(&assistant).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
|
||||
+64
-2
@@ -1,10 +1,15 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import conversationActions from 'dashboard/store/modules/conversations/actions';
|
||||
import types from 'dashboard/store/mutation-types';
|
||||
import CopilotMenuBar from './CopilotMenuBar.vue';
|
||||
import ReplyTopPanel from './ReplyTopPanel.vue';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
vi.mock('dashboard/composables/useCaptain', () => ({
|
||||
useCaptain: () => ({ captainTasksEnabled: ref(true) }),
|
||||
}));
|
||||
@@ -13,19 +18,35 @@ vi.mock('dashboard/composables/useKeyboardEvents', () => ({
|
||||
useKeyboardEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
const createWrapper = ({ aiTakeoverActive = false, props = {} } = {}) => {
|
||||
const createWrapper = ({
|
||||
aiTakeoverActive = false,
|
||||
inboxAssistant = { id: 7 },
|
||||
useRealAssistantAction = false,
|
||||
props = {},
|
||||
} = {}) => {
|
||||
const startAITakeover = vi.fn();
|
||||
const exitAITakeover = vi.fn();
|
||||
const getInboxCaptainAssistantById = vi.fn();
|
||||
const store = createStore({
|
||||
state: {
|
||||
currentChat: { id: 42, ai_takeover_active: aiTakeoverActive },
|
||||
copilotAssistant: inboxAssistant,
|
||||
},
|
||||
getters: {
|
||||
getSelectedChat: state => state.currentChat,
|
||||
getCopilotAssistant: state => state.copilotAssistant,
|
||||
},
|
||||
mutations: {
|
||||
[types.SET_INBOX_CAPTAIN_ASSISTANT](state, data) {
|
||||
state.copilotAssistant = data.assistant;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
startAITakeover,
|
||||
exitAITakeover,
|
||||
getInboxCaptainAssistantById: useRealAssistantAction
|
||||
? conversationActions.getInboxCaptainAssistantById
|
||||
: getInboxCaptainAssistantById,
|
||||
},
|
||||
modules: {
|
||||
draftMessages: {
|
||||
@@ -37,7 +58,9 @@ const createWrapper = ({ aiTakeoverActive = false, props = {} } = {}) => {
|
||||
|
||||
return {
|
||||
store,
|
||||
exitAITakeover,
|
||||
startAITakeover,
|
||||
getInboxCaptainAssistantById,
|
||||
wrapper: mount(ReplyTopPanel, {
|
||||
props: { conversationId: 42, ...props },
|
||||
global: {
|
||||
@@ -93,6 +116,45 @@ describe('ReplyTopPanel', () => {
|
||||
expect(startAITakeover).toHaveBeenCalledWith(expect.any(Object), 42);
|
||||
});
|
||||
|
||||
it('hides AI takeover when the inbox has no AI configured', () => {
|
||||
const { getInboxCaptainAssistantById, startAITakeover, wrapper } =
|
||||
createWrapper({ inboxAssistant: null });
|
||||
|
||||
expect(getInboxCaptainAssistantById).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
42
|
||||
);
|
||||
expect(wrapper.text()).not.toContain('AI takeover');
|
||||
expect(startAITakeover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates after the inbox assistant API returns null', async () => {
|
||||
axios.get.mockResolvedValue({ data: { assistant: null } });
|
||||
const { store, wrapper } = createWrapper({
|
||||
useRealAssistantAction: true,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(store.getters.getCopilotAssistant).toBeNull();
|
||||
expect(wrapper.text()).not.toContain('AI takeover');
|
||||
});
|
||||
|
||||
it('keeps the exit action when takeover is active without an assistant', async () => {
|
||||
const { exitAITakeover, wrapper } = createWrapper({
|
||||
aiTakeoverActive: true,
|
||||
inboxAssistant: null,
|
||||
});
|
||||
const exitButton = wrapper
|
||||
.get('[data-testid="reply-top-panel-actions"]')
|
||||
.findAll('button')
|
||||
.find(button => button.text() === 'Exit AI takeover');
|
||||
|
||||
await exitButton.trigger('click');
|
||||
|
||||
expect(exitAITakeover).toHaveBeenCalledWith(expect.any(Object), 42);
|
||||
});
|
||||
|
||||
it('renders Copilot actions inline and forwards the selected action', async () => {
|
||||
const store = createStore({
|
||||
modules: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { computed } from 'vue';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
@@ -61,6 +61,17 @@ export default {
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxAssistant = useMapGetter('getCopilotAssistant');
|
||||
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
conversationId => {
|
||||
if (conversationId) {
|
||||
store.dispatch('getInboxCaptainAssistantById', conversationId);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const setReplyMode = mode => {
|
||||
emit('setReplyMode', mode);
|
||||
@@ -89,6 +100,9 @@ export default {
|
||||
const aiTakeoverActive = computed(
|
||||
() => currentChat.value?.ai_takeover_active === true
|
||||
);
|
||||
const aiTakeoverAvailable = computed(
|
||||
() => Boolean(inboxAssistant.value?.id) || aiTakeoverActive.value
|
||||
);
|
||||
|
||||
const toggleAITakeover = async () => {
|
||||
try {
|
||||
@@ -129,6 +143,7 @@ export default {
|
||||
captainTasksEnabled,
|
||||
handleCopilotAction,
|
||||
aiTakeoverActive,
|
||||
aiTakeoverAvailable,
|
||||
toggleAITakeover,
|
||||
};
|
||||
},
|
||||
@@ -177,6 +192,7 @@ export default {
|
||||
class="flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-2 sm:w-auto sm:flex-nowrap"
|
||||
>
|
||||
<NextButton
|
||||
v-if="aiTakeoverAvailable"
|
||||
type="button"
|
||||
:label="
|
||||
aiTakeoverActive
|
||||
|
||||
@@ -44,6 +44,8 @@ const getStoredMessage = (conversation, message) => {
|
||||
const isNewerMessage = (currentMessage, message) =>
|
||||
isNewerTimestamp(currentMessage?.updated_at, message.updated_at);
|
||||
|
||||
let inboxAssistantRequestId = 0;
|
||||
|
||||
// actions
|
||||
const actions = {
|
||||
getConversation: async ({ commit }, conversationId) => {
|
||||
@@ -56,7 +58,10 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
fetchAllConversations: async ({ commit, state, dispatch }, explicitFilters) => {
|
||||
fetchAllConversations: async (
|
||||
{ commit, state, dispatch },
|
||||
explicitFilters
|
||||
) => {
|
||||
commit(types.CLEAR_LIST_ERROR_STATUS);
|
||||
commit(types.SET_LIST_LOADING_STATUS);
|
||||
try {
|
||||
@@ -574,9 +579,14 @@ const actions = {
|
||||
},
|
||||
|
||||
getInboxCaptainAssistantById: async ({ commit }, conversationId) => {
|
||||
inboxAssistantRequestId += 1;
|
||||
const requestId = inboxAssistantRequestId;
|
||||
commit(types.SET_INBOX_CAPTAIN_ASSISTANT, { assistant: null });
|
||||
try {
|
||||
const response = await ConversationApi.getInboxAssistant(conversationId);
|
||||
commit(types.SET_INBOX_CAPTAIN_ASSISTANT, response.data);
|
||||
if (requestId === inboxAssistantRequestId) {
|
||||
commit(types.SET_INBOX_CAPTAIN_ASSISTANT, response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
+35
-10
@@ -1002,22 +1002,47 @@ describe('#addMentions', () => {
|
||||
it('fetches inbox assistant by id', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: {
|
||||
id: 1,
|
||||
name: 'Assistant',
|
||||
description: 'Assistant description',
|
||||
},
|
||||
});
|
||||
await actions.getInboxCaptainAssistantById({ commit }, 1);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[
|
||||
types.SET_INBOX_CAPTAIN_ASSISTANT,
|
||||
{
|
||||
assistant: {
|
||||
id: 1,
|
||||
name: 'Assistant',
|
||||
description: 'Assistant description',
|
||||
},
|
||||
},
|
||||
});
|
||||
await actions.getInboxCaptainAssistantById({ commit }, 1);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_INBOX_CAPTAIN_ASSISTANT, { assistant: null }],
|
||||
[
|
||||
types.SET_INBOX_CAPTAIN_ASSISTANT,
|
||||
{
|
||||
assistant: {
|
||||
id: 1,
|
||||
name: 'Assistant',
|
||||
description: 'Assistant description',
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores an older response after switching conversations', async () => {
|
||||
const resolvers = [];
|
||||
axios.get.mockImplementation(
|
||||
() => new Promise(resolve => resolvers.push(resolve))
|
||||
);
|
||||
|
||||
const requestA = actions.getInboxCaptainAssistantById({ commit }, 1);
|
||||
const requestB = actions.getInboxCaptainAssistantById({ commit }, 2);
|
||||
resolvers[1]({ data: { assistant: null } });
|
||||
await requestB;
|
||||
resolvers[0]({ data: { assistant: { id: 1, name: 'Assistant A' } } });
|
||||
await requestA;
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_INBOX_CAPTAIN_ASSISTANT, { assistant: null }],
|
||||
[types.SET_INBOX_CAPTAIN_ASSISTANT, { assistant: null }],
|
||||
[types.SET_INBOX_CAPTAIN_ASSISTANT, { assistant: null }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user