diff --git a/frontend/app/javascript/dashboard/api/captain/skills.js b/frontend/app/javascript/dashboard/api/captain/skills.js
new file mode 100644
index 00000000..386fc5bf
--- /dev/null
+++ b/frontend/app/javascript/dashboard/api/captain/skills.js
@@ -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();
diff --git a/frontend/app/javascript/dashboard/api/captain/specs/skills.spec.js b/frontend/app/javascript/dashboard/api/captain/specs/skills.spec.js
new file mode 100644
index 00000000..fbda4cd4
--- /dev/null
+++ b/frontend/app/javascript/dashboard/api/captain/specs/skills.spec.js
@@ -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);
+ });
+});
diff --git a/frontend/app/javascript/dashboard/components-next/captain/pageComponents/skill/SkillDialog.spec.js b/frontend/app/javascript/dashboard/components-next/captain/pageComponents/skill/SkillDialog.spec.js
new file mode 100644
index 00000000..9f94d5d7
--- /dev/null
+++ b/frontend/app/javascript/dashboard/components-next/captain/pageComponents/skill/SkillDialog.spec.js
@@ -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: '
',
+};
+
+const Button = {
+ name: 'Button',
+ props: ['label'],
+ emits: ['click'],
+ template:
+ '',
+};
+
+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();
+ });
+});
diff --git a/frontend/app/javascript/dashboard/components-next/captain/pageComponents/skill/SkillDialog.vue b/frontend/app/javascript/dashboard/components-next/captain/pageComponents/skill/SkillDialog.vue
new file mode 100644
index 00000000..33c8ac5c
--- /dev/null
+++ b/frontend/app/javascript/dashboard/components-next/captain/pageComponents/skill/SkillDialog.vue
@@ -0,0 +1,435 @@
+
+
+
+
+
diff --git a/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
index 746b1d51..32aaf968 100644
--- a/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
+++ b/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
@@ -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'),
diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/integrations.json b/frontend/app/javascript/dashboard/i18n/locale/en/integrations.json
index 4f50a198..2b79dade 100644
--- a/frontend/app/javascript/dashboard/i18n/locale/en/integrations.json
+++ b/frontend/app/javascript/dashboard/i18n/locale/en/integrations.json
@@ -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": {
diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/settings.json b/frontend/app/javascript/dashboard/i18n/locale/en/settings.json
index ac871ef0..b9515763 100644
--- a/frontend/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/frontend/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -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",
diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/integrations.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/integrations.json
index 7267873f..872a6b6b 100644
--- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/integrations.json
+++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/integrations.json
@@ -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": {
diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/settings.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/settings.json
index 1a94f8bb..a852eb74 100644
--- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/settings.json
+++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/settings.json
@@ -318,6 +318,7 @@
"CAPTAIN_RESPONSES": "常见问题",
"CAPTAIN_TOOLS": "工具",
"CAPTAIN_SCENARIOS": "场景",
+ "CAPTAIN_SKILLS": "技能",
"CAPTAIN_PLAYGROUND": "试验场",
"CAPTAIN_INBOXES": "收件箱",
"CAPTAIN_SETTINGS": "设置",
diff --git a/frontend/app/javascript/dashboard/routes/dashboard/captain/assistants/skills/Index.spec.js b/frontend/app/javascript/dashboard/routes/dashboard/captain/assistants/skills/Index.spec.js
new file mode 100644
index 00000000..6d3dfef3
--- /dev/null
+++ b/frontend/app/javascript/dashboard/routes/dashboard/captain/assistants/skills/Index.spec.js
@@ -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:
+ '
',
+};
+
+const mountPage = () =>
+ shallowMount(Index, {
+ global: {
+ directives: { 'on-clickaway': () => {} },
+ stubs: {
+ PageLayout,
+ CardLayout: { template: '
' },
+ 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,
+ });
+ });
+});
diff --git a/frontend/app/javascript/dashboard/routes/dashboard/captain/assistants/skills/Index.vue b/frontend/app/javascript/dashboard/routes/dashboard/captain/assistants/skills/Index.vue
new file mode 100644
index 00000000..b3382c6f
--- /dev/null
+++ b/frontend/app/javascript/dashboard/routes/dashboard/captain/assistants/skills/Index.vue
@@ -0,0 +1,472 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN.ASSISTANTS.SKILLS.EMPTY.TITLE') }}
+
+
+ {{ t('CAPTAIN.ASSISTANTS.SKILLS.EMPTY.SUBTITLE') }}
+
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN.ASSISTANTS.SKILLS.LOAD_ERROR') }}
+ {{ loadError }}
+
+
+
+
+
+
+ {{ t('CAPTAIN.ASSISTANTS.SKILLS.EMPTY.SEARCH') }}
+
+
+
+
+
+
+
+ {{ group.title }}
+
+
+
+
+
+
+ {{ skill.name }}
+
+
+ {{
+ t(
+ `CAPTAIN.ASSISTANTS.SKILLS.STATUS.${skill.status.toUpperCase()}`
+ )
+ }}
+
+
+
+ {{ skill.description }}
+
+
+ {{
+ t(
+ 'CAPTAIN.ASSISTANTS.SKILLS.META.VERSION',
+ { count: skill.version }
+ )
+ }}
+ {{
+ t(
+ 'CAPTAIN.ASSISTANTS.SKILLS.META.REFERENCES',
+ { count: skill.reference_count }
+ )
+ }}
+ {{
+ t(
+ 'CAPTAIN.ASSISTANTS.SKILLS.META.ASSISTANTS',
+ {
+ count: skill.bound_assistant_count,
+ }
+ )
+ }}
+ {{
+ t(
+ 'CAPTAIN.ASSISTANTS.SKILLS.META.UPDATED',
+ {
+ time: dynamicTime(
+ skill.updated_at
+ ),
+ }
+ )
+ }}
+
+
+
+
+
+
+ handleAction(action, skill)
+ "
+ />
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js b/frontend/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
index 764bb776..2dc89486 100644
--- a/frontend/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
+++ b/frontend/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
@@ -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,
diff --git a/frontend/app/javascript/dashboard/routes/dashboard/captain/captain.routes.spec.js b/frontend/app/javascript/dashboard/routes/dashboard/captain/captain.routes.spec.js
index 1843f9f0..df37cccd 100644
--- a/frontend/app/javascript/dashboard/routes/dashboard/captain/captain.routes.spec.js
+++ b/frontend/app/javascript/dashboard/routes/dashboard/captain/captain.routes.spec.js
@@ -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'");
+ });
});
diff --git a/frontend/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue b/frontend/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue
index 01ec6461..09d679bd 100644
--- a/frontend/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue
+++ b/frontend/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue
@@ -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
diff --git a/frontend/app/javascript/dashboard/store/captain/skills.js b/frontend/app/javascript/dashboard/store/captain/skills.js
new file mode 100644
index 00000000..ca2c7a6d
--- /dev/null
+++ b/frontend/app/javascript/dashboard/store/captain/skills.js
@@ -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)
+ ),
+ }),
+});
diff --git a/frontend/app/javascript/dashboard/store/captain/skills.spec.js b/frontend/app/javascript/dashboard/store/captain/skills.spec.js
new file mode 100644
index 00000000..1342dbd2
--- /dev/null
+++ b/frontend/app/javascript/dashboard/store/captain/skills.spec.js
@@ -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,
+ });
+ });
+});
diff --git a/frontend/app/javascript/dashboard/store/index.js b/frontend/app/javascript/dashboard/store/index.js
index f25f2b7a..4a1f50c4 100755
--- a/frontend/app/javascript/dashboard/store/index.js
+++ b/frontend/app/javascript/dashboard/store/index.js
@@ -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,