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