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,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