H-289: add Captain Skill management UI (#47)
* H-289: add Captain Skill management UI * H-289: guard Captain Skill assistant switches --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainSkills extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/skills', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ assistantId } = {}) {
|
||||
return axios.get(this.url, { params: { assistant_id: assistantId } });
|
||||
}
|
||||
|
||||
create(data) {
|
||||
return axios.post(this.url, { skill: data });
|
||||
}
|
||||
|
||||
update(id, data) {
|
||||
return axios.put(`${this.url}/${id}`, { skill: data });
|
||||
}
|
||||
|
||||
bind({ assistantId, skillId }) {
|
||||
return axios.post(
|
||||
`${this.baseUrl()}/captain/assistants/${assistantId}/skills/${skillId}`
|
||||
);
|
||||
}
|
||||
|
||||
unbind({ assistantId, skillId }) {
|
||||
return axios.delete(
|
||||
`${this.baseUrl()}/captain/assistants/${assistantId}/skills/${skillId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainSkills();
|
||||
@@ -0,0 +1,56 @@
|
||||
import CaptainSkillsAPI from '../skills';
|
||||
import ApiClient from '../../ApiClient';
|
||||
|
||||
describe('#CaptainSkillsAPI', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState({}, '', '/app/accounts/7/captain/2/skills');
|
||||
global.axios = {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('uses the account skill management endpoints', () => {
|
||||
CaptainSkillsAPI.get({ assistantId: 2 });
|
||||
CaptainSkillsAPI.show(3);
|
||||
CaptainSkillsAPI.create({ name: 'Refunds' });
|
||||
CaptainSkillsAPI.update(3, { expected_version: 1 });
|
||||
CaptainSkillsAPI.delete(3);
|
||||
|
||||
expect(axios.get).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/v1/accounts/7/captain/skills',
|
||||
{ params: { assistant_id: 2 } }
|
||||
);
|
||||
expect(axios.get).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/v1/accounts/7/captain/skills/3'
|
||||
);
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/7/captain/skills',
|
||||
{ skill: { name: 'Refunds' } }
|
||||
);
|
||||
expect(axios.put).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/7/captain/skills/3',
|
||||
{ skill: { expected_version: 1 } }
|
||||
);
|
||||
expect(axios.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/7/captain/skills/3'
|
||||
);
|
||||
});
|
||||
|
||||
it('binds and unbinds a skill for one assistant', () => {
|
||||
CaptainSkillsAPI.bind({ assistantId: 2, skillId: 3 });
|
||||
CaptainSkillsAPI.unbind({ assistantId: 2, skillId: 3 });
|
||||
|
||||
const url = '/api/v1/accounts/7/captain/assistants/2/skills/3';
|
||||
expect(axios.post).toHaveBeenCalledWith(url);
|
||||
expect(axios.delete).toHaveBeenCalledWith(url);
|
||||
});
|
||||
|
||||
it('is an account-scoped API client', () => {
|
||||
expect(CaptainSkillsAPI).toBeInstanceOf(ApiClient);
|
||||
});
|
||||
});
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import SkillDialog from './SkillDialog.vue';
|
||||
|
||||
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||
const mockedStore = vi.hoisted(() => ({ dispatch: vi.fn() }));
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useStore: () => mockedStore,
|
||||
}));
|
||||
|
||||
const Dialog = {
|
||||
name: 'Dialog',
|
||||
emits: ['close'],
|
||||
methods: {
|
||||
open() {},
|
||||
close() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
template: '<div><slot /><slot name="footer" /></div>',
|
||||
};
|
||||
|
||||
const Button = {
|
||||
name: 'Button',
|
||||
props: ['label'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button type="button" @click="$emit(\'click\')">{{ label }}</button>',
|
||||
};
|
||||
|
||||
const mountDialog = ({ skill = null, dispatch = vi.fn() } = {}) => {
|
||||
mockedStore.dispatch = dispatch;
|
||||
return shallowMount(SkillDialog, {
|
||||
props: { skill, assistantId: 9 },
|
||||
global: {
|
||||
stubs: {
|
||||
Dialog,
|
||||
Button,
|
||||
Input: true,
|
||||
TextArea: true,
|
||||
Editor: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const detail = {
|
||||
id: 3,
|
||||
name: 'Refunds',
|
||||
description: 'Handle refund requests',
|
||||
instructions_md: 'Ask for the order number.',
|
||||
status: 'active',
|
||||
version: 4,
|
||||
bound_assistant_count: 2,
|
||||
references: [
|
||||
{
|
||||
reference_key: 'refund-policy',
|
||||
content_md: 'Thirty days.',
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('SkillDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates a validated draft with the management API shape', async () => {
|
||||
const dispatch = vi.fn().mockResolvedValue({ id: 5 });
|
||||
const wrapper = mountDialog({ dispatch });
|
||||
Object.assign(wrapper.vm.form, {
|
||||
name: ' Refunds ',
|
||||
description: ' Handle refunds ',
|
||||
instructions_md: 'Use the policy.',
|
||||
});
|
||||
|
||||
await wrapper.vm.submit('draft');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('captainSkills/create', {
|
||||
name: 'Refunds',
|
||||
description: 'Handle refunds',
|
||||
instructions_md: 'Use the policy.',
|
||||
status: 'draft',
|
||||
references: [],
|
||||
});
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('publishes then binds to only the current assistant', async () => {
|
||||
let resolveCreate;
|
||||
const dispatch = vi.fn().mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveCreate = resolve;
|
||||
})
|
||||
);
|
||||
const wrapper = mountDialog({ dispatch });
|
||||
Object.assign(wrapper.vm.form, {
|
||||
name: 'Refunds',
|
||||
description: 'Handle refunds',
|
||||
instructions_md: 'Use the policy.',
|
||||
});
|
||||
|
||||
const publish = wrapper.vm.submit('active', true);
|
||||
await wrapper.setProps({ assistantId: 10 });
|
||||
resolveCreate({ id: 5 });
|
||||
await publish;
|
||||
|
||||
expect(dispatch).toHaveBeenLastCalledWith('captainSkills/bind', {
|
||||
assistantId: 9,
|
||||
skillId: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('warns before changing an active skill used by assistants', async () => {
|
||||
const dispatch = vi.fn();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const wrapper = mountDialog({ skill: detail, dispatch });
|
||||
|
||||
await wrapper.vm.submit('active');
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('2 assistants')
|
||||
);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes the saved skill when binding fails instead of creating a duplicate', async () => {
|
||||
const dispatch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 5 })
|
||||
.mockRejectedValueOnce({ response: { status: 409 } });
|
||||
const wrapper = mountDialog({ dispatch });
|
||||
Object.assign(wrapper.vm.form, {
|
||||
name: 'Refunds',
|
||||
description: 'Handle refunds',
|
||||
instructions_md: 'Use the policy.',
|
||||
});
|
||||
|
||||
await wrapper.vm.submit('active', true);
|
||||
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps form input and reports a concurrent update', async () => {
|
||||
const dispatch = vi.fn().mockRejectedValue({
|
||||
response: { status: 409, data: { error: 'version conflict' } },
|
||||
});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
const wrapper = mountDialog({ skill: detail, dispatch });
|
||||
|
||||
await wrapper.vm.submit('active');
|
||||
|
||||
expect(wrapper.vm.errorMessage).toContain('version conflict');
|
||||
expect(wrapper.vm.form.name).toBe('Refunds');
|
||||
expect(wrapper.emitted('saved')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
skill: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
assistantId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'saved']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const dialogRef = ref(null);
|
||||
const isSaving = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const errors = reactive({});
|
||||
const forceClose = ref(false);
|
||||
|
||||
const initialForm = () => ({
|
||||
name: props.skill?.name || '',
|
||||
description: props.skill?.description || '',
|
||||
instructions_md: props.skill?.instructions_md || '',
|
||||
references: (props.skill?.references || [])
|
||||
.slice()
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map(({ reference_key, content_md }) => ({
|
||||
reference_key,
|
||||
content_md,
|
||||
})),
|
||||
});
|
||||
|
||||
const form = reactive(initialForm());
|
||||
const originalForm = JSON.stringify(initialForm());
|
||||
const isEditing = computed(() => Boolean(props.skill?.id));
|
||||
const currentStatus = computed(() => props.skill?.status || 'draft');
|
||||
const isDirty = computed(() => JSON.stringify(form) !== originalForm);
|
||||
const byteLength = value => new TextEncoder().encode(value).length;
|
||||
|
||||
const clearErrors = () => {
|
||||
Object.keys(errors).forEach(key => delete errors[key]);
|
||||
errorMessage.value = '';
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
clearErrors();
|
||||
if (!form.name.trim() || byteLength(form.name.trim()) > 255) {
|
||||
errors.name = t('CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.NAME');
|
||||
}
|
||||
if (
|
||||
!form.description.trim() ||
|
||||
byteLength(form.description.trim()) > 1024
|
||||
) {
|
||||
errors.description = t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.DESCRIPTION'
|
||||
);
|
||||
}
|
||||
if (!form.instructions_md || byteLength(form.instructions_md) > 32 * 1024) {
|
||||
errors.instructions = t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.INSTRUCTIONS'
|
||||
);
|
||||
}
|
||||
if (form.references.length > 20) {
|
||||
errors.references = t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.REFERENCE_LIMIT'
|
||||
);
|
||||
}
|
||||
|
||||
const seenKeys = new Set();
|
||||
form.references.forEach((reference, index) => {
|
||||
const key = reference.reference_key.trim();
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(key) || seenKeys.has(key)) {
|
||||
errors[`reference-${index}`] = t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.REFERENCE_KEY'
|
||||
);
|
||||
} else if (
|
||||
!reference.content_md ||
|
||||
byteLength(reference.content_md) > 64 * 1024
|
||||
) {
|
||||
errors[`reference-${index}`] = t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.ERRORS.REFERENCE_CONTENT'
|
||||
);
|
||||
}
|
||||
seenKeys.add(key);
|
||||
});
|
||||
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const payloadFor = status => ({
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
instructions_md: form.instructions_md,
|
||||
status,
|
||||
references: form.references.map(reference => ({
|
||||
reference_key: reference.reference_key.trim(),
|
||||
content_md: reference.content_md,
|
||||
})),
|
||||
});
|
||||
|
||||
const apiErrorMessage = error => {
|
||||
const status = error?.response?.status;
|
||||
if (status === 403) return t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.FORBIDDEN');
|
||||
if (status === 404) return t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.NOT_FOUND');
|
||||
const parsed = parseAPIErrorResponse(error);
|
||||
if (status === 409) {
|
||||
return `${parsed} ${t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.CONFLICT')}`;
|
||||
}
|
||||
return parsed || t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.DEFAULT');
|
||||
};
|
||||
|
||||
const finishSave = () => {
|
||||
forceClose.value = true;
|
||||
emit('saved');
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const submit = async (status, bindAfterSave = false) => {
|
||||
if (!validate()) return;
|
||||
if (
|
||||
currentStatus.value === 'active' &&
|
||||
!window.confirm(
|
||||
t('CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.ACTIVE_SAVE', {
|
||||
count: props.skill?.bound_assistant_count || 0,
|
||||
})
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedAssistantId = props.assistantId;
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const payload = payloadFor(status);
|
||||
const saved = isEditing.value
|
||||
? await store.dispatch('captainSkills/update', {
|
||||
id: props.skill.id,
|
||||
...payload,
|
||||
expected_version: props.skill.version,
|
||||
})
|
||||
: await store.dispatch('captainSkills/create', payload);
|
||||
|
||||
if (bindAfterSave) {
|
||||
try {
|
||||
await store.dispatch('captainSkills/bind', {
|
||||
assistantId: requestedAssistantId,
|
||||
skillId: saved.id,
|
||||
});
|
||||
} catch (error) {
|
||||
finishSave();
|
||||
useAlert(apiErrorMessage(error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.SKILLS.SUCCESS.SAVED'));
|
||||
finishSave();
|
||||
} catch (error) {
|
||||
errorMessage.value = apiErrorMessage(error);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addReference = () => {
|
||||
if (form.references.length < 20) {
|
||||
form.references.push({ reference_key: '', content_md: '' });
|
||||
}
|
||||
};
|
||||
|
||||
const removeReference = index => form.references.splice(index, 1);
|
||||
|
||||
const moveReference = (index, offset) => {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= form.references.length) return;
|
||||
const [reference] = form.references.splice(index, 1);
|
||||
form.references.splice(target, 0, reference);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (
|
||||
!forceClose.value &&
|
||||
isDirty.value &&
|
||||
!window.confirm(t('CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.DISCARD'))
|
||||
) {
|
||||
nextTick(() => dialogRef.value?.open());
|
||||
return;
|
||||
}
|
||||
emit('close');
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
dialogRef,
|
||||
isDirty,
|
||||
form,
|
||||
errorMessage,
|
||||
submit,
|
||||
addReference,
|
||||
removeReference,
|
||||
moveReference,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="3xl"
|
||||
position="top"
|
||||
overflow-y-auto
|
||||
:title="
|
||||
isEditing
|
||||
? t('CAPTAIN.ASSISTANTS.SKILLS.DIALOG.EDIT_TITLE')
|
||||
: t('CAPTAIN.ASSISTANTS.SKILLS.DIALOG.CREATE_TITLE')
|
||||
"
|
||||
:description="t('CAPTAIN.ASSISTANTS.SKILLS.DIALOG.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="flex max-h-[65vh] flex-col gap-5 overflow-y-auto px-0.5">
|
||||
<p
|
||||
v-if="errorMessage"
|
||||
class="rounded-lg bg-n-ruby-2 px-3 py-2 text-sm text-n-ruby-11"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<Input
|
||||
v-model="form.name"
|
||||
:label="t('CAPTAIN.ASSISTANTS.SKILLS.FORM.NAME')"
|
||||
:message="errors.name"
|
||||
:message-type="errors.name ? 'error' : 'info'"
|
||||
:disabled="isSaving"
|
||||
/>
|
||||
<TextArea
|
||||
v-model="form.description"
|
||||
id="skill-description"
|
||||
:label="t('CAPTAIN.ASSISTANTS.SKILLS.FORM.DESCRIPTION')"
|
||||
:message="errors.description"
|
||||
:message-type="errors.description ? 'error' : 'info'"
|
||||
:disabled="isSaving"
|
||||
:max-length="1024"
|
||||
show-character-count
|
||||
/>
|
||||
<Editor
|
||||
v-model="form.instructions_md"
|
||||
editor-key="captain-skill-instructions"
|
||||
:label="t('CAPTAIN.ASSISTANTS.SKILLS.FORM.INSTRUCTIONS')"
|
||||
:message="errors.instructions"
|
||||
:message-type="errors.instructions ? 'error' : 'info'"
|
||||
:disabled="isSaving"
|
||||
:max-length="32768"
|
||||
/>
|
||||
|
||||
<section
|
||||
class="flex flex-col gap-3"
|
||||
aria-labelledby="skill-references"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4
|
||||
id="skill-references"
|
||||
class="text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANTS.SKILLS.FORM.REFERENCES') }}
|
||||
</h4>
|
||||
<p class="mb-0 text-xs text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.REFERENCES_HINT'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SKILLS.FORM.ADD_REFERENCE')
|
||||
"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
color="slate"
|
||||
:disabled="isSaving || form.references.length >= 20"
|
||||
@click="addReference"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errors.references" class="mb-0 text-sm text-n-ruby-11">
|
||||
{{ errors.references }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="(reference, index) in form.references"
|
||||
:key="index"
|
||||
class="flex flex-col gap-3 rounded-xl border border-n-weak p-4"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<Input
|
||||
v-model="reference.reference_key"
|
||||
class="flex-1"
|
||||
:label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.REFERENCE_KEY'
|
||||
)
|
||||
"
|
||||
:message="errors[`reference-${index}`]"
|
||||
:message-type="
|
||||
errors[`reference-${index}`] ? 'error' : 'info'
|
||||
"
|
||||
:disabled="isSaving"
|
||||
placeholder="refund-policy"
|
||||
/>
|
||||
<div class="flex gap-1 pt-7">
|
||||
<Button
|
||||
icon="i-lucide-arrow-up"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:aria-label="
|
||||
t('CAPTAIN.ASSISTANTS.SKILLS.FORM.MOVE_UP')
|
||||
"
|
||||
:disabled="isSaving || index === 0"
|
||||
@click="moveReference(index, -1)"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-arrow-down"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:aria-label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.MOVE_DOWN'
|
||||
)
|
||||
"
|
||||
:disabled="
|
||||
isSaving ||
|
||||
index === form.references.length - 1
|
||||
"
|
||||
@click="moveReference(index, 1)"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-trash"
|
||||
color="ruby"
|
||||
size="xs"
|
||||
:aria-label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.REMOVE_REFERENCE'
|
||||
)
|
||||
"
|
||||
:disabled="isSaving"
|
||||
@click="removeReference(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
v-model="reference.content_md"
|
||||
:id="`skill-reference-${index}`"
|
||||
:label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.FORM.REFERENCE_CONTENT'
|
||||
)
|
||||
"
|
||||
:disabled="isSaving"
|
||||
resize
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-end gap-3 border-t border-n-weak pt-4"
|
||||
>
|
||||
<Button
|
||||
:label="t('DIALOG.BUTTONS.CANCEL')"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:disabled="isSaving"
|
||||
@click="dialogRef.close()"
|
||||
/>
|
||||
<template v-if="currentStatus === 'active'">
|
||||
<Button
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.SAVE_CHANGES')
|
||||
"
|
||||
:is-loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
@click="submit('active')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="currentStatus === 'archived'">
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.REACTIVATE_ADD'
|
||||
)
|
||||
"
|
||||
:is-loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
@click="submit('active', true)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.SAVE_DRAFT')
|
||||
"
|
||||
color="slate"
|
||||
:disabled="isSaving"
|
||||
@click="submit('draft')"
|
||||
/>
|
||||
<Button
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.PUBLISH_ADD')
|
||||
"
|
||||
:is-loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
@click="submit('active', true)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -420,6 +420,14 @@ const menuItems = computed(() => {
|
||||
navigationPath: 'captain_assistants_scenarios_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Skills',
|
||||
label: t('SIDEBAR.CAPTAIN_SKILLS'),
|
||||
activeOn: ['captain_assistants_skills_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_skills_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Playground',
|
||||
label: t('SIDEBAR.CAPTAIN_PLAYGROUND'),
|
||||
|
||||
@@ -734,6 +734,97 @@
|
||||
"ERROR": "There was an error deleting scenarios, please try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"SKILLS": {
|
||||
"TITLE": "Skills",
|
||||
"DESCRIPTION": "Create reusable instructions and references, then add active skills to this assistant.",
|
||||
"ADD_NEW": "New skill",
|
||||
"SEARCH": "Search skills",
|
||||
"CLEAR_SEARCH": "Clear search",
|
||||
"RETRY": "Retry",
|
||||
"LOAD_ERROR": "Skills could not be loaded.",
|
||||
"EMPTY": {
|
||||
"TITLE": "No skills yet",
|
||||
"SUBTITLE": "Create a skill, then add it to the current assistant.",
|
||||
"SEARCH": "No skills match this search."
|
||||
},
|
||||
"GROUPS": {
|
||||
"ADDED": "Added to this assistant",
|
||||
"AVAILABLE": "Available to add"
|
||||
},
|
||||
"META": {
|
||||
"VERSION": "Version {count}",
|
||||
"REFERENCES": "{count} references",
|
||||
"ASSISTANTS": "{count} assistants",
|
||||
"UPDATED": "Updated {time}"
|
||||
},
|
||||
"STATUS": {
|
||||
"DRAFT": "Draft",
|
||||
"ACTIVE": "Active",
|
||||
"ARCHIVED": "Archived"
|
||||
},
|
||||
"ACTIONS": {
|
||||
"EDIT": "Edit",
|
||||
"BIND": "Add to assistant",
|
||||
"UNBIND": "Remove from assistant",
|
||||
"ARCHIVE": "Archive",
|
||||
"REACTIVATE": "Reactivate",
|
||||
"DELETE": "Delete",
|
||||
"DELETE_BOUND": "Delete (remove from assistants first)",
|
||||
"DELETE_STATUS": "Delete (drafts only)",
|
||||
"SAVE_DRAFT": "Save draft",
|
||||
"PUBLISH_ADD": "Publish and add",
|
||||
"SAVE_CHANGES": "Save changes",
|
||||
"REACTIVATE_ADD": "Reactivate and add"
|
||||
},
|
||||
"DIALOG": {
|
||||
"CREATE_TITLE": "Create skill",
|
||||
"EDIT_TITLE": "Edit skill",
|
||||
"DESCRIPTION": "Use Markdown for instructions and reference content."
|
||||
},
|
||||
"FORM": {
|
||||
"NAME": "Name",
|
||||
"DESCRIPTION": "Description",
|
||||
"INSTRUCTIONS": "Markdown instructions",
|
||||
"REFERENCES": "References",
|
||||
"REFERENCES_HINT": "Up to 20 ordered Markdown references.",
|
||||
"ADD_REFERENCE": "Add reference",
|
||||
"REFERENCE_KEY": "Reference key",
|
||||
"REFERENCE_CONTENT": "Markdown content",
|
||||
"MOVE_UP": "Move reference up",
|
||||
"MOVE_DOWN": "Move reference down",
|
||||
"REMOVE_REFERENCE": "Remove reference",
|
||||
"ERRORS": {
|
||||
"NAME": "Name is required and must be at most 255 bytes.",
|
||||
"DESCRIPTION": "Description is required and must be at most 1024 bytes.",
|
||||
"INSTRUCTIONS": "Instructions are required and must be at most 32 KB.",
|
||||
"REFERENCE_LIMIT": "At most 20 references are allowed.",
|
||||
"REFERENCE_KEY": "Use a unique lowercase kebab-case reference key.",
|
||||
"REFERENCE_CONTENT": "Reference content is required and must be at most 64 KB."
|
||||
}
|
||||
},
|
||||
"CONFIRM": {
|
||||
"ACTIVE_SAVE": "Saving immediately affects {count} assistants, increments the version, and cannot be rolled back. Continue?",
|
||||
"DISCARD": "Discard unsaved changes?",
|
||||
"UNBIND": "Remove this skill from the current assistant?",
|
||||
"ARCHIVE": "Archive this skill? It will stop running for all bound assistants, but bindings are retained.",
|
||||
"REACTIVATE": "Reactivate this skill and restore it for its bound assistants?",
|
||||
"DELETE": "Delete this draft skill permanently?"
|
||||
},
|
||||
"ERRORS": {
|
||||
"FORBIDDEN": "You do not have permission to manage skills.",
|
||||
"NOT_FOUND": "The skill or assistant does not exist, or you cannot access it.",
|
||||
"CONFLICT": "Refresh the list and try again.",
|
||||
"DEFAULT": "The skill operation failed. Please try again."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"SAVED": "Skill saved.",
|
||||
"BOUND": "Skill added to assistant.",
|
||||
"UNBOUND": "Skill removed from assistant.",
|
||||
"ARCHIVED": "Skill archived.",
|
||||
"REACTIVATED": "Skill reactivated.",
|
||||
"DELETED": "Skill deleted."
|
||||
}
|
||||
}
|
||||
},
|
||||
"DOCUMENTS": {
|
||||
|
||||
@@ -318,6 +318,7 @@
|
||||
"CAPTAIN_RESPONSES": "FAQs",
|
||||
"CAPTAIN_TOOLS": "Tools",
|
||||
"CAPTAIN_SCENARIOS": "Scenarios",
|
||||
"CAPTAIN_SKILLS": "Skills",
|
||||
"CAPTAIN_PLAYGROUND": "Playground",
|
||||
"CAPTAIN_INBOXES": "Inboxes",
|
||||
"CAPTAIN_SETTINGS": "Settings",
|
||||
|
||||
@@ -733,6 +733,97 @@
|
||||
"ERROR": "删除场景时出错,请重试。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SKILLS": {
|
||||
"TITLE": "技能",
|
||||
"DESCRIPTION": "创建可复用的指令和参考资料,然后将已发布技能添加到当前助手。",
|
||||
"ADD_NEW": "新建技能",
|
||||
"SEARCH": "搜索技能",
|
||||
"CLEAR_SEARCH": "清除搜索",
|
||||
"RETRY": "重试",
|
||||
"LOAD_ERROR": "技能加载失败。",
|
||||
"EMPTY": {
|
||||
"TITLE": "暂无技能",
|
||||
"SUBTITLE": "创建技能后,即可添加到当前助手。",
|
||||
"SEARCH": "没有匹配的技能。"
|
||||
},
|
||||
"GROUPS": {
|
||||
"ADDED": "已添加到当前助手",
|
||||
"AVAILABLE": "可添加"
|
||||
},
|
||||
"META": {
|
||||
"VERSION": "版本 {count}",
|
||||
"REFERENCES": "{count} 篇参考资料",
|
||||
"ASSISTANTS": "{count} 个助手",
|
||||
"UPDATED": "更新于 {time}"
|
||||
},
|
||||
"STATUS": {
|
||||
"DRAFT": "草稿",
|
||||
"ACTIVE": "已发布",
|
||||
"ARCHIVED": "已归档"
|
||||
},
|
||||
"ACTIONS": {
|
||||
"EDIT": "编辑",
|
||||
"BIND": "添加到助手",
|
||||
"UNBIND": "从助手移除",
|
||||
"ARCHIVE": "归档",
|
||||
"REACTIVATE": "重新启用",
|
||||
"DELETE": "删除",
|
||||
"DELETE_BOUND": "删除(请先从助手移除)",
|
||||
"DELETE_STATUS": "删除(仅限草稿)",
|
||||
"SAVE_DRAFT": "保存草稿",
|
||||
"PUBLISH_ADD": "发布并添加",
|
||||
"SAVE_CHANGES": "保存修改",
|
||||
"REACTIVATE_ADD": "重新启用并添加"
|
||||
},
|
||||
"DIALOG": {
|
||||
"CREATE_TITLE": "创建技能",
|
||||
"EDIT_TITLE": "编辑技能",
|
||||
"DESCRIPTION": "指令和参考资料使用 Markdown。"
|
||||
},
|
||||
"FORM": {
|
||||
"NAME": "名称",
|
||||
"DESCRIPTION": "描述",
|
||||
"INSTRUCTIONS": "Markdown 指令",
|
||||
"REFERENCES": "参考资料",
|
||||
"REFERENCES_HINT": "最多 20 篇有序 Markdown 参考资料。",
|
||||
"ADD_REFERENCE": "添加参考资料",
|
||||
"REFERENCE_KEY": "参考键",
|
||||
"REFERENCE_CONTENT": "Markdown 内容",
|
||||
"MOVE_UP": "上移参考资料",
|
||||
"MOVE_DOWN": "下移参考资料",
|
||||
"REMOVE_REFERENCE": "移除参考资料",
|
||||
"ERRORS": {
|
||||
"NAME": "名称必填且最多 255 字节。",
|
||||
"DESCRIPTION": "描述必填且最多 1024 字节。",
|
||||
"INSTRUCTIONS": "指令必填且最多 32 KB。",
|
||||
"REFERENCE_LIMIT": "最多允许 20 篇参考资料。",
|
||||
"REFERENCE_KEY": "参考键须唯一,并使用小写 kebab-case。",
|
||||
"REFERENCE_CONTENT": "参考内容必填且最多 64 KB。"
|
||||
}
|
||||
},
|
||||
"CONFIRM": {
|
||||
"ACTIVE_SAVE": "保存后将立即影响 {count} 个助手、递增版本且无法回滚。是否继续?",
|
||||
"DISCARD": "放弃未保存的修改?",
|
||||
"UNBIND": "从当前助手移除此技能?",
|
||||
"ARCHIVE": "归档此技能?它将停止用于全部已绑定助手,但保留绑定关系。",
|
||||
"REACTIVATE": "重新启用此技能并恢复全部现有绑定?",
|
||||
"DELETE": "永久删除此草稿技能?"
|
||||
},
|
||||
"ERRORS": {
|
||||
"FORBIDDEN": "你没有管理技能的权限。",
|
||||
"NOT_FOUND": "技能或助手不存在,或你无权访问。",
|
||||
"CONFLICT": "请刷新列表后重试。",
|
||||
"DEFAULT": "技能操作失败,请重试。"
|
||||
},
|
||||
"SUCCESS": {
|
||||
"SAVED": "技能已保存。",
|
||||
"BOUND": "技能已添加到助手。",
|
||||
"UNBOUND": "技能已从助手移除。",
|
||||
"ARCHIVED": "技能已归档。",
|
||||
"REACTIVATED": "技能已重新启用。",
|
||||
"DELETED": "技能已删除。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"DOCUMENTS": {
|
||||
|
||||
@@ -318,6 +318,7 @@
|
||||
"CAPTAIN_RESPONSES": "常见问题",
|
||||
"CAPTAIN_TOOLS": "工具",
|
||||
"CAPTAIN_SCENARIOS": "场景",
|
||||
"CAPTAIN_SKILLS": "技能",
|
||||
"CAPTAIN_PLAYGROUND": "试验场",
|
||||
"CAPTAIN_INBOXES": "收件箱",
|
||||
"CAPTAIN_SETTINGS": "设置",
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import Index from './Index.vue';
|
||||
|
||||
const mocked = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
records: [],
|
||||
flags: { fetchingList: false, updatingItem: false },
|
||||
assistantId: 9,
|
||||
route: null,
|
||||
updateGuard: null,
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store', async () => {
|
||||
const { computed } = await vi.importActual('vue');
|
||||
return {
|
||||
useStore: () => ({ dispatch: (...args) => mocked.dispatch(...args) }),
|
||||
useMapGetter: key =>
|
||||
computed(() =>
|
||||
key === 'captainSkills/getRecords'
|
||||
? mocked.records
|
||||
: mocked.flags
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||
vi.mock('vue-router', async () => {
|
||||
const actual = await vi.importActual('vue-router');
|
||||
const { reactive } = await vi.importActual('vue');
|
||||
mocked.route = reactive({ params: { assistantId: mocked.assistantId } });
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => mocked.route,
|
||||
onBeforeRouteLeave: vi.fn(),
|
||||
onBeforeRouteUpdate: guard => {
|
||||
mocked.updateGuard = guard;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const PageLayout = {
|
||||
name: 'PageLayout',
|
||||
template:
|
||||
'<div><slot name="search"/><slot name="body"/><slot name="emptyState"/></div>',
|
||||
};
|
||||
|
||||
const mountPage = () =>
|
||||
shallowMount(Index, {
|
||||
global: {
|
||||
directives: { 'on-clickaway': () => {} },
|
||||
stubs: {
|
||||
PageLayout,
|
||||
CardLayout: { template: '<div><slot/></div>' },
|
||||
Button: true,
|
||||
Input: true,
|
||||
DropdownMenu: true,
|
||||
EmptyStateLayout: true,
|
||||
SkillDialog: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const activeSkill = {
|
||||
id: 3,
|
||||
name: 'Refunds',
|
||||
description: 'Handle refunds',
|
||||
status: 'active',
|
||||
version: 2,
|
||||
reference_count: 1,
|
||||
bound_assistant_count: 2,
|
||||
updated_at: 1700000000,
|
||||
};
|
||||
|
||||
describe('Assistant skills page', () => {
|
||||
beforeEach(() => {
|
||||
mocked.records = [];
|
||||
mocked.dispatch = vi.fn().mockResolvedValue([]);
|
||||
mocked.route.params.assistantId = 9;
|
||||
});
|
||||
|
||||
it('loads the library for the route assistant', async () => {
|
||||
mountPage();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mocked.dispatch).toHaveBeenCalledWith('captainSkills/get', {
|
||||
assistantId: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('groups bound and available skills', () => {
|
||||
mocked.records = [
|
||||
{ ...activeSkill, bound: true },
|
||||
{ ...activeSkill, id: 4, name: 'Shipping', bound: false },
|
||||
];
|
||||
const wrapper = mountPage();
|
||||
|
||||
expect(wrapper.text()).toContain('Added to this assistant');
|
||||
expect(wrapper.text()).toContain('Available to add');
|
||||
expect(wrapper.text()).toContain('Refunds');
|
||||
expect(wrapper.text()).toContain('Shipping');
|
||||
});
|
||||
|
||||
it('binds an active skill and reloads server state', async () => {
|
||||
mocked.records = [{ ...activeSkill, bound: false }];
|
||||
const wrapper = mountPage();
|
||||
mocked.dispatch.mockClear();
|
||||
|
||||
await wrapper.vm.handleAction('bind', mocked.records[0]);
|
||||
|
||||
expect(mocked.dispatch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'captainSkills/bind',
|
||||
{
|
||||
assistantId: 9,
|
||||
skillId: 3,
|
||||
}
|
||||
);
|
||||
expect(mocked.dispatch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'captainSkills/get',
|
||||
{
|
||||
assistantId: 9,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('closes and clears the old dialog after route update', async () => {
|
||||
const wrapper = mountPage();
|
||||
wrapper.findComponent(PageLayout).vm.$emit('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.findComponent({ name: 'SkillDialog' }).exists()).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
expect(mocked.updateGuard()).toBe(true);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.findComponent({ name: 'SkillDialog' }).exists()).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a pending detail after route update', async () => {
|
||||
let resolveDetail;
|
||||
mocked.dispatch = vi.fn().mockImplementation(action => {
|
||||
if (action === 'captainSkills/detail') {
|
||||
return new Promise(resolve => {
|
||||
resolveDetail = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
const wrapper = mountPage();
|
||||
const edit = wrapper.vm.handleAction('edit', activeSkill);
|
||||
|
||||
expect(mocked.updateGuard()).toBe(true);
|
||||
mocked.route.params.assistantId = 10;
|
||||
resolveDetail(activeSkill);
|
||||
await edit;
|
||||
|
||||
expect(wrapper.findComponent({ name: 'SkillDialog' }).exists()).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('reactivates and binds to the assistant captured before route update', async () => {
|
||||
let resolveUpdate;
|
||||
mocked.dispatch = vi.fn().mockImplementation(action => {
|
||||
if (action === 'captainSkills/detail') {
|
||||
return Promise.resolve({
|
||||
...activeSkill,
|
||||
status: 'archived',
|
||||
references: [],
|
||||
});
|
||||
}
|
||||
if (action === 'captainSkills/update') {
|
||||
return new Promise(resolve => {
|
||||
resolveUpdate = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
const wrapper = mountPage();
|
||||
const reactivate = wrapper.vm.handleAction('reactivate', {
|
||||
...activeSkill,
|
||||
status: 'archived',
|
||||
bound: false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
mocked.updateGuard();
|
||||
mocked.route.params.assistantId = 10;
|
||||
resolveUpdate(activeSkill);
|
||||
await reactivate;
|
||||
|
||||
expect(mocked.dispatch).toHaveBeenCalledWith('captainSkills/bind', {
|
||||
assistantId: 9,
|
||||
skillId: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import SkillDialog from 'dashboard/components-next/captain/pageComponents/skill/SkillDialog.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const assistantId = computed(() => Number(route.params.assistantId));
|
||||
const skills = useMapGetter('captainSkills/getRecords');
|
||||
const uiFlags = useMapGetter('captainSkills/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const query = ref('');
|
||||
const loadError = ref('');
|
||||
const actionId = ref(null);
|
||||
const openMenuId = ref(null);
|
||||
const selectedSkill = ref(null);
|
||||
const showDialog = ref(false);
|
||||
const dialogRef = ref(null);
|
||||
let latestFetchRequest = 0;
|
||||
let routeGeneration = 0;
|
||||
|
||||
const filteredSkills = computed(() => {
|
||||
const search = query.value.trim().toLowerCase();
|
||||
if (!search) return skills.value;
|
||||
return skills.value.filter(skill =>
|
||||
`${skill.name} ${skill.description}`.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
const groups = computed(() =>
|
||||
[
|
||||
{
|
||||
key: 'bound',
|
||||
title: t('CAPTAIN.ASSISTANTS.SKILLS.GROUPS.ADDED'),
|
||||
items: filteredSkills.value.filter(skill => skill.bound),
|
||||
},
|
||||
{
|
||||
key: 'available',
|
||||
title: t('CAPTAIN.ASSISTANTS.SKILLS.GROUPS.AVAILABLE'),
|
||||
items: filteredSkills.value.filter(skill => !skill.bound),
|
||||
},
|
||||
].filter(group => group.items.length)
|
||||
);
|
||||
|
||||
const errorText = error => {
|
||||
if (error?.response?.status === 403) {
|
||||
return t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.FORBIDDEN');
|
||||
}
|
||||
if (error?.response?.status === 404) {
|
||||
return t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.NOT_FOUND');
|
||||
}
|
||||
const parsed = parseAPIErrorResponse(error);
|
||||
return error?.response?.status === 409
|
||||
? `${parsed} ${t('CAPTAIN.ASSISTANTS.SKILLS.ERRORS.CONFLICT')}`
|
||||
: parsed;
|
||||
};
|
||||
|
||||
const fetchSkills = async () => {
|
||||
const requestId = ++latestFetchRequest;
|
||||
loadError.value = '';
|
||||
try {
|
||||
await store.dispatch('captainSkills/get', {
|
||||
assistantId: assistantId.value,
|
||||
});
|
||||
} catch (error) {
|
||||
if (requestId === latestFetchRequest) {
|
||||
loadError.value = errorText(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
showDialog.value = false;
|
||||
selectedSkill.value = null;
|
||||
};
|
||||
|
||||
const openCreateDialog = () => {
|
||||
selectedSkill.value = null;
|
||||
showDialog.value = true;
|
||||
nextTick(() => dialogRef.value?.dialogRef?.open());
|
||||
};
|
||||
|
||||
const openEditDialog = async skill => {
|
||||
const requestedAssistantId = assistantId.value;
|
||||
const requestedRouteGeneration = routeGeneration;
|
||||
actionId.value = skill.id;
|
||||
try {
|
||||
const detail = await store.dispatch('captainSkills/detail', skill.id);
|
||||
if (
|
||||
requestedAssistantId !== assistantId.value ||
|
||||
requestedRouteGeneration !== routeGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
selectedSkill.value = {
|
||||
...detail,
|
||||
bound: skill.bound,
|
||||
bound_assistant_count: skill.bound_assistant_count,
|
||||
};
|
||||
showDialog.value = true;
|
||||
nextTick(() => dialogRef.value?.dialogRef?.open());
|
||||
} catch (error) {
|
||||
if (
|
||||
requestedAssistantId === assistantId.value &&
|
||||
requestedRouteGeneration === routeGeneration
|
||||
) {
|
||||
useAlert(errorText(error));
|
||||
}
|
||||
} finally {
|
||||
if (requestedRouteGeneration === routeGeneration) {
|
||||
actionId.value = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (skill, status, requestedAssistantId) => {
|
||||
const detail = await store.dispatch('captainSkills/detail', skill.id);
|
||||
const saved = await store.dispatch('captainSkills/update', {
|
||||
id: detail.id,
|
||||
name: detail.name,
|
||||
description: detail.description,
|
||||
instructions_md: detail.instructions_md,
|
||||
status,
|
||||
references: detail.references.map(({ reference_key, content_md }) => ({
|
||||
reference_key,
|
||||
content_md,
|
||||
})),
|
||||
expected_version: detail.version,
|
||||
});
|
||||
if (status === 'active' && !skill.bound) {
|
||||
await store.dispatch('captainSkills/bind', {
|
||||
assistantId: requestedAssistantId,
|
||||
skillId: saved.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (action, skill) => {
|
||||
openMenuId.value = null;
|
||||
if (action === 'edit') {
|
||||
await openEditDialog(skill);
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmations = {
|
||||
unbind: 'CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.UNBIND',
|
||||
archive: 'CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.ARCHIVE',
|
||||
reactivate: 'CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.REACTIVATE',
|
||||
delete: 'CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.DELETE',
|
||||
};
|
||||
if (confirmations[action] && !window.confirm(t(confirmations[action])))
|
||||
return;
|
||||
|
||||
const requestedAssistantId = assistantId.value;
|
||||
actionId.value = skill.id;
|
||||
try {
|
||||
if (action === 'bind' || action === 'unbind') {
|
||||
await store.dispatch(`captainSkills/${action}`, {
|
||||
assistantId: requestedAssistantId,
|
||||
skillId: skill.id,
|
||||
});
|
||||
} else if (action === 'archive') {
|
||||
await updateStatus(skill, 'archived', requestedAssistantId);
|
||||
} else if (action === 'reactivate') {
|
||||
await updateStatus(skill, 'active', requestedAssistantId);
|
||||
} else if (action === 'delete') {
|
||||
await store.dispatch('captainSkills/delete', skill.id);
|
||||
}
|
||||
const successKeys = {
|
||||
bind: 'BOUND',
|
||||
unbind: 'UNBOUND',
|
||||
archive: 'ARCHIVED',
|
||||
reactivate: 'REACTIVATED',
|
||||
delete: 'DELETED',
|
||||
};
|
||||
useAlert(t(`CAPTAIN.ASSISTANTS.SKILLS.SUCCESS.${successKeys[action]}`));
|
||||
await fetchSkills();
|
||||
} catch (error) {
|
||||
useAlert(errorText(error));
|
||||
} finally {
|
||||
actionId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = skill => {
|
||||
const items = [
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.EDIT'),
|
||||
action: 'edit',
|
||||
value: 'edit',
|
||||
icon: 'i-lucide-pencil-line',
|
||||
},
|
||||
];
|
||||
if (skill.bound) {
|
||||
items.push({
|
||||
label: t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.UNBIND'),
|
||||
action: 'unbind',
|
||||
value: 'unbind',
|
||||
icon: 'i-lucide-unlink',
|
||||
});
|
||||
} else if (skill.status === 'active') {
|
||||
items.push({
|
||||
label: t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.BIND'),
|
||||
action: 'bind',
|
||||
value: 'bind',
|
||||
icon: 'i-lucide-link',
|
||||
});
|
||||
}
|
||||
if (skill.status === 'active') {
|
||||
items.push({
|
||||
label: t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.ARCHIVE'),
|
||||
action: 'archive',
|
||||
value: 'archive',
|
||||
icon: 'i-lucide-archive',
|
||||
});
|
||||
} else if (skill.status === 'archived') {
|
||||
items.push({
|
||||
label: t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.REACTIVATE'),
|
||||
action: 'reactivate',
|
||||
value: 'reactivate',
|
||||
icon: 'i-lucide-archive-restore',
|
||||
});
|
||||
}
|
||||
|
||||
const deleteDisabled =
|
||||
skill.status !== 'draft' || skill.bound_assistant_count > 0;
|
||||
const deleteLabel =
|
||||
skill.bound_assistant_count > 0
|
||||
? t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.DELETE_BOUND')
|
||||
: skill.status !== 'draft'
|
||||
? t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.DELETE_STATUS')
|
||||
: t('CAPTAIN.ASSISTANTS.SKILLS.ACTIONS.DELETE');
|
||||
items.push({
|
||||
label: deleteLabel,
|
||||
action: 'delete',
|
||||
value: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
disabled: deleteDisabled,
|
||||
});
|
||||
return items;
|
||||
};
|
||||
|
||||
const statusClass = status =>
|
||||
({
|
||||
draft: 'bg-n-amber-3 text-n-amber-11',
|
||||
active: 'bg-n-teal-3 text-n-teal-11',
|
||||
archived: 'bg-n-slate-3 text-n-slate-11',
|
||||
})[status];
|
||||
|
||||
const confirmDiscard = () =>
|
||||
!dialogRef.value?.isDirty ||
|
||||
window.confirm(t('CAPTAIN.ASSISTANTS.SKILLS.CONFIRM.DISCARD'));
|
||||
|
||||
const handleRouteUpdate = () => {
|
||||
if (!confirmDiscard()) return false;
|
||||
routeGeneration += 1;
|
||||
closeDialog();
|
||||
openMenuId.value = null;
|
||||
actionId.value = null;
|
||||
return true;
|
||||
};
|
||||
|
||||
onBeforeRouteLeave(confirmDiscard);
|
||||
onBeforeRouteUpdate(handleRouteUpdate);
|
||||
onMounted(fetchSkills);
|
||||
watch(assistantId, fetchSkills);
|
||||
|
||||
defineExpose({ handleAction, fetchSkills });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:header-title="t('CAPTAIN.ASSISTANTS.SKILLS.TITLE')"
|
||||
:button-label="t('CAPTAIN.ASSISTANTS.SKILLS.ADD_NEW')"
|
||||
:button-policy="['administrator']"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!loadError && !skills.length"
|
||||
:show-know-more="false"
|
||||
:show-pagination-footer="false"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
<template #search>
|
||||
<Input
|
||||
v-if="skills.length || query"
|
||||
v-model="query"
|
||||
type="search"
|
||||
size="sm"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.SKILLS.SEARCH')"
|
||||
:aria-label="t('CAPTAIN.ASSISTANTS.SKILLS.SEARCH')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<div
|
||||
class="flex min-h-80 flex-col items-center justify-center gap-3 text-center"
|
||||
>
|
||||
<span class="i-lucide-library-big size-8 text-n-slate-10" />
|
||||
<div>
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SKILLS.EMPTY.TITLE') }}
|
||||
</h3>
|
||||
<p class="mb-0 mt-1 text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SKILLS.EMPTY.SUBTITLE') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.SKILLS.ADD_NEW')"
|
||||
icon="i-lucide-plus"
|
||||
@click="openCreateDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div
|
||||
v-if="loadError"
|
||||
class="flex min-h-64 flex-col items-center justify-center gap-3 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p class="mb-0 text-sm text-n-ruby-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SKILLS.LOAD_ERROR') }}
|
||||
{{ loadError }}
|
||||
</p>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.SKILLS.RETRY')"
|
||||
color="slate"
|
||||
@click="fetchSkills"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="query && !filteredSkills.length"
|
||||
class="flex min-h-64 flex-col items-center justify-center gap-3 text-center"
|
||||
>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SKILLS.EMPTY.SEARCH') }}
|
||||
</p>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.SKILLS.CLEAR_SEARCH')"
|
||||
color="slate"
|
||||
@click="query = ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-8">
|
||||
<section
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<h2 class="text-sm font-medium text-n-slate-11">
|
||||
{{ group.title }}
|
||||
</h2>
|
||||
<CardLayout v-for="skill in group.items" :key="skill.id">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3
|
||||
class="truncate text-base font-medium text-n-slate-12"
|
||||
>
|
||||
{{ skill.name }}
|
||||
</h3>
|
||||
<span
|
||||
class="rounded-md px-2 py-0.5 text-xs font-medium"
|
||||
:class="statusClass(skill.status)"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
`CAPTAIN.ASSISTANTS.SKILLS.STATUS.${skill.status.toUpperCase()}`
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mb-0 mt-1 text-sm text-n-slate-11">
|
||||
{{ skill.description }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-n-slate-10"
|
||||
>
|
||||
<span>{{
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.META.VERSION',
|
||||
{ count: skill.version }
|
||||
)
|
||||
}}</span>
|
||||
<span>{{
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.META.REFERENCES',
|
||||
{ count: skill.reference_count }
|
||||
)
|
||||
}}</span>
|
||||
<span>{{
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.META.ASSISTANTS',
|
||||
{
|
||||
count: skill.bound_assistant_count,
|
||||
}
|
||||
)
|
||||
}}</span>
|
||||
<span>{{
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.SKILLS.META.UPDATED',
|
||||
{
|
||||
time: dynamicTime(
|
||||
skill.updated_at
|
||||
),
|
||||
}
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Policy
|
||||
v-on-clickaway="() => (openMenuId = null)"
|
||||
:permissions="['administrator']"
|
||||
class="relative self-end sm:self-start"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:is-loading="actionId === skill.id"
|
||||
:disabled="actionId !== null"
|
||||
:aria-label="`${skill.name} actions`"
|
||||
@click="
|
||||
openMenuId =
|
||||
openMenuId === skill.id
|
||||
? null
|
||||
: skill.id
|
||||
"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="openMenuId === skill.id"
|
||||
:menu-items="menuItems(skill)"
|
||||
class="top-full mt-1 ltr:right-0 rtl:left-0"
|
||||
@action="
|
||||
({ action }) =>
|
||||
handleAction(action, skill)
|
||||
"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
|
||||
<SkillDialog
|
||||
v-if="showDialog"
|
||||
ref="dialogRef"
|
||||
:skill="selectedSkill"
|
||||
:assistant-id="assistantId"
|
||||
@close="closeDialog"
|
||||
@saved="fetchSkills"
|
||||
/>
|
||||
</template>
|
||||
@@ -12,6 +12,7 @@ import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
|
||||
import AssistantGuardrailsIndex from './assistants/guardrails/Index.vue';
|
||||
import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
|
||||
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
|
||||
import AssistantSkillsIndex from './assistants/skills/Index.vue';
|
||||
import DocumentsIndex from './documents/Index.vue';
|
||||
import ResponsesIndex from './responses/Index.vue';
|
||||
import ResponsesPendingIndex from './responses/Pending.vue';
|
||||
@@ -60,6 +61,12 @@ const assistantRoutes = [
|
||||
name: 'captain_assistants_scenarios_index',
|
||||
meta: metaV2,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/skills'),
|
||||
component: AssistantSkillsIndex,
|
||||
name: 'captain_assistants_skills_index',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/playground'),
|
||||
component: AssistantPlaygroundIndex,
|
||||
|
||||
@@ -10,4 +10,9 @@ describe('Captain routes', () => {
|
||||
expect(source).not.toContain("permissions: ['administrator', 'agent']");
|
||||
expect(source.match(/permissions: \['administrator'\]/g)?.length).toBe(4);
|
||||
});
|
||||
|
||||
it('registers the assistant skill management route', () => {
|
||||
expect(source).toContain("name: 'captain_assistants_skills_index'");
|
||||
expect(source).toContain("'accounts/:accountId/captain/:assistantId/skills'");
|
||||
});
|
||||
});
|
||||
|
||||
+1
@@ -56,6 +56,7 @@ const routeToLastActiveAssistant = () => {
|
||||
'captain_assistants_responses_index', // Faq page
|
||||
'captain_assistants_documents_index', // Document page
|
||||
'captain_assistants_scenarios_index', // Scenario page
|
||||
'captain_assistants_skills_index', // Skills page
|
||||
'captain_assistants_playground_index', // Playground page
|
||||
'captain_assistants_inboxes_index', // Inboxes page
|
||||
'captain_tools_index', // Tools page
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import CaptainSkills from 'dashboard/api/captain/skills';
|
||||
import { createStore } from '../storeFactory';
|
||||
|
||||
const withFlag = (mutations, flag, request) => async (context, payload) => {
|
||||
context.commit(mutations.SET_UI_FLAG, { [flag]: true });
|
||||
try {
|
||||
return await request(context, payload);
|
||||
} finally {
|
||||
context.commit(mutations.SET_UI_FLAG, { [flag]: false });
|
||||
}
|
||||
};
|
||||
|
||||
let latestListRequest = 0;
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainSkill',
|
||||
API: CaptainSkills,
|
||||
actions: mutations => ({
|
||||
get: async ({ commit }, params) => {
|
||||
const requestId = ++latestListRequest;
|
||||
commit(mutations.SET_UI_FLAG, { fetchingList: true });
|
||||
try {
|
||||
const { data } = await CaptainSkills.get(params);
|
||||
if (requestId === latestListRequest) {
|
||||
commit(mutations.SET, data.payload);
|
||||
commit(mutations.SET_META, data.meta);
|
||||
}
|
||||
return data.payload;
|
||||
} finally {
|
||||
if (requestId === latestListRequest) {
|
||||
commit(mutations.SET_UI_FLAG, { fetchingList: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
detail: withFlag(mutations, 'fetchingItem', async (_, id) => {
|
||||
return (await CaptainSkills.show(id)).data;
|
||||
}),
|
||||
create: withFlag(
|
||||
mutations,
|
||||
'creatingItem',
|
||||
async ({ commit }, payload) => {
|
||||
const { data } = await CaptainSkills.create(payload);
|
||||
commit(mutations.UPSERT, data);
|
||||
return data;
|
||||
}
|
||||
),
|
||||
update: withFlag(
|
||||
mutations,
|
||||
'updatingItem',
|
||||
async ({ commit }, { id, ...payload }) => {
|
||||
const { data } = await CaptainSkills.update(id, payload);
|
||||
commit(mutations.UPSERT, data);
|
||||
return data;
|
||||
}
|
||||
),
|
||||
delete: withFlag(mutations, 'deletingItem', async ({ commit }, id) => {
|
||||
await CaptainSkills.delete(id);
|
||||
commit(mutations.DELETE, id);
|
||||
return id;
|
||||
}),
|
||||
bind: withFlag(mutations, 'updatingItem', (_, payload) =>
|
||||
CaptainSkills.bind(payload)
|
||||
),
|
||||
unbind: withFlag(mutations, 'updatingItem', (_, payload) =>
|
||||
CaptainSkills.unbind(payload)
|
||||
),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import CaptainSkillsAPI from 'dashboard/api/captain/skills';
|
||||
import store from './skills';
|
||||
|
||||
vi.mock('dashboard/api/captain/skills', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
bind: vi.fn(),
|
||||
unbind: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('captainSkills store', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('keeps only the latest assistant list response and loading state', async () => {
|
||||
let resolveA;
|
||||
let resolveB;
|
||||
CaptainSkillsAPI.get
|
||||
.mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveA = resolve;
|
||||
})
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveB = resolve;
|
||||
})
|
||||
);
|
||||
const commit = vi.fn();
|
||||
const requestA = store.actions.get({ commit }, { assistantId: 1 });
|
||||
const requestB = store.actions.get({ commit }, { assistantId: 2 });
|
||||
|
||||
resolveB({ data: { payload: ['B'], meta: { page: 2 } } });
|
||||
await requestB;
|
||||
resolveA({ data: { payload: ['A'], meta: { page: 1 } } });
|
||||
await requestA;
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('SET_CAPTAINSKILL', ['B']);
|
||||
expect(commit).toHaveBeenCalledWith('SET_CAPTAINSKILL_META', {
|
||||
page: 2,
|
||||
});
|
||||
expect(commit).not.toHaveBeenCalledWith('SET_CAPTAINSKILL', ['A']);
|
||||
expect(commit).not.toHaveBeenCalledWith('SET_CAPTAINSKILL_META', {
|
||||
page: 1,
|
||||
});
|
||||
expect(
|
||||
commit.mock.calls.filter(
|
||||
([type, value]) =>
|
||||
type === 'SET_CAPTAINSKILL_UI_FLAG' &&
|
||||
value.fetchingList === false
|
||||
)
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(['bind', 'unbind'])(
|
||||
'%s toggles the row loading state',
|
||||
async action => {
|
||||
CaptainSkillsAPI[action].mockResolvedValue({});
|
||||
const commit = vi.fn();
|
||||
const payload = { assistantId: 2, skillId: 3 };
|
||||
|
||||
await store.actions[action]({ commit }, payload);
|
||||
|
||||
expect(CaptainSkillsAPI[action]).toHaveBeenCalledWith(payload);
|
||||
expect(commit).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'SET_CAPTAINSKILL_UI_FLAG',
|
||||
{
|
||||
updatingItem: true,
|
||||
}
|
||||
);
|
||||
expect(commit).toHaveBeenLastCalledWith(
|
||||
'SET_CAPTAINSKILL_UI_FLAG',
|
||||
{
|
||||
updatingItem: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('restores loading state when binding fails', async () => {
|
||||
const error = new Error('conflict');
|
||||
CaptainSkillsAPI.bind.mockRejectedValue(error);
|
||||
const commit = vi.fn();
|
||||
|
||||
await expect(
|
||||
store.actions.bind({ commit }, { assistantId: 2, skillId: 3 })
|
||||
).rejects.toBe(error);
|
||||
expect(commit).toHaveBeenLastCalledWith('SET_CAPTAINSKILL_UI_FLAG', {
|
||||
updatingItem: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,7 @@ import copilotMessages from './captain/copilotMessages';
|
||||
import captainScenarios from './captain/scenarios';
|
||||
import captainTools from './captain/tools';
|
||||
import captainCustomTools from './captain/customTools';
|
||||
import captainSkills from './captain/skills';
|
||||
import platform from './modules/platform';
|
||||
|
||||
const plugins = [];
|
||||
@@ -124,6 +125,7 @@ export default createStore({
|
||||
captainScenarios,
|
||||
captainTools,
|
||||
captainCustomTools,
|
||||
captainSkills,
|
||||
platform,
|
||||
},
|
||||
plugins,
|
||||
|
||||
Reference in New Issue
Block a user