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:
Rogee
2026-08-18 15:34:26 +08:00
committed by GitHub
co-authored by rogee
parent 0f2b8d7857
commit f50eacdc92
17 changed files with 1725 additions and 0 deletions
@@ -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);
});
});
@@ -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();
});
});
@@ -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": "设置",
@@ -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,
});
});
});
@@ -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'");
});
});
@@ -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,